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:
@@ -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 %}
|
||||
|
||||
@@ -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 --
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user