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

@@ -16,6 +16,9 @@
<li class="menu-title">{% trans "People" %}</li>
<li><a class="{% if nav == 'member_list' %}menu-active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=16 %} {% trans "Members" %}</a></li>
{% if is_club_admin %}
<li><a class="{% if nav == 'parent_claim_list' %}menu-active{% endif %}" href="{% url 'management:parent_claim_list' %}">{% lucide "inbox" size=16 %} {% trans "Parent claims" %}</a></li>
{% endif %}
{% if is_club_admin %}
<li><a class="{% if nav == 'membership_list' %}menu-active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=16 %} {% trans "Memberships" %}</a></li>
<li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li>

View File

@@ -0,0 +1,108 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Parent claims" %}{% endblock heading %}
{% block subheading %}{% trans "Parents asking to be linked to a child the club already has on file." %}{% endblock subheading %}
{% block panel %}
<div class="card bg-base-100 shadow mb-4">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "inbox" size=18 %} {% trans "Waiting for review" %}</h2>
<p class="text-sm opacity-70">
{% blocktrans %}Check each request against your own records before approving. The shortlist only ever contains children who have nobody on file, so approving can never re-parent a child who already has one. An approved parent is added as a guardian: they get the login, but owe no fee and are not counted as a member.{% endblocktrans %}
</p>
{% for claim in pending %}
<div class="border border-base-300 rounded-box p-4 mt-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="text-xs opacity-70">{% trans "Parent says they are" %}</div>
<div class="font-semibold">{{ claim.parent_name }}</div>
<div class="text-sm opacity-70">{{ claim.parent_email }}</div>
</div>
<div>
<div class="text-xs opacity-70">{% trans "Parent of" %}</div>
<div class="font-semibold">{{ claim.claimed_child_name }}</div>
<div class="text-sm opacity-70">{{ claim.child_date_of_birth|date:"j F Y" }}</div>
</div>
</div>
<div class="flex flex-wrap items-end gap-2 mt-4">
{% if claim.has_candidates %}
<form method="post" action="{% url 'management:parent_claim_approve' claim.pk %}" class="flex flex-wrap items-end gap-2">
{% csrf_token %}
<div class="min-w-64">{% form_field claim.review_form.child %}</div>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "check" size=14 %} {% trans "Approve" %}</button>
</form>
{% else %}
<p class="text-sm opacity-70">{% trans "No child without a parent matches this. Check the spelling and the date of birth with them before rejecting." %}</p>
{% endif %}
<form method="post" action="{% url 'management:parent_claim_reject' claim.pk %}" class="flex items-end gap-2">
{% csrf_token %}
<input type="text" name="note" class="input input-bordered input-sm" placeholder="{% trans 'Reason (optional)' %}">
<button class="btn btn-outline btn-error btn-sm gap-2" type="submit">{% lucide "x" size=14 %} {% trans "Reject" %}</button>
</form>
</div>
</div>
{% empty %}
<p class="text-sm opacity-60 mt-2">{% trans "Nothing waiting." %}</p>
{% endfor %}
</div>
</div>
<div class="card bg-base-100 shadow mb-4">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-search" size=18 %} {% trans "Children with nobody on file" %}</h2>
<p class="text-sm opacity-70">{% trans "Imported without a parent. They stay here until someone claims them." %}</p>
<ul class="divide-y divide-base-200">
{% for child in awaiting_a_parent %}
<li class="py-2 flex items-center justify-between">
<a class="link link-hover" href="{% url 'management:member_detail' child.pk %}">{{ child }}</a>
<span class="text-sm opacity-70">{{ child.date_of_birth|date:"j F Y"|default:"—" }}</span>
</li>
{% empty %}
<li class="py-2 text-sm opacity-60">{% trans "Every child has a parent linked." %}</li>
{% endfor %}
</ul>
</div>
</div>
{% if reviewed %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "history" size=18 %} {% trans "Already dealt with" %}</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Parent" %}</th>
<th>{% trans "Claimed" %}</th>
<th>{% trans "Outcome" %}</th>
<th>{% trans "Reviewed by" %}</th>
</tr>
</thead>
<tbody>
{% for claim in reviewed %}
<tr>
<td>{{ claim.parent_name }}<div class="text-xs opacity-60">{{ claim.parent_email }}</div></td>
<td>{{ claim.claimed_child_name }}</td>
<td>
{% if claim.status == "approved" %}
<span class="badge badge-success badge-sm">{% trans "Approved" %}</span>
{% if claim.child %}<div class="text-xs opacity-60">{{ claim.child }}</div>{% endif %}
{% else %}
<span class="badge badge-error badge-sm">{% trans "Rejected" %}</span>
{% if claim.note %}<div class="text-xs opacity-60">{{ claim.note }}</div>{% endif %}
{% endif %}
</td>
<td>{{ claim.reviewed_by|default:"—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% endblock panel %}