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:
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
84
management/recurrence_ui.py
Normal file
84
management/recurrence_ui.py
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
215
management/templates/management/event_detail.html
Normal file
215
management/templates/management/event_detail.html
Normal 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 %}
|
||||
96
management/templates/management/event_form.html
Normal file
96
management/templates/management/event_form.html
Normal 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 %}
|
||||
118
management/templates/management/event_list.html
Normal file
118
management/templates/management/event_list.html
Normal 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 %}
|
||||
117
management/templates/management/event_series_detail.html
Normal file
117
management/templates/management/event_series_detail.html
Normal 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 %}
|
||||
75
management/templates/management/event_series_form.html
Normal file
75
management/templates/management/event_series_form.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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" }}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
35
management/templates/management/rbihf_import_form.html
Normal file
35
management/templates/management/rbihf_import_form.html
Normal 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 %}
|
||||
152
management/templates/management/rbihf_import_preview.html
Normal file
152
management/templates/management/rbihf_import_preview.html
Normal 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="">—</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>
|
||||
→
|
||||
<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="">—</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" }} — {{ 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
31
management/templates/management/sponsor_form.html
Normal file
31
management/templates/management/sponsor_form.html
Normal 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 %}
|
||||
68
management/templates/management/sponsor_list.html
Normal file
68
management/templates/management/sponsor_list.html
Normal 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">—</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 %}
|
||||
@@ -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>·</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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
1120
management/tests.py
1120
management/tests.py
File diff suppressed because it is too large
Load Diff
@@ -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"),
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user