Files
RosterChief/club/services/seasons.py
Bernard Siebens 744b623403 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>
2026-08-11 16:11:41 +02:00

132 lines
5.3 KiB
Python

"""Generating a club's seasons ahead of time.
Season (club/models.py) has no stored notion of "when a season starts" -- that
lives on Club instead (season_start, season_duration_months), since different
clubs run their year on different cycles. Same shape as
events/services/recurrence.py's generate_occurrences: materialise missing rows
up to a horizon, get_or_create per row, safe to call repeatedly.
"""
import datetime
from dateutil.relativedelta import relativedelta
from django.db.models import ProtectedError
from django.db.models.deletion import Collector
from django.utils import timezone
from club.models import Season
def _initial_season_start(club, today):
"""The most recent occurrence of the club's configured season_start that is
not later than ``today`` -- so a club with no seasons yet gets one covering
"now" (or the most recently completed one), not an arbitrary future year."""
anchor = club.season_start
start = datetime.date(today.year, anchor.month, anchor.day)
if start > today:
start = datetime.date(today.year - 1, anchor.month, anchor.day)
return start
def _season_end(start, club):
return start + relativedelta(months=club.season_duration_months) - datetime.timedelta(days=1)
def generate_seasons(club, until):
"""Materialise seasons for ``club`` from wherever it last left off -- the day
after its latest season's end_date, or its configured season_start if it has
none yet -- through ``until``. get_or_create per row (matches the
unique_season_dates_per_club constraint exactly), safe to call repeatedly.
"""
latest = Season.objects.filter(club=club).order_by("-end_date").first()
start = latest.end_date + datetime.timedelta(days=1) if latest else _initial_season_start(club, timezone.localdate())
created = []
while start <= until:
end = _season_end(start, club)
season, was_created = Season.objects.get_or_create(club=club, start_date=start, end_date=end)
if was_created:
_carry_guardians_into(club, season)
created.append(season)
start = end + datetime.timedelta(days=1)
return created
def _carry_guardians_into(club, season):
"""Copy the previous season's guardians into a season that has just been created.
The other half of members.services.family.carry_guardians_forward, which
covers a guardian added *after* the later seasons already existed. Between
them a parent keeps their tie to the club across every season boundary --
without which they would quietly drop off the club while their child stayed
enrolled, which is exactly the state a guardian exists to prevent.
Copied from the immediately preceding season, not from "any season ever", so
a guardian an admin deliberately removed stays removed rather than being
resurrected from an older row.
"""
from club.models import ClubMembership
previous = Season.objects.filter(club=club, start_date__lt=season.start_date).order_by("-start_date").first()
if previous is None:
return
guardians = ClubMembership.objects.filter(club=club, season=previous, kind=ClubMembership.Kind.GUARDIAN)
ClubMembership.objects.bulk_create(
[ClubMembership(club=club, member_id=guardian.member_id, season=season, kind=ClubMembership.Kind.GUARDIAN, status=guardian.status, signed_up_at=guardian.signed_up_at) for guardian in guardians],
ignore_conflicts=True,
)
def _expected_season_dates(club, until):
"""The (start_date, end_date) pairs generate_seasons would produce for
``club`` from scratch, ignoring whatever already exists -- used by
resync_seasons to tell "matches the club's current settings" from "doesn't"."""
start = _initial_season_start(club, timezone.localdate())
expected = set()
while start <= until:
end = _season_end(start, club)
expected.add((start, end))
start = end + datetime.timedelta(days=1)
return expected
def _is_referenced(season):
"""Whether deleting ``season`` would hit a PROTECT on any of its relations
(ClubMembership, StaffAssignment, TeamMembership, Event, ...) without
actually deleting anything."""
collector = Collector(using=season._state.db)
try:
collector.collect([season])
except ProtectedError:
return True
return False
def resync_seasons(club, until, *, commit=False):
"""Find seasons for ``club`` that don't match what its *current*
season_start/season_duration_months would produce (e.g. left over from a
different rule, or from before those settings were changed), within the
same horizon generate_seasons would cover.
A season is only ever removed if nothing references it through a PROTECTed
relation -- a season already in use is reported as kept, never silently
dropped. With commit=False (the default) nothing is deleted; the caller
gets back what *would* happen.
"""
expected = _expected_season_dates(club, until)
removed, kept = [], []
for season in Season.objects.filter(club=club):
if (season.start_date, season.end_date) in expected:
continue
if _is_referenced(season):
kept.append(season)
else:
removed.append(season)
if commit:
season.delete()
return removed, kept