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)."))

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0.6 on 2026-08-03 14:30
import datetime
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more'),
]
operations = [
migrations.AddField(
model_name='club',
name='season_duration_months',
field=models.PositiveSmallIntegerField(default=12, help_text='How many months a season lasts, counted from its start date.', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(24)], verbose_name='season duration (months)'),
),
migrations.AddField(
model_name='club',
name='season_start',
field=models.DateField(default=datetime.date(2000, 8, 1), help_text='Which day of the year a season begins — only the month and day are used, the year is ignored.', verbose_name='season start'),
),
]

View File

@@ -2,7 +2,7 @@ import datetime
from decimal import Decimal
from django.conf import settings
from django.core.validators import FileExtensionValidator, MinValueValidator, RegexValidator
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -60,6 +60,18 @@ class Club(UUIDModel):
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))
season_start = models.DateField(
_("season start"),
default=datetime.date(2000, 8, 1),
help_text=_("Which day of the year a season begins — only the month and day are used, the year is ignored."),
)
season_duration_months = models.PositiveSmallIntegerField(
_("season duration (months)"),
default=12,
validators=[MinValueValidator(1), MaxValueValidator(24)],
help_text=_("How many months a season lasts, counted from its start date."),
)
objects = ClubManager()
class Meta:

104
club/services/seasons.py Normal file
View 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

View File

@@ -2,11 +2,14 @@ import datetime
import uuid
from contextlib import contextmanager
from decimal import Decimal
from io import StringIO
from allauth.mfa.models import Authenticator
from dateutil.relativedelta import relativedelta
from django.contrib import admin as django_admin
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.db import IntegrityError
from django.db.models import ProtectedError
from django.test import RequestFactory, TestCase, override_settings
@@ -29,6 +32,7 @@ from .services.access import (
teams_staffed_by,
)
from .services.fees import mark_as_paid, record_payment, remaining_balance
from .services.seasons import _initial_season_start, _season_end, generate_seasons, resync_seasons
from .tenancy import (
ClubTenantMiddleware,
get_current_club,
@@ -1190,3 +1194,240 @@ class FeeServiceTests(TestCase):
payment = record_payment(self.membership, amount=Decimal("50.00"), recorded_by=user)
self.assertEqual(payment.recorded_by, user)
class SeasonStartEndTests(TestCase):
"""club.services.seasons._initial_season_start / _season_end -- the
per-club rules generate_seasons chains off, now that a club's own
season_start/season_duration_months drive them instead of a fixed Aug-May
window."""
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_a_date_past_this_years_anchor_uses_this_year(self):
self.club.season_start = datetime.date(2000, 8, 1)
start = _initial_season_start(self.club, datetime.date(2026, 8, 15))
self.assertEqual(start, datetime.date(2026, 8, 1))
def test_a_date_before_this_years_anchor_uses_last_year(self):
self.club.season_start = datetime.date(2000, 8, 1)
start = _initial_season_start(self.club, datetime.date(2027, 2, 1))
self.assertEqual(start, datetime.date(2026, 8, 1))
def test_the_anchor_date_itself_uses_this_year(self):
self.club.season_start = datetime.date(2000, 8, 1)
start = _initial_season_start(self.club, datetime.date(2026, 8, 1))
self.assertEqual(start, datetime.date(2026, 8, 1))
def test_season_end_is_the_day_before_the_start_plus_the_duration(self):
self.club.season_duration_months = 12
end = _season_end(datetime.date(2026, 8, 1), self.club)
self.assertEqual(end, datetime.date(2027, 7, 31))
def test_a_shorter_duration_produces_a_shorter_season(self):
self.club.season_duration_months = 6
end = _season_end(datetime.date(2026, 8, 1), self.club)
self.assertEqual(end, datetime.date(2027, 1, 31))
class GenerateSeasonsTests(TestCase):
"""club.services.seasons.generate_seasons -- the service behind the
generate_seasons management command."""
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_generates_a_season_covering_today(self):
today = timezone.localdate()
generate_seasons(self.club, today)
self.assertTrue(Season.objects.filter(club=self.club, start_date__lte=today, end_date__gte=today).exists())
def test_generates_every_window_through_the_horizon(self):
today = timezone.localdate()
until = today + relativedelta(years=2)
created = generate_seasons(self.club, until)
self.assertGreaterEqual(len(created), 2)
for season in created:
self.assertLessEqual(season.start_date, until)
def test_is_idempotent_on_a_second_run(self):
until = timezone.localdate() + relativedelta(years=2)
generate_seasons(self.club, until)
count_after_first = Season.objects.filter(club=self.club).count()
second_run = generate_seasons(self.club, until)
self.assertEqual(second_run, [])
self.assertEqual(Season.objects.filter(club=self.club).count(), count_after_first)
def test_a_new_season_starts_the_day_after_the_last_one_ends(self):
today = timezone.localdate()
generate_seasons(self.club, today)
latest = Season.objects.filter(club=self.club).order_by("-end_date").first()
generate_seasons(self.club, latest.end_date + relativedelta(months=self.club.season_duration_months))
next_season = Season.objects.filter(club=self.club, start_date=latest.end_date + datetime.timedelta(days=1)).first()
self.assertIsNotNone(next_season)
def test_changing_the_duration_only_affects_the_next_generated_season(self):
today = timezone.localdate()
generate_seasons(self.club, today)
first = Season.objects.filter(club=self.club).order_by("-end_date").first()
self.club.season_duration_months = 6
self.club.save()
generate_seasons(self.club, first.end_date + relativedelta(months=6))
first_end_before = first.end_date
first.refresh_from_db()
self.assertEqual(first.end_date, first_end_before) # existing season untouched
second = Season.objects.get(club=self.club, start_date=first.end_date + datetime.timedelta(days=1))
self.assertEqual(second.end_date, second.start_date + relativedelta(months=6) - datetime.timedelta(days=1))
def test_does_not_disturb_a_pre_existing_irregular_season(self):
odd = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
generate_seasons(self.club, timezone.localdate())
odd.refresh_from_db()
self.assertEqual(odd.start_date, datetime.date(2020, 3, 1))
self.assertEqual(odd.end_date, datetime.date(2020, 9, 1))
class ResyncSeasonsTests(TestCase):
"""club.services.seasons.resync_seasons -- cleaning up seasons that don't
match a club's current settings (e.g. left over from a since-changed
season_start/season_duration_months)."""
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.until = timezone.localdate() + relativedelta(years=2)
def test_a_wrong_and_unreferenced_season_is_reported_as_removable(self):
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
removed, kept = resync_seasons(self.club, self.until)
self.assertIn(wrong, removed)
self.assertEqual(kept, [])
def test_commit_actually_deletes_a_wrong_unreferenced_season(self):
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
resync_seasons(self.club, self.until, commit=True)
self.assertFalse(Season.objects.filter(pk=wrong.pk).exists())
def test_without_commit_nothing_is_actually_deleted(self):
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
resync_seasons(self.club, self.until, commit=False)
self.assertTrue(Season.objects.filter(pk=wrong.pk).exists())
def test_a_wrong_but_referenced_season_is_kept_not_removed(self):
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
member = Member.objects.create(first_name="Jane", last_name="Doe")
ClubMembership.objects.create(club=self.club, member=member, season=wrong)
removed, kept = resync_seasons(self.club, self.until, commit=True)
self.assertEqual(removed, [])
self.assertIn(wrong, kept)
self.assertTrue(Season.objects.filter(pk=wrong.pk).exists())
def test_a_season_matching_current_settings_is_left_alone(self):
generate_seasons(self.club, timezone.localdate())
removed, kept = resync_seasons(self.club, self.until)
self.assertEqual(removed, [])
self.assertEqual(kept, [])
def test_a_season_referenced_only_via_staff_assignment_is_kept_not_removed(self):
# Season is PROTECTed by more than just ClubMembership -- a season kept
# alive only through a StaffAssignment must not be silently deleted either.
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True)
member = Member.objects.create(first_name="Jane", last_name="Doe")
StaffAssignment.objects.create(team=team, member=member, season=wrong, position=position)
removed, kept = resync_seasons(self.club, self.until, commit=True)
self.assertEqual(removed, [])
self.assertIn(wrong, kept)
self.assertTrue(Season.objects.filter(pk=wrong.pk).exists())
class GenerateSeasonsCommandTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_default_years_is_two(self):
call_command("generate_seasons", stdout=StringIO())
today = timezone.localdate()
until = today + relativedelta(years=2)
seasons = Season.objects.filter(club=self.club).order_by("start_date")
self.assertTrue(seasons.exists())
for season in seasons:
self.assertLessEqual(season.start_date, until)
self.assertTrue(seasons.filter(start_date__lte=today, end_date__gte=today).exists())
def test_years_argument_controls_the_horizon(self):
call_command("generate_seasons", "--years", "1", stdout=StringIO())
until = timezone.localdate() + relativedelta(years=1)
for season in Season.objects.filter(club=self.club):
self.assertLessEqual(season.start_date, until)
def test_archived_clubs_are_skipped(self):
self.club.archive()
call_command("generate_seasons", stdout=StringIO())
self.assertFalse(Season.objects.filter(club=self.club).exists())
def test_second_run_creates_nothing_new(self):
call_command("generate_seasons", stdout=StringIO())
count_after_first = Season.objects.filter(club=self.club).count()
out = StringIO()
call_command("generate_seasons", stdout=out)
self.assertEqual(Season.objects.filter(club=self.club).count(), count_after_first)
self.assertIn("Generated 0 season", out.getvalue())
def test_resync_without_commit_reports_but_does_not_delete(self):
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
out = StringIO()
call_command("generate_seasons", "--resync", stdout=out)
self.assertTrue(Season.objects.filter(pk=wrong.pk).exists())
self.assertIn("Would remove", out.getvalue())
def test_resync_with_commit_deletes_the_wrong_season(self):
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
call_command("generate_seasons", "--resync", "--commit", stdout=StringIO())
self.assertFalse(Season.objects.filter(pk=wrong.pk).exists())

View File

@@ -13,7 +13,7 @@ from .services.admins import find_member_by_email
class ClubForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name", "slug", "logo", "primary_color", "secondary_color"]
fields = ["name", "slug", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"]
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
# Deliberately a text input, not <input type="color">: a colour picker cannot
# express "no colour" -- it would submit #000000 for every club that never
@@ -22,6 +22,7 @@ class ClubForm(forms.ModelForm):
"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}),
"secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}),
"logo": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp,image/svg+xml"}),
"season_start": forms.DateInput(attrs={"type": "date"}),
}
def __init__(self, *args, **kwargs):

View File

@@ -117,14 +117,17 @@ class ClubManagementTests(ControlPanelTestBase):
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "Ajax United")
def test_create_club_derives_the_slug(self):
response = self.client.post(reverse("controlpanel:club_create"), {"name": "New Club", "slug": ""})
response = self.client.post(reverse("controlpanel:club_create"), {"name": "New Club", "slug": "", "season_start": "2000-08-01", "season_duration_months": "12"})
club = Club.objects.get(name="New Club")
self.assertEqual(club.slug, "new-club")
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[club.pk]))
def test_update_club(self):
self.client.post(reverse("controlpanel:club_update", args=[self.club.pk]), {"name": "Renamed", "slug": self.club.slug})
self.client.post(
reverse("controlpanel:club_update", args=[self.club.pk]),
{"name": "Renamed", "slug": self.club.slug, "season_start": "2000-08-01", "season_duration_months": "12"},
)
self.club.refresh_from_db()
self.assertEqual(self.club.name, "Renamed")