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 +1,63 @@
# Create your views here.
"""Public and member-facing pages: claiming a child, and a parent's own family.
Everything here is outside the management app on purpose -- a parent is not club
staff, and ClubStaffRequiredMixin would (rightly) turn them away.
"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404
from django.shortcuts import render
from django.views.generic import FormView, TemplateView
from members.forms import ParentClaimForm
from members.models import FamilyMembership, Member
from members.services.claims import submit_claim
class ClubScopedPublicMixin:
"""A club subdomain resolves this page; the base domain has no club to claim
a child at, so it simply doesn't exist there."""
def dispatch(self, request, *args, **kwargs):
if getattr(request, "club", None) is None:
raise Http404("This page belongs to a club.")
return super().dispatch(request, *args, **kwargs)
class ParentClaimView(ClubScopedPublicMixin, FormView):
"""Public. Submitting is also how a parent registers -- there is no open
signup page, so this form is the only way into an account for someone the
club hasn't already added.
It always reports the same thing back, whether or not the child was found:
the response must not tell an anonymous submitter which children the club
has. What actually happens next is decided by an admin.
"""
template_name = "members/parent_claim.html"
form_class = ParentClaimForm
def form_valid(self, form):
submit_claim(self.request.club, **form.cleaned_data)
return render(self.request, "members/parent_claim_submitted.html", {"club": self.request.club})
class MyFamilyView(ClubScopedPublicMixin, LoginRequiredMixin, TemplateView):
"""What a linked parent sees: the children they're responsible for, and
nothing else. The seam a real parent portal would grow from."""
template_name = "members/my_family.html"
def get_context_data(self, **kwargs):
me = Member.objects.filter(user=self.request.user).first()
children = Member.objects.none()
if me is not None:
children = Member.objects.filter(
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
family_memberships__family__memberships__member=me,
family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
member_of__club=self.request.club,
).distinct()
return super().get_context_data(club=self.request.club, me=me, children=children, **kwargs)