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
85 lines
3.4 KiB
Python
85 lines
3.4 KiB
Python
"""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}
|