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

@@ -1,3 +1,4 @@
import datetime
import tempfile
from datetime import date, timedelta
from io import StringIO
@@ -5,6 +6,7 @@ from pathlib import Path
from allauth.mfa.models import Authenticator
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db import IntegrityError
@@ -15,8 +17,9 @@ from django.utils import timezone
from authentication.models import User
from club.models import Club, ClubMembership, Season
from members.admin import FamilyAdmin
from members.models import Family, FamilyMembership, Group, GroupMembership, Member
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
from members.services import MemberImportResult
from members.services.claims import ClaimError, approve_claim, children_awaiting_a_parent, reject_claim, submit_claim, suggested_children
class MemberModelTests(TestCase):
@@ -683,3 +686,123 @@ class MemberImportResultTests(TestCase):
def test_successful_rows_sums_created_and_updated(self):
result = MemberImportResult(created_members=2, updated_members=3)
self.assertEqual(result.successful_rows, 5)
class ParentClaimTests(TestCase):
"""The onboarding path for a club that arrives with a list of children and no
parent records -- see members.services.claims."""
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
today = timezone.localdate()
cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + datetime.timedelta(days=300))
# An imported child: no login, and a family of their own with nobody on it.
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)
cls.family = Family.objects.create()
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
def make_claim(self, **overrides):
details = {
"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": datetime.date(2014, 3, 2),
}
details.update(overrides)
return submit_claim(self.club, **details)
def test_a_child_with_no_parent_is_on_the_worklist(self):
self.assertIn(self.child, children_awaiting_a_parent(self.club))
def test_a_child_who_has_a_parent_is_not(self):
parent = Member.objects.create(first_name="Taylor", last_name="Doe")
FamilyMembership.objects.create(family=self.family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
self.assertNotIn(self.child, children_awaiting_a_parent(self.club))
def test_submitting_records_a_pending_claim_without_matching_anything(self):
# The form is public, so it must not resolve the child -- doing so would
# let an anonymous submitter test which children the club has.
claim = self.make_claim(child_last_name="Nonexistent")
self.assertTrue(claim.is_pending)
self.assertIsNone(claim.child)
def test_suggestions_rank_the_real_child_first(self):
claim = self.make_claim()
self.assertEqual(suggested_children(claim)[0], self.child)
def test_suggestions_never_include_a_child_who_already_has_a_parent(self):
parent = Member.objects.create(first_name="Existing", last_name="Doe")
FamilyMembership.objects.create(family=self.family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
claim = self.make_claim()
self.assertEqual(suggested_children(claim), [])
def test_approving_links_the_parent_as_a_guardian(self):
claim = self.make_claim()
approve_claim(claim, child=self.child, season=self.season)
parent = Member.objects.get(user__email="taylor.doe@example.com")
self.assertEqual(FamilyMembership.objects.get(family=self.family, member=parent).role, FamilyMembership.FamilyRole.PARENT)
# A guardian, not a member: they hold the login but owe no fee and are
# not counted in the club's roll.
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
def test_approving_gives_the_parent_an_unusable_password_to_reset(self):
claim = self.make_claim()
approve_claim(claim, child=self.child, season=self.season)
user = get_user_model().objects.get(email="taylor.doe@example.com")
self.assertFalse(user.has_usable_password())
def test_approving_closes_the_claim_and_records_the_match(self):
claim = self.make_claim()
approve_claim(claim, child=self.child, season=self.season)
claim.refresh_from_db()
self.assertEqual(claim.status, ParentClaim.Status.APPROVED)
self.assertEqual(claim.child, self.child)
self.assertIsNotNone(claim.reviewed_at)
def test_an_approved_child_leaves_the_worklist(self):
claim = self.make_claim()
approve_claim(claim, child=self.child, season=self.season)
# The state is the shape of the data, so it corrects itself rather than
# needing a flag cleared.
self.assertNotIn(self.child, children_awaiting_a_parent(self.club))
def test_a_claim_cannot_be_approved_twice(self):
claim = self.make_claim()
approve_claim(claim, child=self.child, season=self.season)
with self.assertRaises(ClaimError):
approve_claim(claim, child=self.child, season=self.season)
def test_rejecting_records_the_reason_and_links_nobody(self):
claim = self.make_claim()
reject_claim(claim, note="Not on our records.")
claim.refresh_from_db()
self.assertEqual(claim.status, ParentClaim.Status.REJECTED)
self.assertEqual(claim.note, "Not on our records.")
self.assertFalse(Member.objects.filter(user__email="taylor.doe@example.com").exists())
def test_a_rejected_claim_cannot_then_be_approved(self):
claim = self.make_claim()
reject_claim(claim)
with self.assertRaises(ClaimError):
approve_claim(claim, child=self.child, season=self.season)