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

@@ -1,5 +1,5 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% load i18n lucide static ui %}
{% block heading %}{% trans "Parent claims" %}{% endblock heading %}
{% block subheading %}{% trans "Parents asking to be linked to a child the club already has on file." %}{% endblock subheading %}
@@ -12,56 +12,64 @@
{% blocktrans %}Check each request against your own records before approving. The shortlist only ever contains children who have nobody on file, so approving can never re-parent a child who already has one. An approved parent is added as a guardian: they get the login, but owe no fee and are not counted as a member.{% endblocktrans %}
</p>
{% for claim in pending %}
<div class="border border-base-300 rounded-box p-4 mt-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="text-xs opacity-70">{% trans "Parent says they are" %}</div>
<div class="font-semibold">{{ claim.parent_name }}</div>
<div class="text-sm opacity-70">{{ claim.parent_email }}</div>
</div>
<div>
<div class="text-xs opacity-70">{% trans "Parent of" %}</div>
<div class="font-semibold">{{ claim.claimed_child_name }}</div>
<div class="text-sm opacity-70">{{ claim.child_date_of_birth|date:"j F Y" }}</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-2">
{% for claim in pending %}
<div class="card border border-base-300">
<div class="card-body p-4 gap-3">
<div>
<div class="text-xs opacity-70">{% trans "Parent says they are" %}</div>
<div class="font-semibold">{{ claim.parent_name }}</div>
<div class="text-sm opacity-70">{{ claim.parent_email }}</div>
</div>
<div>
<div class="text-xs opacity-70">{% trans "Parent of" %}</div>
<div class="font-semibold">{{ claim.claimed_child_name }}</div>
<div class="text-sm opacity-70">{{ claim.child_date_of_birth|date:"j F Y" }}</div>
</div>
{% comment %}
size="small" + show_label=False on the dropdown: {% form_field %}
defaults to daisyUI's medium control height, taller than the btn-sm
used by Approve/Reject below -- without it the dropdown stands out
against the buttons either side of it.
justify-between (rather than everything in one flex-wrap run) keeps
Approve and Reject apart even as the row wraps on a narrow card --
Approve stays pinned left, Reject right, so the two are never one
stray click apart the way adjacent buttons would be.
{% endcomment %}
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
{% if claim.has_candidates %}
<form method="post" action="{% url 'management:parent_claim_approve' claim.pk %}" class="flex flex-wrap items-center gap-2">
{% csrf_token %}
<div class="min-w-40">{% form_field claim.review_form.child size="small" show_label=False %}</div>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "check" size=14 %} {% trans "Approve" %}</button>
</form>
{% else %}
<p class="text-sm opacity-70">{% trans "No child without a parent matches this. Check the spelling and the date of birth with them before rejecting." %}</p>
{% endif %}
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('{{ claim.pk|dom_id:"claim_reject_modal" }}').showModal()">
{% lucide "x" size=14 %} {% trans "Reject" %}
</button>
</div>
</div>
</div>
{% comment %}
size="small" + show_label=False on the dropdown: {% form_field %}
defaults to daisyUI's medium control height, taller than the btn-sm/
input-sm used by everything else in this row -- without it the
dropdown stands out against the buttons either side of it.
justify-between (rather than everything in one flex-wrap run) keeps
Approve and Reject apart even as the row wraps on a narrow screen --
Approve stays pinned left, Reject right, so the two are never one
stray click apart the way adjacent buttons would be.
{% endcomment %}
<div class="flex flex-wrap items-center justify-between gap-x-6 gap-y-2 mt-4">
{% if claim.has_candidates %}
<form method="post" action="{% url 'management:parent_claim_approve' claim.pk %}" class="flex flex-wrap items-center gap-2">
{% csrf_token %}
<div class="min-w-64">{% form_field claim.review_form.child size="small" show_label=False %}</div>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "check" size=14 %} {% trans "Approve" %}</button>
</form>
{% else %}
<p class="text-sm opacity-70">{% trans "No child without a parent matches this. Check the spelling and the date of birth with them before rejecting." %}</p>
{% endif %}
<form method="post" action="{% url 'management:parent_claim_reject' claim.pk %}" class="flex items-center gap-2">
{% csrf_token %}
<input type="text" name="note" class="input input-bordered input-sm" placeholder="{% trans 'Reason (optional)' %}">
<button class="btn btn-outline btn-error btn-sm gap-2" type="submit">{% lucide "x" size=14 %} {% trans "Reject" %}</button>
</form>
</div>
</div>
{% empty %}
<p class="text-sm opacity-60 mt-2">{% trans "Nothing waiting." %}</p>
{% endfor %}
{% empty %}
<p class="text-sm opacity-60 mt-2">{% trans "Nothing waiting." %}</p>
{% endfor %}
</div>
</div>
</div>
{% trans "Reject claim" as reject_claim_title %}
{% trans "Reject" as reject_claim_submit_label %}
{% for claim in pending %}
{% url 'management:parent_claim_reject' claim.pk as reject_claim_url %}
{% blocktrans with name=claim.parent_name asvar reject_claim_blurb %}Why is {{ name }}'s claim being rejected? Recorded in the club's own history, not sent to them.{% endblocktrans %}
{% include "controlpanel/_modal_form.html" with modal_id=claim.pk|dom_id:"claim_reject_modal" title=reject_claim_title form=claim.reject_form action_url=reject_claim_url submit_label=reject_claim_submit_label submit_icon="x" blurb=reject_claim_blurb %}
{% endfor %}
<div class="card bg-base-100 shadow mb-4">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-search" size=18 %} {% trans "Children with nobody on file" %}</h2>
@@ -117,3 +125,7 @@
</div>
{% endif %}
{% endblock panel %}
{% block extra_body %}
<script src="{% static 'js/searchable-select.js' %}"></script>
{% endblock extra_body %}

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

View File

@@ -38,7 +38,7 @@ 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.forms import ClaimReviewForm
from members.forms import ClaimRejectForm, 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, send_claim_approved_email, 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
@@ -1459,12 +1459,20 @@ class ParentClaimListView(ClubAdminRequiredMixin, ListView):
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]))
initial = {"child": candidates[0].pk} if candidates else None
claim.review_form = ClaimReviewForm(candidates=Member.objects.filter(pk__in=[child.pk for child in candidates]), initial=initial)
claim.has_candidates = bool(candidates)
claim.reject_form = ClaimRejectForm()
# Last season's history is clutter, not context -- only what was reviewed
# within the club's current season stays in view. No current season (a
# club between seasons) means nothing qualifies, rather than erroring.
season = current_season(self.request.club)
reviewed = self.get_queryset().exclude(status=ParentClaim.Status.PENDING).filter(reviewed_at__date__gte=season.start_date) if season is not None else ParentClaim.objects.none()
return super().get_context_data(
pending=pending,
reviewed=[claim for claim in claims if not claim.is_pending],
reviewed=reviewed,
awaiting_a_parent=children_awaiting_a_parent(self.request.club).order_by("last_name", "first_name"),
**kwargs,
)