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:
125
members/tests.py
125
members/tests.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user