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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user