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.
This commit is contained in:
104
club/services/seasons.py
Normal file
104
club/services/seasons.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user