Season-scope claim history, self-service second-child claims, redesign approval screen

Signed-in parents get their details locked and pre-filled on the claim
form instead of retyped; approving links to their existing user and
merges into their existing family instead of creating a duplicate.
The approval screen is now a card grid with a searchable, pre-selected
child dropdown and a reason modal for rejection. The "already dealt
with" history is scoped to the current season.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:52:50 +02:00
parent b2657bb15d
commit c4f0ec71c1
11 changed files with 445 additions and 66 deletions

View File

@@ -1576,6 +1576,151 @@ class ParentClaimViewTests(ManagementTestBase):
self.assertContains(response, "Jamie Doe")
def test_the_child_dropdown_is_searchable_and_the_top_match_is_preselected(self):
self.submit()
self.client.force_login(self.admin_user)
response = self.club_get("parent_claim_list")
self.assertContains(response, 'data-searchable="true"')
html = response.content.decode()
select_tag = html[html.index("<select") : html.index("</select>", html.index("<select"))]
self.assertIn(f'value="{self.child.pk}" selected', select_tag)
def test_a_reject_modal_exists_for_each_pending_claim(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
response = self.club_get("parent_claim_list")
self.assertContains(response, f'id="claim_reject_modal_{claim.pk}"')
self.assertContains(response, f"document.getElementById('claim_reject_modal_{claim.pk}').showModal()")
def test_the_history_section_shows_a_claim_reviewed_this_season(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)
response = self.club_get("parent_claim_list")
self.assertContains(response, "Already dealt with")
self.assertContains(response, "taylor.doe@example.com")
def test_the_history_section_hides_a_claim_reviewed_last_season(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)
claim.refresh_from_db()
claim.reviewed_at = timezone.make_aware(datetime.datetime.combine(self.season.start_date - datetime.timedelta(days=1), datetime.time()))
claim.save(update_fields=["reviewed_at"])
response = self.club_get("parent_claim_list")
self.assertNotContains(response, "Already dealt with")
def test_the_history_section_is_empty_without_a_current_season(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)
# Push the season into the future rather than deleting it -- ClubMembership.season
# is PROTECT, and the club being between seasons is the scenario under test,
# not the absence of any Season row at all.
future_start = timezone.localdate() + datetime.timedelta(days=100)
self.season.start_date = future_start
self.season.end_date = future_start + datetime.timedelta(days=300)
self.season.save(update_fields=["start_date", "end_date"])
response = self.club_get("parent_claim_list")
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "Already dealt with")
def test_a_signed_in_parent_sees_locked_read_only_fields_instead_of_blank_inputs(self):
# Re-typing name/email risks a typo forking off a second account -- the
# fields are shown read-only rather than editable-but-pre-filled.
user = User.objects.create_user(email="already.parent@example.com", password="pw-secret-123")
Member.objects.create(user=user, first_name="Already", last_name="Parent")
self.client.force_login(user)
response = self.client.get(reverse("members:parent_claim"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "Submitting as")
self.assertContains(response, "Already Parent")
self.assertContains(response, "already.parent@example.com")
self.assertNotContains(response, 'name="parent_first_name"')
self.assertNotContains(response, 'name="parent_last_name"')
self.assertNotContains(response, 'name="parent_email"')
def test_a_signed_in_parents_claim_reuses_their_account_and_ignores_posted_parent_fields(self):
user = User.objects.create_user(email="already.parent@example.com", password="pw-secret-123")
Member.objects.create(user=user, first_name="Already", last_name="Parent")
self.client.force_login(user)
response = self.client.post(
reverse("members:parent_claim"),
{
# Even if a tampered request smuggled these in, the fields don't
# exist on the locked form and the view never reads POST for them.
"parent_first_name": "Someone",
"parent_last_name": "Else",
"parent_email": "not-me@example.com",
"child_first_name": "Jamie",
"child_last_name": "Doe",
"child_date_of_birth": "2014-03-02",
},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertEqual(response.status_code, 302)
claim = ParentClaim.objects.get(club=self.club, child_first_name="Jamie")
self.assertEqual(claim.submitted_by_user, user)
self.assertEqual(claim.parent_first_name, "Already")
self.assertEqual(claim.parent_last_name, "Parent")
self.assertEqual(claim.parent_email, "already.parent@example.com")
self.assertFalse(User.objects.filter(email="not-me@example.com").exists())
def test_an_anonymous_submission_has_no_linked_user(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.assertIsNone(claim.submitted_by_user)
def test_a_second_claim_from_a_signed_in_parent_merges_the_new_child_into_their_existing_family(self):
self.submit()
first_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)}, first_claim.pk)
parent_user = User.objects.get(email="taylor.doe@example.com")
second_child = Member.objects.create(first_name="Robin", last_name="Doe", date_of_birth=datetime.date(2016, 5, 1))
ClubMembership.objects.create(club=self.club, member=second_child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
second_family = Family.objects.create()
FamilyMembership.objects.create(family=second_family, member=second_child, role=FamilyMembership.FamilyRole.CHILD)
self.client.force_login(parent_user)
self.client.post(
reverse("members:parent_claim"),
{"child_first_name": "Robin", "child_last_name": "Doe", "child_date_of_birth": "2016-05-01"},
HTTP_HOST="ajax-united.rosterchief.app",
)
second_claim = ParentClaim.objects.get(club=self.club, child_first_name="Robin")
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(second_child.pk)}, second_claim.pk)
# One account, one household with both children -- not a parent split
# across two Family rows, and not a duplicate User/Member.
self.assertEqual(User.objects.filter(email="taylor.doe@example.com").count(), 1)
parent = Member.objects.get(user=parent_user)
family = FamilyMembership.objects.get(member=parent).family
self.assertCountEqual(family.children, [self.child, second_child])
self.assertFalse(Family.objects.filter(pk=second_family.pk).exists())
class GuardianViewTests(ManagementTestBase):
"""How a guardian -- a parent attached to the club only through their child --