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

@@ -0,0 +1,27 @@
{% extends "_club_base.html" %}
{% load i18n lucide %}
{% block head_title %}{% trans "My family" %}{% endblock head_title %}
{% block main %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body">
<h1 class="card-title">{% lucide "users" size=20 %} {% trans "My family" %}</h1>
<ul class="divide-y divide-base-200">
{% for child in children %}
<li class="py-2">
<span class="font-semibold">{{ child }}</span>
{% if child.date_of_birth %}<span class="text-sm opacity-70">— {{ child.date_of_birth|date:"j F Y" }}</span>{% endif %}
</li>
{% empty %}
<li class="py-2 text-sm opacity-70">
{% blocktrans %}Nobody is linked to you yet. If your child plays here, ask the club to link you.{% endblocktrans %}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endblock main %}

View File

@@ -0,0 +1,43 @@
{% extends "_club_base.html" %}
{% load i18n lucide ui %}
{% block head_title %}{% trans "Link me to my child" %}{% endblock head_title %}
{% block main %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body">
<h1 class="card-title">{% lucide "users" size=20 %} {% blocktrans with club=club.name %}Link me to my child at {{ club }}{% endblocktrans %}</h1>
<p class="text-sm opacity-70">
{% blocktrans %}If your child already plays here, fill this in and the club will check it against their records. Once they confirm, you'll get an email to set up your login.{% endblocktrans %}
</p>
<form method="post" class="mt-2">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2"><span>{{ error }}</span></div>
{% endfor %}
<h2 class="font-semibold text-sm mt-2">{% trans "About you" %}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.parent_first_name %}
{% form_field form.parent_last_name %}
</div>
{% form_field form.parent_email %}
<h2 class="font-semibold text-sm mt-4">{% trans "About your child" %}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.child_first_name %}
{% form_field form.child_last_name %}
</div>
{% form_field form.child_date_of_birth %}
<div class="card-actions justify-start pt-4">
<button class="btn btn-primary gap-2" type="submit">{% lucide "send" size=16 %} {% trans "Send to the club" %}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock main %}

View File

@@ -0,0 +1,20 @@
{% extends "_club_base.html" %}
{% load i18n lucide %}
{% block head_title %}{% trans "Request sent" %}{% endblock head_title %}
{% block main %}
{% comment %}
Says the same thing whether or not the child was found: this page is public,
so confirming a match would let anyone test which children the club has.
{% endcomment %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body">
<h1 class="card-title">{% lucide "circle-check" size=20 %} {% trans "Request sent" %}</h1>
<p>{% blocktrans with club=club.name %}Thanks — {{ club }} will check this against their records.{% endblocktrans %}</p>
<p class="text-sm opacity-70">{% blocktrans %}If it matches, you'll get an email with a link to set your password. If you don't hear anything, get in touch with the club directly.{% endblocktrans %}</p>
</div>
</div>
</div>
{% endblock main %}