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:
2026-08-03 17:03:03 +02:00
parent 062da00bb9
commit 8598fd2b46
9 changed files with 447 additions and 4 deletions

View File

View File

View File

@@ -0,0 +1,57 @@
"""Generate seasons ahead of time for every active club.
Meant to run on a schedule (cron): safe to call repeatedly, since generate_seasons
skips whatever already exists. No --dry-run/--commit gate on generation itself --
unlike archiving a club or billing it, creating a future season row is additive
and idempotent, same reasoning as extend_event_series (materialising occurrences).
--resync is different: it can delete rows (any season that doesn't match a
club's *current* season_start/season_duration_months, and isn't referenced by a
membership), so it defaults to reporting only -- pass --commit alongside it to
actually remove anything.
"""
from dateutil.relativedelta import relativedelta
from django.utils import timezone
from club.models import Club
from club.services.seasons import generate_seasons, resync_seasons
from features.commands import MaintenanceAwareCommand
class Command(MaintenanceAwareCommand):
help = "Generate seasons up to N years ahead for every active club (default 2)."
def add_arguments(self, parser):
parser.add_argument("--years", type=int, default=2, help="How many years ahead to generate seasons for (default 2).")
parser.add_argument("--resync", action="store_true", help="Before generating, remove any existing seasons that don't match the club's current settings (skips any still referenced by a membership).")
parser.add_argument("--commit", action="store_true", help="With --resync, actually delete the seasons found to be wrong. Without it, --resync only reports what it would remove.")
def handle(self, *args, **options):
until = timezone.localdate() + relativedelta(years=options["years"])
clubs = Club.objects.active()
if options["resync"]:
total_removed, total_kept = 0, 0
for club in clubs:
removed, kept = resync_seasons(club, until, commit=options["commit"])
total_removed += len(removed)
total_kept += len(kept)
if removed:
verb = "Removed" if options["commit"] else "Would remove"
self.stdout.write(f"{club}: {verb} {len(removed)} season(s) that no longer match its settings.")
if kept:
self.stdout.write(self.style.WARNING(f"{club}: {len(kept)} season(s) don't match its settings but are still in use, left alone."))
if not options["commit"] and total_removed:
self.stdout.write(self.style.WARNING("Dry run -- pass --commit to actually delete these."))
self.stdout.write(self.style.SUCCESS(f"Resync: {total_removed} season(s) {'removed' if options['commit'] else 'to remove'}, {total_kept} kept (in use)."))
total = 0
for club in clubs:
created = generate_seasons(club, until)
total += len(created)
if created:
self.stdout.write(f"{club}: generated {len(created)} season(s).")
self.stdout.write(self.style.SUCCESS(f"Done. Generated {total} season(s) across {clubs.count()} club(s)."))