Add parent claims: onboarding a roster of children with no parents on file

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>
This commit is contained in:
2026-08-11 18:18:26 +02:00
parent 744b623403
commit ca2b1a11b5
25 changed files with 1033 additions and 10 deletions

View File

@@ -12,6 +12,9 @@
You are signed in as <span class="font-semibold">{{ user.get_full_name|default:user.email }}</span>.
</p>
<p class="text-sm opacity-70">The club site lands here. For now this page exists so signing in has somewhere to go.</p>
<div class="card-actions pt-2">
<a class="btn btn-outline btn-sm gap-2" href="{% url 'members:my_family' %}">{% lucide "users" size=14 %} My family</a>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
from django.shortcuts import redirect, render
from django.views.generic import TemplateView
@@ -21,3 +21,13 @@ def root(request):
return redirect("controlpanel:dashboard")
return ClubHomeView.as_view()(request)
def signup_closed(request):
"""Self-registration is closed -- see rosterchief/urls.py.
Shadows allauth's own signup route rather than removing it, so the
`account_signup` URL name every allauth template reverses still resolves and
the login page doesn't 500 looking for it.
"""
return render(request, "account/signup_closed.html", status=403)