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

@@ -211,8 +211,15 @@ def _parse_family_fields(raw):
family_role_raw = raw.get("family_role", "").strip()
if not family_group:
# family_role=child on its own is the migration case this exists for: a
# child the club holds with no parent on file yet. It gets a family of
# its own so there is something for a parent to join later -- see
# members.services.claims. Any other lone role is still a mistake.
if family_role_raw:
errors.append(_("family_role given without a family_group."))
family_role = _match_choice(family_role_raw, FamilyMembership.FamilyRole)
if family_role == FamilyMembership.FamilyRole.CHILD:
return "", family_role, errors
errors.append(_("family_role given without a family_group. Only 'child' is allowed on its own, for a child whose parent will register later."))
return "", None, errors
if not family_role_raw:

View File

@@ -47,6 +47,9 @@ _NAV_SECTIONS = {
"role_list": "role_list",
"role_create": "role_list",
"role_revoke": "role_list",
"parent_claim_list": "parent_claim_list",
"parent_claim_approve": "parent_claim_list",
"parent_claim_reject": "parent_claim_list",
"group_list": "group_list",
"group_create": "group_list",
"group_detail": "group_list",

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 %}

View File

@@ -25,7 +25,8 @@ from events.services.recurrence import detach_occurrence, generate_occurrences
from management.bulk_import import TEMPLATE_COLUMNS
from management.pdf import PDFExportError, _tint_with_white, referee_form_colors, render_pdf
from management.recurrence_ui import build_rrule, describe_rrule, parse_rrule
from members.models import Family, FamilyMembership, Group, GroupMembership, Member
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
from members.services.claims import children_awaiting_a_parent
from news.models import News, NewsPhoto
from shop.models import Order
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
@@ -1363,6 +1364,148 @@ class FamilyManagementTests(ManagementTestBase):
self.assertEqual(family.guardians.count(), 1)
class ParentClaimViewTests(ManagementTestBase):
"""The public claim form and the admin review queue -- see
members.services.claims; the service-level guarantees live in
members.tests.ParentClaimTests."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.child = Member.objects.create(first_name="Jamie", last_name="Doe", date_of_birth=datetime.date(2014, 3, 2))
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
def claim_payload(self, **overrides):
payload = {
"parent_first_name": "Taylor",
"parent_last_name": "Doe",
"parent_email": "taylor.doe@example.com",
"child_first_name": "Jamie",
"child_last_name": "Doe",
"child_date_of_birth": "2014-03-02",
}
payload.update(overrides)
return payload
def submit(self, **overrides):
return self.client.post(reverse("members:parent_claim"), self.claim_payload(**overrides), HTTP_HOST="ajax-united.rosterchief.app")
def test_the_claim_form_is_reachable_without_signing_in(self):
response = self.client.get(reverse("members:parent_claim"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 200)
def test_submitting_records_a_pending_claim(self):
response = self.submit()
self.assertEqual(response.status_code, 200)
self.assertEqual(ParentClaim.objects.filter(club=self.club, status=ParentClaim.Status.PENDING).count(), 1)
def test_an_unmatched_claim_looks_exactly_like_a_matched_one(self):
# The page must not tell an anonymous submitter which children exist.
matched = self.submit()
unmatched = self.submit(child_first_name="Nobody", child_last_name="Here", parent_email="other@example.com")
self.assertEqual(matched.status_code, unmatched.status_code)
self.assertEqual(matched.content, unmatched.content)
def test_submitting_creates_no_account(self):
# A public form that made a User per submission would be a spam magnet;
# the account is created on approval, once a human has vouched for it.
self.submit()
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
def test_open_signup_is_closed(self):
response = self.client.get(reverse("account_signup"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 403)
def test_the_queue_is_admin_only(self):
coach_user = User.objects.create_user(email="coach-claims@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
self.assertEqual(self.club_get("parent_claim_list").status_code, 403)
def test_the_queue_lists_a_pending_claim_and_the_unclaimed_child(self):
self.submit()
self.client.force_login(self.admin_user)
response = self.club_get("parent_claim_list")
self.assertContains(response, "taylor.doe@example.com")
self.assertContains(response, "Jamie Doe")
def test_approving_links_the_parent_as_a_guardian(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
parent = Member.objects.get(user__email="taylor.doe@example.com")
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
claim.refresh_from_db()
self.assertEqual(claim.status, ParentClaim.Status.APPROVED)
def test_approving_without_choosing_a_child_changes_nothing(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {}, claim.pk)
claim.refresh_from_db()
self.assertTrue(claim.is_pending)
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
def test_a_child_who_already_has_a_parent_cannot_be_chosen(self):
# The shortlist is only ever children with nobody on file, so approving
# can never quietly re-parent a child who already has one.
other_child = Member.objects.create(first_name="Sam", last_name="Roe", date_of_birth=datetime.date(2013, 1, 1))
ClubMembership.objects.create(club=self.club, member=other_child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=other_child, role=FamilyMembership.FamilyRole.CHILD)
existing_parent = Member.objects.create(first_name="Existing", last_name="Roe")
FamilyMembership.objects.create(family=family, member=existing_parent, role=FamilyMembership.FamilyRole.PARENT)
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(other_child.pk)}, claim.pk)
claim.refresh_from_db()
self.assertTrue(claim.is_pending)
def test_rejecting_records_the_reason_and_links_nobody(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_reject", {"note": "Not on our records."}, claim.pk)
claim.refresh_from_db()
self.assertEqual(claim.status, ParentClaim.Status.REJECTED)
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
def test_the_parent_sees_their_child_after_approval(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
self.client.force_login(User.objects.get(email="taylor.doe@example.com"))
response = self.client.get(reverse("members:my_family"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "Jamie Doe")
class GuardianViewTests(ManagementTestBase):
"""How a guardian -- a parent attached to the club only through their child --
behaves across the management UI. See club.models.ClubMembership.Kind; the
@@ -3010,6 +3153,29 @@ class MemberBulkImportTests(ManagementTestBase):
member = Member.objects.get(email="solo.blank@example.com")
self.assertEqual(ClubMembership.objects.get(club=self.club, member=member).kind, ClubMembership.Kind.MEMBER)
def test_a_lone_child_row_gets_a_family_of_its_own(self):
# The migration case: children arrive with no parents on file. A family of
# one is what makes "nobody responsible for this child" visible -- see
# members.services.claims.families_awaiting_a_parent.
upload = make_import_workbook([["Jamie", "Lonechild", "2014-03-02", "", "", "", "", "", "", "", "child", ""]])
self.club_post("member_import", {"file": upload})
self.club_post("member_import_confirm", {})
child = Member.objects.get(first_name="Jamie", last_name="Lonechild")
self.assertIn(child, children_awaiting_a_parent(self.club))
def test_a_lone_parent_row_is_still_an_error(self):
# Only `child` is meaningful without a family_group; a parent with nobody
# to be a parent *of* is a mistake in the file.
upload = make_import_workbook([["Odd", "Loneparent", "", "odd.lone@example.com", "", "", "", "", "", "", "parent", ""]])
response = self.club_post("member_import", {"file": upload})
result = response.context["results"][0]
self.assertIsNone(result["member"])
self.assertTrue(any("family_group" in error for error in result["errors"]))
def test_a_child_marked_as_a_guardian_is_an_error(self):
# A child is the member the guardian is attached *to*.
upload = make_import_workbook([["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "guardian"]])

View File

@@ -36,6 +36,9 @@ urlpatterns = [
path("roles/", views.ClubRoleListView.as_view(), name="role_list"),
path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"),
path("roles/<uuid:pk>/revoke/", views.ClubRoleRevokeView.as_view(), name="role_revoke"),
path("parent-claims/", views.ParentClaimListView.as_view(), name="parent_claim_list"),
path("parent-claims/<uuid:pk>/approve/", views.ParentClaimApproveView.as_view(), name="parent_claim_approve"),
path("parent-claims/<uuid:pk>/reject/", views.ParentClaimRejectView.as_view(), name="parent_claim_reject"),
path("groups/", views.GroupListView.as_view(), name="group_list"),
path("groups/new/", views.GroupCreateView.as_view(), name="group_create"),
path("groups/<uuid:pk>/", views.GroupDetailView.as_view(), name="group_detail"),

View File

@@ -38,7 +38,9 @@ from events.services.recurrence import cancel_occurrence, detach_occurrence, gen
from events.services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
from formbuilder.models import Form as FormBuilderForm
from formbuilder.models import Submission
from members.models import Family, FamilyMembership, Group, GroupMembership, Member
from members.forms import ClaimReviewForm
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
from members.services.claims import ClaimError, approve_claim, children_awaiting_a_parent, reject_claim, suggested_children
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, get_or_create_login_user, grant_login, register_family
from news.models import News, NewsPhoto
from shop.models import Discount, Invoice, Order, Product
@@ -515,6 +517,12 @@ class MemberImportConfirmView(ClubAdminRequiredMixin, View):
family = Family.objects.create()
families_by_group[family_group] = family
FamilyMembership.objects.create(family=family, member=member, role=family_role)
elif family_role == FamilyMembership.FamilyRole.CHILD:
# A child with no family_group: nobody is on file for them yet.
# A family of their own is what makes that state visible -- it's
# what members.services.claims.families_awaiting_a_parent looks
# for, and what an approved claim adds the parent to.
FamilyMembership.objects.create(family=Family.objects.create(), member=member, role=family_role)
if season is not None:
ClubMembership.objects.create(club=request.club, member=member, season=season, signed_up_at=timezone.localdate(), **result["membership_kwargs"])
@@ -1432,6 +1440,71 @@ class FamilyAddParentView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormVi
return redirect("management:family_detail", pk=family.pk)
class ParentClaimListView(ClubAdminRequiredMixin, ListView):
"""The review queue for parents asking to be linked to a child.
Approving is a human decision on purpose -- see members.models.ParentClaim.
Each pending claim is shown with what the parent typed *and* a shortlist of
children who have nobody on file, so the admin matches rather than searches.
"""
template_name = "management/parent_claim_list.html"
context_object_name = "claims"
def get_queryset(self):
return ParentClaim.objects.filter(club=self.request.club).select_related("child", "reviewed_by")
def get_context_data(self, **kwargs):
claims = list(self.get_queryset())
pending = [claim for claim in claims if claim.is_pending]
for claim in pending:
candidates = suggested_children(claim)
claim.review_form = ClaimReviewForm(candidates=Member.objects.filter(pk__in=[child.pk for child in candidates]))
claim.has_candidates = bool(candidates)
return super().get_context_data(
pending=pending,
reviewed=[claim for claim in claims if not claim.is_pending],
awaiting_a_parent=children_awaiting_a_parent(self.request.club).order_by("last_name", "first_name"),
**kwargs,
)
class ParentClaimApproveView(ClubAdminRequiredMixin, View):
"""Link the claim's parent to the chosen child. The parent lands as a
guardian, not a member -- see members.services.claims.approve_claim."""
def post(self, request, pk):
claim = get_object_or_404(ParentClaim.objects.filter(club=request.club), pk=pk)
form = ClaimReviewForm(request.POST, candidates=children_awaiting_a_parent(request.club))
if not form.is_valid():
notify(request, f"e|{_('Could not approve')}|{_('Choose which child this claim refers to.')}")
return redirect("management:parent_claim_list")
reviewer = Member.objects.filter(user=request.user).first()
try:
approve_claim(claim, child=form.cleaned_data["child"], season=current_season(request.club), reviewed_by=reviewer)
except ClaimError as error:
notify(request, f"e|{_('Could not approve')}|{error}")
else:
body = _("%(parent)s” is now linked to %(child)s and can set up their login.") % {"parent": claim.parent_name, "child": form.cleaned_data["child"]}
notify(request, f"s|{_('Claim approved')}|{body}")
return redirect("management:parent_claim_list")
class ParentClaimRejectView(ClubAdminRequiredMixin, View):
def post(self, request, pk):
claim = get_object_or_404(ParentClaim.objects.filter(club=request.club), pk=pk)
reviewer = Member.objects.filter(user=request.user).first()
try:
reject_claim(claim, reviewed_by=reviewer, note=request.POST.get("note", "").strip())
except ClaimError as error:
notify(request, f"e|{_('Could not reject')}|{error}")
else:
notify(request, f"w|{_('Claim rejected')}|" + _("%(parent)s” was not linked.") % {"parent": claim.parent_name})
return redirect("management:parent_claim_list")
class PositionListView(ClubStaffRequiredMixin, ListView):
"""Visible to any staff (coaches need to see positions to make sense of a
roster); creating/editing positions is still ADMIN-only, gated in the