Add billing-ending banner, events CRUD, RBIHF import, public API, team photos, and sponsors

A large batch of club-management features built up over one session:

- Club dashboard banner warning admins 1 month before billing ends
- Full Events/EventSeries CRUD (recurrence builder, occurrence lifecycle,
  per-team permissions), with match->game rename and game-specific fields
  (score, competition, live status, external game ID)
- Django-admin competition dropdown, gated per-club by feature flag
- Auto-import of RBIHF fixtures (scrape -> diff -> preview -> confirm),
  with location/opponent dropdowns suggested from existing club data
- Feature-flag-gated Shop/Forms nav sections, reusing the same flag
  machinery for the RBIHF import button
- Team roster now scoped to members active this season or next, sorted and
  grouped by position
- Club sport type (ice hockey / other), shown in the control panel's club
  subtitle
- Per-season team photo upload from the team page
- New public read-only API (Django Ninja) at /api/v1/: news, team rosters,
  upcoming/live/per-team games, and sponsors -- auto-documented via Swagger
  UI, CORS-enabled for a club's own external website
- Club sponsors: admin-only CRUD (logo, URL, active date window) plus a
  date-windowed, optionally randomized API endpoint
- Assorted fixes: NullBooleanField dropdown rendering, cross-club event
  validation timing, searchable-select chip placement, btn-neutral ->
  default button style sweep, calendar-month chart windows

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
This commit is contained in:
2026-08-06 17:36:04 +02:00
parent 6ad0d6658c
commit 98b8002a04
101 changed files with 6165 additions and 135 deletions

View File

@@ -1,17 +1,25 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Club, ClubMembership, ClubRole, FeePayment, Season
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor
@admin.register(Club)
class ClubAdmin(admin.ModelAdmin):
list_display = ["name", "slug"]
list_display = ["name", "slug", "sport_type"]
list_filter = ["sport_type"]
search_fields = ["name", "slug"]
prepopulated_fields = {"slug": ["name"]}
ordering = ["name"]
@admin.register(Sponsor)
class SponsorAdmin(admin.ModelAdmin):
list_display = ["name", "club", "start_date", "end_date"]
list_filter = ["club"]
search_fields = ["name"]
@admin.register(Season)
class SeasonAdmin(admin.ModelAdmin):
list_display = ["__str__", "club", "start_date", "end_date"]

58
club/api.py Normal file
View File

@@ -0,0 +1,58 @@
"""Public read-only sponsors endpoint -- see api/urls.py for how this is
mounted.
"""
import random
import uuid
from datetime import date
from django.db.models import Q
from django.utils import timezone
from ninja import Router, Schema
from api.errors import require_club
from .models import Sponsor
router = Router(tags=["sponsors"])
class SponsorOut(Schema):
id: uuid.UUID
name: str
logo_url: str | None
url: str | None
start_date: date
end_date: date | None
def _to_sponsor_out(sponsor, request) -> SponsorOut:
return SponsorOut(
id=sponsor.pk,
name=sponsor.name,
logo_url=request.build_absolute_uri(sponsor.logo.url) if sponsor.logo else None,
url=sponsor.url or None,
start_date=sponsor.start_date,
end_date=sponsor.end_date,
)
@router.get("/", response=list[SponsorOut], summary="Active sponsors")
def list_sponsors(request, randomize: bool = False):
"""Sponsors currently "live": start_date has passed and either there's no
end_date (runs indefinitely once started) or it hasn't passed yet. Both
bounds are inclusive of today.
`randomize=true` shuffles the result (e.g. for a sponsor strip that
shouldn't always lead with the same one) -- shuffled in Python after a
stable-ordered fetch rather than an ORDER BY RANDOM(), which sponsor
counts are far too small to need and which SQLite/Postgres don't even
express the same way."""
club = require_club(request)
today = timezone.localdate()
sponsors = list(Sponsor.objects.filter(club=club, start_date__lte=today).filter(Q(end_date__isnull=True) | Q(end_date__gte=today)).order_by("name"))
if randomize:
random.shuffle(sponsors)
return [_to_sponsor_out(sponsor, request) for sponsor in sponsors]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-04 22:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0017_club_season_duration_months_club_season_start'),
]
operations = [
migrations.AddField(
model_name='club',
name='sport_type',
field=models.CharField(choices=[('ice_hockey', 'Ice hockey'), ('other', 'Other')], default='other', help_text='Which sport this club plays -- determines which competitions and score fetchers are relevant to it.', max_length=20, verbose_name='sport'),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 6.0.6 on 2026-08-06 15:02
import club.models
import django.core.validators
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0018_club_sport_type'),
]
operations = [
migrations.CreateModel(
name='Sponsor',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255, verbose_name='name')),
('logo', models.FileField(blank=True, upload_to=club.models.sponsor_logo_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'])], verbose_name='logo')),
('url', models.URLField(blank=True, help_text="The sponsor's own website, if they have one.", verbose_name='URL')),
('start_date', models.DateField(verbose_name='start date')),
('end_date', models.DateField(blank=True, help_text='Leave blank to keep this sponsor active indefinitely once it starts.', null=True, verbose_name='end date')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'sponsor',
'verbose_name_plural': 'sponsors',
'ordering': ['name'],
},
),
]

View File

@@ -1,5 +1,6 @@
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import Http404
from waffle import flag_is_active
from .services.access import can_add_news, can_edit_news, can_publish_news, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
@@ -36,6 +37,27 @@ class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
return is_club_admin(self.request.user, self.request.club)
class FeatureRequiredMixin(ClubAdminRequiredMixin):
"""Gate for a whole management section (shop, forms, ...) this club doesn't
have at all unless its waffle Flag (see the ``features`` app, set per-club
from the control panel's Features page) is active for it. Checked before
the admin-only test below and as a plain 404 rather than folded into
``test_func``'s 403: a club with the feature off doesn't have a permissions
problem, the section just doesn't exist there, same reasoning as
``ClubStaffRequiredMixin`` 404ing the whole app off the base domain.
Subclasses set ``feature_flag`` to the Flag's name, e.g. ``"shop"``.
"""
feature_flag: str = ""
def dispatch(self, request, *args, **kwargs):
club = getattr(request, "club", None)
if club is not None and not flag_is_active(request, self.feature_flag):
raise Http404(f"The “{self.feature_flag}” feature isn't enabled for this club.")
return super().dispatch(request, *args, **kwargs)
class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
"""A manager of *this* team, or a club ADMIN. ``self.get_team()`` must return the
``Team`` the view acts on (e.g. from the URL's ``pk``) before ``test_func`` runs.
@@ -51,6 +73,23 @@ class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
class EventManagerRequiredMixin(ClubStaffRequiredMixin):
"""Admin, or a manager of at least one of this event's/series' *current*
teams. ``self.get_teams()`` must return the Team queryset/iterable the
view acts on (e.g. ``self.get_object().teams.all()``) before ``test_func``
runs. Events/series aren't single-team like a roster entry -- ``teams`` is
M2M, so authority is "manages at least one", not "manages the one"."""
def get_teams(self):
raise NotImplementedError("Subclasses must return the Teams this view acts on.")
def test_func(self):
user, club = self.request.user, self.request.club
if is_club_admin(user, club):
return True
return teams_managed_by(user, club).filter(pk__in=self.get_teams().values_list("pk", flat=True)).exists()
class ManagementPositionRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN, or anyone with a current-season *management*-position
StaffAssignment on any team -- unlike ``TeamManagerRequiredMixin``, the

View File

@@ -2,6 +2,7 @@ import datetime
from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.utils import timezone
@@ -30,6 +31,13 @@ def club_logo_path(instance: Club, filename: str) -> str:
class Club(UUIDModel):
class SportType(models.TextChoices):
"""Which sport this club plays. Only two options for now -- expand this as
more sport-specific competition fetchers (see events.competition) are added."""
ICE_HOCKEY = "ice_hockey", _("Ice hockey")
OTHER = "other", _("Other")
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
@@ -58,6 +66,14 @@ class Club(UUIDModel):
help_text=_("Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour."),
)
sport_type = models.CharField(
_("sport"),
max_length=20,
choices=SportType.choices,
default=SportType.OTHER,
help_text=_("Which sport this club plays -- determines which competitions and score fetchers are relevant to it."),
)
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(
@@ -143,6 +159,39 @@ class Club(UUIDModel):
self.save(update_fields=["archived_at"])
def sponsor_logo_path(instance: Sponsor, filename: str) -> str:
return f"clubs/{instance.club.slug}/sponsors/{instance.pk}/{filename}"
class Sponsor(ClubScopedModel):
name = models.CharField(_("name"), max_length=255)
logo = models.FileField(
_("logo"),
upload_to=sponsor_logo_path,
blank=True,
# A plain FileField, not ImageField: same reasoning as Club.logo -- a
# sponsor's own logo is just as commonly a vector file, and ImageField's
# Pillow validation can't read those.
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
)
url = models.URLField(_("URL"), blank=True, help_text=_("The sponsor's own website, if they have one."))
start_date = models.DateField(_("start date"))
end_date = models.DateField(_("end date"), null=True, blank=True, help_text=_("Leave blank to keep this sponsor active indefinitely once it starts."))
class Meta:
verbose_name = _("sponsor")
verbose_name_plural = _("sponsors")
ordering = ["name"]
def __str__(self):
return self.name
def clean(self):
if self.end_date is not None and self.start_date is not None and self.end_date < self.start_date:
raise ValidationError({"end_date": _("End date can't be before the start date.")})
class Season(ClubScopedModel):
start_date = models.DateField(_("start date"))
end_date = models.DateField(_("end date"))
@@ -175,6 +224,12 @@ class Season(ClubScopedModel):
"""Return ``club``'s season covering ``date`` (no tenant context needed)."""
return cls.objects.filter(club=club, start_date__lte=date, end_date__gte=date).first()
@classmethod
def next_after(cls, club, date: datetime.date):
"""Return ``club``'s soonest season starting after ``date`` (no tenant
context needed) -- the season that follows the one covering ``date``."""
return cls.objects.filter(club=club, start_date__gt=date).order_by("start_date").first()
class ClubMembership(ClubScopedModel):
class StatusChoices(models.TextChoices):

View File

@@ -20,7 +20,7 @@ from events.models import Event
from members.models import Family, FamilyMembership, Member
from teams.models import Position, StaffAssignment, Team, TeamMembership
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, club_logo_path
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor, club_logo_path
from .services.access import (
COACH_MANAGER,
can_edit_event,
@@ -464,6 +464,56 @@ class SeasonGetCurrentTests(TestCase):
Season.get_current(datetime.date(2026, 12, 25))
class SeasonNextAfterTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.current = Season.objects.create(club=self.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
self.next_season = Season.objects.create(club=self.club, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31))
def test_returns_the_soonest_season_starting_after_the_date(self):
self.assertEqual(Season.next_after(self.club, datetime.date(2026, 12, 25)), self.next_season)
def test_returns_none_when_there_is_no_later_season(self):
self.assertIsNone(Season.next_after(self.club, datetime.date(2027, 12, 25)))
def test_is_scoped_to_the_given_club(self):
other = Club.objects.create(name="Rival FC", slug="rival-fc")
Season.objects.create(club=other, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31))
self.assertEqual(Season.next_after(other, datetime.date(2026, 12, 25)).club, other)
class SponsorModelTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_str_returns_name(self):
sponsor = Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
self.assertEqual(str(sponsor), "Acme Corp")
def test_a_blank_end_date_is_valid(self):
sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
sponsor.full_clean()
def test_an_end_date_on_the_same_day_as_start_is_valid(self):
sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1), end_date=datetime.date(2026, 1, 1))
sponsor.full_clean()
def test_an_end_date_before_start_is_rejected(self):
sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 6, 1), end_date=datetime.date(2026, 1, 1))
with self.assertRaises(ValidationError) as ctx:
sponsor.full_clean()
self.assertIn("end_date", ctx.exception.error_dict)
def test_sponsors_are_ordered_by_name(self):
Sponsor.objects.create(club=self.club, name="Zulu Corp", start_date=datetime.date(2026, 1, 1))
Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
self.assertEqual(list(Sponsor.objects.values_list("name", flat=True)), ["Acme Corp", "Zulu Corp"])
class AdminRegistrationSmokeTests(TestCase):
"""Every registered model across all apps must have a working admin: load
each changelist and add page to catch bad list_display / search_fields /