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

141
members/services/claims.py Normal file
View File

@@ -0,0 +1,141 @@
"""Linking a parent to a child the club already holds.
The initial-migration path: a club arrives with a list of children and no parent
records. They're imported without logins, each into a family of their own, and
parents come forward afterwards through the public claim form. An admin matches
each claim against a real child and approves it, which is when the account is
created and the family link made.
Why an admin decides: see members.models.ParentClaim. The short version is that
the club is the only party that actually knows its families, and the public form
must never confirm whether a given child exists -- so it takes free text and
matches nothing itself.
"""
from django.db import transaction
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
from club.models import ClubMembership
from members.models import Family, FamilyMembership, Member, ParentClaim
from members.services.family import add_parent_to_family
#: Suggestions are ranked, never auto-applied -- an exact name-and-birthday match
#: is still only a suggestion, because the whole point of the queue is that a
#: human confirms it.
GUARDIAN_ROLES = (FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN)
def families_awaiting_a_parent(club):
"""Families in ``club`` that have children on them but nobody responsible.
That shape *is* the state -- there's no "unclaimed" flag to drift out of step
with reality. A child imported on their own gets a family of one (see
management/bulk_import.py), and the moment a claim is approved a parent joins
it, so the family drops out of here by itself.
"""
# Exists subqueries rather than annotate(Count(..., filter=...)): the club
# filter and the counts would otherwise share one join, so a parent with no
# ClubMembership of their own -- which is exactly what a newly linked
# guardian is before the season row lands -- wouldn't be counted, and the
# family would look unclaimed forever.
child_in_this_club = FamilyMembership.objects.filter(family=OuterRef("pk"), role=FamilyMembership.FamilyRole.CHILD, member__member_of__club=club)
somebody_responsible = FamilyMembership.objects.filter(family=OuterRef("pk"), role__in=GUARDIAN_ROLES)
return Family.objects.filter(Exists(child_in_this_club)).filter(~Exists(somebody_responsible))
def children_awaiting_a_parent(club):
"""The children on those families -- the admin's worklist, and the set a
claim may be matched against."""
return Member.objects.filter(
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
family_memberships__family__in=families_awaiting_a_parent(club),
member_of__club=club,
member_of__kind=ClubMembership.Kind.MEMBER,
).distinct()
def submit_claim(club, *, parent_first_name, parent_last_name, parent_email, child_first_name, child_last_name, child_date_of_birth):
"""Record a claim from the public form. Deliberately does not check whether
the child exists: the form is public, so telling the submitter either way
would turn it into a way to enumerate the club's children. Everything is
judged by a human afterwards."""
return ParentClaim.objects.create(
club=club,
parent_first_name=parent_first_name.strip(),
parent_last_name=parent_last_name.strip(),
parent_email=parent_email.strip().lower(),
child_first_name=child_first_name.strip(),
child_last_name=child_last_name.strip(),
child_date_of_birth=child_date_of_birth,
)
def suggested_children(claim):
"""Children the claim plausibly refers to, best first.
Only ever a shortlist for the admin to choose from. Ordered by how much of
the claim matches, but an exact hit on both name and birthday still has to be
confirmed -- a birthday is not a secret, and the queue exists precisely so
that guessing one isn't enough.
"""
candidates = children_awaiting_a_parent(claim.club).filter(Q(last_name__iexact=claim.child_last_name) | Q(date_of_birth=claim.child_date_of_birth))
def score(child):
return (
child.last_name.lower() == claim.child_last_name.lower(),
child.date_of_birth == claim.child_date_of_birth,
child.first_name.lower() == claim.child_first_name.lower(),
)
return sorted(candidates, key=lambda child: sum(score(child)), reverse=True)
class ClaimError(Exception):
"""A claim could not be approved."""
@transaction.atomic
def approve_claim(claim, *, child, season, reviewed_by=None):
"""Link the claim's parent to ``child`` and close the claim.
The parent lands as a *guardian* (club.models.ClubMembership.Kind): they get
the login and the family link, but they aren't a member and owe no fee. If
they also play, an admin flips that on their membership afterwards --
approving a claim is not the place to decide it.
"""
if not claim.is_pending:
raise ClaimError("This claim has already been dealt with.")
family = child.family_memberships.first()
if family is None:
raise ClaimError("That child is not in a family, so there is nothing to join.")
add_parent_to_family(
claim.club,
season,
family.family,
email=claim.parent_email,
first_name=claim.parent_first_name,
last_name=claim.parent_last_name,
)
claim.status = ParentClaim.Status.APPROVED
claim.child = child
claim.reviewed_by = reviewed_by
claim.reviewed_at = timezone.now()
claim.save(update_fields=["status", "child", "reviewed_by", "reviewed_at"])
return claim
def reject_claim(claim, *, reviewed_by=None, note=""):
if not claim.is_pending:
raise ClaimError("This claim has already been dealt with.")
claim.status = ParentClaim.Status.REJECTED
claim.reviewed_by = reviewed_by
claim.reviewed_at = timezone.now()
claim.note = note
claim.save(update_fields=["status", "reviewed_by", "reviewed_at", "note"])
return claim