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,3 +1,4 @@
import datetime
from decimal import Decimal
from django import forms
@@ -5,12 +6,16 @@ from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, ClubRole, FeePayment
from events.models import Location, Opponent
from club.models import ClubMembership, ClubRole, FeePayment, Season, Sponsor
from club.services.access import is_club_admin, teams_managed_by
from events.models import Competition, Event, EventSeries, Location, Opponent
from events.services.rbihf_import import RBIHFImportError, extract_team_id
from members.models import Family, FamilyMembership, Member
from members.services.family import find_member_by_email
from news.models import News
from teams.models import Position, StaffAssignment, Team, TeamMembership
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
from .recurrence_ui import FREQUENCY_CHOICES, WEEKDAY_CHOICES, build_rrule, parse_rrule
User = get_user_model()
@@ -28,6 +33,16 @@ class TeamForm(forms.ModelForm):
fields = ["name", "short_name"]
class TeamPhotoForm(forms.ModelForm):
"""One photo per (team, season) -- bound to the existing TeamPhoto (if
any) by the view's get_form_kwargs, same "create or update the one row
for this scope" pattern as controlpanel.forms.HomeLocationForm."""
class Meta:
model = TeamPhoto
fields = ["image"]
class TeamMembershipForm(forms.ModelForm):
"""Add/edit one roster entry -- team and season come from the view (the URL
already identifies both), never from the form itself."""
@@ -41,7 +56,13 @@ class TeamMembershipForm(forms.ModelForm):
super().__init__(*args, **kwargs)
self.team = team
self.season = season
members = Member.objects.filter(member_of__club=club).distinct()
# Eligible to be added regardless of which season's roster is being edited
# (the team detail page's season switcher can be pointed at an older
# season): active this season or the next one, not lapsed/pending/cancelled
# or active only in some other season.
today = timezone.localdate()
eligible_seasons = [s for s in (Season.covering(club, today), Season.next_after(club, today)) if s is not None]
members = Member.objects.filter(member_of__club=club, member_of__season__in=eligible_seasons, member_of__status=ClubMembership.StatusChoices.ACTIVE).distinct()
if team is not None and season is not None:
# Already on this team's roster this season -- offering them again
# would just fail the unique_member_per_team_per_season constraint.
@@ -76,7 +97,11 @@ class StaffAssignmentForm(forms.ModelForm):
def __init__(self, *args, club=None, team=None, season=None, **kwargs):
super().__init__(*args, **kwargs)
members = Member.objects.filter(member_of__club=club).distinct()
# See TeamMembershipForm for why current-or-next-season: eligible to be
# assigned regardless of which season's staff list is being edited.
today = timezone.localdate()
eligible_seasons = [s for s in (Season.covering(club, today), Season.next_after(club, today)) if s is not None]
members = Member.objects.filter(member_of__club=club, member_of__season__in=eligible_seasons, member_of__status=ClubMembership.StatusChoices.ACTIVE).distinct()
if team is not None and season is not None:
taken = StaffAssignment.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True)
members = members.exclude(pk__in=taken)
@@ -117,6 +142,206 @@ class OpponentForm(forms.ModelForm):
fields = ["name", "logo"]
class SponsorForm(forms.ModelForm):
class Meta:
model = Sponsor
fields = ["name", "logo", "url", "start_date", "end_date"]
widgets = {
"start_date": forms.DateInput(attrs={"type": "date"}),
"end_date": forms.DateInput(attrs={"type": "date"}),
}
def clean(self):
cleaned = super().clean()
# Mirrors Sponsor.clean() -- caught here too so it reads as a form
# error tied to the end_date field, not a raw ValidationError.
start_date, end_date = cleaned.get("start_date"), cleaned.get("end_date")
if start_date and end_date and end_date < start_date:
self.add_error("end_date", _("End date can't be before the start date."))
return cleaned
class EventAudienceFormMixin:
"""Shared club/user-scoped audience fields for EventForm and EventSeriesForm:
teams restricted to the ones the requester manages (all of them for an
admin), and a non-admin must pick at least one -- a team-less/club-wide
event (e.g. an AGM) has no team-manager claim to anchor it to, so that's
admin-only."""
def scope_audience_fields(self, club, user):
self.club = club
self.user = user
self.fields["teams"].queryset = Team.objects.filter(club=club) if is_club_admin(user, club) else teams_managed_by(user, club)
self.fields["location"].queryset = Location.objects.filter(club=club)
self.fields["opponent"].queryset = Opponent.objects.filter(club=club)
members = Member.objects.filter(member_of__club=club).distinct()
self.fields["invited_members"].queryset = members
self.fields["excluded_members"].queryset = members
def clean_teams_requires_one_for_non_admins(self, cleaned):
teams = cleaned.get("teams")
if teams is not None and not teams.exists() and not is_club_admin(self.user, self.club):
self.add_error("teams", _("Select at least one of your teams, or ask an admin to create a club-wide event."))
_AUDIENCE_WIDGETS = {
"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")}),
"invited_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
"excluded_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
}
class EventForm(EventAudienceFormMixin, forms.ModelForm):
class Meta:
model = Event
fields = ["title", "kind", "teams", "invited_members", "excluded_members", "location", "opponent", "start", "end", "gathering", "deadline", "competition", "external_game_id", "score_for", "score_against", "is_live"]
widgets = {
"start": forms.DateTimeInput(attrs={"type": "datetime-local"}),
"end": forms.DateTimeInput(attrs={"type": "datetime-local"}),
"gathering": forms.DateTimeInput(attrs={"type": "datetime-local"}),
"deadline": forms.DateTimeInput(attrs={"type": "datetime-local"}),
"score_for": forms.NumberInput(attrs={"min": 0}),
"score_against": forms.NumberInput(attrs={"min": 0}),
**_AUDIENCE_WIDGETS,
}
def __init__(self, *args, club=None, user=None, editing=False, **kwargs):
super().__init__(*args, **kwargs)
self.scope_audience_fields(club, user)
# Unlike the Django-admin form (events.admin.EventAdminForm), this dropdown
# isn't filtered by which competitions have their feature flag on for the
# club -- a manager should be able to pick any competition when scheduling a
# game; it's fetch_game_info (events.services.competitions) that gates the
# actual per-club fetch once a competition is set.
self.fields["competition"] = forms.ChoiceField(
choices=[("", "---------"), *[(competition.name, competition.name) for competition in Competition.objects.all()]],
required=False,
label=self.fields["competition"].label,
help_text=self.fields["competition"].help_text,
)
if not editing:
# Score/live status don't exist yet for a game that's only just being
# scheduled -- offering them on the add form is just noise. Editing an
# existing game is the only time there's anything to record here.
del self.fields["score_for"]
del self.fields["score_against"]
del self.fields["is_live"]
def clean(self):
cleaned = super().clean()
self.clean_teams_requires_one_for_non_admins(cleaned)
return cleaned
class RBIHFImportForm(forms.Form):
"""Step 1 of importing a team's fixtures from RBIHF's own website -- see
events.services.rbihf_import. Only the URL and which of the club's own
teams it applies to; everything else is scraped."""
url = forms.CharField(
label=_("RBIHF team page URL"),
help_text=_("E.g. https://www.rbihf.be/league/team/4460"),
widget=forms.URLInput(attrs={"placeholder": "https://www.rbihf.be/league/team/4460"}),
)
team = forms.ModelChoiceField(queryset=Team.objects.none(), label=_("Team"), help_text=_("Which of your teams this fixture list is for."))
def __init__(self, *args, club=None, **kwargs):
super().__init__(*args, **kwargs)
self.fields["team"].queryset = Team.objects.filter(club=club)
def clean_url(self):
url = self.cleaned_data["url"].strip()
try:
extract_team_id(url)
except RBIHFImportError as error:
raise forms.ValidationError(str(error)) from error
return url
class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm):
"""A friendly weekly/monthly recurrence picker on top of EventSeries' raw
RRULE (see management.recurrence_ui) -- covers the common case (every N
weeks/months) with an advanced raw-RRULE field as an escape hatch for
anything else."""
frequency = forms.ChoiceField(choices=FREQUENCY_CHOICES, initial="weekly", label=_("Repeats"))
interval = forms.IntegerField(min_value=1, initial=1, label=_("Every"), help_text=_("E.g. 2 for every other week/month."))
# A plain multi-select, not CheckboxSelectMultiple: its widget_type ("checkboxselectmultiple")
# isn't one form_field's ui.py recognises, which would silently render a broken
# plain text input instead -- see the "lazyselect" fix for the same class of bug.
weekdays = forms.MultipleChoiceField(choices=WEEKDAY_CHOICES, required=False, label=_("On"), widget=forms.SelectMultiple(attrs={"data-searchable": "true"}))
duration_hours = forms.IntegerField(min_value=0, required=False, label=_("Duration (hours)"))
duration_minutes = forms.IntegerField(min_value=0, max_value=59, required=False, label=_("Duration (minutes)"))
gathering_minutes_before = forms.IntegerField(min_value=0, required=False, label=_("Gather (minutes before)"))
deadline_minutes_before = forms.IntegerField(min_value=0, required=False, label=_("Registration deadline (minutes before)"))
advanced_rrule = forms.CharField(required=False, label=_("Advanced: raw recurrence rule"), help_text=_("Overrides the fields above if filled in. RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE."))
class Meta:
model = EventSeries
fields = ["title", "kind", "dtstart", "until", "teams", "invited_members", "excluded_members", "location", "opponent"]
widgets = {
"dtstart": forms.DateTimeInput(attrs={"type": "datetime-local"}),
"until": forms.DateTimeInput(attrs={"type": "datetime-local"}),
**_AUDIENCE_WIDGETS,
}
def __init__(self, *args, club=None, user=None, **kwargs):
super().__init__(*args, **kwargs)
self.scope_audience_fields(club, user)
if self.instance.pk:
parsed = parse_rrule(self.instance.rrule)
if parsed is not None:
self.fields["frequency"].initial = parsed["frequency"]
self.fields["interval"].initial = parsed["interval"]
self.fields["weekdays"].initial = parsed["weekdays"]
else:
# Not one of our two shapes (hand-written/imported) -- show it
# verbatim in the advanced field rather than guessing at it.
self.fields["advanced_rrule"].initial = self.instance.rrule
if self.instance.duration is not None:
total_minutes = int(self.instance.duration.total_seconds() // 60)
self.fields["duration_hours"].initial = total_minutes // 60
self.fields["duration_minutes"].initial = total_minutes % 60
if self.instance.gathering_offset is not None:
self.fields["gathering_minutes_before"].initial = int(self.instance.gathering_offset.total_seconds() // 60)
if self.instance.deadline_offset is not None:
self.fields["deadline_minutes_before"].initial = int(self.instance.deadline_offset.total_seconds() // 60)
def clean(self):
cleaned = super().clean()
self.clean_teams_requires_one_for_non_admins(cleaned)
if cleaned.get("advanced_rrule"):
return cleaned # the advanced field wins outright; nothing else to check
if cleaned.get("frequency") == "weekly" and not cleaned.get("weekdays"):
self.add_error("weekdays", _("Pick at least one day of the week."))
return cleaned
def save(self, commit=True):
instance = super().save(commit=False)
cleaned = self.cleaned_data
advanced = (cleaned.get("advanced_rrule") or "").strip()
instance.rrule = advanced or build_rrule(cleaned["frequency"], cleaned["interval"], cleaned.get("weekdays"))
hours, minutes = cleaned.get("duration_hours") or 0, cleaned.get("duration_minutes") or 0
instance.duration = datetime.timedelta(hours=hours, minutes=minutes) if (hours or minutes) else None
gathering_minutes = cleaned.get("gathering_minutes_before")
instance.gathering_offset = datetime.timedelta(minutes=gathering_minutes) if gathering_minutes else None
deadline_minutes = cleaned.get("deadline_minutes_before")
instance.deadline_offset = datetime.timedelta(minutes=deadline_minutes) if deadline_minutes else None
if commit:
instance.save()
self.save_m2m()
return instance
class ClubRoleAssignForm(forms.ModelForm):
"""Grant a club-wide role to a member already affiliated with this club."""