Separate guardians from members: a parent is not automatically a member

members/services/family.py enrolled a parent exactly like the child they were
registering, so every parent held a full membership: counted in the member
list, in the club and platform KPIs, and in the fee roll, with a fee record of
their own. ClubMembership.kind (member | guardian) separates the two.

A guardian is attached to the club only through their child. They hold the
login, can be contacted and can sit in a Group -- the stated exception -- but
they are not a member: no fee (clean() refuses one), absent from the member
list, the fee list and every member count, and not eligible for a roster or a
staff spot. A parent who also plays or coaches is a member who happens to be a
parent; the two facts are independent, which is why this is its own field
rather than inferred from FamilyMembership.role.

A field on ClubMembership rather than a separate model because everything that
answers "is this person attached to this club" already reads through that table
-- tenancy, groups, the club-wide event audience -- and a second kind of link
would need a parallel path through all of it. What changes is only who counts.

Two things that weren't obvious going in:

Excluding guardians had to be a subtraction, not a narrower filter. The obvious
move -- match only member-kind rows and drop the MEMBER-role branch, since an
active membership of any kind grants that role -- also hides someone the club
knows but hasn't signed up for a season yet, which is a real state the member
edit page supports. Two existing tests caught it. _guardians_only() subtracts
instead, so anyone who also plays, is on staff or runs the club stays visible.

Their tie to the club isn't seasonal but rides on a per-season row, so it has
to be carried forward or a parent silently drops off at the season boundary
while their child stays enrolled. Copied from the immediately preceding season
only, so a deliberate removal isn't resurrected from an older row.

The data migration reclassifies existing parents, deliberately skipping anyone
who plays, is on a team's staff or holds an elevated ClubRole -- demoting them
would strip them from their own team's roster eligibility. Anything ambiguous
stays a member, which an admin can flip; noticing someone quietly vanished is
much harder.

The import template gains a membership_kind column next to family_role (a
child marked guardian is refused), and the review screen shows what each row
will join as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 16:11:41 +02:00
parent ab1c703baf
commit 744b623403
17 changed files with 581 additions and 53 deletions

View File

@@ -7,7 +7,7 @@ from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from club.models import ClubMembership
from club.models import ClubMembership, Season
from members.models import Family, FamilyMembership, Member
User = get_user_model()
@@ -54,9 +54,16 @@ def grant_login(member, email):
return user
def _enrol(club, season, member):
def _enrol(club, season, member, kind=ClubMembership.Kind.MEMBER):
"""Sign a member up for the club's current season, if there is one. The
implicit MEMBER role follows automatically (club/signals.py)."""
implicit MEMBER role follows automatically (club/signals.py).
``kind=GUARDIAN`` attaches a parent to the club *as a parent* -- they hold
the login and can be reached, but they aren't a member, owe no fee and are
left out of every member list and count. A parent who also plays is enrolled
as a MEMBER instead; the two are not exclusive of each other in the family
graph, which records the parent relationship separately.
"""
if season is None:
return None
@@ -64,15 +71,44 @@ def _enrol(club, season, member):
club=club,
member=member,
season=season,
defaults={"status": ClubMembership.StatusChoices.ACTIVE, "signed_up_at": timezone.localdate()},
defaults={"kind": kind, "status": ClubMembership.StatusChoices.ACTIVE, "signed_up_at": timezone.localdate()},
)
if kind == ClubMembership.Kind.GUARDIAN:
carry_guardians_forward(club, member, from_season=season)
return membership
def carry_guardians_forward(club, member, *, from_season):
"""Give ``member`` a guardian row in every season of ``club`` at or after
``from_season``.
A guardian's tie to the club isn't really seasonal -- it lasts as long as
their child is there -- but it rides on ClubMembership so that everything
already keying off that table (tenancy, groups, the event audience) keeps
working. The cost of that is a row per season, and without this a parent
would silently drop off the club at the next season boundary while their
child stayed enrolled. Seasons are generated well ahead of time
(club/services/seasons.py), so "every season from here on" is a real set,
not just the next one. Idempotent.
"""
later_seasons = Season.objects.filter(club=club, start_date__gte=from_season.start_date).exclude(pk=from_season.pk)
for season in later_seasons:
ClubMembership.objects.get_or_create(
club=club,
member=member,
season=season,
defaults={"kind": ClubMembership.Kind.GUARDIAN, "status": ClubMembership.StatusChoices.ACTIVE, "signed_up_at": timezone.localdate()},
)
@transaction.atomic
def register_family(club, season, *, parent_email, parent_first_name, parent_last_name, child_first_name, child_last_name, child_date_of_birth=None):
def register_family(club, season, *, parent_email, parent_first_name, parent_last_name, child_first_name, child_last_name, child_date_of_birth=None, parent_is_member=False):
"""Create a new family in one go: a parent (with a login) and a child
(without one), linked to each other and signed up for ``season``."""
(without one), linked to each other and signed up for ``season``.
The child is always a member; the parent is a guardian unless
``parent_is_member`` says they play (or otherwise belong) in their own right.
"""
parent = get_or_create_login_member(parent_email, parent_first_name, parent_last_name)
child = Member.objects.create(first_name=child_first_name, last_name=child_last_name, date_of_birth=child_date_of_birth)
@@ -80,7 +116,7 @@ def register_family(club, season, *, parent_email, parent_first_name, parent_las
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
_enrol(club, season, parent)
_enrol(club, season, parent, kind=ClubMembership.Kind.MEMBER if parent_is_member else ClubMembership.Kind.GUARDIAN)
_enrol(club, season, child)
return family
@@ -97,13 +133,14 @@ def add_child_to_family(club, season, family, *, first_name, last_name, date_of_
@transaction.atomic
def add_parent_to_family(club, season, family, *, email, first_name="", last_name=""):
"""A family that needs one more parent/guardian registered."""
def add_parent_to_family(club, season, family, *, email, first_name="", last_name="", parent_is_member=False):
"""A family that needs one more parent/guardian registered. A guardian
unless ``parent_is_member`` says they belong to the club in their own right."""
parent = get_or_create_login_member(email, first_name, last_name)
# get_or_create, not create: re-adding an email already on this family (a typo'd
# re-submit, say) must not trip unique_member_per_family.
FamilyMembership.objects.get_or_create(family=family, member=parent, defaults={"role": FamilyMembership.FamilyRole.PARENT})
_enrol(club, season, parent)
_enrol(club, season, parent, kind=ClubMembership.Kind.MEMBER if parent_is_member else ClubMembership.Kind.GUARDIAN)
return parent