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:
@@ -25,7 +25,8 @@ from events.services.recurrence import detach_occurrence, generate_occurrences
|
||||
from management.bulk_import import TEMPLATE_COLUMNS
|
||||
from management.pdf import PDFExportError, _tint_with_white, referee_form_colors, render_pdf
|
||||
from management.recurrence_ui import build_rrule, describe_rrule, parse_rrule
|
||||
from members.models import Family, FamilyMembership, Group, GroupMembership, Member
|
||||
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
|
||||
from members.services.claims import children_awaiting_a_parent
|
||||
from news.models import News, NewsPhoto
|
||||
from shop.models import Order
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||
@@ -1363,6 +1364,148 @@ class FamilyManagementTests(ManagementTestBase):
|
||||
self.assertEqual(family.guardians.count(), 1)
|
||||
|
||||
|
||||
class ParentClaimViewTests(ManagementTestBase):
|
||||
"""The public claim form and the admin review queue -- see
|
||||
members.services.claims; the service-level guarantees live in
|
||||
members.tests.ParentClaimTests."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
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)
|
||||
family = Family.objects.create()
|
||||
FamilyMembership.objects.create(family=family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
|
||||
|
||||
def claim_payload(self, **overrides):
|
||||
payload = {
|
||||
"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": "2014-03-02",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
def submit(self, **overrides):
|
||||
return self.client.post(reverse("members:parent_claim"), self.claim_payload(**overrides), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
def test_the_claim_form_is_reachable_without_signing_in(self):
|
||||
response = self.client.get(reverse("members:parent_claim"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_submitting_records_a_pending_claim(self):
|
||||
response = self.submit()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(ParentClaim.objects.filter(club=self.club, status=ParentClaim.Status.PENDING).count(), 1)
|
||||
|
||||
def test_an_unmatched_claim_looks_exactly_like_a_matched_one(self):
|
||||
# The page must not tell an anonymous submitter which children exist.
|
||||
matched = self.submit()
|
||||
unmatched = self.submit(child_first_name="Nobody", child_last_name="Here", parent_email="other@example.com")
|
||||
|
||||
self.assertEqual(matched.status_code, unmatched.status_code)
|
||||
self.assertEqual(matched.content, unmatched.content)
|
||||
|
||||
def test_submitting_creates_no_account(self):
|
||||
# A public form that made a User per submission would be a spam magnet;
|
||||
# the account is created on approval, once a human has vouched for it.
|
||||
self.submit()
|
||||
|
||||
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
|
||||
|
||||
def test_open_signup_is_closed(self):
|
||||
response = self.client.get(reverse("account_signup"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_the_queue_is_admin_only(self):
|
||||
coach_user = User.objects.create_user(email="coach-claims@example.com", password="pw-secret-123")
|
||||
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
|
||||
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
|
||||
position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
|
||||
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
|
||||
self.client.force_login(coach_user)
|
||||
|
||||
self.assertEqual(self.club_get("parent_claim_list").status_code, 403)
|
||||
|
||||
def test_the_queue_lists_a_pending_claim_and_the_unclaimed_child(self):
|
||||
self.submit()
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("parent_claim_list")
|
||||
|
||||
self.assertContains(response, "taylor.doe@example.com")
|
||||
self.assertContains(response, "Jamie Doe")
|
||||
|
||||
def test_approving_links_the_parent_as_a_guardian(self):
|
||||
self.submit()
|
||||
claim = ParentClaim.objects.get(club=self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
|
||||
|
||||
parent = Member.objects.get(user__email="taylor.doe@example.com")
|
||||
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
|
||||
claim.refresh_from_db()
|
||||
self.assertEqual(claim.status, ParentClaim.Status.APPROVED)
|
||||
|
||||
def test_approving_without_choosing_a_child_changes_nothing(self):
|
||||
self.submit()
|
||||
claim = ParentClaim.objects.get(club=self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("parent_claim_approve", {}, claim.pk)
|
||||
|
||||
claim.refresh_from_db()
|
||||
self.assertTrue(claim.is_pending)
|
||||
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
|
||||
|
||||
def test_a_child_who_already_has_a_parent_cannot_be_chosen(self):
|
||||
# The shortlist is only ever children with nobody on file, so approving
|
||||
# can never quietly re-parent a child who already has one.
|
||||
other_child = Member.objects.create(first_name="Sam", last_name="Roe", date_of_birth=datetime.date(2013, 1, 1))
|
||||
ClubMembership.objects.create(club=self.club, member=other_child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
family = Family.objects.create()
|
||||
FamilyMembership.objects.create(family=family, member=other_child, role=FamilyMembership.FamilyRole.CHILD)
|
||||
existing_parent = Member.objects.create(first_name="Existing", last_name="Roe")
|
||||
FamilyMembership.objects.create(family=family, member=existing_parent, role=FamilyMembership.FamilyRole.PARENT)
|
||||
self.submit()
|
||||
claim = ParentClaim.objects.get(club=self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("parent_claim_approve", {"child": str(other_child.pk)}, claim.pk)
|
||||
|
||||
claim.refresh_from_db()
|
||||
self.assertTrue(claim.is_pending)
|
||||
|
||||
def test_rejecting_records_the_reason_and_links_nobody(self):
|
||||
self.submit()
|
||||
claim = ParentClaim.objects.get(club=self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("parent_claim_reject", {"note": "Not on our records."}, claim.pk)
|
||||
|
||||
claim.refresh_from_db()
|
||||
self.assertEqual(claim.status, ParentClaim.Status.REJECTED)
|
||||
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
|
||||
|
||||
def test_the_parent_sees_their_child_after_approval(self):
|
||||
self.submit()
|
||||
claim = ParentClaim.objects.get(club=self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
|
||||
|
||||
self.client.force_login(User.objects.get(email="taylor.doe@example.com"))
|
||||
response = self.client.get(reverse("members:my_family"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertContains(response, "Jamie Doe")
|
||||
|
||||
|
||||
class GuardianViewTests(ManagementTestBase):
|
||||
"""How a guardian -- a parent attached to the club only through their child --
|
||||
behaves across the management UI. See club.models.ClubMembership.Kind; the
|
||||
@@ -3010,6 +3153,29 @@ class MemberBulkImportTests(ManagementTestBase):
|
||||
member = Member.objects.get(email="solo.blank@example.com")
|
||||
self.assertEqual(ClubMembership.objects.get(club=self.club, member=member).kind, ClubMembership.Kind.MEMBER)
|
||||
|
||||
def test_a_lone_child_row_gets_a_family_of_its_own(self):
|
||||
# The migration case: children arrive with no parents on file. A family of
|
||||
# one is what makes "nobody responsible for this child" visible -- see
|
||||
# members.services.claims.families_awaiting_a_parent.
|
||||
upload = make_import_workbook([["Jamie", "Lonechild", "2014-03-02", "", "", "", "", "", "", "", "child", ""]])
|
||||
self.club_post("member_import", {"file": upload})
|
||||
|
||||
self.club_post("member_import_confirm", {})
|
||||
|
||||
child = Member.objects.get(first_name="Jamie", last_name="Lonechild")
|
||||
self.assertIn(child, children_awaiting_a_parent(self.club))
|
||||
|
||||
def test_a_lone_parent_row_is_still_an_error(self):
|
||||
# Only `child` is meaningful without a family_group; a parent with nobody
|
||||
# to be a parent *of* is a mistake in the file.
|
||||
upload = make_import_workbook([["Odd", "Loneparent", "", "odd.lone@example.com", "", "", "", "", "", "", "parent", ""]])
|
||||
|
||||
response = self.club_post("member_import", {"file": upload})
|
||||
|
||||
result = response.context["results"][0]
|
||||
self.assertIsNone(result["member"])
|
||||
self.assertTrue(any("family_group" in error for error in result["errors"]))
|
||||
|
||||
def test_a_child_marked_as_a_guardian_is_an_error(self):
|
||||
# A child is the member the guardian is attached *to*.
|
||||
upload = make_import_workbook([["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "guardian"]])
|
||||
|
||||
Reference in New Issue
Block a user