Files
RosterChief/club/services/seasons.py
Bernard Siebens 8598fd2b46 Generate seasons per club instead of a hardcoded Aug-May window
Club now carries its own season_start and season_duration_months,
editable via controlpanel; generate_seasons chains each new season off
the day after the club's last one ends (or its configured start, for a
club with none yet) instead of assuming every club runs Aug 1 - May 31.

Adds --resync to the generate_seasons command to clean up seasons left
over from the old hardcoded rule -- removing any that don't match a
club's current settings and aren't still referenced by real data.
2026-08-03 17:03:03 +02:00

105 lines
3.9 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:
created.append(season)
start = end + datetime.timedelta(days=1)
return created
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