Compare commits

238 Commits

Author SHA1 Message Date
d8ee59e3a1 Disable bytecode precompilation in the build -- widening the timeout wasn't enough
Locally, phonenumbers' largest generated geodata files (~900KB of literal
dict data) compile in ~0.1s each; the build host still timed out at 300s+
on a single one of the 29 such files -- a ~3000x gap no reasonable timeout
bump reliably survives across all of them. That gap points to something on
the build host itself (cross-arch emulation or memory pressure), not a file
that's merely slow. Turning bytecode compilation off trades a slow, flaky
build for a slower first import per container boot (--preload/prefork pay
that once, not per request) until the underlying host issue is found.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:41:40 +02:00
19ea65cae8 Widen uv's bytecode-compile timeout to fix the Docker build
uv sync --compile-bytecode enforces a hardcoded 60s-per-file cap, and
phonenumbers' generated geodata/data*.py files (large literal dicts, not
slow code) blow past it on a slower builder even though nothing's actually
hung -- widen the cap via UV_COMPILE_BYTECODE_TIMEOUT rather than dropping
UV_COMPILE_BYTECODE entirely, which would just move that same compile cost
to every cold container start instead of paying it once at build time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:25:43 +02:00
eb37ed187e Fix flaky this-week/next-week grouping tests around the evening boundary
Both fixtures anchored their "this week" event at a hardcoded 18:00 on the
last day of the current week -- fine any other day, but once the suite runs
past 18:00 on that day itself, the anchor is already in the past and the
view's own start__gte=now filter correctly excludes it, failing the test
for a reason that has nothing to do with the grouping logic being tested.
Falls back to "a few minutes from now" (still always this week) whenever
the fixed anchor would already be behind now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:07:47 +02:00
8c415ed46a Line-up: show each player's turnout rate, notify on changes after publish
A "yes" to this game doesn't say how reliably a player actually shows up --
each row on the line-up screen now shows this season's turnout rate
(events.services.attendance.player_attendance_rankings, one query for the
whole team), color-coded, omitted for anyone with too little history to
mean anything.

Also: editing a published line-up and hitting "Save" never notified anyone
(it only ever wrote LineupSelection, never touched Attendance.status) --
"Publish"/"Publish changes" now stays reachable after the first publish,
and publish_lineup diffs against who was SELECTED before this run so only
players whose status actually changed get notified, not everyone currently
selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:59:18 +02:00
240b6bd345 Fix vertical centering of the coach news form's photo picker button
Same Safari quirk as management's own .file-input (assets/management.css):
the file input's Choose Files button is shadow content that ignores
align-items/flex centering, so an explicit box height left it floating at
the top with dead space below. Fixed the same way -- size the box from
padding-block around the button's own height instead of an explicit height.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:52:42 +02:00
c4069f73c7 Style the coach news form's photo picker, reword the footer note
The bare native file input (browser-chrome "Choose Files" button) clashed
with every other styled field on the screen -- new .m-file-input component
class mirrors management's own .file-input treatment, recolored for the
mobile palette. Also "Linked to {team}" reads clearer than "Posted for
{team}" now that the field is a hard-locked link, not a free-text audience.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:51:00 +02:00
148f3a24c9 Add spacing, a larger body, and photo uploads to the coach news form
The post-composer screen was cramped and text-only, and its "audience"
picker exposed the same "which team(s)" choice the create-event screen
already dropped -- hard-lock teams to the coach's active team instead
(hidden field, required=True so a tampered empty submission can't
silently become club-wide), matching how CoachCreateEventView.teams
already works. Photos reuse management's own NewsPhotoUploadForm/
NewsPhoto machinery, first upload becomes the main picture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:47:49 +02:00
b9900510b1 New event: add Competition ID (games) and a Gathering time, both optional
- Competition ID (Event.external_game_id) sits under Competition, game-only,
  one-off only (no EventSeries equivalent) -- was already part of EventForm,
  just never rendered here.
- Gathering time (Event.gathering / EventSeries.gathering_offset) is
  available for every kind, in both the one-off and recurring paths -- the
  recurring form had this explicitly cut earlier; un-cut now that it's
  wanted, mirroring the existing Answers-close/deadline_minutes_before
  pattern right next to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:35:59 +02:00
1eaf92c977 Lay out the weekday picker as a 4-column grid instead of wrapping loosely
Sun no longer strands itself alone on a third line -- Mon-Thu on the first
row, Fri-Sun on the second.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:31:46 +02:00
6f16737bd9 Coach Schedule: same This week/Next week/month-divider agenda grouping
Extracted the grouping algorithm (previously duplicated between mobile.views.
CalendarView and management.views.EventListView's own "List" mode) into a
shared events.services.calendar.agenda_groups, and wired it into both --
plus mobile.coach_views.CoachScheduleView, the coach app's own upcoming-
events screen, which had no grouping at all before (one flat card). All
three now read identically and can't drift apart.

Also: give each management event-list row's kind badge a fixed width, so
titles start at the same horizontal position regardless of whether the row
says "Game" or "Tournament".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:27:56 +02:00
a13ab20a0f Regroup the management event list to match the member app's agenda look
The desktop "List" view was a flat, paginated <table>. It's now grouped the
same way mobile.views.CalendarView already groups the member app's own
calendar: "This week" / "Next week", then everything further out under its
own month divider -- same algorithm (itertools.groupby over an already-
sorted, already-paginated page of events), applied after pagination so the
two don't fight each other. "Show past events" mode skips the this/next-week
labels (they only make sense for what's ahead) and just groups straight into
months, most recent first.

Each row keeps the same information and admin actions the table had (kind
badge, series icon, teams, location, Edit/Delete) -- just laid out as a list
row instead of table cells, in its own reusable partial.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 15:30:59 +02:00
1375083739 Add players: "Suggested" also surfaces players moving up from a younger team
Team carries no real age-group field, so this is a guess -- the club's own
team with the closest smaller "U<N>" number in its name/short name (if
either side has one at all), current-season roster only. Silently adds
nothing extra for a club that doesn't name teams that way, same as the
existing "last season's roster" half of Suggested already does for a team
with no prior season on file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 15:23:01 +02:00
bf62759bfc New event: auto-link the active team, opponent/competition, quick-add popups, recurring series
- teams is now hard-locked to the active team (a hidden field, not a
  picker) -- there's no "which team" question on a screen already scoped
  to one. groups/club_wide are dropped for the same reason (both widen
  the audience past a single team). invited_members/excluded_members stay,
  re-scoped to sensible pools (add someone off the roster; exclude someone
  on it) instead of "every club member", with a note that a genuinely
  multi-team event still needs the desktop.
- Opponent and competition are now rendered (game-only) -- opponent via a
  standalone picker with its own "+ New" popup, competition via EventForm's
  existing club-agnostic Competition list.
- "+ New location"/"+ New opponent" popups create a Location/Opponent
  scoped to the club without leaving the screen: the modal's own htmx
  request creates the row and hands back the picker pre-selected via an
  out-of-band swap, so the rest of the in-progress form is never touched.
- A "This repeats" toggle switches the same screen to build an EventSeries
  instead of a single Event (frequency/interval/weekdays/duration/until),
  reusing EventSeriesForm and generate_occurrences the same way
  management.views.EventSeriesCreateView does -- including that view's own
  behaviour of not sending a per-occurrence notification (the bulk
  send_deadline_reminders sweep covers it instead, same as a rolling-
  horizon extension).
- Confirmed (and covered with a test) that a single event created this way
  still fires notify_new_event, notifying whoever's freshly invited.

Along the way, found and fixed a real, previously-undetected rendering bug
this surfaced: swapping a multi-choice field's widget to CheckboxSelectMultiple
*after* setting its queryset/choices silently drops what the queryset/choices
setter had already pushed onto the old widget, rendering an empty checkbox
list. Hit this for invited_members/excluded_members and weekdays here, and
found the same pre-existing bug in CoachCreateNewsView's own team checkboxes
(the New Post screen's team picker has been rendering empty) -- fixed all of
them (widget swapped in before queryset/choices, not after), with rendering
(not just queryset) regression tests for each.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 15:16:35 +02:00
5d54f1cdcc New event: space out the stacked cards, rework the kind picker to 4 tiles
- The kind-tile grid, title/date/location card, Who card, and Answers-close
  card were direct children of the form with no gap between them at all --
  the wrapping flex/gap-4 on coach-sheet's own content div only ever applied
  between the header bar and the form, not what's inside it. The form itself
  is now flex flex-col gap-4.
- The picker offered Practice/Game/Other, none of which actually changed
  which fields render. Reworked to Practice/Game/Tournament/Meeting (2x2) --
  the four kinds worth adding from the app; social/other stay desktop-only.
  Also narrowed the form's own kind choices to match server-side, not just
  cosmetically in the template.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:23:19 +02:00
fbaf5a8799 No-shows on the desktop member page, coach event screens, "+" popup polish
- management member_detail.html's Attendance card now shows the same
  No-shows count as the mobile player sheet, and the card's content (sparkline
  + tiles) fills the card's height/width instead of sitting in the top-left
  corner -- same flex-1/justify-center + stretched-tiles fix used there.
- Coach mode's Attendance and Line-up screens (opening an event) now
  highlight the Schedule tab instead of Today -- that's the calendar-icon
  tab, and where a coach reaches either from CoachScheduleView's own rows.
- The tab bar's "+" button: rotates 45° into an "×" while its popup is open
  (icon reused via CSS transform, no icon swap needed), the popup sits a
  little higher above the button (mb-3 -> mb-5), and a dark backdrop now
  covers the content behind it so the popup stands out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:16:05 +02:00
4af598500b Add a No-shows count to the player detail sheet
To answer the question this was built to address: "Absent" only ever reads
the RSVP (status=absent) -- someone who said they were coming but never
turned up still counts as present there, since present/absent don't touch
showed_up at all. member_attendance_counts now also returns no_shows, same
present/selected + showed_up=False definition team_no_shows already uses,
so that case shows up as its own number instead of being invisible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:06:23 +02:00
3501dbce94 Stretch the attendance stat tiles across the card on the player detail sheet
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:58:33 +02:00
499f65ef5b Highlight Squad on the Add player/staff screens, add staff removal
- CoachAddPlayerView/CoachAddStaffView now set active_tab = "coach_squad"
  instead of "coach_today" -- both are reached from Squad's own "Add"
  buttons, so that's the tab that should stay highlighted while there.
- Squad screen: each staff row (other than your own) gets a remove button,
  mirroring the roster's own. Self-removal stays a desktop-only action
  (management.views.TeamStaffRemoveView) -- doing it from here would strand
  a coach off a team they're actively viewing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:56:55 +02:00
6e3de13fd8 Add a name search box to Add players, explain what "Suggested" means
"Suggested" is whoever was on this team's roster last season -- now spelled
out in a caption under the chips (same for "No team"), not just left for a
coach to guess at. The search box (?q=, ANDed with whichever filter chip is
active) narrows the eligible pool by first/last name, useful once a club's
pool of eligible members outgrows a single screenful.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:44:45 +02:00
cc9daabcfd Add a "Remind silent" button to the bench attendance sheet
On-demand version of send_deadline_reminders' own NO_RESPONSE nudge, same
title/body shape, for a coach who doesn't want to wait for that once-a-day
sweep. Shown only when there's someone silent to remind, and only to whoever
manages the team.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:40:57 +02:00
3b64fae43f Keep Coach Today's session card current past an event's start time
Previously the "Tonight"/"Next up" card and the missing-line-up check flipped
to the next session the instant the current one started (start__gte=now).
Now an event stays current until 30 minutes past its end time, or 90 minutes
past its start when no end is set (most training events carry none) -- a
coach mid-practice or mid-game no longer sees the card jump ahead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 12:43:17 +02:00
d0e9dde8ed Add a Declined chip to the bench attendance sheet, drop silent's yellow tint
Silent and declined are both left out of the default "Responded" view, but
only silent had its own chip to review -- declined now gets one too. The
silent-row background tint is gone; nothing else on this screen tints rows,
and the chip itself already says which bucket you're looking at.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 12:34:38 +02:00
15d890a24b Coach mode: bench-attendance filter overhaul, squad player detail, add-staff
- Attendance sheet: "All"/"Goalies" chips replaced with "Responded" (the
  new default -- present/selected/maybe) and "Silent"; goalies are just
  another player for a practice, and neither silent nor declined members
  are expected to show up, so the default view skips both.
- Today's KPI header gains an "Out" tile alongside Squad/In/Silent.
- Squad screen: each roster row now opens a per-player detail sheet with
  season attendance stats, tap-to-call buttons (the player's own phone/
  emergency phone, plus each guardian's for a child), and -- for whoever
  manages the team -- the position/jersey/captaincy edit and a remove-
  from-roster action that used to be desktop-only.
- Staff section gets its own "Add" entry point, mirroring the roster's
  bulk-add flow with a shared position picker (StaffAssignment.position
  is required, unlike a roster spot's).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 11:54:48 +02:00
1365dcf18e Coach lineup polish, referee level/position delete, feature-flag nav gating, per-job pause
- Coach line-up screen: more breathing room above the sheet, brighter event
  subtitle, and the "Schedule" button now matches the date input's height.
- Positions and referee levels can now be deleted from Settings (blocked with
  a friendly message if still in use on a roster/referee profile/inheritance
  chain).
- The Evaluations nav placeholder is now gated on the formbuilder flag, same
  as Forms itself, since the design reuses formbuilder underneath.
- Control panel: each scheduled platform job can now be paused/resumed
  individually (features.models.JobToggle), independent of the platform-wide
  Maintenance lock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 11:17:46 +02:00
e621086f37 Fix the barely-legible "Unavailable" pills on the coach line-up screen
text-on-dark-faint plus opacity-50 doubly dimmed the text against the pill's
own bg-steel background -- switched to the brightest on-dark tone with no
extra opacity reduction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 21:05:21 +02:00
1d0c2ef299 Let a coach schedule a line-up to publish itself later
Publish now still works exactly as before (default action), but a coach
can also pick a future date/time -- events.services.lineup.
schedule_lineup_publish sets Lineup.scheduled_publish_at, and a new
periodic task (events.tasks.publish_scheduled_lineups, every 15 minutes)
publishes it once that time arrives via the same publish_lineup used for a
manual publish, so selection/Attendance/notifications all work identically
either way. A pending schedule can be cancelled or published early from the
same screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 20:52:22 +02:00
3b634fda22 Build out coach mode's Today screen: always show the next event, not just tonight
The session card only rendered when something was scheduled today -- a
coach with nothing until Thursday saw an empty screen even though there was
a real next event to show. It now shows whenever there's any upcoming
session, labelled "Tonight" or "Next up · <date>" accordingly, and the KPI
tiles get a header naming which session they're counting.

"Needs you" also now checks every one of the team's next few upcoming games
for a missing line-up, not just whichever happens to be the very next
session -- a practice landing before Saturday's game no longer hides that
the game's own line-up still needs building. Each entry links straight to
its own game's line-up screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 20:46:13 +02:00
fc624ebd96 Drop the referee row's flag icon and stop its title wrapping
Matches the plain calendar row exactly now (no icon there either) -- the
referee accent colour is the only thing that should set it apart, not a
different shape. Also truncates the title to one line instead of wrapping,
same as every other row's own title.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 20:16:53 +02:00
a7fdab4352 Show blocked sign-ups on mobile instead of silently hiding the event
A member excluded from an event's Attendance sync by an open onboarding
requirement (events.services.attendance.effective_members) previously just
never saw that event anywhere -- no row, no explanation. Two new read-side
functions mirror that exclusion instead of hiding it: club.services.
onboarding.open_requirements_blocking (per-member, "why") and events.
services.attendance.blocked_upcoming_events_for_member (which of their
upcoming events are affected).

The Calendar now shows those events as a distinct muted "Blocked" row
naming the outstanding requirement, and the event detail page shows a
"Can't sign up yet" card for the same reason -- no RSVP buttons, no lineup/
referee actions, just the explanation. Write-side blocking (who actually
gets an Attendance row, who a coach can select) is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 19:24:11 +02:00
86ea8d1b15 Fix trapped scrollbars app-wide, keep the week calendar as its own scroll
Several overflow-x-auto wrappers (table cards, a text preview) got an
accidental *vertical* scrollbar too -- per the CSS overflow spec, setting
only overflow-x to a non-visible value forces the other axis to compute as
auto if left unset, so any of these taller than the viewport were trapped
scrolling independently of the page (Safari showed it plainly; other
browsers hid it more subtly). Fixed everywhere with overflow-y-visible,
except the week calendar.

The week grid genuinely needs to stay its own bounded, contained scroll:
it always spans the full 24h day (never clipped, by design), so folding it
into the page's own scroll would mean scrolling past a screenful of empty
early hours most weeks. Instead it's capped to 70vh with a real
overflow-y-auto, and a small script scrolls it to just before the week's
first event on load (events.services.calendar.week_grid now reports
first_event_hour) -- no more landing on an empty view by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 19:13:28 +02:00
ae8e4c411d Make the management app's page scroll as one, not a boxed-in inner panel
Same fix as mobile/templates/mobile/base.html earlier this session: sidebar
and topbar are sticky instead of the page being a fixed-height shell around
an inner overflow-y-auto <main> -- that read as two separate scrollable
areas nested inside each other, most noticeable on a tall page like the
week calendar (event_list.html's own .cal-week-body, routinely taller than
a screenful of hours).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 18:57:57 +02:00
e13b17707c Make referee calendar rows tap-through links, like every other row
Accept/Decline now lives only on the event detail page's own "Refereeing"
card (same signup) -- the calendar row is just a link to the event, same
interaction model as every other row on M3's Calendar, instead of carrying
its own inline buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 18:57:50 +02:00
5ef6ea5529 Move the Evaluations placeholder nav entry to Members
Fits better next to Groups/Households than under Settings -- it's about
people, not club configuration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 18:51:59 +02:00
c47b509207 Design player evaluations in ARCHITECTURE.md, reusing formbuilder
New §5.8: a thin PlayerEvaluation/EvaluationSettings envelope around
formbuilder's already-built Form/Field/Submission/Answer (submission =
evaluator, envelope = who it was about + team/season), club-wide EVALUATOR
role independent of MEMBER_ADMIN, player profile combining evaluation
history with existing attendance/games-per-team data. Never visible to the
evaluated player or their guardians.

Also corrects formbuilder's status (models/services are built, just no
submit UI yet -- not "planned" as this doc previously had it) and moves the
referee self-service item from "still open" to resolved, since it shipped
this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 18:47:28 +02:00
3d0242749d Add a placeholder Evaluations nav entry so the design doesn't get forgotten
Under Settings, next to Referee levels/Forms -- but deliberately not behind
a waffle Flag like the shop/forms stubs, since a flag-gated link disappears
from the nav entirely until someone remembers to turn it on, which defeats
the point of a standing reminder. Visible to MEMBER_ADMIN/ADMIN
unconditionally, links to a bare "coming soon" page (reuses
_generic_list.html with an empty object_list -- there's no model yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 18:44:52 +02:00
618d15a3e0 Notify a member once when a roster/group change adds them to new events
Joining a team or group already re-synced Attendance rows for its upcoming
events (events/signals.py), but did so silently. A fresh membership (not a
field edit, not a removal) now sends one summary notification -- "N new
events on your calendar" -- rather than one per event, so joining mid-season
with a whole recurring series already scheduled doesn't flood the member
with pushes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 18:44:45 +02:00
5db885c809 Let a parent sign up a referee-eligible managed child, and hide scrollbars
Referee sign-up (Calendar row and event detail's own card) was scoped to
self.me only; a referee-eligible child is exactly as real as a referee-
eligible parent, and a parent signing one up is no different from
answering an RSVP on their behalf -- both surfaces and the respond view now
cover every managed person, and the calendar row names whose invite it is
once there's more than one managed person to tell apart. Event detail's
single-card layout became a per-person list, same shape as "Your answers".

Also hide the scrollbar on both mobile shells (the member shell's own
document scroll, and the coach shell's still-inner-scrolling .coach-sheet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 18:15:38 +02:00
e349fbbf03 Make the whole page scroll, show referee sign-up on the event page too
The app shell now scrolls as one page (body/document) instead of boxing
content inside an inner overflow-auto <main> under a fixed header/tab bar --
header and tab bar are sticky instead, which reads as a single intuitive
scroll (most noticeable on Calendar's long agenda) and, as a side effect,
means htmx's boosted-navigation scroll-to-top actually resets what the user
sees. The inner "This week"/"Today" sticky sub-headers are no longer sticky
themselves, since they'd otherwise collide with the now-sticky app header.

Event detail also shows the same referee Accept/Decline card the Calendar
row offers, for a signed-in account with a pending or accepted sign-up on
that game -- not just on the Calendar. Both the calendar row's and the
event page's Accept/Decline forms are now htmx-boosted (no hx-boost="false")
since neither is Alpine-toggled, so a response no longer forces a full page
reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 18:08:17 +02:00
875250366e Stack the notification timestamp time-over-date instead of a relative string
"1 hour, 20 minutes ago" ran long and ate into the body text's width now
that it's no longer truncated -- a compact two-line HH:MM/date column
(same shape as the calendar row's own day/date marker) fixes that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 17:34:52 +02:00
6efb00350b Make the referee dashboard's season stats one compact row, not two
KPIs and the chart now share a single flex row (chart card wider, flex-[3])
instead of a full KPI row followed by a tall standalone chart card -- the
per-referee fee breakdown moves into the chart's own tooltip instead of a
separate list underneath, so this stays compact and the actual
games-needing-a-referee list isn't pushed below the fold.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 17:32:03 +02:00
8ddae5e63b Stack the referee dashboard's season stats full-width instead of 2/3+1/3
The season-at-a-glance numbers become their own full-width KPI row (same
treatment as the existing "games in view" row above), with the chart and
per-referee breakdown as a separate full-width card below, instead of
squeezed into a narrow sidebar next to the chart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 17:29:24 +02:00
cf1a2f0f5a Add a season workload chart to the referee management dashboard
Games refereed per referee this season, as a chart.js bar chart plus a
small table with fees paid per referee, alongside season-at-a-glance KPIs
(active referees, total games refereed, average per referee) -- separate
from the existing "games in view" KPIs, which track the upcoming range
picked by the filter strip rather than season-to-date workload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 17:25:09 +02:00
747514f1ff Let eligible referees self-serve sign-up for games needing one
Creating (or re-teaming) a home game for a club-managed team now auto-
invites every eligible referee (teams.RefereeProfile) via a new
RefereeSignup model, notifying them the same way news/events already do.
They see it as its own distinct row on the mobile Calendar (own accent
colour) and can accept or decline right there -- accepting routes through
the existing capacity-checked assign_referee (assigned_by=None marks it
self-service), so it lands as a real EventReferee row with no separate sync
step. The desktop referee-management screen and event detail page both
surface pending invites and flag self-signed-up referees distinctly from
admin assignments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 17:19:48 +02:00
135580d83e Simplify the line-up to yes/no picks, and stop truncating notifications
Coach mode's line-up screen no longer has lines/slots -- just a yes/no
toggle per available roster player, grouped by their roster position, both
in coach mode and on the published member-side view. Replaces LineupUnit/
LineupSlot with a single LineupSelection model.

Notifications now show their full body text (no more truncatechars) and the
unread colour bar spans the full row height via self-stretch, matching the
calendar row's own marker, so it still reads correctly once a body wraps to
several lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 16:29:21 +02:00
2b6a4d21bb Rationalize news notifications and the Home news teaser for multi-child families
A family with several kids on a news item's audience now gets one email/one
Notification, not one per child -- deduped on resolved recipient emails, so
overlapping (not just identical) guardian sets still collapse correctly.
Event notifications are untouched: each child still needs their own reply.

Home's "Club news" teaser is also decoupled from the person-scope switcher --
a parent with no team of their own still sees their kids' team news when
they've picked their own "Me" chip, not just when "All" is selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 16:09:36 +02:00
62c36f47b9 Show the published line-up on the member event view instead of RSVP buttons
Once a game's line-up is published, "Your answers" switches to a read-only
status pill and a new Line-up card shows every unit/slot; a SELECTED member
can still report they can no longer make it, which flips them to Absent and
notifies the team's managers immediately (the closed-deadline guard doesn't
apply here, since a published line-up is usually well past it). Also shows
the full date for the "Meet" time, not just the hour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 16:04:03 +02:00
a9bf98a3c9 Switch RSVP button rows to CSS grid; brighten the face-off meta line
flex-1 + min-w-0 still wasn't producing equal-width In/Out (and In/Maybe/
Out) buttons in practice -- a longer label's own content could still win
a wider share. Switched every such row to CSS grid (grid-cols-2/3): each
track is a content-independent minmax(0, 1fr), so equal width is
guaranteed by the grid itself rather than relying on flex-basis/min-width
interactions. Applied to the hero In/Out row, its Cancel/Confirm pair,
event_detail's 3-way In/Maybe/Out row, and its own Cancel/Confirm pair.

Also brightened the hero's face-off/meet/location meta line (was
text-on-dark, a dim grey) to plain white, matching the readability pass
already done on the "are you in?" subtext and the button labels.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 15:51:29 +02:00
dba28ffc24 Improve hero RSVP readability: whiter text, bordered buttons, a cohesive reason panel
The "are you in?" subtext and the In/Out button labels were both dim
grey (text-on-dark-dim/text-on-dark) against a photo backdrop -- bumped
to white, and gave both buttons a shared border (border-white/30) for
definition against the image, same colour on both so they read as a
matched pair.

The Out reason step (label, textarea, Cancel/Confirm) used to be loose
floating controls directly on the photo -- now wrapped in one bordered/
backed panel (same border colour as the In/Out pair) so it reads as one
cohesive control instead of scattered text and buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 15:03:33 +02:00
366239e60b Fix RSVP buttons broken by hx-boost; require a real Out reason; add optional Maybe reason
The In/Out hero buttons (and Out's reason confirm) stopped working: Alpine
owns the toggle between a row's two sibling forms (buttons vs. the reason
prompt), and hx-boost="true" on <body> had htmx *also* intercepting the
same submit -- both ended up fighting over it. Fixed by marking every
write-action <form> across the mobile app hx-boost="false" (link
navigation, where the smooth-navigation feature actually matters, is
untouched). The one exception worth calling out: coach/lineup.html's form
uses three submit buttons sharing one <form> via formaction overrides --
htmx's boost reads the form's own action rather than the submitter's
formaction override, so a boosted click there would always have posted to
the wrong endpoint regardless of the Alpine conflict.

Also:
- A reason for Out is now mandatory, not just captured -- empty and
  punctuation-only "answers" (a bare ".", "-", "??") are rejected
  server-side (the authoritative check) with textarea required/minlength
  as a client-side nudge on top.
- Maybe can now carry an optional reason too, visible to the same audience
  as Out's (this member/family, and Coach mode's bench attendance) -- one
  shared reason form in event_detail.html's per-person row, its hidden
  status input following whichever of Maybe/Out was tapped.
- The 3-way In/Maybe/Out row (and the 2-way hero In/Out) now use min-w-0 on
  every button so flex-1 actually splits the row evenly -- a longer
  label's own intrinsic width was winning it a bigger share otherwise.
- HomeView's "Needs your answer" list now excludes events whose
  registration deadline has already passed -- replying is no longer
  possible there (same rule EventDetailView.post already enforces), so it
  doesn't belong in a "still needs a reply" list. hero_attendance is
  unaffected -- it always shows the true next event, falling back to a
  read-only pill once its own deadline closes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 14:59:23 +02:00
614c35861b Capture a private reason when declining an event RSVP
Attendance.note already existed but nothing wrote to it. Picking "Out"
anywhere (Home's hero, Coach Today's "Also yours", event_detail's per-
person answers) now opens an optional reason field before submitting,
via an Alpine two-step within the same form rather than a separate
confirmation screen. EventDetailView.post stores it only when the status
is actually "absent", and clears it the moment someone flips back to
In/Maybe -- a stale "sick" note under a since-changed answer would just
be confusing.

Private by construction, not by a permission check: nothing anywhere
renders another member's own note -- the squad-response view stays
counts-only like it already was. The only two places that read it back
are this member/family's own "Your answers" card and Coach mode's bench
attendance screen, both already scoped to people the viewer has a real
claim on.

Also, on the shared hero In/Out buttons (Home + Coach Today): dropped the
green "In" styling -- nobody's answered yet at that point, so a green
default misleadingly read as an already-recorded answer -- and added
min-w-0 to both buttons so flex-1 actually splits the row evenly; a
longer label's own intrinsic width was winning it a bigger share
otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 13:01:07 +02:00
3426ea8447 Darken the event-background hero gradient for readability
A bright/light event photo (e.g. a well-lit rink) let too much through at
the top and middle of the gradient, making the "Next up" eyebrow and the
face-off/location meta line hard to read against it. Increases the
gradient stops on both Home's hero card and Event detail's header --
darkening the backdrop rather than lightening the text, which was already
white/near-max.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 12:54:12 +02:00
1d22a57208 Coach header: back to dark ink, ice-blue only on the active switcher pill
The previous turn's all-ice-blue header wasn't what the design doc actually
called for -- its own text is explicit: "the role switcher with Coach
active (bg-ice)", i.e. the accent belongs on the switcher's active segment,
not the header background. Reverted .coach-header to dark ink (matching
Member mode's own navy) and added .role-switcher-item-active-ice (bg-ice/
ice-ink) for just the Manager pill, leaving Member mode's own white/ink
active state untouched. All the header_extra text this touched (Attendance,
Line-up) reverts to white/on-dark to match.

Fixed the switcher jumping position between modes: both shells' top info
row now carries the same min-h-11 (previously only implicit in the member
header, via its bell button's own height), and the switcher sits directly
below that row in both -- the coach header's team-picker pill row (multi-
team accounts) now renders *after* the switcher instead of before it, so
its conditional presence can't shift the switcher down anymore.

Tab bar order swapped per feedback -- Today / Squad / Schedule / [+],
not Today / Squad / [+] / Schedule -- the "+" reads better as the last,
most prominent item than sandwiched in the middle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 23:28:23 +02:00
1d159ca5de Redesign the Coach mode shell: ice-blue header, flat sheet, Squad/Schedule tabs
Reworks the coach shell per feedback that the previous dark-ink header
didn't read as visually distinct from Member mode's own navy header, and
that the rounded "sheet overlaps header" treatment wasn't landing:

- .coach-header is now solid ice-blue (--color-ice, never club-themed --
  Coach mode's own signature colour) with --color-ice-ink foreground text,
  instead of dark ink with white text. Every header_extra block across the
  coach templates (attendance, lineup) is recoloured to match.
- .coach-sheet drops the 20px rounded-top/negative-margin overlap -- flush
  edge-to-edge below the header now, same join the member shell already uses.
- The header's "Head coach" label was hardcoded regardless of the account's
  actual role -- CoachScopeMixin now resolves active_team_role from the
  person's own current-season StaffAssignment.position on the active team,
  so a team manager or physio sees their real title, not someone else's.

Tab bar is now Today / Squad / [+] / Schedule -- no Me tab (the account's
own settings already live in Member mode via the role switcher, no need
for a second one). Squad (CoachSquadView) is a new roster+staff screen for
the active team; Schedule (CoachScheduleView) is a new full upcoming-events
list for the team, each row routed straight to the coach-relevant action
(Attendance for a practice, Line-up for a game) rather than the Member-
shell RSVP page. The "+" is a raised ice-blue circle opening a small popup
with New event/New post/Add player -- three genuinely different actions,
so the button opens a menu rather than committing to one destination.
Today's own inline New event/New post/Add player buttons are gone now that
the tab bar covers the same ground.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 23:21:27 +02:00
64d0fec547 Switch the mobile app's icons to lucide, matching management/controlpanel
Every icon in mobile/templates/mobile/ was a hand-rolled inline <svg> with
raw path data -- management and controlpanel have used django-lucide
throughout instead (confirmed already 100% consistent there, audited as
part of this change). django-lucide is a pure server-side Python tag with
no JS/CDN runtime dependency, so this is a drop-in swap producing the same
kind of literal <svg><path .../></svg> markup mobile already wrote by hand,
just pulled from the shared vendored icon set instead of duplicated per
call site. Mapped each shape to its nearest Lucide name: bell, house
("home" doesn't exist as a lucide name -- confirmed by hand), calendar,
newspaper, user, users, chevron-left, chevron-right, check, x. Hardcoded
hex strokes (#0b1220, #fff) became stroke="currentColor" + a text-ink/
text-white class, matching how every other icon in this app already gets
its color.

Also caught and fixed a hx-boost gap while touching coach/base.html: the
coach tab bar's own "Me" link (-> mobile:me, a Member-shell view) was
missing the hx-boost="false" every other cross-shell link already got.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 23:08:32 +02:00
1ced0619a3 Enable hx-boost app-wide for SPA-like navigation; Me page polish
hx-boost="true" on both shells' <body> turns every same-shell link/form
into an AJAX navigation (swapping <body>'s children, pushing the URL)
instead of a full browser reload -- htmx.js was already loaded on every
page but had zero actual usage anywhere in the codebase until now.

Only <body>'s children swap under a boost, never the tag itself, so
anything that crosses between the two differently-styled shells (Member's
bg-paper vs Coach's bg-ink, different data attributes) is marked
hx-boost="false" to force a real navigation instead: both role-switcher
links, the Me page's "Teams I coach/manage" row (leads into Coach mode),
and Coach Today's "Also yours" quick-RSVP forms (post to the Member-shell
event_detail view). The calendar-sync "Add to calendar" webcal:// link is
also excluded -- it's meant to hand off to the OS calendar app, not be
treated as in-app navigation.

Also, on the Me page:
- Renamed the mode switcher and the "Teams I coach" card to "Manager"/
  "Teams I coach/manage" -- a team manager assigned there isn't
  necessarily a coach.
- Fixed the team badge, which was slicing short_name to 2 characters
  ("U16" showing as "U1") -- replaced with a plain icon instead of text,
  so there's nothing left to truncate (the full team name is still the
  row's own label).
- Styled the whole card dark (m-card-dark), matching the design mock's
  own dark "Coach mode" promo card treatment for this part of M5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 22:59:47 +02:00
c0c7ec9f1e Show teams the account holder coaches on the Me page
New beyond the design mock: a "Teams I coach" card listing every
current-season StaffAssignment self.me holds (team + role), each row
linking straight into Coach mode for that team via ?team=<pk>, which
CoachScopeMixin already resolves and persists to the session. Shown for
any staffed team, not just ones self.me manages -- Coach mode's own
screens already render read-only for a non-management position, so
there's nothing to hide on this summary row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 22:51:57 +02:00
53b3c56594 Add Coach mode C3: game line-up, the last of the six coach screens
New models (events/models.py): Lineup (one per event), LineupUnit (Line 1,
Defence pair 1, ... -- coach-entered labels, no fixed sport structure since
neither Club nor Team carries one), LineupSlot (one position, optionally
filled by a member). events/services/lineup.py's publish_lineup is the
reason Attendance.AttendanceStatus.SELECTED/NOT_SELECTED existed at all --
publishing flips every slotted member to SELECTED and every other available
(non-out, non-silent) roster member to NOT_SELECTED, then notifies only the
selected players.

Placement is a native <select> per slot, batch-saved with one "Save
line-up" submit, not the design mock's drag-and-drop -- this codebase has
no established htmx interaction pattern yet (htmx.js is loaded but nothing
uses it) to build a live per-tap version on, and a reliable plain form beats
a first, unproven real-time interaction for an already-large screen.
place_member handles "swap" semantics for it: placing a member vacates any
other slot of theirs in the same lineup, bumping whoever was already in the
target slot back to the available pool.

Wires the missing pieces from C1's own docstring: the "needs you" line-up-
not-published row and the tonight card's "Line-up" button, both deferred
in that stage specifically because this model didn't exist yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 22:47:42 +02:00
ccd4e0aa13 Add Coach mode C6: bulk-add players to the active team's roster
management.forms.TeamMembershipForm is shaped for one member at a time
(per-row jersey number/position/captain flags), which doesn't fit a "tap a
few names, add them" flow -- the design mock itself shows plain checkboxes,
no inline position picker. Reuses the same two eligibility rules the form
applies internally (teams.services.eligible_roster_members, minus whoever's
already on this team+season) directly instead, and lets a coach fill in
jersey number/position afterward on the desktop -- the model's own
help_text already documents a blank position as a normal, expected state.

"Suggested" (on this team last season) is real, computed data via
club.models.Season.before. "Age eligible" from the mock isn't built --
neither Club nor Team carries an age-group field to compare a birth date
against, so faking that filter would just mean it silently matched nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 22:36:29 +02:00
e211584696 Add Coach mode C5: post news, reusing the desktop's own NewsForm
management.forms.NewsForm defaults its teams field to every club team --
fine for an editor/admin, but a real gap for a coach, who should only ever
post as their own team, never "on behalf of" one they don't run. Re-scoped
to teams_managed_by via self.managed_teams, and made required (a coach's
post is always team-scoped, never empty/club-wide -- that stays an
editor/admin claim).

Gated with club.services.access.can_add_news, which already includes
is_coach_manager. On submit the post goes straight to
News.submit_for_review() plus the same notify_editors_of_pending_review
call the desktop's own NewsSubmitForReviewView makes, landing in an
editor's queue instead of a silent draft. The button reads "Send" rather
than the mock's "Publish" -- can_publish_news stays editor/admin-only, so
that's the honest description of what actually happens.

visibility is left at the model's own INTERNAL default rather than
building the mock's "also on club website" toggle -- an editor reviewing
the pending post can widen it before publishing if a public-site
placement is actually warranted; that's a real gate, not a decorative
row, so it isn't reproduced as one here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 22:32:44 +02:00
995f7ecfc9 Add Coach mode C4: create event, reusing the desktop's own EventForm
management.forms.EventForm already scopes its teams field to
teams_managed_by(user, club) via EventAudienceFormMixin -- exactly the
restriction a coach needs, so it's reused as-is rather than duplicated.
Only a subset of the desktop form's fields is rendered (title, kind, teams,
location, start, answers-close deadline); the rest stays unset, all of it
optional on the model except max_referees, which is dropped from the form
entirely so its own default (2) applies instead of demanding a value this
screen has no field for.

After a successful save, replicates management.views.EventCreateView's own
notify_new_event.delay(...) call by hand, since this isn't a CreateView --
attendance sync itself is automatic via events/signals.py.

"Repeat weekly" from the design mock isn't wired up this stage -- the
recurring-series machinery is a separate form with its own fields; adding
it is later work, not something to fake with an inert toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 22:28:44 +02:00
6a9a6204ed Add Coach mode: dark-chrome shell, mode switcher, C1 Today, C2 Bench attendance
First slice of the coach-mode build (design_handoff_rosterchief_platform/
README.md's "Coach mode" section, C1-C6). Ships the foundation together with
the two screens the design doc calls out as the reason coaches install
anything at all, rather than landing a Today screen with a dead "Check
attendance" button:

- mobile/coach_mixins.py's CoachScopeMixin -- the dark-mode mirror of
  PersonScopeMixin, scoped by active team (via club.services.access'
  teams_staffed_by/teams_managed_by) instead of managed people.
- A standalone dark ink/ice shell (mobile/templates/mobile/coach/base.html)
  with the mode's signature 20px-radius overlapping sheet, reusing the
  --color-ice/--color-ink tokens that already existed in assets/mobile.css
  but were unconsumed until now.
- The Coach/Member role switcher is re-added to the member shell, gated on
  a real staff assignment (has_coach_access), replacing the "deliberately
  not rendered yet" placeholder.
- C1 Today: stat tiles, a tonight's-session card, a silent-players "needs
  you" row, and an "Also yours" card reusing HomeView's own hero-RSVP
  pattern scoped to the coach's own member record.
- C2 Bench attendance: writes through events.services.attendance.
  record_check_in, which existed for exactly this and had no caller yet.
  Read-only for staff on a team without a management position.

Line-up (C3), create event (C4), post news (C5), and add-to-roster (C6)
follow in later stages -- see the coach-mode plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 22:21:25 +02:00
9fbcc89646 Fix the staff-row divider on the team detail page to match the player table
divide-y was stacking two borders: the custom .divide-y > * + * rule (top,
--color-rule) plus Tailwind's own generated divide-y utility (bottom width/
style with no color, so it fell back to a dark currentColor). Give each
staff row its own border-b border-rule instead, the same single-border
approach the roster table already uses via .table tbody tr.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 22:09:50 +02:00
0ce1593af1 Add a Payments & dues section to the Me page, matching the design's M5 row
Home already had the M1 dues card; Me's own "Payments & dues" row was
explicitly stubbed out with nowhere to lead. Give it a real destination:
a Payments screen (open balances for everyone managed, reusing Home's
dues-card layout via a shared _dues_row.html partial) and a "N OPEN"
pill on the Me row itself, only shown once something is actually owed.
club.services.fees.open_dues_rows is factored out so Home and Payments
can never drift apart on what counts as "still open".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 21:48:50 +02:00
4b5a4a81ec Show everything past next week on Calendar, grouped by month
The agenda used to hard-cut at a ~14-day lookahead with no way to see
anything past it. Drop the cutoff and bucket whatever falls beyond
"Next week" into per-month groups (each its own sticky header), so the
screen stays a full upcoming agenda instead of losing events that were
still worth seeing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 20:50:54 +02:00
9c16141d06 Stretch the calendar row's kind-marker bar to the full content height
Fixed at h-9.5 it matched the old two-line layout, but fell short next
to a three-line row (title + meta + location). self-stretch keeps it
matching whatever the content column actually renders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 20:45:10 +02:00
a35535de7c Give the calendar row a single kind-marker bar and a dedicated location line
Games previously got a 4px left border while training/other events used a
3px bar next to the date — move games onto the same bar for one consistent
marker position. Also break the location name out of the crowded meta line
into its own row so it doesn't get truncated away.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 20:41:42 +02:00
56e4b988c0 Reorganize the identity page: legal address gets its own card, Colours moves beside the preview
Legal address (street/zip/city) split out of the "Club" card into its
own "Legal address" card. "Colours" moved to the second column, above
"Live preview" -- the card it actually drives, and now sits next to
instead of scrolled away from it.

The colour inputs are physically outside <form id="club-settings-form">
now, so each gets an explicit form="club-settings-form" attribute to
keep submitting with the rest of the identity form -- HTML supports this
natively, no JS or structural change needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 20:32:28 +02:00
9e581370e4 Add a club event-background photo, shown in black & white on event heroes
New Club.event_background, uploaded from the identity page. Individual
events have no photo of their own (an established, deliberate scope
decision for this build), so this is the one club-wide stand-in for the
photo the design canvas's own hero mockups call for -- shown under the
same dark gradient the hero already used, filtered to grayscale so it
never fights the club's own brand colours. Falls back to the existing
plain dark background when nothing's uploaded.

Applied to both event hero treatments: M2's own event-detail screen (the
"big black thing" this was reported against) and Home's "next up" card.
The image and gradient are separate absolutely-positioned layers behind
a z-10 content wrapper, not a filter on the card itself, so the
grayscale treatment never touches the text/buttons drawn on top of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 18:11:02 +02:00
c7887637ff Add a club legal address, used for invoices/referee forms ahead of the home ground
New Club.legal_address/legal_zip_code/legal_city, editable from the
identity page (management:club_settings). Official document headers
(the dues invoice, the referee payment form) previously borrowed the
club's home Location for this -- conflating "where we play" with "our
registered address", which aren't always the same place. New
club.services.invoicing.resolve_document_address(club) picks the club's
own legal address when set, falling back to the home Location exactly as
before when it isn't, so nothing breaks for a club that hasn't set one
yet. Location.is_home now means only what it always should have: telling
a home game from an away one.

Renamed the shared "home_location" template/context variable to
"document_address" on both PDFs to match -- it was never accurate once a
legal address could win instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 17:50:08 +02:00
80fda6a4a4 Verify the news-publish notification chain end-to-end
New test: publishing a news item with the "notify linked members" toggle
checked creates a Notification, sends a push (mocked webpush call
asserted), and the row actually shows up -- unread -- in the member's
own mobile inbox. Confirms mobile.signals' post_save hook on
Notification (built generically, not news-specific) correctly catches
notifications created from the management app's own publish flow, not
just the ones mobile's own views create. No bugs found; this closes the
loop the request asked to verify.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 17:43:30 +02:00
5bfb6ce76e Bigger unread badge with a real count, and a Clear all action for notifications
The header bell's unread indicator was an 8px dot -- now a proper count
badge (capped at "9+"), matching the size/legibility of similar badges
elsewhere in the app. Notifications also gains "Clear all" next to
"Mark all read" -- a hard delete of the list (the same clear-all gesture
a phone's own notification centre uses), not another read-state flip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 17:41:14 +02:00
385e2f3c38 Enforce the RSVP deadline server-side; fix the calendar window shrinking late in the week
EventDetailView.post now rejects a POST once event.deadline has passed
(400), matching the read-only treatment the UI already showed -- a
disabled button is only a hint, not a guarantee against a direct POST.
M2's own "Your answers" card now also shows a read-only status pill
instead of the In/Maybe/Out buttons once closed, the same way Home's
hero card already did.

Also fixed Calendar's window: it was pinned to "through next calendar
week's Sunday", which meant the effective lookahead shrank to as little
as 8-9 days whenever today fell late in the week (e.g. a Friday), so an
event 10 days out could silently not appear even though it's well within
what "the next two weeks" should mean. The window is now always >= 14
days from today regardless of which weekday today is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 17:39:23 +02:00
17b1c0220a Add a periodic deadline-reminder job for events, including recurring series
New events.tasks.send_deadline_reminders (daily beat schedule, registered
in features.jobs.JOB_REGISTRY for the control panel's Jobs tab), the gap
notify_new_event's own docstring flagged: a recurring series' occurrences
never go through that on-creation path (they're bulk-generated by
extend_event_series, and notifying per-occurrence there would flood
everyone), so they never got any "you need to answer this" nudge at all.

This sweeps every upcoming event once, one week before whichever cutoff
matters -- the event's own answer deadline, or its start when none is
set -- and notifies whoever's still NO_RESPONSE, using the same
notify_members() -> Notification -> push chain notify_new_event already
uses. New Event.deadline_reminder_sent_at makes it idempotent: each
event's window opens once, is marked processed regardless of whether
anyone needed notifying, and a missed run still catches anything whose
window hasn't fully closed by the time the job next runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 17:33:33 +02:00
81f8f7f7dd Notify members when a new event is planned
New events.tasks.notify_new_event, scheduled from
management.views.EventCreateView.form_valid -- the deliberate "a staff
member planned one new event" action, not every Event row that happens
to get created. A recurring series' rolling-horizon extension
(extend_event_series) and bulk fixture imports are NOT wired to this on
purpose: either would flood everyone with one push per occurrence
instead of the single, deliberate action this is meant to catch.

Notifies whoever is still NO_RESPONSE right after creation -- exactly
"everyone who needs to respond", using the existing notify_members() ->
Notification -> mobile.signals push chain already in place, so this is
in-app (visible in the M7 inbox, mark read/mark-all-read) and a push
notification both, with no new plumbing needed there.

Generalized mobile.views.NotificationsView's News-only "tap to open the
source" handling (previously isinstance(source, News)) into
_notification_source_link, covering Event sources too -- tapping an
"new event" notification now marks it read and opens the event's answer
screen, the same way a news notification already opened the article.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 17:03:26 +02:00
1bf3a9d37a Merge the News page's title into the shared navy header too
Same header_extra treatment Calendar and Me just got -- a "News" title
in the app-header instead of no page heading at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 16:16:16 +02:00
65801a7843 Add a working event-kind filter to Calendar; merge per-screen headers into the shared navy bar
Calendar gains a real "All/Games/Practices" filter (?kind=), a working
version of the design mock's own "Games only" pill -- its List/Month
toggle isn't reproduced, that's a whole second view mode, not a filter.

base.html gains a header_extra block so a screen can add its own title
into the *same* navy app-header instead of stacking a second,
differently-coloured block underneath it. Me's avatar/name/subtitle row
moved there from its own bg-ink block (a visible colour seam that didn't
match the design canvas's own M5 markup, which keeps it all in one navy
area), and Calendar now shows a "Calendar" title the same way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 16:14:32 +02:00
40bee9af48 Show a News empty state on Home instead of hiding the card
Previously the whole "Club news" card just vanished when there was
nothing to show, dropping the "All news" link along with it. Now it
always renders -- either the real teaser or a "No news yet" card, still
linking through to mobile:news_list either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 16:09:15 +02:00
5096d0ba05 Vertically center the event-detail label/value rows
Face-off/Meet/Where each sat at the top of their row instead of centered
against the value -- fine for a single-line value, but Where's two-line
address made the label visibly misaligned. Added items-center to each
row's flex container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 16:07:00 +02:00
515480ce51 Add calendar sync: a combined iCal subscription feed per account
New mobile:calendar_feed (/app/calendar/<token>.ics) -- a standard
webcal-style subscription feed calendar apps can poll, so a family's
schedule shows up in Apple/Google/Outlook Calendar alongside everything
else. Token-authenticated rather than session-authenticated (calendar
apps can't do interactive login); CalendarFeedToken is deliberately not
club-scoped, since the same token works under whichever club subdomain
the request is fetched from -- an account active in more than one club
needs only the one link.

Combined across every managed person (their name goes in the event
title when there's more than one), every event they're invited to
regardless of RSVP status, not capped to Calendar's own 2-week window --
a synced calendar app is exactly where someone wants the whole season
visible. A cancelled event stays in the feed as STATUS:CANCELLED rather
than disappearing, so it's removed properly on the subscriber's next
refresh instead of just vanishing.

New mobile:calendar_feed_settings (linked from Me) shows the webcal://
and https:// links plus a "reset my calendar link" action that
immediately invalidates the old one. Uses the icalendar package for
RFC 5545 generation rather than hand-rolling escaping/line-folding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 15:15:11 +02:00
0e81e729b3 Show up to 3 news items on Home, with a link to a full "All news" page
Home's news card now shows up to 3 recent items (lead item with its photo
placeholder, the next two as compact rows) instead of just the latest
one, plus an "All news" link -- the design canvas's own M1 markup already
had that link, just unbuilt until now. Both also now filter by
visibility (internal or both, never external-only -- that's the public
website's own audience), which the teaser never actually enforced before.

New mobile:news_list page is the full archive: every published,
member-visible item for the club, newest first, not narrowed to any
particular team the way Home's own teaser is. Paginated at 20/page. The
bottom tab bar's News tab now links here instead of a dead #news anchor
on Home.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 15:07:48 +02:00
96fc58e453 Home/Calendar polish: person switcher placement, full-width Calendar, sponsors
- Home: the person switcher (chip row) now lives in the white content area
  instead of the navy header, matching the design canvas's own M1 markup,
  and renders smaller. Home is now the only screen that shows it at all.
- Calendar: dropped the person switcher and the "My schedule"/"All members"
  toggle entirely -- it always shows every event self.managed_people is
  invited to, full stop. Rows now run edge-to-edge (-mx-4) instead of
  living in a rounded, inset card, matching the M3 design canvas's own
  full-bleed layout. (The design mock's List/Month/"Games only" controls
  aren't reproduced -- no real functionality behind them yet.)
- Home's "Needs your answer" card caps at 5 items with a "+N more in
  Calendar" link, so it can't crowd the dues/news cards below it off the
  first screenful.
- Home gains a sponsors strip at the very bottom (horizontally scrolling,
  scrollbar hidden) -- active sponsors only, reshuffled on every request.
  club.services.sponsors.active_sponsors factors the "what counts as
  active" query out of club/api.py's public sponsors endpoint so both it
  and Home share one definition.
- Also removed mobile/views.py's now-fully-dead _PlaceholderScreen and its
  template -- every M1-M7 screen has had a real implementation for a while
  and nothing subclassed it anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 15:00:33 +02:00
4b2b84793f Fix the bottom tab bar never actually highlighting the active screen
active_tab/screen_title were set as class attributes on every screen view,
but only ever threaded into the template context by _PlaceholderScreen's
own get_context_data -- which no real M1-M7 screen has inherited from
since each one got its own concrete implementation. Every screen's own
get_context_data override skipped them, so `active_tab` was always
undefined in the template and the tab bar never lit up. Defaulted both
from the class attributes in PersonScopeMixin (every screen's shared
base) via kwargs.setdefault, so a screen with its own dynamic
screen_title (EventDetailView's event.title) still wins.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 15:00:16 +02:00
4dcf50c4e3 Hide the scrollbar on the person-switcher chip row
Still scrolls the same way, just without the visible scrollbar chrome --
a new .scrollbar-hide utility (scrollbar-width/-ms-overflow-style plus
the WebKit pseudo-element) rather than anything scroll-behaviour-affecting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 14:43:29 +02:00
29c05b4be6 Stop the service worker from serving stale CSS/JS forever
The shell cache was cache-first: once a browser had static/css/mobile.css
cached, nothing in the fetch handler ever re-requested it, so every CSS/JS
fix shipped since install stayed invisible (exactly what happened with
the crest becoming round -- an already-loaded app kept showing the old
hexagon). Switched to network-first with a cache fallback for offline use
only, and bumped the cache name so existing installs drop their stale
v1 cache on next activation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 14:31:49 +02:00
119341001a Round the app header crest and show initials without a logo
The header crest was management.css's hexagon clip-path mark, and its
no-logo fallback rendered an empty coloured hexagon with no initials at
all. Both now render as a plain circle, and the fallback shows the
club's initials on its own colour -- same source (Club.logo/Club.initials)
the PWA install icon already uses, just inline in the header instead of
a generated PNG.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 14:27:56 +02:00
f4429263d3 Make the management week calendar cover the full 24 hours
DEFAULT_DAY_START_HOUR/DEFAULT_DAY_END_HOUR only widened the grid's
default 08:00-22:00 window for an event outside it, on whichever end
that event fell on -- so a single early-morning event (e.g. one starting
at midnight) widened the start but left the end clipped at 22:00 rather
than covering the full day. Default the window to the full day (0-24)
instead; the same expansion logic still guards a pathological
almost-midnight-ending event, it just never needs to trigger anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 14:24:43 +02:00
397b9cf687 Add an "All" scope to the person switcher, default once there's a family
A lone member (or a parent of exactly one child) still lands straight on
their own record and never sees the switcher, same as before. The moment
there's more than one managed person, an "All" chip appears first and is
selected by default -- Home's cards (hero, needs-your-answer, dues) now
aggregate across everyone in scope instead of just one person, and
Calendar's own "My schedule" default does the same for its agenda.

PersonScopeMixin gains ``scope_everyone`` (bool) and ``people_in_scope``
(the effective list to filter by, regardless of which mode is active) --
kept separate from Calendar's own, unrelated ``?scope=all`` toggle
("every club event" vs. this "every person I manage"). Every RSVP form
now names its target member explicitly (``member_id``) rather than
relying on the old implicit "whoever is currently scoped" fallback, which
stops working the moment "All" -- not one person -- is the default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 14:22:40 +02:00
59d287e978 Factor the avatar-initials circle into a shared partial
mobile/templates/mobile/_avatar.html replaces four copies of the same
rounded-full/font-display/initials-slicing markup across event_detail.html,
me.html (the account's own header avatar and each "People I manage" row),
and news_detail.html's byline -- size/text/background stay parameterized
per call site so nothing changes visually.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 14:14:31 +02:00
2cdba6a571 Build M6 Edit personal info for the mobile app
The last of the seven Member-mode screens. A real editable form for the
fields Member actually has (first/last name, date of birth, email,
phone, emergency phone) -- the design mock's national-register-number,
address, allergies/notes and consent-toggle rows have no backing field
and are omitted rather than added as new schema for a screen-building
pass. Two mock rows do have real data and are shown read-only instead:
every guardian via Member.guardians ("Emergency contact"), and any open
onboarding requirement for the person's current-season membership (via
club.services.onboarding.checklist_for) as a banner -- no upload/complete
action, that stays staff-only elsewhere in the platform.

Authorization: the target Member (from the URL) must be one of the
signed-in account's managed_people, checked on both GET and POST -- an
unmanaged id 404s, same as another club's Event/News already does
elsewhere in this app.

This completes M1-M7 (Home, Event detail, Calendar, News article, Me &
my people, Edit personal info, Notifications). Coach mode (C1-C6) is a
separate, later phase per the design doc and is intentionally not
started here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 14:11:56 +02:00
19e1acb93a Build M5 Me & my people for the mobile app
An account-level hub: a header for the signed-in account's own record
(member-since year, a "Team manager {team}" label for someone managing
1-2 teams, otherwise "Staff"), a "People I manage" list linking each
person into M6's edit screen, and a settings card linking into M6 (for
the account's own record) and M7 (Notifications).

The mockup's "Household & contacts"/"Payments & dues" rows and its
"Coach mode" promo have nowhere to lead in this build -- no dedicated
screen for the former, no Coach mode screens at all for the latter (same
precedent base.html's own role switcher already established) -- so
they're omitted rather than built as dead links. "Licence OK" has no
backing field either; each row's meta line uses real current-season
roster data (team + jersey number) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 14:04:04 +02:00
3559c936f7 Build M4 News article for the mobile app
A photo-hero permalink for one published news item -- same dark-gradient
hero treatment as M2's event detail, with the club's main_photo where one
exists. Visibility mirrors news.tasks.notify_news_published's own
"actually live" gate, so a scheduled-but-not-yet-published item 404s here
the same way. Shows the English translation only when the request's
active language actually is English; native text otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 13:57:50 +02:00
f41a255b61 Build M7 Notifications for the mobile app
A flat, day-grouped notification list (Today / Earlier this week / Older)
scoped to every person the account manages, not just the switched-to one
-- matching how the header's unread badge already counts. The design
mock's per-type cards (RSVP-needed, invoice-due, medical-form, ...) have
no backing model support yet -- only news publishing creates a member
notification today -- so this stays generic rather than fabricating
categories; a notification whose source resolves to a News item links
through to it. Mark-all-read and mark-one-read actions on the same URL,
plus a first UI trigger for the push-subscribe plumbing built earlier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 13:52:44 +02:00
13f369e10c Build M2 Event detail ("answer for several") for the mobile app
Full event-detail screen: hero header, Face-off/Meet/Where facts (the
design mock's Kit/dressing-room details have no backing Event field, so
they're simply omitted), a per-managed-person RSVP card so a parent with
several kids on the same event can answer for each independently, and a
club-visible squad-response aggregate (counts only, never who answered
what).

Extends the existing quick-RSVP POST (built for M1's Home hero) to also
accept "maybe" for M2's three-way buttons, and adds an optional
next=event_detail redirect target so answering here doesn't bounce back
to Home -- M1's and M3's existing forms are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 13:44:53 +02:00
29acd22b7f Build M3 Calendar for the mobile app
A bounded chronological agenda (this week / next week, no month paging --
the mobile screen doesn't need the desktop week/month grid
events.services.calendar was built for) scoped to the person switcher's
current selection, plus an "All members" toggle for the whole club's
schedule. Each row links into the still-placeholder-GET event-detail
screen that M2 builds out next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 10:52:59 +02:00
18627d2843 Build M1 Home for the mobile app
Hero card for the soonest upcoming event with a quick In/Out RSVP,
a "needs your answer" list, a season-dues card, and a news teaser --
all scoped to the person switcher's current selection. The RSVP quick
action posts to the (still placeholder-GET) event-detail URL that a
later screen (M2) will build out fully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 10:39:18 +02:00
09df5d25b8 Scaffold the mobile member app: PWA shell, push, and app-shell tokens
New `mobile` Django app mounted at /app/ -- the installed PWA for Member
mode (M1-M7, see design_handoff_rosterchief_platform/README.md). Coach
mode (C1-C6) is a later phase and has no routes yet.

Foundation pieces:
- assets/mobile.css: Tailwind v4 theme reusing management.css's design
  tokens (same per-club --tenant-* theming pattern), plus the ice/coach
  accent and mobile's 14px card radius.
- PushSubscription model + pywebpush-based sender (mobile/services/push.py),
  wired to notifications.Notification via a post_save signal so the
  existing notification system gains a push channel without knowing about
  PWAs itself.
- Per-club manifest.webmanifest + service worker (served at /app/sw.js)
  + a server-rendered fallback home-screen icon (club initials on
  secondary_color) for clubs without an uploaded logo -- confirmed with
  the user as the fallback, never a generic RosterChief mark.
- App shell (base.html): navy header, person switcher (every child a
  signed-in parent manages, plus "Me"), bottom tab bar, safe-area insets.
  Vendored htmx + Alpine for the screens built on top of it.
- Placeholder views/routes for all seven M1-M7 screens so the shell is
  fully wired end-to-end before each screen is built out individually.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 10:27:13 +02:00
0ecdeac354 Add a news review workflow and a staff notification area
News gains a PENDING_REVIEW status between draft and published. A
non-editor author (can_add_news but not can_publish_news -- a
coach_manager, not an ADMIN/EDITOR) gets a "Send for review" button
instead of Publish; an editor/admin always sees Publish directly, no
review step. Submitting notifies every ADMIN/EDITOR in-app only (see
notify_members' new send_email=False) -- a review queue that emailed
on every submission would get noisy fast.

The notification area itself: a topbar bell (badge + dropdown, same
<details>/<summary> convention as the sidebar's user-menu, generalised
to a shared .dismissable-details close handler) visible on every page,
plus a fuller "Notifications" card on the dashboard, both fed by a new
notification_bell context processor. "Mark all read" clears the
signed-in staff member's own unread notifications for this club.

This is the reusable notification system's first consumer beyond news
publishing itself -- validates that notify_members()/Notification
generalise the way they were meant to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 09:52:59 +02:00
20a44a915f Fix unreadable white-on-white text when hovering the active identity page tab
The JS toggle set bg-white/text-ink on the newly active tab but never
removed hover:text-white, which was still sitting in that tab's class
list from its inactive state (or, for Identity, added by hovering
while active) -- hovering the active pill turned its own text white
on its own white background.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 09:39:05 +02:00
5f7ca98eae Add a reusable notification system; wire it into news publishing
New `notifications` app: Notification (club-scoped, keyed to the
member it's about, generic `source` via a ContentType/object_id pair
so future activities can reuse this without a new model each time)
plus notify_members(), which resolves each member's own email (if
they hold a login) and every parent/guardian's, always -- a child
with their own account doesn't opt their parents out -- and emails
the club-branded template to whichever addresses that resolves to.
Delivery is email-only for now (no in-app feed exists yet); the row
is created either way, ready for one later.

news.tasks.notify_news_published resolves the audience (a team-scoped
item's current rosters, or every active member if it's club-wide) and
calls notify_members with the item's title/plain-text body.
NewsPublishForm gained a "Notify linked members" checkbox (opt-in,
default off); when checked, NewsPublishView schedules the task with
Celery's `eta` set to the item's own published_at -- a scheduled
item's notification arrives when it actually goes live, and an
immediate publish (eta in the past) just runs right away, no separate
branch needed.

Registered the new notification email on the Club identity page's
Email tab alongside the others.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 09:27:55 +02:00
916f9bf8ab Use the club's legal name and home address on the dues invoice header
Same convention as the referee payment form (official_name, home
Location) -- a dues invoice previously just showed the everyday name
with no address at all. Also gave the referee form's PDF preview a
sample external game ID so that part of the template shows up there
too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 09:19:09 +02:00
5b155fe4d4 Match the identity page's tab switcher to the calendar's bigger pill style
Moved into the filter strip, right-aligned like every other page's own
Calendar/List or Week/Month/Season toggle, and switched from the
bespoke .preview-tab/.preview-tab-active classes (now unused, removed)
to the same raw bg-steel pill markup event_list.html already uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 08:58:38 +02:00
3ff882d705 Move email previews onto the Club identity page; add PDF previews
Three top-level tabs (Identity/Email/PDF) replace the standalone
Settings > Email previews page and the small mock that used to live
inside the Identity tab's own Email sub-tab. Email and PDF now show
every real branded email/generated PDF this club can send, rendered
with sample data via a shared _document_preview_card.html partial
(email cards keep the HTML/plain-text toggle; PDF cards -- the
underlying HTML WeasyPrint would turn into a PDF, shown directly
rather than round-tripping through WeasyPrint -- don't need one).

Added a password-reset email preview (previously missing from the
audit -- it's allauth's own send path, not one of this app's, so it
didn't show up in the earlier grep for EmailMultiAlternatives/.send()
calls). Dropped parent-claim-approved: not directly used right now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 08:52:52 +02:00
6d69ff7230 Put the referee level's name and qualifying teams on the same row
Inherits from moves to its own row below -- pairing it with nothing
left an awkward empty half-row otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 23:14:46 +02:00
c91a8aa782 Make the referee level form full width
Dropped the max-w-3xl cap (matching sponsor_form.html's own full-width
card) and gave the teams multi-select the full row -- cramped at half
width, same col-span-2-for-wide-fields convention sponsor_form.html
already uses for its own textarea/file fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 23:12:45 +02:00
b423f0cf12 Fix blank HTML previews by loading each email from its own view, not srcdoc
The iframe's srcdoc="{{ preview.html }}" relied on Django's autoescaping
to correctly re-encode a document full of its own double-quoted
style="..." attributes, and on browser handling of an escaped, inherited-
CSP srcdoc document that didn't render reliably in practice -- the
panel showed blank. EmailPreviewRenderView now serves each preview's
HTML as an ordinary same-origin response at its own URL, and the
iframe just points `src` at it -- xframe_options_sameorigin overrides
the site-wide X-Frame-Options: DENY (SecurityMiddleware's default,
unset in settings.py) since this response only needs to be framed by
the page linking to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 23:06:26 +02:00
413fea59f2 Remove RefereeLevel.ordering now inherits_from covers the same need
A separate manually-kept number for "which tier is higher" is
redundant now that the inheritance chain already expresses it, and
risked drifting out of sync with it. Levels list/sort by name only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 23:03:27 +02:00
d5e396102e Add a Settings > Email previews page showing every branded email with sample data
Renders the real templates every send function uses (claim approved,
membership invoice, invoice reminder) against hand-built placeholder
context -- no real Member/ClubMembership/DuesInvoice row needed, so
nothing here can leak real data. Each card shows the subject line and
toggles between the HTML render (in an iframe, so the email's own
markup can't clash with the page's) and the plain-text body, using
the same bg-steel pill-toggle pattern as the news preview's NL/EN
switch (generalised to .view-toggle-btn, kept .news-lang-btn as an
alias). Admin-only, alongside Club identity in Settings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 22:54:08 +02:00
7ab7ac1982 Fix control panel dropdown text clipping at the bottom of the field
.select's content box (2.125rem height minus padding and border) left
only ~16px for a 14px line -- descenders (g/y/p/q) rendered clipped in
some browsers, most visibly on the Competition edit modal's Sport
dropdown. Bumped .input/.select to 2.25rem for headroom, and .btn/
.btn-square the same amount so a button next to a field in the same
row still lines up -- .btn itself never had the clipping problem
(flex centring), this is purely for row alignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 22:47:35 +02:00
d2eced9a50 Fix RefereeLevel.clean() rejecting a valid inherits_from on create
club_id is still None mid-validation for a brand-new level -- creation
assigns the club in form_valid(), after is_valid() already ran clean().
validate_club_scope only makes sense once club_id is actually set;
skip it on create, where the form's own already-club-scoped
inherits_from queryset is what prevents a cross-club pick anyway.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 22:47:23 +02:00
66d7de820f Let referee levels inherit from a lower level instead of relying on ordering alone
RefereeLevel.inherits_from chains levels together so a higher tier is
automatically eligible for everything a linked lower tier covers,
transitively, without hand-duplicating teams onto every level. Kept
the ordering field (still drives display order) but eligibility
everywhere (RefereeProfile.eligible_teams, events.services.referees,
the team detail page's eligible-referees list) now reads through
RefereeLevel.eligible_team_ids, which walks the inherits_from chain.
clean() rejects a loop, including an indirect one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 22:42:18 +02:00
5889b3740e Add a Competitions card to the control panel's Features page
Competition rows (events.services.competitions' per-club data-source
gate) could previously only be managed through the Django admin.
Adds a card alongside Flags/Switches with create/edit/delete, mirroring
the Flags card's own modal pattern -- no cascading effects to weigh on
delete since Event.competition matches by name, not a foreign key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 21:40:13 +02:00
3159064e2a Merge the identity page's App/Website preview tabs and make Email functional
App and Website were always shown together already, so giving them
separate tabs implied a toggle that didn't exist -- merged into one
"App & Website" tab. The Email tab now shows a real, colour-bound
sample (plain header with a secondary-coloured crest badge, a
primary-coloured button) matching the actual branded emails this app
already sends -- audited every mail-sending call site (claims.py,
the new invoicing service, and platform billing's club-facing
reminders) and the first two already use the branded shell; billing's
is intentionally unbranded since it's RosterChief invoicing the club,
not the club invoicing its own members.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 21:33:07 +02:00
94d6cbd4e9 Add a dues-invoicing feature: send, track and remind on membership fees
New DuesInvoice model (one per membership, resendable) plus
club.services.invoicing: resolves the best email to invoice (the
member's own, else a parent/guardian's), snapshots the outstanding
balance and a due date on send, and mails a branded HTML invoice
(same club-colour email shell as the parent-claim email) with a
WeasyPrint PDF attached when the native libs are available.

Dues & billing gains a bulk "Send invoice" action (checkbox selection
+ a shared due-in-days prompt), a per-row invoice status column, a
staff-facing invoice detail/PDF page, and a push-button "Send
reminders" action for every sent, unpaid invoice past its own due
date.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 21:23:06 +02:00
44ddf7b6eb Redesign referee management to match the rest of the app's visual language
KPIs now use the same flat card+caption anatomy as the dashboard and
finance pages instead of the older colored-border style. The range
picker moved into the filter strip as a segmented pill control (same
pattern as the calendar's Week/Month/Season toggle), and each game
card now carries a left accent stripe instead of a full-border colour
override, matching parent claims/news list conventions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:37:55 +02:00
8fcbf3a310 Move Sponsors to Finance and Referee management to Calendar in the sidebar
Sponsors sits better alongside Dues & billing than in Settings, and
Referee management is day-to-day operational work on games, so it
belongs with Events/Locations/Opponents rather than club-wide setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:34:28 +02:00
45380dc282 Remove OnboardingRequirement.order; restrict checklist actions to admin/MEMBER_ADMIN
Every active requirement blocks equally and there's no set order to complete
them in, so the configurable "order" field (and its ordering-by-number) is
gone -- requirements list alphabetically now, both in the admin UI and the
Onboarding requirements settings page.

Also closes a real permission gap found while checking this: marking a
checklist item complete, bypassing it, or reopening it (management/views.py's
MemberRequirementCompleteView/BypassView/IncompleteView) was open to *any*
staff member with page access, not just admin/MEMBER_ADMIN, despite the
member detail page's own Documents card implying otherwise. Switched all
three to MemberAdminRequiredMixin and hid the corresponding buttons/dialog
from anyone who can't use them. The Sign-up page's Bypass action was already
admin-only end to end (the whole page is ClubAdminRequiredMixin-gated), so
no change in practice there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:27:05 +02:00
224a3fe4ed Rework the Live preview to match D9's actual screenshot
Replaces the earlier sidebar+small-phone mock with D9's real layout: App/
Website/Email tabs (App is the only one with real content, so these are
static labels not a working control), a full-size phone mock, and a public-
site mock, plus the "Where the brand shows up" list. The phone's "Next up"
card now uses primary, not secondary -- it's a notice, not a call to action,
so it shouldn't compete visually with an actual accent-coloured button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:19:23 +02:00
59d1fce674 Add Club.website; rebuild Colours per D9's exact markup; add a mobile app preview
- club.website (URLField, blank) editable from both the club's own Identity
  page and the platform control panel's club form.
- Colours card rebuilt from the design canvas's literal D9 markup rather
  than the prior in-between version: swatch + mono hex merged into one
  bordered row (the swatch input and hex text input are the same real form
  field, just laid out together), and a contrast-check row of two colour-
  filled boxes instead of a plain text list -- labelled dynamically
  ("Black"/"White" on primary/secondary) since a club can pick a colour
  light enough that black, not white, is the real computed text colour.
- Logo card restyled to D9's dashed drop-zone look.
- Live preview gains a mobile app mock (marked "Coming soon") alongside the
  existing sidebar mock, using the same primary/secondary colours, ahead of
  that surface actually being built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:13:03 +02:00
a0226d51a9 Match the News list rows and filter chips to D8's exact spec
Pulled the literal markup from the design canvas (RosterChief Platform.dc.html)
rather than the prose summary: filter chips are 28px/6px-radius with a dark
ink background when active (not the club accent colour, and not a pill/
button shape), and each row is D8's real three-line anatomy -- status pill +
mono meta on their own line, a 20px condensed headline, then a muted author/
audience line -- not the single dense line from the last pass. It reads as a
list because rows are plain hairline-divided strips (#EEF0F3, exactly this
app's --color-rule), not because the type was shrunk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:07:12 +02:00
e5427c31dd Widen News' filter row, drop nested count badges; row Attendance/Documents/Referee cards
News list: the left pane is wider (380px -> 460px) so the four status chips
fit on one line, and each chip's count is now plain text in the pill itself
("All 42") rather than a nested badge -- matches D8's own "All 42 / Drafts 3"
wording literally. Member detail: Attendance, Documents and Referee
eligibility now sit side by side in one row instead of stacking as three
full-width cards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:03:37 +02:00
fc3901fb5a Add a live D9-style preview to the Club identity page
Two columns (form left, preview right, matching D9) with a mini mock of this
app's own sidebar + a sample button -- primary_color/secondary_color are
what those actually control (base.html's --tenant-* properties), so the
preview reflects a real surface rather than D9's aspirational App/Website/
Email tabs. Colour swatch pickers sync with the existing hex text fields in
both directions, and a contrast-ratio row mirrors Club._content_color_for's
exact WCAG math client-side, so the ✓/⚠ shown is the real number the server
computes on save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:00:13 +02:00
8baf3f7b3d Add the D7 attendance sparkline to member detail; tighten the News list rows
Member detail: a 12-bar season attendance sparkline (present/absent/upcoming)
beside Present/Absent/No-reply totals, shown for any rostered non-guardian
member with events this season (events.services.attendance.member_attendance_
sparkline/_counts). News list: rows are one dense line (status pill inline
with the headline, everything else folded into a single meta line) instead
of the previous multi-line stacked layout that read as a small card per item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 08:55:44 +02:00
71a96a8212 Match the Finance KPI cards to the Overview dashboard's style
Same anatomy as home.html's KPI row: plain white cards (no colour-coded
left border, no special dark "Registered" card), status carried by the
number's own colour instead, and a caption line under every card -- three
of the six previously had none.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 08:48:19 +02:00
331b0b8227 Redesign the News page around D8's list + preview split view
Replaces the flat news list with a D8-style two-pane layout: a filterable
list on the left (All/Drafts/Scheduled/Published chips with counts) and a
preview of whichever item is selected on the right, reusing the same
article/photos/publish markup news_detail.html already had (now factored
into _news_preview.html so both pages share it). Adds an NL/EN language
toggle to the article preview -- the club's two content languages are now
switchable in place, with a fallback note when a translation is missing,
instead of a separate "English" card that only appeared once translated.
The standalone news_detail.html permalink page is unchanged behaviourally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 08:30:24 +02:00
2b4cc7d527 Fix Events sidebar highlight broken by a context variable name collision
EventListView's calendar prev/next/today context was keyed "nav", shadowing
management.context_processors.active_nav_section's identically-named "nav"
(the value _nav_items.html uses to mark the Events sub-item active) -- so
clicking Calendar never highlighted Events underneath it. Renamed to
"calendar_nav".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 08:17:51 +02:00
441367362d Build the D5 events calendar, redesign event forms, and polish the events UI
Adds the Week/Month/Season calendar to the Events page (team/group-scoped
visibility, kind-colored blocks, a repeating-series indicator) alongside the
existing table as a List view, and redesigns event/event-series creation
around a kind-first picker with progressive disclosure instead of one long
flat form. Also: an accessible radio-based kind picker (no redundant
select-next-to-cards), a fixed week-grid layout bug, a filterable single-list
redesign of the event detail page's RSVP/responses modal, a tidied-up
recurrence field layout, and assorted button/field height and width fixes
across the referee panel and event forms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 01:08:53 +02:00
adf1120358 Checkpoint: management app redesign, onboarding/signup workflow, and events calendar backend
Large uncommitted body of work accumulated across sessions on this branch --
committing as a checkpoint so it's tracked and future worktree-isolated agents
see the real codebase instead of a stale ancestor commit. Covers the
management app's dedicated Tailwind theme and templates, the club onboarding
requirement/signup workflow (club/services/onboarding.py, requirement/status
models, sign-up dashboard), fee/status auto-activation decoupling, referee
management, and the new events calendar grid service layer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-19 23:34:43 +02:00
bff685966d Fix scrambled mobile layout on member detail and sweep the rest of the app
The mobile responsive pass still had real gaps: on member_detail.html,
dt/dd label-value rows (phone numbers, dates) squeezed onto one line
below `sm` instead of stacking, and guardian call/emergency-call
buttons -- whose labels embed the guardian's name -- used a bare
`grow` inside a non-wrapping flex row, pushing the whole row off
screen once a name was long enough. Both patterns are fixed here and
propagated to every other page with the same shape of bug:
event_detail.html and event_series_detail.html still had the
unmigrated dt/dd rows from before the first pass; sponsor_list.html's
URLs and parent_claim_list.html's emails could force a table-card
wider than the viewport (unbreakable strings, fixed with break-all);
parent_claim_list.html's Approve/Reject buttons could both land on
the left edge once the row wrapped (fixed with ms-auto); news_list.html's
team-badge list and news_detail.html's per-photo action buttons were
missing flex-wrap; team_detail.html had a stray table never converted
to table-cards and two unstacked stat lists; referee_management.html's
per-game referee badges were missing flex-wrap.

Also hide the three dashboard charts (signups, fee status, renewal
rate) below `lg` -- three squeezed canvases stacked on a phone added
scroll without adding readability the stat cards above don't already
give, and match family_list.html's search box to member_list.html's
icon-only-below-`sm` pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:35:26 +02:00
9f4a0ea687 Make the management app responsive on mobile
Replace the mobile nav's horizontal scroll strip (which wrapped into
an unreadable jumble because daisyUI's .menu sets flex-wrap: wrap and
.menu-horizontal never resets it) with a hamburger button that opens
a proper left-side drawer, mirrored in controlpanel for the same bug.
Move the theme toggle, "Management", and "Django admin" controls into
that drawer below `lg`, leaving only the account icon in the navbar,
so the club name has room to render in full instead of truncating to
initials.

Add a reusable `.table-cards` CSS pattern (app.css) that turns a list
table into a stack of labelled cards below `md`, and apply it to every
list/detail table in the management app -- members, families, teams,
memberships, events, groups, locations, opponents, positions, referee
levels/list, roles, sponsors, news, parent claims, team roster/staff,
and the shared family-members table. Tables revert to normal desktop
layout at `md` and up.

Member list gets extra passes: search submits icon-only below `sm` so
it fits next to the input, the Members/Guardians/Both tabs go
full-width, and last/first name merge into one column. Action-button
rows across every page stack full-width on mobile instead of wrapping
mid-button (shared fix in base.html). Home dashboard's upcoming-events
and news mini-tables become simple lists instead of overflowing
tables. Also: brand truncates instead of overflowing on a long club
name, a stray invalid xmlns attribute removed, a wrapping UUID badge
hidden below `sm` on team detail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:55:08 +02:00
5d3dbebd77 Fix Safari rejecting the news publish-date field
<input type=datetime-local> with no step attribute defaults to
whole-minute granularity, but the value was rendered via the "c"
filter (full ISO, always includes seconds) -- NewsPublishForm's
initial=timezone.now made every render carry live, non-zero seconds,
which violates that implicit step. Chrome tolerates the mismatch;
Safari enforces it and silently refuses to accept the field, with no
server-side validation error to explain why. Format datetime-local
values at minute precision instead. Fixes every field using this
widget (event start/end/gathering/deadline, series dtstart/until),
not just the one this was noticed on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:54:48 +02:00
67841491a6 Fix mismatched fallback text colour on the initials badge/button
The initials badge and reset button hardcoded two independent
fallback literals -- a background (#ec4899 / #0ea5e9) and a text
colour (#ffffff) -- that were only ever chosen together for a club's
own colour via Club._content_color_for. #ffffff on #ec4899 or
#0ea5e9 actually contrasts worse than black by the same WCAG formula
the app already uses elsewhere (verified: 6.4:1 vs 3.3:1, and 7.6:1
vs 2.8:1). Added a contrast_color filter so the text colour is always
derived from whatever background hex is actually in play -- real
club colour or fallback alike -- instead of a second, independently
guessed literal that can silently drift out of sync with the first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 09:50:07 +02:00
9901a90266 Add a name search box to the Families list
Matches a first or last name against any visible member on the
family -- parent/guardian or child -- narrowing the same
members_visible_to() set the unfiltered list already uses, so a
match still respects who the requester is allowed to see. Pagination
was already wired up; the shared pager already preserves ?q= on page
links.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 08:57:30 +02:00
ece39e51ed Rebuild compiled CSS for the new claims/families/pagination markup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 23:57:36 +02:00
8e64b37d53 Document the parent-claim, families, and email work in ARCHITECTURE.md
Covers submitted_by_user/family-merge semantics, season-scoped claim
history, the member/guardian/both filter and Families list, sidebar
counters, list pagination, and the HTML email templates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 23:56:13 +02:00
fbefdf3680 Merge branded HTML emails for claim approval and password reset 2026-08-11 23:53:25 +02:00
7f63d646ff Merge families list, filters, pagination, sidebar counters 2026-08-11 23:53:19 +02:00
b71478a018 Merge parent-claim flow redesign 2026-08-11 23:53:14 +02:00
400a930b1a Redesign claim-approved and password-reset emails as branded HTML
Confirmed Resend's /emails endpoint accepts an html field alongside
text. Claim-approved email now carries an HTML alternative with the
club's logo/colours; allauth's password-reset email is overridden
with the same treatment, falling back to RosterChief's own branding
outside a club context.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 23:53:09 +02:00
4be10a57e0 Add families list, member kind filter, sidebar counters, pagination
New Families list page (parents/children columns, edit goes to the
detail view). Members list gets a member/guardian/both filter so
guardians aren't just invisible. Sidebar now shows live counts for
pending parent claims and games missing a referee, always numeric.
Member, event, team, group, news and family lists are paginated with
a shared pager partial that preserves the query string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 23:53:00 +02:00
c4f0ec71c1 Season-scope claim history, self-service second-child claims, redesign approval screen
Signed-in parents get their details locked and pre-filled on the claim
form instead of retyped; approving links to their existing user and
merges into their existing family instead of creating a duplicate.
The approval screen is now a card grid with a searchable, pre-selected
child dropdown and a reason modal for rejection. The "already dealt
with" history is scoped to the current season.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 23:52:50 +02:00
b2657bb15d Fix the claims page's mismatched control heights, and separate approve/reject
Two things, found together while looking at the row of controls under each
pending claim.

The child dropdown stood taller than the btn-sm/input-sm around it because
ClaimReviewForm.child hardcoded its own class="select select-bordered w-full"
on the widget. templatetags/field.html's select branch already builds the
full class list itself (base classes + the size modifier), so the widget's
own class rendered as a second, non-merging class="..." attribute right next
to the generated one -- select-sm was in the markup, just shadowed by a
duplicate attribute the browser never applied. No other Select field in the
app hardcodes a class this way, which is why nothing else had the problem.
Fixed by dropping it and passing size="small" through {% form_field %}
instead, the same way every other compact inline select in the app does.

Also pulled Approve and Reject apart with justify-between rather than letting
both sit in one flex-wrap run, so they stay on opposite sides of the row
(and don't end up adjacent on a wrap) rather than one stray click apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:40:52 +02:00
cb7f56709b Email a set-password link on claim approval, flash identically either way
Two additions to the parent-claim flow: Club.contact_email (set from the
control panel, next to legal_name), and an email sent when an admin approves a
claim -- a real one-time set-password link built with allauth's own token
generator, so it lands in the same flow the login page's own reset would send
a parent to rather than a second, parallel one that could drift out of step
with it.

Never allowed to fail the approval: the family link and the guardian row are
real either way, and a mail server being briefly unreachable must not cost a
parent their place in the queue. The admin gets a distinct warning telling
them the email didn't go and to have the parent use "Forgot your password?"
instead.

The public submission flash keeps the enumeration guarantee the claim form
itself was built around: worded and timed identically whether or not a
matching child was found, sent before any lookup happens at all, mentioning
the club's contact email when the club has set one. A test compares the
rendered flash across a matching and a non-matching submission byte for byte.

One test-writing trap worth recording: assertRedirects follows the redirect
itself by default, and its own probe GET consumed the one-shot flash message
before a later explicit GET in the same test could see it --
fetch_redirect_response=False avoids the double-fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:33:41 +02:00
ca2b1a11b5 Add parent claims: onboarding a roster of children with no parents on file
The migration path for a club arriving with a list of children from a
federation export and no parent records. Children import without logins, each
into a family of their own -- that shape *is* the "nobody is responsible for
this child" state, so there's no unclaimed flag to drift out of step with
reality, and a family drops off the worklist by itself the moment a parent
joins it. `family_role=child` with a blank `family_group` asks for that; any
other lone role is still a mistake in the file.

Verification is a human decision, deliberately. A parent submits a public form
with the child's name and date of birth as free text -- no search, no
autocomplete, and the same response whether or not the child was found, because
the page needs no login and anything that resolved the child would turn it into
a way to enumerate the club's children. An admin matches it from a queue
against a shortlist that only ever contains children with nobody on file, so
approving can never quietly re-parent a child who already has one.

The alternatives were worse. A claim code needs a delivery channel the club may
not have and is a bearer token besides. Matching on name plus birthday hands out
someone else's child to whoever guesses a birthday. The club is the only party
that actually knows its own families.

That form is also the registration: open self-registration is now closed
(shadowing account_signup rather than removing the route, so the URL name
allauth's templates reverse still resolves). The account is created on
approval, not on submission, so a public form can't fill the user table. An
approved parent lands as a guardian -- login and family link, no membership, no
fee -- gets a password-reset link, and a minimal "my family" page.

One bug worth recording: families_awaiting_a_parent first used
annotate(Count(..., filter=...)) over a queryset already filtered on the same
join, so Django reused that join for the counts and a parent with no
ClubMembership of their own -- exactly what a newly linked guardian is -- went
uncounted, leaving the family unclaimed forever. Exists subqueries avoid it. A
test pins both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:18:26 +02:00
744b623403 Separate guardians from members: a parent is not automatically a member
members/services/family.py enrolled a parent exactly like the child they were
registering, so every parent held a full membership: counted in the member
list, in the club and platform KPIs, and in the fee roll, with a fee record of
their own. ClubMembership.kind (member | guardian) separates the two.

A guardian is attached to the club only through their child. They hold the
login, can be contacted and can sit in a Group -- the stated exception -- but
they are not a member: no fee (clean() refuses one), absent from the member
list, the fee list and every member count, and not eligible for a roster or a
staff spot. A parent who also plays or coaches is a member who happens to be a
parent; the two facts are independent, which is why this is its own field
rather than inferred from FamilyMembership.role.

A field on ClubMembership rather than a separate model because everything that
answers "is this person attached to this club" already reads through that table
-- tenancy, groups, the club-wide event audience -- and a second kind of link
would need a parallel path through all of it. What changes is only who counts.

Two things that weren't obvious going in:

Excluding guardians had to be a subtraction, not a narrower filter. The obvious
move -- match only member-kind rows and drop the MEMBER-role branch, since an
active membership of any kind grants that role -- also hides someone the club
knows but hasn't signed up for a season yet, which is a real state the member
edit page supports. Two existing tests caught it. _guardians_only() subtracts
instead, so anyone who also plays, is on staff or runs the club stays visible.

Their tie to the club isn't seasonal but rides on a per-season row, so it has
to be carried forward or a parent silently drops off at the season boundary
while their child stays enrolled. Copied from the immediately preceding season
only, so a deliberate removal isn't resurrected from an older row.

The data migration reclassifies existing parents, deliberately skipping anyone
who plays, is on a team's staff or holds an elevated ClubRole -- demoting them
would strip them from their own team's roster eligibility. Anything ambiguous
stays a member, which an admin can flip; noticing someone quietly vanished is
much harder.

The import template gains a membership_kind column next to family_role (a
child marked guardian is refused), and the review screen shows what each row
will join as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:11:41 +02:00
ab1c703baf Send the groups list Edit button to the group page, not the rename form
Every other list page with a detail view (Teams, Members, News, Events) has
Edit land on the overview; groups jumped straight to the rename form, skipping
the page where the members actually are. The group page already carries its own
Edit action, so nothing becomes unreachable.

The list pages that legitimately do go straight to a form -- Locations,
Opponents, Sponsors, Positions, Referee levels -- have no detail view at all,
which is why they stay as they are. Noted in the test so the inconsistency
doesn't read as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:54:32 +02:00
1d5286fbbf Rebuild team/group bulk add as a searchable row formset
The old page gave every eligible member a table row, which a club with a
hundred-plus members can't use, and its search was a GET round-trip that
discarded anything already ticked. Now you add rows: pick a person from a
searchable select, pick a position, fill in the details.

The position decides the role. Position.staff_position already distinguishes
them, so a staff position creates a StaffAssignment and anything else a
TeamMembership -- no separate "player or staff?" control that could disagree
with the position picked. Someone joining as both is two rows. Jersey number
and captaincy exist only on TeamMembership, so a staff row rejects them and the
row script greys them out, keyed off the data-staff marker PositionSelect
stamps on staff options.

All-or-nothing on submit: one bad row re-renders the page with every row still
filled in and the offending field flagged, rather than saving the good rows and
losing the rest -- a partial save costs far more when the rows were typed by
hand. Cross-row checks no single row can see (the same person twice, two rows
claiming one jersey) live on the formset's clean(); per-row checks live on the
row form. Eligibility is still never trusted from the POST.

Captain and alternate captain on one row is refused as self-contradictory, but
how many captains a team may have is deliberately left alone: neither the model
nor the single-add form constrains it, and inventing the rule in one entry path
only would be bypassable by adding players one at a time. A test pins that
absence so it reads as a decision rather than an oversight.

Two implementation notes worth keeping: rows are cloned from the template's
parsed content, not its innerHTML, because assigning "<tr>...</tr>" to a
detached <div> silently drops it; and these tables deliberately skip the usual
overflow-x-auto wrapper, which would make a scroll container that clips the
picker's dropdown for every row but the first couple. Removing a row leaves
TOTAL_FORMS alone -- Django reads a form whose fields are absent from the POST
as an unchanged extra and skips it, which is safe, unlike re-indexing live
inputs.

management/tests.py also carries the setUpTestData rationalisation from the
previous commit, which couldn't be split cleanly from the new bulk-add tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:48:19 +02:00
ffe8a3d301 Speed up and rationalise the test suite (158s -> 16s)
Nearly all of the wall clock was password hashing: there was no test-time
PASSWORD_HASHERS override, so Django's PBKDF2 default (~1.2M iterations) ran on
every create_user and every login, hundreds of times over. The fix lives in a
DiscoverRunner subclass wired in via TEST_RUNNER rather than a "test" in
sys.argv sniff in settings: a runner is only ever instantiated by `manage.py
test`, so there is no env var to mis-set and no import path by which a deployed
process can reach the weak hasher. Verified: outside the runner the hasher is
still PBKDF2. It also enables the cached template loader (the runner forces
DEBUG off *after* settings are read, so Django never turns it on by itself) and
silences django.request, whose 4xx/5xx logging buried real test output.

Second, the fixtures. Base classes were rebuilding a club, season, admin user,
membership, role and MFA authenticator once per test; those are read-only for
almost every test, so they move to setUpTestData and are built once per class.
Django hands each test its own deep copy and the per-test transaction rolls the
rows back, so the handful of tests that mutate them stay isolated -- proved with
--shuffle, --reverse and --parallel rather than assumed. Per-test work that
genuinely must stay per-test (client sign-ins, waffle cache clears that leak
across the transaction boundary) is left in setUp with a comment saying why.

Five tests removed, each strictly subsumed by another that asserts a superset;
their intent was folded into a comment on the survivor. Regression-pinning
tests -- the ones carrying comments naming the exact bug they catch -- were
left verbatim throughout.

Also closes a real gap this surfaced: teams had a cross-club position test for
TeamMembership but not for StaffAssignment, with an unused `other_coach`
fixture sitting there waiting for it.

Rejected: --parallel by default (every worker re-runs all 88 migrations, buying
~4s of wall clock for ~5x the CPU), and disabling migrations in tests (~3.5s,
but the schema would then come from models and the suite would stop catching a
broken migration).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:48:02 +02:00
581cc81ba7 Add group/club-wide event audiences and a Resend email backend
Events can now target members.Group audiences alongside teams, or go
club_wide (every ACTIVE ClubMembership member for the event's season)
instead of specific teams/groups -- the two are mutually exclusive,
enforced in EventForm/EventSeriesForm.clean() since an M2M can't be
validated via a DB CheckConstraint or Event.clean() (no PK yet). Attendance
sync (events/signals.py) now reacts to GroupMembership and ClubMembership
changes the same way it already did for TeamMembership. Authorization:
club.services.access.groups_manageable_by mirrors teams_managed_by (all
groups for an ADMIN, else only the ones the user belongs to -- Group has no
manager/owner concept); a non-admin needs at least one managed team or
belonged-to group to create/edit an event, club_wide stays admin-only, and
EventManagerRequiredMixin gained a get_groups() hook so a non-admin who
creates a group-only event isn't immediately locked out of managing it.

Also adds rosterchief.mail.ResendEmailBackend, an HTTP-API-based Django
email backend for Resend (resend.com) using the existing `requests`
dependency -- no new SDK. Opt in via DJANGO_EMAIL_BACKEND and RESEND_API_KEY;
every Django-sent email (allauth's password reset included) follows
whichever EMAIL_BACKEND is configured, so this covers all of them for free.
Resend's own SMTP relay remains a valid code-free alternative, documented
alongside it in .env.production.example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 12:02:26 +02:00
e737de9140 Fix the referee PDF's card background, make locations searchable, and add game end times to the API
Referee PDF: the info-card background used CSS color-mix(), which WeasyPrint
doesn't support -- the rule was silently dropped, leaving the card with no
background at all. Computed in Python instead (management/pdf.py) and baked
into the template as a plain hex value; the tint is based on the club's
primary_color, falling back to secondary_color when primary is itself (near)
black or white, where a straight tint would be invisible or too harsh.

Event forms: the location picker now shows "Name — City" (plus the country
when it isn't Belgium) and is searchable by name or city, reusing the
existing single-select searchable-select.js widget.

Games API: GameOut now carries `end` (explicit, or start + 2h when a GAME was
saved without one -- Event.save() sets this, never overwriting an explicit
end; other event kinds are untouched). /games/upcoming/ now includes
anything not yet finished rather than only things that haven't started, so a
game already in progress keeps showing up until its window closes; `status`
was adjusted to match so a game returned there never calls itself
"finished".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 23:44:11 +02:00
5dcffefb28 Round out referee amounts/colors, and add bilingual (nl/en) news content
Referee display: drop the redundant "External" pill from the PDF form, round
the event/dashboard "due" summary to 2 decimals (a fine-grained km rate like
0.083 was pushing the raw total to 3+ decimals), and theme the PDF's accent
colors off the club's own primary/secondary colors instead of a hardcoded
default.

News: title/body stay the club's own-language (Dutch) text; new optional
title_en/body_en carry a translation, with News.effective_title_en/
effective_body_en resolving the fallback to the original on read rather than
copying it in at save time -- so editing the Dutch text later never leaves a
stale English copy behind, and existing rows get correct fallback behaviour
with no backfill. The news form lays both languages out side by side; the
detail page only shows an English section when one was actually added. The
public API returns both languages in one call (title_nl/body_nl/excerpt_nl
alongside title_en/body_en/excerpt_en, the latter never blank) -- a breaking
rename of the previously-unprefixed title/body/excerpt fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 20:47:58 +02:00
0a9ac67b21 Fix widget attrs being dropped from rendered inputs, and redesign the referee PDF
field.html's "input" branch rendered type/class/name/value/placeholder only,
silently dropping every other widget attr -- so a NumberInput's step="any"
(added to let the per-km rate take values like 0.083) never reached the page,
and the browser fell back to whole-number-only validation. Pass widget attrs
through the same way the "select" branch already does.

Also gives the referee payment PDF a proper visual pass (accent header, an
info card for the game details, a real fee/km table with a grand-total row)
instead of the plain label/dotted-line layout, and lets the dashboard tile
show assigned referees with a "referee form" download button per tile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 12:34:33 +02:00
309bd4d83e Add referee assignment/eligibility system, team bulk-add, member Groups, and referee management dashboard with PDF export
Builds the referee workflow end to end: club-defined RefereeLevel/RefereeProfile
eligibility tied to teams, EventReferee assignment (member or external, with
fee/km payment tracking), an admin dashboard with KPI tiles, date-grouped game
tiles and range filters, and a downloadable payment form PDF modeled on the
club's existing paper document (using Club.legal_name when set). Also lands
team roster bulk-add, member mass-upload with family linking, and the
members.Group model, developed alongside this work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:39:19 +02:00
86e28c317f Commited some basic pycharm files 2026-08-08 21:18:20 +02:00
ae31c1d544 Add plan deletion, with a confirmation screen listing affected clubs
Due.plan is PROTECT -- a plan that has ever billed anyone can never
truly be removed, on purpose: amount/period_end/grace_until are
frozen on a Due precisely so a later change can't rewrite what was
actually charged, and losing the plan link off an old Due would do
exactly that to every historical invoice.

"Delete" therefore means one of two things, chosen automatically
(billing/services/plans.py):
- never billed anyone -> the row is removed outright.
- has billing history -> soft-deleted (Plan.deleted_at, is_active
  off): hidden from every picker/listing via the new opt-in
  Plan.objects.visible(), but the row survives so old invoices still
  show what they were billed under.

Either way, every club currently on the plan is unsubscribed outright
-- its Subscription row deleted, not just its plan field cleared.
"No plan" was already a fully-understood state everywhere else in the
app, so this reuses it instead of inventing a new one.

Also handles the easy-to-miss second group: a club on a DIFFERENT
plan, mid-trial, configured to convert to the plan being deleted
(Subscription.post_trial_plan). Left alone that would try to convert
onto a hidden/gone plan later; instead that club's trial is ended now
(both trial fields cleared, per the CheckConstraint requiring them
together) so it needs a new plan picked by hand.

The confirmation screen is a real page, not a modal like every other
billing action -- naming exactly which clubs are affected, in both
groups, and that list can be long.
2026-08-08 20:01:30 +02:00
617271f0d0 Honor auto_archive in the reminder email subject and the trial form
Two gaps found while checking whether auto_archive is honored
end-to-end (archive_overdue_clubs and the on-screen banner already
got it right):

- reminder_subject.txt branched only on notice.level, so a club with
  auto_archive off -- one that will NEVER be archived -- still got
  "Action required: X is about to be archived" as its subject line,
  contradicting the correctly-worded body underneath. Now gated on
  notice.level == 'error' AND notice.will_archive.
- TrialForm had no auto_renew/auto_archive fields at all, so a trial
  could only ever be started on the service defaults (both True).
  The only way to change either afterwards was "Change plan", which
  ends the trial as a side effect. Added both, matching
  SubscriptionForm's existing pair.
2026-08-08 19:14:36 +02:00
c2a4380b70 Lay the plan modal out in two columns instead of one tall one
PlanForm grew to seven fields with the billing rework, three of them
short day counts, and the modal ran off the bottom of the screen.

_modal_form.html gains two optional knobs -- box_class to widen the
box, two_columns to grid the fields -- so only the plan create/edit
modals opt in and every other modal renders exactly as before.
Textareas and file inputs still span the full width; below `sm` it
stays single-column, since two columns of form fields on a phone is
worse than a long scroll.

PlanForm's field order is chosen to suit that grid: pairing name with
duration and the two day-counts with each other fills every row
rather than leaving half of one empty next to the full-width
description.

Rebuilt static/css/app.css (unminified, matching how it is committed)
to pick up the new grid utilities.
2026-08-08 19:01:14 +02:00
fc6488ce55 Rework platform billing: per-plan clocks, grace from period start
Implements BILLING.md. The architecture was sound -- snapshot-on-Due,
dated prices, asymmetric dry-run commands are all kept -- so this
fixes the three hardcoded assumptions rather than rewriting.

The real defect: grace ran from period_END, so an annual club used
the whole unpaid year plus 45 days (~410 days) before anything
switched it off. Grace now runs from the period START, and every
clock is per-plan.

- Tier -> Plan (+ TierPrice -> PlanPrice, and every FK). Migration
  0004 is hand-written: run non-interactively, makemigrations emits
  DeleteModel+CreateModel and drops every price, subscription and
  due. Its two RemoveConstraints must come first, or SQLite's
  table-rebuild tries to render a constraint over a just-renamed
  column. Verified by round-tripping real rows through it.
- Plan gains duration_months / renewal_lead_days / grace_days /
  is_trial, with CheckConstraints and a matching clean() so the form
  reports an impossible plan instead of 500ing on IntegrityError.
- Existing dues keep their stored grace_until. Re-deriving it would
  put the date in the past for every open annual period and archive
  the entire paying customer base on the next --commit run.
- Trials take their length from the trial plan's own duration_months;
  start_trial() loses its trial_months argument.
- New BillingNotice service drives a club-facing warning: every level
  on the dashboard, and on every management page once urgent.
- send_billing_reminders emails club admins, once per escalation
  level so a daily cron is not a daily email. SMTP settings are
  env-driven and provider-agnostic; the backend defaults to console.
- Paying does not auto-restore an archived club -- the control panel
  surfaces a Reactivate prompt instead, since a club can also be
  archived by hand.
2026-08-08 18:49:52 +02:00
ae93406853 Add BILLING.md: design for the platform billing revision
Documents a revision, not a rewrite. The snapshot-on-Due pattern,
dated prices and the asymmetric dry-run commands are sound and are
kept; what's wrong is three hardcoded assumptions in models.py:
annual-only periods, a global GRACE_DAYS, and grace measured from
period_end -- which gives a club ~410 days of unpaid use before
archive_overdue_clubs will touch it.

Proposes per-plan duration/lead/grace, grace measured from
period_start, a Tier -> Plan rename, and a club-facing unpaid-dues
warning with a countdown to archiving.

Flags the migration hazard prominently: re-deriving grace_until on
existing dues under the new rule puts the date in the past for every
open annual period, which would archive the whole paying customer
base on the next --commit run.
2026-08-08 18:14:33 +02:00
34bad16b19 Render News.body as Markdown over the public API
Club staff author body as Markdown in the control panel (help_text
now explains the syntax); the public API renders it to sanitized
HTML on the way out via news/services.py -- markdown for the
conversion, nh3 (Rust/ammonia) to strip anything staff's raw
Markdown source might smuggle through (script tags, event handler
attributes, javascript: URLs) before it reaches someone else's
public website. The control panel's own preview is untouched and
still shows the raw source.

Excerpt is now derived from the rendered HTML's plain text rather
than the raw Markdown source, so syntax like ** or [text](url)
doesn't leak into what's meant to be a short teaser.
2026-08-07 22:19:46 +02:00
3d03ff644c Show a child's guardians' phone numbers on their Personal information card
Member.guardians already scoped correctly to "the parents/guardians of
the families where this member is the CHILD" -- no model changes
needed. Adds clearly-labelled phone/emergency phone rows per guardian
plus a dial button each, following the same tel: link pattern already
used for the member's own numbers. Empty for members who aren't a
child in any family.
2026-08-07 22:12:42 +02:00
783b235bcd Fix Tailwind classes missing in production for management/ and club/ templates
The Docker image's CSS build stage only COPYed assets, templates,
controlpanel and billing before running npm run build -- management
and club were never in that build context, even though assets/app.css's
@source lines already listed (or, for club, should have listed) them.
Any utility class used only inside those two apps' templates was
silently absent from the compiled static/css/app.css in production,
while working fine locally since `npm run build` there scans the full
checkout rather than a Docker COPY subset.

This is what made management/home.html's md:grid-cols-5 (the dashboard
KPI grid fix from earlier) never take effect on the server: the class
just didn't exist in production's CSS, so the grid silently fell back
to sm:grid-cols-2 at every width.

Verified by reproducing the exact Docker build context outside Docker:
md:grid-cols-5 is absent from the compiled CSS with the old COPY list,
present with the new one.

club/templates has no live bug today (everything it uses is also used
elsewhere), but it's the same gap and cheap to close before it bites.
2026-08-07 17:01:01 +02:00
7fd42e6047 Extend the public API: news excerpts/detail, game team logos, sponsor logo dimensions, player licenses
- news: NewsItemOut gains `excerpt` (truncated body); GET /news/{slug}/
  fetches a single item. slug already auto-populates on save, but a
  data migration backfills any pre-existing blank ones.
- games: home_team/away_team change from plain strings to {id, name,
  logo_url} objects -- home links to the actual Team (logo from the
  club's own logo, since teams have none of their own), away links
  to the actual Opponent (which already had a logo field). Breaking
  change for any existing consumer of the old string shape.
- sponsors: SponsorOut gains logo_width/logo_height, computed in
  Sponsor.save() -- Pillow for raster, a bounded regex read of the
  SVG root tag for vector logos (not a full XML parse, since that's
  exposed to entity-expansion attacks on untrusted uploads). A data
  migration backfills dimensions for existing sponsor logos.
- teams: PlayerOut gains `license`, sourced from ClubMembership (not
  Member -- it's per-club, per-season), batched in one query.
2026-08-07 16:33:49 +02:00
fc1942575f Merge remote-tracking branch 'github/main' 2026-08-07 16:07:13 +02:00
2b180f3c39 Update .idea module files and refactor CSS to remove unused styles 2026-08-07 15:55:28 +02:00
59c485a6ac Merge branch 'development'
# Conflicts:
#	.gitignore
#	.idea/modules.xml
#	.idea/pyLspTools.xml
2026-08-07 15:55:12 +02:00
844b0c35cc Refine .idea project settings and CSS styles 2026-08-07 15:54:35 +02:00
cd4d487161 Add new CSS utilities and enhancements to daisyui component styles 2026-08-07 15:52:12 +02:00
101e98b4a8 Show all 5 dashboard KPI cards in one row from md, not lg
The sidebar in management/base.html is hidden below lg (1024px), so
nothing competes for width below that breakpoint either -- but the
KPI grid stayed at 2 columns until lg anyway, showing 2 columns on
any window between 640-1023px wide for no reason.
2026-08-06 22:43:02 +02:00
6cd39fcb7f Document generate_seasons as a third scheduled job
It already exists as a cron-safe, idempotent management command
(club/management/commands/generate_seasons.py) but was missing from
the Scheduled jobs runbook.
2026-08-06 22:39:19 +02:00
e35788c6a7 Merge pull request #4 from bsiebens/development
Memory saving changes
2026-08-06 22:30:52 +02:00
fe19a6f08a Tune gunicorn/Postgres/Redis for a memory-limited server
- gunicorn: 3 workers -> 2 (this workload isn't CPU-bound per
  DEPLOYMENT.md's own sizing), add --preload so workers share
  immutable memory via copy-on-write instead of each independently
  importing Django, add --max-requests so a worker that renders a
  WeasyPrint invoice doesn't carry that memory forever.
- Postgres: trim shared_buffers/max_connections from the image
  defaults (128MB/100), sized for a ~0.2GB dataset instead.
- Redis: cap with --maxmemory as a ceiling, not a saving.
2026-08-06 22:29:20 +02:00
1be9959481 Serve club logos directly from Caddy instead of Django
Every image request was round-tripping through a gunicorn worker
for what is just a static file on disk. Caddy now serves /media/*
straight off the shared media_data volume (mounted read-only) and
only falls through to Django for anything else — Django's own
/media/* route stays as a fallback for compose.behind-proxy.yaml
and runserver, where there is no bundled Caddy container.
2026-08-06 22:18:27 +02:00
30be424985 Serve /media/* in production without the static() DEBUG gate
django.conf.urls.static.static() hard-codes its own
`if not settings.DEBUG: return []` internally, so the earlier
AWS_STORAGE_BUCKET_NAME guard around the call never mattered — no
route was ever added outside DEBUG, and every logo still 404d.
Build the pattern directly against django.views.static.serve, which
has no such gate.
2026-08-06 22:15:16 +02:00
f6d5d18f0d Fix media volume permissions for non-root container user
/app/media didn't exist in the image, so the media_data volume had
nothing to copy ownership from on first mount — Docker created the
mount point owned by root, and the container runs as rosterchief.
Uploads then failed with PermissionError. Create the directory
before the chown so it carries the right ownership into the volume.
2026-08-06 21:58:09 +02:00
9152fa77ce Merge pull request #3 from bsiebens/development
Log django.request errors to console in production
2026-08-06 21:51:52 +02:00
d4084de3c1 Log django.request errors to console in production
Django's default LOGGING gates its console handler behind
require_debug_true, so with DEBUG=False an unhandled exception
becomes a 500 with no trace anywhere in `docker compose logs`.
2026-08-06 21:51:17 +02:00
95cb0cbe3f Merge pull request #2 from bsiebens/development
Fix media files
2026-08-06 20:47:23 +02:00
4d0f74379d Merge remote-tracking branch 'github/development' into development 2026-08-06 20:45:25 +02:00
5b8ab72982 Fix club logo 404 in production and persist uploads
/media/* was only routed when DEBUG=True, so uploaded club logos
404d in production regardless of storage backend. Route it whenever
local-disk storage is in use instead, and give web a persistent
volume for MEDIA_ROOT so uploads survive a rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 20:41:33 +02:00
1ca9a5e908 Merge pull request #1 from bsiebens/development
Initial deployment
2026-08-06 20:00:22 +02:00
80c1fa1226 Merge branch 'main' into development 2026-08-06 19:55:42 +02:00
30b464eb56 Update .idea module configuration and simplify CSS by removing unused classes
- Updated `.idea/rosterchief.iml` to reflect module name changes and added `.venv` exclusions.
- Cleaned up `app.css` by removing unused classes and properties from legacy styles.
2026-08-06 19:51:42 +02:00
98b8002a04 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
2026-08-06 17:36:04 +02:00
6ad0d6658c Add trial subscriptions with automatic switch to a pre-selected plan
A club with no subscription yet can be started on a short trial (e.g.
2 months) from the control panel, on a tier picked up front for what
it switches to once the trial ends -- no manual follow-up needed. The
trial is a real billed period on a dedicated trial tier, reusing the
existing invoice/grace/archive machinery unchanged; the switch happens
in open_period() itself so it fires whether reached via the scheduled
renewal command or a platform admin's manual "Open period" click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
2026-08-04 12:28:47 +02:00
68cad0c951 Add a home-location box to the club detail page in the control panel
Setting a club's home ground here creates/updates the same events.Location
row (flagged is_home) that the club's own Teams > Locations page shows and
edits -- no separate sync step, it's the same record either way. Also
fixes the shared form_field templatetag: django-countries' CountryField
widget reports as "lazyselect", which fell through to a broken plain
text input instead of rendering as a dropdown.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
2026-08-04 12:27:48 +02:00
e6850232f0 Rework Teams/Positions access control and add full Locations/Opponents CRUD
Team managers/coaches now only see their own teams, can't create teams,
and can view (but not edit) positions -- admins keep full rights.
Locations and Opponents move from read-only stubs to full CRUD, gated
to admins and management-position staff, with a country dropdown
(django-countries) instead of free text. Also: the team list shows
player/staff counts, and deleting a news item's main photo promotes
another one instead of leaving the item without one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
2026-08-04 12:27:24 +02:00
9a4da9b136 Add a team attendance KPI panel and no-show check-in tracking
Team pages now show, for the selected season: overall attendance rate,
best/worst attenders, players who missed the last 2 practices, and
no-shows (an affirmative RSVP checked in as absent). No-shows need a
real distinction the RSVP status alone can't make, so Attendance gains
a separate showed_up tri-state field, usable today via Django admin --
a full check-in screen is future work for the coaches app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
2026-08-03 23:08:47 +02:00
ab1d34cd0a Fix a 500 when two roster entries clash on jersey number
team/season aren't TeamMembershipForm fields (the view sets them from
the URL, not user input), so Django's own validate_unique() silently
excludes both of them -- and with them, the whole
unique_jersey_number_per_team_per_season constraint. A clashing jersey
number reached the database unrejected and came back as a raw
IntegrityError. form.clean() now checks it by hand, same as
PositionForm already does for its own check constraint; the four
roster/staff save paths also get a try/except IntegrityError backstop
so any future gap degrades to an error banner instead of a 500.
2026-08-03 22:42:19 +02:00
054e1cb1be Reorganize the nav: drop "Club setup" as its own section
Positions moves under Teams, Roles under People (below Memberships) --
matches how Members/Teams already group what they own instead of every
admin-only page getting its own top-level section.
2026-08-03 22:40:14 +02:00
18e3b0306d Redesign the management home page's KPIs and charts
Drop renewal rate from the top KPI bar; split "Upcoming events" in half
and add a "News" overview beside it (published items only, everyone
can see it, same as events). Signups-per-month, club fee status, and
renewal rate (now a pie chart matching the fee status one, not a bare
stat box) move into their own row and become admin-only like the fee
chart already was. The bottom stat-group row now sizes its columns to
how many groups actually render, instead of leaving a blank slot where
Shop would be for non-admins.
2026-08-03 22:40:06 +02:00
92686b0755 Add a branded 403 page instead of Django's bare error page
templates/403.html is picked up automatically by Django's default
permission_denied handler -- no urls.py wiring needed. Rendered through
the tenant's own skin (base_template, same as maintenance.html), so a
permission error still looks like the app and the navbar (sign out,
theme toggle, home link) stays reachable instead of leaving the user
stuck on a dead end.
2026-08-03 22:15:52 +02:00
a3e413904f Fold Roster and Staff into Teams, with a per-season switcher and real CRUD
One "Teams" nav entry now, matching how Family has none of its own and
is only reached through Members -- Roster/Staff were pure club-wide
list stubs with no way to add/remove anyone except Django admin.
Opening a team shows its roster and staff for whichever season is
selected (?season=, defaulting to the current one, same pattern as the
Memberships page); adding/editing/removing either is gated to that
team's own manager or a club admin (TeamManagerRequiredMixin, defined
earlier but never actually wired up until now).
2026-08-03 22:08:35 +02:00
9a2a06180f Add edit/delete actions to the teams list
Admin-only, matching TeamCreateView/TeamUpdateView's existing gate.
Deleting cascades away the team's roster and staff assignments -- no
ProtectedError to catch, unlike a Member that orders/invoices can still
reference. Edit links to the detail page rather than the edit form
directly, same convention as the news and member lists.
2026-08-03 21:42:23 +02:00
2261f86596 Add edit/delete actions to the news list and detail pages
Both gated the same way editing already was: broad while a draft,
editor/admin-only once published. The list's Edit link goes to the
detail page rather than straight to the edit form, matching the
member list's convention.
2026-08-03 21:38:47 +02:00
859e3e5f84 Fix file uploads in modal forms: multi-file input rendering + enctype
Two bugs, both invisible until the news photo upload modal exercised
them: the shared field template didn't recognise a custom multi-file
widget (it fell through to a plain, useless text input), and the modal
form itself had no enctype="multipart/form-data" -- so even a correctly
rendered file input would have posted filenames only, never content.
2026-08-03 21:37:04 +02:00
d9b4337319 Add a shared searchable multiselect combobox for team/member pickers
Extracts the "Grant role" member picker's typeahead combobox into a
reusable static/js/searchable-select.js (opt in via data-searchable on
the widget), and reuses it for the news form's teams field -- a proper
multiselect with removable pills and filter-as-you-type, replacing the
plain checkbox list. Needed forwarding widget attrs through the shared
select template, which never passed them to the rendered <select>.
2026-08-03 21:36:35 +02:00
38947196c1 Group the club edit form's fields into labelled sections
Branding (logo, colours) and season settings now sit in their own
divider-separated groups instead of one flat grid, so the form reads
as distinct settings groups rather than an undifferentiated field list.
2026-08-03 18:47:09 +02:00
3e0c63ec36 Add a news app: coach_manager authoring, team tagging, photos, editor release flow
News and NewsPhoto (team-tagged instead of categorised, one photo taggable
as main via a partial unique constraint), gated per club/services/access.py:
any current-season coach_manager, EDITOR, or ADMIN can draft and edit a news
item; only EDITOR/ADMIN can publish it, or edit it once it's live. Publishing
takes a date so it can be scheduled ahead of time rather than only right now.

Authoring/release only for now -- no member-facing reading page or public API
yet, the visibility field (internal/external/both) is there for when those land.
2026-08-03 18:46:37 +02:00
ce35348b31 Build out Positions CRUD and rework the Roles page
Positions had list/create/edit already stubbed as list-only; give it real
forms, with a check mirroring the management_position_implies_staff_position
constraint so a bad combination reads as a form error, not a 500. Roles now
groups by role (excluding the MEMBER everyone holds automatically, which was
just noise), grants via a modal instead of a separate page, and its member
picker is a small typeahead combobox instead of a long native <select>.
2026-08-03 18:45:28 +02:00
9b0af5800a Give outline buttons an explicit neutral color
Every plain btn-outline (Cancel, Edit, Search, Waive payment, ...) rode
on daisyUI's default outline color, which reads as primary-tinted in
some themes -- explicit btn-neutral keeps secondary actions visually
distinct from the real primary/error actions beside them.

Also gives the club edit form's fields an explicit layout (a spacer
next to the logo upload, season settings paired with branding) instead
of a generic field loop.
2026-08-03 17:05:59 +02:00
2c4d031050 Show just surnames for an unnamed family, not "... family"
An unnamed family with distinct member surnames read as "Jones / Smith
family" -- dropping the suffix reads more like an actual family name.
2026-08-03 17:04:09 +02:00
d5f45c9404 Make user-facing strings translatable
Statistics labels, shop help text, and the confirm/form modal defaults
were plain strings; wrap them per CLAUDE.md's i18n convention so the
app stays translation-ready as it's written.
2026-08-03 17:03:49 +02:00
8598fd2b46 Generate seasons per club instead of a hardcoded Aug-May window
Club now carries its own season_start and season_duration_months,
editable via controlpanel; generate_seasons chains each new season off
the day after the club's last one ends (or its configured start, for a
club with none yet) instead of assuming every club runs Aug 1 - May 31.

Adds --resync to the generate_seasons command to clean up seasons left
over from the old hardcoded rule -- removing any that don't match a
club's current settings and aren't still referenced by real data.
2026-08-03 17:03:03 +02:00
062da00bb9 Add the management app: club-facing UI + real fee-payment tracking
Gives clubs a self-service /manage/ area for members, families, teams,
roles, and season memberships, alongside real fee-payment tracking
(FeePayment, record_payment/mark_as_paid/remaining_balance) so a
membership's paid status reflects actual money received instead of a
single manually-set flag.
2026-08-03 17:01:15 +02:00
5bea8a4a6c Keep /media/ reachable during maintenance, so the club logo still loads
The maintenance page is deliberately rendered through the club's own
skin so it can show the club's logo -- but MaintenanceMiddleware closed
every path on a club subdomain unconditionally, including /media/. The
logo's <img> then pointed at a URL that itself returned the maintenance
page (503, text/html) instead of the image, so it rendered as broken.

/media/ moves into ALWAYS_OPEN, checked before the club-vs-platform
branch, so it now stays reachable on every host during a lock-down --
matching what OPEN_PREFIXES already granted the base domain, just never
extended to a club subdomain.
2026-07-27 11:56:10 +02:00
febde41214 Let the club logo fill more of its ring, match it in the control panel
Drops the inner padding on the logo image so it fills the circle right
up to the ring, rather than floating small in the middle of it -- most
visible on logos with generous internal whitespace (e.g. some SVGs).

The control panel's club-detail page now frames the logo the same way.
It doesn't get the page-wide --color-primary override the club's own
site uses (the panel must never dress itself up as the club), so the
ring colour is set as a locally-scoped custom property on just this
element instead.
2026-07-27 11:15:08 +02:00
a86c35cbe7 Frame the club logo with a primary-coloured ring, sharpen its scaling
Wraps the uploaded logo in the same avatar shape as the initials
fallback (so the header doesn't jump in size depending on whether a
club has one) and adds a ring in the club's own primary colour as a
plain border around it.

Also hints the browser to use its higher-quality image scaler: a
club's raster logo is often much smaller than the badge it's shown in,
and the default upscaling in some engines reads as pixelated. Doesn't
affect SVG logos, which scale losslessly regardless.
2026-07-27 11:06:08 +02:00
c17e7b5059 Remove unused CSS utility classes and redundant theme variables
Cleans up unused Tailwind CSS classes, DaisyUI components, and theme variables from `static/css/app.css`. Also removes an unnecessary margin class in `field.html` for cleaner templates.
2026-07-27 10:56:30 +02:00
c1ba6e269b Rebuild compiled CSS to include bg-secondary / text-secondary-content
These are the club avatar's classes for the new secondary_color feature.
Tailwind only emits utility classes it sees used in a template scan, and
the dev watcher wasn't running when that avatar edit landed, so the
classes were silently absent from static/css/app.css: no background,
no text colour, just an empty (visually white) circle. A one-off build
regenerates them; both now correctly reference var(--color-secondary)
and var(--color-secondary-content) so a club's colour picks apply.
2026-07-27 10:37:43 +02:00
0223383c9d Fix flaky chart date windows: use calendar months, not 30-day steps
_monthly and signup_split approximated "N months ago" as N*30 days, which
drifts against real calendar months by several days a year. Late in some
months that drift undershot a full month, so the "dense 13-point series"
tests flaked depending on which day they ran (confirmed: every 28th+ of
most months). Switched to dateutil.relativedelta for exact calendar-month
arithmetic, which is stable on every day of every month.
2026-07-27 10:37:37 +02:00
90a99fb03e Rename PyCharm module to match the RosterChief project name 2026-07-27 10:27:18 +02:00
75679dcc40 Fix club-metrics test copy after the dashboard relabel
"No coach" was renamed to "Teams without coach" and the "Unpaid, by
age" card was replaced by the fee-status pie chart; the test still
asserted the old strings.
2026-07-27 10:27:13 +02:00
88a6c63f9f Allow SVG club logos
ImageField validates uploads through Pillow, which can't read SVGs, so
a club crest that's a vector logo was rejected outright. Switches to a
plain FileField with an extension allowlist (png/jpg/jpeg/gif/webp/svg)
instead, and restricts the file picker to image types via the widget's
accept attribute.
2026-07-27 10:27:07 +02:00
075c2918b6 Add a secondary brand colour for clubs
Mirrors primary_color: a club-picked hex highlight used for accents like
the avatar-initials badge, with a computed readable text colour so a
pale pick doesn't produce white-on-yellow text. The shared contrast math
moves into _content_color_for so both colours use the same rule.
2026-07-27 10:26:31 +02:00
ccaa9e991a Add a generic form_field templatetag for daisyUI form rendering
_form_fields.html and club_form.html each hand-rolled the same
label/input/help-text/error markup per field, one copy per widget type,
which drifted out of sync between the two partials. {% form_field %}
picks the right daisyUI markup from the field's widget type in one
place, so every form that renders fields this way gets the same
behaviour (size modifiers, placeholder-as-label, required badges) for
free.
2026-07-27 10:24:28 +02:00
bf1ec322ce Simplify add_one_year using dateutil.relativedelta
Replaces the hand-rolled leap-day fallback with the library that already
handles it, and already sits in the dependency tree.
2026-07-27 10:22:42 +02:00
08bf76e762 Make the OTP boxes focus the input when tapped
The overlaid OTP input has pointer-events: none so clicks land on the
decorative boxes, but that also means a tap never reached the real input
on a touchscreen (desktop got away with it via autofocus/Tab). Wrapping
the boxes in a <label for> restores focus-on-click without adding a DOM
child that would throw off the otp box count.
2026-07-27 10:20:53 +02:00
438d111718 Make the navbar responsive on small phone widths
Icon-only buttons and truncated labels below the sm breakpoint keep the
navbar from forcing the page wider than the device. Also adds a Django
admin shortcut for superusers and pins the scrollbar gutter so short and
tall pages no longer shift width against each other.
2026-07-27 10:19:15 +02:00
40255805c3 Modularize confirmation modal for destructive POST actions and add notify helper for concise message handling across the UI. 2026-07-16 18:34:03 +02:00
127d0e338e Modularize and streamline billing templates; replace _billing_form.html with reusable modals and shared partials, and update styles and interactions for consistency and clarity. billing, feature management, and forms
Refactored club detail templates to modularize common UI components. Standardized layout, interactions, and styles across admin, billing, and feature cards for consistency and reusability.
2026-07-16 16:44:48 +02:00
83caa233d7 Replace doughnut chart with pie chart in fees visualization and clean up unused CSS styles. 2026-07-16 08:38:17 +02:00
fc7a349f8f Add "Open" button to club detail for direct access to club subdomain 2026-07-16 00:12:29 +02:00
f403128f57 Improve club detail layout: enhance logo display, adjust badge styles, refine card layouts, and optimize spacing 2026-07-16 00:06:54 +02:00
91270b0cf8 Add "Plan end" column to health table and simplify badge styles
The health table now includes a "Plan end" column showing the coverage end date ("-" if none). Simplified several badge styles for consistency, replacing "n/a" with "-" and updating waived badges to use a ghost-outline class.
2026-07-15 18:28:02 +02:00
19108407c6 Show the cover-end date for waived periods too, not just paid
The "until <date>" only appeared for a PAID due, because the annotation filtered
status=PAID. A WAIVED period is settled just the same — the club is covered for
that time, and its end is still when grace would start — so it belongs there too.

The annotation is now `covered_until` (furthest-out period end where status is PAID
or WAIVED) plus `covered_status`, read from the same ordered row so the table can
badge "paid" vs "waived" and still show the date for both. Rides the same single
query — assertNumQueries(1) still holds.

Full matrix now: no plan -> n/a; paid -> "paid, until X"; waived -> "waived, until
X"; unpaid/partial -> the amount owed (no date); a paid-then-owing club shows the
amount, not the stale cover date. A subscribed club with no covering period at all
(only cancelled dues) shows a dash rather than a bare "paid".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:16:56 +02:00
10b113f244 Read the health-check port from the server's WEB_PORT
The probe now reads WEB_PORT from the server's .env — the same file compose reads —
and builds the health URL from it, falling back to 8001 (compose's own default) when
it is unset. No more passing HEALTH_URL by hand when the published port changes.

Parsed the way compose parses it: last assignment wins, surrounding quotes and
inline whitespace stripped, commented lines ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:25:39 +02:00
5b51f2c945 Point HOME at /app so gunicorn stops erroring on boot
gunicorn 26's control server creates a socket in $HOME. The app user has no home
directory, so every boot logged "Permission denied: /home/rosterchief" — harmless
but noisy. /app is the workdir and already owned by the app user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:18:10 +02:00
4d74f3fbde Always allow the loopback in ALLOWED_HOSTS, for health checks
/healthz is hit over 127.0.0.1 (the deploy probe) and localhost (the container's
own healthcheck) before any proxy has supplied a real Host header. With only the
public domain in ALLOWED_HOSTS, Django 400s both, the container is marked unhealthy
forever, and the deploy never goes green — which is how the first real deploy
failed.

The loopback is now appended unconditionally. It widens nothing: gunicorn binds to
the loopback only and the proxy owns the public domains, so nothing external can
present these hosts. Tested with a non-loopback ALLOWED_HOSTS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:15:08 +02:00
a1266378fc Fix deploy script: compose run was eating the rest of the heredoc
The whole remote script is fed to `ssh bash -s` as stdin (a heredoc). `docker
compose run` without -T attaches that stdin to the container, so it consumed every
line after the migrate — web was never restarted and no health check ran, yet the
script exited 0 and printed "Done". A deploy that half-ran and reported success.

`-T` plus `</dev/null` on the migrate command stops it reading the heredoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:10:09 +02:00
a2bcb2f0c8 Add a one-command SSH deploy for the dev server
deploy/deploy-dev.sh deploys the test instance to home.siebens.org behind its
existing Caddy: from your machine, over one SSH session, it fetches the pushed
branch, builds, migrates explicitly, restarts web, and waits for /healthz.

- A hard reset to origin/<branch>, not a pull: a deploy target only receives
  deploys, so it should match the branch exactly rather than risk a merge conflict
  from drift no one meant to leave on the server.
- Refuses to deploy a branch with unpushed local commits — the server pulls from
  git, so that would ship stale code without saying so.
- Migrations run explicitly (dc run --rm web migrate), never from the entrypoint,
  and only `web` is recreated so db/redis keep running.
- Fails loudly if .env.production or .env is missing rather than booting a
  half-configured stack, and dumps recent web logs if the health check never passes.

Host/user/dir/branch all override via env vars. Documented in DEPLOYMENT.md with
the first-time server setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:02:11 +02:00
9bc5377cc5 Drop the sidebar's "Platform" title, add top margin
Cosmetic: the section label was redundant with the panel it lives in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:58:24 +02:00
7c874aa87b Stack the paid-until date under the paid badge
The "until <date>" cover-end now sits on its own row beneath the paid badge,
right-aligned, rather than to its left on one line. Only the paid-with-plan branch
changed; overdue and n/a are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:48:20 +02:00
5283262e6b Show a paid club's cover end date in the health table
For a club on a plan whose dues are settled, the Dues cell now reads "until
<date> · paid" — the end of the current paid period, which is the day the grace
period would start if nothing renews. It is exactly the "when does this lapse?"
question the paid badge alone could not answer.

Driven by a new paid_until annotation: the furthest-out PAID period end, null when
the club owes or was never billed (so a fully-paid free tier shows just "paid",
and an owing club shows the amount, unchanged). It rides the SAME single query —
the assertNumQueries(1) test still holds — and the date is whitespace-nowrap so it
does not wrap in the narrow cell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:41:20 +02:00
65a2f741f6 Format: collapse a chained queryset to one line
ruff format, no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:35:19 +02:00
975426a17f Match tests to the reworked dashboard, surface the renewals KPI
The club table was rebuilt (logo, status badges, Plan/Dues columns, an Edit
action) and the dashboard dropped the second chart, so three render tests were
asserting columns and a canvas that no longer exist. Updated to the current
layout — the service-level tests were already correct, since the annotations they
check still exist even where the template stopped rendering them.

Worked the renewals KPI into the billing card: "N awaiting renewal", shown only
when non-zero. It should sit at 0 in normal running — the cron renews clubs 30
days out and they fall past the horizon — so a number here means the job has
stopped and a club is about to use the platform free, which nothing else on the
page reveals because nothing has been billed yet.

Fixed two things in the WIP table while here: a debug line that printed the raw
grace/period/owed values into the Dues cell, and a missing {% empty %} clause
(so an empty list showed a headed table with no "no clubs" row, and empty_message
was dead). Removed the stale commented-out copy of the old table.

Answers "auto-renewed but unpaid?": it is not a special case. Renewal opens an
ordinary unpaid Due, which flows unpaid -> grace -> overdue -> archive like any
period — so the safety net that the never-billed club slipped past now fires,
because there is a due to be overdue. Tested both ways.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:34:49 +02:00
9a616c20e4 Auto-renew subscriptions before they lapse
Answers "does a plan renew itself?": until now, no — and that was a silent revenue
leak, not merely a missing convenience. A club whose period ended with its last due
PAID owes nothing, so dues_overdue() is empty, so archive_overdue_clubs never fires.
The club kept using the platform for free and no dashboard number went red, because
nothing was ever billed. The safety net only caught clubs you remembered to invoice.

`renew_subscriptions` (cron) issues the next period 30 days before the current one
ends, so the invoice lands before the period lapses and grace only matters for
genuine non-payers. It is idempotent by construction: a just-renewed club has a
latest period a year out, past the horizon, so a second run is a no-op.

It ACTS by default and previews with --dry-run — the opposite asymmetry to
archiving, and deliberately so. Archiving switches off a customer, so not-acting is
safe there; here, not-acting is the expensive failure, because an unbilled club is
also an unchased one. An unpriced tier fails that one club loudly (non-zero exit, so
cron mails you) without stopping the rest.

Opt-out per club via Subscription.auto_renew, mirroring auto_archive: off means you
invoice that club by hand. The dashboard gains a "renewals pending" count that
should sit at ~0 — a number here means cron has died and a club is about to go free,
which no other metric would reveal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 07:26:41 +02:00
2d43b0b903 Make the control panel a real app shell with a fixed sidebar
The navbar and the menu stay put; only the content scrolls.

That is a property of the layout, not a `sticky` class: the body is a flex column
pinned to the viewport with overflow hidden, and <main> is the single scrolling
region. The sidebar is a SIBLING of <main>, not inside it, so it sits outside that
region and cannot drift by construction. A sticky sidebar in a scrollable body
still slides on a long page, which is the failure this avoids.

The tabs become a daisyUI menu with an active indicator (`menu-active`) and the
hover/focus states `.menu` already provides. Sidebar below `lg` would be a menu
nobody can reach, so the same items render as a horizontal menu there -- from ONE
partial, because two copies of a link list is how a new section ends up reachable on
a desktop and invisible on a phone.

Auth pages leave the menu block empty and <main> simply takes the full width.

Verified in a browser: content scrolled 800px, sidebar and navbar moved 0px, and
the body is not scrollable at all. Below `lg` the sidebar hides and all five links
are still there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:06:31 +02:00
a759f763ed Update to the CSS file 2026-07-14 17:54:18 +02:00
428 changed files with 64094 additions and 2402 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -36,3 +36,26 @@ DJANGO_STATICFILES_BACKEND=whitenoise.storage.CompressedManifestStaticFilesStora
# AWS_S3_REGION_NAME=fsn1
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# --- Email: any SMTP provider. Left unset, mail is PRINTED TO THE LOG and never delivered,
# which means send_billing_reminders will look like it worked while no club hears from you.
DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
DJANGO_EMAIL_HOST=smtp.example.com
DJANGO_EMAIL_PORT=587
DJANGO_EMAIL_HOST_USER=
DJANGO_EMAIL_HOST_PASSWORD=
DJANGO_EMAIL_USE_TLS=True
DJANGO_DEFAULT_FROM_EMAIL=RosterChief <noreply@rosterchief.app>
# Where a club is told to reply with a billing question.
ROSTERCHIEF_BILLING_CONTACT_EMAIL=billing@rosterchief.app
# --- Email via Resend (resend.com), as an alternative to the SMTP block above ---
# Two ways to use Resend; pick one, don't set both:
# 1. Resend's SMTP relay -- no code involved, just point the SMTP settings above at it:
# DJANGO_EMAIL_HOST=smtp.resend.com
# DJANGO_EMAIL_HOST_USER=resend
# DJANGO_EMAIL_HOST_PASSWORD=<your Resend API key>
# 2. Resend's HTTP API via rosterchief.mail.ResendEmailBackend (see that module):
# DJANGO_EMAIL_BACKEND=rosterchief.mail.ResendEmailBackend
# RESEND_API_KEY=<your Resend API key>
RESEND_API_KEY=

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ local_settings.py
db.sqlite3
db.sqlite3-journal
media
private_media
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.

2
.idea/modules.xml generated
View File

@@ -2,7 +2,7 @@
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ClubManager.iml" filepath="$PROJECT_DIR$/.idea/ClubManager.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/rosterchief.iml" filepath="$PROJECT_DIR$/.idea/rosterchief.iml" />
</modules>
</component>
</project>

13
.idea/pyLspTools.xml generated
View File

@@ -1,5 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PyToolsState">
<option name="tools">
<map>
<entry key="ruff">
<value>
<ToolEntry>
<option name="enabled" value="true" />
</ToolEntry>
</value>
</entry>
</map>
</option>
</component>
<component name="RuffConfiguration">
<option name="enabled" value="true" />
</component>

View File

@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
<component name="FacetManager">
<facet type="django" name="Django">
<configuration>
<option name="rootFolder" value="$MODULE_DIR$" />
<option name="settingsModule" value="clubmanager/settings.py" />
<option name="settingsModule" value="rosterchief/settings.py" />
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
<option name="environment" value="&lt;map/&gt;" />
<option name="doNotUseTestRunner" value="false" />
@@ -20,12 +20,4 @@
<orderEntry type="jdk" jdkName="uv (ClubManager) (3)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Django" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/templates" />
</list>
</option>
</component>
</module>

View File

@@ -26,6 +26,12 @@ The `isort` `known-first-party` roadmap lists nine apps. The build split the ori
two apps (`formbuilder`, `shop`) are added **beyond the original roadmap** — add all new
labels to `known-first-party` in `pyproject.toml` when they land. The target decomposition:
> Several rows below (`teams`, `events`, `members`, `club`'s `Season`/`ClubRole`) are marked
> "planned" but have since been built out considerably further than this table reflects —
> it hasn't been kept in lockstep with every session's work. Only `formbuilder` and the new
> `evaluations` row have been corrected here; treat the rest as directional, not current,
> and verify against the actual tree (per `CLAUDE.md`) before relying on a "planned" marker.
| App | Status | Responsibility | Models |
|------------------|--------------|-----------------------------------------------------------|--------|
| `authentication` | **built** | Login identity + tenancy/role services (global, cross-club) | `User` |
@@ -33,12 +39,13 @@ labels to `known-first-party` in `pyproject.toml` when they land. The target dec
| `club` | **built** | Tenant root, **season**, season-scoped affiliation, club roles | `Club`, `Season` *(planned)*, `ClubMembership`, `ClubRole` *(planned)* |
| `teams` | planned | Teams and season rosters | `Team`, `TeamMembership`, `StaffAssignment` |
| `events` | planned | Training / matches / social events + attendance | `Event`, `Attendance` |
| `news` | planned | Editorial news for the public site | `Article`, `Category` |
| `news` | **built** | Club news: coach_manager-authored, editor-released | `News`, `NewsPhoto` |
| `pages` | planned | Flat CMS pages for the public site | `Page` |
| `home` | planned | Homepage composition / featured content | `HomeConfig` (per-club) or config-only |
| `formbuilder` | planned | Admin-defined dynamic forms + submissions + reporting | `Form`, `Field`, `Submission`, `Answer` |
| `formbuilder` | **partial** | Admin-defined dynamic forms + submissions + reporting — models, dynamic-form-class builder, and the submit service are built (§5.6); no view/template renders a form for someone to fill in yet | `Form`, `Field`, `Submission`, `Answer` |
| `shop` | planned | Cart-like shop, orders, payments, PDF invoices | `Product`, `Cart`, `CartItem`, `Order`, `OrderLine`, `Payment`, `Invoice` |
| `search` | planned | Site search (likely no models; index/config only) | — |
| `evaluations` | **design only** | Player evaluations: customizable rubric (reuses `formbuilder`), restricted to a new `EVALUATOR` role/ADMIN, player profile (skills + attendance + notes) (§5.8) | `EvaluationSettings`, `PlayerEvaluation` |
**`User` stays global** (one login identity across the whole platform); everything else
that belongs to a club is tenant-scoped (§2.4). This is why `Member` — a *person within a
@@ -217,17 +224,31 @@ platform-operator layer (`is_staff` / `is_superuser` in Django admin).
```
ClubRole(ClubScopedModel) # ClubScopedModel -> carries `club` (§2.4)
member FK Member (CASCADE, related_name="roles")
role CharField (TextChoices: MEMBER | EDITOR | TREASURER | BOARD)
role CharField (TextChoices: MEMBER | EDITOR | TREASURER | BOARD | EVALUATOR)
Meta: unique_together (club, member, role)
```
| Role | Grants (representative) |
|-------------|------------------------------------------------------------------------------|
| *Public* | Anonymous — no row; read-only public site of that club. |
| `MEMBER` | View own + family data, own rosters/attendance, own orders/invoices, submit member-only forms. |
| `EDITOR` | Manage that club's `news`, `pages`, `formbuilder` content. |
| `TREASURER` | Manage that club's `shop`: products, orders, payments, issue/void invoices. |
| `BOARD` | Full management of that club: members, roles, all of the above. |
> ⚠️ **This table is aspirational, not current.** The actually-implemented
> `ClubRole.Roles` (`club/models.py`) is `ADMIN | MEMBER | EDITOR | MEMBER_ADMIN` — no
> `TREASURER`/`BOARD` yet, and `MEMBER_ADMIN` (full read/write on people, short of
> Finance/Club identity/role-granting — see `club.services.access.can_manage_members`)
> isn't reflected below either. Treat this table as the target shape; verify the real
> enum before writing code against it. `EVALUATOR` (§5.8) is **new, not yet added** to
> either the aspirational list here or the real code enum.
| Role | Grants (representative) |
|--------------|--------------------------------------------------------------------------------|
| *Public* | Anonymous — no row; read-only public site of that club. |
| `MEMBER` | View own + family data, own rosters/attendance, own orders/invoices, submit member-only forms. |
| `EDITOR` | Manage that club's `news`, `pages`, `formbuilder` content. |
| `TREASURER` | Manage that club's `shop`: products, orders, payments, issue/void invoices. |
| `BOARD` | Full management of that club: members, roles, all of the above. |
| `EVALUATOR` | Write + view **every** player evaluation club-wide (§5.8) — independent of `MEMBER_ADMIN`/coach `StaffAssignment`, since evaluating isn't the same trust boundary as either (a technical director might get this without full people-management access; a team's own coach doesn't get it just for coaching that team — see §5.8's own access note). Never granted to the evaluated player or their guardians. |
`news` is the one place a `ClubRole` and a derived role (`COACH_MANAGER`, see below)
share a single workflow rather than each owning a separate permission: drafting is
open to EDITOR/ADMIN *or* any coach_manager, but only EDITOR/ADMIN may publish —
see §5.4.
`COACH` / `TEAM_MANAGER` are deliberately **not** `ClubRole`s — being a coach is always
*of a team*, so it lives on `StaffAssignment` (§5.3). "Is this user a coach at this club?"
@@ -291,6 +312,27 @@ a `FamilyRole` (`parent` / `child` / `guardian` / `other`), `unique_together (fa
member)`; `Family.guardians` / `Family.children` are role-derived querysets. Powers the
"parents see their children's data" object-scope (§3.1).
**`Group`** *(built)* + **`GroupMembership`** — a generic, tenant-scoped, **opaque** named
collection of members: "all coaches", "all team managers", an ad-hoc committee. Deliberately
minimal (`name` + a through-membership, same shape as `Family`/`FamilyMembership`) — it
carries **no knowledge of any specific consumer** (not team-scoped, not referee-scoped, not
anything-scoped). Any feature wanting to use "a named set of people" for something specific
builds its own connective model elsewhere rather than teaching `Group` about that use case —
see `teams.RefereeProfile` (§5.2), which deliberately does **not** go through `Group` even
though an earlier draft of that feature did; referee eligibility is a fact about a *member*,
not about group membership.
```
Group(ClubScopedModel) # -> carries `club`
name CharField
Meta: UniqueConstraint(club, name)
GroupMembership(UUIDModel) # club implied by group
group FK Group (CASCADE, related_name="memberships")
member FK Member (CASCADE, related_name="group_memberships")
Meta: UniqueConstraint(group, member)
```
### `club`
**`Club`** — **the tenant root** (§2.4). Currently just `name`; extend with `slug` (unique,
@@ -302,6 +344,47 @@ season-scoped** (§5.1): it gains a `season` FK and sign-up / fee-status fields,
is one member's affiliation for one season (`unique_together (club, member, season)`). This
is the record the `MEMBER` role and shop fulfilment key off of (§3.4, §5.7).
### `billing` — what the platform charges a club
**Deliberately NOT club-scoped, and the only app that isn't.** `shop` (§5.7) is a club charging
its *members* — tenant data, owned by the club. `billing` is RosterChief charging the *club*:
platform-owned, never visible to a club user except as the one notice described below. Nothing
here inherits `ClubScopedModel` — these rows reference a `Club`, they are not owned by one, and a
tenant-scoped manager would be exactly the wrong default.
**`Plan`** — a duration and three clocks, named for what they measure *from*, which is the easy
thing to get wrong: `duration_months` (period length, from its start), `renewal_lead_days` (how far
*before* a period starts its invoice is raised), `grace_days` (how long *after* a period starts it
may stay unpaid). Two `CheckConstraint`s keep them coherent. `is_trial` marks a plan offered as a
trial; a trial's length is simply its own `duration_months`. `deleted_at` is a soft-delete marker:
`Due.plan` is `PROTECT`, so a plan that has ever billed anyone can't really be removed — "delete"
hides it (`Plan.objects.visible()` excludes it) and unsubscribes every club currently on it instead;
see `billing/services/plans.py` and `BILLING.md` §11.
**`PlanPrice`** — a dated price (`active_from`). A rate change is a new row, never an edit, so
every period already opened keeps what it was billed at.
**`Subscription`** — one per club (`OneToOneField`): its current `plan`, `auto_renew`,
`auto_archive`, and the trial pair (`trial_ends_at` + `post_trial_plan`, constrained to be set
together or not at all).
**`Due`** — one billing period for one club, and **the snapshot boundary**. `plan`, `amount`,
`period_end` and `grace_until` are all frozen when the period opens and never read back through
the plan at display time: raise a price or edit a plan's grace and last year's invoice must still
say what was actually charged. Storing the computed *dates* rather than the plan's *numbers* is
what buys that.
**`DuePayment`** / **`Invoice`** — money received against a due (several may land on one), and the
gapless per-year invoice number. The PDF itself is rendered on demand from the `Due` snapshot;
only the number is stored.
All lifecycle changes go through `billing/services/``dues.py` (open, renew, pay, waive,
archive), `notices.py` (the one club-facing warning), `reminders.py` (its email). Never through
the models directly: a `Due` whose `amount_paid` disagrees with its payments is a wrong invoice.
**`BILLING.md` is the authoritative document for this app** — the lifecycle, the worked timelines,
and the migration hazards live there rather than here.
---
## 5. Planned models (design)
@@ -337,14 +420,125 @@ Season(ClubScopedModel) # -> carries `club`
ClubMembership(ClubScopedModel) # -> carries `club`
member FK Member (CASCADE, related_name="club_memberships")
season FK Season (PROTECT, related_name="memberships")
kind CharField (TextChoices: member | guardian) # default member
license CharField (blank) # federation license for that season
status CharField (TextChoices: pending | active | lapsed | cancelled)
fee_status CharField (TextChoices: unpaid | partial | paid | waived)
signed_up_at DateTimeField (null) # when the member registered for the season
activated_at DateTimeField (null) # when membership became active (usually on payment)
activated_at DateTimeField (null) # when membership became active (admin approval only, never on payment alone)
Meta: unique_together (club, member, season); ordering = ["-season__start_date", ...]
```
- **`kind` separates a member from a guardian** *(built)*. A guardian is a parent attached
to the club only through their child: they hold the login, can be contacted and can sit in
a Group, but they are **not a member** — no fee, absent from the member list, the fee list
and every member KPI (club + platform), and not eligible for a roster *or* a staff spot. A
parent who also plays or coaches is a `member` who happens to be a parent; the two facts
are independent, which is why this is its own field rather than being inferred from
`members.FamilyMembership.role`. Before this existed, `members/services/family.py` enrolled
a parent exactly like the child, so every parent counted as a member — a data migration
reclassifies them, deliberately skipping anyone who plays, is on a team's staff, or holds
an elevated ClubRole.
- **Onboarding a legacy roster: `members.ParentClaim`** *(built)*. The migration path for a
club that arrives with a list of children and no parent records at all. Children import
without logins, each into a **family of their own** — that shape *is* the "nobody is
responsible for this child" state (`members/services/claims.py::families_awaiting_a_parent`),
so there is no flag to drift out of step with reality, and the family drops off the worklist
by itself the moment a parent joins it. `family_role=child` with a blank `family_group` is
what asks for it; any other lone role is still a mistake in the file.
- **Verification is a human decision, deliberately.** A parent submits a public form
(`/claim/`) with the child's name and date of birth as **free text — no search, no
autocomplete, no confirmation of whether the child was found**, because the page is
reachable without logging in and anything that resolved the child would turn it into a way
to enumerate the club's children. An admin matches it from a queue against a shortlist that
only ever contains children with nobody on file, so approving can never quietly re-parent a
child who already has one. The alternatives were rejected: a claim code needs a delivery
channel the club may not have, and matching on name plus birthday hands out someone else's
child to whoever guesses a birthday.
- **The claim form is also the registration.** Open self-registration is closed
(`club.views.signup_closed` shadows `account_signup` rather than removing the route, so the
URL name allauth's own templates reverse still resolves). Accounts are created by an admin,
by the family-registration form, or on claim approval — never by a stranger, which also
keeps the review queue from being a spam target. The account is created **on approval**, not
on submission, so a public form can't fill the user table.
- **An approved parent lands as a guardian**, with the login and the family link but no
membership and no fee. If they also play, an admin flips `kind` on their membership
afterwards; approving a claim is not the place to decide it. They then get a password-reset
link and a minimal "my family" page (`members/views.py::MyFamilyView`) — the seam a real
parent portal would grow from.
- **`Club.contact_email`** *(built)* — the club's own public address, set from the control
panel next to `legal_name`. Shown to the parent both in the submission flash and in the
approval email, as somewhere to write if something's wrong — falls back to nothing shown at
all when unset, same pattern as `legal_name`/`official_name`.
- **The flash after submitting is worded and timed to reveal nothing.** Sent *before* any
lookup happens, from a fixed string that never varies with whether a matching child was
actually found (`members/views.py::ParentClaimView.form_valid`) — a message that differed
would be exactly the enumeration channel free-text matching was built to avoid.
- **Approval emails a real one-time set-password link**, built with allauth's own token
generator (`default_token_generator`/`user_pk_to_url_str`) so it lands in the same flow the
login page's own reset would send them to, rather than a second, parallel one that could
drift out of step with it. Sending is never allowed to fail the approval — the family link
and the guardian row are real either way, and a briefly unreachable mail server must not
cost the parent their place in the queue; the admin sees a distinct warning message
(`management/views.py::ParentClaimApproveView`) telling them the email didn't go and to have
the parent use "Forgot your password?" instead.
- **A signed-in parent claiming a second child links, never duplicates.**
`ParentClaim.submitted_by_user` (nullable FK to `authentication.User`) records who was
actually logged in when a claim was submitted — kept separate from the free-text
`parent_email`/name fields, which stay exactly as typed as the admin's audit trail even when
they drift from the account. When set, `members/views.py::ParentClaimView` drops the parent
fields from the form entirely (shown as read-only "submitting as…" text, not just pre-filled
inputs — a disabled input doesn't even submit, and a pre-filled-but-editable one still lets a
typo through) and populates them server-side from the `Member` instead of trusting POST data.
On approval, `members/services/claims.py::approve_claim` resolves the parent via that FK
(never re-derived from email) and, if they already belong to a `Family`, moves the newly
claimed child's `FamilyMembership` onto *that* family rather than leaving them split across
two households — the child's now-empty solo family is cleaned up the same way
`members/services/family.py::detach_from_family` already does elsewhere. No existing family →
falls back to the original behaviour of joining the child's own family. This is the one place
a parent, once they exist, is the anchor for "one household," not the child.
- **The "already dealt with" history is scoped to the current season.** Last season's approved
and rejected claims are real history but not something a club needs staring at year after
year, so `management/views.py::ParentClaimListView` filters `reviewed` to
`reviewed_at__date__gte=` the current season's start (empty, not erroring, when there's no
current season) rather than dropping the section outright.
- **Why a field and not a separate model.** Everything that answers "is this person attached
to this club" already reads through `ClubMembership` — tenancy scoping, group membership,
the club-wide event audience — and a second kind of link would need a parallel path through
all of it. What changes is only who *counts*.
- **Guardians are carried forward across seasons.** Their tie to the club isn't really
seasonal (it lasts as long as the child is there) but it rides on a per-season row, so
`club/services/seasons.py::_carry_guardians_into` copies them when a season is created and
`members/services/family.py::carry_guardians_forward` covers a guardian added after later
seasons already existed. Copied from the *immediately preceding* season only, so a guardian
an admin deliberately removed stays removed rather than being resurrected from an old row.
- **Excluding guardians is a subtraction, not a narrower filter.**
`club/services/access.py::members_visible_to` subtracts `_guardians_only(club)` rather than
matching only member-kind rows: a bare MEMBER `ClubRole` with no `ClubMembership` is a real
state (someone the club knows but hasn't signed up yet), and narrowing the role branch to
weed guardians out would take those people with it. Pass `include_guardians=True` where the
page is about a *person* rather than about members — a guardian's own detail page, editing
them, the group pickers, or a family page (whose parents are the whole point).
- **The members list makes the member/guardian split visible instead of silent.**
`management/views.py::MemberListView` reads `?kind=member|guardian|both` (default `member`,
unchanged from before this filter existed) — without it, a guardian was simply absent from
the page with nothing explaining why, which read as a bug rather than the intended "guardians
aren't members" design. `management/views.py::FamilyListView` (`management:family_list`) is
the complementary household-first view: one row per family, its parents/guardians in one
column and its children in another, with the family name and a separate "Edit" action both
landing on `family_detail` — matching the "Edit goes to the overview, not a bare rename form"
convention the Groups list already established.
- **List views are paginated** (`MemberListView`, `EventListView`, `TeamListView`,
`GroupListView`, `NewsListView`, `FamilyListView`; `paginate_by = 25`) through one shared
`management/templates/management/_pagination.html` partial, using Django's `{% querystring %}`
tag so a page link preserves whatever `?q=`/`?kind=`/etc. filter is active rather than
resetting it.
- **The sidebar surfaces two counts that would otherwise require opening the page to see**:
pending parent claims, and games in the next 10 with no referee assigned (the latter shares
its query with `RefereeManagementDashboardView`'s own KPI, factored out so the two can't drift
apart). Both are admin-gated the same way the rest of that nav section is, and both render
`0` explicitly rather than hiding the badge — the point is to make "nothing outstanding" just
as visible as "something needs attention," not to declutter the zero case away.
- One row per member **per season** — sign-up and fee payment are tracked independently
each season. `unique_together` moves from `(club, member)``(club, member, season)`
(a data migration must backfill existing rows with the current season).
@@ -389,6 +583,82 @@ number — modeled by `TeamMembership`, exactly matching the domain note.
extra tenancy field is needed on the constraint.
- `StaffAssignment` drives the coach/manager object-scope (§3.13.2) — it *is* the "is a
coach of this team" fact; no `ClubRole` mirrors it.
- **Bulk add is a row formset, not a table of every member** (`TeamBulkAddView`, and the same
shape for groups in `GroupBulkAddView`). Each row picks one person from a searchable select
and one `Position`; the *position* decides what the row means — `Position.staff_position`
true ⇒ a `StaffAssignment`, otherwise a `TeamMembership` with an optional jersey number and
captain/alternate-captain flags — so there's no separate "player or staff?" control that
could disagree with the position picked. Jersey number and captaincy exist only on
`TeamMembership`, so a staff row rejects them (and the row script greys them out, keyed off
the `data-staff` marker `PositionSelect` stamps on staff options). Captain *and* alternate
captain on one row is refused as self-contradictory, but how many captains a team may have
is left alone: neither the model nor the single-add form constrains it, and inventing the
rule in one entry path only would be worse than not having it.
A playing coach is simply two rows. Rows are cloned client-side from the formset's
`empty_form` (`static/js/bulk-add-rows.js`); removing one deletes the node and deliberately
leaves `TOTAL_FORMS` alone, since Django reads a form whose fields are absent from the POST
as an unchanged extra and skips it — safe, unlike re-indexing live inputs. The earlier
design rendered *every* eligible member as a table row, which a club with a hundred-plus
members can't use, and its search was a GET round-trip that discarded anything already
ticked. **All-or-nothing on submit**: one bad row re-renders the page with every row still
filled in and the offending field flagged, rather than saving the good rows and losing the
rest (a partial save is far more costly when the rows were typed by hand). Cross-row checks
no single row can see — the same person twice, two rows claiming one jersey — live on the
formset's `clean()`; per-row checks (already assigned, jersey already taken by an existing
entry) live on the row form. Eligibility is never trusted from the POST: the member field's
queryset is `eligible_roster_members`, so an id that was never offered fails its own lookup.
The member `choices` are built once in the view and assigned onto each row's field —
a `ModelChoiceField` otherwise re-runs its queryset per form, i.e. once per row.
**As built, `Team` also carries `referee_management`** (`TextChoices`: `club` | `federation`,
default `club`) — whether the *club* arranges referees for this team's home games, or the
*federation* does. A federation-managed team is left out of the referee tools **entirely**:
no eligibility, no assignment, no entry on the referee management dashboard (§5.3) — see
`events/services/referees.py::needs_referee_management(event)`, the single gate every
referee-facing screen reads through.
**`RefereeLevel`** *(built)* — a club-defined referee qualification tier ("Regional",
"National", ...), admin-managed like `Position` (own name, own ordering, no fixed list).
**Owns which teams it qualifies for** — eligibility is a property of the *level*, not of the
individual referee: a club configures a handful of levels once, each unlocking a tier of
teams, rather than hand-picking teams per referee.
```
RefereeLevel(ClubScopedModel) # -> carries `club`
name CharField
ordering PositiveSmallIntegerField (default=0)
teams M2M Team (blank=True, related_name="referee_levels")
Meta: UniqueConstraint(club, name); ordering = ["ordering", "name"]
```
**`RefereeProfile`** *(built)* — a **member-level** fact: which level this member holds and
how long it's valid for. Which teams that translates to is *derived* (`eligible_teams`),
never picked per member. Managed from the member's own page (`management`), read (not
edited) from the team's own page too. Deliberately **not** routed through `members.Group`
eligibility is a property of a person, not of a group they might belong to; see the note on
`Group` above for why an earlier draft that did this was reworked. It also deliberately does
**not** put `teams` directly on the profile — a later draft of this feature did that too,
before the levels-own-the-teams shape replaced it, matching how real officiating
qualifications actually work (a certification tier unlocks a tier of competitions).
```
RefereeProfile(UUIDModel) # club reachable via member -- Member itself has no club FK
member OneToOneField members.Member (CASCADE, related_name="referee_profile")
level FK RefereeLevel (PROTECT, null=True, blank=True, related_name="referees")
valid_until DateField (null=True, blank=True)
```
- **`is_currently_valid`** (property): `valid_until` is set and hasn't passed — a pure date
check, independent of whether a level is even set.
- **`is_eligible`** (property): the full gate every consumer reads through (the event assign
panel, the team page, the referees list) — `level` is set **and** `is_currently_valid`.
Once `valid_until` passes, `is_eligible` flips to `False` and the referee drops out of
every eligibility query until the date is extended; nothing else needs to change.
- **`eligible_teams`** (property): `level.teams.all()` when `is_eligible`, else empty.
- One `RefereeProfile` per member (`OneToOneField`) rather than a field bag on `Member`
itself, matching this file's general pattern of keeping `Member` a plain identity record
and hanging every role-specific fact off its own small table (`ClubMembership`,
`StaffAssignment`, `TeamMembership`, and now this).
### 5.3 `events`
@@ -416,18 +686,156 @@ Attendance(UUIDModel) # through model Event <-> Member
club-wide (`team=None`), so `season` stays a first-class FK. Keep it consistent in a
service/clean().
### 5.4 `news`, `pages`, `home` (public site / editorial)
**As built, `Attendance` also carries `showed_up`** (nullable bool, default `None`) —
deliberately separate from `status`: `status` is the RSVP, `showed_up` is whether they
actually turned up, set by a check-in. `None` means "never checked in" (true for every
row today — there's no check-in UI yet, only Django admin); a "no-show" is
`status in (present, selected)` and `showed_up is False`, and is *never* inferred from
a missing check-in. See `events/services/attendance.py::record_check_in` and
`management/views.py::TeamDetailView`'s attendance panel.
**As built, an `Event`'s (and `EventSeries`') audience is teams + groups + invited/excluded
members, or the whole club** — `teams` (existing) sits alongside `groups`
(`M2M members.Group`, blank) and `club_wide` (`BooleanField`, default `False`). All three
feed `events/services/attendance.py::effective_members`: teams contribute their
season-scoped roster, groups contribute *every current* `GroupMembership` (Group has no
season concept, unlike Team, so this isn't season-scoped the way a team roster is), and
`invited_members`/`excluded_members` still layer on top/subtract as before — **unless**
`club_wide` is set, which replaces teams+groups with every member holding an ACTIVE
`ClubMembership` for the event's season instead (`invited_members`/`excluded_members` still
apply on top of that too). `club_wide` can't be combined with `teams`/`groups` — enforced in
`EventForm`/`EventSeriesForm.clean()` (`clean_club_wide_excludes_teams_and_groups`), not the
DB (an M2M can't be checked from a `CheckConstraint`, and can't be validated in `Event.clean()`
either — M2M state isn't available until the row has a PK). `events/signals.py` re-syncs a
group's future events on `GroupMembership` change, and a club_wide event's future rows on
`ClubMembership` change, the same way a `TeamMembership` change already re-syncs team events.
- **Authorization**: `club.services.access.groups_manageable_by(user, club)` mirrors
`teams_managed_by` — every group for an ADMIN, else only the ones the user is themselves a
`GroupMembership` of (Group has no manager/owner concept the way Team does via
`StaffAssignment`, so membership is the only claim there is). A non-admin must select at
least one team they manage **or** group they belong to when creating/editing an event
(`EventAudienceFormMixin.clean_audience_requires_a_claim_for_non_admins`) — `club_wide`
itself stays admin-only (the field is removed from the form entirely for anyone else, not
just rejected on submit). `club.mixins.EventManagerRequiredMixin` (gates edit/delete/detach/
etc. on an *existing* event/series) checks the same two claims via `get_teams()`/
`get_groups()` (the latter defaults to none, so a view that never deals with groups doesn't
need to override it) — without this, a non-admin who created a group-only event via the
broader create-time gate would immediately be locked out of managing what they just made.
**As built, a GAME-kind `Event` defaults its own `end`**`Event.save()` sets
`end = start + events.models.ASSUMED_EVENT_DURATION` (2 hours) whenever a game is saved
with no explicit `end`, and never overwrites one that's already set. Other event kinds are
untouched — `end` stays blank for them unless explicitly given one. The public games API
(`events/api.py`, `GET /games/upcoming/`) reads through this: it returns every non-cancelled
game/tournament that **hasn't finished yet** (`end` — explicit, defaulted, or, for the rare
un-saved-since / non-GAME row still lacking one, `start` within the assumed window — is at or
after now), not just ones that haven't started, so a game already in progress keeps showing up
until its window closes; `GameOut.end` is always populated the same way, and `status` treats
"started but before its (assumed) end, not flagged `is_live`" as `"live"` too, so a game
`/games/upcoming/` still lists never turns around and calls itself `"finished"`.
**As built, `Event` also carries `max_referees`** (`PositiveSmallIntegerField`, default
`2`) and **`EventReferee`** *(built)* — referee sign-up/assignment for a **home game**
only (`Event.is_home_game`), staff-assigned for now (self-service subscribe is a planned
extension, §7). A referee row is either a club member **or** an externally-logged name
(e.g. a federation-appointed referee the club still needs to pay), never both/neither, and
carries its own payment snapshot:
```
news.Article(ClubScopedModel) # -> carries `club`
title, slug (SlugField), body (TextField)
excerpt (blank), cover_image (ImageField, null)
author FK members.Member (SET_NULL, null, related_name="articles")
category FK news.Category (SET_NULL, null)
is_published BooleanField; published_at DateTimeField (null)
Meta: unique_together (club, slug); ordering = ["-published_at"]
EventReferee(UUIDModel) # club implied by event
event FK Event (CASCADE, related_name="referees")
member FK Member (CASCADE, null=True, blank=True, related_name="referee_assignments")
external_name CharField (blank=True) # set instead of member for a non-member referee
assigned_by FK Member (SET_NULL, null=True, related_name="+")
fee DecimalField (default 0.00)
km DecimalField (null=True, blank=True)
km_rate DecimalField (null=True, blank=True) # snapshotted per assignment, not a
# live club-wide setting
Meta: unique_together (event, member); CheckConstraint XOR(member, external_name)
display_name / is_external / km_total / total_payable # computed properties
```
news.Category(ClubScopedModel): name, slug # Meta: unique_together (club, slug)
- **Eligibility** comes from `teams.RefereeProfile.is_eligible`/`eligible_teams` (§5.2): a
member is eligible to referee an event if their profile is currently eligible (a level is
set and its validity hasn't passed) and that level qualifies for one of the event's
`teams`. `events/services/referees.py::eligible_referees(event)` computes this, and is
empty for anything `needs_referee_management(event)` says no to — not a home game, or a
home game whose team(s) are all federation-managed (§5.2). External referees bypass
eligibility entirely (`add_external_referee`) — they're logged by name only, not vetted
against a level.
- **Assignment is admin-only for now**, stricter than most event actions (a team
manager/coach can edit the event itself, but not the referee panel's assign/remove/fee
controls) — see `EventRefereeAssignView`/`EventRefereeRemoveView`/
`EventRefereeAddExternalView`/`EventRefereeFeeUpdateView` (all `ClubAdminRequiredMixin`)
and `EventDetailView`'s separate `can_manage_referees` flag. A team manager still **sees**
the panel (who's assigned, capacity, fees) — visibility and authority are deliberately
split here, same reasoning as §3's "coach visibility ≠ coach authority" for team rosters.
- **The referee management dashboard** (`management:referee_management`, admin-only) is the
one-stop alternative to hunting through individual events: every upcoming home game
`needs_referee_management`, with inline assign/remove/add-external/fee-editing (posting to
the same views the event detail page uses, returning to the dashboard via a `next` param
rather than the event detail page). It leads with KPI tiles (games in view, without a
referee, partially staffed, fully staffed) and a button-based range filter (this
week/this+next week/next 10/25/50 — an ISO-week window for the calendar options, a flat
slice for the count ones), then lists games grouped by date as compact tiles; each tile's
"Manage" button opens a `<dialog>` with the full assign/remove/external/fee panel so the
list itself stays scannable. Both the dashboard and the event detail page share one
`_referee_assignment_panel.html` include so this UI never drifts out of sync between them.
- **`max_referees` is a hard ceiling everywhere** — staff and external assignment included.
Enforced in `_lock_and_check_capacity()` (shared by `assign_referee()` and
`add_external_referee()`), which locks the `Event` row (`select_for_update`) for the
duration of the count-check + write so two admins assigning at the same moment can't both
squeeze past the ceiling.
- **Schedule conflicts are a soft warning, never a block.** `conflicting_events(member,
event)` finds other events overlapping this one's time window where the member is part of
the expected audience (`effective_members`, reused from the attendance service above) — the
UI shows it (⚠ + tooltip on the assign control) but a human decides; an event with no
explicit `end` is assumed to run `events.models.ASSUMED_EVENT_DURATION` (2 hours) for this
check. External referees have no conflict check (no member to check a schedule against).
`ASSUMED_EVENT_DURATION` is also what `Event.save()` writes into `end` for a GAME with none
set (below) — the *other* event kinds still leave `end` blank rather than defaulting it, so
this read-time fallback still matters for them.
- **`assigned_by` is required for now** (admin-only assignment). A future self-service
sign-up would make it nullable to mean "the referee signed themself up" rather than adding
a parallel model — see §7.
- **The referee payment form is a downloadable PDF** (`event_referee_form_pdf`,
`EventRefereeFormPdfView`, admin-only, WeasyPrint via `management/pdf.py`'s lazy-import
pattern), modeled directly on the club's existing paper form: game details, referee names,
a fee+km breakdown per referee, and blank signature lines (referee always; team manager
left blank — not reliably known at print time). The header uses `Club.official_name`
(`legal_name` if the club has set one, else plain `name` — §2.2) and the club's home
`Location` address; the body's payment sentence uses the plain `name` — mirroring the
original paper form, which itself uses a longer legal form up top and a shorter one in the
body text.
### 5.4 `news`, `pages`, `home` (public site / editorial)
**`news` is built** (as of the coach_manager-authoring / editor-release-flow work) —
team-tagged instead of categorised, with a two-step release flow rather than a bare
`is_published` flag:
```
news.News(ClubScopedModel) # -> carries `club`
title, slug (SlugField, auto from title), body (TextField)
title_en CharField (blank) -- optional English translation of `title`
body_en TextField (blank) -- optional English translation of `body`
teams M2M teams.Team (blank -- empty means club-wide)
visibility CharField (TextChoices: internal | external | both)
status CharField (TextChoices: draft | published)
published_at DateTimeField (null) -- may be in the future: a *scheduled* release,
not a cron-flipped field (see below)
created_by FK members.Member (SET_NULL, null, related_name="news_items")
Meta: unique_together (club, slug); ordering = ["-created"]
news.NewsPhoto(UUIDModel) # club reached via news_item, not directly scoped
news_item FK news.News (CASCADE, related_name="photos")
image ImageField
is_main BooleanField
ordering PositiveSmallIntegerField
Meta: UniqueConstraint(fields=["news_item"], condition=Q(is_main=True))
-- a partial unique index enforcing "at most one main photo per item"
at the DB level, the same trick teams.Position uses for
management_position_implies_staff_position.
pages.Page(ClubScopedModel) # flat CMS pages: "About", "Contact", ...
title, slug, body (TextField)
@@ -439,12 +847,40 @@ home.HomeConfig(ClubScopedModel) # one row PER CLUB: featured articles/teams,
# (unique_together (club,) — one per tenant). May be config-only.
```
- **`Article.author` links to `members.Member`** (decision §7 #5) — attribution is to a
- **Authoring vs. releasing are deliberately separate authorities**
(`club/services/access.py::can_add_news`/`can_publish_news`/`can_edit_news`): any
current-season coach_manager (management-position `StaffAssignment`), EDITOR, or
ADMIN can draft a `News` item and edit it while it's a draft; only EDITOR/ADMIN can
move it to `published` (or edit it once it is) — a physio or plain staff member can't
post news, and a coach_manager can't push their own draft live.
- **Scheduling needs no cron job.** `published_at` can be set in the future; `status`
already reads `PUBLISHED` (it passed the editor's release gate) but `News.is_scheduled`
is true until that moment passes. A later public/member-facing consumer just filters
`status=PUBLISHED, published_at__lte=now()` — nothing has to flip a row at the
scheduled instant.
- **`created_by` links to `members.Member`** (decision §7 #5) — attribution is to a
club person, not a raw login; `SET_NULL` so deleting a member doesn't erase their posts.
- `slug`s back clean public URLs and feed `search`; they are **unique per club** (§2.4), so
two clubs can both have `/news/season-kickoff`. Resolve within the request's club.
- `cover_image` / hero images use `ImageField`**media storage must be configured** (§8).
If page/news trees grow, consider a tree library later — start flat.
- `visibility` (internal/external/both) is enforced by the public read-only API
(`news/api.py`, mounted under `api/`) — only `external`/`both` items, published and
past their release date, are ever returned. No member-facing internal reading page
exists yet; that's later work.
- **`title`/`body` are Dutch (the club's own language, and the only one required);
`title_en`/`body_en` are an optional English translation**, both left blank by
default. Nothing computes or stores a fallback — `News.effective_title_en` /
`effective_body_en` resolve it on read (`title_en or title`), so translating a Dutch
edit later never leaves a stale English copy behind, and every existing row gets
correct fallback behaviour with no backfill. The public API always returns both
languages in one call (`title_nl`/`body_nl`/`excerpt_nl` alongside
`title_en`/`body_en`/`excerpt_en`, the latter three via the `effective_*` properties
so they're never blank) — no `?lang=` param, the consumer picks what it needs. The
control panel's news form lays the two languages out in side-by-side columns
(`management/templates/management/news_form.html`); the detail page only shows an
"English" section when a translation was actually added, not the fallback-filled
text under a second heading.
- `NewsPhoto.image` / hero images use `ImageField` → **media storage must be configured**
(§8). If page/news trees grow, consider a tree library later — start flat.
### 5.5 `search`
@@ -691,6 +1127,67 @@ per-member entitlements would extend this — add an eligibility rule / code fie
auto-apply service on top of the same model when that need is real, rather than a parallel
mechanism.
### 5.8 `evaluations` — player evaluations *(new app, design only)*
Coaching-staff-only assessment of a player: a customizable rubric (skills, ratings, notes),
recorded per team/season, feeding a player profile alongside their attendance history —
**never visible to the evaluated player or their guardians**, regardless of how they're
otherwise permitted (a parent with `EVALUATOR` sees every *other* child's evaluations but
not their own kid's — see the access note below).
**Built on `formbuilder` (§5.6), not a parallel form engine.** `formbuilder`'s `Form`/
`Field`/`Submission`/`Answer` already do everything a rubric needs (admin-defined fields,
normalized answers, a submit service) — reuse them literally. The one thing `formbuilder`
can't express is *who the submission is about*: `Submission.member` is the **submitter**
(here, the evaluator), and a generic form has no notion of a separate subject. Rather than
growing `formbuilder.Submission` an evaluation-specific field, `evaluations` owns a thin
envelope that pairs a `Submission` with the player it was about:
```
EvaluationSettings(club OneToOne) # which Form is *the* current rubric
form FK formbuilder.Form (PROTECT)
PlayerEvaluation(UUIDModel) # the "this was about whom, on which roster" envelope
player FK members.Member (CASCADE, related_name="evaluations_received")
team FK teams.Team (CASCADE)
season FK club.Season (PROTECT)
submission OneToOne FK formbuilder.Submission (CASCADE) # evaluator + Answers live here
Meta: ordering = ["-created"]
```
- **Rubric changes don't corrupt history.** Swapping the active rubric re-points
`EvaluationSettings.form` at a new `Form`; existing `PlayerEvaluation`s keep referencing
their original `Form`/`Field`s (already immutable/`PROTECT`-ed once submissions exist —
§5.6's own design notes), so an old evaluation still renders with the questions it was
actually scored against.
- **One rubric, club-wide** (not per team/age-group) — simplest, and keeps every age group
on a comparable scale. Revisit only if a club actually needs per-team rubrics.
- **Entry point**: an "Evaluate" action from the team roster (same "act from the page the
data lives on" pattern as the referee-assignment panel, §5.3) renders the active rubric's
`Form` for one player (`formbuilder.services.form_factory.build_form`) and, on submit,
calls `formbuilder.services.submission.submit_form` then wraps the resulting `Submission`
in a `PlayerEvaluation` — one transaction.
- **Player profile** (a tab on the member detail page, gated the same as everything else
here): evaluation history (each `PlayerEvaluation`'s answers + evaluator + date), games
played **per team** ("U12: 14 games · U14: 6 games" — `Attendance` grouped by
`event__teams`, no new tracking), and attendance rate (already-existing `Attendance`
data, same definition `team_attendance_rate` uses, §5.2). No trend chart in v1.
**Access.** A new `ClubRole.EVALUATOR` (§3.2), club-wide and independent of `MEMBER_ADMIN`
and coach `StaffAssignment` — evaluating is its own trust boundary, not "manages people" or
"coaches this team." `club/services/access.py` gets `can_evaluate_players(user, club) =
is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EVALUATOR)`. Gated at
the view/mixin level (a `EvaluatorRequiredMixin`, mirroring `MemberAdminRequiredMixin`) —
nothing evaluation-related is ever built into a mobile/member-facing context, not hidden
behind a flag: the member app's views simply never query it.
**Build order**: (1) the `EVALUATOR` role + `can_evaluate_players`; (2) `EvaluationSettings`
+ `PlayerEvaluation` models + migration; (3) the "Evaluate" entry point + submit flow; (4)
the player-profile tab. `formbuilder`'s own model/service layer (§5.6) is **already built**
— no prerequisite work needed there beyond, eventually, its own missing render/submit *view*
if a public-facing form ever needs one (evaluations builds its own player-scoped one instead
of waiting on that).
---
## 6. Entity-relationship overview
@@ -707,9 +1204,13 @@ User 1───< Member (FK, unique per club) # User is GLOBAL — no clu
├───< TeamMembership >─── Team ───> Season
├───< StaffAssignment >─── Team (= "coach of this team", §3.2)
├───< GroupMembership >─── Group (opaque -- no team/referee link)
├─1:1─ RefereeProfile ──> RefereeLevel >──< Team
│ (profile's valid_until gates eligibility; level owns teams)
├───< Attendance >─── Event ───> Season
│ └───> Team (nullable)
├───< EventReferee >─── Event (assigned_by another Member; home games only)
├───< Submission >─── Form ───< Field (Submission ──< Answer >── Field)
@@ -759,6 +1260,17 @@ Legend: `───<` one-to-many, `>───<` many-to-many via a through model
presets on a `pending` order (each = a snapshotting `AppliedDiscount` row) before
`finalize()`, rather than typing values. Presets stack against the same subtotal base;
optional per-row value override for one-offs. Adds an `Order.pending → finalized` step.
10. ✅ **Referee self-service sign-up** — **built**, superseding the "still open" note this
replaces. A `RefereeSignup(event, member, status: invited|accepted|declined)` model
tracks the invite/response; `sync_referee_invites` (wired from the same signal points
as attendance sync, §5.3) auto-invites every eligible referee the moment a home game
needs one. Accepting calls the existing `assign_referee(event, member, assigned_by=
None)` — capacity-checked in the one place admin assignment already enforces it, so it
lands as a real `EventReferee` row with no separate sync step (`assigned_by=None`
marks it self-service, exactly the extension point this doc's earlier note predicted).
Surfaced on the mobile Calendar and event detail page, scoped to every managed person
(not just the account's own `self.me`) — a referee-eligible child is exactly as real
as a referee-eligible parent.
Infrastructure/config for the above (media storage, dependencies + exact setup) is
specified in **§8**.
@@ -781,6 +1293,13 @@ specified in **§8**.
(checkout-date anchor, recommended, frozen total) or by *paying* before it (payment-date
anchor, mutable total)? Doc implements checkout-date; confirm no club needs the literal
"paid before date" semantics (§5.7.1).
- **Player evaluations** (§5.8) — designed, not built. Open question worth confirming
before implementation: should `PlayerEvaluation` be strictly one-per-(player, team,
season, evaluator) or allow several evaluators to each leave their own entry for the same
player/season (the design as written allows the latter — no uniqueness constraint — since
a rubric answered once per submission is `formbuilder`'s own natural shape, and multiple
coaches' perspectives on the same player seems like a feature, not a bug, but it hasn't
been explicitly confirmed).
---
@@ -840,6 +1359,42 @@ Setup:
`stripe`) + webhook endpoint that creates/confirms `Payment`s (§5.7).
- **Excel export** for form reporting beyond CSV: `openpyxl`.
### 8.5 Email — SMTP or Resend *(built)*
Every Django-sent email — allauth's password reset, `send_billing_reminders`, anything else
that goes through `django.core.mail` — follows whichever `EMAIL_BACKEND` is configured; no
per-feature wiring. Console backend by default (§ "Email" in `settings.py`), so a deployment
that forgets to configure mail prints to the log instead of raising against `localhost:25`.
Two ways to use **Resend** (resend.com), no third-party SDK either way:
- **SMTP relay, zero code**: point the stock `django.core.mail.backends.smtp.EmailBackend` at
`smtp.resend.com` with `resend` as the username and the API key as the password.
- **HTTP API**: `rosterchief.mail.ResendEmailBackend` (`DJANGO_EMAIL_BACKEND=rosterchief.mail.ResendEmailBackend`,
`RESEND_API_KEY=…`) posts each message straight to Resend's `/emails` endpoint via `requests`
(already a dependency, so no new one needed for this). Handles plain text, the HTML
alternative on an `EmailMultiAlternatives`, cc/bcc/reply-to, and base64-encoded attachments;
`fail_silently` is honoured the same way Django's own backends honour it. Confirmed against
Resend's own docs: `POST /emails` accepts `html` alongside `text` (and will derive `text` from
`html` if only the former is given), so this backend needed no changes to carry the HTML
templates below.
- **HTML email templates** *(built)*. `templates/email/_base.html` is the one shared shell
(inline styles only, ~600px single-column table — email clients strip `<style>` blocks and
ignore linked stylesheets, so daisyUI/Tailwind's compiled CSS is irrelevant here) with a
`{% include "email/_button.html" %}` for a styled CTA link. Every HTML email extends it and
supplies a plain-text sibling — `members/services/claims.py::send_claim_approved_email` builds
an `EmailMultiAlternatives` from `claim_approved.txt` + `claim_approved.html`
(`.attach_alternative(html, "text/html")`), the same never-fail-the-caller contract as before
(`except OSError: return False`). `club/templatetags/club_email.py::absolute_media_url` turns
`Club.logo` (a relative `FileSystemStorage` URL) into something an inbox can actually fetch,
by resolving it against the `request` carried in the template context.
- **allauth's password-reset email is overridden**, not left at its default plain text —
`templates/account/email/password_reset_key_message.html` sits next to allauth's own
`.txt` at the path its `render_mail` already looks for (no Python-side override needed;
allauth auto-attaches a same-named `.html` as the HTML alternative). Branches on whether a
`club` is in context (a reset requested from a club subdomain gets that club's branding) and
falls back to a plain RosterChief wordmark otherwise (the platform-level login has no club to
brand it with).
---
*Conventions cross-reference:* `rosterchief/base.py` (`UUIDModel`, `ClubScopedModel`),

439
BILLING.md Normal file
View File

@@ -0,0 +1,439 @@
# Platform billing — design
How RosterChief charges a club, and what happens when a club doesn't pay.
This is the design for a **revision** of the billing subsystem, not a rewrite. `ARCHITECTURE.md`
covers the club-owned domain; billing is the other direction — the platform charging the tenant —
and has never been documented there. Once this design is implemented, §4 (the model) gets folded
into `ARCHITECTURE.md` and this file keeps the lifecycle and operational detail, the same split
`DEPLOYMENT.md` already has with the rest of the docs.
Status: **implemented.** The four decisions left open in §10 have been taken and are recorded
there.
---
## 1. What ships today, and what's actually wrong with it
The existing implementation (`billing/`) is structurally sound. Three things in particular are
worth keeping and are not up for renegotiation in this redesign:
- **Snapshot-on-`Due`.** `Due.tier` and `Due.amount` are frozen when the period opens and never
read back through the plan at display time. Raise a price and last year's invoice still says what
was actually charged. This is the single most important property in the subsystem — a live
lookup would rewrite financial history.
- **Dated prices.** `TierPrice.active_from` means a rate change is one new row with a future date,
not an edit to an existing one. Every period already opened keeps its amount.
- **Asymmetric commands.** `archive_overdue_clubs` reports by default and only acts with
`--commit`; `renew_subscriptions` acts by default and only previews with `--dry-run`. The
asymmetry is deliberate and correct: not archiving is a safe failure, not renewing is a silent
revenue leak.
What's wrong is narrower than "the approach": **three hardcoded assumptions**, all in
`billing/models.py`.
| # | Today | Problem |
|---|---|---|
| 1 | `Due.save()``add_one_year(period_start)` | Every plan is annual. Duration is only overridable by passing an explicit `period_end`, which is the hack trials use. |
| 2 | `GRACE_DAYS = 45`, module constant | One grace period for every plan, editable only by deploying code. |
| 3 | `grace_until = period_end + GRACE_DAYS` | **The clock runs from the wrong end.** A club uses the entire unpaid year *and then* gets 45 days — roughly **410 days of unpaid use** before `archive_overdue_clubs` will touch it. |
Item 3 is the real defect. The requested behaviour — "unpaid → warned → archived after N days" —
isn't a tweak to the current rule, it's the opposite of it.
A fourth gap is in the UI rather than the model: the club-facing banner
(`management/views.py:89-93`) fires on *the period ending*, not on *money being owed*. A club
that owes money is told nothing.
---
## 2. Target lifecycle
```
┌──────────────────────────────────────────┐
│ │
[no plan] ──► TRIAL ──────► UNPAID ──► IN GRACE ──► OVERDUE ─┴─► ARCHIVED
│ │ ▲ │ │ │
│ │ │ │ │ │
└──────────────┘ │ payment payment reactivate
auto-converts │ │ │ (+ new period)
on renewal │ ▼ ▼ │
│ PAID PAID │
└──────────────────────────────────┘
renewal opens
the next period
```
**Trial.** A club starts on a trial plan (there can be several — a 1-month and a 3-month trial are
just two plans). It carries a price of 0, so it settles itself the moment it opens and can never
make a club archivable. When the trial period runs out, the next renewal converts the subscription
onto the pre-selected paid plan. This already works (`open_period`'s trial-conversion check) and
is kept as-is.
**Unpaid.** Every paid period — new or renewed — opens `UNPAID` with an invoice attached. From
that moment the club's admins see a warning on their management home page. This is the state the
whole redesign is about.
**In grace.** The period has started and is still unpaid. The warning escalates and gains a
countdown: *"Your club will be archived in N days."*
**Overdue.** Past `grace_until`. `archive_overdue_clubs` will now pick this club up — subject to
`Subscription.auto_archive`, which stays as the manual override for a club you're negotiating with.
**Archived.** `Club.archive()` sets `archived_at`. The subdomain stops resolving
(`ClubTenantMiddleware.get_club()` filters on `Club.objects.active()`), so the club is frozen
exactly as requested: no access, nothing destroyed. Reactivation is a deliberate platform-admin
action.
---
## 3. The three clocks
Every plan carries three numbers. Getting them confused is the easiest way to misread this design,
so they are named for what they measure from:
| Field | Measured from | Answers |
|---|---|---|
| `duration_months` | `period_start` | How long is a period? |
| `renewal_lead_days` | `period_start`, backwards | How early is the invoice raised? |
| `grace_days` | `period_start`, forwards | How long may it stay unpaid? |
**`grace_days` runs from the period start, not from the invoice and not from the period end.**
That is the decision this redesign turns on. It means the warning window is
`renewal_lead_days + grace_days` — the club is told before the period begins *and* gets a further
grace once it has, but is cut off partway into a period it never paid for.
### Worked timelines
```
ANNUAL duration 12mo · lead 30d · grace 30d
period 1 Jan 2027 31 Dec 2027
2 Dec 26 1 Jan 27 31 Jan 27 31 Dec 27
│ │ │ │
INVOICE PERIOD STARTS grace ends PERIOD ENDS
│─────────────│──────────────│
30d warned 30d grace └─► ARCHIVED 1 Feb 27 if unpaid
└──────────── 60 days of warning ────────────┘
QUARTERLY duration 3mo · lead 14d · grace 30d
period 1 Jan 2027 31 Mar 2027
18 Dec 26 1 Jan 27 31 Jan 27 31 Mar 27
│ │ │ │
INVOICE PERIOD STARTS grace ends PERIOD ENDS
└──────── 44 days of warning ───┘
MONTHLY duration 1mo · lead 7d · grace 14d
period 1 Jan 2027 31 Jan 2027
25 Dec 26 1 Jan 27 15 Jan 27 31 Jan 27
│ │ │ │
INVOICE PERIOD ST. grace ends PERIOD ENDS
└─── 21 days of warning ─┘
```
### Why the lead time has to be per-plan
Today's single `RENEWAL_LEAD_DAYS = 30` is silently annual-only. On a 1-month plan it would issue
the next period **before the current one had started** — periods would run away from the calendar
within a couple of cycles. Any plan shorter than 30 days is broken by a global constant, which is
why `renewal_lead_days` moves onto the plan.
### Guardrails
Two invariants, enforced as `CheckConstraint`s. `duration_months × 28` is the conservative
lower bound on days in that many months, which keeps the check expressible in SQL:
- `renewal_lead_days < duration_months * 28` — or the next invoice precedes the current period.
- `grace_days <= duration_months * 28` — or a club can never be archived before its next period is
issued, and unpaid periods silently stack.
---
## 4. Model changes
### 4.1 `Tier` → `Plan` (rename)
The code says `Tier`, every spec and screen says "plan". Renaming now, while only ~80 references
exist, is cheaper than carrying the mismatch. `TierPrice``PlanPrice` follows, as do the FK
field names (`Subscription.tier``plan`, `Subscription.post_trial_tier``post_trial_plan`,
`Due.tier``plan`, `PlanPrice.tier``plan`).
### 4.2 `Plan` — new fields
```python
class Plan(UUIDModel):
name, slug, description, is_active # unchanged
duration_months = PositiveSmallIntegerField(default=12, validators=[MinValueValidator(1)])
renewal_lead_days = PositiveSmallIntegerField(default=30)
grace_days = PositiveSmallIntegerField(default=30)
is_trial = BooleanField(default=False)
```
`is_trial` is an explicit flag rather than "price == 0" — a genuinely free tier is not a trial, and
the two dropdowns on the trial form need to offer different sets of plans. It also lets the control
panel keep trial plans out of the normal "change plan" picker.
`price_on(day)` is unchanged.
### 4.3 `Due` — derivation changes, fields don't
```python
def save(self, *args, **kwargs):
if not self.period_end:
self.period_end = add_months(self.period_start, self.plan.duration_months) - timedelta(days=1)
if not self.grace_until:
self.grace_until = self.period_start + timedelta(days=self.plan.grace_days)
super().save(*args, **kwargs)
```
`grace_until` stays a **stored** field. That is what makes it a snapshot: editing a plan's
`grace_days` afterwards must not move the archive date of a period that is already running, for the
same reason `amount` is frozen. Storing the computed *date* rather than the input *days* gets this
for free — no extra column needed.
The predicates change meaning even though only one changes shape:
```python
def is_in_grace(self, today=None): # period running, unpaid, not yet archivable
return self.is_owing and self.period_start <= today <= self.grace_until
def is_overdue(self, today=None): # unchanged logic, new meaning of grace_until
return self.is_owing and self.grace_until < today
def days_until_archive(self, today=None): # new — drives the countdown in the banner
return (self.grace_until - today).days
```
`is_in_grace` previously required `period_end < today`; it now requires `period_start <= today`.
An owing due before its period starts is neither in grace nor overdue — it is simply *issued*,
which is the gentlest of the three warning levels in §6.
---
## 5. Price changes
Already correct, and worth stating precisely because the interaction with lead time is not obvious.
`open_period` snapshots `plan.price_on(period_start)` — the price in force on the day the period
**starts**, not the day the invoice is raised. Add a `PlanPrice` with `active_from = 1 Jan 2027` and
every period starting on or after that date bills at the new amount. That is exactly "adjust the
price, effective as of the club's next billing cycle".
**The edge case that will bite:** periods are issued `renewal_lead_days` early. Enter a price change
on 15 December for periods starting 1 January, and any annual period already issued on 2 December
keeps the old amount — its `Due.amount` was frozen two weeks before the new price existed. The
snapshot is behaving correctly; the *operational* rule is what matters:
> Enter a price change before the renewal lead window opens for the periods it should apply to.
Two mitigations, both recommended:
1. Document the rule where prices are added (control-panel help text).
2. Warn in the control panel when a new `PlanPrice.active_from` falls inside a period that has
already been issued, naming the affected clubs. Correcting one is then a deliberate
cancel-and-reopen, not a silent surprise.
---
## 6. The club-facing warning
A new service — `billing/services/notices.py` — returns one small object for a club, or `None`:
```python
@dataclass(frozen=True)
class BillingNotice:
level: str # "info" | "warning" | "error"
due: Due
amount_outstanding: Decimal
grace_until: date
days_until_archive: int
```
| Condition | Level | Message |
|---|---|---|
| Owing, `today < period_start` | `info` | Invoice outstanding, due by *grace_until*. |
| Owing, in grace, > 7 days left | `warning` | Fees are due. Club will be archived in *N* days. |
| Owing, in grace, ≤ 7 days left | `error` | Final notice. Archived in *N* days. |
| Owing, overdue | `error` | Archiving is pending. |
| Nothing owing | — | No banner. |
Rendered in `management/home.html`, in the existing alert block, and gated on `is_club_admin` the
same way the current billing banner is — ordinary members have no business seeing platform billing.
The existing "your period ends soon / renews automatically" notice is kept, at lower priority: it
answers a different question and only shows when nothing is owed.
All strings go through `{% trans %}` / `{% blocktrans %}` with `%(name)s`-style placeholders per
`CLAUDE.md` — the countdown is a `{% blocktrans count %}` so the plural form survives translation.
---
## 7. Services and commands
`billing/services/dues.py`:
| Function | Change |
|---|---|
| `subscribe(club, plan, …)` | Rename only. |
| `start_trial(club, trial_plan, *, post_trial_plan, start=None, …)` | **Drop `trial_months`** — the trial's length is now `trial_plan.duration_months`, and `period_end` no longer needs to be passed explicitly. |
| `open_period(…)` | Duration and grace come from the plan. Trial-conversion check unchanged. |
| `next_period_start` | Unchanged. |
| `dues_in_grace(today)` | `owing_dues().filter(period_start__lte=today, grace_until__gte=today)`. |
| `dues_overdue(today)` | Unchanged. |
| `archivable_clubs(today)` | Unchanged. |
| `subscriptions_due_for_renewal(today, lead_days=None)` | Per-plan lead. See below. |
`subscriptions_due_for_renewal` currently compares every subscription against one horizon in SQL.
With a per-plan lead the comparison is per-row. The function **already** materialises its result as
a Python list comprehension, so the honest move is to keep doing that and compare against each
plan's own lead:
```python
latest_period_end is None or latest_period_end <= today + timedelta(days=subscription.plan.renewal_lead_days)
```
At platform scale (tens to low hundreds of clubs) this is one query plus a list walk; pushing
portable date arithmetic into SQL to save that is not worth the opacity. The command's
`--lead-days` flag stays, redefined as a **global override** of the per-plan value, which is what
makes backfills and rehearsals possible.
`archive_overdue_clubs` needs no changes — but note that with the clock moving from ~410 days to
~60, this job goes from theoretical to load-bearing. Its dry-run-by-default posture matters more
now, not less, and `DEPLOYMENT.md`'s advice to run it without `--commit` for the first week should
be re-followed after this ships.
---
## 8. Migration plan
Four steps, in order. **Step 3 is the one that can take down live clubs.**
**1 — Rename.** `RenameModel` + `RenameField` operations. `makemigrations` will prompt for each
("Did you rename …?"); answer yes. **Inspect the generated migration by hand before running it**
if Django emits `DeleteModel` + `CreateModel` instead of `RenameModel`, it will drop every price,
subscription and due in the table. This is the single highest-risk step in the change.
**2 — Add the new `Plan` fields**, with defaults chosen to reproduce *today's* behaviour on
existing rows: `duration_months=12`, `renewal_lead_days=30`, `is_trial=False`. `grace_days`
defaults to **30, not 45** — the number is not being carried over, because the thing it measures
from has changed.
**3 — Do NOT backfill `grace_until` on existing dues.**
> An open annual period that started in, say, March would today have `grace_until` around the
> following February. Re-derived under the new rule it becomes *March + 30 days* — a date already
> in the past. Every such club becomes instantly overdue, and the next `archive_overdue_clubs
> --commit` run switches off the entire paying customer base overnight.
Existing `Due.grace_until` values are snapshots and must be left exactly as they are. Only periods
opened *after* this ships use the new rule; every club migrates onto the new terms naturally at its
next renewal. If a specific club should move sooner, that is a deliberate one-off (cancel the open
period and reopen it), not a bulk data migration.
**4 — Retire the constants.** `GRACE_DAYS` and `RENEWAL_LEAD_DAYS` become the `default=` values on
the new `Plan` fields and are deleted from `billing/models.py`.
---
## 9. Blast radius
Everything below references `Tier`, the constants, or the grace semantics, and will need touching:
| Area | Files |
|---|---|
| Models & services | `billing/models.py`, `billing/services/dues.py`, **new** `billing/services/notices.py` |
| Commands | `billing/management/commands/renew_subscriptions.py` |
| Admin | `billing/admin.py` |
| Control panel | `controlpanel/views.py` (20 refs), `forms.py` (10), `urls.py` (3), `services/statistics.py` |
| Control-panel templates | `billing.html`, `_club_billing_card.html`, `_club_health_table.html`, `dashboard.html` |
| Club-facing | `management/views.py`, `management/templates/management/home.html` |
| Invoice | `billing/templates/billing/invoice.html` |
| Tests | `billing/tests.py` (16), `controlpanel/tests.py` (17), `management/tests.py` (3), `formbuilder/tests.py` (1) |
| Docs | `ARCHITECTURE.md` (new billing section), `DEPLOYMENT.md` (archive job note) |
`controlpanel/services/statistics.py` deserves particular attention: `tier_name`,
`dues_grace_until` and `dues_period_end` all feed the club-health table, and `dues_in_grace()` /
`dues_overdue()` feed the dashboard counters. Their *numbers* will move once the clock changes,
which is expected — but the annotations themselves need renaming, not just re-pointing.
---
## 10. Decisions taken, and the ones still open
**Taken:**
- Grace runs from `period_start`. *(Alternatives considered: from invoice issue — strictest, archives
a club before it has used anything; from `period_end` — today's behaviour, ~410 days of unpaid
use.)*
- Lead time is a per-plan field. *(Alternatives: derived from duration — invisible in the admin;
one small global constant — too tight for an annual invoice paid by bank transfer.)*
- `Tier``Plan`.
- Trial length comes from the trial plan's own `duration_months`, not a per-subscription number.
- `post_trial_plan` stays on `Subscription`, not on `Plan` — the same trial can convert to different
paid plans for different clubs.
**Resolved during implementation:**
1. **Full payment does NOT auto-restore an archived club.** `reactivate()` stays an explicit
platform-admin action — a club can also be archived by hand for reasons that have nothing to do
with money, and an automatic restore would silently reverse that the next time a stray payment
was recorded. Instead, `_club_billing_card.html` shows a prominent prompt on any archived club
whose dues are settled, so the deliberate act is one click away. No `archived_reason` field was
needed.
2. **The banner escalates.** `management/home.html` renders it at every level; `management/base.html`
repeats it on every *other* management page only once it reaches `error` (≤7 days, or overdue).
Shown from the moment anything is owed it would sit on every screen for weeks and train people to
ignore the one week that matters.
3. **Email reminders are in.** `send_billing_reminders` (dry-run by default, `--commit` to send)
plus provider-agnostic SMTP settings read from the environment. Reminders go **once per
escalation level**, tracked on `Due.last_reminder_level`, because the command is on a daily cron
and a club that owes money for a month must not get thirty identical emails.
4. **Online payment stays out of scope.** Every payment is still recorded by hand by a platform
admin (`record_payment`). The consequence is real and worth stating: the banner and the reminder
email both tell a club admin money is due while giving them no way to pay it in-app. They pay by
transfer; you record it.
### The email default that will catch you out
`EMAIL_BACKEND` defaults to the **console backend**, not SMTP. That is deliberate — Django's own
default tries localhost:25 and raises `ConnectionRefused` on a box with no MTA — but it means a
deployment that forgets `DJANGO_EMAIL_HOST` will watch `send_billing_reminders --commit` report
success while no club hears anything. Set the mail variables in `.env.production` (see
`.env.production.example`) before trusting the job.
## 11. Addendum: deleting a plan
Added after the initial implementation. `Due.plan` is `PROTECT` — a plan that has ever billed
anyone can never truly be removed, on purpose: `amount`, `period_end` and `grace_until` are frozen
on a `Due` precisely so a later change can't rewrite what was actually charged, and losing the plan
link off an old `Due` would do exactly that to every historical invoice. "Delete" therefore means
one of two things, chosen automatically (`billing/services/plans.py`):
- **No `Due` ever referenced the plan** (created, never actually used to bill anyone) — the row is
removed outright.
- **At least one `Due` references it** — soft-deleted instead: `Plan.deleted_at` is set and
`is_active` turned off. The row survives (so old invoices still say what they were billed under)
but is hidden from every picker and listing via `Plan.objects.visible()` — an opt-in queryset
method, same shape as `Club.objects.active()`, so the plain default manager stays unfiltered for
Django admin and anything reading historical data.
Either way, every club **currently on the plan** is unsubscribed outright — its `Subscription` row
is deleted, not just its `plan` field cleared. "No plan" was already a state the rest of the app
fully understood (every billing view already handles `getattr(club, "subscription", None)` being
`None`), so this reuses it rather than inventing a new one.
One easy-to-miss second group: a club on a **different** plan, mid-trial, configured to convert to
the plan being deleted (`Subscription.post_trial_plan`). Left alone, that club's trial would try to
convert onto a plan that no longer exists (or has been hidden) the moment `open_period()`'s
trial-conversion check next runs. Handled at delete time instead: that club's trial is ended
(`trial_ends_at` and `post_trial_plan` both cleared, per the `CheckConstraint` that requires them
set together or not at all), leaving it on the trial plan with no scheduled conversion until a
platform admin picks a new one.
The confirmation screen (`controlpanel/templates/controlpanel/plan_delete.html`) is a real page,
not a modal like every other billing action — the whole point is naming exactly which clubs are
affected, in both groups, and that list can be long.

View File

@@ -53,3 +53,7 @@ Domain notes (drive modeling decisions):
- Ruff config anticipates a Wagtail-style codebase (`DJ` Django rules; `RUF012`/`RUF005` ignored for framework idioms; `line-length = 250`). Migrations are excluded from linting — don't hand-edit them to satisfy ruff.
- Settings files are exempt from `F403/F405/E501` (star imports allowed) under `rosterchief/settings/*` — note the config expects a settings *package*, though the current code is a single `settings.py`. If you split settings, match that path.
- **Every user-facing string must be translatable** (`USE_I18N = True`; no `.po` files exist yet, but the codebase is kept translation-ready as it's written, not audited later). This applies to templates, models, forms, and views alike:
- Templates: `{% load i18n %}`, then `{% trans "..." %}` for literal text and `{% blocktrans %}...{% endblocktrans %}` for text containing a variable (bind filter chains to a plain name first via `{% blocktrans with x=some.filtered|value %}`, or `{% blocktrans count counter=n %}...{% plural %}...{% endblocktrans %}` for pluralized counts — never hand-roll pluralization with `|pluralize`, real languages have more than two plural forms). Covers headings, buttons, table headers, empty-state text, `placeholder=`/`aria-label=` attributes — not URL names, CSS classes, icon names, `dom_id` arguments, or raw data interpolated on its own.
- Python (models, forms, views, services): `from django.utils.translation import gettext_lazy as _`, wrapping `verbose_name`, `help_text`, `Meta.verbose_name(_plural)`, form field `label`/`help_text`, `TextChoices`/`IntegerChoices` labels (never the choice *value*), and any user-facing string built in a method/view (`notify()` messages, raised `ValidationError`/`ValueError` text, dict labels rendered directly in a template).
- Never bake an interpolated value directly into a translatable string (an f-string with `{variable}` *inside* the translated text) — word order isn't guaranteed to survive translation. Use `%(name)s` placeholders instead: `_("“%(name)s” updated.") % {"name": obj}`. This matters most for `controlpanel.messages.notify(request, spec)`, whose `"<level>|<title>|<body>"` spec is assembled via f-string — translate the title and body as separate `_()`/`%()` expressions, then drop the already-resolved strings into the f-string skeleton.

View File

@@ -34,9 +34,15 @@ Caddy terminates TLS, so without it Django believes every request is plain HTTP:
`header_up X-Forwarded-Proto`); don't remove either.
**4. Uploads must move to object storage before the second app server.**
Club logos go to `MEDIA_ROOT` on local disk. On one box that is fine. On two, a logo
uploaded to node A is a 404 on node B. Setting `AWS_STORAGE_BUCKET_NAME` switches the
default storage to S3 — do it *before* you scale, not during.
Club logos go to `MEDIA_ROOT` on local disk by default. `compose.yaml` mounts a `media_data`
volume, shared read-write with `web` and read-only with `caddy`, so uploads both survive a
rebuild and get served by Caddy directly (`handle_path /media/*` in the Caddyfile) rather than
round-tripping through a gunicorn worker. `rosterchief/urls.py` still serves `/media/*` itself
as a fallback whenever `AWS_STORAGE_BUCKET_NAME` is unset — needed for `compose.behind-proxy.yaml`
(no bundled Caddy there) and for `runserver`. On two boxes local disk stops working regardless
of any of this: a logo uploaded to node A is still a 404 on node B, since nothing shares the
volume between them. Setting `AWS_STORAGE_BUCKET_NAME` switches the default storage to S3 — do
it *before* you scale, not during.
**5. PDF invoices need native libraries.**
WeasyPrint binds to pango/cairo. The image installs them; a bare-metal deploy would need
@@ -111,20 +117,30 @@ docker compose up -d --no-deps web
## Scheduled jobs
Two commands need to run on a schedule. Put them on the **host**, not in a container, and on
**exactly one node** when you have several — three nodes archiving the same club is three
emails to the same club.
Five jobs run on a schedule via **Celery Beat**, not host cron — see `rosterchief/settings.py`
(`CELERY_BEAT_SCHEDULE`) for the exact times and `features/jobs.py` for what each one does.
`worker` and `beat` are just the `web` image running a different command (see `compose.yaml`);
`worker` can scale to several containers, but run **exactly one `beat`** across the whole
deployment — it decides *when* a task fires, so two of them means every job runs twice (two
`archive_overdue_clubs` runs is two emails to the same club, the same "exactly one node"
reasoning the old crontab needed).
```cron
# Bill: archive clubs unpaid past their grace period.
# Run it WITHOUT --commit for the first week and read the output. The flag exists because
# this switches off paying customers: a bad clock or a bad cron should cost you an email,
# not a morning of angry clubs.
0 6 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py archive_overdue_clubs --commit
| Job | Cadence | What it does |
|---|---|---|
| `extend_event_series` | daily 03:00 | materialises recurring event occurrences so the calendar never runs dry |
| `renew_subscriptions` | daily 04:00 | opens the next billing period for clubs whose current one is running out |
| `send_billing_reminders` | daily 05:00 | emails club admins about outstanding platform fees, once per escalation level |
| `archive_overdue_clubs` | daily 06:00 | archives clubs unpaid past their grace period |
| `generate_seasons` | monthly, 1st 05:00 | generates the next 2 years of seasons for every active club |
# Events: extend recurring series so the calendar never runs dry.
0 3 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py extend_event_series
```
Each task always acts (no `--dry-run`/`--commit` gate) — the same as the old crontab always
passing `--commit`. Run status (started, finished, success/failure, what it returned or
raised) is recorded in `features.models.JobRun` and shown on the control panel's **Jobs**
tab, which a crontab line mailing stderr on failure never gave us.
The `manage.py <command>` versions of these still exist unchanged, for manual/dry-run use
from a shell — see each command's own `--help` (`generate_seasons --resync`, for one, is
still CLI-only: it can delete rows, so it isn't something a beat schedule runs unattended).
## Maintenance mode
@@ -135,16 +151,19 @@ Control panel → **Features → Maintenance mode**. While it is on:
you with no way to turn it back off;
- `/healthz` keeps answering on every host, or the load balancer would take the node out of
rotation and the control panel with it;
- the **scheduled jobs stand down**`archive_overdue_clubs`, `extend_event_series` and
`import_members_csv` refuse to run.
- the **scheduled jobs stand down**the five Celery tasks in the table above, plus
`import_members_csv` when run by hand.
`migrate` and `collectstatic` are deliberately **not** blocked. Maintenance is usually
declared *in order* to run them, and a guard that stopped them would mean turning the mode
off to do the work you turned it on for.
The scheduled jobs exit **non-zero** while the platform is closed, so cron will mail you.
That is intended: a job that silently skips itself is how a month of billing goes missing. If
you genuinely mean to run one during a window, pass `--ignore-maintenance`.
A Celery task raises loudly rather than skipping quietly while the platform is closed — that
is intended, a job that silently no-ops is how a month of billing goes missing — which
`worker` logs and, via `features/signals.py`, records as a `Failed` JobRun on the control
panel's **Jobs** tab. The `manage.py` version of each command still exits non-zero the same
way and accepts `--ignore-maintenance` for the rare case you genuinely mean to run one by
hand during a window.
So a migration-heavy deploy looks like:
@@ -298,6 +317,35 @@ A *.test.rosterchief.app -> <server ip>
The compose project is named `rosterchief-test`, so its containers and volumes never collide
with a production stack on the same host.
### Deploying with one command
Once the server has the repo cloned at `/home/bernard/RosterChief` and its two env files in
place, `deploy/deploy-dev.sh` does a full deploy over SSH:
```bash
deploy/deploy-dev.sh # deploy the current branch
BRANCH=main deploy/deploy-dev.sh
deploy/deploy-dev.sh --push # push the branch first, then deploy
```
It runs from your machine and does the work on the server in one SSH session: fetch the pushed
branch (a hard reset to `origin/<branch>`, since a deploy target only receives deploys), build
the image, run migrations *explicitly*, restart only `web`, and wait for `/healthz`.
It refuses to deploy a branch whose local commits are not pushed — the server pulls from git,
so unpushed work would ship stale code silently. Override the host, user, directory or branch
with the `SSH_HOST` / `SSH_USER` / `REMOTE_DIR` / `BRANCH` environment variables.
First-time setup on the server, once:
```bash
git clone git@git.siebens.org:bernard/RosterChief.git /home/bernard/RosterChief
cd /home/bernard/RosterChief
cp .env.compose.example .env # fill in POSTGRES_PASSWORD etc.
cp .env.production.example .env.production
# then add the reverse_proxy site block to the host's Caddy (see above)
```
## Automated backups
`deploy/backup.sh` dumps the database, tars the uploads while they are still on local disk,
@@ -388,7 +436,8 @@ So do not size for the data. Size for the **processes**.
### What actually consumes the box
Measured, running this app under gunicorn with `DEBUG=False`:
Measured, running this app under gunicorn with `DEBUG=False`, before the tuning below —
`--workers 3`, no `--preload`, Postgres and Redis on their image defaults:
| | memory |
|---|---|
@@ -399,6 +448,19 @@ Measured, running this app under gunicorn with `DEBUG=False`:
| OS + Docker daemon | ~400 MB |
| **steady state** | **~1.01.2 GB** |
Since then, `Dockerfile`/`compose.yaml` were tuned for smaller boxes: `--workers 2 --preload`
(one fewer duplicated Django process, and `--preload` shares immutable memory across workers
via copy-on-write instead of each worker importing Django independently), plus trimmed Postgres
`shared_buffers`/`max_connections` and a Redis `--maxmemory` cap. Expect the gunicorn and
Postgres rows to come in lower than above — not yet re-measured, so treat the table as the
shape of where memory goes rather than exact numbers on the current config.
The table also predates `worker` and `beat` (see "Scheduled jobs"): each is one more full
Django process, not re-measured yet either, but expect each to land in the same range as one
gunicorn worker above (~5060 MB) since it's the same app import cost with none of gunicorn's
own overhead. `beat` additionally has essentially nothing to do between firing its five daily
tasks, so it's the cheapest process in the stack to run.
2 GB would run it. 4 GB is the recommendation for three reasons, all of which are the kind of
thing that bites at the worst moment:
@@ -542,7 +604,7 @@ Nothing in the code changes. What changes is where the services live:
| Cache / flags | `redis` container | managed Redis (or your existing one) |
| Uploads | local disk | **S3 bucket** (`AWS_STORAGE_BUCKET_NAME`) |
| Static files | WhiteNoise, in the image | unchanged — that is why WhiteNoise is there |
| Cron | host crontab | one node only |
| Scheduled jobs | `worker` + `beat` containers | `worker` on any/every node; **`beat` on exactly one** |
| TLS | Caddy on the box | load balancer, or Caddy on each node |
Drop `db` and `redis` from `compose.yaml`, point the URLs at the central services, and run

View File

@@ -8,10 +8,16 @@ FROM node:22-slim AS css
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
# Every directory assets/app.css's @source lines scan -- miss one here and Tailwind's build
# silently emits no utilities for classes used only in that app's templates. Locally `npm run
# build` runs against the full checkout and never shows this; only a container image, built
# from just what's COPYed here, can.
COPY assets ./assets
COPY templates ./templates
COPY controlpanel ./controlpanel
COPY billing ./billing
COPY management ./management
COPY club ./club
RUN npm run build
@@ -27,7 +33,18 @@ RUN apt-get update && apt-get install --no-install-recommends -y git ca-certific
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 \
# Bytecode precompilation is off, not just given a longer leash: locally,
# phonenumbers' largest generated geodata/data*.py files (~900KB of literal
# dict data each) compile in ~0.1s, but the build host needed 300s+ for a
# single one of the 29 such files uv tried to compile here -- a ~3000x gap
# that a bigger UV_COMPILE_BYTECODE_TIMEOUT can't fix, since 29 files at that
# rate could still blow past any reasonable build budget. That gap points to
# something on the host itself (cross-arch QEMU emulation or memory pressure
# during the build), worth root-causing separately. Leaving bytecode
# compilation off means each container pays a first-import cost instead
# (--preload/prefork make that once per boot, not per request) -- slower
# startup until the host issue is fixed, but a build that reliably finishes.
ENV UV_COMPILE_BYTECODE=0 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
@@ -57,7 +74,11 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/app/.venv/bin:$PATH"
PATH="/app/.venv/bin:$PATH" \
# gunicorn 26's control server puts a socket in $HOME. The app user has no home dir, so
# without this it logs "Permission denied: /home/rosterchief" on every boot. /app is
# already the workdir and owned by the app user, so point HOME there.
HOME="/app"
WORKDIR /app
@@ -67,13 +88,21 @@ COPY --from=venv /app/.venv ./.venv
COPY . .
COPY --from=css /build/static/css/app.css ./static/css/app.css
COPY --from=css /build/static/css/controlpanel.css ./static/css/controlpanel.css
COPY --from=css /build/static/css/management.css ./static/css/management.css
# collectstatic needs a settings module that imports: a throwaway key, never used at runtime.
RUN DJANGO_SECRET_KEY=build-only-not-a-secret \
DJANGO_STATICFILES_BACKEND=whitenoise.storage.CompressedManifestStaticFilesStorage \
python manage.py collectstatic --noinput
RUN useradd --system --uid 1000 rosterchief && chown -R rosterchief /app
# mkdir before chown, and before the volume ever mounts: media_data has nothing to copy from
# at /app/media otherwise, so Docker creates the mount point itself, owned by root — and the
# app runs as rosterchief, not root. Existing image content (even an empty, correctly-owned
# dir) is what a named volume copies its initial ownership from on first use.
RUN useradd --system --uid 1000 rosterchief \
&& mkdir -p /app/media /app/private_media \
&& chown -R rosterchief /app
USER rosterchief
EXPOSE 8000
@@ -81,10 +110,19 @@ EXPOSE 8000
# Migrations are NOT run here. With more than one app container they would race, and a failed
# migration inside a starting web process is a bad place to find out — deploy runs them once,
# explicitly (see DEPLOYMENT.md).
# 2 workers, not 3: DEPLOYMENT.md's own sizing says this workload isn't CPU-bound, and each
# worker duplicates a full Django process — the single biggest lever on a memory-limited box.
# --preload imports the app once in the master and forks workers via copy-on-write instead of
# each re-importing Django independently (safe here: no app's ready() touches DB/Redis eagerly,
# checked club/features/news/events). --max-requests recycles a worker periodically so the one
# that happens to render a WeasyPrint invoice doesn't carry that +50-100MB forever.
CMD ["gunicorn", "rosterchief.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "3", \
"--workers", "2", \
"--threads", "4", \
"--preload", \
"--max-requests", "500", \
"--max-requests-jitter", "50", \
"--timeout", "60", \
"--access-logfile", "-", \
"--error-logfile", "-"]

Binary file not shown.

0
api/__init__.py Normal file
View File

6
api/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "api"
verbose_name = "Public API"

17
api/errors.py Normal file
View File

@@ -0,0 +1,17 @@
"""Shared error helpers for the public API -- see api/urls.py.
Every endpoint is club-scoped via the same subdomain-based tenant resolution
the rest of the platform uses (club.tenancy.ClubTenantMiddleware sets
request.club before any view runs). A request with no club on the host --
the bare base domain, an unknown slug, an archived club -- has nothing to
serve, so it 404s the same way club.mixins.ClubStaffRequiredMixin already
404s the staff-facing app off the base domain.
"""
from ninja.errors import HttpError
def require_club(request):
if request.club is None:
raise HttpError(404, "No club found for this host.")
return request.club

31
api/middleware.py Normal file
View File

@@ -0,0 +1,31 @@
"""CORS for the public API only.
Every route under /api/v1/ is public, read-only, and unauthenticated -- no
cookies or credentials are ever involved, so there's no CSRF/session risk in
answering any origin. That's the whole reason this is a few lines here
instead of pulling in django-cors-headers for a handful of GET routes: the
rest of the site keeps Django's ordinary same-origin behaviour untouched.
"""
from django.http import HttpResponse
API_PATH_PREFIX = "/api/v1/"
class PublicApiCorsMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.path.startswith(API_PATH_PREFIX):
return self.get_response(request)
if request.method == "OPTIONS":
response = HttpResponse(status=204)
else:
response = self.get_response(request)
response["Access-Control-Allow-Origin"] = "*"
response["Access-Control-Allow-Methods"] = "GET, OPTIONS"
response["Access-Control-Allow-Headers"] = "Content-Type"
return response

681
api/tests.py Normal file
View File

@@ -0,0 +1,681 @@
import datetime
import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.utils import timezone
from PIL import Image
from club.models import Club, ClubMembership, Season, Sponsor
from events.models import Event, Location, Opponent
from members.models import Member
from news.models import News, NewsPhoto
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
@override_settings(
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"],
)
class ApiTestBase(TestCase):
# The tenant, its current season and its one team back every API test and are read
# only. The handful of tests that do change them (deleting the season, giving the
# club a logo) get their own copy from setUpTestData and are rolled back with the
# per-test transaction.
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
today = timezone.localdate()
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
def api_get(self, path, **params):
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="ajax-united.rosterchief.app")
def api_get_base_domain(self, path, **params):
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="rosterchief.app")
class NewsApiTests(ApiTestBase):
def make_news(self, **overrides):
defaults = {"club": self.club, "title": "News", "body": "body", "status": News.Status.PUBLISHED, "published_at": timezone.now() - datetime.timedelta(hours=1), "visibility": News.Visibility.EXTERNAL}
defaults.update(overrides)
return News.objects.create(**defaults)
def test_a_draft_is_excluded(self):
self.make_news(status=News.Status.DRAFT, published_at=None)
self.assertEqual(self.api_get("/news/").json()["count"], 0)
def test_a_scheduled_but_not_yet_released_item_is_excluded(self):
self.make_news(published_at=timezone.now() + datetime.timedelta(days=1))
self.assertEqual(self.api_get("/news/").json()["count"], 0)
def test_an_internal_only_item_is_excluded(self):
self.make_news(visibility=News.Visibility.INTERNAL)
self.assertEqual(self.api_get("/news/").json()["count"], 0)
def test_an_external_item_is_included(self):
item = self.make_news(visibility=News.Visibility.EXTERNAL)
data = self.api_get("/news/").json()
self.assertEqual(data["count"], 1)
self.assertEqual(data["results"][0]["id"], str(item.pk))
def test_a_both_visibility_item_is_included(self):
self.make_news(visibility=News.Visibility.BOTH)
self.assertEqual(self.api_get("/news/").json()["count"], 1)
def test_newest_first(self):
older = self.make_news(title="Older", published_at=timezone.now() - datetime.timedelta(days=2))
newer = self.make_news(title="Newer", published_at=timezone.now() - datetime.timedelta(hours=1))
results = self.api_get("/news/").json()["results"]
self.assertEqual([r["id"] for r in results], [str(newer.pk), str(older.pk)])
def test_photos_get_absolute_urls(self):
item = self.make_news()
NewsPhoto.objects.create(news_item=item, image="clubs/ajax-united/news/x/pic.jpg", is_main=True)
photo = self.api_get("/news/").json()["results"][0]["photos"][0]
self.assertTrue(photo["url"].startswith("http://ajax-united.rosterchief.app/media/"))
self.assertTrue(photo["is_main"])
def test_pagination_limit_and_offset(self):
for i in range(3):
self.make_news(title=f"Item {i}", published_at=timezone.now() - datetime.timedelta(hours=1, minutes=i))
data = self.api_get("/news/", limit=1, offset=1).json()
self.assertEqual(data["count"], 3)
self.assertEqual(len(data["results"]), 1)
def test_limit_is_capped(self):
self.assertEqual(self.api_get("/news/", limit=1000).json()["limit"], 100)
def test_no_news_is_an_empty_list_not_an_error(self):
response = self.api_get("/news/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["results"], [])
def test_excerpt_is_a_truncated_prefix_of_the_body(self):
self.make_news(body=" ".join(f"word{i}" for i in range(80)))
excerpt = self.api_get("/news/").json()["results"][0]["excerpt_nl"]
self.assertTrue(excerpt.startswith("word0 word1"))
self.assertTrue(excerpt.endswith(""))
self.assertLess(len(excerpt.split()), 80)
def test_excerpt_is_unchanged_when_the_body_is_already_short(self):
item = self.make_news(body="Short body.")
excerpt = self.api_get("/news/").json()["results"][0]["excerpt_nl"]
self.assertEqual(excerpt, item.body)
def test_slug_is_auto_populated_from_the_title(self):
item = self.make_news(title="Big Win This Weekend")
self.assertEqual(item.slug, "big-win-this-weekend")
def test_get_single_news_item_by_slug(self):
item = self.make_news(title="Big Win This Weekend")
response = self.api_get(f"/news/{item.slug}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["id"], str(item.pk))
def test_get_single_news_item_404s_for_an_unknown_slug(self):
response = self.api_get("/news/no-such-item/")
self.assertEqual(response.status_code, 404)
def test_get_single_news_item_respects_visibility(self):
item = self.make_news(visibility=News.Visibility.INTERNAL)
response = self.api_get(f"/news/{item.slug}/")
self.assertEqual(response.status_code, 404)
def test_body_markdown_is_rendered_to_html(self):
self.make_news(body="## Big win\n\nWe beat **Rivals FC** 4-2. [Full report](https://example.com).")
body = self.api_get("/news/").json()["results"][0]["body_nl"]
self.assertIn("<h2>Big win</h2>", body)
self.assertIn("<strong>Rivals FC</strong>", body)
self.assertIn('href="https://example.com"', body)
self.assertIn(">Full report</a>", body)
def test_body_markdown_a_single_newline_becomes_a_line_break(self):
self.make_news(body="Line one\nLine two")
body = self.api_get("/news/").json()["results"][0]["body_nl"]
self.assertIn("Line one<br", body)
def test_body_html_strips_a_script_tag(self):
self.make_news(body="Hello<script>alert('xss')</script>world")
body = self.api_get("/news/").json()["results"][0]["body_nl"]
self.assertNotIn("<script", body)
self.assertNotIn("alert(", body)
def test_body_html_strips_an_event_handler_attribute(self):
self.make_news(body='<img src="x" onerror="alert(1)">')
body = self.api_get("/news/").json()["results"][0]["body_nl"]
self.assertNotIn("onerror", body)
def test_body_html_strips_a_javascript_url(self):
self.make_news(body="[click me](javascript:alert(1))")
body = self.api_get("/news/").json()["results"][0]["body_nl"]
self.assertNotIn("javascript:", body)
def test_excerpt_strips_markdown_syntax(self):
self.make_news(body="**Bold** and a [link](https://example.com) and # not a heading here")
excerpt = self.api_get("/news/").json()["results"][0]["excerpt_nl"]
self.assertNotIn("**", excerpt)
self.assertNotIn("[link]", excerpt)
self.assertNotIn("<", excerpt)
def test_english_falls_back_to_the_original_when_not_translated(self):
self.make_news(title="Seizoensstart", body="We beginnen het seizoen.")
result = self.api_get("/news/").json()["results"][0]
self.assertEqual(result["title_en"], "Seizoensstart")
self.assertIn("We beginnen het seizoen.", result["body_en"])
self.assertEqual(result["excerpt_en"], result["excerpt_nl"])
def test_english_is_used_when_translated(self):
self.make_news(title="Seizoensstart", title_en="Season kickoff", body="We beginnen het seizoen.", body_en="We're starting the season.")
result = self.api_get("/news/").json()["results"][0]
self.assertEqual(result["title_nl"], "Seizoensstart")
self.assertEqual(result["title_en"], "Season kickoff")
self.assertIn("We beginnen het seizoen.", result["body_nl"])
self.assertIn("We're starting the season.", result["body_en"])
self.assertNotEqual(result["excerpt_en"], result["excerpt_nl"])
class TeamsApiTests(ApiTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.forward = Position.objects.create(club=cls.club, name="Forward", short_name="FW", ordering=1)
cls.defense = Position.objects.create(club=cls.club, name="Defense", short_name="DF", ordering=2)
cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
def test_list_teams(self):
data = self.api_get("/teams/").json()
self.assertEqual(data, [{"id": str(self.team.pk), "name": "First Team", "short_name": "1st", "photo_url": None}])
def test_roster_groups_players_by_position(self):
alice = Member.objects.create(first_name="Alice", last_name="Ash")
bob = Member.objects.create(first_name="Bob", last_name="Birch")
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
# Two forwards (ordering 1) at different jersey numbers, one defense (ordering 2).
TeamMembership.objects.create(team=self.team, member=bob, season=self.season, position=self.forward, jersey_number=9)
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
TeamMembership.objects.create(team=self.team, member=carol, season=self.season, position=self.defense, jersey_number=1)
groups = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
# Groups in Position.ordering order (Forward before Defense); within a
# group, sorted by jersey number.
self.assertEqual([g["position"] for g in groups], ["Forward", "Defense"])
self.assertEqual([p["first_name"] for p in groups[0]["players"]], ["Alice", "Bob"])
self.assertEqual([p["first_name"] for p in groups[1]["players"]], ["Carol"])
def test_a_player_entry_no_longer_repeats_its_position(self):
# The position is now the group key, not a per-player field.
alice = Member.objects.create(first_name="Alice", last_name="Ash")
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
player = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"][0]["players"][0]
self.assertNotIn("position", player)
def test_roster_includes_staff(self):
dana = Member.objects.create(first_name="Dana", last_name="Dean")
StaffAssignment.objects.create(team=self.team, member=dana, season=self.season, position=self.coach_position)
staff = self.api_get(f"/teams/{self.team.pk}/roster/").json()["staff"]
self.assertEqual(staff, [{"id": str(dana.pk), "first_name": "Dana", "last_name": "Dean", "position": "Head Coach"}])
def test_roster_is_current_season_only(self):
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
eve = Member.objects.create(first_name="Eve", last_name="Elm")
TeamMembership.objects.create(team=self.team, member=eve, season=other_season, position=self.forward, jersey_number=1)
players = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
self.assertEqual(players, [])
def test_roster_is_empty_with_no_current_season(self):
self.season.delete()
team_without_season = self.team
data = self.api_get(f"/teams/{team_without_season.pk}/roster/").json()
self.assertEqual(data["season"], None)
self.assertEqual(data["players"], [])
self.assertEqual(data["staff"], [])
def test_list_teams_includes_the_current_seasons_photo(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
photo_url = self.api_get("/teams/").json()[0]["photo_url"]
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
def test_roster_includes_the_current_seasons_photo(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
photo_url = self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"]
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
def test_a_photo_from_a_different_season_does_not_leak(self):
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
TeamPhoto.objects.create(team=self.team, season=other_season, image="clubs/ajax-united/teams/x/00-00/pic.jpg")
self.assertIsNone(self.api_get("/teams/").json()[0]["photo_url"])
self.assertIsNone(self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"])
def test_a_team_from_another_club_404s(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
response = self.api_get(f"/teams/{other_team.pk}/roster/")
self.assertEqual(response.status_code, 404)
def test_a_players_license_comes_from_their_club_membership(self):
alice = Member.objects.create(first_name="Alice", last_name="Ash")
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
ClubMembership.objects.create(club=self.club, member=alice, season=self.season, license="BE-12345")
player = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"][0]["players"][0]
self.assertEqual(player["license"], "BE-12345")
def test_a_players_license_is_null_without_a_club_membership(self):
alice = Member.objects.create(first_name="Alice", last_name="Ash")
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
player = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"][0]["players"][0]
self.assertIsNone(player["license"])
class GamesApiTests(ApiTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.home_location = Location.objects.create(club=cls.club, name="Home Arena", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
cls.opponent = Opponent.objects.create(club=cls.club, name="Rivals FC")
def make_game(self, **overrides):
defaults = {"club": self.club, "title": "Game", "kind": Event.EventKind.GAME, "start": timezone.now() + datetime.timedelta(days=1), "opponent": self.opponent}
defaults.update(overrides)
event = Event.objects.create(**defaults)
event.teams.add(self.team)
return event
def test_upcoming_games_are_listed_with_location_and_teams(self):
self.make_game(location=self.home_location)
games = self.api_get("/games/upcoming/").json()
self.assertEqual(len(games), 1)
self.assertEqual(games[0]["home_team"]["name"], "First Team")
self.assertEqual(games[0]["away_team"]["name"], "Rivals FC")
self.assertEqual(games[0]["location"]["name"], "Home Arena")
self.assertEqual(games[0]["status"], "upcoming")
def test_upcoming_excludes_cancelled_games(self):
self.make_game(cancelled=True)
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_upcoming_excludes_past_games(self):
self.make_game(start=timezone.now() - datetime.timedelta(days=1))
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_upcoming_includes_a_game_already_in_progress(self):
# Started 30 minutes ago, no explicit end -- the assumed 2h window
# means it isn't finished yet, so it must still show up.
self.make_game(start=timezone.now() - datetime.timedelta(minutes=30))
games = self.api_get("/games/upcoming/").json()
self.assertEqual(len(games), 1)
self.assertEqual(games[0]["status"], "live")
def test_upcoming_excludes_a_game_past_its_explicit_end(self):
game = self.make_game(start=timezone.now() - datetime.timedelta(hours=3))
game.end = timezone.now() - datetime.timedelta(hours=1)
game.save()
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_the_response_includes_an_end_time(self):
game = self.make_game()
end = self.api_get("/games/upcoming/").json()[0]["end"]
# JSON round-trips to millisecond precision -- compare with a small
# tolerance rather than an exact microsecond match.
self.assertLess(abs((datetime.datetime.fromisoformat(end) - game.end).total_seconds()), 1)
def test_upcoming_includes_tournaments(self):
self.make_game(kind=Event.EventKind.TOURNAMENT)
self.assertEqual(len(self.api_get("/games/upcoming/").json()), 1)
def test_upcoming_excludes_other_kinds(self):
self.make_game(kind=Event.EventKind.TRAINING)
self.make_game(kind=Event.EventKind.SOCIAL)
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_live_endpoint_stays_game_only(self):
# is_live/scores are game-specific -- a tournament wouldn't have
# anything meaningful to show here even if flagged live.
self.make_game(kind=Event.EventKind.TOURNAMENT, start=timezone.now() - datetime.timedelta(minutes=10), is_live=True)
self.assertEqual(self.api_get("/games/live/").json(), [])
def test_count_is_respected(self):
for i in range(3):
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
self.assertEqual(len(self.api_get("/games/upcoming/", count=2).json()), 2)
def test_count_is_capped(self):
for i in range(3):
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
# Cap is 50, well above the 3 created -- just confirm an oversized
# request doesn't error and doesn't somehow exceed what exists.
response = self.api_get("/games/upcoming/", count=1000)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 3)
def test_live_games_are_listed_with_scores(self):
self.make_game(start=timezone.now() - datetime.timedelta(minutes=10), is_live=True, score_for=2, score_against=1, location=self.home_location)
games = self.api_get("/games/live/").json()
self.assertEqual(len(games), 1)
self.assertEqual(games[0]["status"], "live")
self.assertEqual(games[0]["home_score"], 2)
self.assertEqual(games[0]["away_score"], 1)
def test_live_excludes_cancelled_games(self):
self.make_game(is_live=True, cancelled=True)
self.assertEqual(self.api_get("/games/live/").json(), [])
def test_non_live_games_are_excluded_from_live_endpoint(self):
self.make_game()
self.assertEqual(self.api_get("/games/live/").json(), [])
def test_score_relabelling_for_an_away_game(self):
# No location (or a non-home one) -- is_home_game is False, so our
# team's score_for/score_against map to the away side.
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3)
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
self.assertEqual(games[0]["home_team"]["name"], "Rivals FC")
self.assertEqual(games[0]["away_team"]["name"], "First Team")
self.assertEqual(games[0]["home_score"], 3)
self.assertEqual(games[0]["away_score"], 4)
self.assertEqual(games[0]["status"], "finished")
def test_score_relabelling_for_a_home_game(self):
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3, location=self.home_location)
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
self.assertEqual(games[0]["home_team"]["name"], "First Team")
self.assertEqual(games[0]["away_team"]["name"], "Rivals FC")
self.assertEqual(games[0]["home_score"], 4)
self.assertEqual(games[0]["away_score"], 3)
def test_team_games_includes_past_and_upcoming_for_the_current_season(self):
self.make_game(title="Past", start=timezone.now() - datetime.timedelta(days=1), score_for=1, score_against=0)
self.make_game(title="Future", start=timezone.now() + datetime.timedelta(days=1))
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
self.assertEqual(len(games), 2)
def test_team_games_excludes_a_different_season(self):
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
self.make_game(start=datetime.datetime(2000, 6, 1, tzinfo=datetime.UTC), season=other_season)
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
def test_team_games_excludes_cancelled(self):
self.make_game(cancelled=True)
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
def test_team_games_is_empty_with_no_current_season(self):
self.make_game()
self.season.delete()
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
def test_a_team_from_another_club_404s_on_games(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
response = self.api_get(f"/teams/{other_team.pk}/games/")
self.assertEqual(response.status_code, 404)
def test_home_team_links_to_the_actual_team_and_the_clubs_logo(self):
# Our own teams have no logo of their own -- they're shown under the club's badge.
self.club.logo = "clubs/ajax-united/logo.png"
self.club.save()
self.make_game(location=self.home_location)
home_team = self.api_get("/games/upcoming/").json()[0]["home_team"]
self.assertEqual(home_team["id"], str(self.team.pk))
self.assertEqual(home_team["name"], "First Team")
self.assertTrue(home_team["logo_url"].startswith("http://ajax-united.rosterchief.app/media/"))
def test_away_team_links_to_the_opponent_and_its_own_logo(self):
self.opponent.logo = "opponents/rivals.png"
self.opponent.save()
self.make_game(location=self.home_location)
away_team = self.api_get("/games/upcoming/").json()[0]["away_team"]
self.assertEqual(away_team["id"], str(self.opponent.pk))
self.assertEqual(away_team["name"], "Rivals FC")
self.assertTrue(away_team["logo_url"].startswith("http://ajax-united.rosterchief.app/media/"))
def test_team_logo_url_is_null_without_a_club_logo(self):
self.make_game(location=self.home_location)
home_team = self.api_get("/games/upcoming/").json()[0]["home_team"]
self.assertIsNone(home_team["logo_url"])
class TenancyAndCorsTests(ApiTestBase):
def test_the_base_domain_404s(self):
response = self.api_get_base_domain("/news/")
self.assertEqual(response.status_code, 404)
def test_a_get_response_carries_the_cors_header(self):
response = self.api_get("/news/")
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
def test_an_options_preflight_gets_a_204_with_cors_headers(self):
response = self.client.options("/api/v1/news/", HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 204)
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
self.assertIn("GET", response["Access-Control-Allow-Methods"])
def test_cors_headers_are_not_added_outside_the_api(self):
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotIn("Access-Control-Allow-Origin", response)
def test_docs_page_resolves(self):
self.assertEqual(self.api_get("/docs").status_code, 200)
def test_openapi_schema_resolves(self):
self.assertEqual(self.api_get("/openapi.json").status_code, 200)
class SponsorApiTests(ApiTestBase):
def make_sponsor(self, **overrides):
today = timezone.localdate()
defaults = {"club": self.club, "name": "Acme Corp", "start_date": today - datetime.timedelta(days=10), "end_date": today + datetime.timedelta(days=10)}
defaults.update(overrides)
return Sponsor.objects.create(**defaults)
def test_a_sponsor_covering_today_is_included(self):
self.make_sponsor()
data = self.api_get("/sponsors/").json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["name"], "Acme Corp")
def test_a_sponsor_starting_in_the_future_is_excluded(self):
today = timezone.localdate()
self.make_sponsor(start_date=today + datetime.timedelta(days=1), end_date=None)
self.assertEqual(self.api_get("/sponsors/").json(), [])
def test_a_sponsor_that_already_ended_is_excluded(self):
today = timezone.localdate()
self.make_sponsor(start_date=today - datetime.timedelta(days=20), end_date=today - datetime.timedelta(days=1))
self.assertEqual(self.api_get("/sponsors/").json(), [])
def test_a_sponsor_with_no_end_date_and_a_past_start_is_included(self):
today = timezone.localdate()
self.make_sponsor(start_date=today - datetime.timedelta(days=100), end_date=None)
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
def test_a_sponsor_starting_today_is_included(self):
today = timezone.localdate()
self.make_sponsor(start_date=today, end_date=None)
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
def test_a_sponsor_ending_today_is_included(self):
today = timezone.localdate()
self.make_sponsor(start_date=today - datetime.timedelta(days=10), end_date=today)
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
def test_another_clubs_sponsor_never_leaks_in(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
self.make_sponsor(club=other_club)
self.assertEqual(self.api_get("/sponsors/").json(), [])
def test_logo_url_is_absolute_when_set(self):
self.make_sponsor(logo="clubs/ajax-united/sponsors/x/logo.png")
logo_url = self.api_get("/sponsors/").json()[0]["logo_url"]
self.assertTrue(logo_url.startswith("http://ajax-united.rosterchief.app/media/"))
def test_logo_url_is_null_when_not_set(self):
self.make_sponsor()
self.assertIsNone(self.api_get("/sponsors/").json()[0]["logo_url"])
def test_logo_dimensions_are_computed_for_a_raster_image(self):
buffer = io.BytesIO()
Image.new("RGB", (300, 150)).save(buffer, format="PNG")
logo = SimpleUploadedFile("logo.png", buffer.getvalue(), content_type="image/png")
self.make_sponsor(logo=logo)
sponsor = self.api_get("/sponsors/").json()[0]
self.assertEqual(sponsor["logo_width"], 300)
self.assertEqual(sponsor["logo_height"], 150)
def test_logo_dimensions_are_computed_for_an_svg_with_width_and_height(self):
svg = b'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"></svg>'
logo = SimpleUploadedFile("logo.svg", svg, content_type="image/svg+xml")
self.make_sponsor(logo=logo)
sponsor = self.api_get("/sponsors/").json()[0]
self.assertEqual(sponsor["logo_width"], 120)
self.assertEqual(sponsor["logo_height"], 80)
def test_logo_dimensions_fall_back_to_an_svg_viewbox(self):
svg = b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32"></svg>'
logo = SimpleUploadedFile("logo.svg", svg, content_type="image/svg+xml")
self.make_sponsor(logo=logo)
sponsor = self.api_get("/sponsors/").json()[0]
self.assertEqual(sponsor["logo_width"], 64)
self.assertEqual(sponsor["logo_height"], 32)
def test_logo_dimensions_are_null_without_a_logo(self):
self.make_sponsor()
sponsor = self.api_get("/sponsors/").json()[0]
self.assertIsNone(sponsor["logo_width"])
self.assertIsNone(sponsor["logo_height"])
def test_randomize_returns_the_same_set_of_sponsors(self):
for i in range(5):
self.make_sponsor(name=f"Sponsor {i}")
stable = {s["id"] for s in self.api_get("/sponsors/").json()}
randomized = {s["id"] for s in self.api_get("/sponsors/", randomize="true").json()}
self.assertEqual(stable, randomized)
self.assertEqual(len(stable), 5)
def test_default_order_is_stable_and_alphabetical(self):
self.make_sponsor(name="Zulu Corp")
self.make_sponsor(name="Acme Corp")
names = [s["name"] for s in self.api_get("/sponsors/").json()]
self.assertEqual(names, ["Acme Corp", "Zulu Corp"])

28
api/urls.py Normal file
View File

@@ -0,0 +1,28 @@
"""The public read-only API -- see ARCHITECTURE.md and the plan this shipped
under. Mounted at /api/v1/ (rosterchief/urls.py), club-scoped by the same
subdomain-based tenant resolution every other view uses
(club.tenancy.ClubTenantMiddleware sets request.club before this ever runs).
Each domain app owns its own router and schemas (news/api.py, teams/api.py,
events/api.py) -- this module only wires them together, same reasoning as
management/controlpanel never owning domain logic themselves.
"""
from ninja import NinjaAPI
from club.api import router as club_router
from events.api import router as events_router
from news.api import router as news_router
from teams.api import router as teams_router
api = NinjaAPI(
title="RosterChief public API",
version="1.0.0",
description="Public, read-only data for a club's own external website: news, team rosters, fixtures, and sponsors.",
urls_namespace="api",
)
api.add_router("/news", news_router)
api.add_router("/teams", teams_router)
api.add_router("/", events_router)
api.add_router("/sponsors", club_router)

View File

@@ -5,6 +5,8 @@
@source "../templates";
@source "../controlpanel";
@source "../billing";
@source "../management";
@source "../club";
/* daisyUI: light is the default, dark applies automatically when the OS asks
for it. An explicit data-theme on <html> (set by the toggle) overrides both. */
@@ -193,3 +195,74 @@
[data-theme="dark"] .logo {
background-image: var(--logo-light);
}
/* Club-uploaded logos are arbitrary raster files (often much smaller than the
badge they're displayed in), so the browser is upscaling them -- and the
default resampling some engines fall back to for that reads as pixelated.
Forcing the element onto its own GPU-composited layer makes Chrome/Safari
pick their higher-quality scaler; the image-rendering hints are a no-op
where unsupported, degrading gracefully to the browser default. Doesn't
apply to SVG logos, which scale losslessly regardless. */
.club-logo {
image-rendering: -webkit-optimize-contrast;
image-rendering: smooth;
image-rendering: high-quality;
backface-visibility: hidden;
transform: translateZ(0);
}
/* Below `md`, a `.table-cards` table reads as a stack of cards instead of a
horizontally-scrolling grid: each row becomes a bordered block, each cell
becomes a label/value line pulling its label from `data-label` (set on the
<td> in the template -- there's no way to read the matching <th> text from
pure CSS). A <td> with no `data-label` renders as plain full-width content
instead of a label/value row -- for an image cell, a heading-style link, or
an actions row that already lays itself out. At `md` and up this reverts to
an ordinary table with nothing left over from the mobile styling. */
@media (width < 48rem) {
.table-cards, .table-cards :is(thead, tbody, tr, th, td) {
display: block;
width: 100%;
}
.table-cards thead {
display: none;
}
.table-cards tbody tr {
border: 1px solid var(--color-base-300);
border-radius: var(--radius-box);
padding: 0 0.75rem;
}
.table-cards tbody tr + tr {
margin-top: 0.75rem;
}
.table-cards td {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--color-base-200);
}
.table-cards td:last-child {
border-bottom: none;
}
.table-cards td[data-label]:before {
content: attr(data-label);
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
opacity: 0.5;
}
.table-cards td:not([data-label]) {
justify-content: flex-start;
}
}

1073
assets/controlpanel.css Normal file

File diff suppressed because it is too large Load Diff

2037
assets/management.css Normal file

File diff suppressed because it is too large Load Diff

595
assets/mobile.css Normal file
View File

@@ -0,0 +1,595 @@
/* RosterChief member app -- the installed PWA (see design_handoff_rosterchief_platform/
* README.md, "Member mode (mobile, 402x874 reference)"). Its own stylesheet, not shared
* with management.css/controlpanel.css/app.css: the component vocabulary (app header,
* role switcher, bottom tab bar, hero card) has nothing in common with the desktop
* surfaces' daisyUI-shaped one.
*
* Design tokens copied from management.css's own @theme (built against the same design
* doc) rather than re-derived -- same colours, same self-hosted fonts (shared
* static/fonts/ directory, one download for every stylesheet). Two differences:
* - --radius-box is 14px here (mobile cards), not management's 0.75rem/12px (desktop
* "rounded-xl" cards) -- see the design doc's own radius table.
* - --color-ice/--color-ice-ink get their own names (management just calls the same
* #14B8E8 hex "info", since it has no coach-mode concept) -- the design doc is
* explicit that Coach mode uses ice "regardless of club", so it needs a name that
* survives club theming rather than colliding with a generic status colour.
*
* Club theming (design doc, "Club theming"): mobile/templates/mobile/base.html sets
* --tenant-club/--tenant-club-content from Club.secondary_color (app header active tab,
* primary buttons, hero eyebrow) and --tenant-navy from Club.primary_color where the
* club overrides the platform navy header -- same --tenant-* custom-property pattern
* management/templates/management/base.html already uses, so both surfaces stay
* consistent with each other and with the identity page's own live preview.
*/
@import "tailwindcss";
@source "../mobile";
@theme {
--font-display: "Barlow Condensed", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace;
--color-ink: #0b1220;
--color-navy: var(--tenant-navy, #101e36);
--color-steel: #1b2b47;
--color-hairline: #1e2b42;
--color-paper: #f4f5f7;
--color-line: #e3e6eb;
--color-rule: #eef0f3;
--color-edge: #d6dae1;
--color-stroke: #c9cfd8;
--color-muted: #6c7787;
--color-dim: #8b95a4;
--color-slate: #3a4658;
--color-on-dark: #93a0b4;
--color-on-dark-dim: #7c8aa0;
--color-on-dark-faint: #5c6b85;
--color-club: var(--tenant-club, #e4002b);
--color-club-dark: var(--tenant-club-dark, #b00021);
--color-club-content: var(--tenant-club-content, #ffffff);
--color-ok: #14a05a;
--color-ok-bg: #e6f6ee;
--color-ok-border: #bfe7d3;
--color-ok-text: #0c7a43;
--color-warn: #f0a22e;
--color-warn-bg: #fff5e4;
--color-warn-border: #f6e0b8;
--color-warn-text: #9a6410;
--color-warn-deep: #7a4e08;
--color-danger-bg: #fdecec;
--color-danger-border: #f5c9ce;
--color-info: #14b8e8;
--color-info-bg: #eaf7fc;
--color-info-border: #c3e7f4;
--color-info-text: #0a6f91;
/* Coach-mode accent -- never club-themed, see the file banner. */
--color-ice: #14b8e8;
--color-ice-ink: #04212c;
/* Referee sign-up accent -- deliberately distinct from game/training/other's
club-red/info-blue/warn-amber (mobile/_calendar_row.html) so a "needs a
referee" row stands out from the member's own RSVP rows at a glance. */
--color-referee: #7c4dff;
--color-referee-bg: #f1ecff;
--color-referee-border: #d9caff;
--color-referee-text: #5b2fd6;
--color-row-sel: #fff7f8;
--color-row-focus: #f4f9ff;
--color-row-warn: #fffdf6;
--color-subhead: #f8f9fa;
--radius-box: 0.875rem; /* 14px -- mobile card radius, see the file banner */
}
/* --- fonts: identical set to management.css/controlpanel.css, same self-hosted files. --- */
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../fonts/barlow-latin-400-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("../fonts/barlow-latin-500-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("../fonts/barlow-latin-600-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Barlow";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../fonts/barlow-latin-700-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Barlow Condensed";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("../fonts/barlow-condensed-latin-600-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Barlow Condensed";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../fonts/barlow-condensed-latin-700-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Barlow Condensed";
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("../fonts/barlow-condensed-latin-800-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("../fonts/ibm-plex-mono-latin-500-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@layer base {
body {
background: var(--color-paper);
color: var(--color-slate);
font-family: var(--font-sans);
/* env(safe-area-inset-*) support -- design doc: "The app content starts below a
54px status-bar inset and ends above a 26-30px home-indicator inset -- preserve
those safe areas via env(safe-area-inset-*), not fixed padding." */
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
/* Standard Alpine convention -- hides an x-cloak'd element until Alpine has
initialized, so an x-show/x-if starting state never flashes unstyled. */
[x-cloak] {
display: none !important;
}
}
@layer components {
/* --- App shell -------------------------------------------------------------- */
.app-header {
padding: max(env(safe-area-inset-top), 14px) 16px 14px;
background: var(--color-navy);
color: #fff;
flex-shrink: 0;
}
/* Club logo (img) or initials (span) in the app header -- always a plain
circle, unlike management.css's own hexagon crest mark. */
.app-crest {
width: 32px;
height: 32px;
border-radius: 999px;
flex-shrink: 0;
object-fit: cover;
}
.app-crest-fallback {
background: var(--color-club);
color: var(--color-club-content);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 800;
font-size: 0.75rem;
}
/* Scrolls normally, just without the visible scrollbar chrome -- for the
person-switcher's horizontally-scrolling chip row. */
.scrollbar-hide {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* old Edge */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome/Safari/WebKit */
}
/* Role switcher -- design doc "Component" table: full-width segmented pill,
bg-steel track, active segment bg-white text-ink (member mode). Only rendered
when the account holds >=1 staff role -- see mobile/templates/mobile/base.html. */
.role-switcher {
display: flex;
background: var(--color-steel);
border-radius: 999px;
padding: 4px;
gap: 4px;
margin-top: 10px;
}
.role-switcher-item {
flex: 1;
height: 36px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 800;
font-size: 0.875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-on-dark);
}
.role-switcher-item-active {
background: #fff;
color: var(--color-ink);
}
/* Coach mode's own active segment -- design doc: "the role switcher with
Coach active (bg-ice)", distinct from member mode's white/ink one above. */
.role-switcher-item-active-ice {
background: var(--color-ice);
color: var(--color-ice-ink);
}
/* Bottom tab bar -- design doc: "bg-white border-t border-line pt-2 pb-[26px]
(member)... four equal items, 48px tall". pb uses the safe-area inset instead of
the doc's fixed 26px, which was standing in for it. */
.tab-bar {
flex-shrink: 0;
background: #fff;
border-top: 1px solid var(--color-line);
padding: 8px 8px max(env(safe-area-inset-bottom), 26px);
display: flex;
}
.tab-bar-item {
flex: 1;
height: 48px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
color: var(--color-dim);
}
.tab-bar-item-active {
color: var(--color-club);
}
.tab-bar-label {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* --- Buttons ------------------------------------------------------------------ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 46px; /* design doc: "height 46px mobile" */
padding-inline: 1.25rem;
border-radius: 0.5rem;
font-family: var(--font-display);
font-weight: 800;
font-size: 1rem;
letter-spacing: 0.1em;
text-transform: uppercase;
border: none;
cursor: pointer;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-club);
color: #fff;
}
.btn-dark {
background: var(--color-ink);
color: #fff;
}
.btn-positive {
background: var(--color-ok);
color: #fff;
}
.btn-secondary {
background: #fff;
border: 1.5px solid var(--color-stroke);
color: var(--color-ink);
}
.btn-ghost-on-dark {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
/* --- Status pills --------------------------------------------------------------- */
.pill {
border-radius: 999px;
padding: 5px 10px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
display: inline-flex;
align-items: center;
}
.pill-ok {
background: var(--color-ok-bg);
color: var(--color-ok-text);
border: 1px solid var(--color-ok-border);
}
.pill-danger {
background: var(--color-danger-bg);
color: var(--color-club-dark);
border: 1px solid var(--color-danger-border);
}
.pill-warn {
background: var(--color-warn-bg);
color: var(--color-warn-text);
border: 1px solid var(--color-warn-border);
}
.pill-info {
background: var(--color-info-bg);
color: var(--color-info-text);
border: 1px solid var(--color-info-border);
}
.pill-neutral {
background: var(--color-rule);
color: #4a5566;
border: 1px solid #dde1e7;
}
.pill-referee {
background: var(--color-referee-bg);
color: var(--color-referee-text);
border: 1px solid var(--color-referee-border);
}
/* --- Person switcher chips (Home's own -- see mobile/templates/mobile/home.html) --- */
.person-chip {
display: flex;
align-items: center;
gap: 6px;
background: #fff;
border: 1.5px solid var(--color-line);
border-radius: 999px;
padding: 4px 10px 4px 4px;
color: var(--color-muted);
flex-shrink: 0;
}
.person-chip-active {
border-color: var(--color-ink);
color: var(--color-ink);
}
.person-chip-avatar {
width: 22px;
height: 22px;
border-radius: 999px;
background: var(--color-stroke);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 800;
font-size: 0.6875rem;
flex-shrink: 0;
}
.person-chip-active .person-chip-avatar {
background: var(--color-club);
}
.person-chip-label {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.8125rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* --- Cards ---------------------------------------------------------------------- */
.m-card {
background: #fff;
border: 1px solid var(--color-line);
border-radius: var(--radius-box);
}
.m-card-dark {
background: var(--color-ink);
color: #fff;
border-radius: var(--radius-box);
}
/* --- File input ------------------------------------------------------------------
The bare native control (a browser-chrome "Choose Files" button + status text)
clashes with every other input on the page, which are all styled boxes -- same
fix as management's own .file-input (assets/management.css), recolored for the
mobile palette. ::file-selector-button needs `appearance: none` first or Safari
keeps drawing its own bezeled button regardless of the rest of this.
Height comes from padding-block, not an explicit height -- the button is shadow
content Safari won't vertically center via align-items no matter what, so it
floats at the top of any extra box height instead of the middle. Sizing the box
around the button's own height (padding-block split evenly: 2.75rem target box -
2 * 1px border - 1.75rem button, halved) keeps the button flush with no leftover
space for Safari to mis-center it into (same fix, same reasoning, as management's
own .file-input's own comment). */
.m-file-input {
display: flex;
align-items: center;
padding-block: 0.4375rem;
width: 100%;
border: 1px solid var(--color-stroke);
border-radius: 0.5rem;
background: var(--color-paper);
padding-inline: 0.75rem;
color: var(--color-dim);
font-size: 0.9375rem;
}
.m-file-input::file-selector-button,
.m-file-input::-webkit-file-upload-button {
appearance: none;
-webkit-appearance: none;
height: 1.75rem;
padding-inline: 0.75rem;
margin-right: 0.75rem;
border: none;
border-radius: 0.375rem;
background: var(--color-ink);
color: #fff;
font-family: var(--font-display);
font-weight: 800;
font-size: 0.6875rem;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transition: opacity 0.15s ease;
}
.m-file-input:hover::file-selector-button,
.m-file-input:hover::-webkit-file-upload-button {
opacity: 0.85;
}
.m-file-input:focus-within {
outline: 2px solid var(--color-club);
outline-offset: -1px;
border-color: var(--color-club);
}
/* --- Coach mode shell (C1-C6) ---------------------------------------------------
Dark ink header (never club-themed) with the role switcher's own active segment
in ice-blue (see .role-switcher-item-active-ice above) -- that's the mode's
signature accent, not the header background itself. .coach-sheet sits flush
below it, edge to edge like the member shell's own header/body join -- no
rounded overlap. */
.coach-header {
padding: max(env(safe-area-inset-top), 14px) 16px 22px;
background: var(--color-ink);
color: #fff;
flex-shrink: 0;
}
.coach-sheet {
background: var(--color-paper);
flex: 1;
overflow-y: auto;
}
.coach-tab-bar {
flex-shrink: 0;
position: relative; /* anchors the "+" action's popup menu */
background: var(--color-ink);
border-top: 1px solid var(--color-hairline);
padding: 8px 8px max(env(safe-area-inset-bottom), 26px);
display: flex;
}
.coach-tab-bar-item {
flex: 1;
height: 48px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
color: var(--color-on-dark);
}
.coach-tab-bar-item-active {
color: var(--color-ice);
}
/* The tab bar's own "+" action -- raised above the bar line in a solid ice
circle so it reads as the one prominent action among otherwise-equal nav
items, same idea as a FAB parked inside a bottom bar. */
.coach-tab-bar-add {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
}
.coach-tab-bar-add-button {
width: 48px;
height: 48px;
margin-top: -20px;
border-radius: 999px;
background: var(--color-ice);
color: var(--color-ice-ink);
display: flex;
align-items: center;
justify-content: center;
border: 4px solid var(--color-ink);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}
}

View File

@@ -1,8 +1,8 @@
"""Force MFA enrolment for privileged users.
Anyone who can change other people's data must have a second factor: Django
staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN or EDITOR)
in *any* club. Regular members may enrol, but aren't forced to.
staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN, EDITOR, or
MEMBER_ADMIN) in *any* club. Regular members may enrol, but aren't forced to.
Enrolled users are challenged for their second factor by allauth at login; this
middleware only handles the other half — a privileged user who has never
@@ -21,7 +21,7 @@ from club.models import ClubRole
#: under DEBUG — without it, live reload dies on the enrolment page itself.
EXEMPT_PREFIXES = ("/accounts/", "/static/", "/media/", "/__reload__/")
ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)
ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR, ClubRole.Roles.MEMBER_ADMIN)
def mfa_required_for(user) -> bool:

View File

@@ -1,5 +1,6 @@
import re
import uuid
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
from allauth.core import context
@@ -7,8 +8,10 @@ from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.core import mail
from django.db import IntegrityError
from django.http import HttpResponse
from django.template.loader import render_to_string
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse
@@ -145,8 +148,10 @@ class WebAuthnRelyingPartyTests(TestCase):
class MFARequirementTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
@classmethod
def setUpTestData(cls):
# Read-only for every test here: each one brings its own user and role.
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def make_user(self, email, **kwargs):
return User.objects.create_user(email=email, password="pw-secret-123", **kwargs)
@@ -232,9 +237,6 @@ class AdminLoginRoutingTests(TestCase):
# The original destination survives the hop (percent-encoded).
self.assertEqual(parse_qs(redirect.query)["next"], ["/admin/"])
def test_allauth_login_page_loads(self):
self.assertEqual(self.client.get(reverse("account_login")).status_code, 200)
class AuthFormRenderingTests(TestCase):
"""Every allauth form must actually render its fields.
@@ -252,14 +254,104 @@ class AuthFormRenderingTests(TestCase):
def test_the_password_reset_form_renders_its_fields(self):
self.assertContains(self.client.get(reverse("account_reset_password")), 'name="email"')
def test_the_signup_form_renders_its_fields(self):
self.assertContains(self.client.get(reverse("account_signup")), 'name="password1"')
def test_self_registration_is_closed(self):
# A club has no reason to let a stranger create an account: they're made by
# an admin, by the family-registration form, or by an approved parent claim
# (members/views.py). The route is shadowed rather than removed so that the
# `account_signup` name allauth's own templates reverse still resolves.
response = self.client.get(reverse("account_signup"))
self.assertEqual(response.status_code, 403)
self.assertNotContains(response, 'name="password1"', status_code=403)
@override_settings(
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
)
class PasswordResetEmailTests(TestCase):
"""allauth auto-attaches templates/account/email/password_reset_key_message.html
as an HTML alternative next to its own .txt body -- see
allauth.account.adapter.DefaultAccountAdapter.render_mail, which looks for
"<prefix>_message.<ext>" for ext in [TEMPLATE_EXTENSION ("html", unset here), "txt"].
No Python override needed; this only exercises the template."""
def test_the_html_email_carries_the_clubs_branding_on_a_club_subdomain(self):
Club.objects.create(name="Ajax United", slug="ajax-united")
User.objects.create_user(email="parent@example.com", password="pw-secret-123")
response = self.client.post(reverse("account_reset_password"), {"email": "parent@example.com"}, HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 302)
self.assertEqual(len(mail.outbox), 1)
[(html_body, mimetype)] = mail.outbox[0].alternatives
self.assertEqual(mimetype, "text/html")
self.assertIn("Ajax United", html_body)
self.assertIn("/accounts/password/reset/key/", html_body)
def test_the_html_email_falls_back_to_rosterchief_branding_off_a_club_subdomain(self):
# The base domain has no tenant, e.g. a platform control-panel user
# resetting their own password -- club.context_processors.branding
# leaves `club` unset there, so the template must not assume one.
User.objects.create_user(email="admin@example.com", password="pw-secret-123")
self.client.post(reverse("account_reset_password"), {"email": "admin@example.com"})
self.assertEqual(len(mail.outbox), 1)
[(html_body, mimetype)] = mail.outbox[0].alternatives
self.assertEqual(mimetype, "text/html")
# The wordmark splits "Chief" into its own <span> for the sky-blue accent
# (matching templates/_platform_base.html), so the two halves aren't
# contiguous text in the raw HTML -- check for both rather than the
# combined word.
self.assertIn("Roster", html_body)
self.assertIn("Chief", html_body)
def test_the_password_reset_key_html_template_renders_directly(self):
# Mirrors authentication.tests.AuthFormRenderingTests' direct-render
# style: exercises the template's own branching (club vs. none, logo
# vs. initials) without going through the full request/email pipeline.
club = Club.objects.create(name="Ajax United", slug="ajax-united", primary_color="#1e40af")
with_club = render_to_string("account/email/password_reset_key_message.html", {"club": club, "password_reset_url": "https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/"})
self.assertIn("Ajax United", with_club)
self.assertIn("AU", with_club) # initials fallback: no logo set
self.assertIn("https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/", with_club)
def test_the_initials_badge_text_contrasts_against_the_fallback_colour(self):
# Club has no secondary_color, so the badge falls back to #ec4899 --
# black text (contrast_color("#ec4899")) reads far better on it than
# the white the template used to hardcode. See
# club/templatetags/club_email.py::contrast_color.
club = Club.objects.create(name="Ajax United", slug="ajax-united")
rendered = render_to_string("account/email/password_reset_key_message.html", {"club": club, "password_reset_url": "https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/"})
self.assertIn("background-color:#ec4899", rendered)
self.assertIn("color:#000000", rendered)
def test_the_reset_button_text_contrasts_against_the_no_club_fallback_colour(self):
# No club at all (the base-domain flow) -- the button falls back to
# #0ea5e9, RosterChief's own sky blue, which also needs black text
# for a passing contrast ratio, not the white previously hardcoded.
rendered = render_to_string("account/email/password_reset_key_message.html", {"club": None, "password_reset_url": "https://rosterchief.app/accounts/password/reset/key/abc-def/", "current_site": None})
self.assertIn("background-color:#0ea5e9", rendered)
self.assertIn("color:#000000", rendered)
without_club = render_to_string("account/email/password_reset_key_message.html", {"club": None, "password_reset_url": "https://rosterchief.app/accounts/password/reset/key/abc-def/", "current_site": SimpleNamespace(name="rosterchief.app")})
self.assertIn("Roster", without_club)
self.assertIn("Chief", without_club)
self.assertIn("https://rosterchief.app/accounts/password/reset/key/abc-def/", without_club)
class TwoFactorPageTests(TestCase):
@classmethod
def setUpTestData(cls):
enrol_mfa(User.objects.create_user(email="mfa@example.com", password="pw-secret-123"))
def setUp(self):
user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
enrol_mfa(user)
# The test client is per-test, so the sign-in itself cannot be hoisted.
# Password accepted, second factor still owed: this is the 2FA challenge page.
self.response = self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
@@ -276,6 +368,20 @@ class TwoFactorPageTests(TestCase):
self.assertEqual(otp.count("<span></span>"), 6)
def test_the_boxes_are_wrapped_in_a_label_so_tapping_focuses_the_input(self):
# daisyUI's overlaid otp input carries `pointer-events: none` (so clicks land on the
# boxes, not a naked input) — which also means a tap on the boxes never reaches the
# input directly. A <label for> is what closes that gap: browsers focus a labelled
# control on click regardless of the control's own pointer-events. Without this
# wrapper the field cannot be entered on a touchscreen, which has no Tab key to fall
# back on.
html = self.response.content.decode()
label_start = html.index('<label class="contents"')
otp_start = html.index('class="otp otp-lg"')
self.assertLess(label_start, otp_start)
self.assertIn('for="id_code"', html[label_start : label_start + 60])
def test_the_otp_field_has_no_placeholder(self):
# allauth sets placeholder="Code"; inside the boxes it reads as a typed-in code.
self.assertNotContains(self.response, 'placeholder="Code"')
@@ -299,8 +405,11 @@ class MfaPageTests(TestCase):
"""Every MFA screen must render. They are built from allauth's `element` primitives,
so styling lives in the element overrides rather than in eight page templates."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
def setUp(self):
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
# A real password login (not force_login) so allauth counts it as a recent
# authentication and doesn't bounce the sensitive pages to reauthenticate.
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
@@ -347,8 +456,11 @@ class ActionBarTests(TestCase):
Keying the bar on it hid the button on exactly the pages that are nothing but a button.
"""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
def setUp(self):
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
def test_the_sign_out_page_has_its_button(self):
@@ -367,8 +479,11 @@ class ActionBarTests(TestCase):
class SignOutPageTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
def setUp(self):
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
self.client.force_login(self.user)
self.response = self.client.get(reverse("account_logout"))
@@ -393,8 +508,11 @@ class SignOutPageTests(TestCase):
class ChangePasswordPageTests(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
def setUp(self):
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
self.response = self.client.get(reverse("account_change_password"))
@@ -424,8 +542,11 @@ class MfaButtonIconTests(TestCase):
ranked: View is primary, Download and Generate are outline. Generate throws away the
codes you already have, so it must not read as the obvious thing to click."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
def setUp(self):
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
# Sign in *before* enrolling: a user who already holds a second factor is stopped at
# the 2FA challenge and never reaches these pages.
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)

View File

@@ -1,32 +1,32 @@
from django.contrib import admin
from .models import Due, DuePayment, Subscription, Tier, TierPrice
from .models import Due, DuePayment, Plan, PlanPrice, Subscription
class TierPriceInline(admin.TabularInline):
model = TierPrice
class PlanPriceInline(admin.TabularInline):
model = PlanPrice
extra = 0
@admin.register(Tier)
class TierAdmin(admin.ModelAdmin):
list_display = ["name", "is_active"]
list_filter = ["is_active"]
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
list_display = ["name", "duration_months", "renewal_lead_days", "grace_days", "is_trial", "is_active"]
list_filter = ["is_active", "is_trial"]
search_fields = ["name"]
prepopulated_fields = {"slug": ["name"]}
inlines = [TierPriceInline]
inlines = [PlanPriceInline]
@admin.register(TierPrice)
class TierPriceAdmin(admin.ModelAdmin):
list_display = ["tier", "amount", "active_from"]
list_filter = ["tier"]
@admin.register(PlanPrice)
class PlanPriceAdmin(admin.ModelAdmin):
list_display = ["plan", "amount", "active_from"]
list_filter = ["plan"]
@admin.register(Subscription)
class SubscriptionAdmin(admin.ModelAdmin):
list_display = ["club", "tier", "auto_archive"]
list_filter = ["tier", "auto_archive"]
list_display = ["club", "plan", "auto_renew", "auto_archive"]
list_filter = ["plan", "auto_renew", "auto_archive"]
search_fields = ["club__name"]
@@ -38,11 +38,13 @@ class DuePaymentInline(admin.TabularInline):
@admin.register(Due)
class DueAdmin(admin.ModelAdmin):
list_display = ["club", "tier", "period_start", "period_end", "amount", "amount_paid", "status"]
list_filter = ["status", "tier"]
list_display = ["club", "plan", "period_start", "period_end", "grace_until", "amount", "amount_paid", "status"]
list_filter = ["status", "plan"]
search_fields = ["club__name"]
# Money is settled by the billing service, which re-derives these from the payments.
readonly_fields = ["amount_paid", "status", "paid_at"]
# period_end/grace_until are snapshots taken when the period opened -- editing a plan
# afterwards must not move them, and neither should a hand edit here.
readonly_fields = ["amount_paid", "status", "paid_at", "period_end", "grace_until"]
inlines = [DuePaymentInline]

View File

@@ -27,7 +27,7 @@ class Command(MaintenanceAwareCommand):
for due in overdue:
days = (today - due.grace_until).days
self.stdout.write(f"{due.club}{due.tier}, {due.balance} owed, grace ended {due.grace_until} ({days} day{'s'[: days != 1]} ago)")
self.stdout.write(f"{due.club}{due.plan}, {due.balance} owed, grace ended {due.grace_until} ({days} day{'s'[: days != 1]} ago)")
if not options["commit"]:
self.stdout.write(self.style.WARNING(f"\nDry run: {len(overdue)} club(s) would be archived. Re-run with --commit to do it."))

View File

@@ -0,0 +1,56 @@
"""Issue the next billing period for clubs whose current one is running out.
Unlike archive_overdue_clubs, this ACTS by default and only previews with --dry-run. The
asymmetry is deliberate and runs the other way: archiving switches off a paying customer, so
not acting is the safe failure. Here, not acting means a club keeps using the platform for
free — and because nothing is owed, no dashboard number goes red and the archive job never
fires either. A missed renewal is silent, and silence is the expensive failure.
"""
from django.core.management.base import CommandError
from billing.services import BillingError
from billing.services.dues import renew, subscriptions_due_for_renewal
from features.commands import MaintenanceAwareCommand
class Command(MaintenanceAwareCommand):
help = "Open the next billing period for clubs whose current period ends soon."
def add_arguments(self, parser):
parser.add_argument("--dry-run", action="store_true", help="Report what would be issued, and issue nothing.")
parser.add_argument("--lead-days", type=int, default=None, help="Override every plan's own renewal lead. Left off, each plan uses its own.")
def handle(self, *args, **options):
due_for_renewal = subscriptions_due_for_renewal(lead_days=options["lead_days"])
if not due_for_renewal:
self.stdout.write(self.style.SUCCESS("Nothing to renew."))
return
failures = []
for subscription in due_for_renewal:
club = subscription.club
if options["dry_run"]:
self.stdout.write(f"would renew {club}{subscription.plan}, current period ends {subscription.latest_period_end or 'never opened'}")
continue
try:
due = renew(subscription)
except BillingError as error:
# One unpriced plan must not stop every other club from being billed.
failures.append(f"{club}: {error}")
self.stdout.write(self.style.ERROR(f"{club}{error}"))
continue
self.stdout.write(self.style.SUCCESS(f"{club}{due.period_start} to {due.period_end}, {due.amount} ({due.invoice.number})"))
if options["dry_run"]:
self.stdout.write(self.style.WARNING(f"\nDry run: {len(due_for_renewal)} club(s) would be renewed."))
return
if failures:
# Non-zero, so cron mails you: a club that could not be billed is revenue quietly
# not being collected.
raise CommandError(f"{len(failures)} club(s) could not be renewed:\n " + "\n ".join(failures))

View File

@@ -0,0 +1,64 @@
"""Email club admins about platform fees they owe.
Reports by default and only sends with --commit, the same posture as archive_overdue_clubs
and for a related reason: this one mails paying customers, and a bad clock, a bad import or a
rehearsal against production data should cost you a confusing dry-run listing rather than a
mailshot you cannot recall.
Idempotent across runs by design -- one reminder per due per escalation level, tracked on
Due.last_reminder_level -- so a daily cron does not produce a daily email.
"""
from django.core.management.base import CommandError
from billing.services.reminders import reminders_to_send, send_reminder
from club.models import Club
from features.commands import MaintenanceAwareCommand
class Command(MaintenanceAwareCommand):
help = "Email club admins about outstanding platform fees (dry run unless --commit)."
def add_arguments(self, parser):
parser.add_argument("--commit", action="store_true", help="Actually send. Without this the command only reports.")
parser.add_argument("--force", action="store_true", help="Re-send even where a reminder already went out at this level.")
def handle(self, *args, **options):
clubs = Club.objects.active().select_related("subscription", "subscription__plan").order_by("name")
results = reminders_to_send(clubs, force=options["force"])
sendable = [result for result in results if result.sent]
skipped = [result for result in results if not result.sent]
if not results:
self.stdout.write(self.style.SUCCESS("Nothing owing. No reminders to send."))
return
for result in skipped:
style = self.style.WARNING if result.recipients else self.style.ERROR
self.stdout.write(style(f"{result.club} — skipped: {result.skipped_reason}"))
for result in sendable:
self.stdout.write(f"{result.notice.level:>7} · {result.club} — €{result.notice.amount_outstanding} owed, {result.notice.days_until_archive}d to archive → {', '.join(result.recipients)}")
if not options["commit"]:
self.stdout.write(self.style.WARNING(f"\nDry run: {len(sendable)} reminder(s) would be sent. Re-run with --commit to send them."))
return
failures = []
for result in sendable:
try:
send_reminder(result.club, result.notice, recipients=result.recipients)
except OSError as error:
# One bad address or a momentary SMTP failure must not stop the rest of the
# run: the clubs further down the list are the ones closest to being archived.
failures.append(f"{result.club}: {error}")
self.stdout.write(self.style.ERROR(f"{result.club}{error}"))
sent = len(sendable) - len(failures)
self.stdout.write(self.style.SUCCESS(f"\nSent {sent} reminder(s)."))
if failures:
# Non-zero so cron mails you: a club that could not be warned is a club that gets
# archived without notice.
raise CommandError(f"{len(failures)} reminder(s) could not be sent:\n " + "\n ".join(failures))

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-07-14 22:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='subscription',
name='auto_renew',
field=models.BooleanField(default=True, help_text='Issue the next period automatically before this one ends. Off means you invoice this club by hand.', verbose_name='auto renew'),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0.6 on 2026-08-04 10:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0002_subscription_auto_renew'),
('club', '0017_club_season_duration_months_club_season_start'),
]
operations = [
migrations.AddField(
model_name='due',
name='is_trial',
field=models.BooleanField(default=False, help_text="This period was opened as a trial. A durable marker on the row itself -- the subscription's own trial fields are cleared once it converts.", verbose_name='trial period'),
),
migrations.AddField(
model_name='subscription',
name='post_trial_tier',
field=models.ForeignKey(blank=True, help_text='The plan this club switches to automatically once its trial ends.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='billing.tier', verbose_name='post-trial tier'),
),
migrations.AddField(
model_name='subscription',
name='trial_ends_at',
field=models.DateField(blank=True, help_text='Set while this club is on a trial. The tier switches to post_trial_tier the next time a period is opened after this date.', null=True, verbose_name='trial ends at'),
),
migrations.AddConstraint(
model_name='subscription',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('post_trial_tier__isnull', True), ('trial_ends_at__isnull', True)), models.Q(('post_trial_tier__isnull', False), ('trial_ends_at__isnull', False)), _connector='OR'), name='trial_fields_set_together'),
),
]

View File

@@ -0,0 +1,34 @@
"""Rename Tier -> Plan, and every field that pointed at it.
Hand-written rather than generated. `makemigrations` only detects a rename by asking
interactively; run non-interactively it emits DeleteModel + CreateModel instead, which drops
every price, subscription and due in the table. RenameModel/RenameField preserve the data.
The two RemoveConstraints have to come FIRST. Both constraints name fields this migration is
about to rename (`tier`, `post_trial_tier`), and SQLite implements a rename by rebuilding the
table -- which re-renders every constraint on it. Left in place, the rebuild tries to emit a
constraint over a column that no longer exists under that name and dies with
FieldDoesNotExist. 0005 adds them back under the new field names.
Split from the field additions (0005) so this migration is pure renaming and can be read --
and if necessary reversed -- without any other change mixed into it.
"""
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("billing", "0003_due_is_trial_subscription_post_trial_tier_and_more"),
]
operations = [
migrations.RemoveConstraint(model_name="tierprice", name="unique_tier_price_per_start_date"),
migrations.RemoveConstraint(model_name="subscription", name="trial_fields_set_together"),
migrations.RenameModel(old_name="Tier", new_name="Plan"),
migrations.RenameModel(old_name="TierPrice", new_name="PlanPrice"),
migrations.RenameField(model_name="planprice", old_name="tier", new_name="plan"),
migrations.RenameField(model_name="subscription", old_name="tier", new_name="plan"),
migrations.RenameField(model_name="subscription", old_name="post_trial_tier", new_name="post_trial_plan"),
migrations.RenameField(model_name="due", old_name="tier", new_name="plan"),
]

View File

@@ -0,0 +1,96 @@
# Generated by Django 6.0.6 on 2026-08-08 16:30
import django.core.validators
import django.db.models.deletion
import django.db.models.expressions
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0004_rename_tier_to_plan'),
('club', '0020_sponsor_logo_height_sponsor_logo_width'),
]
operations = [
migrations.AlterModelOptions(
name='plan',
options={'ordering': ['name'], 'verbose_name': 'plan', 'verbose_name_plural': 'plans'},
),
migrations.AlterModelOptions(
name='planprice',
options={'ordering': ['plan__name', '-active_from'], 'verbose_name': 'plan price', 'verbose_name_plural': 'plan prices'},
),
migrations.AddField(
model_name='plan',
name='duration_months',
field=models.PositiveSmallIntegerField(default=12, help_text='How long one billing period runs.', validators=[django.core.validators.MinValueValidator(1)], verbose_name='duration (months)'),
),
migrations.AddField(
model_name='plan',
name='grace_days',
field=models.PositiveSmallIntegerField(default=30, help_text='Days after a period starts before an unpaid club is archived.', verbose_name='grace (days)'),
),
migrations.AddField(
model_name='plan',
name='is_trial',
field=models.BooleanField(default=False, help_text='Offered as a trial rather than as a paid plan. A trial converts to the plan chosen on the subscription once it runs out.', verbose_name='trial plan'),
),
migrations.AddField(
model_name='plan',
name='renewal_lead_days',
field=models.PositiveSmallIntegerField(default=30, help_text="Raise the next period's invoice this many days before that period starts.", verbose_name='renewal lead (days)'),
),
migrations.AlterField(
model_name='due',
name='grace_until',
field=models.DateField(blank=True, help_text='Past this date an unpaid club is archived. Measured from the period start, not its end.', verbose_name='grace until'),
),
migrations.AlterField(
model_name='due',
name='plan',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dues', to='billing.plan', verbose_name='plan'),
),
migrations.AlterField(
model_name='plan',
name='is_active',
field=models.BooleanField(default=True, help_text='Inactive plans keep billing existing subscriptions but cannot be chosen for new ones.', verbose_name='active'),
),
migrations.AlterField(
model_name='planprice',
name='plan',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='billing.plan', verbose_name='plan'),
),
migrations.AlterField(
model_name='subscription',
name='plan',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='billing.plan', verbose_name='plan'),
),
migrations.AlterField(
model_name='subscription',
name='post_trial_plan',
field=models.ForeignKey(blank=True, help_text='The plan this club switches to automatically once its trial ends.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='billing.plan', verbose_name='post-trial plan'),
),
migrations.AlterField(
model_name='subscription',
name='trial_ends_at',
field=models.DateField(blank=True, help_text='Set while this club is on a trial. The plan switches to the post-trial plan the next time a period is opened after this date.', null=True, verbose_name='trial ends at'),
),
migrations.AddConstraint(
model_name='plan',
constraint=models.CheckConstraint(condition=models.Q(('renewal_lead_days__lt', django.db.models.expressions.CombinedExpression(models.F('duration_months'), '*', models.Value(28)))), name='renewal_lead_shorter_than_duration'),
),
migrations.AddConstraint(
model_name='plan',
constraint=models.CheckConstraint(condition=models.Q(('grace_days__lte', django.db.models.expressions.CombinedExpression(models.F('duration_months'), '*', models.Value(28)))), name='grace_no_longer_than_duration'),
),
migrations.AddConstraint(
model_name='planprice',
constraint=models.UniqueConstraint(fields=('plan', 'active_from'), name='unique_plan_price_per_start_date'),
),
migrations.AddConstraint(
model_name='subscription',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('post_trial_plan__isnull', True), ('trial_ends_at__isnull', True)), models.Q(('post_trial_plan__isnull', False), ('trial_ends_at__isnull', False)), _connector='OR'), name='trial_fields_set_together'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.6 on 2026-08-08 16:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0005_alter_plan_options_alter_planprice_options_and_more'),
]
operations = [
migrations.AddField(
model_name='due',
name='last_reminder_level',
field=models.CharField(blank=True, editable=False, max_length=20, verbose_name='last reminder level'),
),
migrations.AddField(
model_name='due',
name='last_reminder_sent_at',
field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='last reminder sent at'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-08 17:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0006_due_last_reminder_level_due_last_reminder_sent_at'),
]
operations = [
migrations.AddField(
model_name='plan',
name='deleted_at',
field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='deleted at'),
),
]

View File

@@ -9,9 +9,12 @@ by one, and a tenant-scoped manager would be exactly the wrong default.
from datetime import date, timedelta
from decimal import Decimal
from dateutil import relativedelta
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import F, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -19,35 +22,112 @@ from rosterchief.base import UUIDModel, unique_slugify
ZERO = Decimal("0.00")
#: A club stays live for six weeks past the end of an unpaid period before it is archived.
GRACE_DAYS = 45
#: Conservative lower bound on the number of days in a month, used to express the plan's
#: clock invariants as CheckConstraints — month arithmetic is not available in SQL, and
#: under-counting is the safe direction for a guard rail.
DAYS_PER_MONTH_FLOOR = 28
# Defaults for a new plan, chosen to reproduce the annual billing the platform started with.
DEFAULT_DURATION_MONTHS = 12
DEFAULT_RENEWAL_LEAD_DAYS = 30
DEFAULT_GRACE_DAYS = 30
def add_one_year(day: date) -> date:
"""The day one year on. 29 February has no counterpart in a common year, so it falls
back to the 28th rather than raising."""
try:
return day.replace(year=day.year + 1)
except ValueError:
return day.replace(year=day.year + 1, day=28)
def add_months(day: date, months: int) -> date:
return day + relativedelta.relativedelta(months=months)
class Tier(UUIDModel):
"""A price band. The price itself lives in TierPrice, which is dated."""
class PlanQuerySet(models.QuerySet):
def visible(self):
"""Excludes soft-deleted plans -- see billing.services.plans.delete_plan.
Opt-in, same shape as club.models.ClubManager.active(): the default manager stays
unfiltered (Django admin, and anything reading historical data, sees everything),
and every picker/listing a platform admin actually chooses from calls this.
"""
return self.filter(deleted_at__isnull=True)
class Plan(UUIDModel):
"""What a club is billed on: a duration, a set of clocks, and a dated price.
The price itself lives in PlanPrice, which is dated. The three day/month numbers here
are the plan's *clocks*, and they are named for what they measure from — see BILLING.md
§3, because confusing them is the easy mistake:
* ``duration_months`` — how long a period runs, from its start.
* ``renewal_lead_days`` — how far BEFORE a period starts its invoice is raised.
* ``grace_days`` — how long AFTER a period starts it may remain unpaid.
"""
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True)
description = models.TextField(_("description"), blank=True)
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive tiers keep billing existing subscriptions but cannot be chosen for new ones."))
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive plans keep billing existing subscriptions but cannot be chosen for new ones."))
duration_months = models.PositiveSmallIntegerField(_("duration (months)"), default=DEFAULT_DURATION_MONTHS, validators=[MinValueValidator(1)], help_text=_("How long one billing period runs."))
renewal_lead_days = models.PositiveSmallIntegerField(_("renewal lead (days)"), default=DEFAULT_RENEWAL_LEAD_DAYS, help_text=_("Raise the next period's invoice this many days before that period starts."))
grace_days = models.PositiveSmallIntegerField(_("grace (days)"), default=DEFAULT_GRACE_DAYS, help_text=_("Days after a period starts before an unpaid club is archived."))
is_trial = models.BooleanField(
_("trial plan"),
default=False,
help_text=_("Offered as a trial rather than as a paid plan. A trial converts to the plan chosen on the subscription once it runs out."),
)
# Not user-editable: set by billing.services.plans.delete_plan. Due.plan is PROTECT, so
# a plan that has ever billed anyone can never actually be removed -- deleting it hides
# it (and clears every club currently on it) instead, so past invoices still say what
# they were billed under. See that module's docstring for the full reasoning.
deleted_at = models.DateTimeField(_("deleted at"), null=True, blank=True, editable=False)
objects = PlanQuerySet.as_manager()
class Meta:
verbose_name = _("tier")
verbose_name_plural = _("tiers")
verbose_name = _("plan")
verbose_name_plural = _("plans")
ordering = ["name"]
constraints = [
# Lead longer than the period itself would raise the next invoice before the
# current period had even started, and periods would run away from the calendar.
models.CheckConstraint(
condition=Q(renewal_lead_days__lt=F("duration_months") * DAYS_PER_MONTH_FLOOR),
name="renewal_lead_shorter_than_duration",
),
# Grace longer than the period means the next period is issued while this one is
# still in grace: unpaid periods stack and the club is never archived.
models.CheckConstraint(
condition=Q(grace_days__lte=F("duration_months") * DAYS_PER_MONTH_FLOOR),
name="grace_no_longer_than_duration",
),
]
def __str__(self):
return self.name
@property
def is_deleted(self) -> bool:
return self.deleted_at is not None
def clean(self):
"""The same two invariants the CheckConstraints enforce, as form errors.
Without this a form would hand the database an impossible plan and get back an
IntegrityError -- a 500 rather than "that lead is longer than the period".
"""
if not self.duration_months:
return
period_days = self.duration_months * DAYS_PER_MONTH_FLOOR
errors = {}
if self.renewal_lead_days is not None and self.renewal_lead_days >= period_days:
errors["renewal_lead_days"] = _("Must be shorter than the period itself (under %(days)s days for this duration), or the next invoice would be raised before the current period starts.") % {"days": period_days}
if self.grace_days is not None and self.grace_days > period_days:
errors["grace_days"] = _("Must not be longer than the period itself (at most %(days)s days for this duration), or unpaid periods stack up and the club is never archived.") % {"days": period_days}
if errors:
raise ValidationError(errors)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = unique_slugify(self, self.name)
@@ -56,7 +136,7 @@ class Tier(UUIDModel):
def price_on(self, day: date | None = None) -> Decimal | None:
"""The price in force on ``day`` — the latest one that had started by then.
None means the tier had no price yet on that date. Callers must treat that as
None means the plan had no price yet on that date. Callers must treat that as
"cannot bill", never as free.
"""
day = day or timezone.localdate()
@@ -65,52 +145,68 @@ class Tier(UUIDModel):
return price.amount if price else None
class TierPrice(UUIDModel):
"""A dated price for a tier.
class PlanPrice(UUIDModel):
"""A dated price for a plan.
Dated rather than keyed by year: a rate change is one new row with a future
``active_from``, and every period already opened keeps the amount it was billed at.
"""
tier = models.ForeignKey(Tier, on_delete=models.CASCADE, related_name="prices", verbose_name=_("tier"))
plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name="prices", verbose_name=_("plan"))
active_from = models.DateField(_("active from"), help_text=_("Periods opening on or after this date are billed at this amount."))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
class Meta:
verbose_name = _("tier price")
verbose_name_plural = _("tier prices")
ordering = ["tier__name", "-active_from"]
verbose_name = _("plan price")
verbose_name_plural = _("plan prices")
ordering = ["plan__name", "-active_from"]
constraints = [
models.UniqueConstraint(fields=["tier", "active_from"], name="unique_tier_price_per_start_date"),
models.UniqueConstraint(fields=["plan", "active_from"], name="unique_plan_price_per_start_date"),
]
def __str__(self):
return f"{self.tier}{self.amount} from {self.active_from}"
return f"{self.plan}{self.amount} from {self.active_from}"
class Subscription(UUIDModel):
"""A club's current plan. The periods it is billed for are Dues."""
club = models.OneToOneField("club.Club", on_delete=models.CASCADE, related_name="subscription", verbose_name=_("club"))
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("tier"))
plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("plan"))
auto_renew = models.BooleanField(_("auto renew"), default=True, help_text=_("Issue the next period automatically before this one ends. Off means you invoice this club by hand."))
auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period."))
notes = models.TextField(_("notes"), blank=True)
trial_ends_at = models.DateField(_("trial ends at"), null=True, blank=True, help_text=_("Set while this club is on a trial. The plan switches to the post-trial plan the next time a period is opened after this date."))
post_trial_plan = models.ForeignKey(Plan, on_delete=models.PROTECT, null=True, blank=True, related_name="+", verbose_name=_("post-trial plan"), help_text=_("The plan this club switches to automatically once its trial ends."))
class Meta:
verbose_name = _("subscription")
verbose_name_plural = _("subscriptions")
ordering = ["club__name"]
constraints = [
# Both set together or neither -- a trial with no target plan (or a target
# plan with no trial end date) is a half-configured state nothing should read.
models.CheckConstraint(
condition=Q(trial_ends_at__isnull=True, post_trial_plan__isnull=True) | Q(trial_ends_at__isnull=False, post_trial_plan__isnull=False),
name="trial_fields_set_together",
),
]
def __str__(self):
return f"{self.club}{self.tier}"
return f"{self.club}{self.plan}"
class Due(UUIDModel):
"""One billing period for one club.
``tier`` and ``amount`` are snapshots taken when the period opens, never read back
through the tier at display time: raise the price and last year's period must still say
``plan`` and ``amount`` are snapshots taken when the period opens, never read back
through the plan at display time: raise the price and last year's period must still say
what was actually charged. A live lookup would rewrite financial history.
``period_end`` and ``grace_until`` are snapshots for the same reason. They are stored as
*dates* rather than as the plan's duration/grace *numbers*, which is what makes editing a
plan afterwards leave every period already running exactly where it was.
"""
class Status(models.TextChoices):
@@ -124,18 +220,27 @@ class Due(UUIDModel):
OWING = (Status.UNPAID, Status.PARTIAL)
club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="dues", verbose_name=_("club"))
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="dues", verbose_name=_("tier"))
plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name="dues", verbose_name=_("plan"))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=ZERO, help_text=_("Kept in step with the payments by the billing service."))
period_start = models.DateField(_("period start"))
period_end = models.DateField(_("period end"), blank=True)
grace_until = models.DateField(_("grace until"), blank=True, help_text=_("Past this date an unpaid club is archived."))
grace_until = models.DateField(_("grace until"), blank=True, help_text=_("Past this date an unpaid club is archived. Measured from the period start, not its end."))
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.UNPAID)
paid_at = models.DateTimeField(_("paid at"), null=True, blank=True)
is_trial = models.BooleanField(_("trial period"), default=False, help_text=_("This period was opened as a trial. A durable marker on the row itself -- the subscription's own trial fields are cleared once it converts."))
# Reminders are sent once per escalation level, not once per run: the cron job runs daily,
# and a club that owes money for a month must not get thirty identical emails. Storing the
# level last sent (rather than a date) means an escalation always gets through, and nothing
# else does. See billing/services/reminders.py.
last_reminder_level = models.CharField(_("last reminder level"), max_length=20, blank=True, editable=False)
last_reminder_sent_at = models.DateTimeField(_("last reminder sent at"), null=True, blank=True, editable=False)
class Meta:
verbose_name = _("due")
verbose_name_plural = _("dues")
@@ -148,12 +253,14 @@ class Due(UUIDModel):
return f"{self.club}{self.period_start} to {self.period_end}"
def save(self, *args, **kwargs):
# A period runs a rolling year from its start and the grace hangs off its end.
# Derived here so no caller can open a period without them.
# A period runs for the plan's duration from its start, and the grace runs from that
# same start -- NOT from the period end. Measured from the end, a club would get the
# whole unpaid period plus the grace on top (~410 days on an annual plan) before
# anything switched it off. Derived here so no caller can open a period without them.
if not self.period_end:
self.period_end = add_one_year(self.period_start) - timedelta(days=1)
self.period_end = add_months(self.period_start, self.plan.duration_months) - timedelta(days=1)
if not self.grace_until:
self.grace_until = self.period_end + timedelta(days=GRACE_DAYS)
self.grace_until = self.period_start + timedelta(days=self.plan.grace_days)
super().save(*args, **kwargs)
@property
@@ -164,11 +271,21 @@ class Due(UUIDModel):
def is_owing(self) -> bool:
return self.status in self.OWING
def is_in_grace(self, today: date | None = None) -> bool:
"""The period has ended unpaid, but the club is not archivable yet."""
def is_issued_ahead(self, today: date | None = None) -> bool:
"""Billed and owing, but the period it covers has not started yet.
The gentlest of the three owing states: the invoice was raised during the plan's
renewal lead window, and nothing is late yet.
"""
today = today or timezone.localdate()
return self.is_owing and self.period_end < today <= self.grace_until
return self.is_owing and today < self.period_start
def is_in_grace(self, today: date | None = None) -> bool:
"""The period has started and is still unpaid, but is not archivable yet."""
today = today or timezone.localdate()
return self.is_owing and self.period_start <= today <= self.grace_until
def is_overdue(self, today: date | None = None) -> bool:
"""Unpaid past grace — this is what makes a club archivable."""
@@ -176,6 +293,12 @@ class Due(UUIDModel):
return self.is_owing and self.grace_until < today
def days_until_archive(self, today: date | None = None) -> int:
"""Days left before this period makes the club archivable. Negative once past."""
today = today or timezone.localdate()
return (self.grace_until - today).days
class DuePayment(UUIDModel):
"""Money received against a due.
@@ -210,7 +333,7 @@ class DuePayment(UUIDModel):
class Invoice(UUIDModel):
"""The bill for one period.
Only the number and the issue date are stored: the money, the tier and the dates are
Only the number and the issue date are stored: the money, the plan and the dates are
already frozen on the Due, so the PDF is rendered from those snapshots on demand. The
number, though, must be stable and gapless — it is the thing an accountant reconciles
against, so it is allocated once and never recomputed.

View File

@@ -6,22 +6,44 @@ from datetime import date, timedelta
from decimal import Decimal
from django.db import transaction
from django.db.models import Sum
from django.db.models import DateField, OuterRef, Subquery, Sum
from django.utils import timezone
from billing.models import ZERO, Due, DuePayment, Subscription, Tier
from billing.models import ZERO, Due, DuePayment, Plan, Subscription, add_months
from billing.services import BillingError
from billing.services.invoices import issue_invoice
def subscribe(club, tier: Tier, *, start: date | None = None, auto_archive: bool = True) -> Subscription:
"""Put a club on a tier and open its first period."""
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"tier": tier, "auto_archive": auto_archive})
def subscribe(club, plan: Plan, *, start: date | None = None, auto_archive: bool = True, auto_renew: bool = True) -> Subscription:
"""Put a club on a plan and open its first period."""
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"plan": plan, "auto_archive": auto_archive, "auto_renew": auto_renew})
open_period(club, start=start)
return subscription
@transaction.atomic
def start_trial(club, trial_plan: Plan, *, post_trial_plan: Plan, start: date | None = None, auto_renew: bool = True, auto_archive: bool = True) -> Due:
"""Put a club on a trial that switches itself to ``post_trial_plan`` the moment the trial
period is renewed -- see open_period()'s trial-conversion check.
The trial's length is the trial plan's own ``duration_months``: a 1-month and a 3-month
trial are two plans, not one plan plus a number passed at the call site.
Only for a club with no subscription yet -- converting an existing paying subscription
into a trial is a different, deliberately unsupported operation for now.
"""
if getattr(club, "subscription", None) is not None:
raise BillingError(f"{club} is already subscribed -- use Change plan instead.")
start = start or next_period_start(club)
trial_end = add_months(start, trial_plan.duration_months) - timedelta(days=1)
Subscription.objects.create(club=club, plan=trial_plan, trial_ends_at=trial_end, post_trial_plan=post_trial_plan, auto_renew=auto_renew, auto_archive=auto_archive)
return open_period(club, start=start, period_end=trial_end, is_trial=True)
def next_period_start(club, today: date | None = None) -> date:
"""Where the club's next period begins.
@@ -36,23 +58,40 @@ def next_period_start(club, today: date | None = None) -> date:
@transaction.atomic
def open_period(club, *, start: date | None = None, tier: Tier | None = None) -> Due:
"""Issue the next due for a club, snapshotting the tier and the price of the day."""
def open_period(club, *, start: date | None = None, plan: Plan | None = None, period_end: date | None = None, is_trial: bool = False) -> Due:
"""Issue the next due for a club, snapshotting the plan and the price of the day."""
subscription = getattr(club, "subscription", None)
tier = tier or (subscription.tier if subscription else None)
if tier is None:
raise BillingError(f"{club} has no tier: put it on a subscription before billing it.")
if plan is None:
if subscription is None:
raise BillingError(f"{club} has no plan: put it on a subscription before billing it.")
# A trial that has run its course: swap onto the pre-selected plan before billing
# the next period, rather than silently renewing the trial plan forever. Checked
# here (not in renew()) so it fires whether this period was opened by the renewal
# command or by a platform admin clicking "Open period"/"Reactivate" by hand --
# both call open_period() directly.
if subscription.trial_ends_at is not None and (start or next_period_start(club)) > subscription.trial_ends_at:
subscription.plan = subscription.post_trial_plan
subscription.trial_ends_at = None
subscription.post_trial_plan = None
subscription.save(update_fields=["plan", "trial_ends_at", "post_trial_plan"])
plan = subscription.plan
start = start or next_period_start(club)
amount = tier.price_on(start)
amount = plan.price_on(start)
if amount is None:
raise BillingError(f"{tier} has no price in force on {start:%d %b %Y}. Add one before opening the period.")
raise BillingError(f"{plan} has no price in force on {start:%d %b %Y}. Add one before opening the period.")
if club.dues.filter(period_start=start).exists():
raise BillingError(f"{club} is already billed for a period starting {start:%d %b %Y}.")
due = Due.objects.create(club=club, tier=tier, amount=amount, period_start=start)
due = Due.objects.create(club=club, plan=plan, amount=amount, period_start=start, period_end=period_end, is_trial=is_trial)
if amount == ZERO:
# Nothing is actually owed -- left at the default UNPAID, this would eventually
# trip is_overdue() and get a free club archived for non-payment of nothing.
due.status = Due.Status.PAID
due.paid_at = timezone.now()
due.save(update_fields=["status", "paid_at"])
issue_invoice(due) # every period is billable the moment it opens
return due
@@ -114,15 +153,19 @@ def waive(due: Due, *, note: str = "") -> Due:
return due
def owing_dues(today: date | None = None):
def owing_dues():
return Due.objects.filter(status__in=Due.OWING)
def dues_in_grace(today: date | None = None):
"""Period over, unpaid, not yet archivable."""
"""Period started, unpaid, not yet archivable.
Bounded below by ``period_start``, not ``period_end``: grace now runs from the start of
the period, so a due is in grace *during* the period it covers, not after it.
"""
today = today or timezone.localdate()
return owing_dues().filter(period_end__lt=today, grace_until__gte=today)
return owing_dues().filter(period_start__lte=today, grace_until__gte=today)
def dues_overdue(today: date | None = None):
@@ -138,7 +181,7 @@ def archivable_clubs(today: date | None = None):
A club with auto_archive off is deliberately spared — that flag is how you keep a club
you are negotiating with from being switched off overnight.
"""
return dues_overdue(today).filter(club__archived_at__isnull=True, club__subscription__auto_archive=True).select_related("club", "tier").order_by("club__name")
return dues_overdue(today).filter(club__archived_at__isnull=True, club__subscription__auto_archive=True).select_related("club", "plan").order_by("club__name")
@transaction.atomic
@@ -151,3 +194,47 @@ def reactivate(club, *, start: date | None = None) -> Due:
club.restore()
return open_period(club, start=start)
def subscriptions_due_for_renewal(today: date | None = None, lead_days: int | None = None):
"""Clubs whose next period should be issued now.
Each plan sets its own ``renewal_lead_days``: a single global lead is silently annual-only,
and on a 1-month plan a 30-day lead would issue the next period before the current one had
started. ``lead_days`` overrides every plan's own value — that is what makes a rehearsal or
a backfill possible, and it is what the command's --lead-days flag passes.
The per-plan comparison is done in Python rather than SQL. The function already
materialised its result as a list, and date arithmetic against a field value is not
portably expressible across SQLite and Postgres; at platform scale (tens to low hundreds of
clubs) this is one query plus a list walk.
Idempotent by construction: a club that has just been renewed has a latest period ending a
full duration out, which is past its horizon, so it cannot be picked up twice. Running the
job twice a day is harmless.
A subscription with no period at all (its only due was cancelled) counts too — a club on a
plan and billed for nothing is the leak this whole job exists to close.
"""
today = today or timezone.localdate()
latest_period_end = Subquery(
Due.objects.filter(club=OuterRef("club")).exclude(status=Due.Status.CANCELLED).order_by("-period_end").values("period_end")[:1],
output_field=DateField(),
)
subscriptions = Subscription.objects.filter(auto_renew=True, club__archived_at__isnull=True).select_related("club", "plan").annotate(latest_period_end=latest_period_end).order_by("club__name")
def is_due(subscription) -> bool:
if subscription.latest_period_end is None:
return True
lead = subscription.plan.renewal_lead_days if lead_days is None else lead_days
return subscription.latest_period_end <= today + timedelta(days=lead)
return [subscription for subscription in subscriptions if is_due(subscription)]
def renew(subscription: Subscription) -> Due:
"""Open the club's next period, continuing from the last one."""
return open_period(subscription.club)

View File

@@ -1,6 +1,6 @@
"""Invoice PDFs.
The PDF is rendered on demand from the Due's frozen snapshot (tier, amount, dates), so it
The PDF is rendered on demand from the Due's frozen snapshot (plan, amount, dates), so it
carries no state of its own beyond the number. Only the number is stored — an accountant
reconciles against it, so it is allocated once, never recomputed.
"""

View File

@@ -0,0 +1,77 @@
"""What a club's own admins are told about money they owe the platform.
Separate from dues.py because the audience is different: everything in dues.py is read by
platform staff in the control panel, and this is the one piece of billing a *club* sees. It
returns data, never rendered text — the wording lives in the template so it can be translated,
and the same notice feeds both the on-screen banner and the reminder email.
"""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from django.utils import timezone
from billing.models import Due
#: Inside this many days of being archived, the notice stops being a warning and becomes a
#: final one — which is also the point at which it follows the admin onto every page.
URGENT_DAYS = 7
INFO = "info"
WARNING = "warning"
ERROR = "error"
@dataclass(frozen=True)
class BillingNotice:
"""The single most pressing thing a club owes, and how alarmed to be about it."""
level: str
due: Due
amount_outstanding: Decimal
period_start: date
grace_until: date
days_until_archive: int
#: False when the subscription has auto_archive off. Money is still owed and still worth
#: saying so, but the countdown must not claim an archiving that will never happen.
will_archive: bool
@property
def is_urgent(self) -> bool:
return self.level == ERROR
def club_billing_notice(club, today: date | None = None) -> BillingNotice | None:
"""The notice for ``club``, or None when it owes nothing.
Picks the due with the earliest ``grace_until`` when several are owing: that is the one
that will archive the club first, so it is the one worth shouting about.
"""
today = today or timezone.localdate()
due = club.dues.filter(status__in=Due.OWING).select_related("plan").order_by("grace_until").first()
if due is None:
return None
subscription = getattr(club, "subscription", None)
will_archive = subscription.auto_archive if subscription is not None else False
days_left = due.days_until_archive(today)
if due.is_overdue(today):
level = ERROR
elif due.is_in_grace(today):
level = ERROR if days_left <= URGENT_DAYS else WARNING
else:
# Issued during the plan's renewal lead window: billed, but nothing is late yet.
level = INFO
return BillingNotice(
level=level,
due=due,
amount_outstanding=due.balance,
period_start=due.period_start,
grace_until=due.grace_until,
days_until_archive=days_left,
will_archive=will_archive,
)

85
billing/services/plans.py Normal file
View File

@@ -0,0 +1,85 @@
"""Deleting a plan.
Due.plan is PROTECT -- a plan that has ever billed anyone can never truly be removed, and
must not be: `amount`, `period_end` and `grace_until` are frozen on a Due precisely so a
later change can't rewrite what was actually charged, and losing the plan link off an old
Due would do exactly that to every historical invoice. So "delete" means one of two things,
chosen automatically depending on whether the plan has billing history:
* No Due ever referenced it (created, then never actually used to bill anyone) -- the row
itself is removed.
* At least one Due references it -- soft-deleted instead (Plan.deleted_at set, is_active
turned off): hidden from every picker and listing (Plan.objects.visible()), but the row
survives so every old invoice still says what it was for.
Either way, every club CURRENTLY on the plan is unsubscribed outright -- its Subscription
row deleted, not just its `plan` field cleared. "No plan" is already a state the rest of the
app fully understands (every billing view already handles `getattr(club, "subscription",
None)` being None), so there is no new state to teach it.
A second, easy-to-miss group: a club on a DIFFERENT plan, mid-trial, configured to convert
to THIS plan once its trial ends (Subscription.post_trial_plan). Deleting the target plan
out from under that trial can't be allowed to raise a stale IntegrityError days or weeks
later when the trial tries to convert -- so it's handled now, at delete time: that club's
trial is ended (trial_ends_at and post_trial_plan both cleared, per the CheckConstraint that
requires them set together or not at all), leaving it on the trial plan with no scheduled
conversion until a platform admin picks a new one.
"""
from dataclasses import dataclass
from django.db import transaction
from django.utils import timezone
from billing.models import Plan, Subscription
@dataclass(frozen=True)
class PlanDeletionImpact:
plan: Plan
#: Clubs currently ON this plan -- lose it entirely (Subscription row deleted).
unsubscribed_clubs: list
#: Clubs on a different plan, mid-trial, configured to convert to this one -- their
#: trial's landing plan is cleared, leaving no scheduled conversion.
broken_trial_clubs: list
#: Whether the Plan row itself will be removed (True) or only hidden (False, because
#: it has billing history).
will_hard_delete: bool
@property
def has_impact(self) -> bool:
return bool(self.unsubscribed_clubs or self.broken_trial_clubs)
def plan_deletion_impact(plan: Plan) -> PlanDeletionImpact:
"""What deleting `plan` right now would do -- read-only, for the confirmation screen.
delete_plan() re-derives the same two lists itself rather than trust one computed here
moments earlier and possibly stale by the time the platform admin actually confirms.
"""
unsubscribed = Subscription.objects.filter(plan=plan).select_related("club").order_by("club__name")
broken_trial = Subscription.objects.filter(post_trial_plan=plan).exclude(plan=plan).select_related("club").order_by("club__name")
return PlanDeletionImpact(
plan=plan,
unsubscribed_clubs=[subscription.club for subscription in unsubscribed],
broken_trial_clubs=[subscription.club for subscription in broken_trial],
will_hard_delete=not plan.dues.exists(),
)
@transaction.atomic
def delete_plan(plan: Plan) -> PlanDeletionImpact:
impact = plan_deletion_impact(plan)
Subscription.objects.filter(plan=plan).delete()
Subscription.objects.filter(post_trial_plan=plan).update(trial_ends_at=None, post_trial_plan=None)
if impact.will_hard_delete:
plan.delete()
else:
plan.deleted_at = timezone.now()
plan.is_active = False
plan.save(update_fields=["deleted_at", "is_active", "modified"])
return impact

View File

@@ -0,0 +1,98 @@
"""Emailing a club's admins about money it owes the platform.
Built on the same BillingNotice the on-screen banner uses (notices.py), so the email and the
banner can never disagree about how much is owed or how long is left.
**Sent once per escalation level, not once per run.** The command is on a daily cron; a club
that owes money for a month must not receive thirty identical emails. ``Due.last_reminder_level``
records the level last mailed, so an escalation (info -> warning -> error) always gets through
and a repeat of the same level never does.
"""
from dataclasses import dataclass
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext as _
from billing.services.notices import BillingNotice, club_billing_notice
from club.models import ClubRole
@dataclass(frozen=True)
class ReminderResult:
club: object
notice: BillingNotice
recipients: list[str]
sent: bool
skipped_reason: str = ""
def admin_emails(club) -> list[str]:
"""Every club admin we can actually reach, de-duplicated and order-stable.
A club with admins but no email addresses returns empty — the caller reports that rather
than silently counting it as reminded.
"""
roles = ClubRole.objects.filter(club=club, role=ClubRole.Roles.ADMIN).select_related("member", "member__user").order_by("member__last_name", "member__first_name")
seen, emails = set(), []
for role in roles:
email = role.member.contact_email
if email and email not in seen:
seen.add(email)
emails.append(email)
return emails
def needs_reminder(due, notice: BillingNotice) -> bool:
"""True when this due has not yet been mailed at its current level."""
return due.last_reminder_level != notice.level
def send_reminder(club, notice: BillingNotice, *, recipients: list[str]) -> None:
"""Render and send one reminder, then record the level so it is not repeated."""
context = {
"club": club,
"notice": notice,
"due": notice.due,
"billing_contact": settings.BILLING_CONTACT_EMAIL,
}
subject = render_to_string("billing/email/reminder_subject.txt", context).strip()
text_body = render_to_string("billing/email/reminder.txt", context)
message = EmailMultiAlternatives(subject=subject, body=text_body, from_email=settings.DEFAULT_FROM_EMAIL, to=recipients)
message.send(fail_silently=False)
notice.due.last_reminder_level = notice.level
notice.due.last_reminder_sent_at = timezone.now()
notice.due.save(update_fields=["last_reminder_level", "last_reminder_sent_at", "modified"])
def reminders_to_send(clubs, today=None, *, force: bool = False) -> list[ReminderResult]:
"""Work out who would be reminded, without sending anything.
Returned whether or not each one is actually sendable, so the command can report a club
with no reachable admin instead of skipping it in silence — an unreachable club is exactly
the one that gets archived without ever having been told.
"""
results = []
for club in clubs:
notice = club_billing_notice(club, today)
if notice is None:
continue
recipients = admin_emails(club)
if not recipients:
results.append(ReminderResult(club=club, notice=notice, recipients=[], sent=False, skipped_reason=_("no club admin with an email address")))
continue
if not force and not needs_reminder(notice.due, notice):
results.append(ReminderResult(club=club, notice=notice, recipients=recipients, sent=False, skipped_reason=_("already reminded at this level")))
continue
results.append(ReminderResult(club=club, notice=notice, recipients=recipients, sent=True))
return results

92
billing/tasks.py Normal file
View File

@@ -0,0 +1,92 @@
"""Celery tasks behind the billing beat schedule entries (see
rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py).
Each mirrors its management command's *acting* behaviour exactly -- manage.py's own
--dry-run/--commit flags exist for a human at a terminal to preview first (see
billing/management/commands/), which a beat schedule has no terminal to do. These always
act, the same as the crontab entries they replace always passed --commit.
"""
from celery import shared_task
from django.utils import timezone
from billing.services import BillingError
from billing.services.dues import archivable_clubs, renew, subscriptions_due_for_renewal
from billing.services.reminders import reminders_to_send, send_reminder
from club.models import Club
from features.models import JobToggle, Maintenance
def _stand_down():
# Loud, not silent -- see events/tasks.py for why these raise instead of skipping quietly.
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
def _check_enabled(name):
if not JobToggle.is_enabled(name):
raise RuntimeError("This job is disabled in the control panel.")
@shared_task(name="billing.tasks.renew_subscriptions")
def renew_subscriptions():
if Maintenance.is_on():
_stand_down()
_check_enabled("billing.tasks.renew_subscriptions")
due_for_renewal = subscriptions_due_for_renewal()
if not due_for_renewal:
return "Nothing to renew."
renewed, failures = 0, []
for subscription in due_for_renewal:
try:
renew(subscription)
renewed += 1
except BillingError as error:
# One unpriced plan must not stop every other club from being billed.
failures.append(f"{subscription.club}: {error}")
if failures:
raise RuntimeError(f"Renewed {renewed} club(s), {len(failures)} failed:\n " + "\n ".join(failures))
return f"Renewed {renewed} club(s)."
@shared_task(name="billing.tasks.send_billing_reminders")
def send_billing_reminders():
if Maintenance.is_on():
_stand_down()
_check_enabled("billing.tasks.send_billing_reminders")
clubs = Club.objects.active().select_related("subscription", "subscription__plan").order_by("name")
sendable = [result for result in reminders_to_send(clubs) if result.sent]
if not sendable:
return "Nothing owing. No reminders to send."
sent, failures = 0, []
for result in sendable:
try:
send_reminder(result.club, result.notice, recipients=result.recipients)
sent += 1
except OSError as error:
# One bad address or a momentary SMTP failure must not stop the rest of the run.
failures.append(f"{result.club}: {error}")
if failures:
raise RuntimeError(f"Sent {sent} reminder(s), {len(failures)} failed:\n " + "\n ".join(failures))
return f"Sent {sent} reminder(s)."
@shared_task(name="billing.tasks.archive_overdue_clubs")
def archive_overdue_clubs():
if Maintenance.is_on():
_stand_down()
_check_enabled("billing.tasks.archive_overdue_clubs")
overdue = list(archivable_clubs(timezone.localdate()))
for due in overdue:
due.club.archive()
return f"Archived {len(overdue)} club(s)."

View File

@@ -0,0 +1,14 @@
{% load i18n %}{% blocktrans with club=club.name %}Hello,
This is a reminder about the RosterChief platform fees for {{ club }}.{% endblocktrans %}
{% blocktrans with amount=notice.amount_outstanding %}Outstanding: EUR {{ amount }}{% endblocktrans %}
{% blocktrans with start=due.period_start|date:"j M Y" end=due.period_end|date:"j M Y" %}Period: {{ start }} to {{ end }}{% endblocktrans %}
{% if due.invoice %}{% blocktrans with number=due.invoice.number %}Invoice: {{ number }}{% endblocktrans %}
{% endif %}
{% if notice.will_archive %}{% if notice.days_until_archive < 0 %}{% trans "This club is now past its payment deadline and is due to be archived. While archived, nobody can sign in and the club's site stops resolving. No data is deleted, and access is restored as soon as payment is received." %}{% else %}{% blocktrans count days=notice.days_until_archive %}If we do not receive payment within {{ days }} day, this club will be archived. While archived, nobody can sign in and the club's site stops resolving. No data is deleted.{% plural %}If we do not receive payment within {{ days }} days, this club will be archived. While archived, nobody can sign in and the club's site stops resolving. No data is deleted.{% endblocktrans %}{% endif %}{% else %}{% trans "Please settle this to keep your account in good standing." %}{% endif %}
{% blocktrans with contact=billing_contact %}If you have already paid, or you think this is a mistake, reply to {{ contact }} and we will sort it out.{% endblocktrans %}
{% trans "Thank you," %}
{% trans "RosterChief" %}

View File

@@ -0,0 +1 @@
{% load i18n %}{% if notice.level == 'error' and notice.will_archive %}{% blocktrans with club=club.name %}Action required: {{ club }} is about to be archived{% endblocktrans %}{% else %}{% blocktrans with club=club.name %}Platform fees are due for {{ club }}{% endblocktrans %}{% endif %}

View File

@@ -52,7 +52,7 @@
<div class="parties">
<div>
<h2>Billed to</h2>
<div><strong>{{ club.name }}</strong></div>
<div><strong>{{ club.official_name }}</strong></div>
<div class="muted">{{ club.slug }}.rosterchief.app</div>
</div>
<div class="right">
@@ -72,7 +72,7 @@
<tbody>
<tr>
<td>
<strong>{{ due.tier.name }}</strong> — platform subscription
<strong>{{ due.plan.name }}</strong> — platform subscription
<div class="muted">{{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }}</div>
</td>
<td class="right">€{{ due.amount|floatformat:2 }}</td>

View File

@@ -4,63 +4,84 @@ from decimal import Decimal
from io import StringIO
from unittest import mock
from django.core import mail
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db.utils import IntegrityError
from django.test import TestCase
from django.utils import timezone
from club.models import Club
from authentication.models import User
from club.models import Club, ClubRole
from members.models import Member
from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year
from .models import DEFAULT_DURATION_MONTHS, DEFAULT_GRACE_DAYS, DEFAULT_RENEWAL_LEAD_DAYS, Due, Invoice, Plan, PlanPrice, Subscription, add_months
from .services import BillingError
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, subscribe, waive
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, renew, start_trial, subscribe, subscriptions_due_for_renewal, waive
from .services.invoices import invoice_pdf, issue_invoice, render_pdf
from .services.notices import club_billing_notice
from .services.plans import delete_plan, plan_deletion_impact
from .services.reminders import admin_emails, reminders_to_send, send_reminder
class BillingTestBase(TestCase):
def setUp(self):
self.today = timezone.localdate()
self.club = Club.objects.create(name="Ajax United")
self.tier = Tier.objects.create(name="Standard")
# setUpTestData, not setUp: the club and the priced plan are read-only scaffolding for
# every subclass, so they are built once per class. Django hands each test its own deep
# copy and rolls the database back afterwards, so the tests that archive the club or
# soft-delete the plan still start from a clean slate.
@classmethod
def setUpTestData(cls):
cls.today = timezone.localdate()
cls.club = Club.objects.create(name="Ajax United")
cls.plan = Plan.objects.create(name="Standard")
# Priced well back, so a backdated (lapsed) period still has a price in force —
# opening one before any price existed is refused, and rightly so.
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
PlanPrice.objects.create(plan=cls.plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def bill(self, start=None, club=None):
return open_period(club or self.club, start=start, tier=self.tier)
return open_period(club or self.club, start=start, plan=self.plan)
class TierPriceTests(BillingTestBase):
class PlanPriceTests(BillingTestBase):
def test_the_price_in_force_is_the_latest_one_that_has_started(self):
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("600.00"))
PlanPrice.objects.create(plan=self.plan, active_from=self.today, amount=Decimal("600.00"))
self.assertEqual(self.tier.price_on(self.today - datetime.timedelta(days=1)), Decimal("500.00"))
self.assertEqual(self.tier.price_on(self.today), Decimal("600.00"))
self.assertEqual(self.plan.price_on(self.today - datetime.timedelta(days=1)), Decimal("500.00"))
self.assertEqual(self.plan.price_on(self.today), Decimal("600.00"))
def test_a_future_price_does_not_apply_yet(self):
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
PlanPrice.objects.create(plan=self.plan, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
self.assertEqual(self.tier.price_on(self.today), Decimal("500.00"))
self.assertEqual(self.plan.price_on(self.today), Decimal("500.00"))
def test_a_tier_with_no_price_yet_cannot_be_billed(self):
def test_a_plan_with_no_price_yet_cannot_be_billed(self):
# None must never be read as free.
empty = Tier.objects.create(name="Enterprise")
empty = Plan.objects.create(name="Enterprise")
self.assertIsNone(empty.price_on(self.today))
with self.assertRaises(BillingError):
open_period(self.club, tier=empty)
open_period(self.club, plan=empty)
class PeriodTests(BillingTestBase):
def test_a_period_runs_a_rolling_year_with_a_grace_tail(self):
def test_a_period_runs_for_the_plans_duration(self):
due = self.bill(start=datetime.date(2026, 3, 1))
self.assertEqual(due.period_end, datetime.date(2027, 2, 28))
self.assertEqual(due.grace_until, due.period_end + datetime.timedelta(days=GRACE_DAYS))
def test_grace_is_measured_from_the_period_start_not_its_end(self):
# The whole point of the redesign: measured from the end, an annual club would get
# ~410 days of unpaid use before anything switched it off.
due = self.bill(start=datetime.date(2026, 3, 1))
self.assertEqual(due.grace_until, datetime.date(2026, 3, 1) + datetime.timedelta(days=DEFAULT_GRACE_DAYS))
self.assertLess(due.grace_until, due.period_end)
def test_a_leap_day_period_does_not_explode(self):
# 29 February has no counterpart in a common year.
self.assertEqual(add_one_year(datetime.date(2028, 2, 29)), datetime.date(2029, 2, 28))
self.assertEqual(add_months(datetime.date(2028, 2, 29), 12), datetime.date(2029, 2, 28))
def test_the_next_period_continues_from_the_last_one(self):
# Not from today: a club that pays two months late has still used those two months,
@@ -74,7 +95,7 @@ class PeriodTests(BillingTestBase):
def test_the_amount_is_snapshotted_at_the_price_of_the_day(self):
due = self.bill()
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=1), amount=Decimal("900.00"))
PlanPrice.objects.create(plan=self.plan, active_from=self.today + datetime.timedelta(days=1), amount=Decimal("900.00"))
due.refresh_from_db()
# Raising the rate must not rewrite what was already billed.
@@ -86,23 +107,24 @@ class PeriodTests(BillingTestBase):
with self.assertRaises(BillingError):
self.bill(start=self.today)
def test_a_club_with_no_tier_cannot_be_billed(self):
def test_a_club_with_no_plan_cannot_be_billed(self):
with self.assertRaises(BillingError):
open_period(Club.objects.create(name="Feyenoord"))
def test_subscribing_puts_a_club_on_a_tier_and_opens_a_period(self):
def test_subscribing_puts_a_club_on_a_plan_and_opens_a_period(self):
club = Club.objects.create(name="Feyenoord")
subscribe(club, self.tier)
subscribe(club, self.plan)
self.assertEqual(Subscription.objects.get(club=club).tier, self.tier)
self.assertEqual(Subscription.objects.get(club=club).plan, self.plan)
self.assertEqual(club.dues.count(), 1)
class PaymentTests(BillingTestBase):
def setUp(self):
super().setUp()
self.due = self.bill()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.due = open_period(cls.club, plan=cls.plan)
def test_a_part_payment_leaves_the_due_partially_paid(self):
record_payment(self.due, Decimal("200.00"))
@@ -174,15 +196,24 @@ class PaymentTests(BillingTestBase):
class GraceAndArchiveTests(BillingTestBase):
LAPSED = 365 + GRACE_DAYS + 10
LAPSED = DEFAULT_GRACE_DAYS + 10
def test_a_period_past_its_end_but_inside_grace_is_in_grace(self):
due = self.bill(start=self.today - datetime.timedelta(days=370))
def test_a_started_but_unpaid_period_inside_grace_is_in_grace(self):
# Grace runs from the period START now, so this is a period that began a few days
# ago and has not been paid -- not one that has already run its full length.
due = self.bill(start=self.today - datetime.timedelta(days=5))
self.assertTrue(due.is_in_grace(self.today))
self.assertFalse(due.is_overdue(self.today))
self.assertIn(due, dues_in_grace(self.today))
def test_a_period_issued_ahead_of_its_start_is_not_yet_in_grace(self):
due = self.bill(start=self.today + datetime.timedelta(days=10))
self.assertTrue(due.is_issued_ahead(self.today))
self.assertFalse(due.is_in_grace(self.today))
self.assertFalse(due.is_overdue(self.today))
def test_a_period_past_grace_is_overdue(self):
due = self.bill(start=self.today - datetime.timedelta(days=self.LAPSED))
@@ -199,28 +230,29 @@ class GraceAndArchiveTests(BillingTestBase):
self.assertNotIn(due, dues_overdue(self.today))
def test_an_overdue_club_is_archivable(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=self.LAPSED))
self.assertEqual(archivable_clubs(self.today).count(), 1)
def test_a_club_that_opted_out_is_never_archived(self):
# auto_archive off is how you stop a club you are negotiating with from being
# switched off overnight.
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED), auto_archive=False)
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=self.LAPSED), auto_archive=False)
self.assertEqual(archivable_clubs(self.today).count(), 0)
def test_an_already_archived_club_is_not_archived_again(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=self.LAPSED))
self.club.archive()
self.assertEqual(archivable_clubs(self.today).count(), 0)
class ArchiveCommandTests(BillingTestBase):
def setUp(self):
super().setUp()
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=365 + GRACE_DAYS + 10))
@classmethod
def setUpTestData(cls):
super().setUpTestData()
subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 10))
def run_command(self, *args):
out = StringIO()
@@ -250,13 +282,14 @@ class ArchiveCommandTests(BillingTestBase):
class ReactivationTests(BillingTestBase):
def setUp(self):
super().setUp()
# Through subscribe(), not open_period(): reactivating reads the club's tier off its
@classmethod
def setUpTestData(cls):
super().setUpTestData()
# Through subscribe(), not open_period(): reactivating reads the club's plan off its
# subscription, and a club billed without one cannot be re-billed later.
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
self.first = self.club.dues.first()
self.club.archive()
subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=400))
cls.first = cls.club.dues.first()
cls.club.archive()
def test_reactivating_continues_from_the_lapsed_period_by_default(self):
due = reactivate(self.club)
@@ -281,7 +314,7 @@ class InvoiceTests(BillingTestBase):
# Unlike the shop's per-club order numbers: these are OUR invoices, and one sequence
# covers every club we bill.
first = self.bill(start=self.today).invoice
second = open_period(Club.objects.create(name="Feyenoord"), tier=self.tier).invoice
second = open_period(Club.objects.create(name="Feyenoord"), plan=self.plan).invoice
year = timezone.now().year
self.assertEqual(first.number, f"INV-{year}-00001")
@@ -309,6 +342,17 @@ class InvoiceTests(BillingTestBase):
self.assertIn("200.00", html) # paid
self.assertIn("300.00", html) # balance
def test_the_invoice_is_billed_to_the_clubs_legal_name_when_set(self):
self.club.legal_name = "Ajax United VZW"
self.club.save(update_fields=["legal_name"])
due = self.bill()
with mock.patch("billing.services.invoices.render_pdf", return_value=b"%PDF-fake") as renderer:
invoice_pdf(due.invoice)
html = renderer.call_args.args[0]
self.assertIn("Ajax United VZW", html)
def test_the_pdf_library_is_only_needed_when_a_pdf_is_asked_for(self):
# WeasyPrint binds to native pango/cairo. The app, the tests and every other page must
# run without them; only this call may fail.
@@ -329,9 +373,608 @@ class ModelStringTests(BillingTestBase):
due = self.bill()
payment = record_payment(due, Decimal("10.00"))
self.assertEqual(str(self.tier), "Standard")
self.assertIn("500.00", str(self.tier.prices.first()))
self.assertEqual(str(self.plan), "Standard")
self.assertIn("500.00", str(self.plan.prices.first()))
self.assertIn("Ajax United", str(due))
self.assertIn("10.00", str(payment))
self.assertIn("INV-", str(due.invoice))
self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.tier)))
self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.plan)))
class RenewalTests(BillingTestBase):
"""The leak this closes: a club whose period lapses with its last due PAID owes nothing,
so dues_overdue() is empty, so archive_overdue_clubs never fires — and the club keeps
using the platform for free while every number on the dashboard stays green."""
def ending_in(self, days, **kwargs):
"""A club whose current period ends `days` from now."""
club = Club.objects.create(name=f"Club {days}")
subscribe(club, self.plan, start=self.today - datetime.timedelta(days=365 - days), **kwargs)
return club
def test_a_club_nearing_its_end_date_is_picked_up(self):
club = self.ending_in(20)
due = [s.club for s in subscriptions_due_for_renewal()]
self.assertIn(club, due)
def test_a_club_with_a_period_beyond_the_horizon_is_left_alone(self):
club = self.ending_in(200)
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_renewing_continues_from_the_last_period(self):
club = self.ending_in(20)
first = club.dues.first()
renew(club.subscription)
latest = club.dues.order_by("-period_start").first()
self.assertEqual(latest.period_start, first.period_end + datetime.timedelta(days=1))
self.assertEqual(club.dues.count(), 2)
def test_running_twice_does_not_bill_twice(self):
# Idempotent by construction: once renewed, the club's latest period ends a year out,
# which is past the horizon.
club = self.ending_in(20)
call_command("renew_subscriptions", stdout=StringIO())
call_command("renew_subscriptions", stdout=StringIO())
self.assertEqual(club.dues.count(), 2)
def test_a_club_that_opted_out_is_not_renewed(self):
club = self.ending_in(20, auto_renew=False)
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_an_archived_club_is_not_renewed(self):
# Reactivation is the way back, and it opens a period of its own.
club = self.ending_in(20)
club.archive()
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_the_new_period_is_billed_at_the_price_in_force_then(self):
club = self.ending_in(20)
PlanPrice.objects.create(plan=self.plan, active_from=self.today, amount=Decimal("900.00"))
due = renew(club.subscription)
self.assertEqual(due.amount, Decimal("900.00")) # the new rate
self.assertEqual(club.dues.order_by("period_start").first().amount, Decimal("500.00")) # the old one, untouched
def test_the_new_period_is_invoiced(self):
club = self.ending_in(20)
due = renew(club.subscription)
self.assertTrue(due.invoice.number.startswith("INV-"))
def test_a_dry_run_issues_nothing(self):
club = self.ending_in(20)
out = StringIO()
call_command("renew_subscriptions", "--dry-run", stdout=out)
self.assertEqual(club.dues.count(), 1)
self.assertIn("would renew", out.getvalue())
def test_the_command_issues_by_default(self):
# The opposite asymmetry to archiving: NOT acting is the expensive failure here,
# because a club that is never billed is never chased either.
club = self.ending_in(20)
call_command("renew_subscriptions", stdout=StringIO())
self.assertEqual(club.dues.count(), 2)
def test_an_unpriced_plan_fails_loudly_without_stopping_the_others(self):
priced = self.ending_in(20)
broken = Club.objects.create(name="Unpriced FC")
subscribe(broken, self.plan, start=self.today - datetime.timedelta(days=350))
# Its next period starts beyond the last price... by removing every price, it cannot bill.
PlanPrice.objects.all().delete()
cheap = Plan.objects.create(name="Cheap")
PlanPrice.objects.create(plan=cheap, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
priced.subscription.plan = cheap
priced.subscription.save()
with self.assertRaises(CommandError):
call_command("renew_subscriptions", stdout=StringIO(), stderr=StringIO())
# ...and the club that COULD be billed still was.
self.assertEqual(priced.dues.count(), 2)
def test_a_subscription_with_no_period_at_all_is_renewed(self):
club = Club.objects.create(name="Orphan FC")
Subscription.objects.create(club=club, plan=self.plan)
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_it_says_so_when_there_is_nothing_to_renew(self):
self.assertIn("Nothing to renew", self.run_renewal())
def run_renewal(self, *args):
out = StringIO()
call_command("renew_subscriptions", *args, stdout=out)
return out.getvalue()
class RenewedButUnpaidTests(BillingTestBase):
"""A club auto-renewed that never pays the new fee flows through the ordinary
unpaid -> grace -> overdue -> archive path. Renewal creates a normal Due; it does not
create a special case, and the safety net that the never-billed club slipped past now
fires, because there IS an unpaid due."""
def lapsed_club(self):
"""A club on its first, PAID period — far enough back that a renewal from its end is
itself already past grace, so only the renewal's payment state decides the outcome."""
club = Club.objects.create(name="Renewed FC")
subscribe(club, self.plan, start=self.today - datetime.timedelta(days=800))
first = club.dues.first()
record_payment(first, first.amount) # the FIRST period is settled; only the renewal is in question
return club
def test_an_unpaid_renewal_becomes_overdue_and_archivable(self):
club = self.lapsed_club()
renewed = renew(club.subscription) # continues from the first period's end, unpaid
self.assertTrue(renewed.is_overdue(self.today))
self.assertIn(renewed, dues_overdue(self.today))
self.assertIn(club, [d.club for d in archivable_clubs(self.today)])
def test_a_paid_renewal_is_not_chased(self):
club = self.lapsed_club()
renewed = renew(club.subscription)
record_payment(renewed, renewed.amount)
self.assertNotIn(club, [d.club for d in archivable_clubs(self.today)])
class TrialTests(BillingTestBase):
"""A club with no subscription yet can be started on a short trial that switches
itself to a pre-selected plan automatically once the trial period is renewed --
see billing.services.dues.start_trial and the trial-conversion check in
open_period()."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
# A trial is a plan whose own duration_months IS the trial length -- there is no
# trial_months argument any more.
cls.trial_plan = Plan.objects.create(name="Trial", duration_months=2, is_trial=True, grace_days=14, renewal_lead_days=7)
PlanPrice.objects.create(plan=cls.trial_plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
def test_start_trial_creates_a_short_trial_period(self):
due = start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
subscription = self.club.subscription
self.assertEqual(subscription.plan, self.trial_plan)
self.assertEqual(subscription.post_trial_plan, self.plan)
self.assertEqual(subscription.trial_ends_at, due.period_end)
self.assertTrue(due.is_trial)
# Roughly 2 months, nowhere near the standard ~1-year period.
self.assertLess((due.period_end - due.period_start).days, 65)
def test_start_trial_refuses_if_already_subscribed(self):
subscribe(self.club, self.plan)
with self.assertRaises(BillingError):
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
def test_the_trials_length_comes_from_its_plan(self):
due = start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
self.assertEqual(due.period_end, add_months(due.period_start, 2) - datetime.timedelta(days=1))
def test_renewing_after_the_trial_switches_to_the_post_trial_plan(self):
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
due = renew(self.club.subscription)
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.plan, self.plan)
self.assertIsNone(self.club.subscription.trial_ends_at)
self.assertIsNone(self.club.subscription.post_trial_plan)
self.assertEqual(due.plan, self.plan)
self.assertFalse(due.is_trial)
self.assertEqual(due.amount, Decimal("500.00"))
def test_manually_opening_the_next_period_also_switches_plan(self):
# Same conversion must fire via the control panel's "Open period" button, which
# calls open_period() directly rather than renew().
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
open_period(self.club)
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.plan, self.plan)
def test_a_trial_nearing_its_end_is_picked_up_for_renewal(self):
# Inside the TRIAL PLAN's own 7-day lead, not the 30-day one an annual plan uses:
# a 2-month trial renewed a month early would be renewed before it had begun.
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan, start=self.today - datetime.timedelta(days=57))
self.assertIn(self.club, [s.club for s in subscriptions_due_for_renewal()])
def test_a_trial_outside_its_own_lead_window_is_not_yet_renewed(self):
# Same trial 7 days earlier in its life: an annual plan's 30-day lead would have
# picked this up, and the per-plan lead is exactly what stops that.
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan, start=self.today - datetime.timedelta(days=40))
self.assertNotIn(self.club, [s.club for s in subscriptions_due_for_renewal()])
def test_a_zero_amount_trial_is_created_already_paid(self):
free_plan = Plan.objects.create(name="Free Trial")
PlanPrice.objects.create(plan=free_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
due = start_trial(self.club, free_plan, post_trial_plan=self.plan)
self.assertEqual(due.status, Due.Status.PAID)
self.assertIsNotNone(due.paid_at)
far_future = due.grace_until + datetime.timedelta(days=100)
self.assertNotIn(due, dues_overdue(far_future))
self.assertNotIn(self.club, [d.club for d in archivable_clubs(far_future)])
class PlanClockTests(BillingTestBase):
"""The three per-plan clocks, and the constraints that keep them sane -- see BILLING.md §3."""
def make_plan(self, **kwargs):
# A short plan cannot keep the annual defaults -- 30 days' lead on a 1-month period is
# exactly what the constraints forbid, so scale them down with the duration.
months = kwargs.get("duration_months", DEFAULT_DURATION_MONTHS)
defaults = {"name": f"Plan {Plan.objects.count()}", "renewal_lead_days": min(DEFAULT_RENEWAL_LEAD_DAYS, months * 7), "grace_days": min(DEFAULT_GRACE_DAYS, months * 14)}
plan = Plan.objects.create(**defaults | kwargs)
PlanPrice.objects.create(plan=plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("10.00"))
return plan
def test_a_monthly_plan_gets_a_one_month_period(self):
plan = self.make_plan(duration_months=1)
due = open_period(self.club, plan=plan, start=datetime.date(2026, 3, 1))
self.assertEqual(due.period_end, datetime.date(2026, 3, 31))
def test_a_quarterly_plan_gets_a_three_month_period(self):
plan = self.make_plan(duration_months=3)
due = open_period(self.club, plan=plan, start=datetime.date(2026, 3, 1))
self.assertEqual(due.period_end, datetime.date(2026, 5, 31))
def test_grace_days_are_per_plan(self):
plan = self.make_plan(duration_months=1, grace_days=14)
due = open_period(self.club, plan=plan, start=datetime.date(2026, 3, 1))
self.assertEqual(due.grace_until, datetime.date(2026, 3, 15))
def test_editing_a_plans_grace_does_not_move_an_open_period(self):
# grace_until is a stored snapshot for the same reason `amount` is: repricing the
# plan must not silently re-date an archiving already in flight.
plan = self.make_plan(grace_days=30)
due = open_period(self.club, plan=plan, start=self.today)
original = due.grace_until
plan.grace_days = 1
plan.save(update_fields=["grace_days"])
due.refresh_from_db()
self.assertEqual(due.grace_until, original)
def test_a_lead_longer_than_the_period_is_rejected(self):
with self.assertRaises(IntegrityError):
Plan.objects.create(name="Runaway", duration_months=1, renewal_lead_days=90)
def test_grace_longer_than_the_period_is_rejected(self):
with self.assertRaises(IntegrityError):
Plan.objects.create(name="Never archives", duration_months=1, grace_days=90)
def test_full_clean_reports_an_impossible_lead_as_a_form_error(self):
# Not an IntegrityError/500: a platform admin typing this into the plan form should
# be told which field is wrong.
plan = Plan(name="Runaway", duration_months=1, renewal_lead_days=90, grace_days=14)
with self.assertRaises(ValidationError) as caught:
plan.full_clean()
self.assertIn("renewal_lead_days", caught.exception.error_dict)
def test_renewal_lead_is_read_from_each_plan(self):
monthly = self.make_plan(duration_months=1, renewal_lead_days=7, grace_days=14)
club = Club.objects.create(name="Monthly FC")
# Period ends in 3 days: inside a 7-day lead, well outside an annual plan's 30.
subscribe(club, monthly, start=self.today - datetime.timedelta(days=27))
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_an_explicit_lead_days_overrides_every_plan(self):
monthly = self.make_plan(duration_months=1, renewal_lead_days=1, grace_days=14)
club = Club.objects.create(name="Override FC")
subscribe(club, monthly, start=self.today - datetime.timedelta(days=20))
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal(lead_days=30)])
class BillingNoticeTests(BillingTestBase):
"""What a club's own admins are told -- see billing/services/notices.py."""
def test_no_notice_when_nothing_is_owed(self):
due = self.bill()
record_payment(due, Decimal("500.00"))
self.assertIsNone(club_billing_notice(self.club, self.today))
def test_no_notice_for_a_club_that_was_never_billed(self):
self.assertIsNone(club_billing_notice(self.club, self.today))
def test_a_period_issued_ahead_of_its_start_is_only_informational(self):
self.bill(start=self.today + datetime.timedelta(days=10))
self.assertEqual(club_billing_notice(self.club, self.today).level, "info")
def test_an_unpaid_started_period_warns(self):
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=1))
notice = club_billing_notice(self.club, self.today)
self.assertEqual(notice.level, "warning")
self.assertEqual(notice.amount_outstanding, Decimal("500.00"))
self.assertFalse(notice.is_urgent)
def test_the_last_week_before_archiving_is_urgent(self):
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS - 2))
notice = club_billing_notice(self.club, self.today)
self.assertEqual(notice.level, "error")
self.assertTrue(notice.is_urgent)
self.assertEqual(notice.days_until_archive, 2)
def test_an_overdue_period_is_urgent_with_a_negative_countdown(self):
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 5))
notice = club_billing_notice(self.club, self.today)
self.assertEqual(notice.level, "error")
self.assertLess(notice.days_until_archive, 0)
def test_auto_archive_off_still_reports_the_debt_but_promises_no_archiving(self):
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=1), auto_archive=False)
notice = club_billing_notice(self.club, self.today)
self.assertEqual(notice.amount_outstanding, Decimal("500.00"))
self.assertFalse(notice.will_archive)
def test_the_soonest_archiving_due_is_the_one_reported(self):
self.bill(start=self.today - datetime.timedelta(days=1))
later = self.bill(start=self.today + datetime.timedelta(days=400))
self.assertNotEqual(club_billing_notice(self.club, self.today).due, later)
class BillingReminderTests(BillingTestBase):
"""Reminder emails -- see billing/services/reminders.py. Sent once per escalation
level, because the command is on a daily cron."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
user = User.objects.create_user(email="admin@ajax.example", password="pw-secret-123")
member = Member.objects.create(user=user, first_name="Ada", last_name="Admin")
ClubRole.objects.create(club=cls.club, member=member, role=ClubRole.Roles.ADMIN)
subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=1))
cls.due = cls.club.dues.first()
def test_a_reminder_goes_to_the_club_admins(self):
self.assertEqual(admin_emails(self.club), ["admin@ajax.example"])
def make_overdue_club(self, name, *, auto_archive):
# A separate club rather than resubscribing self.club: open_period() caches
# club.subscription on the instance it's given, so calling subscribe() twice
# against the SAME Python object -- only possible by reusing one across two calls,
# never a real request -- would read the first call's now-stale cached subscription.
club = Club.objects.create(name=name)
user = User.objects.create_user(email=f"{name.lower()}@ajax.example", password="pw-secret-123")
member = Member.objects.create(user=user, first_name="A", last_name="Admin")
ClubRole.objects.create(club=club, member=member, role=ClubRole.Roles.ADMIN)
subscribe(club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 5), auto_archive=auto_archive)
return club
def test_an_overdue_reminder_threatens_archiving_when_it_will_happen(self):
club = self.make_overdue_club("Archive On", auto_archive=True)
notice = club_billing_notice(club, self.today)
self.assertTrue(notice.will_archive)
send_reminder(club, notice, recipients=["archive-on@ajax.example"])
self.assertIn("about to be archived", mail.outbox[0].subject)
self.assertIn("archived", mail.outbox[0].body)
def test_an_overdue_reminder_does_not_threaten_archiving_when_auto_archive_is_off(self):
# This is the bug the two-column-modal review turned up: the subject branched only
# on notice.level, so a club that will NEVER be archived still got told it was
# "about to be archived" -- while the body correctly said otherwise.
club = self.make_overdue_club("Archive Off", auto_archive=False)
notice = club_billing_notice(club, self.today)
self.assertFalse(notice.will_archive)
send_reminder(club, notice, recipients=["archive-off@ajax.example"])
self.assertNotIn("about to be archived", mail.outbox[0].subject)
self.assertNotIn("archived", mail.outbox[0].body)
self.assertIn("good standing", mail.outbox[0].body)
def test_sending_records_the_level_and_fills_the_outbox(self):
notice = club_billing_notice(self.club, self.today)
send_reminder(self.club, notice, recipients=["admin@ajax.example"])
self.due.refresh_from_db()
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(self.due.last_reminder_level, notice.level)
self.assertIsNotNone(self.due.last_reminder_sent_at)
def test_a_second_run_at_the_same_level_sends_nothing(self):
notice = club_billing_notice(self.club, self.today)
send_reminder(self.club, notice, recipients=["admin@ajax.example"])
self.due.refresh_from_db()
results = reminders_to_send([self.club], self.today)
self.assertFalse(results[0].sent)
self.assertIn("already reminded", results[0].skipped_reason)
def test_an_escalation_gets_through(self):
send_reminder(self.club, club_billing_notice(self.club, self.today), recipients=["admin@ajax.example"])
# Far enough on that the same due is now urgent rather than merely a warning.
later = self.today + datetime.timedelta(days=DEFAULT_GRACE_DAYS)
results = reminders_to_send([self.club], later)
self.assertTrue(results[0].sent)
self.assertEqual(results[0].notice.level, "error")
def test_force_resends_at_the_same_level(self):
send_reminder(self.club, club_billing_notice(self.club, self.today), recipients=["admin@ajax.example"])
self.due.refresh_from_db()
self.assertTrue(reminders_to_send([self.club], self.today, force=True)[0].sent)
def test_a_club_with_no_reachable_admin_is_reported_not_skipped_silently(self):
ClubRole.objects.all().delete()
results = reminders_to_send([self.club], self.today)
self.assertFalse(results[0].sent)
self.assertIn("no club admin", results[0].skipped_reason)
def test_a_settled_club_produces_no_reminder(self):
record_payment(self.due, Decimal("500.00"))
self.assertEqual(reminders_to_send([self.club], self.today), [])
class PlanVisibilityTests(BillingTestBase):
"""Plan.objects.visible() -- see PlanQuerySet."""
def test_a_plain_plan_is_visible(self):
self.assertIn(self.plan, Plan.objects.visible())
def test_a_soft_deleted_plan_is_excluded(self):
self.plan.deleted_at = timezone.now()
self.plan.save(update_fields=["deleted_at"])
self.assertNotIn(self.plan, Plan.objects.visible())
def test_the_default_manager_still_returns_a_soft_deleted_plan(self):
# Django admin, and anything reading historical data, must still be able to find it.
self.plan.deleted_at = timezone.now()
self.plan.save(update_fields=["deleted_at"])
self.assertIn(self.plan, Plan.objects.all())
class PlanDeletionTests(BillingTestBase):
"""billing.services.plans -- see its module docstring for the full reasoning."""
def test_a_never_billed_plan_is_hard_deleted(self):
unused = Plan.objects.create(name="Unused")
impact = delete_plan(unused)
self.assertTrue(impact.will_hard_delete)
self.assertFalse(Plan.objects.filter(pk=unused.pk).exists())
def test_a_plan_with_a_due_cannot_be_hard_deleted(self):
self.bill()
impact = delete_plan(self.plan)
self.assertFalse(impact.will_hard_delete)
self.assertTrue(Plan.objects.filter(pk=self.plan.pk).exists())
def test_a_cancelled_due_still_protects_the_plan(self):
# PROTECT does not care about the referencing row's own status -- a cancelled due is
# still a row, and financial history includes rows nobody expects to see again.
due = self.bill()
due.status = Due.Status.CANCELLED
due.save(update_fields=["status"])
impact = delete_plan(self.plan)
self.assertFalse(impact.will_hard_delete)
def test_soft_delete_marks_the_plan_inactive_and_deleted(self):
subscribe(self.club, self.plan)
delete_plan(self.plan)
self.plan.refresh_from_db()
self.assertTrue(self.plan.is_deleted)
self.assertFalse(self.plan.is_active)
def test_deleting_unsubscribes_the_club_entirely_rather_than_nulling_a_field(self):
subscribe(self.club, self.plan)
delete_plan(self.plan)
self.assertFalse(Subscription.objects.filter(club=self.club).exists())
def test_a_deleted_plans_historical_due_is_untouched(self):
subscribe(self.club, self.plan)
due = self.club.dues.first()
amount, period_end, grace_until = due.amount, due.period_end, due.grace_until
delete_plan(self.plan)
due.refresh_from_db()
self.assertEqual(due.plan_id, self.plan.pk)
self.assertEqual(due.amount, amount)
self.assertEqual(due.period_end, period_end)
self.assertEqual(due.grace_until, grace_until)
def test_a_club_not_on_the_plan_is_unaffected(self):
other_plan = Plan.objects.create(name="Other")
PlanPrice.objects.create(plan=other_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
untouched = Club.objects.create(name="Untouched FC")
subscribe(untouched, other_plan)
delete_plan(self.plan)
self.assertTrue(Subscription.objects.filter(club=untouched, plan=other_plan).exists())
def test_a_trial_scheduled_to_convert_to_the_deleted_plan_is_cleared(self):
trial_plan = Plan.objects.create(name="Trial", is_trial=True, duration_months=2, renewal_lead_days=7, grace_days=14)
PlanPrice.objects.create(plan=trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
club = Club.objects.create(name="Mid Trial FC")
start_trial(club, trial_plan, post_trial_plan=self.plan)
impact = delete_plan(self.plan)
self.assertEqual([c.pk for c in impact.broken_trial_clubs], [club.pk])
club.refresh_from_db()
subscription = club.subscription
self.assertEqual(subscription.plan, trial_plan)
self.assertIsNone(subscription.trial_ends_at)
self.assertIsNone(subscription.post_trial_plan)
def test_a_club_currently_on_the_plan_is_not_also_counted_as_a_broken_trial(self):
subscribe(self.club, self.plan)
impact = delete_plan(self.plan)
self.assertEqual(impact.unsubscribed_clubs, [self.club])
self.assertEqual(impact.broken_trial_clubs, [])
def test_plan_deletion_impact_is_read_only(self):
subscribe(self.club, self.plan)
plan_deletion_impact(self.plan)
self.assertTrue(Subscription.objects.filter(club=self.club).exists())
self.assertFalse(self.plan.is_deleted)

View File

@@ -1,17 +1,25 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Club, ClubMembership, ClubRole, Season
from .models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
@admin.register(Club)
class ClubAdmin(admin.ModelAdmin):
list_display = ["name", "slug"]
list_display = ["name", "slug", "sport_type"]
list_filter = ["sport_type"]
search_fields = ["name", "slug"]
prepopulated_fields = {"slug": ["name"]}
ordering = ["name"]
@admin.register(Sponsor)
class SponsorAdmin(admin.ModelAdmin):
list_display = ["name", "club", "start_date", "end_date"]
list_filter = ["club"]
search_fields = ["name"]
@admin.register(Season)
class SeasonAdmin(admin.ModelAdmin):
list_display = ["__str__", "club", "start_date", "end_date"]
@@ -20,17 +28,42 @@ class SeasonAdmin(admin.ModelAdmin):
ordering = ["club", "-start_date"]
class FeePaymentInline(admin.TabularInline):
model = FeePayment
extra = 0
readonly_fields = ["recorded_by"]
@admin.register(ClubMembership)
class ClubMembershipAdmin(admin.ModelAdmin):
list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "license"]
list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "fee_amount", "amount_paid", "license"]
search_fields = ["club__name", "member__last_name", "member__first_name", "license"]
list_filter = ["club", "season", "status", "fee_status"]
raw_id_fields = ["member"]
# Money is settled by club.services.fees, which re-derives fee_status from the payments.
readonly_fields = ["amount_paid", "fee_status"]
fieldsets = [
[None, {"fields": ["club", "season", "member"]}],
[_("Membership"), {"fields": ["license", "status", "fee_status"]}],
[_("Membership"), {"fields": ["license", "status", "fee_status", "fee_amount", "amount_paid"]}],
[_("Dates"), {"fields": ["signed_up_at", "activated_at"]}],
]
inlines = [FeePaymentInline]
@admin.register(FeePayment)
class FeePaymentAdmin(admin.ModelAdmin):
list_display = ["membership", "amount", "method", "paid_at", "recorded_by"]
list_filter = ["method"]
search_fields = ["membership__club__name", "membership__member__last_name", "reference"]
@admin.register(DuesInvoice)
class DuesInvoiceAdmin(admin.ModelAdmin):
list_display = ["number", "membership", "amount", "due_date", "sent_at", "reminder_count"]
list_filter = ["club"]
search_fields = ["number", "membership__member__last_name", "membership__member__first_name"]
raw_id_fields = ["membership"]
readonly_fields = ["number"]
@admin.register(ClubRole)
@@ -39,3 +72,19 @@ class ClubRoleAdmin(admin.ModelAdmin):
search_fields = ["club__name", "member__last_name", "member__first_name"]
list_filter = ["club", "role"]
raw_id_fields = ["member"]
@admin.register(OnboardingRequirement)
class OnboardingRequirementAdmin(admin.ModelAdmin):
list_display = ["name", "club", "requires_document", "is_active"]
list_filter = ["club", "is_active", "requires_document"]
search_fields = ["name", "club__name"]
ordering = ["club", "name"]
@admin.register(MemberRequirementStatus)
class MemberRequirementStatusAdmin(admin.ModelAdmin):
list_display = ["membership", "requirement", "is_complete", "completed_at", "completed_by"]
list_filter = ["requirement__club", "is_complete", "requirement"]
search_fields = ["membership__member__last_name", "membership__member__first_name", "requirement__name"]
raw_id_fields = ["membership"]

47
club/api.py Normal file
View File

@@ -0,0 +1,47 @@
"""Public read-only sponsors endpoint -- see api/urls.py for how this is
mounted.
"""
import uuid
from datetime import date
from ninja import Router, Schema
from api.errors import require_club
from .services.sponsors import active_sponsors
router = Router(tags=["sponsors"])
class SponsorOut(Schema):
id: uuid.UUID
name: str
logo_url: str | None
logo_width: int | None
logo_height: int | None
url: str | None
start_date: date
end_date: date | None
def _to_sponsor_out(sponsor, request) -> SponsorOut:
return SponsorOut(
id=sponsor.pk,
name=sponsor.name,
logo_url=request.build_absolute_uri(sponsor.logo.url) if sponsor.logo else None,
logo_width=sponsor.logo_width,
logo_height=sponsor.logo_height,
url=sponsor.url or None,
start_date=sponsor.start_date,
end_date=sponsor.end_date,
)
@router.get("/", response=list[SponsorOut], summary="Active sponsors")
def list_sponsors(request, randomize: bool = False):
"""See club.services.sponsors.active_sponsors for what "active" means and
why `randomize=true` shuffles in Python rather than in SQL."""
club = require_club(request)
sponsors = active_sponsors(club, randomize=randomize)
return [_to_sponsor_out(sponsor, request) for sponsor in sponsors]

View File

@@ -1,23 +1,46 @@
"""Tenant-aware page branding.
Every page inherits its chrome from ``base_template``. On a club subdomain that
resolves to the club-branded skin, on the base domain to the RosterChief one, so
the auth screens (login, password reset, MFA, passkeys — anything allauth ships,
now or later) follow the tenant without a single template of their own knowing
that clubs exist.
resolves to the club-branded skin, on the base domain to the platform one — the
control panel's own industrial design system (assets/controlpanel.css) — so the
auth screens (login, password reset, MFA, passkeys — anything allauth ships, now
or later) follow the tenant without a single template of their own knowing that
clubs exist. templates/403.html and templates/maintenance.html extend
``base_template`` directly too, so they follow the same split.
The control panel deliberately does *not* use this: it hardcodes the platform
base, so no branding bug can ever dress the platform panel up as a club.
A club subdomain serves two very different chromes, though: the public club site
(daisyUI, assets/app.css) and the management app (assets/management.css) live on
the same tenant, distinguished only by path. Without the checks below, a staff
member clicking "Change password" from inside the management app would land back
on the club's *public* skin -- jarring, and visually nothing like where they just
were. MANAGEMENT_BASE_TEMPLATE picks up management/base.html's own chrome instead,
for two cases: a request path directly under /manage/ (matching management/urls.py's
own hardcoded "manage/" prefix in rosterchief/urls.py -- e.g. a 403 on a management
page), and the session flag ClubStaffRequiredMixin.dispatch sets on every management
view (club/mixins.py) -- needed because allauth's password-change/MFA/logout screens
live under /accounts/, outside /manage/, so the path check alone can't see they were
reached from the management app's own user menu.
The control panel's own pages deliberately do *not* use this: controlpanel/base.html
hardcodes itself, so no branding bug can ever dress the platform panel up as a club.
"""
PLATFORM_BASE_TEMPLATE = "_platform_base.html"
PLATFORM_BASE_TEMPLATE = "controlpanel/_auth_base.html"
CLUB_BASE_TEMPLATE = "_club_base.html"
MANAGEMENT_BASE_TEMPLATE = "management/_auth_base.html"
def branding(request):
club = getattr(request, "club", None) # set by ClubTenantMiddleware
if club and (request.path.startswith("/manage/") or request.session.get("management_context")):
base_template = MANAGEMENT_BASE_TEMPLATE
elif club:
base_template = CLUB_BASE_TEMPLATE
else:
base_template = PLATFORM_BASE_TEMPLATE
return {
"club": club,
"base_template": CLUB_BASE_TEMPLATE if club else PLATFORM_BASE_TEMPLATE,
"base_template": base_template,
}

View File

View File

View File

@@ -0,0 +1,57 @@
"""Generate seasons ahead of time for every active club.
Meant to run on a schedule (cron): safe to call repeatedly, since generate_seasons
skips whatever already exists. No --dry-run/--commit gate on generation itself --
unlike archiving a club or billing it, creating a future season row is additive
and idempotent, same reasoning as extend_event_series (materialising occurrences).
--resync is different: it can delete rows (any season that doesn't match a
club's *current* season_start/season_duration_months, and isn't referenced by a
membership), so it defaults to reporting only -- pass --commit alongside it to
actually remove anything.
"""
from dateutil.relativedelta import relativedelta
from django.utils import timezone
from club.models import Club
from club.services.seasons import generate_seasons, resync_seasons
from features.commands import MaintenanceAwareCommand
class Command(MaintenanceAwareCommand):
help = "Generate seasons up to N years ahead for every active club (default 2)."
def add_arguments(self, parser):
parser.add_argument("--years", type=int, default=2, help="How many years ahead to generate seasons for (default 2).")
parser.add_argument("--resync", action="store_true", help="Before generating, remove any existing seasons that don't match the club's current settings (skips any still referenced by a membership).")
parser.add_argument("--commit", action="store_true", help="With --resync, actually delete the seasons found to be wrong. Without it, --resync only reports what it would remove.")
def handle(self, *args, **options):
until = timezone.localdate() + relativedelta(years=options["years"])
clubs = Club.objects.active()
if options["resync"]:
total_removed, total_kept = 0, 0
for club in clubs:
removed, kept = resync_seasons(club, until, commit=options["commit"])
total_removed += len(removed)
total_kept += len(kept)
if removed:
verb = "Removed" if options["commit"] else "Would remove"
self.stdout.write(f"{club}: {verb} {len(removed)} season(s) that no longer match its settings.")
if kept:
self.stdout.write(self.style.WARNING(f"{club}: {len(kept)} season(s) don't match its settings but are still in use, left alone."))
if not options["commit"] and total_removed:
self.stdout.write(self.style.WARNING("Dry run -- pass --commit to actually delete these."))
self.stdout.write(self.style.SUCCESS(f"Resync: {total_removed} season(s) {'removed' if options['commit'] else 'to remove'}, {total_kept} kept (in use)."))
total = 0
for club in clubs:
created = generate_seasons(club, until)
total += len(created)
if created:
self.stdout.write(f"{club}: generated {len(created)} season(s).")
self.stdout.write(self.style.SUCCESS(f"Done. Generated {total} season(s) across {clubs.count()} club(s)."))

View File

@@ -0,0 +1,19 @@
# Generated by Django 6.0.6 on 2026-07-24 16:30
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0013_club_created_club_modified_clubmembership_created_and_more'),
]
operations = [
migrations.AddField(
model_name='club',
name='secondary_color',
field=models.CharField(blank=True, help_text="Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour.", max_length=7, validators=[django.core.validators.RegexValidator('^#[0-9a-fA-F]{6}$', 'Enter a colour as a hex value, e.g. #be185d.')], verbose_name='secondary colour'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0.6 on 2026-07-24 16:40
import club.models
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0014_club_secondary_color'),
]
operations = [
migrations.AlterField(
model_name='club',
name='logo',
field=models.FileField(blank=True, help_text="Shown on the club's own pages. Without one, the club's initials are used.", upload_to=club.models.club_logo_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'])], verbose_name='logo'),
),
]

View File

@@ -0,0 +1,50 @@
# Generated by Django 6.0.6 on 2026-08-03 11:22
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
import uuid
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0015_alter_club_logo'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='clubmembership',
name='amount_paid',
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), help_text='Kept in step with payments by the fee service; not hand-edited.', max_digits=10, verbose_name='amount paid'),
),
migrations.AddField(
model_name='clubmembership',
name='fee_amount',
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='fee amount'),
),
migrations.CreateModel(
name='FeePayment',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')),
('method', models.CharField(choices=[('bank_transfer', 'bank transfer'), ('cash', 'cash'), ('card', 'card'), ('other', 'other')], default='bank_transfer', max_length=20, verbose_name='method')),
('reference', models.CharField(blank=True, help_text='Bank reference, transaction id — whatever lets you find this again.', max_length=255, verbose_name='reference')),
('paid_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='paid at')),
('note', models.TextField(blank=True, verbose_name='note')),
('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='club.clubmembership', verbose_name='membership')),
('recorded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recorded_fee_payments', to=settings.AUTH_USER_MODEL, verbose_name='recorded by')),
],
options={
'verbose_name': 'fee payment',
'verbose_name_plural': 'fee payments',
'ordering': ['-paid_at'],
},
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0.6 on 2026-08-03 14:30
import datetime
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more'),
]
operations = [
migrations.AddField(
model_name='club',
name='season_duration_months',
field=models.PositiveSmallIntegerField(default=12, help_text='How many months a season lasts, counted from its start date.', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(24)], verbose_name='season duration (months)'),
),
migrations.AddField(
model_name='club',
name='season_start',
field=models.DateField(default=datetime.date(2000, 8, 1), help_text='Which day of the year a season begins — only the month and day are used, the year is ignored.', verbose_name='season start'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-04 22:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0017_club_season_duration_months_club_season_start'),
]
operations = [
migrations.AddField(
model_name='club',
name='sport_type',
field=models.CharField(choices=[('ice_hockey', 'Ice hockey'), ('other', 'Other')], default='other', help_text='Which sport this club plays -- determines which competitions and score fetchers are relevant to it.', max_length=20, verbose_name='sport'),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 6.0.6 on 2026-08-06 15:02
import club.models
import django.core.validators
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0018_club_sport_type'),
]
operations = [
migrations.CreateModel(
name='Sponsor',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255, verbose_name='name')),
('logo', models.FileField(blank=True, upload_to=club.models.sponsor_logo_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'])], verbose_name='logo')),
('url', models.URLField(blank=True, help_text="The sponsor's own website, if they have one.", verbose_name='URL')),
('start_date', models.DateField(verbose_name='start date')),
('end_date', models.DateField(blank=True, help_text='Leave blank to keep this sponsor active indefinitely once it starts.', null=True, verbose_name='end date')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'sponsor',
'verbose_name_plural': 'sponsors',
'ordering': ['name'],
},
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 6.0.6 on 2026-08-07 14:26
from django.db import migrations, models
from club.services.images import get_image_dimensions
def backfill_logo_dimensions(apps, schema_editor):
"""Existing sponsors uploaded a logo before these fields existed, so
Sponsor.save()'s new dimension computation never ran for them."""
Sponsor = apps.get_model("club", "Sponsor")
for sponsor in Sponsor.objects.exclude(logo=""):
width, height = get_image_dimensions(sponsor.logo)
Sponsor.objects.filter(pk=sponsor.pk).update(logo_width=width, logo_height=height)
class Migration(migrations.Migration):
dependencies = [
('club', '0019_sponsor'),
]
operations = [
migrations.AddField(
model_name='sponsor',
name='logo_height',
field=models.PositiveIntegerField(blank=True, editable=False, null=True, verbose_name='logo height'),
),
migrations.AddField(
model_name='sponsor',
name='logo_width',
field=models.PositiveIntegerField(blank=True, editable=False, null=True, verbose_name='logo width'),
),
migrations.RunPython(backfill_logo_dimensions, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-09 18:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0020_sponsor_logo_height_sponsor_logo_width'),
]
operations = [
migrations.AddField(
model_name='club',
name='legal_name',
field=models.CharField(blank=True, help_text='Full registered name (e.g. including a legal form like VZW/ASBL), used on official documents. Falls back to the name above when left blank.', max_length=255, verbose_name='legal name'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.6 on 2026-08-11 13:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0021_club_legal_name'),
]
operations = [
migrations.AddField(
model_name='clubmembership',
name='kind',
field=models.CharField(choices=[('member', 'member'), ('guardian', 'guardian')], default='member', help_text="A guardian is attached to the club only as a parent of a member -- they hold the login, but don't count as a member themselves and owe no fee. A parent who also plays is a member.", max_length=20, verbose_name='kind'),
),
migrations.AlterField(
model_name='club',
name='legal_name',
field=models.CharField(blank=True, help_text='Full registered name (e.g. including a legal form like VZW/ASBL), used on official documents. Falls back to club name if blank.', max_length=255, verbose_name='legal name'),
),
]

View File

@@ -0,0 +1,58 @@
"""Reclassify existing family parents as guardians.
Before ``kind`` existed, members/services/family.py enrolled a parent exactly like
the child they were registering, so every parent already in the database holds a
full membership and is counted as a member.
The guard matters more than the rule: anyone who is *also* on a roster, on a
team's staff, or holds an elevated ClubRole is left as a member. A parent who
plays, coaches or runs the club is a member who happens to have children there,
and silently demoting them would strip them out of the member list and their own
team's roster eligibility. Anything ambiguous stays as it is -- an admin can flip
a membership to guardian by hand, which is cheap; noticing that someone quietly
vanished is not.
"""
from django.db import migrations
def backfill_guardians(apps, schema_editor):
ClubMembership = apps.get_model("club", "ClubMembership")
FamilyMembership = apps.get_model("members", "FamilyMembership")
TeamMembership = apps.get_model("teams", "TeamMembership")
StaffAssignment = apps.get_model("teams", "StaffAssignment")
ClubRole = apps.get_model("club", "ClubRole")
parent_ids = set(FamilyMembership.objects.filter(role__in=["parent", "guardian"]).values_list("member_id", flat=True))
if not parent_ids:
return
for membership in ClubMembership.objects.filter(member_id__in=parent_ids).iterator():
club_id, member_id = membership.club_id, membership.member_id
plays = TeamMembership.objects.filter(member_id=member_id, team__club_id=club_id).exists()
on_staff = StaffAssignment.objects.filter(member_id=member_id, team__club_id=club_id).exists()
runs_the_club = ClubRole.objects.filter(member_id=member_id, club_id=club_id, role__in=["admin", "editor"]).exists()
if plays or on_staff or runs_the_club:
continue
membership.kind = "guardian"
# Guardians owe nothing; clear any fee the old parent-as-member flow left behind.
membership.fee_amount = 0
membership.save(update_fields=["kind", "fee_amount"])
def restore_members(apps, schema_editor):
"""Everything was a member before this migration ran."""
ClubMembership = apps.get_model("club", "ClubMembership")
ClubMembership.objects.filter(kind="guardian").update(kind="member")
class Migration(migrations.Migration):
dependencies = [
("club", "0022_clubmembership_kind"),
("members", "0004_group_groupmembership_and_more"),
("teams", "0009_team_referee_management"),
]
operations = [migrations.RunPython(backfill_guardians, restore_members)]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-11 16:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0023_backfill_guardian_memberships'),
]
operations = [
migrations.AddField(
model_name='club',
name='contact_email',
field=models.EmailField(blank=True, help_text="The club's public address, shown to people the club writes to or asks to get in touch -- e.g. a parent claiming a child. Falls back to nothing being shown at all, so it's worth setting.", max_length=254, verbose_name='contact email'),
),
]

View File

@@ -0,0 +1,65 @@
# Generated by Django 6.0.6 on 2026-08-16 20:42
import club.models
import django.core.files.storage
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0024_club_contact_email'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='OnboardingRequirement',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, verbose_name='name')),
('description', models.TextField(blank=True, help_text="Shown to staff on the member's checklist.", verbose_name='description')),
('requires_document', models.BooleanField(default=False, help_text='Staff can attach a file (e.g. the certificate itself) when marking this complete.', verbose_name='requires a document')),
('is_active', models.BooleanField(default=True, help_text='Inactive requirements no longer apply to new memberships, but existing statuses are kept.', verbose_name='active')),
('order', models.PositiveIntegerField(default=0, help_text='Lower numbers show first on the checklist.', verbose_name='order')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'onboarding requirement',
'verbose_name_plural': 'onboarding requirements',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='MemberRequirementStatus',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_complete', models.BooleanField(default=False, verbose_name='complete')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='completed at')),
('document', models.FileField(blank=True, help_text="Stored privately -- readable only through this member's own page, never a direct link.", storage=django.core.files.storage.FileSystemStorage(base_url=None, location='/Users/bernard/Code/PycharmProjects/RosterChief/private_media'), upload_to=club.models.onboarding_document_path, verbose_name='document')),
('note', models.TextField(blank=True, help_text='Staff-only, e.g. how or when this was received.', verbose_name='note')),
('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='completed by')),
('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requirement_statuses', to='club.clubmembership', verbose_name='membership')),
('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statuses', to='club.onboardingrequirement', verbose_name='requirement')),
],
options={
'verbose_name': 'member requirement status',
'verbose_name_plural': 'member requirement statuses',
},
),
migrations.AddConstraint(
model_name='onboardingrequirement',
constraint=models.UniqueConstraint(fields=('club', 'name'), name='unique_onboarding_requirement_name_per_club'),
),
migrations.AddConstraint(
model_name='memberrequirementstatus',
constraint=models.UniqueConstraint(fields=('membership', 'requirement'), name='unique_requirement_status_per_membership'),
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 6.0.6 on 2026-08-17 11:39
import club.models
import rosterchief.storage
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0025_onboardingrequirement_memberrequirementstatus_and_more'),
]
operations = [
migrations.AddField(
model_name='memberrequirementstatus',
name='is_bypassed',
field=models.BooleanField(default=False, verbose_name='bypassed'),
),
migrations.AddField(
model_name='onboardingrequirement',
name='blocked_event_kinds',
field=models.JSONField(blank=True, default=list, help_text="Event kinds a member can't be invited to or selected for while this is open. Empty means purely informational.", verbose_name='blocks selection for'),
),
migrations.AlterField(
model_name='clubrole',
name='role',
field=models.CharField(choices=[('admin', 'admin'), ('member', 'member'), ('editor', 'editor'), ('member_admin', 'member admin')], default='member', max_length=250, verbose_name='role'),
),
migrations.AlterField(
model_name='memberrequirementstatus',
name='document',
field=models.FileField(blank=True, help_text="Stored privately -- readable only through this member's own page, never a direct link.", storage=rosterchief.storage.PrivateStorage(location='/Users/bernard/Code/PycharmProjects/RosterChief/private_media'), upload_to=club.models.onboarding_document_path, verbose_name='document'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-20 07:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0026_memberrequirementstatus_is_bypassed_and_more'),
]
operations = [
migrations.AddField(
model_name='club',
name='website',
field=models.URLField(blank=True, help_text="The club's own site, if it has one -- shown alongside its RosterChief pages, not used for anything else yet.", verbose_name='website'),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 6.0.6 on 2026-08-20 07:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('club', '0027_club_website'),
]
operations = [
migrations.AlterModelOptions(
name='onboardingrequirement',
options={'ordering': ['name'], 'verbose_name': 'onboarding requirement', 'verbose_name_plural': 'onboarding requirements'},
),
migrations.RemoveField(
model_name='onboardingrequirement',
name='order',
),
]

View File

@@ -0,0 +1,39 @@
# Generated by Django 6.0.6 on 2026-08-20 07:46
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0028_alter_onboardingrequirement_options_and_more'),
]
operations = [
migrations.CreateModel(
name='DuesInvoice',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('number', models.CharField(blank=True, max_length=255, verbose_name='number')),
('amount', models.DecimalField(decimal_places=2, help_text='The outstanding balance at the time this was sent — not re-read from the membership afterwards.', max_digits=10, verbose_name='amount')),
('due_date', models.DateField(verbose_name='due date')),
('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='sent at')),
('sent_to_email', models.EmailField(blank=True, max_length=254, verbose_name='sent to')),
('sent_to_guardian', models.BooleanField(default=False, help_text="The member had no email on file, so a parent/guardian's was used instead.", verbose_name='sent to a parent/guardian')),
('last_reminder_sent_at', models.DateTimeField(blank=True, null=True, verbose_name='last reminder sent at')),
('reminder_count', models.PositiveIntegerField(default=0, verbose_name='reminders sent')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
('membership', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='dues_invoice', to='club.clubmembership', verbose_name='membership')),
],
options={
'verbose_name': 'dues invoice',
'verbose_name_plural': 'dues invoices',
'ordering': ['-sent_at'],
'constraints': [models.UniqueConstraint(fields=('club', 'number'), name='unique_dues_invoice_number_per_club')],
},
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 6.0.6 on 2026-08-21 15:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0029_duesinvoice'),
]
operations = [
migrations.AddField(
model_name='club',
name='legal_address',
field=models.CharField(blank=True, help_text="Street address for official documents (invoices, the referee payment form). Falls back to the home location's address when left blank -- set this when the club's registered address isn't where it actually plays.", max_length=255, verbose_name='legal address'),
),
migrations.AddField(
model_name='club',
name='legal_city',
field=models.CharField(blank=True, max_length=255, verbose_name='legal city'),
),
migrations.AddField(
model_name='club',
name='legal_zip_code',
field=models.CharField(blank=True, max_length=255, verbose_name='legal zip code'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 6.0.6 on 2026-08-21 16:10
import club.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0030_club_legal_address_club_legal_city_and_more'),
]
operations = [
migrations.AddField(
model_name='club',
name='event_background',
field=models.ImageField(blank=True, help_text='A generic background photo for event screens in the mobile app, shown in black & white under a dark gradient. Without one, a plain dark background is used.', upload_to=club.models.club_event_background_path, verbose_name='event background'),
),
]

162
club/mixins.py Normal file
View File

@@ -0,0 +1,162 @@
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import Http404
from waffle import flag_is_active
from members.models import Group
from .services.access import can_add_news, can_edit_news, can_manage_members, can_publish_news, groups_manageable_by, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
"""Gate for the club-facing management UI.
Two rules, the mirror image of ``controlpanel.mixins.PlatformStaffRequiredMixin``:
* **Club subdomain only.** This UI manages *one* club, so it doesn't exist on the
base domain — same reasoning as the control panel refusing to exist on a club
subdomain, just inverted.
* **Staff only.** ADMIN/EDITOR, or a current-season ``StaffAssignment`` (coach,
team manager, ...) — see ``has_management_access``. The plain MEMBER role every
active player/club member holds automatically does *not* count: a club member
with neither is a player/parent, and belongs in the separate app that serves
them.
"""
def dispatch(self, request, *args, **kwargs):
if getattr(request, "club", None) is None:
raise Http404("The management app is not available on the base domain.")
# Read by club/context_processors.py's branding() -- allauth's password-change/MFA/
# logout screens live under /accounts/, not /manage/, so a path check alone can't
# tell they were reached from the management app's own user menu. This sticks for
# the rest of the session (nothing clears it back to False on a public-site visit),
# which is the right default for the common case of one person, one role.
request.session["management_context"] = True
return super().dispatch(request, *args, **kwargs)
def test_func(self):
return has_management_access(self.request.user, self.request.club)
class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN role only (a platform superuser always passes too, see
is_club_admin) — genuinely admin-only ground: Finance/Shop, Club identity,
Sponsors, seasons, and granting/revoking ClubRole itself. Everything a
MEMBER_ADMIN may also touch uses MemberAdminRequiredMixin below instead."""
def test_func(self):
return is_club_admin(self.request.user, self.request.club)
class MemberAdminRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN, a platform superuser, or MEMBER_ADMIN specifically -- full read/write
on people: members, families, groups, parent claims, member import, teams
(roster/staff/CRUD), referee levels, referee management, and onboarding
requirements. Deliberately does NOT cover Finance/Shop, Club identity,
Sponsors, or role-granting (role_list/role_create/role_revoke stay
ClubAdminRequiredMixin) -- a MEMBER_ADMIN must never be able to grant
themselves, or anyone else, real ADMIN."""
def test_func(self):
return can_manage_members(self.request.user, self.request.club)
class FeatureRequiredMixin(ClubAdminRequiredMixin):
"""Gate for a whole management section (shop, forms, ...) this club doesn't
have at all unless its waffle Flag (see the ``features`` app, set per-club
from the control panel's Features page) is active for it. Checked before
the admin-only test below and as a plain 404 rather than folded into
``test_func``'s 403: a club with the feature off doesn't have a permissions
problem, the section just doesn't exist there, same reasoning as
``ClubStaffRequiredMixin`` 404ing the whole app off the base domain.
Subclasses set ``feature_flag`` to the Flag's name, e.g. ``"shop"``.
"""
feature_flag: str = ""
def dispatch(self, request, *args, **kwargs):
club = getattr(request, "club", None)
if club is not None and not flag_is_active(request, self.feature_flag):
raise Http404(f"The “{self.feature_flag}” feature isn't enabled for this club.")
return super().dispatch(request, *args, **kwargs)
class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
"""A manager of *this* team, or a club ADMIN. ``self.get_team()`` must return the
``Team`` the view acts on (e.g. from the URL's ``pk``) before ``test_func`` runs.
"""
def get_team(self):
raise NotImplementedError("Subclasses must return the Team this view acts on.")
def test_func(self):
user, club = self.request.user, self.request.club
if is_club_admin(user, club):
return True
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
class EventManagerRequiredMixin(ClubStaffRequiredMixin):
"""Admin, a manager of at least one of this event's/series' *current*
teams, or a member of at least one of its groups. ``self.get_teams()``
must return the Team queryset/iterable the view acts on (e.g.
``self.get_object().teams.all()``) before ``test_func`` runs; override
``get_groups()`` the same way for a view whose object can carry groups
(it defaults to none, so most subclasses only need get_teams()). Events/
series aren't single-team/-group like a roster entry -- both are M2M, so
authority is "belongs to at least one", not "belongs to the one". A
club_wide event has no equivalent membership claim to check -- it's
admin-only to create in the first place (EventForm), so the plain
is_club_admin check below already covers it."""
def get_teams(self):
raise NotImplementedError("Subclasses must return the Teams this view acts on.")
def get_groups(self):
return Group.objects.none()
def test_func(self):
user, club = self.request.user, self.request.club
if is_club_admin(user, club):
return True
if teams_managed_by(user, club).filter(pk__in=self.get_teams().values_list("pk", flat=True)).exists():
return True
return groups_manageable_by(user, club).filter(pk__in=self.get_groups().values_list("pk", flat=True)).exists()
class ManagementPositionRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN, or anyone with a current-season *management*-position
StaffAssignment on any team -- unlike ``TeamManagerRequiredMixin``, the
entity here (Location, Opponent, ...) isn't scoped to one team, so "manager
of this team" doesn't apply; any management position qualifies."""
def test_func(self):
return is_club_admin(self.request.user, self.request.club) or is_coach_manager(self.request.user, self.request.club)
class NewsAuthorRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
author club content in the first place (creating a draft)."""
def test_func(self):
return can_add_news(self.request.user, self.request.club)
class NewsPublisherRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN/EDITOR only -- the release-flow gate for pushing a news item live
(or pulling it back)."""
def test_func(self):
return can_publish_news(self.request.user, self.request.club)
class NewsEditRequiredMixin(ClubStaffRequiredMixin):
"""Whoever may edit *this* news item right now: broad while it's a draft,
editor/admin-only once published. ``self.get_news_item()`` must return the
News the view acts on before ``test_func`` runs."""
def get_news_item(self):
raise NotImplementedError("Subclasses must return the News item this view acts on.")
def test_func(self):
return can_edit_news(self.request.user, self.get_news_item())

View File

@@ -1,12 +1,17 @@
import datetime
from decimal import Decimal
from django.core.validators import RegexValidator
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
from django.db import IntegrityError, models, transaction
from django.db.models import Q, UniqueConstraint
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
from rosterchief.storage import private_storage
class ClubManager(models.Manager):
@@ -27,11 +32,56 @@ def club_logo_path(instance: Club, filename: str) -> str:
return f"clubs/{instance.slug}/{filename}"
class Club(UUIDModel):
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
def club_event_background_path(instance: Club, filename: str) -> str:
return f"clubs/{instance.slug}/event-background/{filename}"
logo = models.ImageField(_("logo"), upload_to=club_logo_path, blank=True, help_text=_("Shown on the club's own pages. Without one, the club's initials are used."))
class Club(UUIDModel):
class SportType(models.TextChoices):
"""Which sport this club plays. Only two options for now -- expand this as
more sport-specific competition fetchers (see events.competition) are added."""
ICE_HOCKEY = "ice_hockey", _("Ice hockey")
OTHER = "other", _("Other")
name = models.CharField(_("name"), max_length=255)
legal_name = models.CharField(_("legal name"), max_length=255, blank=True, help_text=_("Full registered name (e.g. including a legal form like VZW/ASBL), used on official documents. Falls back to club name if blank."))
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
contact_email = models.EmailField(
_("contact email"),
blank=True,
help_text=_("The club's public address, shown to people the club writes to or asks to get in touch -- e.g. a parent claiming a child. Falls back to nothing being shown at all, so it's worth setting."),
)
website = models.URLField(_("website"), blank=True, help_text=_("The club's own site, if it has one -- shown alongside its RosterChief pages, not used for anything else yet."))
legal_address = models.CharField(
_("legal address"),
max_length=255,
blank=True,
help_text=_("Street address for official documents (invoices, the referee payment form). Falls back to the home location's address when left blank -- set this when the club's registered address isn't where it actually plays."),
)
legal_zip_code = models.CharField(_("legal zip code"), max_length=255, blank=True)
legal_city = models.CharField(_("legal city"), max_length=255, blank=True)
logo = models.FileField(
_("logo"),
upload_to=club_logo_path,
blank=True,
# A plain FileField, not ImageField: Pillow (which ImageField validates through)
# cannot read SVGs, and club crests are commonly vector logos.
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
help_text=_("Shown on the club's own pages. Without one, the club's initials are used."),
)
event_background = models.ImageField(
_("event background"),
upload_to=club_event_background_path,
blank=True,
# A real photo, unlike logo -- shown in black & white under a dark
# gradient (see mobile/templates/mobile/event_detail.html), so an
# ImageField (Pillow-validated, unlike logo's plain FileField) is the
# right fit here: this is never a vector crest.
help_text=_("A generic background photo for event screens in the mobile app, shown in black & white under a dark gradient. Without one, a plain dark background is used."),
)
primary_color = models.CharField(
_("primary colour"),
max_length=7,
@@ -40,8 +90,36 @@ class Club(UUIDModel):
help_text=_("Hex colour for buttons and links on the club's pages, e.g. #1e40af."),
)
secondary_color = models.CharField(
_("secondary colour"),
max_length=7,
blank=True,
validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$", _("Enter a colour as a hex value, e.g. #be185d."))],
help_text=_("Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour."),
)
sport_type = models.CharField(
_("sport"),
max_length=20,
choices=SportType.choices,
default=SportType.OTHER,
help_text=_("Which sport this club plays -- determines which competitions and score fetchers are relevant to it."),
)
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))
season_start = models.DateField(
_("season start"),
default=datetime.date(2000, 8, 1),
help_text=_("Which day of the year a season begins — only the month and day are used, the year is ignored."),
)
season_duration_months = models.PositiveSmallIntegerField(
_("season duration (months)"),
default=12,
validators=[MinValueValidator(1), MaxValueValidator(24)],
help_text=_("How many months a season lasts, counted from its start date."),
)
objects = ClubManager()
class Meta:
@@ -61,6 +139,12 @@ class Club(UUIDModel):
def is_archived(self) -> bool:
return self.archived_at is not None
@property
def official_name(self) -> str:
"""The name official documents (e.g. the referee payment form) should
show -- `legal_name` when the club has set one, else the everyday `name`."""
return self.legal_name or self.name
@property
def initials(self) -> str:
"""Stand-in for a missing logo. Never the RosterChief mark — that would
@@ -69,19 +153,29 @@ class Club(UUIDModel):
@property
def primary_content_color(self) -> str:
"""Readable text colour to sit *on* ``primary_color``.
"""Readable text colour to sit *on* ``primary_color``. See ``_content_color_for``."""
return self._content_color_for(self.primary_color)
@property
def secondary_content_color(self) -> str:
"""Readable text colour to sit *on* ``secondary_color``. See ``_content_color_for``."""
return self._content_color_for(self.secondary_color)
@staticmethod
def _content_color_for(hex_color: str) -> str:
"""Black or white, whichever reads on ``hex_color``.
A club picking a pale yellow would otherwise get white-on-yellow buttons.
Relative luminance per WCAG, with its 0.179 threshold for black vs white.
"""
if not self.primary_color:
if not hex_color:
return ""
def channel(value: int) -> float:
fraction = value / 255
return fraction / 12.92 if fraction <= 0.04045 else ((fraction + 0.055) / 1.055) ** 2.4
red, green, blue = (channel(int(self.primary_color[index : index + 2], 16)) for index in (1, 3, 5))
red, green, blue = (channel(int(hex_color[index : index + 2], 16)) for index in (1, 3, 5))
luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
return "#000000" if luminance > 0.179 else "#ffffff"
@@ -103,6 +197,53 @@ class Club(UUIDModel):
self.save(update_fields=["archived_at"])
def sponsor_logo_path(instance: Sponsor, filename: str) -> str:
return f"clubs/{instance.club.slug}/sponsors/{instance.pk}/{filename}"
class Sponsor(ClubScopedModel):
name = models.CharField(_("name"), max_length=255)
logo = models.FileField(
_("logo"),
upload_to=sponsor_logo_path,
blank=True,
# A plain FileField, not ImageField: same reasoning as Club.logo -- a
# sponsor's own logo is just as commonly a vector file, and ImageField's
# Pillow validation can't read those.
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
)
# Not user-editable: recomputed from the logo file itself on every save, same reasoning
# NewsPhoto/TeamPhoto don't need this -- FileField (not ImageField) means Django never
# populates width/height on its own. The public API exposes these so a consumer can lay
# out a sponsor strip without waiting on the image to load.
logo_width = models.PositiveIntegerField(_("logo width"), null=True, blank=True, editable=False)
logo_height = models.PositiveIntegerField(_("logo height"), null=True, blank=True, editable=False)
url = models.URLField(_("URL"), blank=True, help_text=_("The sponsor's own website, if they have one."))
start_date = models.DateField(_("start date"))
end_date = models.DateField(_("end date"), null=True, blank=True, help_text=_("Leave blank to keep this sponsor active indefinitely once it starts."))
class Meta:
verbose_name = _("sponsor")
verbose_name_plural = _("sponsors")
ordering = ["name"]
def __str__(self):
return self.name
def clean(self):
if self.end_date is not None and self.start_date is not None and self.end_date < self.start_date:
raise ValidationError({"end_date": _("End date can't be before the start date.")})
def save(self, *args, **kwargs):
# Deferred: club.services (via its __init__) imports back from club.models, so a
# module-level import here would be circular.
from club.services.images import get_image_dimensions
self.logo_width, self.logo_height = get_image_dimensions(self.logo) if self.logo else (None, None)
super().save(*args, **kwargs)
class Season(ClubScopedModel):
start_date = models.DateField(_("start date"))
end_date = models.DateField(_("end date"))
@@ -135,8 +276,26 @@ class Season(ClubScopedModel):
"""Return ``club``'s season covering ``date`` (no tenant context needed)."""
return cls.objects.filter(club=club, start_date__lte=date, end_date__gte=date).first()
@classmethod
def next_after(cls, club, date: datetime.date):
"""Return ``club``'s soonest season starting after ``date`` (no tenant
context needed) -- the season that follows the one covering ``date``."""
return cls.objects.filter(club=club, start_date__gt=date).order_by("start_date").first()
@classmethod
def before(cls, club, season):
"""Return ``club``'s most recent season starting before ``season`` --
e.g. the management dashboard's member-count trend compares against
this. Mirrors next_after's own "adjacent by date" reasoning, just
looking the other way."""
return cls.objects.filter(club=club, start_date__lt=season.start_date).order_by("-start_date").first()
class ClubMembership(ClubScopedModel):
class Kind(models.TextChoices):
MEMBER = "member", _("member")
GUARDIAN = "guardian", _("guardian")
class StatusChoices(models.TextChoices):
ACTIVE = "active", _("active")
PENDING = "pending", _("pending")
@@ -152,10 +311,21 @@ class ClubMembership(ClubScopedModel):
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="member_of", verbose_name=_("member"))
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="memberships", verbose_name=_("season"))
kind = models.CharField(
_("kind"),
max_length=20,
choices=Kind.choices,
default=Kind.MEMBER,
help_text=_("A guardian is attached to the club only as a parent of a member -- they hold the login, but don't count as a member themselves and owe no fee. A parent who also plays is a member."),
)
license = models.CharField(_("license"), max_length=250, blank=True)
status = models.CharField(_("status"), max_length=250, choices=StatusChoices.choices, default=StatusChoices.PENDING)
fee_status = models.CharField(_("fee status"), max_length=250, choices=FeeStatus.choices, default=FeeStatus.UNPAID)
fee_amount = models.DecimalField(_("fee amount"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True)
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True, help_text=_("Kept in step with payments by the fee service; not hand-edited."))
signed_up_at = models.DateField(_("signed up at"), blank=True, null=True)
activated_at = models.DateField(_("activated at"), blank=True, null=True)
@@ -170,8 +340,234 @@ class ClubMembership(ClubScopedModel):
def __str__(self):
return f"{self.club} - {self.member}"
@property
def is_guardian(self) -> bool:
"""Attached to the club as a parent of a member, not as one themselves.
Guardians are deliberately kept as ClubMembership rows rather than given
their own model: everything that answers "is this person attached to this
club" (tenancy scoping, group membership, the event audience) already
reads through this table, and a second kind of link would need a parallel
path through all of it. What changes is only who *counts* -- the member
list, the fee list and every member KPI filter on ``kind``.
"""
return self.kind == self.Kind.GUARDIAN
@property
def open_requirement_count(self) -> int:
"""How many active onboarding requirements this membership hasn't resolved
yet (completed or bypassed) -- see OnboardingRequirement's docstring for why
this is separate from status/fee_status. One query per call; for a list of
memberships, annotate with club.services.onboarding.annotate_onboarding_status
instead."""
met = set(self.requirement_statuses.filter(Q(is_complete=True) | Q(is_bypassed=True)).values_list("requirement_id", flat=True))
required = set(OnboardingRequirement.objects.filter(club_id=self.club_id, is_active=True).values_list("pk", flat=True))
return len(required - met)
@property
def onboarding_complete(self) -> bool:
return self.open_requirement_count == 0
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("season",))
# A guardian owes nothing -- they're not a member. Caught here rather than
# silently zeroed on save so a mistaken import row says so out loud.
if self.is_guardian and self.fee_amount:
raise ValidationError({"fee_amount": _("A guardian doesn't hold a membership, so they can't owe a fee.")})
class FeePayment(UUIDModel):
"""Money received against one membership's fee. Several may land on one
membership: a family paying in two installments must not read as unpaid, and
the part that did arrive has to be recorded somewhere. Not itself club-scoped
-- its club is reached through ``membership``, same as DuePayment/Due."""
class Method(models.TextChoices):
BANK_TRANSFER = "bank_transfer", _("bank transfer")
CASH = "cash", _("cash")
CARD = "card", _("card")
OTHER = "other", _("other")
membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="payments", verbose_name=_("membership"))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
note = models.TextField(_("note"), blank=True)
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_fee_payments", verbose_name=_("recorded by"))
class Meta:
verbose_name = _("fee payment")
verbose_name_plural = _("fee payments")
ordering = ["-paid_at"]
def __str__(self):
return f"{self.membership}{self.amount}"
class DuesInvoice(ClubScopedModel):
"""A record of asking one membership's fee to be paid — not itself the source of
truth for what's owed or settled (that's still ``ClubMembership.fee_amount``/
``amount_paid``/``fee_status``, via ``club.services.fees``). Sending one snapshots
the outstanding balance and a due date so a later fee change or reminder never
silently rewrites a bill someone already received; whether it still needs chasing
is read live off the membership's own ``fee_status``, since a payment recorded
through any route settles the same balance this invoice asked for.
One per membership (see ``club.services.invoicing``): "send" creates it if
missing, "resend" re-snapshots the balance and pushes the due date out again on
the existing row, so a membership never accumulates a history of stale invoices.
"""
membership = models.OneToOneField(ClubMembership, on_delete=models.CASCADE, related_name="dues_invoice", verbose_name=_("membership"))
number = models.CharField(_("number"), max_length=255, blank=True)
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, help_text=_("The outstanding balance at the time this was sent — not re-read from the membership afterwards."))
due_date = models.DateField(_("due date"))
sent_at = models.DateTimeField(_("sent at"), null=True, blank=True)
sent_to_email = models.EmailField(_("sent to"), blank=True)
sent_to_guardian = models.BooleanField(_("sent to a parent/guardian"), default=False, help_text=_("The member had no email on file, so a parent/guardian's was used instead."))
last_reminder_sent_at = models.DateTimeField(_("last reminder sent at"), null=True, blank=True)
reminder_count = models.PositiveIntegerField(_("reminders sent"), default=0)
class Meta:
verbose_name = _("dues invoice")
verbose_name_plural = _("dues invoices")
ordering = ["-sent_at"]
constraints = [
UniqueConstraint(fields=["club", "number"], name="unique_dues_invoice_number_per_club"),
]
def __str__(self):
return self.number or _("Unsent invoice for %(member)s") % {"member": self.membership.member}
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("membership",))
@property
def is_paid(self) -> bool:
return self.membership.fee_status == ClubMembership.FeeStatus.PAID
@property
def is_overdue(self) -> bool:
return bool(self.sent_at) and not self.is_paid and self.due_date < timezone.now().date()
def generate_number(self) -> str:
"""Next per-club invoice number for the current year: ``DUE-<year>-<seq>``.
Same shape as shop.models.Invoice's numbering, duplicated rather than shared
across the two apps — see that module's own numbering helpers."""
prefix = f"DUE-{timezone.now().year}-"
sequences = [int(suffix) for existing in DuesInvoice.objects.filter(club=self.club, number__startswith=prefix).values_list("number", flat=True) if (suffix := existing.removeprefix(prefix)).isdigit()]
return f"{prefix}{max(sequences, default=0) + 1:05d}"
def save(self, *args, **kwargs):
if self.number:
return super().save(*args, **kwargs)
# Retrying on a numbering collision (two invoices allocated the same
# sequence in the same instant) rather than locking: this only ever
# fires once, on first send, so a rare retry is cheaper than a lock
# held around every save.
for attempt in range(5):
self.number = self.generate_number()
try:
with transaction.atomic():
return super().save(*args, **kwargs)
except IntegrityError:
self.number = ""
if attempt == 4:
raise
def onboarding_document_path(instance: MemberRequirementStatus, filename: str) -> str:
return f"clubs/{instance.membership.club.slug}/onboarding/{instance.membership_id}/{filename}"
class OnboardingRequirement(ClubScopedModel):
"""A club-defined item every member must satisfy after signing up or renewing --
e.g. "provide a medical certificate", "upload a photo".
``ClubMembership.fee_status`` is still driven by payment alone (see
``club.services.fees._sync_fee_status``) and this never touches it -- a member
reads as paid *and* still has an open checklist, both true at once. ``status``
is different: paying in full only ever settles ``fee_status`` now -- it never
flips ``status`` to ACTIVE by itself. The only path there is the deliberately
manual one, ``club.services.onboarding.approve_one``/``approve_all_clean``, run
by an admin from the Sign-up page, which additionally requires every blocking
requirement to be resolved first. Nothing flips status automatically just
because the fee cleared or the last checklist item was ticked (checklist actions
aren't even admin-gated); activation is always that one deliberate admin step,
so a membership can be fully paid *and* fully checked off and still sit PENDING
until someone actually clicks Approve.
``blocked_event_kinds`` is what makes a specific requirement matter before that
point: a club can decide e.g. a medical certificate blocks GAME invitations/
selection but not TRAINING ones, so a provisionally-rostered member (see
``events.services.attendance.effective_members``) can still be invited to practice
while their paperwork is outstanding. Empty means "informational only" -- open or
not, it never blocks anything. Stored as a plain list of ``events.models.Event.
EventKind`` values (not a FK/enum at the DB layer) specifically to avoid a
club -> events import cycle (events already imports club for Event.club); the
form layer (management/forms.py) is what actually validates against EventKind.
``MemberRequirementStatus`` tracks completion per ``ClubMembership`` (so a fresh
checklist starts each season, matching how membership itself is season-scoped).
"""
name = models.CharField(_("name"), max_length=100)
description = models.TextField(_("description"), blank=True, help_text=_("Shown to staff on the member's checklist."))
requires_document = models.BooleanField(_("requires a document"), default=False, help_text=_("Staff can attach a file (e.g. the certificate itself) when marking this complete."))
blocked_event_kinds = models.JSONField(_("blocks selection for"), default=list, blank=True, help_text=_("Event kinds a member can't be invited to or selected for while this is open. Empty means purely informational."))
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive requirements no longer apply to new memberships, but existing statuses are kept."))
class Meta:
verbose_name = _("onboarding requirement")
verbose_name_plural = _("onboarding requirements")
# Alphabetical, not a configurable sequence: every active requirement
# blocks equally and there's no set order to complete them in, so
# ordering here is purely for a stable, predictable listing.
ordering = ["name"]
constraints = [
models.UniqueConstraint(fields=["club", "name"], name="unique_onboarding_requirement_name_per_club"),
]
def __str__(self):
return self.name
class MemberRequirementStatus(UUIDModel):
"""Whether one ``ClubMembership`` has satisfied one ``OnboardingRequirement``,
this season. Not itself club-scoped -- its club is reached through ``membership``,
same reasoning as ``FeePayment`` above."""
membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="requirement_statuses", verbose_name=_("membership"))
requirement = models.ForeignKey(OnboardingRequirement, on_delete=models.CASCADE, related_name="statuses", verbose_name=_("requirement"))
is_complete = models.BooleanField(_("complete"), default=False)
#: Distinct from is_complete -- "confirmed, not needed for this person" (e.g. they
#: already have a recent photo on file) reads differently from "actually received"
#: on a checklist/audit, even though both equally stop this item from blocking
#: anything (see club.services.onboarding.is_open). Mutually exclusive with
#: is_complete in practice (mark_bypassed/mark_complete each clear the other).
is_bypassed = models.BooleanField(_("bypassed"), default=False)
completed_at = models.DateTimeField(_("completed at"), null=True, blank=True)
completed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+", verbose_name=_("completed by"))
document = models.FileField(_("document"), storage=private_storage, upload_to=onboarding_document_path, blank=True, help_text=_("Stored privately -- readable only through this member's own page, never a direct link."))
note = models.TextField(_("note"), blank=True, help_text=_("Staff-only, e.g. how or when this was received."))
class Meta:
verbose_name = _("member requirement status")
verbose_name_plural = _("member requirement statuses")
constraints = [
models.UniqueConstraint(fields=["membership", "requirement"], name="unique_requirement_status_per_membership"),
]
def __str__(self):
return f"{self.membership}{self.requirement}"
def clean(self):
validate_club_scope(self, self.membership.club_id, same_club_fields=("requirement",))
class ClubRole(ClubScopedModel):
@@ -179,6 +575,12 @@ class ClubRole(ClubScopedModel):
ADMIN = "admin", _("admin")
MEMBER = "member", _("member")
EDITOR = "editor", _("editor")
#: Full read/write on people (members, families, groups, parent claims,
#: teams, referee setup, onboarding requirements) without Finance/Shop,
#: Club identity, Sponsors, or the ability to grant/revoke ClubRole itself
#: -- see club.services.access.can_manage_members and
#: club.mixins.MemberAdminRequiredMixin for exactly what that covers.
MEMBER_ADMIN = "member_admin", _("member admin")
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="roles", verbose_name=_("member"))
role = models.CharField(_("role"), max_length=250, choices=Roles.choices, default=Roles.MEMBER)

View File

@@ -20,9 +20,9 @@ from django.db.models import Q, QuerySet
from django.utils import timezone
from authentication.models import User
from club.models import Club, ClubRole, Season
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Event
from members.models import FamilyMembership, Member
from members.models import FamilyMembership, Group, Member
from teams.models import StaffAssignment, Team
#: Derived (never stored) roles.
@@ -45,8 +45,45 @@ def has_club_role(user: User, club: Club, role: ClubRole.Roles) -> bool:
return ClubRole.objects.filter(member__user=user, club=club, role=role).exists()
def is_platform_superuser(user: User) -> bool:
"""A Django superuser sees and manages every club as if they held ADMIN there,
with no ClubRole row needed -- the platform-operator override. Already forced
through MFA regardless (authentication.middleware.mfa_required_for checks
is_superuser directly), so this bypass never skips that."""
return bool(user and user.is_authenticated and user.is_superuser)
def is_club_admin(user: User, club: Club) -> bool:
return has_club_role(user, club, ClubRole.Roles.ADMIN)
return is_platform_superuser(user) or has_club_role(user, club, ClubRole.Roles.ADMIN)
def is_member_admin(user: User, club: Club) -> bool:
"""MEMBER_ADMIN: full read/write on people (members, families, groups, parent
claims, teams, referee setup, onboarding requirements) without Finance/Shop,
Club identity, Sponsors, or the ability to grant/revoke ClubRole itself --
see can_manage_members for the actual gate, this is just the role check."""
return has_club_role(user, club, ClubRole.Roles.MEMBER_ADMIN)
def can_manage_members(user: User, club: Club) -> bool:
"""The gate for club.mixins.MemberAdminRequiredMixin -- real ADMIN (which already
includes the superuser bypass), or MEMBER_ADMIN specifically."""
return is_club_admin(user, club) or is_member_admin(user, club)
def has_management_access(user: User, club: Club) -> bool:
"""Anyone with real authority in the club: ADMIN/EDITOR/MEMBER_ADMIN, a platform
superuser, or *any* current-season staff assignment (coach, team manager,
physio, ...).
Deliberately excludes the plain MEMBER role -- every signed-up player (or club
member generally) holds that automatically the moment their ClubMembership goes
active (club/signals.py), so it says nothing about whether someone is staff.
"""
if is_platform_superuser(user):
return True
elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR, ClubRole.Roles.MEMBER_ADMIN)).exists()
return elevated or teams_staffed_by(user, club).exists()
def is_coach_manager(user: User, club: Club) -> bool:
@@ -80,6 +117,15 @@ def teams_managed_by(user: User, club: Club) -> QuerySet[Team]:
).distinct()
def groups_manageable_by(user: User, club: Club) -> QuerySet[Group]:
"""Groups the user may schedule an event for: all for an ADMIN, else only
the ones they're themselves a member of -- unlike Team, Group has no
manager/owner concept, so membership is the only claim there is to check."""
if is_club_admin(user, club):
return Group.objects.filter(club=club)
return Group.objects.filter(club=club, memberships__member__user=user).distinct()
def teams_staffed_by(user: User, club: Club) -> QuerySet[Team]:
"""Teams the user is on the staff of this season, management or not.
@@ -93,15 +139,41 @@ def teams_staffed_by(user: User, club: Club) -> QuerySet[Team]:
).distinct()
def members_visible_to(user: User, club: Club) -> QuerySet[Member]:
def _guardians_only(club: Club) -> QuerySet[Member]:
"""People whose *only* tie to ``club`` is being a parent of a member.
Subtracted rather than filtered out at the source, because a bare MEMBER
ClubRole with no ClubMembership is a real state -- someone the club knows
but hasn't signed up for a season yet -- and narrowing the role branch to
weed guardians out would take those people with it. Anyone who also holds a
real membership, plays, is on a team's staff or runs the club is a member
who happens to be a parent, and stays visible.
"""
return Member.objects.filter(member_of__club=club, member_of__kind=ClubMembership.Kind.GUARDIAN).exclude(
Q(member_of__club=club, member_of__kind=ClubMembership.Kind.MEMBER)
| Q(team_memberships__team__club=club)
| Q(staff_assignments__team__club=club)
| Q(roles__club=club, roles__role__in=[ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR])
)
def members_visible_to(user: User, club: Club, *, include_guardians: bool = False) -> QuerySet[Member]:
"""Members the user may see.
ADMIN: everyone linked to the club (membership, roster, staff or role).
Otherwise: themselves, their children, and the current-season players *and*
staff of every team they're staffed on.
Guardians -- parents attached to the club only through a child, see
``ClubMembership.Kind`` -- are **excluded by default**: they aren't members,
so they don't belong in a member list or any member count. Pass
``include_guardians=True`` where the page is about a *person* rather than
about members: opening a guardian's own detail page, editing them, putting
them in a group, or showing a family (whose parents are the whole point).
"""
if is_club_admin(user, club):
return Member.objects.filter(Q(member_of__club=club) | Q(team_memberships__team__club=club) | Q(staff_assignments__team__club=club) | Q(roles__club=club)).distinct()
attached = Member.objects.filter(Q(member_of__club=club) | Q(team_memberships__team__club=club) | Q(staff_assignments__team__club=club) | Q(roles__club=club)).distinct()
return attached if include_guardians else attached.exclude(pk__in=_guardians_only(club))
me = Member.objects.filter(user=user).first()
if me is None:
@@ -139,3 +211,22 @@ def can_edit_event(user: User, event: Event) -> bool:
def can_manage_shop(user: User, club: Club) -> bool:
return is_club_admin(user, club)
def can_add_news(user: User, club: Club) -> bool:
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
author club content, not just anyone on staff (a physio shouldn't post news)."""
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR) or is_coach_manager(user, club)
def can_publish_news(user: User, club: Club) -> bool:
"""Only ADMIN/EDITOR may push a news item live -- the release-flow gate."""
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR)
def can_edit_news(user: User, news_item) -> bool:
"""Broad while it's a draft (anyone who could create one); editor/admin-only
once published -- an editor is accountable for what's actually live."""
if news_item.status == news_item.Status.PUBLISHED:
return can_publish_news(user, news_item.club)
return can_add_news(user, news_item.club)

80
club/services/fees.py Normal file
View File

@@ -0,0 +1,80 @@
"""Recording money received against a membership's fee.
Mirrors billing.services.dues.record_payment for a different kind of money: a
member's own club fee, not the club's platform subscription. amount_paid is kept in
step here, never recomputed by re-aggregating FeePayment on every read.
"""
from decimal import Decimal
from django.db.models import F
from club.models import ClubMembership, FeePayment
def remaining_balance(membership):
return max(membership.fee_amount - membership.amount_paid, Decimal("0.00"))
def open_dues_rows(club, people, season):
"""Every season-dues row still owed by ``people`` in ``season`` -- shared by
mobile's Home dues card and its Payments & dues screen so the two never
drift out of sync on what counts as "still open". WAIVED memberships and
fully-paid balances are excluded."""
if season is None or not people:
return []
memberships = ClubMembership.objects.filter(club=club, member__in=people, season=season).exclude(fee_status=ClubMembership.FeeStatus.WAIVED).select_related("dues_invoice", "member")
rows = []
for membership in memberships:
balance = remaining_balance(membership)
if balance > 0:
rows.append({"membership": membership, "balance": balance, "invoice": getattr(membership, "dues_invoice", None)})
return rows
def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
"""Record money received against one membership's fee. Several payments may
land on one membership -- a family paying in two installments must not read as
unpaid. Updates amount_paid and re-syncs fee_status to match; membership.status
is untouched -- see _sync_fee_status."""
payment = FeePayment.objects.create(membership=membership, amount=amount, method=method, reference=reference, note=note, recorded_by=recorded_by)
membership.amount_paid = F("amount_paid") + amount
membership.save(update_fields=["amount_paid"])
membership.refresh_from_db(fields=["amount_paid"])
_sync_fee_status(membership)
return payment
def mark_as_paid(membership, *, recorded_by=None):
"""The "settle this one" action behind both the per-row and bulk buttons. If
there's a real remaining balance, records it as a payment (auditable, shows up
in history); if fee_amount was never priced (remaining is 0), just flips the
flags directly -- there's no real transaction to log."""
remaining = remaining_balance(membership)
if remaining > 0:
record_payment(membership, amount=remaining, method=FeePayment.Method.OTHER, note="Marked as paid", recorded_by=recorded_by)
else:
_sync_fee_status(membership, force_paid=True)
def _sync_fee_status(membership, *, force_paid=False):
if membership.fee_status == ClubMembership.FeeStatus.WAIVED:
return # manual, independent of payments -- this never overrides it
if force_paid or (membership.fee_amount > 0 and membership.amount_paid >= membership.fee_amount):
new_status = ClubMembership.FeeStatus.PAID
elif membership.amount_paid > 0:
new_status = ClubMembership.FeeStatus.PARTIALLY_PAID
else:
new_status = ClubMembership.FeeStatus.UNPAID
# fee_status only -- membership.status is never touched here. Paying in full
# used to also flip status straight to ACTIVE on its own; now that's exclusively
# club.services.onboarding.approve_one/approve_all_clean's call, so a paid-up
# membership still waits on that deliberate admin step. See OnboardingRequirement's
# docstring (club/models.py) for why.
membership.fee_status = new_status
membership.save(update_fields=["fee_status"])

82
club/services/images.py Normal file
View File

@@ -0,0 +1,82 @@
"""Dimensions for uploads that aren't Django ImageFields.
Logos (Club.logo, Sponsor.logo) are plain FileFields, not ImageFields --
Pillow can't validate SVGs, and crests/sponsor logos are commonly vector
files -- so there's no automatic width_field/height_field the way there
would be on an ImageField. This fills that gap: Pillow for raster formats,
a bounded regex read of the root <svg> tag for vector ones (not a full XML
parse -- this reads untrusted uploads, and a parser is exposed to entity
expansion attacks a plain attribute read never is).
"""
import re
from PIL import Image, UnidentifiedImageError
_SVG_TAG_RE = re.compile(rb"<svg\b[^>]*>", re.IGNORECASE | re.DOTALL)
_WIDTH_RE = re.compile(rb"""\bwidth\s*=\s*["']([^"']+)["']""", re.IGNORECASE)
_HEIGHT_RE = re.compile(rb"""\bheight\s*=\s*["']([^"']+)["']""", re.IGNORECASE)
_VIEWBOX_RE = re.compile(rb"""\bviewBox\s*=\s*["']\s*([\d.+-]+)[ ,]+([\d.+-]+)[ ,]+([\d.+-]+)[ ,]+([\d.+-]+)""", re.IGNORECASE)
_LEADING_NUMBER_RE = re.compile(r"[\d.]+")
#: The root <svg> tag is always near the top of the file -- no need to read
#: (or regex-scan) anything past a small header.
_SVG_HEAD_BYTES = 8192
def _svg_length(raw: bytes) -> int | None:
"""Parse an SVG length attribute (``"200"``, ``"200px"``) to a rounded
int, or None if it's relative (``"100%"``) and so not a real pixel size."""
text = raw.decode("utf-8", errors="ignore").strip()
if text.endswith("%"):
return None
match = _LEADING_NUMBER_RE.match(text)
return round(float(match.group(0))) if match else None
def _svg_dimensions(file) -> tuple[int | None, int | None]:
try:
file.seek(0)
head = file.read(_SVG_HEAD_BYTES)
except OSError:
return None, None
# Reset for whatever reads the file next (e.g. FileField writing it to storage).
file.seek(0)
tag_match = _SVG_TAG_RE.search(head)
svg_tag = tag_match.group(0) if tag_match else head
width_match, height_match = _WIDTH_RE.search(svg_tag), _HEIGHT_RE.search(svg_tag)
if width_match and height_match:
width, height = _svg_length(width_match.group(1)), _svg_length(height_match.group(1))
if width and height:
return width, height
viewbox_match = _VIEWBOX_RE.search(svg_tag)
if viewbox_match:
_, _, width, height = viewbox_match.groups()
return round(float(width)), round(float(height))
return None, None
def get_image_dimensions(file) -> tuple[int | None, int | None]:
"""Best-effort (width, height) for an uploaded logo -- (None, None) if the
file can't be read as an image (corrupt upload, unrecognised format)."""
if not file:
return None, None
name = getattr(file, "name", "") or ""
if name.lower().endswith(".svg"):
return _svg_dimensions(file)
try:
file.seek(0)
with Image.open(file) as image:
size = image.size
# Reset for whatever reads the file next (e.g. FileField writing it to storage) --
# only on the success path, since a failed open/read leaves nothing to rewind.
file.seek(0)
return size
except (OSError, UnidentifiedImageError):
return None, None

188
club/services/invoicing.py Normal file
View File

@@ -0,0 +1,188 @@
"""Dues invoices: asking a member (or their parent/guardian) to pay an outstanding
membership fee, and chasing it if the due date passes unpaid.
Kept separate from club.services.fees on purpose: fees.py owns what's actually owed
and settled (fee_amount/amount_paid/fee_status), this module only owns the paper
trail of having asked for it. A DuesInvoice's own "paid" reading is always the live
membership.fee_status -- never a flag duplicated here that could drift out of step.
"""
from datetime import timedelta
from types import SimpleNamespace
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, DuesInvoice
from club.services.fees import remaining_balance
from events.models import Location
class DuesInvoicePDFError(Exception):
"""Raised when WeasyPrint's native libraries aren't available."""
def recipient_for(member) -> tuple[str, bool]:
"""Best email to invoice ``member`` at: their own, else the first parent/guardian
who has one. Empty string means nobody reachable at all -- the caller must not
create or send an invoice in that case."""
if member.contact_email:
return member.contact_email, False
for guardian in member.guardians.order_by("last_name", "first_name"):
if guardian.contact_email:
return guardian.contact_email, True
return "", False
def create_or_resend_invoice(membership: ClubMembership, *, due_in_days: int, recipient_email: str, sent_to_guardian: bool) -> DuesInvoice:
"""Create the membership's one invoice, or re-snapshot it if it already has one.
Never touches reminder_count/last_reminder_sent_at -- a fresh send earns a fresh
reminder clock, but that's set by the reminder path itself, not reset here, since
a resend before any reminder went out has nothing to reset."""
invoice, _created = DuesInvoice.objects.get_or_create(
club=membership.club,
membership=membership,
defaults={"amount": remaining_balance(membership), "due_date": timezone.now().date() + timedelta(days=due_in_days)},
)
invoice.amount = remaining_balance(membership)
invoice.due_date = timezone.now().date() + timedelta(days=due_in_days)
invoice.sent_at = timezone.now()
invoice.sent_to_email = recipient_email
invoice.sent_to_guardian = sent_to_guardian
# get_or_create's own save (for a new row) already assigned invoice.number,
# so it's always set by this point -- update_fields never needs to include it.
invoice.save(update_fields=["amount", "due_date", "sent_at", "sent_to_email", "sent_to_guardian", "modified"])
return invoice
def _email_context(invoice: DuesInvoice, *, request=None) -> dict:
return {"club": invoice.club, "invoice": invoice, "membership": invoice.membership, "member": invoice.membership.member, "request": request}
def _attach_pdf(message: EmailMultiAlternatives, invoice: DuesInvoice) -> None:
"""Best-effort: a club running without WeasyPrint's native libraries still gets
the invoice email itself, just without the PDF -- everything the PDF shows is
already in the email body."""
try:
pdf_bytes = invoice_pdf(invoice)
except DuesInvoicePDFError:
return
message.attach(f"{invoice.number}.pdf", pdf_bytes, "application/pdf")
def send_invoice_email(invoice: DuesInvoice, *, request=None) -> bool:
"""Mail the branded invoice to invoice.sent_to_email. Never fatal: the invoice
row (and its sent_at stamp) exists whether or not the mail leaves the building --
see members.services.claims.send_claim_approved_email for the same reasoning."""
if not invoice.sent_to_email:
return False
context = _email_context(invoice, request=request)
subject = " ".join(render_to_string("club/email/dues_invoice_subject.txt", context).split())
text_body = render_to_string("club/email/dues_invoice.txt", context).strip() + "\n"
html_body = render_to_string("club/email/dues_invoice.html", context)
message = EmailMultiAlternatives(subject, text_body, settings.DEFAULT_FROM_EMAIL, [invoice.sent_to_email])
message.attach_alternative(html_body, "text/html")
_attach_pdf(message, invoice)
try:
message.send(fail_silently=False)
except OSError:
return False
return True
def invoices_due_for_reminder(club, today=None):
"""Sent, unpaid (and not waived -- nothing's owed there), past their own due
date. Reminders are opt-in per club-wide button push, not a cron job, so there's
no "already reminded today" guard here -- see MembershipSendInvoiceRemindersView."""
today = today or timezone.now().date()
return (
DuesInvoice.objects.filter(club=club, sent_at__isnull=False, due_date__lt=today)
.exclude(membership__fee_status__in=[ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED])
.select_related("membership__member")
)
def send_reminder_email(invoice: DuesInvoice, *, request=None) -> bool:
if not invoice.sent_to_email:
return False
context = _email_context(invoice, request=request)
subject = " ".join(render_to_string("club/email/dues_invoice_reminder_subject.txt", context).split())
text_body = render_to_string("club/email/dues_invoice_reminder.txt", context).strip() + "\n"
html_body = render_to_string("club/email/dues_invoice_reminder.html", context)
message = EmailMultiAlternatives(subject, text_body, settings.DEFAULT_FROM_EMAIL, [invoice.sent_to_email])
message.attach_alternative(html_body, "text/html")
_attach_pdf(message, invoice)
try:
message.send(fail_silently=False)
except OSError:
return False
invoice.last_reminder_sent_at = timezone.now()
invoice.reminder_count += 1
invoice.save(update_fields=["last_reminder_sent_at", "reminder_count", "modified"])
return True
def send_reminders(club, *, request=None) -> tuple[int, int]:
"""Push-button "remind everyone past due" -- returns (sent, failed)."""
sent = failed = 0
for invoice in invoices_due_for_reminder(club):
if send_reminder_email(invoice, request=request):
sent += 1
else:
failed += 1
return sent, failed
def render_pdf(html: str) -> bytes:
"""Same lazy-import shape as management.pdf.render_pdf/billing.services.invoices.render_pdf
-- WeasyPrint binds to native pango/cairo libraries, and a machine without them
must still be able to run the app; this only fails when someone actually asks
for a PDF. Not shared with either of those: an app depending on another app's
PDF error type for a two-line function isn't worth the coupling."""
try:
from weasyprint import HTML
except (ImportError, OSError) as error:
raise DuesInvoicePDFError(_("PDF rendering needs the native pango/cairo libraries (on macOS: brew install pango).")) from error
return HTML(string=html).write_pdf()
def resolve_document_address(club):
"""The address to print on an official document header (a dues invoice,
the referee payment form) -- the club's own ``legal_address`` when set,
else its home ground (``events.models.Location``, ``is_home=True``), so
a club that hasn't set a legal address yet still gets *something* rather
than a blank header.
``Location.is_home`` itself is purely about telling a home game from an
away one -- this is the one place its address doubles as a stand-in for
an actual registered/mailing address, and only when the club hasn't set
one of its own. Returns an object exposing ``.address``/``.zip_code``/
``.city`` either way (a plain namespace for the legal-address branch, the
real ``Location`` for the fallback), or ``None`` when neither is set.
"""
if club.legal_address:
return SimpleNamespace(address=club.legal_address, zip_code=club.legal_zip_code, city=club.legal_city)
return Location.objects.filter(club=club, is_home=True).first()
def invoice_pdf(invoice: DuesInvoice) -> bytes:
# Same header convention as management/event_referee_form_pdf.html: the club's
# legal name (official_name falls back to the everyday name when unset) and its
# document address -- never an event-specific location, since a dues invoice
# isn't tied to any one event.
document_address = resolve_document_address(invoice.club)
html = render_to_string("club/dues_invoice_pdf.html", {"club": invoice.club, "invoice": invoice, "membership": invoice.membership, "member": invoice.membership.member, "document_address": document_address})
return render_pdf(html)

253
club/services/onboarding.py Normal file
View File

@@ -0,0 +1,253 @@
"""Per-member onboarding checklist -- see OnboardingRequirement's docstring
(club/models.py) for why fee_status stays untouched by any of this, and for
why approve_one/approve_all_clean below are the only way to reach
ClubMembership.status ACTIVE (fee_status alone, even fully PAID, never does).
No signal pre-creates a MemberRequirementStatus row when a membership is created
or a requirement is added: "required, no row yet" and "required, row with
is_complete=is_bypassed=False" both mean the same thing (not done), so there is
nothing to backfill either way -- a club adding a new requirement mid-season
immediately shows it as open on every existing membership, and deactivating one
immediately stops asking for it, with no migration-shaped cleanup step in either
direction.
"""
from collections import defaultdict
from django.db.models import Q
from django.utils import timezone
from club.models import ClubMembership, MemberRequirementStatus, OnboardingRequirement
from members.models import Member
#: Shared by every "is this item resolved" check below -- resolved means it no
#: longer blocks anything, whether that's because it was actually completed or
#: because staff decided it doesn't apply to this person.
_RESOLVED = Q(is_complete=True) | Q(is_bypassed=True)
def checklist_for(membership):
"""Every active requirement for this membership's club, each paired with its
status row if one exists (or None -- not started). One query for the
requirements, one for the statuses that exist; the membership detail page
renders exactly this list under its Documents tab."""
requirements = OnboardingRequirement.objects.filter(club_id=membership.club_id, is_active=True)
statuses = {status.requirement_id: status for status in membership.requirement_statuses.select_related("completed_by")}
return [(requirement, statuses.get(requirement.pk)) for requirement in requirements]
def mark_complete(membership, requirement, *, user, document=None, note=""):
"""Actually received/verified -- as opposed to mark_bypassed, "not needed for
this person". Clears any prior bypass: the two are mutually exclusive."""
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
status.is_complete = True
status.is_bypassed = False
status.completed_at = timezone.now()
status.completed_by = user
status.note = note
if document:
status.document = document
status.save()
return status
def mark_bypassed(membership, requirement, *, user, note=""):
"""Confirmed not needed for this member (e.g. they already have a recent
photo on file) -- stops the item blocking anything, same as mark_complete,
but reads correctly on the checklist/audit trail as a deliberate staff
decision rather than a document actually received. A note is expected here
(not enforced at this layer -- see RequirementBypassForm) since "why" is the
whole point of a bypass in a way it isn't for an ordinary completion."""
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
status.is_complete = False
status.is_bypassed = True
status.completed_at = timezone.now()
status.completed_by = user
status.note = note
status.document = None
status.save()
return status
def mark_incomplete(membership, requirement):
"""Undo a mark_complete/mark_bypassed -- kept as a row (not deleted) so the
document/note a club already collected isn't thrown away by an accidental
toggle."""
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
status.is_complete = False
status.is_bypassed = False
status.completed_at = None
status.completed_by = None
status.save()
return status
def annotate_onboarding_status(queryset):
"""`queryset` of ClubMembership, returned as a list with each row given an
`.onboarding_open` attribute (count of unresolved active requirements) -- the
list-page equivalent of the `open_requirement_count` property, in a fixed
number of queries regardless of list size rather than the N+1 a per-row
property call would cost across a whole table."""
memberships = list(queryset)
if not memberships:
return memberships
required_by_club = {}
for club_id in {membership.club_id for membership in memberships}:
required_by_club[club_id] = set(OnboardingRequirement.objects.filter(club_id=club_id, is_active=True).values_list("pk", flat=True))
met_by_membership = defaultdict(set)
statuses = MemberRequirementStatus.objects.filter(membership_id__in=[membership.pk for membership in memberships]).filter(_RESOLVED)
for membership_id, requirement_id in statuses.values_list("membership_id", "requirement_id"):
met_by_membership[membership_id].add(requirement_id)
for membership in memberships:
required = required_by_club.get(membership.club_id, set())
membership.onboarding_open = len(required - met_by_membership[membership.pk])
return memberships
def members_with_open_requirements(club, season):
"""Members whose current-season membership has at least one unresolved active
requirement -- the same condition the dashboard's "Missing documentation" KPI
counts (management.views.HomeView), reused here for the member list's own
?docs=open filter. None when there's no season to check against."""
if season is None:
return Member.objects.none()
memberships = list(ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER))
annotate_onboarding_status(memberships)
member_ids = [membership.member_id for membership in memberships if membership.onboarding_open]
return Member.objects.filter(pk__in=member_ids)
def blocking_event_kinds(membership) -> set:
"""Every event kind currently blocked for this membership by at least one open
(not complete, not bypassed) active requirement -- e.g. {"game"} while a medical
certificate is outstanding but nothing blocks training. Powers the Sign-up page's
detail pane and member_detail's Documents tab ("blocks: Games" next to an open
item), so staff can see exactly what's at stake without reading every requirement."""
blocked = set()
for requirement, status in checklist_for(membership):
if status is not None and (status.is_complete or status.is_bypassed):
continue
blocked.update(requirement.blocked_event_kinds)
return blocked
def blocked_member_ids_for_event(club, season, event_kind) -> set:
"""Member ids that must NOT be invited to (or selectable for) an event of
`event_kind` this season, because at least one active requirement that blocks
that kind is still open on their current-season membership. Bulk, not per-member
-- events.services.attendance.effective_members() calls this once per event save,
not once per candidate member.
A member with no current-season ClubMembership.MEMBER row at all isn't covered
here -- effective_members() already wouldn't include them (they're not on any
roster to begin with), so there's nothing to subtract.
Filtered in Python, not via a `blocked_event_kinds__contains=[event_kind]`
queryset lookup -- JSONField `contains` isn't supported on SQLite (only
Postgres/MySQL/Oracle), and a club's own requirement count is always small
enough that fetching them all costs nothing worth optimising away."""
blocking_requirement_ids = {requirement.pk for requirement in OnboardingRequirement.objects.filter(club=club, is_active=True) if event_kind in requirement.blocked_event_kinds}
if not blocking_requirement_ids:
return set()
memberships = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER)
resolved_by_membership = defaultdict(set)
statuses = MemberRequirementStatus.objects.filter(membership__in=memberships, requirement_id__in=blocking_requirement_ids).filter(_RESOLVED)
for membership_id, requirement_id in statuses.values_list("membership_id", "requirement_id"):
resolved_by_membership[membership_id].add(requirement_id)
blocked_member_ids = set()
for membership_id, member_id in memberships.values_list("pk", "member_id"):
if blocking_requirement_ids - resolved_by_membership.get(membership_id, set()):
blocked_member_ids.add(member_id)
return blocked_member_ids
def open_requirements_blocking(member, club, season, event_kind) -> list:
"""The specific, still-open `OnboardingRequirement`s blocking `member` from
`event_kind` events this season -- the per-member, explain-*why* mirror of
`blocked_member_ids_for_event`'s bulk set. Built for the member-facing "you
can't sign up for this yet" card (mobile app): unlike that function, this
one is meant to be called once for a person looking at one event, not once
per event for a whole club, so the per-member cost here is fine.
Empty (never blocked) for a member with no current-season ClubMembership.MEMBER
row -- same "nothing to check" reasoning as blocked_member_ids_for_event's own."""
membership = ClubMembership.objects.filter(club=club, season=season, member=member, kind=ClubMembership.Kind.MEMBER).first()
if membership is None:
return []
blocking = [requirement for requirement in OnboardingRequirement.objects.filter(club=club, is_active=True) if event_kind in requirement.blocked_event_kinds]
if not blocking:
return []
resolved_ids = set(MemberRequirementStatus.objects.filter(membership=membership, requirement__in=blocking).filter(_RESOLVED).values_list("requirement_id", flat=True))
return [requirement for requirement in blocking if requirement.pk not in resolved_ids]
#: Fee states "clean" enough to activate on -- PARTIALLY_PAID/UNPAID never are.
_CLEAN_FEE_STATUSES = (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED)
def is_signup_clean(membership) -> bool:
"""Paid up (or waived) and every active requirement resolved -- what both
approve_all_clean and approve_one gate on, and what the Sign-up page's
per-member Approve button enables/disables against. Not itself a shortcut
for "already active": a membership can be exactly this clean and still be
PENDING, waiting on this deliberately manual step."""
return membership.fee_status in _CLEAN_FEE_STATUSES and membership.onboarding_complete
def approve_one(membership) -> bool:
"""Admin-triggered single activation from the Sign-up page's detail panel --
same rule and same reasoning as approve_all_clean, just one membership instead
of a whole season's queue. Returns whether it actually activated (False if it
wasn't PENDING or wasn't clean)."""
if membership.status != ClubMembership.StatusChoices.PENDING or not is_signup_clean(membership):
return False
membership.status = ClubMembership.StatusChoices.ACTIVE
update_fields = ["status"]
if membership.activated_at is None:
membership.activated_at = timezone.localdate()
update_fields.append("activated_at")
membership.save(update_fields=update_fields)
return True
def approve_all_clean(club, season) -> int:
"""Admin-triggered bulk activation from the Sign-up page -- the *only* path to
ClubMembership.status ACTIVE (see OnboardingRequirement's docstring: paying in
full only settles fee_status now, club.services.fees._sync_fee_status never
touches status). Only ever moves PENDING -> ACTIVE, and only for a membership
that is both paid up (fee_status PAID or WAIVED) and has resolved every active
requirement -- "manual documentation check to be done by the admin" means
clicking this once everything has actually been checked, not something that runs
on its own. Returns how many memberships were activated."""
memberships = list(
ClubMembership.objects.filter(
club=club,
season=season,
kind=ClubMembership.Kind.MEMBER,
status=ClubMembership.StatusChoices.PENDING,
fee_status__in=_CLEAN_FEE_STATUSES,
)
)
annotate_onboarding_status(memberships)
ready = [membership for membership in memberships if membership.onboarding_open == 0]
today = timezone.localdate()
for membership in ready:
membership.status = ClubMembership.StatusChoices.ACTIVE
if membership.activated_at is None:
membership.activated_at = today
if ready:
ClubMembership.objects.bulk_update(ready, ["status", "activated_at"])
return len(ready)

131
club/services/seasons.py Normal file
View File

@@ -0,0 +1,131 @@
"""Generating a club's seasons ahead of time.
Season (club/models.py) has no stored notion of "when a season starts" -- that
lives on Club instead (season_start, season_duration_months), since different
clubs run their year on different cycles. Same shape as
events/services/recurrence.py's generate_occurrences: materialise missing rows
up to a horizon, get_or_create per row, safe to call repeatedly.
"""
import datetime
from dateutil.relativedelta import relativedelta
from django.db.models import ProtectedError
from django.db.models.deletion import Collector
from django.utils import timezone
from club.models import Season
def _initial_season_start(club, today):
"""The most recent occurrence of the club's configured season_start that is
not later than ``today`` -- so a club with no seasons yet gets one covering
"now" (or the most recently completed one), not an arbitrary future year."""
anchor = club.season_start
start = datetime.date(today.year, anchor.month, anchor.day)
if start > today:
start = datetime.date(today.year - 1, anchor.month, anchor.day)
return start
def _season_end(start, club):
return start + relativedelta(months=club.season_duration_months) - datetime.timedelta(days=1)
def generate_seasons(club, until):
"""Materialise seasons for ``club`` from wherever it last left off -- the day
after its latest season's end_date, or its configured season_start if it has
none yet -- through ``until``. get_or_create per row (matches the
unique_season_dates_per_club constraint exactly), safe to call repeatedly.
"""
latest = Season.objects.filter(club=club).order_by("-end_date").first()
start = latest.end_date + datetime.timedelta(days=1) if latest else _initial_season_start(club, timezone.localdate())
created = []
while start <= until:
end = _season_end(start, club)
season, was_created = Season.objects.get_or_create(club=club, start_date=start, end_date=end)
if was_created:
_carry_guardians_into(club, season)
created.append(season)
start = end + datetime.timedelta(days=1)
return created
def _carry_guardians_into(club, season):
"""Copy the previous season's guardians into a season that has just been created.
The other half of members.services.family.carry_guardians_forward, which
covers a guardian added *after* the later seasons already existed. Between
them a parent keeps their tie to the club across every season boundary --
without which they would quietly drop off the club while their child stayed
enrolled, which is exactly the state a guardian exists to prevent.
Copied from the immediately preceding season, not from "any season ever", so
a guardian an admin deliberately removed stays removed rather than being
resurrected from an older row.
"""
from club.models import ClubMembership
previous = Season.objects.filter(club=club, start_date__lt=season.start_date).order_by("-start_date").first()
if previous is None:
return
guardians = ClubMembership.objects.filter(club=club, season=previous, kind=ClubMembership.Kind.GUARDIAN)
ClubMembership.objects.bulk_create(
[ClubMembership(club=club, member_id=guardian.member_id, season=season, kind=ClubMembership.Kind.GUARDIAN, status=guardian.status, signed_up_at=guardian.signed_up_at) for guardian in guardians],
ignore_conflicts=True,
)
def _expected_season_dates(club, until):
"""The (start_date, end_date) pairs generate_seasons would produce for
``club`` from scratch, ignoring whatever already exists -- used by
resync_seasons to tell "matches the club's current settings" from "doesn't"."""
start = _initial_season_start(club, timezone.localdate())
expected = set()
while start <= until:
end = _season_end(start, club)
expected.add((start, end))
start = end + datetime.timedelta(days=1)
return expected
def _is_referenced(season):
"""Whether deleting ``season`` would hit a PROTECT on any of its relations
(ClubMembership, StaffAssignment, TeamMembership, Event, ...) without
actually deleting anything."""
collector = Collector(using=season._state.db)
try:
collector.collect([season])
except ProtectedError:
return True
return False
def resync_seasons(club, until, *, commit=False):
"""Find seasons for ``club`` that don't match what its *current*
season_start/season_duration_months would produce (e.g. left over from a
different rule, or from before those settings were changed), within the
same horizon generate_seasons would cover.
A season is only ever removed if nothing references it through a PROTECTed
relation -- a season already in use is reported as kept, never silently
dropped. With commit=False (the default) nothing is deleted; the caller
gets back what *would* happen.
"""
expected = _expected_season_dates(club, until)
removed, kept = [], []
for season in Season.objects.filter(club=club):
if (season.start_date, season.end_date) in expected:
continue
if _is_referenced(season):
kept.append(season)
else:
removed.append(season)
if commit:
season.delete()
return removed, kept

30
club/services/sponsors.py Normal file
View File

@@ -0,0 +1,30 @@
"""Which sponsors are currently "live" -- shared by the public API
(club/api.py, the club's own external website) and the mobile member app's
Home screen (mobile/views.py), so both read the same definition of "active"
rather than each re-deriving it.
"""
import random
from django.db.models import Q
from django.utils import timezone
from ..models import Sponsor
def active_sponsors(club, *, randomize=False):
"""Sponsors currently live for ``club``: ``start_date`` has passed and
either there's no ``end_date`` (runs indefinitely once started) or it
hasn't passed yet. Both bounds are inclusive of today.
``randomize=True`` shuffles the result (e.g. for a sponsor strip that
shouldn't always lead with the same one) -- shuffled in Python after a
stable-ordered fetch rather than an ORDER BY RANDOM(), which sponsor
counts are far too small to need and which SQLite/Postgres don't even
express the same way.
"""
today = timezone.localdate()
sponsors = list(Sponsor.objects.filter(club=club, start_date__lte=today).filter(Q(end_date__isnull=True) | Q(end_date__gte=today)).order_by("name"))
if randomize:
random.shuffle(sponsors)
return sponsors

36
club/tasks.py Normal file
View File

@@ -0,0 +1,36 @@
"""Celery task behind the `generate-seasons` beat schedule entry (see
rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py).
Mirrors `manage.py generate_seasons`'s default behaviour (generate, not --resync) exactly --
that command still exists, unchanged, for manual use from a shell, including --resync, which
this task deliberately does not run unattended (see club/management/commands/generate_seasons.py:
--resync can delete rows, so it isn't something a beat schedule should do on its own).
"""
from celery import shared_task
from dateutil.relativedelta import relativedelta
from django.utils import timezone
from club.models import Club
from club.services.seasons import generate_seasons as generate_seasons_for_club
from features.models import JobToggle, Maintenance
#: How far ahead to generate, matching the management command's own default.
YEARS_AHEAD = 2
@shared_task(name="club.tasks.generate_seasons")
def generate_seasons():
if Maintenance.is_on():
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
if not JobToggle.is_enabled("club.tasks.generate_seasons"):
raise RuntimeError("This job is disabled in the control panel.")
until = timezone.localdate() + relativedelta(years=YEARS_AHEAD)
clubs = Club.objects.active()
total = 0
for club in clubs:
total += len(generate_seasons_for_club(club, until))
return f"Generated {total} season(s) across {clubs.count()} club(s)."

View File

@@ -0,0 +1,100 @@
{% load i18n %}
{% comment %}
Rendered by WeasyPrint, not a browser -- same convention as billing/templates/billing/invoice.html
and management/templates/management/membership_list_pdf.html: a standalone document with its
own print stylesheet, no app.css. Branded off the club's own colours (falling back to the same
shades club.templatetags.club_email's HTML emails use) rather than a fixed accent, since this
is the club's invoice to its own member, not RosterChief's to the club.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ invoice.number }}</title>
<style>
@page {
size: A4;
margin: 20mm;
@bottom-center {
content: "{{ club.name }} — {% trans "invoice" %} {{ invoice.number }} — " counter(page) " / " counter(pages);
font-size: 8pt;
color: #666;
}
}
body { font-family: sans-serif; font-size: 10pt; color: #111; }
h1 { font-size: 20pt; margin: 0 0 2mm; }
.muted { color: #666; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12mm; }
.accent { color: {{ club.primary_color|default:"#4f46e5" }}; }
.parties { display: flex; justify-content: space-between; margin-bottom: 10mm; }
.parties h2 { font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; margin: 0 0 2mm; }
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
th { text-align: left; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; border-bottom: 1px solid #ccc; padding: 2mm 0; }
td { padding: 2mm 0; border-bottom: 1px solid #eee; }
.right { text-align: right; }
.total td { font-weight: bold; border-bottom: 2px solid #111; border-top: 1px solid #111; }
.balance { font-size: 12pt; font-weight: bold; }
.paid { color: #15803d; }
.owed { color: #b91c1c; }
</style>
</head>
<body>
<div class="header">
<div>
<h1>{{ club.official_name }}</h1>
{% if document_address %}
<div class="muted">{{ document_address.address }}</div>
<div class="muted">{{ document_address.zip_code }} {{ document_address.city }}</div>
{% endif %}
{% if club.contact_email %}<div class="muted">{{ club.contact_email }}</div>{% endif %}
</div>
<div class="right">
<h1 class="accent">{% trans "Invoice" %}</h1>
<div><strong>{{ invoice.number }}</strong></div>
{% if invoice.sent_at %}<div class="muted">{% blocktrans with date=invoice.sent_at|date:"j F Y" %}Issued {{ date }}{% endblocktrans %}</div>{% endif %}
</div>
</div>
<div class="parties">
<div>
<h2>{% trans "Billed to" %}</h2>
<div><strong>{{ member }}</strong></div>
{% if invoice.sent_to_email %}<div class="muted">{{ invoice.sent_to_email }}{% if invoice.sent_to_guardian %} ({% trans "parent/guardian" %}){% endif %}</div>{% endif %}
</div>
<div class="right">
<h2>{% trans "Season" %}</h2>
<div>{{ membership.season }}</div>
<div class="muted">{% blocktrans with date=invoice.due_date|date:"j F Y" %}Due {{ date }}{% endblocktrans %}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>{% trans "Description" %}</th>
<th class="right">{% trans "Amount" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<strong>{% trans "Membership fee" %}</strong>
<div class="muted">{{ club.name }} — {{ membership.season }}</div>
</td>
<td class="right">€{{ invoice.amount }}</td>
</tr>
<tr class="total">
<td>{% trans "Balance due" %}</td>
<td class="right balance {% if invoice.is_paid %}paid{% else %}owed{% endif %}">€{{ invoice.amount }}</td>
</tr>
</tbody>
</table>
{% if invoice.is_paid %}
<p class="paid"><strong>{% trans "Paid in full." %}</strong> {% trans "Thank you." %}</p>
{% else %}
<p class="muted">{% blocktrans with date=invoice.due_date|date:"j F Y" %}Payable by {{ date }}.{% endblocktrans %}</p>
{% endif %}
</body>
</html>

View File

@@ -0,0 +1,72 @@
{% extends "email/_base.html" %}
{% load i18n club_email %}
{% comment %}
HTML sibling of dues_invoice.txt -- same content, same context (club, membership,
member, invoice, request), laid out for an inbox. Kept in lockstep with the .txt
version by hand, same as members/templates/members/email/claim_approved.html.
{% endcomment %}
{% block title %}{% blocktrans with club=club.name number=invoice.number %}{{ club }} — invoice {{ number }}{% endblocktrans %}{% endblock title %}
{% block preheader %}{% blocktrans with club=club.name %}{{ club }} has sent you an invoice for a membership fee.{% endblocktrans %}{% endblock preheader %}
{% block header %}
{% absolute_media_url club.logo as logo_url %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="middle">
{% if club.logo %}
<img src="{{ logo_url }}" alt="{{ club.name }}" width="48" height="48" style="display:block; width:48px; height:48px; border-radius:24px; object-fit:contain; background-color:#f3f4f6;">
{% else %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
<tr>
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_color|default:"#ec4899"|contrast_color }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
{{ club.initials }}
</td>
</tr>
</table>
{% endif %}
</td>
<td style="padding-left:14px;" valign="middle">
<span style="font-family: Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; color:#111827;">{{ club.name }}</span>
</td>
</tr>
</table>
{% endblock header %}
{% block content %}
<p style="margin:0 0 16px 0;">{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}</p>
<p style="margin:0 0 20px 0;">{% blocktrans with club=club.name season=membership.season %}{{ club }} has sent you an invoice for your {{ season }} membership fee.{% endblocktrans %}</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0; border:1px solid #e5e7eb; border-radius:8px;">
<tr>
<td style="padding:16px 20px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="font-size:13px; color:#6b7280;">{% trans "Amount due" %}</td>
<td align="right" style="font-size:18px; font-weight:bold; color:#111827;">€{{ invoice.amount }}</td>
</tr>
<tr>
<td style="font-size:13px; color:#6b7280; padding-top:6px;">{% trans "Due date" %}</td>
<td align="right" style="font-size:13px; color:#111827; padding-top:6px;">{{ invoice.due_date|date:"j F Y" }}</td>
</tr>
<tr>
<td style="font-size:13px; color:#6b7280; padding-top:6px;">{% trans "Invoice number" %}</td>
<td align="right" style="font-size:13px; color:#111827; padding-top:6px;">{{ invoice.number }}</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="margin:0;">{% trans "A PDF copy of this invoice is attached." %}</p>
{% endblock content %}
{% block footer %}
{% if club.contact_email %}
<p style="margin:0 0 8px 0;">{% blocktrans with email=club.contact_email %}Questions about this invoice? Reply to this note or write to {{ email }}.{% endblocktrans %}</p>
{% endif %}
<p style="margin:0;">{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}</p>
{% endblock footer %}

View File

@@ -0,0 +1,11 @@
{% load i18n %}{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}
{% blocktrans with club=club.name season=membership.season %}{{ club }} has sent you an invoice for your {{ season }} membership fee.{% endblocktrans %}
{% trans "Amount due:" %} €{{ invoice.amount }}
{% trans "Due date:" %} {{ invoice.due_date|date:"j F Y" }}
{% trans "Invoice number:" %} {{ invoice.number }}
{% if club.contact_email %}
{% blocktrans with email=club.contact_email %}Questions about this invoice? Reply to this note or write to {{ email }}.{% endblocktrans %}
{% endif %}
{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}

View File

@@ -0,0 +1,73 @@
{% extends "email/_base.html" %}
{% load i18n club_email %}
{% comment %}
HTML sibling of dues_invoice_reminder.txt -- same content, same context (club,
membership, member, invoice, request). Same shell/branding as dues_invoice.html,
just a different message and no "PDF attached" line (the reminder re-attaches
the same PDF the original invoice did, but leads with the overdue note instead).
{% endcomment %}
{% block title %}{% blocktrans with club=club.name number=invoice.number %}Reminder: {{ club }} invoice {{ number }} is overdue{% endblocktrans %}{% endblock title %}
{% block preheader %}{% blocktrans with club=club.name %}A membership fee invoice from {{ club }} is still unpaid.{% endblocktrans %}{% endblock preheader %}
{% block header %}
{% absolute_media_url club.logo as logo_url %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="middle">
{% if club.logo %}
<img src="{{ logo_url }}" alt="{{ club.name }}" width="48" height="48" style="display:block; width:48px; height:48px; border-radius:24px; object-fit:contain; background-color:#f3f4f6;">
{% else %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
<tr>
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_color|default:"#ec4899"|contrast_color }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
{{ club.initials }}
</td>
</tr>
</table>
{% endif %}
</td>
<td style="padding-left:14px;" valign="middle">
<span style="font-family: Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; color:#111827;">{{ club.name }}</span>
</td>
</tr>
</table>
{% endblock header %}
{% block content %}
<p style="margin:0 0 16px 0;">{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}</p>
<p style="margin:0 0 20px 0;">{% blocktrans with club=club.name date=invoice.due_date|date:"j F Y" %}This is a reminder that {{ club }}'s invoice for your membership fee was due on {{ date }} and is still unpaid.{% endblocktrans %}</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0; border:1px solid #fca5a5; background-color:#fef2f2; border-radius:8px;">
<tr>
<td style="padding:16px 20px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="font-size:13px; color:#991b1b;">{% trans "Amount due" %}</td>
<td align="right" style="font-size:18px; font-weight:bold; color:#991b1b;">€{{ invoice.amount }}</td>
</tr>
<tr>
<td style="font-size:13px; color:#991b1b; padding-top:6px;">{% trans "Was due" %}</td>
<td align="right" style="font-size:13px; color:#991b1b; padding-top:6px;">{{ invoice.due_date|date:"j F Y" }}</td>
</tr>
<tr>
<td style="font-size:13px; color:#991b1b; padding-top:6px;">{% trans "Invoice number" %}</td>
<td align="right" style="font-size:13px; color:#991b1b; padding-top:6px;">{{ invoice.number }}</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="margin:0;">{% trans "A PDF copy of this invoice is attached." %}</p>
{% endblock content %}
{% block footer %}
{% if club.contact_email %}
<p style="margin:0 0 8px 0;">{% blocktrans with email=club.contact_email %}Already paid? Let us know at {{ email }} so we can update our records.{% endblocktrans %}</p>
{% endif %}
<p style="margin:0;">{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}</p>
{% endblock footer %}

View File

@@ -0,0 +1,10 @@
{% load i18n %}{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}
{% blocktrans with club=club.name date=invoice.due_date|date:"j F Y" %}This is a reminder that {{ club }}'s invoice for your membership fee was due on {{ date }} and is still unpaid.{% endblocktrans %}
{% trans "Amount due:" %} €{{ invoice.amount }}
{% trans "Invoice number:" %} {{ invoice.number }}
{% if club.contact_email %}
{% blocktrans with email=club.contact_email %}Already paid? Let us know at {{ email }} so we can update our records.{% endblocktrans %}
{% endif %}
{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}

View File

@@ -0,0 +1 @@
{% load i18n %}{% blocktrans with club=club.name number=invoice.number %}Reminder: {{ club }} invoice {{ number }} is overdue{% endblocktrans %}

View File

@@ -0,0 +1 @@
{% load i18n %}{% blocktrans with club=club.name number=invoice.number %}{{ club }} — invoice {{ number }}{% endblocktrans %}

View File

@@ -12,6 +12,9 @@
You are signed in as <span class="font-semibold">{{ user.get_full_name|default:user.email }}</span>.
</p>
<p class="text-sm opacity-70">The club site lands here. For now this page exists so signing in has somewhere to go.</p>
<div class="card-actions pt-2">
<a class="btn btn-outline btn-sm gap-2" href="{% url 'members:my_family' %}">{% lucide "users" size=14 %} My family</a>
</div>
</div>
</div>
</div>

View File

View File

@@ -0,0 +1,43 @@
"""Template helper shared by every HTML email that shows a club's logo.
An <img> in an email has no page to resolve a relative /media/... URL against
the way a browser tab would -- the inbox fetches it cold. Storage backends that
already return an absolute URL (e.g. S3 in production) are unaffected; this
only matters for the local FileSystemStorage used in dev, where FieldFile.url
is relative.
"""
from django import template
from club.models import Club
register = template.Library()
@register.filter
def contrast_color(hex_color):
"""Black or white, whichever reads on ``hex_color`` -- for a literal
fallback background (e.g. ``club.secondary_color|default:"#ec4899"``)
rather than a club's own colour, which already has a matching
``primary_content_color``/``secondary_content_color`` computed for it.
Chaining this onto the *same* expression used for the background --
``club.secondary_color|default:"#ec4899"|contrast_color`` -- rather than
hardcoding a second, independently-guessed text colour is what keeps the
two from drifting apart: a pale fallback and a dark one both get the
contrast Club._content_color_for would compute for them either way.
"""
return Club._content_color_for(hex_color)
@register.simple_tag(takes_context=True)
def absolute_media_url(context, file_field):
"""An absolute URL for ``file_field`` (e.g. ``club.logo``), for use in an
email. Falls back to the field's own (possibly relative) ``.url`` when
there's no request in the template context to build an absolute one from --
better a relative URL than a hard error while rendering the email."""
if not file_field:
return ""
request = context.get("request")
return request.build_absolute_uri(file_field.url) if request is not None else file_field.url

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
from django.shortcuts import redirect, render
from django.views.generic import TemplateView
@@ -21,3 +21,13 @@ def root(request):
return redirect("controlpanel:dashboard")
return ClubHomeView.as_view()(request)
def signup_closed(request):
"""Self-registration is closed -- see rosterchief/urls.py.
Shadows allauth's own signup route rather than removing it, so the
`account_signup` URL name every allauth template reverses still resolves and
the login page doesn't 500 looking for it.
"""
return render(request, "account/signup_closed.html", status=403)

View File

@@ -19,6 +19,14 @@ services:
env_file: .env.production
ports:
- "127.0.0.1:${WEB_PORT:-8001}:8000"
volumes:
# Uploaded club logos, while storage is local disk (see rosterchief/urls.py). Without
# this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps
# running fine in between.
- media_data:/app/media
# Private uploads (e.g. a member's medical certificate) -- see compose.yaml's own
# comment on this volume for why it's absent from every other service here.
- private_media_data:/app/private_media
depends_on:
db:
condition: service_healthy
@@ -31,6 +39,32 @@ services:
retries: 3
start_period: 20s
worker:
build: .
restart: unless-stopped
env_file: .env.production
# See compose.yaml for what runs here and why.
command: ["celery", "-A", "rosterchief", "worker", "--loglevel=info", "--concurrency=2"]
volumes:
- media_data:/app/media
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
beat:
build: .
restart: unless-stopped
env_file: .env.production
# Exactly ONE of these across the whole deployment -- see compose.yaml.
command: ["celery", "-A", "rosterchief", "beat", "--loglevel=info"]
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:17-alpine
restart: unless-stopped
@@ -38,6 +72,8 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-rosterchief}
POSTGRES_USER: ${POSTGRES_USER:-rosterchief}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a database password}
# See compose.yaml for why these are trimmed from the defaults.
command: ["postgres", "-c", "shared_buffers=64MB", "-c", "max_connections=20"]
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
@@ -49,7 +85,10 @@ services:
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
# Cache AND Celery broker for worker/beat above -- see compose.yaml's redis comment.
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "32mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
pgdata:
media_data:
private_media_data:

View File

@@ -25,6 +25,9 @@ services:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
# Read-only: Caddy serves club logos straight off disk instead of round-tripping every
# image request through a gunicorn worker. Same volume `web` writes uploads into.
- media_data:/srv/media:ro
depends_on:
- web
@@ -32,6 +35,15 @@ services:
build: .
restart: unless-stopped
env_file: .env.production
volumes:
# Uploaded club logos, while storage is local disk (see rosterchief/urls.py). Without
# this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps
# running fine in between.
- media_data:/app/media
# Private uploads (e.g. a member's medical certificate -- see rosterchief/storage.py).
# Deliberately NOT mounted into `caddy` below, unlike media_data: nothing should be able
# to serve this except the authenticated Django view that reads it.
- private_media_data:/app/private_media
depends_on:
db:
condition: service_healthy
@@ -44,6 +56,38 @@ services:
retries: 3
start_period: 20s
worker:
build: .
restart: unless-stopped
env_file: .env.production
# The scheduled platform jobs (see billing/tasks.py, club/tasks.py, events/tasks.py) run
# here, dispatched by `beat` below over the same Redis `web` uses as a cache — see
# rosterchief/settings.py's "Task queue (Celery)" section. Several of these are safe to
# scale; `beat` is not (see its own comment).
command: ["celery", "-A", "rosterchief", "worker", "--loglevel=info", "--concurrency=2"]
volumes:
- media_data:/app/media
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
beat:
build: .
restart: unless-stopped
env_file: .env.production
# The scheduler -- decides *when* each task in CELERY_BEAT_SCHEDULE fires and hands it to
# a worker. Run exactly ONE of these: two beats would each independently decide it's time
# and every job runs twice (two archive_overdue_clubs runs is two emails to the same club,
# same reasoning as the old crontab's "exactly one node" -- see DEPLOYMENT.md).
command: ["celery", "-A", "rosterchief", "beat", "--loglevel=info"]
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:17-alpine
restart: unless-stopped
@@ -51,6 +95,11 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-rosterchief}
POSTGRES_USER: ${POSTGRES_USER:-rosterchief}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a database password}
# shared_buffers/max_connections default to 128MB / 100 — sized for a much bigger database
# than this app's (DEPLOYMENT.md: ~0.2GB after 5 years). 20 connections is comfortably above
# 2 gunicorn workers x 4 threads plus the odd `manage.py` one-off; trimmed both for the box,
# not for the data.
command: ["postgres", "-c", "shared_buffers=64MB", "-c", "max_connections=20"]
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
@@ -62,11 +111,19 @@ services:
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
# Cache only, so nothing here needs to survive a restart. It is not optional though: it
# is what keeps every gunicorn worker agreeing about which feature flags are on.
# Doubles as the Celery broker/result backend for `worker`/`beat` (see rosterchief/settings.py)
# as well as the cache. maxmemory-policy allkeys-lru is right for a cache — evict rather than
# grow unbounded — but it means a queued task message COULD be evicted under memory pressure
# before a worker consumes it, same as a Redis restart drops anything queued (--save "",
# --appendonly no: nothing here persists by design). Acceptable at this job volume (five
# scheduled tasks a day; a missed one runs at its next scheduled time regardless, per
# CELERY_BEAT_SCHEDULE); if that stops being true, give Celery its own Redis instance rather
# than changing this cache's eviction policy to suit it.
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "32mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
pgdata:
caddy_data:
caddy_config:
media_data:
private_media_data:

View File

@@ -0,0 +1,16 @@
"""So the command bar's status indicator (base.html) can reflect real job health on every
control panel page, not just the dashboard, without every view remembering to pass it.
Guarded to controlpanel pages only -- unlike features.context_processors.maintenance (a
cached read, cheap anywhere), this runs a real query, and every other page on the platform
(club subdomains, the public site) has no command bar to show it on.
"""
from .services.jobs import recent_job_failures
def job_health(request):
if not (request.resolver_match and request.resolver_match.app_name == "controlpanel"):
return {}
return {"failed_jobs": recent_job_failures()}

View File

@@ -4,8 +4,9 @@ from django import forms
from django.utils.translation import gettext_lazy as _
from waffle import get_waffle_flag_model
from billing.models import DuePayment, Subscription, Tier, TierPrice
from billing.models import DuePayment, Plan, PlanPrice, Subscription
from club.models import Club
from events.models import Competition, Location
from .services.admins import find_member_by_email
@@ -13,18 +14,38 @@ from .services.admins import find_member_by_email
class ClubForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name", "slug", "logo", "primary_color"]
fields = ["name", "legal_name", "contact_email", "website", "slug", "sport_type", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"]
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
# Deliberately a text input, not <input type="color">: a colour picker cannot
# express "no colour" -- it would submit #000000 for every club that never
# touched it, and every club would silently get a black theme.
widgets = {"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"})}
widgets = {
"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}),
"secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}),
"logo": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp,image/svg+xml"}),
"season_start": forms.DateInput(attrs={"type": "date"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["slug"].required = False
class HomeLocationForm(forms.ModelForm):
"""Create or update the club's home ground -- this *is* an events.Location row
(flagged ``is_home``), the same one that shows up under the club's own
Teams > Locations page, so the two stay in sync by construction rather than
needing anything to keep them that way."""
class Meta:
model = Location
fields = ["name", "address", "city", "zip_code", "country"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["country"].widget.attrs.update({"data-searchable": "true", "data-search-placeholder": _("Type a country to search...")})
class ClubAdminForm(forms.Form):
"""Grant club-admin rights to an email address, creating the person if new."""
@@ -61,34 +82,94 @@ class FlagForm(forms.ModelForm):
}
class TierForm(forms.ModelForm):
class CompetitionForm(forms.ModelForm):
"""Metadata for events.services.competitions.fetch_game_info's per-club gate --
`module` is a dotted import path to a class named `name` that implements
`update_game_information(event=...)`; there is no such class for a new
competition until one is actually written, but that's fine here, same as
editing this by hand in the Django admin today: fetch_game_info already
catches the resulting ImportError/AttributeError and treats it as "nothing to
fetch from" rather than a 500, so this form doesn't need to validate the path
against real code to be safe to use."""
class Meta:
model = Tier
fields = ["name", "description", "is_active"]
model = Competition
fields = ["name", "module", "sport_type", "flag"]
help_texts = {
"module": _("Dotted path to the Python module implementing this competition's data source, e.g. events.services.competitions.cehl."),
}
class TierPriceForm(forms.ModelForm):
class PlanForm(forms.ModelForm):
"""Field order is chosen for the two-column modal (see _form_fields.html): description
spans both columns, so pairing name with duration and the two day-counts with each other
fills every row instead of leaving half of one empty.
name | duration_months
description ....................... (spans both)
renewal_lead_days | grace_days
is_trial | is_active
"""
class Meta:
model = TierPrice
model = Plan
fields = ["name", "duration_months", "description", "renewal_lead_days", "grace_days", "is_trial", "is_active"]
class PlanPriceForm(forms.ModelForm):
class Meta:
model = PlanPrice
fields = ["active_from", "amount"]
widgets = {"active_from": forms.DateInput(attrs={"type": "date"})}
help_texts = {"active_from": _("Periods opening on or after this date are billed at this amount. Existing periods keep the amount they were billed at.")}
help_texts = {
"active_from": _(
"Periods opening on or after this date are billed at this amount. Existing periods keep the amount "
"they were billed at — including any already issued during a plan's renewal lead window, so enter a "
"price change before that window opens."
)
}
class SubscriptionForm(forms.ModelForm):
"""Put a club on a tier. The first period opens when the subscription is created."""
"""Put a club on a plan. The first period opens when the subscription is created."""
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("First period starts"), help_text=_("Left blank, the period starts today."))
class Meta:
model = Subscription
fields = ["tier", "auto_archive", "notes"]
fields = ["plan", "auto_renew", "auto_archive", "notes"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# An inactive tier still bills its existing subscriptions, but must not be picked up
# by a new one — which is the whole point of retiring a tier.
self.fields["tier"].queryset = Tier.objects.filter(is_active=True)
# An inactive plan still bills its existing subscriptions, but must not be picked up
# by a new one — which is the whole point of retiring a plan. Trial plans are excluded
# too: they are reached through the trial form, which converts them properly.
self.fields["plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=False)
class TrialForm(forms.Form):
"""Put a club with no subscription yet on a trial that switches itself to
``post_trial_plan`` automatically once it ends -- see billing.services.dues.start_trial.
There is no length field: a trial's length is its plan's own ``duration_months``, so a
1-month and a 3-month trial are two plans rather than one plan plus a number typed here.
"""
trial_plan = forms.ModelChoiceField(queryset=Plan.objects.none(), label=_("Trial plan"), help_text=_("What this club is billed on during the trial. Its length is the plan's own duration."))
post_trial_plan = forms.ModelChoiceField(queryset=Plan.objects.none(), label=_("Then switch to"), help_text=_("The plan it lands on automatically once the trial ends."))
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("Trial starts"), help_text=_("Left blank, the trial starts today."))
# Same two switches SubscriptionForm offers. Without them here a trial could only be
# started on the defaults, and the only way to change them afterwards is the "Change
# plan" modal -- which ends the trial as a side effect.
auto_renew = forms.BooleanField(required=False, initial=True, label=_("Auto renew"), help_text=_("Issue the next period automatically before this one ends."))
auto_archive = forms.BooleanField(required=False, initial=True, label=_("Auto archive"), help_text=_("Archive this club when a period goes unpaid past its grace period."))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Same reasoning as SubscriptionForm: a retired plan keeps billing whoever is
# already on it, but must not be offered for a new trial or a new plan either.
self.fields["trial_plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=True)
self.fields["post_trial_plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=False)
class DuePaymentForm(forms.Form):

35
controlpanel/messages.py Normal file
View File

@@ -0,0 +1,35 @@
"""A compact way to queue a Django message that carries its own title.
Django's messages framework has no title field — a call site that wants one passes it
as ``extra_tags`` (``messages.success(request, body, extra_tags="Club created")``), which
reads fine written out but is easy to forget, so in practice every message ends up on
the generic per-level heading (`as_alert`'s "Done" / "Careful" / "Something went wrong").
``notify`` folds level, title and body into one string instead: ``"<level>|<title>|<body>"``.
One call, title included, nothing to forget. `as_alert` (controlpanel/templatetags/ui.py)
reads the title back off ``extra_tags`` at render time — unchanged from before.
"""
from django.contrib import messages
#: One letter per Django message level. `notify` picks the level from the spec string;
#: `as_alert` picks the icon/colour/fallback-title from the level the message actually
#: carries (via ``level_tag``), so the two stay in step by construction.
LEVELS = {
"s": messages.SUCCESS,
"i": messages.INFO,
"w": messages.WARNING,
"e": messages.ERROR,
"d": messages.DEBUG,
}
def notify(request, spec: str, **kwargs) -> None:
"""Queue a message from a ``"<level>|<title>|<body>"`` spec.
``level`` is one of ``s`` (success), ``i`` (info), ``w`` (warning), ``e`` (error),
``d`` (debug). An empty title (``"s||Body text"``) falls back to the generic
per-level heading, same as never passing ``extra_tags`` at all.
"""
level_code, title, body = spec.split("|", 2)
messages.add_message(request, LEVELS[level_code], body, extra_tags=title, **kwargs)

View File

@@ -1,5 +1,8 @@
from django.contrib.auth.mixins import UserPassesTestMixin
from django.http import Http404
from django.shortcuts import redirect
from .messages import notify
class PlatformStaffRequiredMixin(UserPassesTestMixin):
@@ -38,3 +41,21 @@ class PlatformSuperuserRequiredMixin(PlatformStaffRequiredMixin):
def test_func(self):
return self.request.user.is_superuser
class RedirectOnInvalidMixin:
"""A form submitted from a modal has nowhere sensible to re-render on error: the page
that opened it has already moved on, and the view has no standalone template of its
own. Redirect back to ``invalid_redirect_url_name`` instead, with the errors flattened
into messages, rather than Django's default of re-rendering ``template_name``.
"""
invalid_redirect_url_name = None
def get_invalid_redirect_kwargs(self):
return {}
def form_invalid(self, form):
for error in form.errors.values():
notify(self.request, f"e|Couldn't save|{' '.join(error)}")
return redirect(self.invalid_redirect_url_name, **self.get_invalid_redirect_kwargs())

View File

@@ -0,0 +1,52 @@
"""Read side of the scheduled-job history for the control panel's Jobs tab and the
Platform dashboard's job log / failed-jobs tile.
``features.jobs.JOB_REGISTRY`` is what a job *is* (label, description, schedule);
``features.models.JobRun`` is what actually happened, written by the Celery signal handlers
in features/signals.py. This module just joins the two for a template.
"""
from datetime import timedelta
from django.utils import timezone
from features.jobs import JOB_REGISTRY
from features.models import JobRun, JobToggle
#: Runs shown per job on the Jobs tab -- enough to see a pattern (a job that fails every
#: third day, say) without the page turning into a full audit log.
RECENT_RUNS = 10
#: What counts as "recent" for the dashboard's failed-jobs KPI tile.
FAILURE_WINDOW_HOURS = 24
#: Rows in the Platform dashboard's job log card.
JOB_LOG_ROWS = 8
def job_overview():
"""One entry per registered job, its most recent runs, and a shortcut to the latest."""
return [
{
"name": name,
"label": meta["label"],
"description": meta["description"],
"schedule": meta["schedule"],
"enabled": JobToggle.is_enabled(name),
"runs": (runs := list(JobRun.objects.filter(name=name)[:RECENT_RUNS])),
"latest": runs[0] if runs else None,
}
for name, meta in JOB_REGISTRY.items()
]
def recent_job_failures(hours=FAILURE_WINDOW_HOURS):
"""Failures in the last `hours` -- the platform-health "failed jobs" signal. A number
that sits here is exactly what a dead beat schedule or a broken task looks like."""
since = timezone.now() - timedelta(hours=hours)
return JobRun.objects.filter(status=JobRun.Status.FAILURE, started_at__gte=since)
def recent_job_runs(limit=JOB_LOG_ROWS):
"""Every job's runs, most recent first, for the dashboard's Job log card."""
return JobRun.objects.all()[:limit]

View File

@@ -10,15 +10,17 @@ from datetime import timedelta
from decimal import Decimal
from allauth.mfa.models import Authenticator
from dateutil.relativedelta import relativedelta
from django.contrib.auth import get_user_model
from django.db.models import Count, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value
from django.db.models import Count, DateField, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value
from django.db.models.functions import Coalesce, TruncMonth
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from waffle import get_waffle_flag_model
from authentication.middleware import ELEVATED_ROLES
from billing.models import Due, DuePayment, Subscription
from billing.services.dues import dues_in_grace, dues_overdue
from billing.services.dues import dues_in_grace, dues_overdue, subscriptions_due_for_renewal
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Event
from members.models import Member
@@ -39,7 +41,10 @@ def clubs_with_totals(queryset=None):
"""Clubs annotated with headline counts (one query, no N+1)."""
clubs = Club.objects.all() if queryset is None else queryset
return clubs.annotate(
member_count=Count("clubmemberships__member", distinct=True),
# Members only: a guardian is attached to the club as a parent of a member,
# not as one, so counting them would overstate every club's size (and the
# onboarding funnel's "with members" step).
member_count=Count("clubmemberships__member", filter=Q(clubmemberships__kind=ClubMembership.Kind.MEMBER), distinct=True),
team_count=Count("teams", distinct=True),
event_count=Count("events", distinct=True),
admin_count=Count("clubroles", filter=Q(clubroles__role=ClubRole.Roles.ADMIN), distinct=True),
@@ -64,6 +69,8 @@ def clubs_with_health(queryset=None, today=None, now=None):
clubs = Club.objects.active() if queryset is None else queryset
in_season = Q(season__start_date__lte=today, season__end_date__gte=today)
# A period the club is covered for, most recent first — paid or waived, both settled.
_covered = Due.objects.filter(club=OuterRef("pk"), status__in=(Due.Status.PAID, Due.Status.WAIVED)).order_by("-period_end")
managed_this_season = Q(
staff_assignments__season__start_date__lte=today,
staff_assignments__season__end_date__gte=today,
@@ -73,28 +80,71 @@ def clubs_with_health(queryset=None, today=None, now=None):
return (
clubs.annotate(
has_season=Exists(Season.objects.filter(club=OuterRef("pk"), start_date__lte=today, end_date__gte=today)),
active_members=_subquery(ClubMembership.objects.filter(in_season, status=ClubMembership.StatusChoices.ACTIVE), Count("pk"), IntegerField()),
unpaid_members=_subquery(ClubMembership.objects.filter(in_season, fee_status=ClubMembership.FeeStatus.UNPAID), Count("pk"), IntegerField()),
active_members=_subquery(ClubMembership.objects.filter(in_season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE), Count("pk"), IntegerField()),
unpaid_members=_subquery(ClubMembership.objects.filter(in_season, kind=ClubMembership.Kind.MEMBER, fee_status=ClubMembership.FeeStatus.UNPAID), Count("pk"), IntegerField()),
outstanding=_subquery(Order.objects.filter(status__in=OWED_STATUSES), Sum("total"), DecimalField(max_digits=10, decimal_places=2)),
upcoming_events=_subquery(Event.objects.filter(start__gte=now, start__lte=now + timedelta(days=DORMANT_DAYS)), Count("pk"), IntegerField()),
team_count=_subquery(Team.objects.all(), Count("pk"), IntegerField()),
teams_managed=_subquery(Team.objects.filter(managed_this_season), Count("pk", distinct=True), IntegerField()),
admin_count=_subquery(ClubRole.objects.filter(role=ClubRole.Roles.ADMIN), Count("pk"), IntegerField()),
tier_name=Subquery(Subscription.objects.filter(club=OuterRef("pk")).values("tier__name")[:1]),
plan_name=Subquery(Subscription.objects.filter(club=OuterRef("pk")).values("plan__name")[:1]),
dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)),
dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]),
dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]),
# How far the club is covered: the furthest-out period that is settled. PAID and
# WAIVED both mean nothing is owed for that period, and its end is the day grace
# would start if nothing renews — so both count. `covered_status` is read from the
# same top row, so the table can badge "paid" vs "waived". Null when the club owes
# or was never billed.
covered_until=Subquery(_covered.values("period_end")[:1], output_field=DateField()),
covered_status=Subquery(_covered.values("status")[:1]),
)
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
.order_by("name")
)
#: Risk tiers for the dashboard's "Club health" table, high risk first. Derived from signals
#: `clubs_with_health` already annotates -- no separate query, and nothing here is invented:
#: a club with no season covering today cannot take a signup, and dues past their grace date
#: are exactly what the archive job is about to act on.
RISK_HIGH, RISK_WATCH, RISK_OK = "high", "watch", "ok"
def club_risk(club, today):
"""The risk tier, plus a human reason naming exactly which signal tripped it -- so the
dashboard can show *why*, not just a colour. Checked in the same order as the tier
logic below: the first matching condition is the one reported."""
if not club.has_season:
return RISK_HIGH, _("No season covers today")
if club.dues_grace_until is not None and club.dues_grace_until < today:
return RISK_HIGH, _("Dues overdue past grace")
if not club.upcoming_events:
return RISK_WATCH, _("No events in the next 30 days")
if club.dues_owed:
return RISK_WATCH, _("Dues outstanding")
return RISK_OK, _("Nothing needs attention")
def clubs_by_risk(queryset=None, today=None):
"""`clubs_with_health`, ordered highest risk first -- the dashboard's Club health table
is "sorted by risk" per the design, and risk is exactly the thing that table is for."""
today = today or timezone.localdate()
order = {RISK_HIGH: 0, RISK_WATCH: 1, RISK_OK: 2}
clubs = list(clubs_with_health(queryset, today=today))
for club in clubs:
club.risk, club.risk_reason = club_risk(club, today)
clubs.sort(key=lambda club: order[club.risk])
return clubs
def platform_totals():
return {
"clubs": Club.objects.active().count(),
"archived_clubs": Club.objects.archived().count(),
"members": Member.objects.count(),
"members": Member.objects.filter(member_of__kind=ClubMembership.Kind.MEMBER).distinct().count(),
"admins": ClubRole.objects.filter(role=ClubRole.Roles.ADMIN).count(),
}
@@ -139,10 +189,24 @@ def onboarding_funnel():
total = len(clubs)
return [
{"label": "Clubs", "count": total, "icon": "building-2"},
{"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
{"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "shield"},
{"label": "With events", "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
{"label": _("Clubs"), "count": total, "icon": "building-2"},
{"label": _("With members"), "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
{"label": _("With a team"), "count": sum(1 for club in clubs if club.team_count), "icon": "trophy"},
{"label": _("With events"), "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
]
def flags_for_club(club):
"""Every flag, annotated with whether it is on for this club and why."""
enabled_ids = set(club.flags.values_list("pk", flat=True))
return [
{
"flag": flag,
"enabled": flag.pk in enabled_ids,
# `everyone` overrides club targeting, so the per-club toggle is moot.
"overridden": flag.everyone is not None,
}
for flag in get_waffle_flag_model().objects.order_by("name")
]
@@ -172,6 +236,10 @@ def platform_attention():
"dues_in_grace": dues_in_grace().count(),
"dues_overdue": dues_overdue().count(),
"clubs_unbilled": Club.objects.active().filter(subscription__isnull=True).count(),
# Normally ~0: the renewal job keeps it there. A number that sits here means cron is
# dead, and a club is about to use the platform for free — silently, because nothing is
# owed, so no other number on this page would go red.
"renewals_pending": len(subscriptions_due_for_renewal()),
}
@@ -185,7 +253,7 @@ def _dues_owed():
def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY):
"""A dense month-by-month series — zero-filled, because a chart that silently skips
empty months draws a smooth line over a month where nothing happened."""
start = (timezone.now() - timedelta(days=30 * months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
start = (timezone.now() - relativedelta(months=months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
rows = queryset.filter(**{f"{field}__gte": start}).annotate(month=TruncMonth(field)).values("month").annotate(value=value).order_by("month")
found = {row["month"].strftime("%Y-%m"): row["value"] or 0 for row in rows if row["month"]}
@@ -235,12 +303,12 @@ def renewal_rate(club, season):
if previous is None:
return None
was_active = ClubMembership.objects.filter(club=club, season=previous, status=ClubMembership.StatusChoices.ACTIVE)
was_active = ClubMembership.objects.filter(club=club, season=previous, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE)
total = was_active.count()
if not total:
return None
returned = ClubMembership.objects.filter(club=club, season=season, member__in=was_active.values("member")).count()
returned = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER, member__in=was_active.values("member")).count()
return round(100 * returned / total)
@@ -255,7 +323,7 @@ def new_members(club, season):
if season is None:
return Member.objects.none()
seen_before = ClubMembership.objects.filter(club=club, season__start_date__lt=season.start_date).values("member")
seen_before = ClubMembership.objects.filter(club=club, season__start_date__lt=season.start_date, kind=ClubMembership.Kind.MEMBER).values("member")
return Member.objects.filter(member_of__club=club, member_of__season=season).exclude(pk__in=seen_before).distinct()
@@ -270,9 +338,9 @@ def signup_split(club=None, months=MONTHS_OF_HISTORY):
Each member's earliest season is resolved once up front rather than per row: the same
question asked inside a loop is one query per membership.
"""
start = (timezone.now() - timedelta(days=30 * months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
start = (timezone.now() - relativedelta(months=months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
memberships = ClubMembership.objects.all() if club is None else ClubMembership.objects.filter(club=club)
memberships = ClubMembership.objects.filter(kind=ClubMembership.Kind.MEMBER) if club is None else ClubMembership.objects.filter(club=club, kind=ClubMembership.Kind.MEMBER)
first_season = {}
for club_id, member_id, season_start in memberships.values_list("club_id", "member_id", "season__start_date"):
@@ -313,7 +381,7 @@ def unrostered_members(club, season):
rostered = TeamMembership.objects.filter(team__club=club, season=season).values("member")
return Member.objects.filter(member_of__club=club, member_of__season=season, member_of__status=ClubMembership.StatusChoices.ACTIVE).exclude(pk__in=rostered).distinct()
return Member.objects.filter(member_of__club=club, member_of__season=season, member_of__kind=ClubMembership.Kind.MEMBER, member_of__status=ClubMembership.StatusChoices.ACTIVE).exclude(pk__in=rostered).distinct()
def fee_aging(club):
@@ -323,7 +391,7 @@ def fee_aging(club):
owed = Order.objects.filter(club=club, status__in=OWED_STATUSES)
buckets = []
for label, older_than, newer_than in (("0-30 days", 0, 30), ("30-60 days", 30, 60), ("60+ days", 60, None)):
for label, older_than, newer_than in ((_("0-30 days"), 0, 30), (_("30-60 days"), 30, 60), (_("60+ days"), 60, None)):
rows = owed.filter(created__lte=now - timedelta(days=older_than))
if newer_than is not None:
rows = rows.filter(created__gt=now - timedelta(days=newer_than))
@@ -360,7 +428,7 @@ def attendance_rates(club, season):
def club_attention(club):
"""A club's own numbers that are supposed to be zero."""
season = Season.covering(club, timezone.localdate())
memberships = ClubMembership.objects.filter(club=club)
memberships = ClubMembership.objects.filter(club=club, kind=ClubMembership.Kind.MEMBER)
return {
"season": season,
@@ -379,7 +447,7 @@ def club_attention(club):
def club_charts(club):
season = Season.covering(club, timezone.localdate())
memberships = ClubMembership.objects.filter(club=club, season=season) if season else ClubMembership.objects.none()
memberships = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER) if season else ClubMembership.objects.none()
return {
"signups": signup_split(club),
@@ -387,10 +455,10 @@ def club_charts(club):
"fees": [
{"label": label, "value": memberships.filter(fee_status=status).count()}
for status, label in (
(ClubMembership.FeeStatus.PAID, "Paid"),
(ClubMembership.FeeStatus.PARTIALLY_PAID, "Partial"),
(ClubMembership.FeeStatus.UNPAID, "Unpaid"),
(ClubMembership.FeeStatus.WAIVED, "Waived"),
(ClubMembership.FeeStatus.PAID, _("Paid")),
(ClubMembership.FeeStatus.PARTIALLY_PAID, _("Partial")),
(ClubMembership.FeeStatus.UNPAID, _("Unpaid")),
(ClubMembership.FeeStatus.WAIVED, _("Waived")),
)
],
}
@@ -401,46 +469,46 @@ def club_statistics(club):
season = Season.covering(club, timezone.localdate())
now = timezone.now()
memberships = ClubMembership.objects.filter(club=club)
memberships = ClubMembership.objects.filter(club=club, kind=ClubMembership.Kind.MEMBER)
events = Event.objects.filter(club=club)
orders = Order.objects.filter(club=club)
return [
{
"title": "Members",
"title": _("Members"),
"icon": "users",
"stats": [
("Members", memberships.values("member").distinct().count()),
("Active this season", memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0),
("Pending", memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()),
("Lapsed", memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()),
(_("Members"), memberships.values("member").distinct().count()),
(_("Active this season"), memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0),
(_("Pending"), memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()),
(_("Lapsed"), memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()),
],
},
{
"title": "Teams & staff",
"title": _("Teams & staff"),
"icon": "shield",
"stats": [
("Teams", Team.objects.filter(club=club).count()),
("Players this season", TeamMembership.objects.filter(team__club=club, season=season).count() if season else 0),
("Staff this season", StaffAssignment.objects.filter(team__club=club, season=season).count() if season else 0),
(_("Teams"), Team.objects.filter(club=club).count()),
(_("Players this season"), TeamMembership.objects.filter(team__club=club, season=season).count() if season else 0),
(_("Staff this season"), StaffAssignment.objects.filter(team__club=club, season=season).count() if season else 0),
],
},
{
"title": "Events",
"title": _("Events"),
"icon": "calendar-days",
"stats": [
("Upcoming", events.filter(start__gte=now).count()),
("This season", events.filter(season=season).count() if season else 0),
(_("Upcoming"), events.filter(start__gte=now).count()),
(_("This season"), events.filter(season=season).count() if season else 0),
],
},
{
"title": "Shop",
"title": _("Shop"),
"icon": "shopping-cart",
"stats": [
("Orders", orders.count()),
("Revenue", _money(orders.filter(status__in=PAID_STATUSES))),
("Outstanding", _money(orders.filter(status__in=OWED_STATUSES))),
("Open carts", Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()),
(_("Orders"), orders.count()),
(_("Revenue"), _money(orders.filter(status__in=PAID_STATUSES))),
(_("Outstanding"), _money(orders.filter(status__in=OWED_STATUSES))),
(_("Open carts"), Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()),
],
},
]

View File

@@ -0,0 +1,112 @@
{% load static lucide ui %}
{% comment %}
Standalone shell for every sitewide allauth screen -- login, password change/reset,
MFA, passkeys, recovery codes -- plus 403.html/maintenance.html, rendered whenever
there is no club tenant (see club/context_processors.py: this is
PLATFORM_BASE_TEMPLATE). Same industrial design language as controlpanel/base.html --
dark ink chrome, Barlow/Barlow Condensed/IBM Plex Mono, assets/controlpanel.css --
but deliberately simpler: one centred card on a dark page, not a full command-bar
app shell, since these are public entrance screens for the whole platform (every
club admin and base-domain account), not the control panel itself.
Block names match what templates/_base.html used to provide (head_title, extra_head,
main, extra_body) rather than inventing new ones: templates/allauth/layouts/base.html
and templates/403.html/maintenance.html target those names directly, and both are
shared with the club-branded skin (_club_base.html, still on assets/app.css and real
daisyUI, untouched) -- give them a different block name here and they would have
nothing to override on this side of the fork.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>
{% block head_title %}{% endblock head_title %} &middot; RosterChief
</title>
<link rel="stylesheet" href="{% static 'css/controlpanel.css' %}">
{% block extra_head %}{% endblock extra_head %}
</head>
<body class="flex min-h-screen flex-col items-center gap-10 bg-ink px-4 py-14 font-sans text-slate">
{# Explicit bg-ink here too, not just on <body>: the white-on-dark brand mark must stay legible on its own. #}
<div class="flex w-full max-w-md items-center justify-between gap-4 bg-ink py-1">
<a class="flex min-w-0 shrink-0 items-center gap-2.5" href="/">
{# The real mark, not the .crest clip-path fallback (that's for clubs with no logo of their own) -- white-on-dark variant for this page. #}
<img class="h-8 w-8 shrink-0" src="{% static 'images/rosterchief-white.svg' %}" alt="" width="32" height="32">
<span class="font-display text-xl font-extrabold tracking-[.1em] text-white uppercase">RosterChief</span>
</a>
<a class="flex shrink-0 items-center gap-1.5 font-mono text-xs text-on-dark-dim hover:text-white" href="/">
{% lucide "arrow-left" size=14 %} Back to site
</a>
</div>
<main class="flex w-full max-w-md flex-1 flex-col justify-center gap-4">
{% if messages %}
<div class="flex flex-col gap-2">
{% for message in messages %}
{% with alert=message|as_alert %}
<div class="alert {{ alert.css }}" role="alert">
{% lucide alert.icon size=18 %}
<div>
<div class="font-display text-sm font-bold tracking-wide uppercase">{{ alert.title }}</div>
<div class="text-sm">{{ alert.body }}</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endif %}
{% block main %}{% endblock main %}
</main>
<p class="font-mono text-[11px] text-on-dark-faint">&copy; {% now "Y" %} RosterChief</p>
{% comment %}
A TOTP code is 6 characters, a recovery code 8, and allauth accepts either in
the same field (templates/allauth/elements/fields.html). The boxed .otp layout
only fits six, so past that this falls back to a plain .input-lg rather than
letting the text spill out of the boxes.
The real <input>'s own text is invisible (assets/controlpanel.css: `.otp input`
is `color: transparent`, only its caret shows) -- this writes each typed
character into its matching <span> directly instead, which is exact by
construction. A pure-CSS letter-spacing overlay (spacing the real glyphs to
match the box pitch) was tried first and drifted more with every character
typed, in a way font-metric tuning couldn't reliably fix.
{% endcomment %}
<script>
document.querySelectorAll("[data-otp]").forEach((otp) => {
const input = otp.querySelector("input");
const boxes = otp.querySelectorAll("span");
if (!input) return;
const fit = () => {
const boxed = input.value.length <= boxes.length;
otp.classList.toggle("otp", boxed);
otp.classList.toggle("otp-lg", boxed);
boxes.forEach((box, index) => {
box.classList.toggle("hidden", !boxed);
box.textContent = boxed ? input.value[index] || "" : "";
});
input.classList.toggle("input", !boxed);
input.classList.toggle("input-lg", !boxed);
};
input.addEventListener("input", fit);
fit();
});
</script>
{% comment %}
allauth puts page-level scripts and out-of-form markup here -- notably the
hidden `mfa_login` form the passkey button submits on the login page. Without
this block that form is never rendered and "Sign in with a passkey" is dead.
{% endcomment %}
{% block extra_body %}{% endblock extra_body %}
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More