The migration path for a club arriving with a list of children from a federation export and no parent records. Children import without logins, each into a family of their own -- that shape *is* the "nobody is responsible for this child" state, so there's no unclaimed flag to drift out of step with reality, and a family drops off the worklist by itself the moment a parent joins it. `family_role=child` with a blank `family_group` asks for that; any other lone role is still a mistake in the file. Verification is a human decision, deliberately. A parent submits a public form with the child's name and date of birth as free text -- no search, no autocomplete, and the same response whether or not the child was found, because the page needs no login and anything that resolved the child would turn it into a way to enumerate the club's children. An admin matches it from a queue against a shortlist that only ever contains children with nobody on file, so approving can never quietly re-parent a child who already has one. The alternatives were worse. A claim code needs a delivery channel the club may not have and is a bearer token besides. Matching on name plus birthday hands out someone else's child to whoever guesses a birthday. The club is the only party that actually knows its own families. That form is also the registration: open self-registration is now closed (shadowing account_signup rather than removing the route, so the URL name allauth's templates reverse still resolves). The account is created on approval, not on submission, so a public form can't fill the user table. An approved parent lands as a guardian -- login and family link, no membership, no fee -- gets a password-reset link, and a minimal "my family" page. One bug worth recording: families_awaiting_a_parent first used annotate(Count(..., filter=...)) over a queryset already filtered on the same join, so Django reused that join for the counts and a parent with no ClubMembership of their own -- exactly what a newly linked guardian is -- went uncounted, leaving the family unclaimed forever. Exists subqueries avoid it. A test pins both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
64 lines
3.1 KiB
Python
64 lines
3.1 KiB
Python
"""URL configuration for rosterchief.
|
|
|
|
``/admin/login/`` is deliberately intercepted *before* ``admin.site.urls`` and
|
|
redirected to the allauth login, so Django staff go through the same MFA
|
|
challenge as everyone else — Django's own admin login form knows nothing about
|
|
second factors. ``RequireMFAMiddleware`` then blocks any staff user who has not
|
|
enrolled.
|
|
"""
|
|
|
|
import re
|
|
|
|
from django.conf import settings
|
|
from django.contrib import admin
|
|
from django.urls import include, path, re_path
|
|
from django.views.generic import RedirectView
|
|
from django.views.static import serve
|
|
|
|
from api.urls import api
|
|
from club.views import root, signup_closed
|
|
|
|
from .health import healthz
|
|
|
|
urlpatterns = [
|
|
# No auth and no tenant: the proxy and the load balancer must reach it on any host.
|
|
path("healthz", healthz, name="healthz"),
|
|
path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"),
|
|
path("admin/", admin.site.urls),
|
|
# Before allauth's own urls so it wins the match: self-registration is closed.
|
|
# Accounts are created by an admin, by the family-registration form, or by an
|
|
# approved parent claim (members/views.py) -- a club has no reason to let a
|
|
# stranger create one, and the claim queue would be the first thing to suffer.
|
|
path("accounts/signup/", signup_closed, name="account_signup"),
|
|
path("accounts/", include("allauth.urls")),
|
|
path("", include("members.urls")),
|
|
path("controlpanel/", include("controlpanel.urls")),
|
|
path("manage/", include("management.urls")),
|
|
path("api/v1/", api.urls),
|
|
# "/" resolves per tenant: a club subdomain lands on the club, the base domain
|
|
# hands off to the control panel. This is why LOGIN_REDIRECT_URL can stay "/".
|
|
path("", root, name="root"),
|
|
]
|
|
|
|
if settings.DEBUG:
|
|
# Only when the app is actually installed. It is a dev dependency, and the production
|
|
# image installs with --no-dev, so DEBUG=True in a container must not take the whole
|
|
# site down over a package that is only there to refresh a browser tab.
|
|
if settings.BROWSER_RELOAD_AVAILABLE:
|
|
urlpatterns += [path("__reload__/", include("django_browser_reload.urls"))]
|
|
|
|
if not settings.AWS_STORAGE_BUCKET_NAME:
|
|
# Gated on the storage backend, not on DEBUG: local disk is the default until a bucket is
|
|
# configured (see settings.STORAGES), and Caddy only reverse-proxies — it never serves
|
|
# /media/* itself — so without this route every uploaded club logo 404s in production too.
|
|
# Once AWS_STORAGE_BUCKET_NAME is set, club.logo.url points straight at the bucket and this
|
|
# route is simply never hit.
|
|
#
|
|
# django.conf.urls.static.static() looks like the right helper, but it hard-codes its own
|
|
# `if not settings.DEBUG: return []` — it is documented as dev-only and silently no-ops in
|
|
# production no matter what guards the call site. Build the pattern directly against the
|
|
# view it wraps instead, which has no such gate.
|
|
urlpatterns += [
|
|
re_path(rf"^{re.escape(settings.MEDIA_URL.lstrip('/'))}(?P<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT}),
|
|
]
|