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:
@@ -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
58
club/api.py
Normal 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]
|
||||
18
club/migrations/0018_club_sport_type.py
Normal file
18
club/migrations/0018_club_sport_type.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
36
club/migrations/0019_sponsor.py
Normal file
36
club/migrations/0019_sponsor.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 /
|
||||
|
||||
Reference in New Issue
Block a user