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:
@@ -423,6 +423,33 @@ ClubMembership(ClubScopedModel) # -> carries `club`
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -251,8 +251,15 @@ class AuthFormRenderingTests(TestCase):
|
||||
def test_the_password_reset_form_renders_its_fields(self):
|
||||
self.assertContains(self.client.get(reverse("account_reset_password")), 'name="email"')
|
||||
|
||||
def test_the_signup_form_renders_its_fields(self):
|
||||
self.assertContains(self.client.get(reverse("account_signup")), 'name="password1"')
|
||||
def test_self_registration_is_closed(self):
|
||||
# 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):
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
You are signed in as <span class="font-semibold">{{ user.get_full_name|default:user.email }}</span>.
|
||||
</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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.shortcuts import redirect
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
|
||||
@@ -21,3 +21,13 @@ def root(request):
|
||||
return redirect("controlpanel:dashboard")
|
||||
|
||||
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)
|
||||
|
||||
@@ -211,8 +211,15 @@ def _parse_family_fields(raw):
|
||||
family_role_raw = raw.get("family_role", "").strip()
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
if not family_role_raw:
|
||||
|
||||
@@ -47,6 +47,9 @@ _NAV_SECTIONS = {
|
||||
"role_list": "role_list",
|
||||
"role_create": "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_create": "group_list",
|
||||
"group_detail": "group_list",
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
|
||||
<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>
|
||||
{% 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 %}
|
||||
<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>
|
||||
|
||||
108
management/templates/management/parent_claim_list.html
Normal file
108
management/templates/management/parent_claim_list.html
Normal 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 %}
|
||||
@@ -25,7 +25,8 @@ from events.services.recurrence import detach_occurrence, generate_occurrences
|
||||
from management.bulk_import import TEMPLATE_COLUMNS
|
||||
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 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 shop.models import Order
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||
@@ -1363,6 +1364,148 @@ class FamilyManagementTests(ManagementTestBase):
|
||||
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):
|
||||
"""How a guardian -- a parent attached to the club only through their child --
|
||||
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")
|
||||
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):
|
||||
# A child is the member the guardian is attached *to*.
|
||||
upload = make_import_workbook([["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "guardian"]])
|
||||
|
||||
@@ -36,6 +36,9 @@ urlpatterns = [
|
||||
path("roles/", views.ClubRoleListView.as_view(), name="role_list"),
|
||||
path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"),
|
||||
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/new/", views.GroupCreateView.as_view(), name="group_create"),
|
||||
path("groups/<uuid:pk>/", views.GroupDetailView.as_view(), name="group_detail"),
|
||||
|
||||
@@ -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 formbuilder.models import Form as FormBuilderForm
|
||||
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 news.models import News, NewsPhoto
|
||||
from shop.models import Discount, Invoice, Order, Product
|
||||
@@ -515,6 +517,12 @@ class MemberImportConfirmView(ClubAdminRequiredMixin, View):
|
||||
family = Family.objects.create()
|
||||
families_by_group[family_group] = family
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
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.
|
||||
@@ -87,3 +87,17 @@ class GroupMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ("group", "member")
|
||||
autocomplete_fields = ("group", "member")
|
||||
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
37
members/forms.py
Normal 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)
|
||||
41
members/migrations/0005_parent_claim.py
Normal file
41
members/migrations/0005_parent_claim.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -101,6 +101,69 @@ class FamilyMembership(models.Model):
|
||||
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):
|
||||
"""An arbitrary named collection of members -- deliberately generic, not
|
||||
team-shaped and not aware of any specific use: "all coaches", "all team
|
||||
|
||||
141
members/services/claims.py
Normal file
141
members/services/claims.py
Normal 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
|
||||
27
members/templates/members/my_family.html
Normal file
27
members/templates/members/my_family.html
Normal 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 %}
|
||||
43
members/templates/members/parent_claim.html
Normal file
43
members/templates/members/parent_claim.html
Normal 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 %}
|
||||
20
members/templates/members/parent_claim_submitted.html
Normal file
20
members/templates/members/parent_claim_submitted.html
Normal 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 %}
|
||||
125
members/tests.py
125
members/tests.py
@@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
import tempfile
|
||||
from datetime import date, timedelta
|
||||
from io import StringIO
|
||||
@@ -5,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
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.base import CommandError
|
||||
from django.db import IntegrityError
|
||||
@@ -15,8 +17,9 @@ from django.utils import timezone
|
||||
from authentication.models import User
|
||||
from club.models import Club, ClubMembership, Season
|
||||
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.claims import ClaimError, approve_claim, children_awaiting_a_parent, reject_claim, submit_claim, suggested_children
|
||||
|
||||
|
||||
class MemberModelTests(TestCase):
|
||||
@@ -683,3 +686,123 @@ class MemberImportResultTests(TestCase):
|
||||
def test_successful_rows_sums_created_and_updated(self):
|
||||
result = MemberImportResult(created_members=2, updated_members=3)
|
||||
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
10
members/urls.py
Normal 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"),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.views.generic import RedirectView
|
||||
from django.views.static import serve
|
||||
|
||||
from api.urls import api
|
||||
from club.views import root
|
||||
from club.views import root, signup_closed
|
||||
|
||||
from .health import healthz
|
||||
|
||||
@@ -25,7 +25,13 @@ urlpatterns = [
|
||||
path("healthz", healthz, name="healthz"),
|
||||
path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"),
|
||||
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("", include("members.urls")),
|
||||
path("controlpanel/", include("controlpanel.urls")),
|
||||
path("manage/", include("management.urls")),
|
||||
path("api/v1/", api.urls),
|
||||
|
||||
@@ -3611,6 +3611,9 @@
|
||||
.min-w-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
.min-w-64 {
|
||||
min-width: calc(var(--spacing) * 64);
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -4051,6 +4054,9 @@
|
||||
.pt-3 {
|
||||
padding-top: calc(var(--spacing) * 3);
|
||||
}
|
||||
.pt-4 {
|
||||
padding-top: calc(var(--spacing) * 4);
|
||||
}
|
||||
.pb-2 {
|
||||
padding-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
|
||||
20
templates/account/signup_closed.html
Normal file
20
templates/account/signup_closed.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user