From ca2b1a11b546c8089cc82655ecebd75d51006ebb Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Tue, 11 Aug 2026 18:18:26 +0200 Subject: [PATCH] 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 --- ARCHITECTURE.md | 27 +++ authentication/tests.py | 11 +- club/templates/club/home.html | 3 + club/views.py | 12 +- management/bulk_import.py | 9 +- management/context_processors.py | 3 + .../templates/management/_nav_items.html | 3 + .../management/parent_claim_list.html | 108 +++++++++++ management/tests.py | 168 +++++++++++++++++- management/urls.py | 3 + management/views.py | 75 +++++++- members/admin.py | 16 +- members/forms.py | 37 ++++ members/migrations/0005_parent_claim.py | 41 +++++ members/models.py | 63 +++++++ members/services/claims.py | 141 +++++++++++++++ members/templates/members/my_family.html | 27 +++ members/templates/members/parent_claim.html | 43 +++++ .../members/parent_claim_submitted.html | 20 +++ members/tests.py | 125 ++++++++++++- members/urls.py | 10 ++ members/views.py | 64 ++++++- rosterchief/urls.py | 8 +- static/css/app.css | 6 + templates/account/signup_closed.html | 20 +++ 25 files changed, 1033 insertions(+), 10 deletions(-) create mode 100644 management/templates/management/parent_claim_list.html create mode 100644 members/forms.py create mode 100644 members/migrations/0005_parent_claim.py create mode 100644 members/services/claims.py create mode 100644 members/templates/members/my_family.html create mode 100644 members/templates/members/parent_claim.html create mode 100644 members/templates/members/parent_claim_submitted.html create mode 100644 members/urls.py create mode 100644 templates/account/signup_closed.html diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7570243..8721133 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/authentication/tests.py b/authentication/tests.py index 4d06b23..11b421e 100644 --- a/authentication/tests.py +++ b/authentication/tests.py @@ -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): diff --git a/club/templates/club/home.html b/club/templates/club/home.html index f6ce595..ef0affc 100644 --- a/club/templates/club/home.html +++ b/club/templates/club/home.html @@ -12,6 +12,9 @@ You are signed in as {{ user.get_full_name|default:user.email }}.

The club site lands here. For now this page exists so signing in has somewhere to go.

+ diff --git a/club/views.py b/club/views.py index 3a5a18b..db18beb 100644 --- a/club/views.py +++ b/club/views.py @@ -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) diff --git a/management/bulk_import.py b/management/bulk_import.py index a042aa8..a0db564 100644 --- a/management/bulk_import.py +++ b/management/bulk_import.py @@ -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: diff --git a/management/context_processors.py b/management/context_processors.py index 63905fa..2bb6a18 100644 --- a/management/context_processors.py +++ b/management/context_processors.py @@ -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", diff --git a/management/templates/management/_nav_items.html b/management/templates/management/_nav_items.html index 650d98e..d79f873 100644 --- a/management/templates/management/_nav_items.html +++ b/management/templates/management/_nav_items.html @@ -16,6 +16,9 @@
  • {% lucide "users" size=16 %} {% trans "Members" %}
  • +{% if is_club_admin %} +
  • {% lucide "inbox" size=16 %} {% trans "Parent claims" %}
  • +{% endif %} {% if is_club_admin %}
  • {% lucide "wallet" size=16 %} {% trans "Memberships" %}
  • {% lucide "shield-check" size=16 %} {% trans "Roles" %}
  • diff --git a/management/templates/management/parent_claim_list.html b/management/templates/management/parent_claim_list.html new file mode 100644 index 0000000..30e5e8b --- /dev/null +++ b/management/templates/management/parent_claim_list.html @@ -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 %} +
    +
    +

    {% lucide "inbox" size=18 %} {% trans "Waiting for review" %}

    +

    + {% 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 %} +

    + + {% for claim in pending %} +
    +
    +
    +
    {% trans "Parent says they are" %}
    +
    {{ claim.parent_name }}
    +
    {{ claim.parent_email }}
    +
    +
    +
    {% trans "Parent of" %}
    +
    {{ claim.claimed_child_name }}
    +
    {{ claim.child_date_of_birth|date:"j F Y" }}
    +
    +
    + +
    + {% if claim.has_candidates %} +
    + {% csrf_token %} +
    {% form_field claim.review_form.child %}
    + +
    + {% else %} +

    {% trans "No child without a parent matches this. Check the spelling and the date of birth with them before rejecting." %}

    + {% endif %} + +
    + {% csrf_token %} + + +
    +
    +
    + {% empty %} +

    {% trans "Nothing waiting." %}

    + {% endfor %} +
    +
    + +
    +
    +

    {% lucide "user-search" size=18 %} {% trans "Children with nobody on file" %}

    +

    {% trans "Imported without a parent. They stay here until someone claims them." %}

    +
      + {% for child in awaiting_a_parent %} +
    • + {{ child }} + {{ child.date_of_birth|date:"j F Y"|default:"—" }} +
    • + {% empty %} +
    • {% trans "Every child has a parent linked." %}
    • + {% endfor %} +
    +
    +
    + + {% if reviewed %} +
    +
    +

    {% lucide "history" size=18 %} {% trans "Already dealt with" %}

    +
    + + + + + + + + + + + {% for claim in reviewed %} + + + + + + + {% endfor %} + +
    {% trans "Parent" %}{% trans "Claimed" %}{% trans "Outcome" %}{% trans "Reviewed by" %}
    {{ claim.parent_name }}
    {{ claim.parent_email }}
    {{ claim.claimed_child_name }} + {% if claim.status == "approved" %} + {% trans "Approved" %} + {% if claim.child %}
    {{ claim.child }}
    {% endif %} + {% else %} + {% trans "Rejected" %} + {% if claim.note %}
    {{ claim.note }}
    {% endif %} + {% endif %} +
    {{ claim.reviewed_by|default:"—" }}
    +
    +
    +
    + {% endif %} +{% endblock panel %} diff --git a/management/tests.py b/management/tests.py index d9cae01..0216631 100644 --- a/management/tests.py +++ b/management/tests.py @@ -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"]]) diff --git a/management/urls.py b/management/urls.py index 4bc3441..c841469 100644 --- a/management/urls.py +++ b/management/urls.py @@ -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//revoke/", views.ClubRoleRevokeView.as_view(), name="role_revoke"), + path("parent-claims/", views.ParentClaimListView.as_view(), name="parent_claim_list"), + path("parent-claims//approve/", views.ParentClaimApproveView.as_view(), name="parent_claim_approve"), + path("parent-claims//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//", views.GroupDetailView.as_view(), name="group_detail"), diff --git a/management/views.py b/management/views.py index 70eabc2..4bdfbe1 100644 --- a/management/views.py +++ b/management/views.py @@ -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 diff --git a/members/admin.py b/members/admin.py index 5839024..9e4a246 100644 --- a/members/admin.py +++ b/members/admin.py @@ -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") diff --git a/members/forms.py b/members/forms.py new file mode 100644 index 0000000..1fbfc33 --- /dev/null +++ b/members/forms.py @@ -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) diff --git a/members/migrations/0005_parent_claim.py b/members/migrations/0005_parent_claim.py new file mode 100644 index 0000000..5f12484 --- /dev/null +++ b/members/migrations/0005_parent_claim.py @@ -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'], + }, + ), + ] diff --git a/members/models.py b/members/models.py index ddd4ce2..92e3e60 100644 --- a/members/models.py +++ b/members/models.py @@ -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 diff --git a/members/services/claims.py b/members/services/claims.py new file mode 100644 index 0000000..df4ef80 --- /dev/null +++ b/members/services/claims.py @@ -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 diff --git a/members/templates/members/my_family.html b/members/templates/members/my_family.html new file mode 100644 index 0000000..d2dbed0 --- /dev/null +++ b/members/templates/members/my_family.html @@ -0,0 +1,27 @@ +{% extends "_club_base.html" %} +{% load i18n lucide %} + +{% block head_title %}{% trans "My family" %}{% endblock head_title %} + +{% block main %} +
    +
    +
    +

    {% lucide "users" size=20 %} {% trans "My family" %}

    + +
      + {% for child in children %} +
    • + {{ child }} + {% if child.date_of_birth %}— {{ child.date_of_birth|date:"j F Y" }}{% endif %} +
    • + {% empty %} +
    • + {% blocktrans %}Nobody is linked to you yet. If your child plays here, ask the club to link you.{% endblocktrans %} +
    • + {% endfor %} +
    +
    +
    +
    +{% endblock main %} diff --git a/members/templates/members/parent_claim.html b/members/templates/members/parent_claim.html new file mode 100644 index 0000000..3332856 --- /dev/null +++ b/members/templates/members/parent_claim.html @@ -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 %} +
    +
    +
    +

    {% lucide "users" size=20 %} {% blocktrans with club=club.name %}Link me to my child at {{ club }}{% endblocktrans %}

    +

    + {% 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 %} +

    + +
    + {% csrf_token %} + + {% for error in form.non_field_errors %} +
    {{ error }}
    + {% endfor %} + +

    {% trans "About you" %}

    +
    + {% form_field form.parent_first_name %} + {% form_field form.parent_last_name %} +
    + {% form_field form.parent_email %} + +

    {% trans "About your child" %}

    +
    + {% form_field form.child_first_name %} + {% form_field form.child_last_name %} +
    + {% form_field form.child_date_of_birth %} + +
    + +
    +
    +
    +
    +
    +{% endblock main %} diff --git a/members/templates/members/parent_claim_submitted.html b/members/templates/members/parent_claim_submitted.html new file mode 100644 index 0000000..5cebfb1 --- /dev/null +++ b/members/templates/members/parent_claim_submitted.html @@ -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 %} +
    +
    +
    +

    {% lucide "circle-check" size=20 %} {% trans "Request sent" %}

    +

    {% blocktrans with club=club.name %}Thanks — {{ club }} will check this against their records.{% endblocktrans %}

    +

    {% 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 %}

    +
    +
    +
    +{% endblock main %} diff --git a/members/tests.py b/members/tests.py index b042401..136bdda 100644 --- a/members/tests.py +++ b/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) diff --git a/members/urls.py b/members/urls.py new file mode 100644 index 0000000..f2b2153 --- /dev/null +++ b/members/urls.py @@ -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"), +] diff --git a/members/views.py b/members/views.py index 60f00ef..dde2340 100644 --- a/members/views.py +++ b/members/views.py @@ -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) + diff --git a/rosterchief/urls.py b/rosterchief/urls.py index d4c2ec2..fe06a97 100644 --- a/rosterchief/urls.py +++ b/rosterchief/urls.py @@ -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), diff --git a/static/css/app.css b/static/css/app.css index c6d032d..fde5952 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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); } diff --git a/templates/account/signup_closed.html b/templates/account/signup_closed.html new file mode 100644 index 0000000..6bac09a --- /dev/null +++ b/templates/account/signup_closed.html @@ -0,0 +1,20 @@ +{% extends "_base.html" %} +{% load i18n lucide %} + +{% block head_title %}{% trans "Registration is closed" %}{% endblock head_title %} + +{% block main %} +
    +
    +
    +

    {% lucide "lock-keyhole" size=20 %} {% trans "Registration is closed" %}

    +

    {% blocktrans %}Accounts here are set up by the club, not by signing up.{% endblocktrans %}

    +

    {% blocktrans %}If your child plays at this club, use the link-my-child form. Otherwise ask the club to add you.{% endblocktrans %}

    + +
    +
    +
    +{% endblock main %}