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

@@ -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