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:
2026-08-11 18:18:26 +02:00
parent 744b623403
commit ca2b1a11b5
25 changed files with 1033 additions and 10 deletions

View File

@@ -423,6 +423,33 @@ ClubMembership(ClubScopedModel) # -> carries `club`
a parent exactly like the child, so every parent counted as a member — a data migration a parent exactly like the child, so every parent counted as a member — a data migration
reclassifies them, deliberately skipping anyone who plays, is on a team's staff, or holds reclassifies them, deliberately skipping anyone who plays, is on a team's staff, or holds
an elevated ClubRole. an elevated ClubRole.
- **Onboarding a legacy roster: `members.ParentClaim`** *(built)*. The migration path for a
club that arrives with a list of children and no parent records at all. Children import
without logins, each into a **family of their own** — that shape *is* the "nobody is
responsible for this child" state (`members/services/claims.py::families_awaiting_a_parent`),
so there is no flag to drift out of step with reality, and the family drops off the worklist
by itself the moment a parent joins it. `family_role=child` with a blank `family_group` is
what asks for it; any other lone role is still a mistake in the file.
- **Verification is a human decision, deliberately.** A parent submits a public form
(`/claim/`) with the child's name and date of birth as **free text — no search, no
autocomplete, no confirmation of whether the child was found**, because the page is
reachable without logging in 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 rejected: a claim code needs a delivery
channel the club may not have, and matching on name plus birthday hands out someone else's
child to whoever guesses a birthday.
- **The claim form is also the registration.** Open self-registration is closed
(`club.views.signup_closed` shadows `account_signup` rather than removing the route, so the
URL name allauth's own templates reverse still resolves). Accounts are created by an admin,
by the family-registration form, or on claim approval — never by a stranger, which also
keeps the review queue from being a spam target. 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**, with the login and the family link but no
membership and no fee. If they also play, an admin flips `kind` on their membership
afterwards; approving a claim is not the place to decide it. They then get a password-reset
link and a minimal "my family" page (`members/views.py::MyFamilyView`) — the seam a real
parent portal would grow from.
- **Why a field and not a separate model.** Everything that answers "is this person attached - **Why a field and not a separate model.** Everything that answers "is this person attached
to this club" already reads through `ClubMembership` — tenancy scoping, group membership, to this club" already reads through `ClubMembership` — tenancy scoping, group membership,
the club-wide event audience — and a second kind of link would need a parallel path through the club-wide event audience — and a second kind of link would need a parallel path through

View File

@@ -251,8 +251,15 @@ class AuthFormRenderingTests(TestCase):
def test_the_password_reset_form_renders_its_fields(self): def test_the_password_reset_form_renders_its_fields(self):
self.assertContains(self.client.get(reverse("account_reset_password")), 'name="email"') self.assertContains(self.client.get(reverse("account_reset_password")), 'name="email"')
def test_the_signup_form_renders_its_fields(self): def test_self_registration_is_closed(self):
self.assertContains(self.client.get(reverse("account_signup")), 'name="password1"') # A club has no reason to let a stranger create an account: they're made by
# an admin, by the family-registration form, or by an approved parent claim
# (members/views.py). The route is shadowed rather than removed so that the
# `account_signup` name allauth's own templates reverse still resolves.
response = self.client.get(reverse("account_signup"))
self.assertEqual(response.status_code, 403)
self.assertNotContains(response, 'name="password1"', status_code=403)
class TwoFactorPageTests(TestCase): class TwoFactorPageTests(TestCase):

View File

@@ -12,6 +12,9 @@
You are signed in as <span class="font-semibold">{{ user.get_full_name|default:user.email }}</span>. You are signed in as <span class="font-semibold">{{ user.get_full_name|default:user.email }}</span>.
</p> </p>
<p class="text-sm opacity-70">The club site lands here. For now this page exists so signing in has somewhere to go.</p> <p class="text-sm opacity-70">The club site lands here. For now this page exists so signing in has somewhere to go.</p>
<div class="card-actions pt-2">
<a class="btn btn-outline btn-sm gap-2" href="{% url 'members:my_family' %}">{% lucide "users" size=14 %} My family</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect from django.shortcuts import redirect, render
from django.views.generic import TemplateView from django.views.generic import TemplateView
@@ -21,3 +21,13 @@ def root(request):
return redirect("controlpanel:dashboard") return redirect("controlpanel:dashboard")
return ClubHomeView.as_view()(request) return ClubHomeView.as_view()(request)
def signup_closed(request):
"""Self-registration is closed -- see rosterchief/urls.py.
Shadows allauth's own signup route rather than removing it, so the
`account_signup` URL name every allauth template reverses still resolves and
the login page doesn't 500 looking for it.
"""
return render(request, "account/signup_closed.html", status=403)

View File

@@ -211,8 +211,15 @@ def _parse_family_fields(raw):
family_role_raw = raw.get("family_role", "").strip() family_role_raw = raw.get("family_role", "").strip()
if not family_group: if not family_group:
# family_role=child on its own is the migration case this exists for: a
# child the club holds with no parent on file yet. It gets a family of
# its own so there is something for a parent to join later -- see
# members.services.claims. Any other lone role is still a mistake.
if family_role_raw: if family_role_raw:
errors.append(_("family_role given without a family_group.")) family_role = _match_choice(family_role_raw, FamilyMembership.FamilyRole)
if family_role == FamilyMembership.FamilyRole.CHILD:
return "", family_role, errors
errors.append(_("family_role given without a family_group. Only 'child' is allowed on its own, for a child whose parent will register later."))
return "", None, errors return "", None, errors
if not family_role_raw: if not family_role_raw:

View File

@@ -47,6 +47,9 @@ _NAV_SECTIONS = {
"role_list": "role_list", "role_list": "role_list",
"role_create": "role_list", "role_create": "role_list",
"role_revoke": "role_list", "role_revoke": "role_list",
"parent_claim_list": "parent_claim_list",
"parent_claim_approve": "parent_claim_list",
"parent_claim_reject": "parent_claim_list",
"group_list": "group_list", "group_list": "group_list",
"group_create": "group_list", "group_create": "group_list",
"group_detail": "group_list", "group_detail": "group_list",

View File

@@ -16,6 +16,9 @@
<li class="menu-title">{% trans "People" %}</li> <li class="menu-title">{% trans "People" %}</li>
<li><a class="{% if nav == 'member_list' %}menu-active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=16 %} {% trans "Members" %}</a></li> <li><a class="{% if nav == 'member_list' %}menu-active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=16 %} {% trans "Members" %}</a></li>
{% if is_club_admin %}
<li><a class="{% if nav == 'parent_claim_list' %}menu-active{% endif %}" href="{% url 'management:parent_claim_list' %}">{% lucide "inbox" size=16 %} {% trans "Parent claims" %}</a></li>
{% endif %}
{% if is_club_admin %} {% if is_club_admin %}
<li><a class="{% if nav == 'membership_list' %}menu-active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=16 %} {% trans "Memberships" %}</a></li> <li><a class="{% if nav == 'membership_list' %}menu-active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=16 %} {% trans "Memberships" %}</a></li>
<li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li> <li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li>

View File

@@ -0,0 +1,108 @@
{% extends "management/base.html" %}
{% load i18n lucide 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 %}
{% block panel %}
<div class="card bg-base-100 shadow mb-4">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "inbox" size=18 %} {% trans "Waiting for review" %}</h2>
<p class="text-sm opacity-70">
{% 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>
</div>
<div class="flex flex-wrap items-end gap-2 mt-4">
{% if claim.has_candidates %}
<form method="post" action="{% url 'management:parent_claim_approve' claim.pk %}" class="flex flex-wrap items-end gap-2">
{% csrf_token %}
<div class="min-w-64">{% form_field claim.review_form.child %}</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-end 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 %}
</div>
</div>
<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>
<p class="text-sm opacity-70">{% trans "Imported without a parent. They stay here until someone claims them." %}</p>
<ul class="divide-y divide-base-200">
{% for child in awaiting_a_parent %}
<li class="py-2 flex items-center justify-between">
<a class="link link-hover" href="{% url 'management:member_detail' child.pk %}">{{ child }}</a>
<span class="text-sm opacity-70">{{ child.date_of_birth|date:"j F Y"|default:"—" }}</span>
</li>
{% empty %}
<li class="py-2 text-sm opacity-60">{% trans "Every child has a parent linked." %}</li>
{% endfor %}
</ul>
</div>
</div>
{% if reviewed %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "history" size=18 %} {% trans "Already dealt with" %}</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Parent" %}</th>
<th>{% trans "Claimed" %}</th>
<th>{% trans "Outcome" %}</th>
<th>{% trans "Reviewed by" %}</th>
</tr>
</thead>
<tbody>
{% for claim in reviewed %}
<tr>
<td>{{ claim.parent_name }}<div class="text-xs opacity-60">{{ claim.parent_email }}</div></td>
<td>{{ claim.claimed_child_name }}</td>
<td>
{% if claim.status == "approved" %}
<span class="badge badge-success badge-sm">{% trans "Approved" %}</span>
{% if claim.child %}<div class="text-xs opacity-60">{{ claim.child }}</div>{% endif %}
{% else %}
<span class="badge badge-error badge-sm">{% trans "Rejected" %}</span>
{% if claim.note %}<div class="text-xs opacity-60">{{ claim.note }}</div>{% endif %}
{% endif %}
</td>
<td>{{ claim.reviewed_by|default:"—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% endblock panel %}

View File

@@ -25,7 +25,8 @@ from events.services.recurrence import detach_occurrence, generate_occurrences
from management.bulk_import import TEMPLATE_COLUMNS from management.bulk_import import TEMPLATE_COLUMNS
from management.pdf import PDFExportError, _tint_with_white, referee_form_colors, render_pdf 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 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 news.models import News, NewsPhoto
from shop.models import Order from shop.models import Order
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
@@ -1363,6 +1364,148 @@ class FamilyManagementTests(ManagementTestBase):
self.assertEqual(family.guardians.count(), 1) 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): class GuardianViewTests(ManagementTestBase):
"""How a guardian -- a parent attached to the club only through their child -- """How a guardian -- a parent attached to the club only through their child --
behaves across the management UI. See club.models.ClubMembership.Kind; the 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") member = Member.objects.get(email="solo.blank@example.com")
self.assertEqual(ClubMembership.objects.get(club=self.club, member=member).kind, ClubMembership.Kind.MEMBER) 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): def test_a_child_marked_as_a_guardian_is_an_error(self):
# A child is the member the guardian is attached *to*. # A child is the member the guardian is attached *to*.
upload = make_import_workbook([["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "guardian"]]) upload = make_import_workbook([["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "guardian"]])

View File

@@ -36,6 +36,9 @@ urlpatterns = [
path("roles/", views.ClubRoleListView.as_view(), name="role_list"), path("roles/", views.ClubRoleListView.as_view(), name="role_list"),
path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"), path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"),
path("roles/<uuid:pk>/revoke/", views.ClubRoleRevokeView.as_view(), name="role_revoke"), path("roles/<uuid:pk>/revoke/", views.ClubRoleRevokeView.as_view(), name="role_revoke"),
path("parent-claims/", views.ParentClaimListView.as_view(), name="parent_claim_list"),
path("parent-claims/<uuid:pk>/approve/", views.ParentClaimApproveView.as_view(), name="parent_claim_approve"),
path("parent-claims/<uuid:pk>/reject/", views.ParentClaimRejectView.as_view(), name="parent_claim_reject"),
path("groups/", views.GroupListView.as_view(), name="group_list"), path("groups/", views.GroupListView.as_view(), name="group_list"),
path("groups/new/", views.GroupCreateView.as_view(), name="group_create"), path("groups/new/", views.GroupCreateView.as_view(), name="group_create"),
path("groups/<uuid:pk>/", views.GroupDetailView.as_view(), name="group_detail"), path("groups/<uuid:pk>/", views.GroupDetailView.as_view(), name="group_detail"),

View File

@@ -38,7 +38,9 @@ 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 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 Form as FormBuilderForm
from formbuilder.models import Submission from formbuilder.models import Submission
from members.models import Family, FamilyMembership, Group, GroupMembership, Member from members.forms import 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, 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 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
from news.models import News, NewsPhoto from news.models import News, NewsPhoto
from shop.models import Discount, Invoice, Order, Product from shop.models import Discount, Invoice, Order, Product
@@ -515,6 +517,12 @@ class MemberImportConfirmView(ClubAdminRequiredMixin, View):
family = Family.objects.create() family = Family.objects.create()
families_by_group[family_group] = family families_by_group[family_group] = family
FamilyMembership.objects.create(family=family, member=member, role=family_role) FamilyMembership.objects.create(family=family, member=member, role=family_role)
elif family_role == FamilyMembership.FamilyRole.CHILD:
# A child with no family_group: nobody is on file for them yet.
# A family of their own is what makes that state visible -- it's
# what members.services.claims.families_awaiting_a_parent looks
# for, and what an approved claim adds the parent to.
FamilyMembership.objects.create(family=Family.objects.create(), member=member, role=family_role)
if season is not None: if season is not None:
ClubMembership.objects.create(club=request.club, member=member, season=season, signed_up_at=timezone.localdate(), **result["membership_kwargs"]) ClubMembership.objects.create(club=request.club, member=member, season=season, signed_up_at=timezone.localdate(), **result["membership_kwargs"])
@@ -1432,6 +1440,71 @@ class FamilyAddParentView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormVi
return redirect("management:family_detail", pk=family.pk) return redirect("management:family_detail", pk=family.pk)
class ParentClaimListView(ClubAdminRequiredMixin, ListView):
"""The review queue for parents asking to be linked to a child.
Approving is a human decision on purpose -- see members.models.ParentClaim.
Each pending claim is shown with what the parent typed *and* a shortlist of
children who have nobody on file, so the admin matches rather than searches.
"""
template_name = "management/parent_claim_list.html"
context_object_name = "claims"
def get_queryset(self):
return ParentClaim.objects.filter(club=self.request.club).select_related("child", "reviewed_by")
def get_context_data(self, **kwargs):
claims = list(self.get_queryset())
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]))
claim.has_candidates = bool(candidates)
return super().get_context_data(
pending=pending,
reviewed=[claim for claim in claims if not claim.is_pending],
awaiting_a_parent=children_awaiting_a_parent(self.request.club).order_by("last_name", "first_name"),
**kwargs,
)
class ParentClaimApproveView(ClubAdminRequiredMixin, View):
"""Link the claim's parent to the chosen child. The parent lands as a
guardian, not a member -- see members.services.claims.approve_claim."""
def post(self, request, pk):
claim = get_object_or_404(ParentClaim.objects.filter(club=request.club), pk=pk)
form = ClaimReviewForm(request.POST, candidates=children_awaiting_a_parent(request.club))
if not form.is_valid():
notify(request, f"e|{_('Could not approve')}|{_('Choose which child this claim refers to.')}")
return redirect("management:parent_claim_list")
reviewer = Member.objects.filter(user=request.user).first()
try:
approve_claim(claim, child=form.cleaned_data["child"], season=current_season(request.club), reviewed_by=reviewer)
except ClaimError as error:
notify(request, f"e|{_('Could not approve')}|{error}")
else:
body = _("%(parent)s” is now linked to %(child)s and can set up their login.") % {"parent": claim.parent_name, "child": form.cleaned_data["child"]}
notify(request, f"s|{_('Claim approved')}|{body}")
return redirect("management:parent_claim_list")
class ParentClaimRejectView(ClubAdminRequiredMixin, View):
def post(self, request, pk):
claim = get_object_or_404(ParentClaim.objects.filter(club=request.club), pk=pk)
reviewer = Member.objects.filter(user=request.user).first()
try:
reject_claim(claim, reviewed_by=reviewer, note=request.POST.get("note", "").strip())
except ClaimError as error:
notify(request, f"e|{_('Could not reject')}|{error}")
else:
notify(request, f"w|{_('Claim rejected')}|" + _("%(parent)s” was not linked.") % {"parent": claim.parent_name})
return redirect("management:parent_claim_list")
class PositionListView(ClubStaffRequiredMixin, ListView): class PositionListView(ClubStaffRequiredMixin, ListView):
"""Visible to any staff (coaches need to see positions to make sense of a """Visible to any staff (coaches need to see positions to make sense of a
roster); creating/editing positions is still ADMIN-only, gated in the roster); creating/editing positions is still ADMIN-only, gated in the

View File

@@ -1,7 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Family, FamilyMembership, Group, GroupMembership, Member from .models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
# Register your models here. # Register your models here.
@@ -87,3 +87,17 @@ class GroupMembershipAdmin(admin.ModelAdmin):
list_display = ("group", "member") list_display = ("group", "member")
autocomplete_fields = ("group", "member") autocomplete_fields = ("group", "member")
search_fields = ("group__name", "member__first_name", "member__last_name") search_fields = ("group__name", "member__first_name", "member__last_name")
@admin.register(ParentClaim)
class ParentClaimAdmin(admin.ModelAdmin):
"""Read-mostly: approving belongs in the club's own review queue
(management.views.ParentClaimListView), which links the family and creates the
account as one atomic step. Flipping `status` here would leave a claim marked
approved with nothing actually linked."""
list_display = ("parent_name", "claimed_child_name", "club", "status", "reviewed_by", "created")
list_filter = ("club", "status")
search_fields = ("parent_first_name", "parent_last_name", "parent_email", "child_first_name", "child_last_name")
raw_id_fields = ("child", "reviewed_by")
readonly_fields = ("created", "modified")

37
members/forms.py Normal file
View File

@@ -0,0 +1,37 @@
from django import forms
from django.utils.translation import gettext_lazy as _
class ParentClaimForm(forms.Form):
"""The public "link me to my child" form -- see members.models.ParentClaim.
Every child field is free text. There is deliberately no picker, no search
and no autocomplete: this page is reachable without logging in, so anything
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.
"""
parent_first_name = forms.CharField(label=_("Your first name"), max_length=150)
parent_last_name = forms.CharField(label=_("Your last name"), max_length=150)
parent_email = forms.EmailField(label=_("Your email address"), help_text=_("We'll use this to set up your login once the club has confirmed the link."))
child_first_name = forms.CharField(label=_("Child's first name"), max_length=150)
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"}))
class ClaimReviewForm(forms.Form):
"""An admin approving one claim: which child it actually refers to.
The child is chosen from the shortlist rather than typed, and the shortlist
is only ever children who have no parent on file -- approving a claim can
never quietly re-parent a child who already has one.
"""
child = forms.ModelChoiceField(queryset=None, label=_("Link to"), widget=forms.Select(attrs={"class": "select select-bordered w-full"}))
def __init__(self, *args, candidates=None, **kwargs):
super().__init__(*args, **kwargs)
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)

View File

@@ -0,0 +1,41 @@
# Generated by Django 6.0.6 on 2026-08-11 14:22
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0023_backfill_guardian_memberships'),
('members', '0004_group_groupmembership_and_more'),
]
operations = [
migrations.CreateModel(
name='ParentClaim',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('parent_first_name', models.CharField(max_length=150, verbose_name='parent first name')),
('parent_last_name', models.CharField(max_length=150, verbose_name='parent last name')),
('parent_email', models.EmailField(max_length=254, verbose_name='parent email')),
('child_first_name', models.CharField(max_length=150, verbose_name='child first name')),
('child_last_name', models.CharField(max_length=150, verbose_name='child last name')),
('child_date_of_birth', models.DateField(verbose_name='child date of birth')),
('status', models.CharField(choices=[('pending', 'pending'), ('approved', 'approved'), ('rejected', 'rejected')], default='pending', max_length=20, verbose_name='status')),
('reviewed_at', models.DateTimeField(blank=True, null=True, verbose_name='reviewed at')),
('note', models.TextField(blank=True, help_text='Why it was rejected, or anything worth recording about the decision.', verbose_name='note')),
('child', models.ForeignKey(blank=True, help_text='Set when an admin approves the claim -- the child it was matched to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parent_claims', to='members.member', verbose_name='matched child')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='members.member', verbose_name='reviewed by')),
],
options={
'verbose_name': 'parent claim',
'verbose_name_plural': 'parent claims',
'ordering': ['-created'],
},
),
]

View File

@@ -101,6 +101,69 @@ class FamilyMembership(models.Model):
return f"{self.family} - {self.member} ({self.get_role_display()})" return f"{self.family} - {self.member} ({self.get_role_display()})"
class ParentClaim(ClubScopedModel):
"""A parent asking to be linked to a child the club already has on file.
The migration path this exists for: a club arrives with a list of children
from a federation export and no parent records at all. The children are
imported without logins (each into a family of their own, see
members.services.claims.children_awaiting_a_parent), and parents come forward
afterwards.
Verification is a human decision, deliberately. The alternatives -- a claim
code, or matching on name and date of birth -- either need a delivery channel
the club may not have, or hand out someone else's child to whoever guesses a
birthday. The club already knows its own families, so an admin approving from
a queue is the only check that is actually worth anything.
The parent's details are held here as plain text rather than as a User: this
form is public, so creating an account per submission would let anyone fill
the table with them. The account is created on approval, by which point a
human has vouched for it.
"""
class Status(models.TextChoices):
PENDING = "pending", _("pending")
APPROVED = "approved", _("approved")
REJECTED = "rejected", _("rejected")
parent_first_name = models.CharField(_("parent first name"), max_length=150)
parent_last_name = models.CharField(_("parent last name"), max_length=150)
parent_email = models.EmailField(_("parent email"))
# 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)
child_last_name = models.CharField(_("child last name"), max_length=150)
child_date_of_birth = models.DateField(_("child date of birth"))
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.PENDING)
child = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, blank=True, related_name="parent_claims", verbose_name=_("matched child"), help_text=_("Set when an admin approves the claim -- the child it was matched to."))
reviewed_by = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, blank=True, related_name="+", verbose_name=_("reviewed by"))
reviewed_at = models.DateTimeField(_("reviewed at"), null=True, blank=True)
note = models.TextField(_("note"), blank=True, help_text=_("Why it was rejected, or anything worth recording about the decision."))
class Meta:
verbose_name = _("parent claim")
verbose_name_plural = _("parent claims")
ordering = ["-created"]
def __str__(self):
return f"{self.parent_first_name} {self.parent_last_name} -> {self.child_first_name} {self.child_last_name}"
@property
def is_pending(self) -> bool:
return self.status == self.Status.PENDING
@property
def claimed_child_name(self) -> str:
return f"{self.child_first_name} {self.child_last_name}".strip()
@property
def parent_name(self) -> str:
return f"{self.parent_first_name} {self.parent_last_name}".strip()
class Group(ClubScopedModel): class Group(ClubScopedModel):
"""An arbitrary named collection of members -- deliberately generic, not """An arbitrary named collection of members -- deliberately generic, not
team-shaped and not aware of any specific use: "all coaches", "all team team-shaped and not aware of any specific use: "all coaches", "all team

141
members/services/claims.py Normal file
View File

@@ -0,0 +1,141 @@
"""Linking a parent to a child the club already holds.
The initial-migration path: a club arrives with a list of children and no parent
records. They're imported without logins, each into a family of their own, and
parents come forward afterwards through the public claim form. An admin matches
each claim against a real child and approves it, which is when the account is
created and the family link made.
Why an admin decides: see members.models.ParentClaim. The short version is that
the club is the only party that actually knows its families, and the public form
must never confirm whether a given child exists -- so it takes free text and
matches nothing itself.
"""
from django.db import transaction
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
from club.models import ClubMembership
from members.models import Family, FamilyMembership, Member, ParentClaim
from members.services.family import add_parent_to_family
#: Suggestions are ranked, never auto-applied -- an exact name-and-birthday match
#: is still only a suggestion, because the whole point of the queue is that a
#: human confirms it.
GUARDIAN_ROLES = (FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN)
def families_awaiting_a_parent(club):
"""Families in ``club`` that have children on them but nobody responsible.
That shape *is* the state -- there's no "unclaimed" flag to drift out of step
with reality. A child imported on their own gets a family of one (see
management/bulk_import.py), and the moment a claim is approved a parent joins
it, so the family drops out of here by itself.
"""
# Exists subqueries rather than annotate(Count(..., filter=...)): the club
# filter and the counts would otherwise share one join, so a parent with no
# ClubMembership of their own -- which is exactly what a newly linked
# guardian is before the season row lands -- wouldn't be counted, and the
# family would look unclaimed forever.
child_in_this_club = FamilyMembership.objects.filter(family=OuterRef("pk"), role=FamilyMembership.FamilyRole.CHILD, member__member_of__club=club)
somebody_responsible = FamilyMembership.objects.filter(family=OuterRef("pk"), role__in=GUARDIAN_ROLES)
return Family.objects.filter(Exists(child_in_this_club)).filter(~Exists(somebody_responsible))
def children_awaiting_a_parent(club):
"""The children on those families -- the admin's worklist, and the set a
claim may be matched against."""
return Member.objects.filter(
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
family_memberships__family__in=families_awaiting_a_parent(club),
member_of__club=club,
member_of__kind=ClubMembership.Kind.MEMBER,
).distinct()
def submit_claim(club, *, parent_first_name, parent_last_name, parent_email, child_first_name, child_last_name, child_date_of_birth):
"""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."""
return ParentClaim.objects.create(
club=club,
parent_first_name=parent_first_name.strip(),
parent_last_name=parent_last_name.strip(),
parent_email=parent_email.strip().lower(),
child_first_name=child_first_name.strip(),
child_last_name=child_last_name.strip(),
child_date_of_birth=child_date_of_birth,
)
def suggested_children(claim):
"""Children the claim plausibly refers to, best first.
Only ever a shortlist for the admin to choose from. Ordered by how much of
the claim matches, but an exact hit on both name and birthday still has to be
confirmed -- a birthday is not a secret, and the queue exists precisely so
that guessing one isn't enough.
"""
candidates = children_awaiting_a_parent(claim.club).filter(Q(last_name__iexact=claim.child_last_name) | Q(date_of_birth=claim.child_date_of_birth))
def score(child):
return (
child.last_name.lower() == claim.child_last_name.lower(),
child.date_of_birth == claim.child_date_of_birth,
child.first_name.lower() == claim.child_first_name.lower(),
)
return sorted(candidates, key=lambda child: sum(score(child)), reverse=True)
class ClaimError(Exception):
"""A claim could not be approved."""
@transaction.atomic
def approve_claim(claim, *, child, season, reviewed_by=None):
"""Link the claim's parent to ``child`` and close the claim.
The parent lands as a *guardian* (club.models.ClubMembership.Kind): they get
the login and the family link, but they aren't a member and owe no fee. If
they also play, an admin flips that on their membership afterwards --
approving a claim is not the place to decide it.
"""
if not claim.is_pending:
raise ClaimError("This claim has already been dealt with.")
family = child.family_memberships.first()
if family is None:
raise ClaimError("That child is not in a family, so there is nothing to join.")
add_parent_to_family(
claim.club,
season,
family.family,
email=claim.parent_email,
first_name=claim.parent_first_name,
last_name=claim.parent_last_name,
)
claim.status = ParentClaim.Status.APPROVED
claim.child = child
claim.reviewed_by = reviewed_by
claim.reviewed_at = timezone.now()
claim.save(update_fields=["status", "child", "reviewed_by", "reviewed_at"])
return claim
def reject_claim(claim, *, reviewed_by=None, note=""):
if not claim.is_pending:
raise ClaimError("This claim has already been dealt with.")
claim.status = ParentClaim.Status.REJECTED
claim.reviewed_by = reviewed_by
claim.reviewed_at = timezone.now()
claim.note = note
claim.save(update_fields=["status", "reviewed_by", "reviewed_at", "note"])
return claim

View File

@@ -0,0 +1,27 @@
{% extends "_club_base.html" %}
{% load i18n lucide %}
{% block head_title %}{% trans "My family" %}{% endblock head_title %}
{% block main %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body">
<h1 class="card-title">{% lucide "users" size=20 %} {% trans "My family" %}</h1>
<ul class="divide-y divide-base-200">
{% for child in children %}
<li class="py-2">
<span class="font-semibold">{{ child }}</span>
{% if child.date_of_birth %}<span class="text-sm opacity-70">— {{ child.date_of_birth|date:"j F Y" }}</span>{% endif %}
</li>
{% empty %}
<li class="py-2 text-sm opacity-70">
{% blocktrans %}Nobody is linked to you yet. If your child plays here, ask the club to link you.{% endblocktrans %}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endblock main %}

View File

@@ -0,0 +1,43 @@
{% extends "_club_base.html" %}
{% load i18n lucide ui %}
{% block head_title %}{% trans "Link me to my child" %}{% endblock head_title %}
{% block main %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body">
<h1 class="card-title">{% lucide "users" size=20 %} {% blocktrans with club=club.name %}Link me to my child at {{ club }}{% endblocktrans %}</h1>
<p class="text-sm opacity-70">
{% blocktrans %}If your child already plays here, fill this in and the club will check it against their records. Once they confirm, you'll get an email to set up your login.{% endblocktrans %}
</p>
<form method="post" class="mt-2">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2"><span>{{ error }}</span></div>
{% 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 %}
<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">
{% form_field form.child_first_name %}
{% form_field form.child_last_name %}
</div>
{% form_field form.child_date_of_birth %}
<div class="card-actions justify-start pt-4">
<button class="btn btn-primary gap-2" type="submit">{% lucide "send" size=16 %} {% trans "Send to the club" %}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock main %}

View File

@@ -0,0 +1,20 @@
{% extends "_club_base.html" %}
{% load i18n lucide %}
{% block head_title %}{% trans "Request sent" %}{% endblock head_title %}
{% block main %}
{% comment %}
Says the same thing whether or not the child was found: this page is public,
so confirming a match would let anyone test which children the club has.
{% endcomment %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body">
<h1 class="card-title">{% lucide "circle-check" size=20 %} {% trans "Request sent" %}</h1>
<p>{% blocktrans with club=club.name %}Thanks — {{ club }} will check this against their records.{% endblocktrans %}</p>
<p class="text-sm opacity-70">{% blocktrans %}If it matches, you'll get an email with a link to set your password. If you don't hear anything, get in touch with the club directly.{% endblocktrans %}</p>
</div>
</div>
</div>
{% endblock main %}

View File

@@ -1,3 +1,4 @@
import datetime
import tempfile import tempfile
from datetime import date, timedelta from datetime import date, timedelta
from io import StringIO from io import StringIO
@@ -5,6 +6,7 @@ from pathlib import Path
from allauth.mfa.models import Authenticator from allauth.mfa.models import Authenticator
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import CommandError from django.core.management.base import CommandError
from django.db import IntegrityError from django.db import IntegrityError
@@ -15,8 +17,9 @@ from django.utils import timezone
from authentication.models import User from authentication.models import User
from club.models import Club, ClubMembership, Season from club.models import Club, ClubMembership, Season
from members.admin import FamilyAdmin from members.admin import FamilyAdmin
from members.models import Family, FamilyMembership, Group, GroupMembership, Member from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
from members.services import MemberImportResult from members.services import MemberImportResult
from members.services.claims import ClaimError, approve_claim, children_awaiting_a_parent, reject_claim, submit_claim, suggested_children
class MemberModelTests(TestCase): class MemberModelTests(TestCase):
@@ -683,3 +686,123 @@ class MemberImportResultTests(TestCase):
def test_successful_rows_sums_created_and_updated(self): def test_successful_rows_sums_created_and_updated(self):
result = MemberImportResult(created_members=2, updated_members=3) result = MemberImportResult(created_members=2, updated_members=3)
self.assertEqual(result.successful_rows, 5) self.assertEqual(result.successful_rows, 5)
class ParentClaimTests(TestCase):
"""The onboarding path for a club that arrives with a list of children and no
parent records -- see members.services.claims."""
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
today = timezone.localdate()
cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + datetime.timedelta(days=300))
# An imported child: no login, and a family of their own with nobody on it.
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)
cls.family = Family.objects.create()
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
def make_claim(self, **overrides):
details = {
"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": datetime.date(2014, 3, 2),
}
details.update(overrides)
return submit_claim(self.club, **details)
def test_a_child_with_no_parent_is_on_the_worklist(self):
self.assertIn(self.child, children_awaiting_a_parent(self.club))
def test_a_child_who_has_a_parent_is_not(self):
parent = Member.objects.create(first_name="Taylor", last_name="Doe")
FamilyMembership.objects.create(family=self.family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
self.assertNotIn(self.child, children_awaiting_a_parent(self.club))
def test_submitting_records_a_pending_claim_without_matching_anything(self):
# The form is public, so it must not resolve the child -- doing so would
# let an anonymous submitter test which children the club has.
claim = self.make_claim(child_last_name="Nonexistent")
self.assertTrue(claim.is_pending)
self.assertIsNone(claim.child)
def test_suggestions_rank_the_real_child_first(self):
claim = self.make_claim()
self.assertEqual(suggested_children(claim)[0], self.child)
def test_suggestions_never_include_a_child_who_already_has_a_parent(self):
parent = Member.objects.create(first_name="Existing", last_name="Doe")
FamilyMembership.objects.create(family=self.family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
claim = self.make_claim()
self.assertEqual(suggested_children(claim), [])
def test_approving_links_the_parent_as_a_guardian(self):
claim = self.make_claim()
approve_claim(claim, child=self.child, season=self.season)
parent = Member.objects.get(user__email="taylor.doe@example.com")
self.assertEqual(FamilyMembership.objects.get(family=self.family, member=parent).role, FamilyMembership.FamilyRole.PARENT)
# A guardian, not a member: they hold the login but owe no fee and are
# not counted in the club's roll.
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
def test_approving_gives_the_parent_an_unusable_password_to_reset(self):
claim = self.make_claim()
approve_claim(claim, child=self.child, season=self.season)
user = get_user_model().objects.get(email="taylor.doe@example.com")
self.assertFalse(user.has_usable_password())
def test_approving_closes_the_claim_and_records_the_match(self):
claim = self.make_claim()
approve_claim(claim, child=self.child, season=self.season)
claim.refresh_from_db()
self.assertEqual(claim.status, ParentClaim.Status.APPROVED)
self.assertEqual(claim.child, self.child)
self.assertIsNotNone(claim.reviewed_at)
def test_an_approved_child_leaves_the_worklist(self):
claim = self.make_claim()
approve_claim(claim, child=self.child, season=self.season)
# The state is the shape of the data, so it corrects itself rather than
# needing a flag cleared.
self.assertNotIn(self.child, children_awaiting_a_parent(self.club))
def test_a_claim_cannot_be_approved_twice(self):
claim = self.make_claim()
approve_claim(claim, child=self.child, season=self.season)
with self.assertRaises(ClaimError):
approve_claim(claim, child=self.child, season=self.season)
def test_rejecting_records_the_reason_and_links_nobody(self):
claim = self.make_claim()
reject_claim(claim, note="Not on our records.")
claim.refresh_from_db()
self.assertEqual(claim.status, ParentClaim.Status.REJECTED)
self.assertEqual(claim.note, "Not on our records.")
self.assertFalse(Member.objects.filter(user__email="taylor.doe@example.com").exists())
def test_a_rejected_claim_cannot_then_be_approved(self):
claim = self.make_claim()
reject_claim(claim)
with self.assertRaises(ClaimError):
approve_claim(claim, child=self.child, season=self.season)

10
members/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "members"
urlpatterns = [
path("claim/", views.ParentClaimView.as_view(), name="parent_claim"),
path("my-family/", views.MyFamilyView.as_view(), name="my_family"),
]

View File

@@ -1 +1,63 @@
# Create your views here. """Public and member-facing pages: claiming a child, and a parent's own family.
Everything here is outside the management app on purpose -- a parent is not club
staff, and ClubStaffRequiredMixin would (rightly) turn them away.
"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404
from django.shortcuts import render
from django.views.generic import FormView, TemplateView
from members.forms import ParentClaimForm
from members.models import FamilyMembership, Member
from members.services.claims import submit_claim
class ClubScopedPublicMixin:
"""A club subdomain resolves this page; the base domain has no club to claim
a child at, so it simply doesn't exist there."""
def dispatch(self, request, *args, **kwargs):
if getattr(request, "club", None) is None:
raise Http404("This page belongs to a club.")
return super().dispatch(request, *args, **kwargs)
class ParentClaimView(ClubScopedPublicMixin, FormView):
"""Public. Submitting is also how a parent registers -- there is no open
signup page, so this form is the only way into an account for someone the
club hasn't already added.
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.
"""
template_name = "members/parent_claim.html"
form_class = ParentClaimForm
def form_valid(self, form):
submit_claim(self.request.club, **form.cleaned_data)
return render(self.request, "members/parent_claim_submitted.html", {"club": self.request.club})
class MyFamilyView(ClubScopedPublicMixin, LoginRequiredMixin, TemplateView):
"""What a linked parent sees: the children they're responsible for, and
nothing else. The seam a real parent portal would grow from."""
template_name = "members/my_family.html"
def get_context_data(self, **kwargs):
me = Member.objects.filter(user=self.request.user).first()
children = Member.objects.none()
if me is not None:
children = Member.objects.filter(
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
family_memberships__family__memberships__member=me,
family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
member_of__club=self.request.club,
).distinct()
return super().get_context_data(club=self.request.club, me=me, children=children, **kwargs)

View File

@@ -16,7 +16,7 @@ from django.views.generic import RedirectView
from django.views.static import serve from django.views.static import serve
from api.urls import api from api.urls import api
from club.views import root from club.views import root, signup_closed
from .health import healthz from .health import healthz
@@ -25,7 +25,13 @@ urlpatterns = [
path("healthz", healthz, name="healthz"), path("healthz", healthz, name="healthz"),
path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"), path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
# Before allauth's own urls so it wins the match: self-registration is closed.
# Accounts are created by an admin, by the family-registration form, or by an
# approved parent claim (members/views.py) -- a club has no reason to let a
# stranger create one, and the claim queue would be the first thing to suffer.
path("accounts/signup/", signup_closed, name="account_signup"),
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
path("", include("members.urls")),
path("controlpanel/", include("controlpanel.urls")), path("controlpanel/", include("controlpanel.urls")),
path("manage/", include("management.urls")), path("manage/", include("management.urls")),
path("api/v1/", api.urls), path("api/v1/", api.urls),

View File

@@ -3611,6 +3611,9 @@
.min-w-0 { .min-w-0 {
min-width: 0; min-width: 0;
} }
.min-w-64 {
min-width: calc(var(--spacing) * 64);
}
.flex-1 { .flex-1 {
flex: 1; flex: 1;
} }
@@ -4051,6 +4054,9 @@
.pt-3 { .pt-3 {
padding-top: calc(var(--spacing) * 3); padding-top: calc(var(--spacing) * 3);
} }
.pt-4 {
padding-top: calc(var(--spacing) * 4);
}
.pb-2 { .pb-2 {
padding-bottom: calc(var(--spacing) * 2); padding-bottom: calc(var(--spacing) * 2);
} }

View File

@@ -0,0 +1,20 @@
{% extends "_base.html" %}
{% load i18n lucide %}
{% block head_title %}{% trans "Registration is closed" %}{% endblock head_title %}
{% block main %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body">
<h1 class="card-title">{% lucide "lock-keyhole" size=20 %} {% trans "Registration is closed" %}</h1>
<p>{% blocktrans %}Accounts here are set up by the club, not by signing up.{% endblocktrans %}</p>
<p class="text-sm opacity-70">{% blocktrans %}If your child plays at this club, use the link-my-child form. Otherwise ask the club to add you.{% endblocktrans %}</p>
<div class="card-actions pt-2">
<a class="btn btn-primary gap-2" href="{% url 'members:parent_claim' %}">{% lucide "users" size=16 %} {% trans "Link me to my child" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'account_login' %}">{% lucide "log-in" size=16 %} {% trans "Sign in" %}</a>
</div>
</div>
</div>
</div>
{% endblock main %}