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

@@ -7,6 +7,8 @@ Same reasoning for ``news_permissions`` below, gating just the "New news item"
action rather than the whole section (``NewsAuthorRequiredMixin``/``can_add_news``).
"""
from waffle import flag_is_active
from club.services.access import can_add_news, has_management_access, is_club_admin, is_coach_manager
#: Every management URL name, mapped to the nav item it should light up --
@@ -54,6 +56,8 @@ _NAV_SECTIONS = {
"team_staff_add": "team_list",
"team_staff_update": "team_list",
"team_staff_remove": "team_list",
"team_photo_set": "team_list",
"team_photo_delete": "team_list",
"news_list": "news_list",
"news_create": "news_list",
"news_detail": "news_list",
@@ -65,7 +69,20 @@ _NAV_SECTIONS = {
"news_photo_set_main": "news_list",
"news_photo_delete": "news_list",
"event_list": "event_list",
"event_series_list": "event_series_list",
"event_create": "event_list",
"event_detail": "event_list",
"event_update": "event_list",
"event_delete": "event_list",
"event_detach": "event_list",
"event_fetch_game_info": "event_list",
"rbihf_import": "event_list",
"rbihf_import_confirm": "event_list",
"event_series_create": "event_list",
"event_series_detail": "event_list",
"event_series_update": "event_list",
"event_series_delete": "event_list",
"event_series_stop": "event_list",
"event_series_generate": "event_list",
"location_list": "location_list",
"location_create": "location_list",
"location_update": "location_list",
@@ -74,6 +91,10 @@ _NAV_SECTIONS = {
"opponent_create": "opponent_list",
"opponent_update": "opponent_list",
"opponent_delete": "opponent_list",
"sponsor_list": "sponsor_list",
"sponsor_create": "sponsor_list",
"sponsor_update": "sponsor_list",
"sponsor_delete": "sponsor_list",
"product_list": "product_list",
"order_list": "order_list",
"discount_list": "discount_list",
@@ -124,6 +145,23 @@ def management_link(request):
return {"has_management_access": has_management_access(request.user, club)}
def feature_sections(request):
"""Whether the nav's Shop/Forms sections -- and the Events page's "Import
from RBIHF" button -- should show at all. Each is gated behind its own
waffle Flag (see club.mixins.FeatureRequiredMixin, which gates the
underlying views regardless), on top of the existing is_club_admin check
those all already require."""
club = getattr(request, "club", None)
if club is None or not request.user.is_authenticated:
return {"shop_enabled": False, "forms_enabled": False, "rbihf_enabled": False}
return {
"shop_enabled": flag_is_active(request, "shop"),
"forms_enabled": flag_is_active(request, "formbuilder"),
"rbihf_enabled": flag_is_active(request, "RBIHF"),
}
def news_permissions(request):
"""Whether the "New news item" action should show -- viewing the News
section itself is open to any staff, same as Roster/Staff."""

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

View File

@@ -0,0 +1,84 @@
"""A friendly weekly/monthly recurrence picker on top of EventSeries.rrule (a raw
RFC 5545 string, e.g. "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE") -- covers the common
club-scheduling case (every N weeks on these weekdays, or every N months on the
same day of month) without building a full RRULE UI.
dateutil's rrule recurs on dtstart's own day-of-month for FREQ=MONTHLY with no
BYMONTHDAY needed, which is what keeps monthly this cheap to support alongside
weekly.
Anything that isn't exactly one of the two shapes build_rrule() produces --
hand-written, imported, or from a future frequency this module doesn't cover --
is left alone: parse_rrule() returns None, and EventSeriesForm falls back to
showing the raw string in an "Advanced" field instead of the friendly picker.
"""
import re
from django.utils.translation import gettext_lazy as _
WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
WEEKDAY_LABELS = {
"MO": _("Mon"),
"TU": _("Tue"),
"WE": _("Wed"),
"TH": _("Thu"),
"FR": _("Fri"),
"SA": _("Sat"),
"SU": _("Sun"),
}
WEEKDAY_CHOICES = [(code, WEEKDAY_LABELS[code]) for code in WEEKDAY_CODES]
FREQUENCY_CHOICES = [
("weekly", _("Weekly")),
("monthly", _("Monthly")),
]
_WEEKLY_RE = re.compile(r"^FREQ=WEEKLY;INTERVAL=(\d+);BYDAY=([A-Z,]+)$")
_MONTHLY_RE = re.compile(r"^FREQ=MONTHLY;INTERVAL=(\d+)$")
def build_rrule(frequency, interval, weekdays=None):
"""Compose an RRULE string from the friendly fields. ``weekdays`` is a list
of two-letter codes (e.g. ["MO", "WE"]), required for "weekly", ignored for
"monthly"."""
interval = interval or 1
if frequency == "weekly":
# Preserve WEEKDAY_CODES order regardless of the order the caller passed
# them in, so the same pattern always serializes to the same string.
ordered = [code for code in WEEKDAY_CODES if code in (weekdays or [])]
return f"FREQ=WEEKLY;INTERVAL={interval};BYDAY={','.join(ordered)}"
if frequency == "monthly":
return f"FREQ=MONTHLY;INTERVAL={interval}"
raise ValueError(f"Unknown frequency: {frequency!r}")
def parse_rrule(rrule):
"""The friendly fields that produced ``rrule``, or None if it doesn't match
one of build_rrule()'s two shapes."""
if match := _WEEKLY_RE.match(rrule):
interval, days = match.groups()
return {"frequency": "weekly", "interval": int(interval), "weekdays": days.split(",")}
if match := _MONTHLY_RE.match(rrule):
return {"frequency": "monthly", "interval": int(match.group(1)), "weekdays": []}
return None
def describe_rrule(rrule):
"""A human-readable summary for list/detail pages, e.g. "Every week on Mon,
Wed" or "Every 2 months" -- or the raw string verbatim if it isn't one of
ours (an imported/hand-written RRULE)."""
parsed = parse_rrule(rrule)
if parsed is None:
return rrule
interval = parsed["interval"]
if parsed["frequency"] == "weekly":
days = ", ".join(str(WEEKDAY_LABELS[code]) for code in parsed["weekdays"] if code in WEEKDAY_LABELS)
if not days:
return _("Every week") if interval == 1 else _("Every %(n)d weeks") % {"n": interval}
return _("Every week on %(days)s") % {"days": days} if interval == 1 else _("Every %(n)d weeks on %(days)s") % {"n": interval, "days": days}
return _("Every month") if interval == 1 else _("Every %(n)d months") % {"n": interval}

View File

@@ -45,11 +45,11 @@ leaves it unset, since staying on the family page is already the right place the
{% if is_club_admin %}
<div class="flex justify-end gap-1">
{% if person.grant_login_form %}
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('grant_login_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Grant login' %}">
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('grant_login_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Grant login' %}">
{% lucide "key-round" size=14 %} {% trans "Grant login" %}
</button>
{% endif %}
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:member_detail' person.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<a class="btn btn-outline btn-sm" href="{% url 'management:member_detail' person.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('remove_family_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Remove from family' %}">
{% lucide "user-x" size=14 %} {% trans "Remove" %}
</button>

View File

@@ -30,19 +30,25 @@
<li class="menu-title">{% trans "Calendar" %}</li>
<li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li>
<li><a class="{% if nav == 'event_series_list' %}menu-active{% endif %}" href="{% url 'management:event_series_list' %}">{% lucide "repeat" size=16 %} {% trans "Event series" %}</a></li>
{% if has_management_position %}
<li><a class="{% if nav == 'location_list' %}menu-active{% endif %}" href="{% url 'management:location_list' %}">{% lucide "map-pin" size=16 %} {% trans "Locations" %}</a></li>
<li><a class="{% if nav == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</a></li>
{% endif %}
{% if is_club_admin %}
<li class="menu-title">{% trans "Sponsors" %}</li>
<li><a class="{% if nav == 'sponsor_list' %}menu-active{% endif %}" href="{% url 'management:sponsor_list' %}">{% lucide "handshake" size=16 %} {% trans "Sponsors" %}</a></li>
{% endif %}
{% if is_club_admin and shop_enabled %}
<li class="menu-title">{% trans "Shop" %}</li>
<li><a class="{% if nav == 'product_list' %}menu-active{% endif %}" href="{% url 'management:product_list' %}">{% lucide "package" size=16 %} {% trans "Products" %}</a></li>
<li><a class="{% if nav == 'order_list' %}menu-active{% endif %}" href="{% url 'management:order_list' %}">{% lucide "shopping-cart" size=16 %} {% trans "Orders" %}</a></li>
<li><a class="{% if nav == 'discount_list' %}menu-active{% endif %}" href="{% url 'management:discount_list' %}">{% lucide "percent" size=16 %} {% trans "Discounts" %}</a></li>
<li><a class="{% if nav == 'invoice_list' %}menu-active{% endif %}" href="{% url 'management:invoice_list' %}">{% lucide "receipt" size=16 %} {% trans "Invoices" %}</a></li>
{% endif %}
{% if is_club_admin and forms_enabled %}
<li class="menu-title">{% trans "Forms" %}</li>
<li><a class="{% if nav == 'form_list' %}menu-active{% endif %}" href="{% url 'management:form_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Forms" %}</a></li>
{% endif %}

View File

@@ -0,0 +1,215 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{{ event.title }}{% endblock heading %}
{% block subheading %}{{ event.get_kind_display }}{% endblock subheading %}
{% block actions %}
{% if can_manage %}
<a class="btn btn-outline gap-2" href="{% url 'management:event_update' event.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
{% if event.series_id and not event.detached %}
<form method="post" action="{% url 'management:event_detach' event.pk %}">
{% csrf_token %}
<button class="btn btn-outline gap-2" type="submit">{% lucide "unlink" size=16 %} {% trans "Detach from series" %}</button>
</form>
{% endif %}
<button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('event_delete_modal').showModal()">
{% lucide "trash-2" size=16 %}
{% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete" %}{% endif %}
</button>
{% endif %}
{% endblock actions %}
{% block panel %}
{% if event.series_id %}
<div class="alert alert-info mb-6">
{% lucide "repeat" size=20 %}
<span>
{% blocktrans with series=event.series %}Part of the recurring series “{{ series }}”.{% endblocktrans %}
<a class="link" href="{% url 'management:event_series_detail' event.series_id %}">{% trans "View series" %}</a>
{% if event.detached %}<span class="badge badge-sm badge-neutral ml-2">{% trans "Detached" %}</span>{% endif %}
</span>
</div>
{% endif %}
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Details" %}</h2>
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Start" %}</dt>
<dd>{{ event.start|date:"j M Y H:i" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "End" %}</dt>
<dd>{{ event.end|date:"j M Y H:i"|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Gathering" %}</dt>
<dd>{{ event.gathering|date:"j M Y H:i"|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Registration deadline" %}</dt>
<dd>{{ event.deadline|date:"j M Y H:i"|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Location" %}</dt>
<dd>{{ event.location|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Opponent" %}</dt>
<dd>{{ event.opponent|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Teams" %}</dt>
<dd>{% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
</div>
</dl>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "user-check" size=18 %} {% trans "RSVPs" %}</h2>
{% if has_attendance_rows %}
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('rsvp_modal').showModal()">
{% lucide "list" size=14 %} {% trans "View responses" %}
</button>
{% endif %}
</div>
<dl class="divide-y divide-base-200">
{% for group in attendance_groups %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{{ group.label }}</dt>
<dd class="font-semibold tabular-nums font-mono">{{ group.rows|length }}</dd>
</div>
{% endfor %}
</dl>
{% if not has_attendance_rows %}
<p class="text-sm opacity-60">{% trans "No one invited yet." %}</p>
{% endif %}
</div>
</div>
</div>
{% if event.kind == "game" %}
<div class="mb-6 card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">
{% lucide "trophy" size=18 %} {% trans "Game" %}
{% if event.is_live %}<span class="badge badge-error gap-1 animate-pulse">{% lucide "circle" size=10 %} {% trans "Live" %}</span>{% endif %}
</h2>
{% if can_manage and event.competition %}
<form method="post" action="{% url 'management:event_fetch_game_info' event.pk %}">
{% csrf_token %}
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "refresh-cw" size=14 %} {% trans "Fetch new game info" %}</button>
</form>
{% endif %}
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<div class="text-sm opacity-70">{% trans "Score" %}</div>
<div class="text-sm font-semibold tabular-nums font-mono">
{% if event.score_for is not None and event.score_against is not None %}{{ event.score_for }} - {{ event.score_against }}{% else %}—{% endif %}
</div>
</div>
<div>
<div class="text-sm opacity-70">{% trans "Competition" %}</div>
<div class="text-sm">{{ event.competition|default:"—" }}</div>
</div>
<div>
<div class="text-sm opacity-70">{% trans "External game ID" %}</div>
<div class="font-mono text-sm">{{ event.external_game_id|default:"—" }}</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock panel %}
{% block extra_body %}
{% if can_manage %}
<dialog id="event_delete_modal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">
{% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete event" %}{% endif %}
</h3>
<form method="post" action="{% url 'management:event_delete' event.pk %}" id="event_delete_form">
{% csrf_token %}
{% if event.series_id %}
<p class="py-2 text-sm opacity-70">{% trans "Removes this occurrence from the schedule. It won't be regenerated." %}</p>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="keep_record" class="checkbox">
<span class="label-text">{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
</label>
{% else %}
<p class="py-2 text-sm opacity-70">{% trans "This cannot be undone." %}</p>
{% endif %}
</form>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
</form>
<button class="btn btn-error gap-2" type="submit" form="event_delete_form">{% lucide "trash-2" size=16 %} {% trans "Confirm" %}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{% endif %}
{% if has_attendance_rows %}
<dialog id="rsvp_modal" class="modal">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold">{% trans "Who responded" %}</h3>
<div class="mt-2 space-y-2">
{% for group in attendance_groups %}
{% if group.rows %}
<details class="collapse collapse-arrow border border-base-300 bg-base-100">
<summary class="collapse-title text-sm font-medium">
<span class="badge badge-sm
{% if group.value == "present" %}badge-success
{% elif group.value == "absent" %}badge-error
{% elif group.value == "excused" %}badge-warning
{% elif group.value == "selected" %}badge-info
{% elif group.value == "maybe" %}badge-warning badge-outline
{% elif group.value == "not_selected" %}badge-neutral
{% else %}badge-outline{% endif %}">
{{ group.label }}
</span>
<span class="opacity-60">({{ group.rows|length }})</span>
</summary>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-sm">
<tbody>
{% for row in group.rows %}
<tr>
<td>{{ row.member }}</td>
<td class="text-sm opacity-70">{{ row.note|default:"—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</details>
{% endif %}
{% endfor %}
</div>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Close" %}</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{% endif %}
{% endblock extra_body %}

View File

@@ -0,0 +1,96 @@
{% extends "management/base.html" %}
{% load i18n lucide static ui %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New event" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.title %}
{% form_field form.kind %}
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% form_field form.teams %}
{% form_field form.invited_members %}
{% form_field form.excluded_members %}
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Where" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.location %}
<div class="game-only">
{% form_field form.opponent %}
</div>
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "When" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
{% form_field form.start %}
{% form_field form.end %}
{% form_field form.gathering %}
{% form_field form.deadline %}
</div>
<div id="game-details-section" class="game-only">
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Game" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.competition %}
{% form_field form.external_game_id %}
</div>
{% if update_view %}
{% comment %}
Score/live status only exist to record once a game has
actually been created -- the add form leaves them out
entirely (see EventForm's editing=False path).
{% endcomment %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% form_field form.score_for %}
{% form_field form.score_against %}
{% form_field form.is_live %}
</div>
{% endif %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:event_detail" object.pk %}{% else %}{% url "management:event_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}
{% block extra_body %}
<script src="{% static 'js/searchable-select.js' %}"></script>
<script>
(() => {
// Progressive enhancement only -- the fields stay in the form and post
// normally either way, this just hides them when they don't apply so a
// training/social/etc. event isn't cluttered with opponent/score/competition
// inputs. Anything gated on "only makes sense for a game" carries this class.
const kindField = document.getElementById("id_kind");
const gameOnlyElements = document.querySelectorAll(".game-only");
if (!kindField || !gameOnlyElements.length) return;
const sync = () => gameOnlyElements.forEach((element) => element.classList.toggle("hidden", kindField.value !== "game"));
kindField.addEventListener("change", sync);
sync();
})();
</script>
{% endblock extra_body %}

View File

@@ -0,0 +1,118 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Events" %}{% endblock heading %}
{% block actions %}
{% if is_club_admin and rbihf_enabled %}
<a class="btn btn-outline gap-2" href="{% url 'management:rbihf_import' %}">{% lucide "download" size=16 %} {% trans "Import from RBIHF" %}</a>
{% endif %}
{% if can_create %}
<a class="btn btn-outline gap-2" href="{% url 'management:event_series_create' %}">{% lucide "repeat" size=16 %} {% trans "New series" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:event_create' %}">{% lucide "plus" size=16 %} {% trans "New event" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<form method="get" class="mb-4">
<div class="flex flex-row flex-wrap items-center gap-2">
<select name="season" class="select select-bordered">
{% for season in seasons %}
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
{% endfor %}
</select>
<select name="kind" class="select select-bordered">
<option value="">{% trans "All kinds" %}</option>
{% for value, label in event_kinds %}
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<label class="label cursor-pointer gap-2">
<input type="checkbox" name="show_past" value="1" class="checkbox" {% if show_past %}checked{% endif %} onchange="this.form.submit()">
<span class="label-text">{% trans "Show past events" %}</span>
</label>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "View" %}</button>
</div>
</form>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Title" %}</th>
<th>{% trans "Kind" %}</th>
<th>{% trans "When" %}</th>
<th>{% trans "Teams" %}</th>
<th>{% trans "Location" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td class="flex flex-row items-center gap-2">
<a class="link link-hover" href="{% url 'management:event_detail' event.pk %}">{{ event.title }}</a>
{% if event.series_id %}<span class="tooltip" data-tip="{% trans 'Part of a series' %}">{% lucide "repeat" size=12 %}</span>{% endif %}
</td>
<td>{{ event.get_kind_display }}</td>
<td>{{ event.start|date:"j M Y H:i" }}</td>
<td>{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
<td>{{ event.location.name|default:"—" }}</td>
<td class="text-right">
{% if event.can_manage %}
<div class="flex justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:event_detail' event.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ event.pk|dom_id:"event_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center opacity-60">{% trans "No events." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for event in events %}
{% if event.can_manage %}
<dialog id="{{ event.pk|dom_id:"event_delete_modal" }}" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">
{% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete event" %}{% endif %}
</h3>
<form method="post" action="{% url 'management:event_delete' event.pk %}" id="{{ event.pk|dom_id:"event_delete_form" }}">
{% csrf_token %}
{% if event.series_id %}
{% blocktrans with name=event.title asvar delete_body %}Removes “{{ name }}” from the schedule. It won't be regenerated.{% endblocktrans %}
<p class="py-2 text-sm opacity-70">{{ delete_body }}</p>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="keep_record" class="checkbox">
<span class="label-text">{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
</label>
{% else %}
{% blocktrans with name=event.title asvar delete_body %}Delete “{{ name }}”? This cannot be undone.{% endblocktrans %}
<p class="py-2 text-sm opacity-70">{{ delete_body }}</p>
{% endif %}
</form>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
</form>
<button class="btn btn-error gap-2" type="submit" form="{{ event.pk|dom_id:"event_delete_form" }}">{% lucide "trash-2" size=16 %} {% trans "Confirm" %}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{% endif %}
{% endfor %}
{% endblock panel %}

View File

@@ -0,0 +1,117 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{{ series.title }}{% endblock heading %}
{% block subheading %}{{ series.get_kind_display }} · {{ recurrence_summary }}{% endblock subheading %}
{% block actions %}
{% if can_manage %}
<a class="btn btn-outline gap-2" href="{% url 'management:event_series_update' series.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
<form method="post" action="{% url 'management:event_series_generate' series.pk %}">
{% csrf_token %}
<button class="btn btn-outline gap-2" type="submit">{% lucide "refresh-cw" size=16 %} {% trans "Regenerate occurrences" %}</button>
</form>
<button class="btn btn-outline btn-warning gap-2" type="button" onclick="document.getElementById('series_stop_modal').showModal()">{% lucide "octagon-pause" size=16 %} {% trans "Stop repeating" %}</button>
<button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('series_delete_modal').showModal()">{% lucide "trash-2" size=16 %} {% trans "Delete series" %}</button>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} {% trans "Recurrence" %}</h2>
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Pattern" %}</dt>
<dd>{{ recurrence_summary }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "First occurrence" %}</dt>
<dd>{{ series.dtstart|date:"j M Y H:i" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Repeats until" %}</dt>
<dd>{{ series.until|date:"j M Y H:i"|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Generated up to" %}</dt>
<dd>{{ series.generated_until|date:"j M Y"|default:"—" }}</dd>
</div>
</dl>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Template" %}</h2>
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Location" %}</dt>
<dd>{{ series.location|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Opponent" %}</dt>
<dd>{{ series.opponent|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Teams" %}</dt>
<dd>{% for team in series.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
</div>
</dl>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Occurrences" %}</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "When" %}</th>
<th>{% trans "Status" %}</th>
</tr>
</thead>
<tbody>
{% for occurrence in occurrences %}
<tr>
<td><a class="link link-hover" href="{% url 'management:event_detail' occurrence.pk %}">{{ occurrence.start|date:"j M Y H:i" }}</a></td>
<td>
{% if occurrence.cancelled %}
<span class="badge badge-outline gap-1">{% lucide "ban" size=12 %} {% trans "Cancelled" %}</span>
{% elif occurrence.detached %}
<span class="badge badge-neutral gap-1">{% lucide "unlink" size=12 %} {% trans "Detached" %}</span>
{% elif occurrence.is_past %}
<span class="badge badge-outline">{% trans "Past" %}</span>
{% else %}
<span class="badge badge-success">{% trans "Upcoming" %}</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center opacity-60">{% trans "No occurrences generated yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}
{% block extra_body %}
{% if can_manage %}
{% trans "Stop repeating" as stop_title %}
{% blocktrans with name=series.title asvar stop_body %}Stop “{{ name }}” from generating new occurrences? Existing ones (past and future) are left exactly as they are — this only affects what happens from here on.{% endblocktrans %}
{% url 'management:event_series_stop' series.pk as stop_url %}
{% include "controlpanel/_confirm_modal.html" with modal_id="series_stop_modal" title=stop_title body=stop_body action_url=stop_url submit_label=stop_title submit_icon="octagon-pause" %}
{% trans "Delete series" as delete_title %}
{% blocktrans with name=series.title asvar delete_body %}Delete “{{ name }}”? This also deletes every occurrence it has ever generated, past and future, and their attendance records. This cannot be undone — use “Stop repeating” instead if you just want it to stop.{% endblocktrans %}
{% url 'management:event_series_delete' series.pk as delete_url %}
{% include "controlpanel/_confirm_modal.html" with modal_id="series_delete_modal" title=delete_title body=delete_body action_url=delete_url submit_label=delete_title %}
{% endif %}
{% endblock extra_body %}

View File

@@ -0,0 +1,75 @@
{% extends "management/base.html" %}
{% load i18n lucide static ui %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New series" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.title %}
{% form_field form.kind %}
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Repeats" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% form_field form.frequency %}
{% form_field form.interval %}
{% form_field form.dtstart %}
{% form_field form.until %}
</div>
{% trans "Only used for a weekly pattern -- a monthly one repeats on the same day of the month as the first occurrence above." as weekdays_help %}
{% form_field form.weekdays help_text=weekdays_help %}
<details class="collapse collapse-arrow bg-base-200 mt-2">
<summary class="collapse-title text-sm font-medium">{% trans "Advanced: raw recurrence rule" %}</summary>
<div class="collapse-content">
{% form_field form.advanced_rrule %}
</div>
</details>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% form_field form.teams %}
{% form_field form.invited_members %}
{% form_field form.excluded_members %}
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Where" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.location %}
{% form_field form.opponent %}
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Timing" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
{% form_field form.duration_hours %}
{% form_field form.duration_minutes %}
{% form_field form.gathering_minutes_before %}
{% form_field form.deadline_minutes_before %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:event_series_detail" object.pk %}{% else %}{% url "management:event_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}
{% block extra_body %}
<script src="{% static 'js/searchable-select.js' %}"></script>
{% endblock extra_body %}

View File

@@ -7,8 +7,8 @@
{% if is_club_admin %}
{% trans "Add parent" as add_parent_label %}
{% trans "Add child" as add_child_label %}
<button class="btn btn-outline btn-neutral gap-2" type="button" onclick="document.getElementById('add_parent_modal').showModal()">{% lucide "user-plus" size=16 %} {{ add_parent_label }}</button>
<button class="btn btn-outline btn-neutral gap-2" type="button" onclick="document.getElementById('add_child_modal').showModal()">{% lucide "baby" size=16 %} {{ add_child_label }}</button>
<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('add_parent_modal').showModal()">{% lucide "user-plus" size=16 %} {{ add_parent_label }}</button>
<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('add_child_modal').showModal()">{% lucide "baby" size=16 %} {{ add_child_label }}</button>
{% endif %}
{% endblock actions %}

View File

@@ -34,7 +34,7 @@
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:member_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<a class="btn btn-outline gap-2" href="{% url "management:member_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>

View File

@@ -14,6 +14,19 @@
</div>
{% endif %}
{% if is_club_admin and billing_ends_at %}
<div class="alert alert-warning mb-6">
{% lucide "calendar-clock" size=20 %}
<span>
{% if billing_auto_renews %}
{% blocktrans with date=billing_ends_at|date:"j M Y" %}Your current billing period ends {{ date }} and will renew automatically.{% endblocktrans %}
{% else %}
{% blocktrans with date=billing_ends_at|date:"j M Y" %}Your current billing period ends {{ date }} and is not set to renew automatically — billing is about to stop. Contact us to keep access.{% endblocktrans %}
{% endif %}
</span>
</div>
{% endif %}
{% comment %}
The club's own numbers that should be zero -- same attention/chart/stat-group
data controlpanel/club_detail.html shows a platform admin drilling into this
@@ -82,7 +95,7 @@
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Upcoming events" %}</h2>
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
</div>
<div class="overflow-x-auto">
<table class="table">
@@ -91,7 +104,8 @@
<tr>
<td class="whitespace-nowrap">{{ event.start|date:"D j M, H:i" }}</td>
<td>{{ event.title }}</td>
<td>{{ event.get_kind_display|capfirst }}</td>
<td>{{ event.get_kind_display }}</td>
<td>{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
</tr>
{% empty %}
<tr>
@@ -108,7 +122,7 @@
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "newspaper" size=18 %} {% trans "News" %}</h2>
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:news_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:news_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
</div>
<div class="overflow-x-auto">
<table class="table">

View File

@@ -22,7 +22,7 @@
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:location_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<a class="btn btn-outline gap-2" href="{% url "management:location_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>

View File

@@ -33,7 +33,7 @@
<td>{{ location.country }}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:location_update' location.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<a class="btn btn-outline btn-sm" href="{% url 'management:location_update' location.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ location.pk|dom_id:"location_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
</td>

View File

@@ -6,7 +6,7 @@
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_update' member.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:member_update' member.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
{% endif %}
{% endblock actions %}
@@ -114,8 +114,8 @@
</h2>
{% if is_club_admin %}
<div class="flex gap-2">
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_parent_modal" }}').showModal()">{% lucide "user-plus" size=14 %} {{ add_parent_label }}</button>
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_child_modal" }}').showModal()">{% lucide "baby" size=14 %} {{ add_child_label }}</button>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_parent_modal" }}').showModal()">{% lucide "user-plus" size=14 %} {{ add_parent_label }}</button>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_child_modal" }}').showModal()">{% lucide "baby" size=14 %} {{ add_child_label }}</button>
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"detach_family_modal" }}').showModal()">{% lucide "user-x" size=14 %} {{ remove_from_family_label }}</button>
</div>
{% endif %}
@@ -140,7 +140,7 @@
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "users" size=18 %} {% trans "Family" %}</h2>
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('attach_family_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add to family" %}</button>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('attach_family_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add to family" %}</button>
</div>
{% if not family_groups %}
<p class="text-sm opacity-60">{% trans "Not part of a family." %}</p>

View File

@@ -45,7 +45,7 @@
{% endif %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:member_detail" object.pk %}{% else %}{% url "management:member_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:member_detail" object.pk %}{% else %}{% url "management:member_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>

View File

@@ -4,7 +4,7 @@
{% block heading %}{% trans "Mass upload members" %}{% endblock heading %}
{% block actions %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_import_template' %}">{% lucide "download" size=16 %} {% trans "Download template" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:member_import_template' %}">{% lucide "download" size=16 %} {% trans "Download template" %}</a>
{% endblock actions %}
{% block panel %}
@@ -30,7 +30,7 @@
{% endfor %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:member_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "upload" size=16 %} {% trans "Upload and preview" %}</button>
</div>
</form>

View File

@@ -55,7 +55,7 @@
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:member_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
{% if valid_count %}
<form method="post" action="{% url 'management:member_import_confirm' %}">
{% csrf_token %}

View File

@@ -7,8 +7,8 @@
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import_template' %}" xmlns:management="http://www.w3.org/1999/xhtml">{% lucide "download" size=16 %} {% trans "Download upload template" %}</a>
{% if is_club_admin %}
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import' %}">{% lucide "upload" size=16 %} {% trans "Mass upload" %}</a>
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=16 %} {% trans "Add member" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=16 %} {% trans "Add member" %}</a>
{% endif %}
{% endblock actions %}
@@ -18,9 +18,9 @@
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered w-full max-w-xs">
</label>
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "search" size=16 %} {% trans "Search" %}</button>
<button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=16 %} {% trans "Search" %}</button>
{% if search %}
<a class="btn btn-neutral gap-2" href="{% url "management:member_list" %}">{% lucide "x" size=16 %} {% trans "Clear filter" %}</a>
<a class="btn gap-2" href="{% url "management:member_list" %}">{% lucide "x" size=16 %} {% trans "Clear filter" %}</a>
{% endif %}
</form>
@@ -49,7 +49,7 @@
<div class="flex flex-col gap-1">
{% for fm in member.family_memberships_display %}
<div>
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:family_detail' fm.family.pk %}">{% lucide "users" size=14 %} {{ fm.family }} {% lucide "arrow-right" size=14 %}</a>
<a class="btn btn-outline btn-sm" href="{% url 'management:family_detail' fm.family.pk %}">{% lucide "users" size=14 %} {{ fm.family }} {% lucide "arrow-right" size=14 %}</a>
</div>
{% endfor %}
</div>
@@ -73,7 +73,7 @@
<td class="text-right">
{% if is_club_admin %}
<div class="flex justify-end gap-1">
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:member_detail' member.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<a class="btn btn-outline btn-sm" href="{% url 'management:member_detail' member.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ member.pk|dom_id:"member_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
{% endif %}

View File

@@ -5,7 +5,7 @@
{% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %}
{% block actions %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:membership_export_pdf' %}?{{ request.GET.urlencode }}">{% lucide "file-down" size=16 %} {% trans "Export to PDF" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:membership_export_pdf' %}?{{ request.GET.urlencode }}">{% lucide "file-down" size=16 %} {% trans "Export to PDF" %}</a>
{% endblock actions %}
{% block panel %}
@@ -93,8 +93,8 @@
{% endfor %}
</select>
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
<a class="btn btn-neutral gap-2" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
<a class="btn gap-2" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a>
</div>
</form>
@@ -173,7 +173,7 @@
<td class="text-right">
{% if membership.record_payment_form %}
<div class="flex justify-end gap-1">
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"record_payment_modal" }}').showModal()">
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"record_payment_modal" }}').showModal()">
{% lucide "receipt" size=12 %} {% trans "Record payment" %}
</button>
<button class="btn btn-sm btn-outline btn-success" type="submit" form="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">

View File

@@ -15,7 +15,7 @@
{% block actions %}
{% if can_edit %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:news_update' news_item.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:news_update' news_item.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
<button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('delete_news_modal').showModal()">{% lucide "trash-2" size=16 %} {% trans "Delete" %}</button>
{% endif %}
{% if can_publish %}
@@ -46,7 +46,7 @@
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Photos" %}</h2>
{% if can_edit %}
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('add_photos_modal').showModal()">{% lucide "image-plus" size=14 %} {% trans "Add photos" %}</button>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_photos_modal').showModal()">{% lucide "image-plus" size=14 %} {% trans "Add photos" %}</button>
{% endif %}
</div>
@@ -62,7 +62,7 @@
{% if not photo.is_main %}
<form method="post" action="{% url 'management:news_photo_set_main' news_item.pk photo.pk %}">
{% csrf_token %}
<button class="btn btn-outline btn-neutral btn-xs gap-1" type="submit">{% lucide "star" size=12 %} {% trans "Set main" %}</button>
<button class="btn btn-outline btn-xs gap-1" type="submit">{% lucide "star" size=12 %} {% trans "Set main" %}</button>
</form>
{% endif %}
<button class="btn btn-outline btn-error btn-xs gap-1" type="button" onclick="document.getElementById('{{ photo.pk|dom_id:"delete_photo_modal" }}').showModal()">{% lucide "trash-2" size=12 %} {% trans "Delete" %}</button>

View File

@@ -26,7 +26,7 @@
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:news_detail" object.pk %}{% else %}{% url "management:news_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:news_detail" object.pk %}{% else %}{% url "management:news_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>

View File

@@ -5,7 +5,7 @@
{% block actions %}
{% if can_add_news %}
<a class="btn btn-primary gap-2" href="{% url 'management:news_create' %}">{% lucide "plus" size=16 %} {% trans "New news item" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:news_create' %}">{% lucide "plus" size=16 %} {% trans "New news item" %}</a>
{% endif %}
{% endblock actions %}
@@ -47,7 +47,7 @@
<td class="text-right">
{% if news_item.can_edit %}
<div class="flex justify-end gap-1">
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:news_detail' news_item.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<a class="btn btn-outline btn-sm" href="{% url 'management:news_detail' news_item.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ news_item.pk|dom_id:"news_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
{% endif %}

View File

@@ -22,7 +22,7 @@
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:opponent_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<a class="btn btn-outline gap-2" href="{% url "management:opponent_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>

View File

@@ -30,7 +30,7 @@
<td>{{ opponent.name }}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:opponent_update' opponent.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<a class="btn btn-outline btn-sm" href="{% url 'management:opponent_update' opponent.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ opponent.pk|dom_id:"opponent_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
</td>

View File

@@ -29,7 +29,7 @@
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:position_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<a class="btn btn-outline gap-2" href="{% url "management:position_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>

View File

@@ -5,7 +5,7 @@
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-primary gap-2" href="{% url 'management:position_create' %}">{% lucide "plus" size=16 %} {% trans "New position" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:position_create' %}">{% lucide "plus" size=16 %} {% trans "New position" %}</a>
{% endif %}
{% endblock actions %}
@@ -42,7 +42,7 @@
</td>
<td class="text-right">
{% if is_club_admin %}
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:position_update' position.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:position_update' position.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
{% endif %}
</td>
</tr>

View File

@@ -0,0 +1,35 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% block heading %}{% trans "Import from RBIHF" %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<p class="text-sm opacity-70">
{% blocktrans %}Paste the URL of a team's page on the RBIHF website and pick which of
your teams it's for. Nothing is created yet: you'll see exactly what will
be added, changed, or removed before anything is saved.{% endblocktrans %}
</p>
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
{% form_field field %}
{% endfor %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "download" size=16 %} {% trans "Fetch and preview" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,152 @@
{% extends "management/base.html" %}
{% load lucide i18n %}
{% block heading %}{% trans "Review import" %}{% endblock heading %}
{% block panel %}
<div class="alert alert-info mb-4">
{% lucide "info" size=16 %}
<span>{% blocktrans with scraped=plan.scraped_team_name team=plan.team %}Importing fixtures for “{{ scraped }}” onto your team “{{ team }}”. Double check that's the right match before confirming.{% endblocktrans %}</span>
</div>
<form method="post" action="{% url 'management:rbihf_import_confirm' %}">
{% csrf_token %}
<div class="card bg-base-100 shadow mb-4">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "plus" size=18 %} {% blocktrans count counter=plan.to_create|length %}{{ counter }} game to create{% plural %}{{ counter }} games to create{% endblocktrans %}</h2>
{% if plan.to_create %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "#" %}</th>
<th>{% trans "Start" %}</th>
<th>{% trans "Opponent" %}</th>
<th>{% trans "Location" %}</th>
</tr>
</thead>
<tbody>
{% for planned in plan.to_create %}
<tr>
<td>{{ planned.fixture.external_game_id }}</td>
<td>{{ planned.fixture.start|date:"Y-m-d H:i" }}</td>
<td>
<div class="flex items-center gap-2">
<select name="opponent_{{ planned.fixture.external_game_id }}" class="select select-sm w-full">
<option value="">{% trans "Use scraped name" %}</option>
{% for opponent in plan.opponent_choices %}
<option value="{{ opponent.pk }}" {% if planned.suggested_opponent.pk == opponent.pk %}selected{% endif %}>{{ opponent.name }}</option>
{% endfor %}
</select>
<span class="text-xs opacity-60 whitespace-nowrap">{% blocktrans with opponent=planned.fixture.opponent_name %}RBIHF: {{ opponent }}{% endblocktrans %}</span>
</div>
</td>
<td>
<div class="flex items-center gap-2">
<select name="location_{{ planned.fixture.external_game_id }}" class="select select-sm w-full">
<option value="">&mdash;</option>
{% for location in plan.location_choices %}
<option value="{{ location.pk }}" {% if planned.suggested_location.pk == location.pk %}selected{% endif %}>{{ location.name }}</option>
{% endfor %}
</select>
<span class="text-xs opacity-60 whitespace-nowrap">{% blocktrans with venue=planned.fixture.venue_text %}RBIHF: {{ venue }}{% endblocktrans %}</span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
<div class="card bg-base-100 shadow mb-4">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "refresh-cw" size=18 %} {% blocktrans count counter=plan.to_update|length %}{{ counter }} game to update{% plural %}{{ counter }} games to update{% endblocktrans %}</h2>
{% if plan.to_update %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "#" %}</th>
<th>{% trans "Changes" %}</th>
<th>{% trans "Opponent" %}</th>
<th>{% trans "Location" %}</th>
</tr>
</thead>
<tbody>
{% for planned in plan.to_update %}
<tr>
<td>{{ planned.fixture.external_game_id }}</td>
<td>
{% for field, change in planned.changes.items %}
<div class="text-xs">
<span class="font-semibold">{{ field }}:</span>
<span class="opacity-60 line-through">{{ change.0|default:"—" }}</span>
&rarr;
<span>{{ change.1 }}</span>
</div>
{% endfor %}
</td>
<td>
<div class="flex items-center gap-2">
<select name="opponent_{{ planned.fixture.external_game_id }}" class="select select-sm w-full">
<option value="">{% trans "Use scraped name" %}</option>
{% for opponent in plan.opponent_choices %}
<option value="{{ opponent.pk }}" {% if planned.suggested_opponent.pk == opponent.pk %}selected{% endif %}>{{ opponent.name }}</option>
{% endfor %}
</select>
<span class="text-xs opacity-60 whitespace-nowrap">{% blocktrans with opponent=planned.fixture.opponent_name %}RBIHF: {{ opponent }}{% endblocktrans %}</span>
</div>
</td>
<td>
<div class="flex items-center gap-2">
<select name="location_{{ planned.fixture.external_game_id }}" class="select select-sm w-full">
<option value="">&mdash;</option>
{% for location in plan.location_choices %}
<option value="{{ location.pk }}" {% if planned.suggested_location.pk == location.pk %}selected{% endif %}>{{ location.name }}</option>
{% endfor %}
</select>
<span class="text-xs opacity-60 whitespace-nowrap">{% blocktrans with venue=planned.fixture.venue_text %}RBIHF: {{ venue }}{% endblocktrans %}</span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
<div class="card bg-base-100 shadow mb-4">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "trash-2" size=18 %} {% blocktrans count counter=plan.to_delete|length %}{{ counter }} game to delete{% plural %}{{ counter }} games to delete{% endblocktrans %}</h2>
<p class="text-sm opacity-70">{% trans "No longer listed on the RBIHF page — only upcoming games are ever removed this way, past results are left alone." %}</p>
{% if plan.to_delete %}
<ul class="list-disc list-inside text-sm">
{% for event in plan.to_delete %}
<li>{{ event.start|date:"Y-m-d H:i" }} &mdash; {{ event }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% if plan.unchanged_count %}
<p class="text-sm opacity-60 mb-4">{% blocktrans count counter=plan.unchanged_count %}{{ counter }} game is already up to date.{% plural %}{{ counter }} games are already up to date.{% endblocktrans %}</p>
{% endif %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline gap-2" href="{% url 'management:rbihf_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
{% if plan.to_create or plan.to_update or plan.to_delete %}
<button class="btn btn-primary gap-2" type="submit">{% lucide "check" size=16 %} {% trans "Confirm import" %}</button>
{% endif %}
</div>
</form>
{% endblock panel %}

View File

@@ -12,7 +12,7 @@
{% block heading %}{% trans "Roles" %}{% endblock heading %}
{% block actions %}
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('grant_role_modal').showModal()">{% lucide "plus" size=16 %} {% trans "Grant role" %}</button>
<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('grant_role_modal').showModal()">{% lucide "plus" size=16 %} {% trans "Grant role" %}</button>
{% endblock actions %}
{% block panel %}

View File

@@ -0,0 +1,31 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New sponsor" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline gap-2" href="{% url "management:sponsor_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,68 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Sponsors" %}{% endblock heading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'management:sponsor_create' %}">{% lucide "plus" size=16 %} {% trans "New sponsor" %}</a>
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th></th>
<th>{% trans "Name" %}</th>
<th>{% trans "URL" %}</th>
<th>{% trans "Start date" %}</th>
<th>{% trans "End date" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for sponsor in sponsors %}
<tr>
<td>
{% if sponsor.logo %}
<img src="{{ sponsor.logo.url }}" alt="" class="size-8 rounded object-contain">
{% endif %}
</td>
<td>{{ sponsor.name }}</td>
<td>
{% if sponsor.url %}
<a class="link link-hover" href="{{ sponsor.url }}" target="_blank" rel="noopener noreferrer">{{ sponsor.url }}</a>
{% else %}
<span class="opacity-50">&mdash;</span>
{% endif %}
</td>
<td>{{ sponsor.start_date|date:"Y-m-d" }}</td>
<td>{{ sponsor.end_date|date:"Y-m-d"|default:"—" }}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:sponsor_update' sponsor.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ sponsor.pk|dom_id:"sponsor_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center opacity-60">{% trans "No sponsors yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% trans "Delete sponsor" as delete_sponsor_title %}
{% trans "Delete" as delete_label %}
{% for sponsor in sponsors %}
{% url 'management:sponsor_delete' sponsor.pk as sponsor_delete_url %}
{% blocktrans with name=sponsor.name asvar delete_sponsor_body %}Delete “{{ name }}”? This cannot be undone.{% endblocktrans %}
{% include "controlpanel/_confirm_modal.html" with modal_id=sponsor.pk|dom_id:"sponsor_delete_modal" title=delete_sponsor_title body=delete_sponsor_body action_url=sponsor_delete_url submit_label=delete_label %}
{% endfor %}
{% endblock panel %}

View File

@@ -2,11 +2,17 @@
{% load i18n lucide static ui %}
{% block heading %}{{ team.name }}{% endblock heading %}
{% block subheading %}{{ team.short_name }}{% endblock subheading %}
{% block subheading %}
<span class="flex flex-row gap-2 items-center">
<span>{{ team.short_name }}</span>
<span>&middot;</span>
<span class="badge-sm badge badge-outline">{% lucide "bookmark" size=14 %} {{ team.pk }}</span>
</span>
{% endblock subheading %}
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:team_update' team.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:team_update' team.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
{% endif %}
{% endblock actions %}
@@ -18,7 +24,7 @@
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
{% endfor %}
</select>
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "View" %}</button>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "View" %}</button>
</div>
</form>
@@ -28,6 +34,23 @@
<span>{% trans "This club has no seasons yet, so there's no roster or staff to show." %}</span>
</div>
{% else %}
{% if can_manage %}
{% if team_photo %}
{% trans "Replace photo" as photo_modal_title %}
{% else %}
{% trans "Upload photo" as photo_modal_title %}
{% endif %}
{% url 'management:team_photo_set' team.pk selected_season.pk as team_photo_set_url %}
{% include "controlpanel/_modal_form.html" with modal_id="team_photo_modal" title=photo_modal_title form=team_photo_form action_url=team_photo_set_url submit_label="Save" submit_icon="save" %}
{% if team_photo %}
{% trans "Remove team photo?" as remove_photo_body %}
{% trans "Remove photo" as remove_photo_title %}
{% url 'management:team_photo_delete' team.pk selected_season.pk as team_photo_delete_url %}
{% include "controlpanel/_confirm_modal.html" with modal_id="team_photo_delete_modal" title=remove_photo_title body=remove_photo_body action_url=team_photo_delete_url submit_label="Remove" submit_icon="trash-2" %}
{% endif %}
{% endif %}
<div class="mb-4 grid gap-4 lg:grid-cols-3">
<div class="card bg-base-100 shadow border-l-4 {% if attendance_rate is None %}border-info{% elif attendance_rate < 30 %}border-error{% elif attendance_rate < 65 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
@@ -126,7 +149,7 @@
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Roster" %}</h2>
{% if can_manage %}
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('add_player_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add player" %}</button>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_player_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add player" %}</button>
{% endif %}
</div>
<div class="overflow-x-auto">
@@ -153,7 +176,7 @@
<td class="text-right">
{% if can_manage %}
<div class="flex justify-end gap-1">
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"edit_player_modal" }}').showModal()" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"edit_player_modal" }}').showModal()" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"remove_player_modal" }}').showModal()" aria-label="{% trans 'Remove' %}">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
</div>
{% endif %}
@@ -175,7 +198,7 @@
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Staff" %}</h2>
{% if can_manage %}
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('add_staff_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Assign staff" %}</button>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_staff_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Assign staff" %}</button>
{% endif %}
</div>
<div class="overflow-x-auto">
@@ -195,7 +218,7 @@
<td class="text-right">
{% if can_manage %}
<div class="flex justify-end gap-1">
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('{{ assignment.pk|dom_id:"edit_staff_modal" }}').showModal()" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('{{ assignment.pk|dom_id:"edit_staff_modal" }}').showModal()" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ assignment.pk|dom_id:"remove_staff_modal" }}').showModal()" aria-label="{% trans 'Remove' %}">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
</div>
{% endif %}
@@ -212,6 +235,36 @@
</div>
</div>
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Team photo" %}</h2>
{% if can_manage %}
<div class="flex gap-2">
{% trans "Upload photo" as upload_photo_label %}
{% trans "Replace photo" as replace_photo_label %}
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('team_photo_modal').showModal()">
{% if team_photo %}
{% lucide "pencil" size=14 %} {{ replace_photo_label }}
{% else %}
{% lucide "upload" size=14 %} {{ upload_photo_label }}
{% endif %}
</button>
{% if team_photo %}
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('team_photo_delete_modal').showModal()">{% lucide "trash-2" size=14 %} {% trans "Remove" %}</button>
{% endif %}
</div>
{% endif %}
</div>
{% if team_photo %}
<img class="mt-2 w-full max-w-md rounded-lg object-cover" src="{{ team_photo.image.url }}" alt="{{ team }}">
{% else %}
<p class="text-sm opacity-60">{% trans "No photo uploaded for this season yet." %}</p>
{% endif %}
</div>
</div>
{% if can_manage %}
{% trans "Add player" as add_player_label %}
{% url 'management:team_roster_add' team.pk selected_season.pk as add_player_url %}

View File

@@ -22,7 +22,7 @@
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:team_detail" object.pk %}{% else %}{% url "management:team_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:team_detail" object.pk %}{% else %}{% url "management:team_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>

View File

@@ -5,7 +5,7 @@
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-primary gap-2" href="{% url 'management:team_create' %}">{% lucide "plus" size=16 %} {% trans "New team" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:team_create' %}">{% lucide "plus" size=16 %} {% trans "New team" %}</a>
{% endif %}
{% endblock actions %}
@@ -33,7 +33,7 @@
<td class="text-right">
{% if is_club_admin %}
<div class="flex justify-end gap-1">
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:team_detail' team.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<a class="btn btn-outline btn-sm" href="{% url 'management:team_detail' team.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ team.pk|dom_id:"team_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
{% endif %}

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,8 @@ urlpatterns = [
path("teams/<uuid:pk>/staff/<uuid:season_pk>/add/", views.TeamStaffAddView.as_view(), name="team_staff_add"),
path("teams/<uuid:pk>/staff/<uuid:assignment_pk>/edit/", views.TeamStaffUpdateView.as_view(), name="team_staff_update"),
path("teams/<uuid:pk>/staff/<uuid:assignment_pk>/remove/", views.TeamStaffRemoveView.as_view(), name="team_staff_remove"),
path("teams/<uuid:pk>/photo/<uuid:season_pk>/set/", views.TeamPhotoSetView.as_view(), name="team_photo_set"),
path("teams/<uuid:pk>/photo/<uuid:season_pk>/delete/", views.TeamPhotoDeleteView.as_view(), name="team_photo_delete"),
# News
path("news/", views.NewsListView.as_view(), name="news_list"),
path("news/new/", views.NewsCreateView.as_view(), name="news_create"),
@@ -60,7 +62,20 @@ urlpatterns = [
path("news/<uuid:pk>/photos/<uuid:photo_pk>/delete/", views.NewsPhotoDeleteView.as_view(), name="news_photo_delete"),
# Calendar
path("events/", views.EventListView.as_view(), name="event_list"),
path("event-series/", views.EventSeriesListView.as_view(), name="event_series_list"),
path("events/new/", views.EventCreateView.as_view(), name="event_create"),
path("events/<uuid:pk>/", views.EventDetailView.as_view(), name="event_detail"),
path("events/<uuid:pk>/edit/", views.EventUpdateView.as_view(), name="event_update"),
path("events/<uuid:pk>/delete/", views.EventDeleteView.as_view(), name="event_delete"),
path("events/<uuid:pk>/detach/", views.EventDetachView.as_view(), name="event_detach"),
path("events/<uuid:pk>/fetch-game-info/", views.EventFetchGameInfoView.as_view(), name="event_fetch_game_info"),
path("events/rbihf-import/", views.RBIHFImportView.as_view(), name="rbihf_import"),
path("events/rbihf-import/confirm/", views.RBIHFImportConfirmView.as_view(), name="rbihf_import_confirm"),
path("event-series/new/", views.EventSeriesCreateView.as_view(), name="event_series_create"),
path("event-series/<uuid:pk>/", views.EventSeriesDetailView.as_view(), name="event_series_detail"),
path("event-series/<uuid:pk>/edit/", views.EventSeriesUpdateView.as_view(), name="event_series_update"),
path("event-series/<uuid:pk>/delete/", views.EventSeriesDeleteView.as_view(), name="event_series_delete"),
path("event-series/<uuid:pk>/stop/", views.EventSeriesStopView.as_view(), name="event_series_stop"),
path("event-series/<uuid:pk>/generate/", views.EventSeriesGenerateView.as_view(), name="event_series_generate"),
path("locations/", views.LocationListView.as_view(), name="location_list"),
path("locations/new/", views.LocationCreateView.as_view(), name="location_create"),
path("locations/<uuid:pk>/edit/", views.LocationUpdateView.as_view(), name="location_update"),
@@ -69,6 +84,11 @@ urlpatterns = [
path("opponents/new/", views.OpponentCreateView.as_view(), name="opponent_create"),
path("opponents/<uuid:pk>/edit/", views.OpponentUpdateView.as_view(), name="opponent_update"),
path("opponents/<uuid:pk>/delete/", views.OpponentDeleteView.as_view(), name="opponent_delete"),
# Sponsors (admin only)
path("sponsors/", views.SponsorListView.as_view(), name="sponsor_list"),
path("sponsors/new/", views.SponsorCreateView.as_view(), name="sponsor_create"),
path("sponsors/<uuid:pk>/edit/", views.SponsorUpdateView.as_view(), name="sponsor_update"),
path("sponsors/<uuid:pk>/delete/", views.SponsorDeleteView.as_view(), name="sponsor_delete"),
# Shop (admin only)
path("shop/products/", views.ProductListView.as_view(), name="product_list"),
path("shop/orders/", views.OrderListView.as_view(), name="order_list"),

View File

@@ -9,22 +9,36 @@ from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin, ManagementPositionRequiredMixin, NewsAuthorRequiredMixin, NewsEditRequiredMixin, NewsPublisherRequiredMixin, TeamManagerRequiredMixin
from club.models import ClubMembership, ClubRole, Season
from billing.models import RENEWAL_LEAD_DAYS, Due
from club.mixins import (
ClubAdminRequiredMixin,
ClubStaffRequiredMixin,
EventManagerRequiredMixin,
FeatureRequiredMixin,
ManagementPositionRequiredMixin,
NewsAuthorRequiredMixin,
NewsEditRequiredMixin,
NewsPublisherRequiredMixin,
TeamManagerRequiredMixin,
)
from club.models import ClubMembership, ClubRole, Season, Sponsor
from club.services.access import can_edit_news, can_publish_news, current_season, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
from club.services.fees import mark_as_paid, record_payment, remaining_balance
from controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin
from controlpanel.services.statistics import club_attention, club_charts, club_statistics
from events.models import Event, EventSeries, Location, Opponent
from events.models import Attendance, Event, EventSeries, Location, Opponent
from events.services.attendance import player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows
from events.services.competitions import CompetitionFetchError, fetch_game_info
from events.services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, fetch_html
from events.services.recurrence import cancel_occurrence, detach_occurrence, generate_occurrences, propagate_series
from formbuilder.models import Form as FormBuilderForm
from formbuilder.models import Submission
from members.models import Family, FamilyMembership, Member
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, grant_login, register_family
from news.models import News, NewsPhoto
from shop.models import Discount, Invoice, Order, Product
from teams.models import Position, StaffAssignment, Team, TeamMembership
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
from .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook
from .forms import (
@@ -33,6 +47,8 @@ from .forms import (
AttachToFamilyForm,
ClubMembershipForm,
ClubRoleAssignForm,
EventForm,
EventSeriesForm,
FamilyCreateForm,
GrantLoginForm,
LocationForm,
@@ -43,12 +59,16 @@ from .forms import (
NewsPublishForm,
OpponentForm,
PositionForm,
RBIHFImportForm,
RecordFeePaymentForm,
SponsorForm,
StaffAssignmentForm,
TeamForm,
TeamMembershipForm,
TeamPhotoForm,
)
from .pdf import PDFExportError, membership_list_pdf
from .recurrence_ui import describe_rrule
class HomeView(ClubStaffRequiredMixin, TemplateView):
@@ -56,18 +76,32 @@ class HomeView(ClubStaffRequiredMixin, TemplateView):
club_attention/club_charts/club_statistics are the exact functions
controlpanel/club_detail.html uses for the platform admin's per-club drill-down --
already club-scoped, so directly reusable for this club's own staff. Published
news sits alongside upcoming events -- open to everyone here, same as events."""
news is open to everyone; upcoming events are scoped the same way the events
list is (see scoped_to_managed_teams) -- a manager shouldn't see another
team's practice show up here either."""
template_name = "management/home.html"
def get_context_data(self, **kwargs):
club = self.request.club
club, user = self.request.club, self.request.user
subscription = getattr(club, "subscription", None)
billing_ends_at = None
if subscription is not None:
latest_due = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()
if latest_due is not None and 0 <= (latest_due.period_end - timezone.localdate()).days <= RENEWAL_LEAD_DAYS:
billing_ends_at = latest_due.period_end
upcoming_events = scoped_to_managed_teams(Event.objects.filter(club=club, start__gte=timezone.now()), user, club).order_by("start").prefetch_related("teams")[:5]
return super().get_context_data(
attention=club_attention(club),
charts=club_charts(club),
groups=club_statistics(club),
upcoming_events=Event.objects.filter(club=club, start__gte=timezone.now()).order_by("start")[:5],
upcoming_events=upcoming_events,
published_news=News.objects.filter(club=club, status=News.Status.PUBLISHED, published_at__lte=timezone.now()).order_by("-published_at")[:5],
billing_ends_at=billing_ends_at,
billing_auto_renews=subscription.auto_renew if subscription else False,
today=timezone.localdate(),
**kwargs,
)
@@ -774,6 +808,7 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
top_attenders, bottom_attenders = [], []
missed_practices = Member.objects.none()
no_shows = []
team_photo = TeamPhoto.objects.filter(team=team, season=season).first() if season is not None else None
if season is not None:
roster = list(TeamMembership.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
staff = list(StaffAssignment.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
@@ -798,6 +833,8 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
can_manage=can_manage,
roster_form=TeamMembershipForm(club=club, team=team, season=season) if can_manage and season else None,
staff_form=StaffAssignmentForm(club=club, team=team, season=season) if can_manage and season else None,
team_photo=team_photo,
team_photo_form=TeamPhotoForm(instance=team_photo) if can_manage and season else None,
attendance_rate=attendance_rate,
top_attenders=top_attenders,
bottom_attenders=bottom_attenders,
@@ -983,6 +1020,56 @@ class TeamStaffRemoveView(TeamManagerRequiredMixin, View):
return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_id}")
class TeamPhotoSetView(TeamManagerRequiredMixin, FormView):
"""Reachable only via the "Upload"/"Replace" modal on the team page. Binds
to the existing TeamPhoto for this team+season (if any) so re-uploading
replaces it in place -- same "create or update the one row for this
scope" pattern as controlpanel.views.ClubHomeLocationSetView. Not
RedirectOnInvalidMixin, same reasoning as TeamRosterAddView: that can't
carry ?season= through a plain redirect(view_name, **kwargs)."""
form_class = TeamPhotoForm
http_method_names = ["post"]
def get_team(self):
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_season(self):
return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"])
def get_form_kwargs(self):
return super().get_form_kwargs() | {"instance": TeamPhoto.objects.filter(team=self.get_team(), season=self.get_season()).first()}
def team_detail_url(self):
return f"{reverse('management:team_detail', args=[self.kwargs['pk']])}?season={self.kwargs['season_pk']}"
def form_invalid(self, form):
for error in form.errors.values():
notify(self.request, f"e|{_('Could not upload photo')}|{' '.join(error)}")
return redirect(self.team_detail_url())
def form_valid(self, form):
photo = form.save(commit=False)
photo.team = self.get_team()
photo.season = self.get_season()
photo.save()
notify(self.request, f"s|{_('Photo uploaded')}|{_('Team photo updated.')}")
return redirect(self.team_detail_url())
class TeamPhotoDeleteView(TeamManagerRequiredMixin, View):
def get_team(self):
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk, season_pk):
team = self.get_team()
TeamPhoto.objects.filter(team=team, season_id=season_pk).delete()
notify(request, f"w|{_('Photo removed')}|{_('Team photo removed.')}")
return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_pk}")
# --- Club roles (full tier: assign / revoke, no update -- a role isn't edited, just
# granted or taken away) -------------------------------------------------------------
@@ -1411,19 +1498,440 @@ class NewsPhotoDeleteView(NewsEditRequiredMixin, View):
return redirect("management:news_detail", pk=news_item.pk)
class EventListView(ClubStaffRequiredMixin, StubListMixin, ListView):
page_title = _("Events")
def scoped_to_managed_teams(queryset, user, club):
"""Non-admin: only rows for a team they manage, plus team-less/club-wide
ones (a social, an AGM) -- there's no team to scope those to, so they stay
visible to everyone. Same "manages" rule as who can edit (teams_managed_by),
not the broader "staffed on any role" one. Works for any queryset whose
model has a `teams` M2M -- Event and EventSeries both do -- so it backs the
events list, the dashboard's upcoming-events widget, and both detail views
(an out-of-scope one 404s if opened directly, same as any other
queryset-scoped detail view here, not just unlisted)."""
if is_club_admin(user, club):
return queryset
managed_team_ids = teams_managed_by(user, club).values_list("pk", flat=True)
return queryset.filter(Q(teams__in=managed_team_ids) | Q(teams__isnull=True)).distinct()
class EventListView(ClubStaffRequiredMixin, ListView):
"""Upcoming by default (what a coach actually opens this page to check);
``?show_past=1`` flips to the most recent past events instead. Season
filtering mirrors Event.season's own "explicit, else derived from start
date" rule (events/models.py) rather than requiring a stored season on
every row."""
template_name = "management/event_list.html"
context_object_name = "events"
def get_queryset(self):
club = self.request.club
user = self.request.user
events = Event.objects.filter(club=club).select_related("location", "opponent").prefetch_related("teams")
season = selected_season_from_request(self.request, club)
if season is not None:
events = events.filter(Q(season=season) | Q(season__isnull=True, start__date__gte=season.start_date, start__date__lte=season.end_date))
kind = self.request.GET.get("kind", "")
if kind:
events = events.filter(kind=kind)
events = scoped_to_managed_teams(events, user, club)
is_admin = is_club_admin(user, club)
managed_team_ids = set() if is_admin else set(teams_managed_by(user, club).values_list("pk", flat=True))
now = timezone.now()
if self.request.GET.get("show_past") == "1":
events = list(events.filter(start__lt=now).order_by("-start"))
else:
events = list(events.filter(start__gte=now).order_by("start"))
# Attached per row so the template can show/hide Edit/Delete per event --
# computed once here rather than a query per row (teams is already
# prefetched above).
for event in events:
event.can_manage = is_admin or any(team.pk in managed_team_ids for team in event.teams.all())
return events
def get_context_data(self, **kwargs):
club, user = self.request.club, self.request.user
return super().get_context_data(
seasons=Season.objects.filter(club=club).order_by("-start_date"),
selected_season=selected_season_from_request(self.request, club),
selected_kind=self.request.GET.get("kind", ""),
show_past=self.request.GET.get("show_past") == "1",
event_kinds=Event.EventKind.choices,
can_create=is_club_admin(user, club) or teams_managed_by(user, club).exists(),
**kwargs,
)
class EventDetailView(ClubStaffRequiredMixin, DetailView):
template_name = "management/event_detail.html"
context_object_name = "event"
def get_queryset(self):
events = Event.objects.filter(club=self.request.club).select_related("series", "location", "opponent", "season").prefetch_related("teams", "invited_members", "excluded_members")
return scoped_to_managed_teams(events, self.request.user, self.request.club)
def get_context_data(self, **kwargs):
club, user, event = self.request.club, self.request.user, self.object
can_manage = is_club_admin(user, club) or teams_managed_by(user, club).filter(pk__in=event.teams.values_list("pk", flat=True)).exists()
rows_by_status = {}
for row in event.attendances.select_related("member").order_by("member__last_name", "member__first_name"):
rows_by_status.setdefault(row.status, []).append(row)
# Every status, in its declared order -- not just the ones that happen to have
# a response yet, or "0 people excused" silently disappears instead of reading
# as good news. Same grouping backs both the breakdown counts and the modal's
# per-status sections, so there's exactly one query, not two.
attendance_groups = [{"value": value, "label": label, "rows": rows_by_status.get(value, [])} for value, label in Attendance.AttendanceStatus.choices]
return super().get_context_data(
can_manage=can_manage,
attendance_groups=attendance_groups,
has_attendance_rows=any(group["rows"] for group in attendance_groups),
**kwargs,
)
class EventCreateView(ClubStaffRequiredMixin, CreateView):
"""Broader than EventManagerRequiredMixin's own gate (no object yet to check
teams against): anyone managing at least one team, or an admin. EventForm
itself then restricts *which* teams a non-admin can pick and requires at
least one, so a team-less/club-wide event stays admin-only."""
model = Event
form_class = EventForm
template_name = "management/event_form.html"
def test_func(self):
user, club = self.request.user, self.request.club
return is_club_admin(user, club) or teams_managed_by(user, club).exists()
def get_form_kwargs(self):
# Event.clean() rejects a location/opponent from another club by comparing
# against self.club_id -- on a brand-new instance that's still None until
# ClubScopedModel.save() auto-assigns it, which only happens *after*
# validation. Set it here so full_clean() sees the real club, not None.
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "instance": Event(club=self.request.club)}
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(event)s” created.") % {"event": self.object}
notify(self.request, f"s|{_('Event created')}|{body}")
return response
def get_success_url(self):
return reverse("management:event_detail", args=[self.object.pk])
class EventUpdateView(EventManagerRequiredMixin, UpdateView):
model = Event
form_class = EventForm
template_name = "management/event_form.html"
def get_queryset(self):
return Event.objects.filter(club=self.request.club)
def get_teams(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all()
class EventSeriesListView(ClubStaffRequiredMixin, StubListMixin, ListView):
page_title = _("Event series")
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "editing": True}
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(event)s” updated.") % {"event": self.object}
notify(self.request, f"s|{_('Event updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:event_detail", args=[self.object.pk])
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class EventDeleteView(EventManagerRequiredMixin, View):
"""A series occurrence is cancelled (keeps the series' excluded_dates in
sync -- see cancel_occurrence), never just deleted outright; a one-off
event is deleted outright. One button, the branch is internal."""
def get_event(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_event().teams.all()
def post(self, request, pk):
event = self.get_event()
title = str(event)
if event.series_id:
cancel_occurrence(event, hard_delete=request.POST.get("keep_record") != "on")
notify(request, f"w|{_('Occurrence cancelled')}|{_('%(event)s” was cancelled.') % {'event': title}}")
else:
event.delete()
notify(request, f"w|{_('Event deleted')}|{_('%(event)s” has been deleted.') % {'event': title}}")
return redirect("management:event_list")
class EventDetachView(EventManagerRequiredMixin, View):
"""Stop this occurrence from being touched by future series-wide edits --
see detach_occurrence. Editing a still-attached occurrence directly would
otherwise be silently overwritten by the next propagate_series() call."""
def get_event(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_event().teams.all()
def post(self, request, pk):
event = self.get_event()
detach_occurrence(event)
notify(request, f"s|{_('Detached from series')}|{_('%(event)s” is now edited independently and will not be touched by future series-wide changes.') % {'event': event}}")
return redirect("management:event_detail", pk=event.pk)
class EventFetchGameInfoView(EventManagerRequiredMixin, View):
"""Refresh a game's score/status from its competition -- see
events.services.competitions.fetch_game_info, which gates on the
competition's feature flag being active for this club and otherwise
no-ops. No data source is wired up yet either way, so an actual fetch
attempt always reports the same honest "not configured" error; the
button/view exist so a real integration only has to replace that function."""
def get_event(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_event().teams.all()
def post(self, request, pk):
event = self.get_event()
try:
if fetch_game_info(event):
notify(request, f"s|{_('Game info updated')}|{_('%(event)s” was refreshed from its competition.') % {'event': event}}")
else:
notify(request, f"i|{_('Nothing to fetch')}|{_('%(competition)s” is not enabled for this club.') % {'competition': event.competition}}")
except CompetitionFetchError as error:
notify(request, f"e|{_('Could not fetch game info')}|{error}")
return redirect("management:event_detail", pk=event.pk)
class RBIHFImportView(FeatureRequiredMixin, View):
"""Step 1: paste an RBIHF team page URL, pick which of the club's teams it's
for. Fetches and parses the page server-side, stashes the raw HTML (not
client-trusted parsed data) in the session, and renders a create/update/
delete preview -- see events.services.rbihf_import and
RBIHFImportConfirmView, which mirrors MemberImportView/
MemberImportConfirmView's session-stash-and-reparse shape."""
feature_flag = "RBIHF"
def get(self, request):
return render(request, "management/rbihf_import_form.html", {"form": RBIHFImportForm(club=request.club)})
def post(self, request):
form = RBIHFImportForm(request.POST, club=request.club)
if not form.is_valid():
return render(request, "management/rbihf_import_form.html", {"form": form})
url = form.cleaned_data["url"]
team = form.cleaned_data["team"]
rbihf_team_id = extract_team_id(url)
try:
html = fetch_html(url)
plan = build_plan(request.club, team, rbihf_team_id, html)
except RBIHFImportError as error:
form.add_error("url", str(error))
return render(request, "management/rbihf_import_form.html", {"form": form})
request.session["rbihf_import_html"] = html
request.session["rbihf_import_team_id"] = str(team.pk)
request.session["rbihf_import_rbihf_team_id"] = rbihf_team_id
return render(request, "management/rbihf_import_preview.html", {"plan": plan})
class RBIHFImportConfirmView(FeatureRequiredMixin, View):
"""Step 2: re-parses and re-diffs the HTML stashed by RBIHFImportView
against the *current* DB state (catching anything that changed since the
preview was shown), reads each row's chosen location/opponent back from
the preview form, and applies the result in one transaction."""
feature_flag = "RBIHF"
def post(self, request):
html = request.session.pop("rbihf_import_html", None)
team_id = request.session.pop("rbihf_import_team_id", None)
rbihf_team_id = request.session.pop("rbihf_import_rbihf_team_id", None)
if not html or not team_id or not rbihf_team_id:
notify(request, f"w|{_('Nothing to import')}|{_('Start over by pasting the RBIHF team URL again.')}")
return redirect("management:rbihf_import")
team = get_object_or_404(Team.objects.filter(club=request.club), pk=team_id)
try:
plan = build_plan(request.club, team, rbihf_team_id, html)
except RBIHFImportError:
notify(request, f"e|{_('Could not import')}|{_('Something went wrong re-reading the fetched page. Try again.')}")
return redirect("management:rbihf_import")
locations_by_game_id = {}
opponents_by_game_id = {}
for planned in [*plan.to_create, *plan.to_update]:
game_id = planned.fixture.external_game_id
locations_by_game_id[game_id] = request.POST.get(f"location_{game_id}", "")
opponents_by_game_id[game_id] = request.POST.get(f"opponent_{game_id}", "")
result = apply_plan(plan, locations_by_game_id, opponents_by_game_id)
body = _("%(created)s created, %(updated)s updated, %(deleted)s deleted.") % result
notify(request, f"s|{_('Fixtures imported')}|{body}")
return redirect("management:event_list")
class EventSeriesDetailView(ClubStaffRequiredMixin, DetailView):
template_name = "management/event_series_detail.html"
context_object_name = "series"
def get_queryset(self):
series = EventSeries.objects.filter(club=self.request.club).select_related("location", "opponent").prefetch_related("teams", "invited_members", "excluded_members")
return scoped_to_managed_teams(series, self.request.user, self.request.club)
def get_context_data(self, **kwargs):
club, user, series = self.request.club, self.request.user, self.object
can_manage = is_club_admin(user, club) or teams_managed_by(user, club).filter(pk__in=series.teams.values_list("pk", flat=True)).exists()
now = timezone.now()
occurrences = list(series.occurrences.order_by("start"))
for occurrence in occurrences:
occurrence.is_past = occurrence.start < now
return super().get_context_data(
can_manage=can_manage,
recurrence_summary=describe_rrule(series.rrule),
occurrences=occurrences,
**kwargs,
)
class EventSeriesCreateView(ClubStaffRequiredMixin, CreateView):
model = EventSeries
form_class = EventSeriesForm
template_name = "management/event_series_form.html"
def test_func(self):
user, club = self.request.user, self.request.club
return is_club_admin(user, club) or teams_managed_by(user, club).exists()
def get_form_kwargs(self):
# Same reasoning as EventCreateView: EventSeries.clean() needs a real
# club_id on the instance before full_clean() runs, not the None a
# brand-new instance starts with.
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "instance": EventSeries(club=self.request.club)}
def form_valid(self, form):
response = super().form_valid(form)
# Not automatic on save -- without this the series would exist with zero
# occurrences until the extend_event_series cron command next runs.
created = generate_occurrences(self.object)
body = ngettext("%(series)s” created, with %(count)d occurrence scheduled.", "%(series)s” created, with %(count)d occurrences scheduled.", len(created)) % {"series": self.object, "count": len(created)}
notify(self.request, f"s|{_('Series created')}|{body}")
return response
def get_success_url(self):
return reverse("management:event_series_detail", args=[self.object.pk])
class EventSeriesUpdateView(EventManagerRequiredMixin, UpdateView):
model = EventSeries
form_class = EventSeriesForm
template_name = "management/event_series_form.html"
def get_queryset(self):
return EventSeries.objects.filter(club=self.request.club)
def get_teams(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all()
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user}
def form_valid(self, form):
response = super().form_valid(form)
# Push the template change to future, non-detached occurrences, then fill
# in any further-out dates the (possibly changed) pattern now implies.
# Occurrences that no longer match a changed pattern are NOT auto-removed
# -- reconciling that is ambiguous (which to drop vs. keep attendance
# history for) and is left as a manual "Cancel" per stale occurrence.
propagate_series(self.object)
generate_occurrences(self.object)
body = _("%(series)s” updated.") % {"series": self.object}
notify(self.request, f"s|{_('Series updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:event_series_detail", args=[self.object.pk])
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class EventSeriesDeleteView(EventManagerRequiredMixin, View):
"""series is on_delete=CASCADE -- this also deletes every occurrence and its
attendance history. The confirm modal must say so; "Stop repeating"
(EventSeriesStopView) is the non-destructive alternative."""
def get_series(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_series().teams.all()
def post(self, request, pk):
series = self.get_series()
title = str(series)
series.delete()
notify(request, f"w|{_('Series deleted')}|{_('%(series)s” and all of its occurrences have been deleted.') % {'series': title}}")
return redirect("management:event_list")
class EventSeriesStopView(EventManagerRequiredMixin, View):
"""Stop future generation without touching any existing occurrence or its
attendance history -- the non-destructive alternative to deleting the
series outright."""
def get_series(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_series().teams.all()
def post(self, request, pk):
series = self.get_series()
series.until = timezone.now()
series.save(update_fields=["until"])
notify(request, f"s|{_('Series stopped')}|{_('%(series)s” will no longer generate new occurrences. Existing ones are untouched.') % {'series': series}}")
return redirect("management:event_series_detail", pk=series.pk)
class EventSeriesGenerateView(EventManagerRequiredMixin, View):
def get_series(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_series().teams.all()
def post(self, request, pk):
series = self.get_series()
created = generate_occurrences(series)
body = ngettext("%(count)d new occurrence generated.", "%(count)d new occurrences generated.", len(created)) % {"count": len(created)}
notify(request, f"s|{_('Occurrences generated')}|{body}")
return redirect("management:event_series_detail", pk=series.pk)
class LocationListView(ManagementPositionRequiredMixin, ListView):
template_name = "management/location_list.html"
@@ -1537,42 +2045,107 @@ class OpponentDeleteView(ManagementPositionRequiredMixin, View):
return redirect("management:opponent_list")
class ProductListView(ClubAdminRequiredMixin, StubListMixin, ListView):
class SponsorListView(ClubAdminRequiredMixin, ListView):
"""Sponsors are a business/revenue relationship, same bucket as
memberships/roles/shop -- admin-only, unlike Location/Opponent which any
management position can maintain."""
template_name = "management/sponsor_list.html"
context_object_name = "sponsors"
def get_queryset(self):
return Sponsor.objects.filter(club=self.request.club)
class SponsorCreateView(ClubAdminRequiredMixin, CreateView):
model = Sponsor
form_class = SponsorForm
template_name = "management/sponsor_form.html"
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(sponsor)s” created.") % {"sponsor": self.object}
notify(self.request, f"s|{_('Sponsor created')}|{body}")
return response
def get_success_url(self):
return reverse("management:sponsor_list")
class SponsorUpdateView(ClubAdminRequiredMixin, UpdateView):
model = Sponsor
form_class = SponsorForm
template_name = "management/sponsor_form.html"
def get_queryset(self):
return Sponsor.objects.filter(club=self.request.club)
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(sponsor)s” updated.") % {"sponsor": self.object}
notify(self.request, f"s|{_('Sponsor updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:sponsor_list")
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class SponsorDeleteView(ClubAdminRequiredMixin, View):
def post(self, request, pk):
sponsor = get_object_or_404(Sponsor.objects.filter(club=request.club), pk=pk)
name = str(sponsor)
sponsor.delete()
body = _("%(sponsor)s” has been deleted.") % {"sponsor": name}
notify(request, f"w|{_('Sponsor deleted')}|{body}")
return redirect("management:sponsor_list")
class ProductListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "shop"
page_title = _("Products")
def get_queryset(self):
return Product.objects.filter(club=self.request.club)
class OrderListView(ClubAdminRequiredMixin, StubListMixin, ListView):
class OrderListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "shop"
page_title = _("Orders")
def get_queryset(self):
return Order.objects.filter(club=self.request.club)
class DiscountListView(ClubAdminRequiredMixin, StubListMixin, ListView):
class DiscountListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "shop"
page_title = _("Discounts")
def get_queryset(self):
return Discount.objects.filter(club=self.request.club)
class InvoiceListView(ClubAdminRequiredMixin, StubListMixin, ListView):
class InvoiceListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "shop"
page_title = _("Invoices")
def get_queryset(self):
return Invoice.objects.filter(club=self.request.club)
class FormListView(ClubAdminRequiredMixin, StubListMixin, ListView):
class FormListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "formbuilder"
page_title = _("Forms")
def get_queryset(self):
return FormBuilderForm.objects.filter(club=self.request.club)
class SubmissionListView(ClubAdminRequiredMixin, StubListMixin, ListView):
class SubmissionListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "formbuilder"
page_title = _("Submissions")
def get_queryset(self):