Add parent claims: onboarding a roster of children with no parents on file

The migration path for a club arriving with a list of children from a
federation export and no parent records. Children import without logins, each
into a family of their own -- that shape *is* the "nobody is responsible for
this child" state, so there's no unclaimed flag to drift out of step with
reality, and a family drops off the worklist by itself the moment a parent
joins it. `family_role=child` with a blank `family_group` asks for that; any
other lone role is still a mistake in the file.

Verification is a human decision, deliberately. A parent submits a public form
with the child's name and date of birth as free text -- no search, no
autocomplete, and the same response whether or not the child was found, because
the page needs no login and anything that resolved the child would turn it into
a way to enumerate the club's children. An admin matches it from a queue
against a shortlist that only ever contains children with nobody on file, so
approving can never quietly re-parent a child who already has one.

The alternatives were worse. A claim code needs a delivery channel the club may
not have and is a bearer token besides. Matching on name plus birthday hands out
someone else's child to whoever guesses a birthday. The club is the only party
that actually knows its own families.

That form is also the registration: open self-registration is now closed
(shadowing account_signup rather than removing the route, so the URL name
allauth's templates reverse still resolves). The account is created on
approval, not on submission, so a public form can't fill the user table. An
approved parent lands as a guardian -- login and family link, no membership, no
fee -- gets a password-reset link, and a minimal "my family" page.

One bug worth recording: families_awaiting_a_parent first used
annotate(Count(..., filter=...)) over a queryset already filtered on the same
join, so Django reused that join for the counts and a parent with no
ClubMembership of their own -- exactly what a newly linked guardian is -- went
uncounted, leaving the family unclaimed forever. Exists subqueries avoid it. A
test pins both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:18:26 +02:00
parent 744b623403
commit ca2b1a11b5
25 changed files with 1033 additions and 10 deletions

View File

@@ -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
View File

@@ -0,0 +1,37 @@
from django import forms
from django.utils.translation import gettext_lazy as _
class ParentClaimForm(forms.Form):
"""The public "link me to my child" form -- see members.models.ParentClaim.
Every child field is free text. There is deliberately no picker, no search
and no autocomplete: this page is reachable without logging in, so anything
that confirmed whether a given child exists would turn it into a way to
enumerate the club's children. The submitter types what they know and an
admin matches it against the real record.
"""
parent_first_name = forms.CharField(label=_("Your first name"), max_length=150)
parent_last_name = forms.CharField(label=_("Your last name"), max_length=150)
parent_email = forms.EmailField(label=_("Your email address"), help_text=_("We'll use this to set up your login once the club has confirmed the link."))
child_first_name = forms.CharField(label=_("Child's first name"), max_length=150)
child_last_name = forms.CharField(label=_("Child's last name"), max_length=150)
child_date_of_birth = forms.DateField(label=_("Child's date of birth"), widget=forms.DateInput(attrs={"type": "date"}))
class ClaimReviewForm(forms.Form):
"""An admin approving one claim: which child it actually refers to.
The child is chosen from the shortlist rather than typed, and the shortlist
is only ever children who have no parent on file -- approving a claim can
never quietly re-parent a child who already has one.
"""
child = forms.ModelChoiceField(queryset=None, label=_("Link to"), widget=forms.Select(attrs={"class": "select select-bordered w-full"}))
def __init__(self, *args, candidates=None, **kwargs):
super().__init__(*args, **kwargs)
self.fields["child"].queryset = candidates
self.fields["child"].label_from_instance = lambda child: f"{child} ({child.date_of_birth:%d %b %Y})" if child.date_of_birth else str(child)

View File

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

View File

@@ -101,6 +101,69 @@ class FamilyMembership(models.Model):
return f"{self.family} - {self.member} ({self.get_role_display()})"
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
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import datetime
import tempfile
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
View File

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

View File

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