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>
132 lines
5.3 KiB
Python
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
|