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

View File

@@ -1,6 +1,8 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from members.models import Member
class ParentClaimForm(forms.Form):
"""The public "link me to my child" form -- see members.models.ParentClaim.
@@ -10,6 +12,13 @@ class ParentClaimForm(forms.Form):
that confirmed whether a given child exists would turn it into a way to
enumerate the club's children. The submitter types what they know and an
admin matches it against the real record.
``lock_parent_fields`` drops the "about you" fields entirely rather than
just pre-filling them: a signed-in parent claiming a second child is shown
read-only text instead (see members.views.ParentClaimView), because an
editable-but-pre-filled input still lets a mismatched email slip through.
The view fills parent_first_name/parent_last_name/parent_email in from the
authenticated Member afterwards -- never from anything the client submits.
"""
parent_first_name = forms.CharField(label=_("Your first name"), max_length=150)
@@ -20,6 +29,12 @@ class ParentClaimForm(forms.Form):
child_last_name = forms.CharField(label=_("Child's last name"), max_length=150)
child_date_of_birth = forms.DateField(label=_("Child's date of birth"), widget=forms.DateInput(attrs={"type": "date"}))
def __init__(self, *args, lock_parent_fields=False, **kwargs):
super().__init__(*args, **kwargs)
if lock_parent_fields:
for name in ("parent_first_name", "parent_last_name", "parent_email"):
del self.fields[name]
class ClaimReviewForm(forms.Form):
"""An admin approving one claim: which child it actually refers to.
@@ -33,9 +48,31 @@ class ClaimReviewForm(forms.Form):
# already builds the full class list (including the size modifier), and a
# class baked into the widget attrs would render a second, conflicting
# class="..." on the <select> alongside it rather than merging with it.
child = forms.ModelChoiceField(queryset=None, label=_("Link to"), widget=forms.Select())
# data-searchable matches every other member-picker in the app (e.g.
# TeamMembershipForm.member) -- useful once a club has many candidates.
child = forms.ModelChoiceField(
queryset=None,
label=_("Link to"),
widget=forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
)
def __init__(self, *args, candidates=None, **kwargs):
super().__init__(*args, **kwargs)
if candidates is None:
candidates = Member.objects.none()
self.fields["child"].queryset = candidates
self.fields["child"].label_from_instance = lambda child: f"{child} ({child.date_of_birth:%d %b %Y})" if child.date_of_birth else str(child)
# Pre-select the top match rather than leaving the empty placeholder as
# the genuinely-selected option -- see management.views.ParentClaimListView.
top_match = candidates.first()
if top_match is not None:
self.fields["child"].initial = top_match.pk
class ClaimRejectForm(forms.Form):
"""The optional reason recorded when an admin rejects a claim -- see
members.services.claims.reject_claim. Shown in a modal rather than an
inline row input so a real reason isn't fighting for space next to the
Approve/Reject buttons."""
note = forms.CharField(label=_("Reason"), required=False, widget=forms.Textarea(attrs={"rows": 2}), help_text=_("Recorded in the club's own history -- not sent to the parent."))

View File

@@ -0,0 +1,21 @@
# Generated by Django 6.0.6 on 2026-08-11 21:39
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('members', '0005_parent_claim'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='parentclaim',
name='submitted_by_user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_parent_claims', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'),
),
]

View File

@@ -131,6 +131,15 @@ class ParentClaim(ClubScopedModel):
parent_last_name = models.CharField(_("parent last name"), max_length=150)
parent_email = models.EmailField(_("parent email"))
# The signed-in account that submitted this claim, if any -- e.g. a parent
# already linked to one child, claiming a second. Distinct from parent_email
# above: that stays free text kept verbatim as the evidence an admin judged,
# while this is the authoritative "is this actually you" signal, set from
# request.user and never taken from anything the client could fake. Null for
# an anonymous public submission, which is the common case for a family's
# first claim.
submitted_by_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="submitted_parent_claims", verbose_name=_("submitted by"))
# What the parent typed, kept verbatim even after the claim is matched -- it is
# the evidence the admin judged, and a later dispute needs to see it unchanged.
child_first_name = models.CharField(_("child first name"), max_length=150)

View File

@@ -65,13 +65,18 @@ def children_awaiting_a_parent(club):
).distinct()
def submit_claim(club, *, parent_first_name, parent_last_name, parent_email, child_first_name, child_last_name, child_date_of_birth):
def submit_claim(club, *, parent_first_name, parent_last_name, parent_email, child_first_name, child_last_name, child_date_of_birth, submitted_by_user=None):
"""Record a claim from the public form. Deliberately does not check whether
the child exists: the form is public, so telling the submitter either way
would turn it into a way to enumerate the club's children. Everything is
judged by a human afterwards."""
judged by a human afterwards.
``submitted_by_user`` is the signed-in account submitting a second (or
later) claim -- e.g. a parent already linked to one child, claiming a
sibling. ``None`` for the common case of an anonymous public submission."""
return ParentClaim.objects.create(
club=club,
submitted_by_user=submitted_by_user if submitted_by_user is not None and submitted_by_user.is_authenticated else None,
parent_first_name=parent_first_name.strip(),
parent_last_name=parent_last_name.strip(),
parent_email=parent_email.strip().lower(),
@@ -117,17 +122,47 @@ def approve_claim(claim, *, child, season, reviewed_by=None):
if not claim.is_pending:
raise ClaimError("This claim has already been dealt with.")
family = child.family_memberships.first()
if family is None:
child_membership = child.family_memberships.first()
if child_membership is None:
raise ClaimError("That child is not in a family, so there is nothing to join.")
child_family = child_membership.family
# A signed-in submitter's account is authoritative for who the parent is --
# resolved straight off the FK, never re-derived from the free-text
# parent_email, so a second claim from the same parent can never fork off a
# duplicate User/Member even if the typed email drifted from their login one.
parent = Member.objects.filter(user=claim.submitted_by_user).first() if claim.submitted_by_user_id else None
existing_membership = parent.family_memberships.first() if parent is not None else None
if existing_membership is not None and existing_membership.family_id != child_family.pk:
# The parent already has a household on file -- most often because this
# is a second (or third) child of theirs being claimed. A parent who
# already exists as a Member is the anchor for "one household" from
# then on: the child's own solo family (created for them on import, see
# families_awaiting_a_parent above) merges into the parent's existing
# family, rather than the two staying split across separate Family
# rows or the parent forking into a fresh family alongside their other
# child. The child's now-empty solo family is cleaned up the same way
# members.services.family.detach_from_family already does elsewhere.
family = existing_membership.family
child_membership.family = family
child_membership.save(update_fields=["family"])
if not child_family.memberships.exists():
child_family.delete()
else:
# No existing household to anchor to (a brand new parent, or one whose
# only family link is this very child) -- fall back to the original
# behaviour of joining the child's own family.
family = child_family
add_parent_to_family(
claim.club,
season,
family.family,
family,
email=claim.parent_email,
first_name=claim.parent_first_name,
last_name=claim.parent_last_name,
parent=parent,
)
claim.status = ParentClaim.Status.APPROVED

View File

@@ -133,12 +133,20 @@ def add_child_to_family(club, season, family, *, first_name, last_name, date_of_
@transaction.atomic
def add_parent_to_family(club, season, family, *, email, first_name="", last_name="", parent_is_member=False):
def add_parent_to_family(club, season, family, *, email="", first_name="", last_name="", parent_is_member=False, parent=None):
"""A family that needs one more parent/guardian registered. A guardian
unless ``parent_is_member`` says they belong to the club in their own right."""
parent = get_or_create_login_member(email, first_name, last_name)
# get_or_create, not create: re-adding an email already on this family (a typo'd
# re-submit, say) must not trip unique_member_per_family.
unless ``parent_is_member`` says they belong to the club in their own right.
``parent`` lets a caller that already knows the Member (e.g.
members.services.claims.approve_claim, once a claim carries a signed-in
submitter) attach them directly instead of resolving ``email`` again --
authoritative when the caller has it, and the only way to guarantee no
second User/Member is ever created for the same person.
"""
if parent is None:
parent = get_or_create_login_member(email, first_name, last_name)
# get_or_create, not create: re-adding a parent already on this family (a typo'd
# re-submit, or a second claim that merged into it) must not trip unique_member_per_family.
FamilyMembership.objects.get_or_create(family=family, member=parent, defaults={"role": FamilyMembership.FamilyRole.PARENT})
_enrol(club, season, parent, kind=ClubMembership.Kind.MEMBER if parent_is_member else ClubMembership.Kind.GUARDIAN)

View File

@@ -20,11 +20,17 @@
{% endfor %}
<h2 class="font-semibold text-sm mt-2">{% trans "About you" %}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.parent_first_name %}
{% form_field form.parent_last_name %}
</div>
{% form_field form.parent_email %}
{% if locked_member %}
<p class="text-sm bg-base-200 rounded-box px-3 py-2">
{% blocktrans with name=locked_member.get_full_name email=locked_member.contact_email %}Submitting as: {{ name }} ({{ email }}){% endblocktrans %}
</p>
{% else %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.parent_first_name %}
{% form_field form.parent_last_name %}
</div>
{% form_field form.parent_email %}
{% endif %}
<h2 class="font-semibold text-sm mt-4">{% trans "About your child" %}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -806,3 +806,72 @@ class ParentClaimTests(TestCase):
with self.assertRaises(ClaimError):
approve_claim(claim, child=self.child, season=self.season)
def test_submit_claim_records_the_signed_in_submitter(self):
user = User.objects.create_user(email="taylor.doe@example.com", password="x")
claim = self.make_claim(submitted_by_user=user)
self.assertEqual(claim.submitted_by_user, user)
def test_submit_claim_leaves_the_submitter_blank_for_an_anonymous_submission(self):
claim = self.make_claim()
self.assertIsNone(claim.submitted_by_user)
def test_approving_a_second_claim_from_a_known_parent_merges_into_their_existing_family(self):
# A parent claiming a second (or third) child of theirs: the child's own
# solo family (created for them on import) must merge into the family
# the parent already belongs to, not sit alongside it in a second row.
first_claim = self.make_claim()
approve_claim(first_claim, child=self.child, season=self.season)
parent_user = User.objects.get(email="taylor.doe@example.com")
parent = Member.objects.get(user=parent_user)
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)
second_claim = self.make_claim(
child_first_name="Robin",
child_last_name="Doe",
child_date_of_birth=datetime.date(2016, 5, 1),
submitted_by_user=parent_user,
)
approve_claim(second_claim, child=second_child, season=self.season)
# Exactly one account and one household with both children.
self.assertEqual(User.objects.filter(email="taylor.doe@example.com").count(), 1)
self.assertEqual(Member.objects.filter(user=parent_user).count(), 1)
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())
# Still a guardian on the second child's enrolment too, not a member.
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
def test_approving_falls_back_to_the_childs_family_when_the_parent_has_none_yet(self):
# A known account with no family link at all (e.g. login granted without
# ever being attached to a family) -- nothing to anchor to, so this
# behaves exactly like the original, family-less flow.
user = User.objects.create_user(email="taylor.doe@example.com", password="x")
parent = Member.objects.create(user=user, first_name="Taylor", last_name="Doe")
claim = self.make_claim(submitted_by_user=user)
approve_claim(claim, child=self.child, season=self.season)
self.assertEqual(FamilyMembership.objects.get(member=parent).family, self.family)
self.assertEqual(Member.objects.filter(user=user).count(), 1)
def test_approving_uses_the_authenticated_members_account_even_if_the_typed_email_differs(self):
# submitted_by_user is authoritative -- a stale or mistyped parent_email
# must never fork off a second User/Member for someone already known.
user = User.objects.create_user(email="real.taylor@example.com", password="x")
parent = Member.objects.create(user=user, first_name="Taylor", last_name="Doe")
claim = self.make_claim(parent_email="typo.taylor@example.com", submitted_by_user=user)
approve_claim(claim, child=self.child, season=self.season)
self.assertEqual(Member.objects.filter(first_name="Taylor", last_name="Doe").count(), 1)
self.assertFalse(User.objects.filter(email="typo.taylor@example.com").exists())
self.assertEqual(FamilyMembership.objects.get(member=parent).family, self.family)

View File

@@ -34,13 +34,42 @@ class ParentClaimView(ClubScopedPublicMixin, FormView):
It always reports the same thing back, whether or not the child was found:
the response must not tell an anonymous submitter which children the club
has. What actually happens next is decided by an admin.
A signed-in parent claiming a second child (a sibling, most often) gets
their "about you" fields locked to read-only text instead of blank inputs
-- typing their name/email again risks a typo forking off a second
User/Member instead of reusing theirs, and approval also uses this to
merge the new child into the family they and their first child already
belong to (see members.services.claims.approve_claim).
"""
template_name = "members/parent_claim.html"
form_class = ParentClaimForm
def get_member(self):
if not self.request.user.is_authenticated:
return None
return Member.objects.filter(user=self.request.user).first()
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["lock_parent_fields"] = self.get_member() is not None
return kwargs
def get_context_data(self, **kwargs):
return super().get_context_data(locked_member=self.get_member(), **kwargs)
def form_valid(self, form):
submit_claim(self.request.club, **form.cleaned_data)
data = dict(form.cleaned_data)
member = self.get_member()
if member is not None:
# Never trust the client for this -- the fields aren't even in the
# submitted form when locked. Pulled straight from the account
# that's actually signed in.
data["parent_first_name"] = member.first_name
data["parent_last_name"] = member.last_name
data["parent_email"] = member.contact_email
submit_claim(self.request.club, submitted_by_user=self.request.user if self.request.user.is_authenticated else None, **data)
club = self.request.club
title = _("Request received")