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