Compare commits

..

308 Commits

Author SHA1 Message Date
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
c42963c447 Do not let DEBUG=True take down a container that has no dev deps
The image installs with --no-dev, so django_browser_reload is absent. Settings and
urls both assumed DEBUG implied it was installed, so DJANGO_DEBUG=True in a
deployed container did not merely turn on debugging: the app refused to start, with
a ModuleNotFoundError that says nothing about the actual mistake.

Both now guard on the module being importable. Reproduced the failure locally by
hiding the package with DEBUG on, and confirmed the urlconf loads afterwards.

DEPLOYMENT.md says the obvious thing out loud: a test server is still a deployment
-- real TLS, real domain, real passkeys -- so DEBUG stays off there. The crash is
fixed; the reason to keep it off was never the crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:14:53 +02:00
5d42691a10 Fix the image build: fetch the git dependency in its own stage
The build died at `uv sync`: django-lucide is our fork, declared as a git source
and pinned by the lock to a commit, so uv shells out to `git` to fetch it — and
python:3.14-slim has no git.

Installing git in the runtime image would have fixed it and left a build tool, plus
its dependency tree, in production for the sake of one package that is already
vendored into the venv by then. So the virtualenv is now built in a stage that has
git, and the finished .venv is copied into a runtime stage that does not. Same base
image, so the compiled wheels inside it stay ABI compatible.

Also drops the second `uv sync`, which installed the project itself: there is no
[build-system] and rosterchief is not a package — gunicorn imports it from the
working directory, exactly as it does locally.

Unverified end to end: still no container runtime on this machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:07:54 +02:00
a759f763ed Update to the CSS file 2026-07-14 17:54:18 +02:00
534d01d6fe Size the server, and cost an AWS three-node layout
For 1-5 clubs / 1000 members / 10 events per club per week: 2 vCPU, 4 GB, 40 GB.

The data does not size this box. Computed from the real schema, attendance
dominates (every event invites a squad, so one event is ~20 rows) and the whole
thing comes to ~40 MB/year -- 0.2 GB after five years. Invoices are rendered on
demand and never stored.

What sizes it is the processes, measured rather than guessed: gunicorn master plus
three workers is ~270 MB (~54 MB each), and the whole stack idles around 1.0-1.2 GB.
2 GB would run it; 4 GB is the recommendation because `docker compose build` is the
memory spike, not serving -- npm, uv and collectstatic together will OOM a 2 GB box
that is also running Postgres. Rendering an invoice adds ~50-100 MB to one worker
the first time, since WeasyPrint is imported lazily.

Also adds the AWS three-node layout for fun, with a cost table. Two things worth
knowing there: ACM issues the wildcard certificate free with Route 53 validation, so
the entire DNS-01 dance disappears; and a NAT Gateway would cost more than the
compute (~$32/month per AZ) if the tasks sit in private subnets.

The honest line at the end: ~$110-130/month on AWS against ~EUR 5 on a VPS, for a
database that is 200 MB after five years. The money buys resilience, not capacity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:25:40 +02:00
98e5f22873 Document running RosterChief beside another domain on one Caddy
The "behind an existing Caddy" section assumed the DNS plugin was being set up
from scratch. The realistic case is a box whose Caddy already does Cloudflare
DNS-01 for another domain, so it now covers that: set acme_dns once globally and
every site inherits it, or scope a token per zone with a snippet.

Leads with the failure that will actually happen -- a Cloudflare token is scoped to
named zones, so the existing one grants DNS:Edit on the domain it was made for and
nothing else, and the new site fails its challenge on a permissions error whose
text does not say so.

Also spells out that *.test.rosterchief.app does NOT match test.rosterchief.app: a
wildcard covers exactly one label, so leaving the bare host off the site line gives
the club subdomains a certificate and the control panel none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:22:37 +02:00
d30b163122 Add maintenance mode: lock the platform down from the control panel
Closes every club subdomain with a 503 in that club's own colours, stands the
scheduled jobs down, and keeps open exactly what is needed to end it again.

The exemptions ARE the feature:

- /accounts/ stays open on the base domain. Close it too and you cannot sign in to
  turn maintenance off -- a lock-down with no key, fixable only from a shell.
- /healthz answers on every host. Close it and the load balancer decides the node
  is dead, stops routing to it, and takes the control panel down with everything
  else.
- migrate and collectstatic are NOT blocked. Maintenance is usually declared in
  order to run them; a blanket guard on BaseCommand would mean turning the mode off
  to do the work you turned it on for. Only the domain jobs (archive_overdue_clubs,
  extend_event_series, import_members_csv) refuse, and they exit non-zero so cron
  mails you -- a scheduled job that silently skips itself is how a month of billing
  goes missing.

The state is cached with a 10-second TTL, not for ever. Write-through makes the
flip instant for the shared Redis of a real deployment, and the TTL is the belt to
that braces: on a per-process cache -- a dev box with no Redis, or a misconfigured
deploy -- a lock-down that reached only one gunicorn worker would be worse than
useless. Live-verified: a club subdomain, its login page and the base domain all
503 while the control panel and the sign-in screens stay up.

Also adds the two deployment pieces asked for: compose.behind-proxy.yaml for a
dev/test box that already runs Caddy on :80 (app on the loopback, host Caddy proxies
to it -- and the host's Caddy still needs the DNS plugin, because the wildcard is
still a wildcard), and deploy/backup.sh + restore-check.sh with a cron schedule. The
backup writes to a .part file and only lands it once gzip -t says it is readable: a
truncated dump that looks like a backup is the failure you find on the day you need
it. The weekly restore rehearsal is the only line in that cron that proves the rest
work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:11:15 +02:00
c0a44093d9 Add a health check and the deployment runbook
/healthz checks the database and does a cache ROUND TRIP, not a ping. Both matter:
a node that cannot reach Postgres serves nothing, and a cache that accepts writes
and returns nothing would have waffle read every feature flag as unset -- so
"healthy" has to mean more than "the process is listening", or the load balancer
will keep feeding traffic to a node that only looks alive.

No auth and no tenant on it: the proxy, and later a load balancer, must reach it on
any host.

DEPLOYMENT.md is the runbook, and leads with the five things that make this app not
a generic Django deploy: the wildcard cert forces DNS-01 (Let's Encrypt will not
issue a wildcard over HTTP-01); Redis is required on one server, not two, because
of the per-process flag cache; SECURE_PROXY_SSL_HEADER plus Caddy's
X-Forwarded-Proto or WebAuthn and the SSL redirect both break; uploads must reach
object storage BEFORE the second app server, not during; and invoices need native
pango.

Also documents why the archive job ships with --commit off, why migrations are run
explicitly rather than from the entrypoint, and how to test the restore before the
day you need it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:45:21 +02:00
35d1ec45a7 Containerise: Dockerfile, Compose stack and wildcard TLS
One server now, the same image and env vars for many later: point
DJANGO_DATABASE_URL / DJANGO_REDIS_URL at central services, set a bucket, drop the
db and redis services, run several web containers behind a load balancer. No code
changes.

The wildcard certificate is what shapes this. Subdomain tenancy needs
*.rosterchief.app, and Let's Encrypt will not issue a wildcard over HTTP-01 -- only
DNS-01 -- so Caddy is built with a DNS provider plugin and needs an API token. That
single constraint is why the proxy is Caddy rather than the usual nginx+certbot.

The image apt-installs libpango and friends, which is what WeasyPrint binds to. The
PDF invoices that cannot render on a Mac without Homebrew work in the container by
construction.

Migrations are NOT run by the entrypoint: with more than one web container they
would race, and a starting gunicorn worker is a bad place to discover a failed
migration. Deploy runs them once, explicitly.

Two things the local build check caught, either of which would have failed the
image build at collectstatic (manifest storage treats a missing referenced file as
fatal):

- chart.js ended with a sourceMappingURL pointing at a .map we never vendored.
  Stripped, with an npm script so re-vendoring cannot bring it back.
- The Tailwind INPUT file lived at static/src/app.css, inside the served static
  tree, so collectstatic collected it and then choked on its @import "tailwindcss".
  It belongs outside: it is a build input, not an asset. Now assets/app.css.

Verified locally under gunicorn + WhiteNoise + manifest storage: pages serve and
the CSS comes back hashed. The image itself is unverified -- there is no container
runtime on this machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:41:51 +02:00
e5a93194bf Make the app deployable: Postgres, shared cache, S3, HTTPS
Three things would have broken a deploy, all invisible until it happened:

- psycopg was missing. dj-database-url parses postgres:// happily, so the app
  would have started and died on its first query.
- No CACHES, so Django used LocMemCache -- private to one process. waffle caches
  each flag's targeting there, so under several gunicorn workers a toggle flipped
  in the control panel flushes ONE worker and the others keep serving the stale
  flag. That is a feature that "sometimes doesn't turn on", and it makes Redis a
  requirement of the first multi-worker deploy, not of the second server.
- Uploads (club logos) sat on local disk. Fine on one box; on two, a logo
  uploaded to node A 404s on node B. Storage now switches to S3 the moment a
  bucket is configured, so adding a server stays a config change.

HTTPS behind a proxy: SECURE_PROXY_SSL_HEADER is not optional once Caddy
terminates TLS -- without it Django thinks every request is plain HTTP,
request.is_secure() is false, WebAuthn disagrees with the browser about the
origin, and SECURE_SSL_REDIRECT turns into a loop. HSTS covers subdomains,
because every club is one.

The SSL/cookie flags default to off and are switched on by the production
environment on purpose: defaulting them to `not DEBUG` would redirect every test
request to https and break the suite wherever DEBUG is unset. `check --deploy` is
what catches a deploy that forgot them.

Static files are served by WhiteNoise from the app itself, so a second app server
needs no shared volume or CDN. Manifest storage is production-only: it demands a
collectstatic manifest that no test run has.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:37:36 +02:00
29d61eeea8 Manage billing from the control panel
A Billing tab (tiers, their dated prices, and everything we are owed), a billing
panel on each club (plan, periods, payment history, invoice), and the dues on the
dashboard and the club tables.

Every state change goes through the billing service, and a BillingError surfaces
as a message rather than a 500 -- so "that period is waived", "no price in force",
"already billed for that period" and a missing PDF library all explain themselves
instead of crashing.

The dashboard now separates the two pots of money that were previously one word.
"Revenue per month" was CLUB SHOP revenue -- members paying their clubs, which is
never ours -- sitting on our dashboard under a label that implied it was income.
It is now "Platform dues per month" (what clubs paid us) with the club-shop series
renamed club_revenue, and the club tables carry a Plan column and what each club
owes us, annotated in the same single query.

Rate changes are add-only in the UI as well as the model: the price form creates a
dated row and never edits the last one, and a test asserts that raising the rate
leaves an already-open period at the amount it was billed at.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:52:43 +02:00
60bfac9881 Add platform billing: tiers, dues, payments and invoices
RosterChief charging the clubs, which is a different domain from `shop` (a club
charging its members). Nothing here is club-scoped: these rows reference a Club,
they are not owned by one, and no club user ever sees them.

- Tier + TierPrice. Prices are dated, not keyed by year: a rate change is one row
  with a future active_from, and price_on(day) answers "what was in force then".
  A tier with no price yet returns None, which callers must treat as "cannot
  bill" -- never as free.
- Due: one rolling-year period per club, with a 45-day grace tail. The tier and
  the amount are SNAPSHOTS taken when the period opens. Raise the price and last
  year's period must still say what was actually charged; reading it back through
  the tier would silently rewrite financial history.
- DuePayment: partial payments accumulate. amount_paid is re-summed from the
  payments on every change, never incremented -- an increment drifts the moment a
  payment is deleted, and the drift still looks like money.
- Invoice: PDF via WeasyPrint, rendered on demand from the frozen snapshot. Only
  the number is stored, in one platform-wide series (unlike the shop's per-club
  order numbers), and re-issuing returns the existing one rather than burning a
  number -- a gap in an invoice series is a question you don't want to answer.
  WeasyPrint is imported lazily: it binds to native pango/cairo, and the app, the
  tests and every other page must still run on a machine without them.
- archive_overdue_clubs reports by default and archives only with --commit. That
  asymmetry is deliberate: this switches off paying customers, so a bad clock or a
  cron misconfiguration should cost an email, not a morning of angry clubs. A club
  with auto_archive off is spared entirely.

Renewal continues from the last period end, not from the payment date: a club that
pays two months late has still used those two months.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:45:15 +02:00
6899e203f6 Show club health on the clubs list too
The list page carried the same vanity counts the dashboard just lost. It now shows
active members, unpaid members, money owed, teams (flagging those nobody can pick a
squad for), upcoming events and admins, with the same No season / Dormant badges --
and the annotations survive search and the archived filter.

The table lives in one partial, included by both pages, so they cannot drift apart.
Archived clubs are badged "Archived" rather than "Dormant": their subdomain does not
resolve, so of course nothing is scheduled, and flagging that as a problem would be
noise on the one page where every row has it.

assertNumQueries(1) covers the list as well now -- health is annotated per club, so
the page must not fan out as clubs are added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:24:31 +02:00
3a6dc00e05 Guard against multi-line {# #} template comments
Django's {# #} is single-line only -- its lexer regex is not DOTALL -- so a
multi-line one is not a comment at all and renders to the page as text. It shipped
into the clubs list, where the archived row read:

    Probe Retired probe-retired {# An archived club's subdomain does not... #}

A test now walks every template and fails on a {# without a closing #} on the same
line, since this is an easy habit to fall back into and the failure is invisible
until someone looks at the rendered page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:24:19 +02:00
192fe5ad0e Split platform signups, and rework the club table into health
The platform signups chart gets the same stacked new/returning split as the club
one. "First season" is keyed on (club, member), never the member alone: the same
person can be new at one club while renewing at another, and collapsing that would
file their second club's very first signup as a renewal.

The dashboard's club table stops reporting vanity counts. A member total says
nothing you can act on; "no coach", "nothing scheduled", "€ owed" and "no admins"
each name something somebody has to go and fix. Columns are now active members,
unpaid members, money owed, teams (flagging those nobody can pick a squad for),
upcoming events, and admins -- with No season / Dormant badges on the club itself.

Every column is annotated in ONE query, each aggregate in its own subquery. That
is not stylistic: aggregates spanning different joins multiply each other's rows,
so a Sum of orders sitting next to a Count of memberships returns the club's debt
multiplied by its membership count. A test pins €100 against three memberships and
would catch it coming back as €300.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:17:26 +02:00
639807b2d2 Split club signups into new members and renewals
A "New members" card sits beside Renewal, and the signups chart becomes a stacked
bar: bar height stays "signups this month" while the split shows where they came
from.

New means "first-ever season at this club", not "signed up recently". A member who
lapsed for a year and came back is a renewal, and counting them as new would
flatter every recovery into growth. It is also per club, not per platform: someone
who plays for another club is still new here.

The split resolves each member's earliest season once up front rather than asking
per row, so the chart costs two queries instead of one per membership.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:12:01 +02:00
f2b78bb1dd Build the club metrics
The club page now leads with its own numbers that should be zero, then the health
signals underneath.

- Teams with no coach. Not a statistic but a defect in the club's setup: with
  nobody in a management position the access service grants no authority over that
  team, so nobody can pick the squad. A physio does not count -- the query keys on
  Position.management_position, and on this season only.
- Unrostered members: active, paid, and on no team.
- Unpaid money bucketed by age. "€250 overdue past 60 days" drives a phone call;
  "€250 outstanding" does not.
- Renewal rate -- last season's actives who signed up again. Exactly computable
  because memberships are season-scoped.
- Turnout, plus the share who never responded. Silence is not an absence, so it is
  excluded from turnout and reported separately: no-response is the leading
  indicator, since it measures whether members use the app at all.

Two of these return None rather than a number, deliberately: a club in its first
season has not failed to renew anyone, and a season with no past events has no
turnout. Rendering either as 0% would libel the club, so the page says why instead.

Money is pinned to two decimals -- SQLite's Sum() drops trailing zeros, so an
aggregate rendered "€250" next to a "€0.00" constant on the same card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:08:37 +02:00
21d3947d08 Label the revenue axis in euros
The y-axis ticks and the hover both carry the unit now — an axis in euros with a
bare number in the tooltip reads as two different quantities.

Two formatters, not one: the axis rounds to whole euros to keep the labels short,
but the tooltip keeps the cents. Rounding an amount someone is reading off a
chart to answer "how much did we take in March" is a lie, and €1.234,50 becoming
"€ 1.235" is exactly that.

Locale is nl-BE, so it renders "€ 1.234,50" rather than "€1,234.5".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:02:14 +02:00
016206a79e Build the platform metrics dashboard
The dashboard leads with the numbers that are supposed to be zero, because a
dashboard of healthy counts is one nobody opens:

- Clubs with no season covering today. Seasons scope memberships, rosters and
  events, so such a club cannot take a signup or schedule a match -- and it fails
  silently, nothing errors, it is just inert.
- Dormant clubs: nothing on the calendar for 30 days. Churn signal.
- Admins pending MFA. RequireMFAMiddleware redirects them to enrolment, so they
  are locked out of their own club until they act: a support queue, not a stat.
- Outstanding money across every club.

Then the shape of the business: an onboarding funnel (clubs → with members → with
a team → with events, which separates working clubs from shells), feature-flag
adoption per club, and two charts -- signups and revenue per month.

Charts use chart.js, self-hosted rather than pulled from a CDN, for the same
reason as the fonts: no third-party in the render path. Two things the browser
taught me: the canvas needs a height-bounded wrapper (with maintainAspectRatio
off it sizes to its parent, and a parent with no height grew it to 3489px), and
chart.js cannot read daisyUI's CSS variables, so the charts re-render on a
data-theme change or keep the light palette in dark mode.

The month series is zero-filled: a chart that skips empty months draws a smooth
line straight over a month in which nothing happened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:59:28 +02:00
9127be0c42 Give every model created/modified timestamps
TimeStampedModel goes on UUIDModel, so all 28 domain models get row birthdays in
one place. Without them the dashboard can only ever describe the present: "42
members" is knowable, "members joined this month" is not, and no metric can show
direction.

Order dropped its own created/modified -- redeclaring a field from an abstract
base is an error, and its column survives as a plain AlterField (verbose_name
only), so no order data moves.

Note for reading early charts: auto_now_add backfills existing rows with a single
migration timestamp, so everything that predates this commit shares one birthday
and will show up as a spike at that instant rather than as real history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:53:12 +02:00
1dd2e5099f Replace "usb" icons with "user-key" for WebAuthn buttons; refine button styles and layouts across templates 2026-07-14 00:31:22 +02:00
bf86654e72 Give every auth button an icon, and lay out the password and MFA screens
The button element now takes an `icon`, so a page gets one by passing
icon="name" rather than by hand-rolling its own button markup. Every button on
the account and MFA screens carries one; a test walks each page and asserts no
button is left bare.

Change password: labels dropped (allauth already sets a placeholder on each
field), the current password set apart from the new pair, help text kept on the
new password, and Forgot Password promoted from a bare link to an accent button.

MFA management: recovery-code actions are now ranked -- View is primary, Download
and Generate are outline. Generate silently invalidates the codes you already
hold, so it must not read as the obvious thing to click. Panel actions get
breathing room from the body text (card-actions mt-4).

Viewing recovery codes: Download and Generate sit side by side instead of
stacking.

TOTP activate: the code box loses its heading -- an otp field never takes a
visible label, the boxes say what they are -- and the authenticator secret gets
margin around it, since it is copied out by hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:53:35 +02:00
0eb6838cba Make every cancel/back button an outline button
The filled base button reads as heavy as the primary next to it, so cancel is now
btn-outline everywhere it appears: sign out, the 2FA challenge, and the four
control-panel forms (which also gain the back-arrow icon the auth pages already
had).

For the record, the buttons were never different heights -- measured in a real
browser, every .btn on every page is 40px, anchor and button alike. It was the
fill, not the box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:32:27 +02:00
c9935d0a04 Add Cancel to the sign-out page, and icons to both buttons
Sign Out keeps btn-primary and gains a log-out icon; Cancel sits beside it as a
plain button with a back arrow.

Cancel links to "/" rather than the Referer header. "/" already resolves per
tenant -- club home on a club subdomain, control panel on the base domain --
whereas Referer can be absent or point off-site, which is not something to render
as a link unchecked.

It is an anchor, not a submit, so it cannot post the form: a test asserts that
clicking it leaves the session signed in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:21:56 +02:00
7900233f6d Restore the buttons on the sign-out and deactivate pages
The form element drew its action bar only when `no_visible_fields` was unset. That
attribute means the form has no visible *fields* -- logout and TOTP deactivate are
a bare csrf token plus a button -- and says nothing about its actions. So the bar,
and the only button on the page, was hidden on exactly the pages that exist to
offer that button. Sign Out could not be clicked at all.

The bar is now drawn when the actions slot has content, which is the condition
that was meant all along. Tests cover both pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:20:13 +02:00
add1ee87ae Put the otp input after its boxes
A seventh box appeared when the field took focus.

daisyUI positions each otp box with nth-child, which counts *every* child of the
container, not just the spans. With the input as the first child, all six boxes
shifted one stride right and the container matched :has(>span:nth-child(7)), so
it grew to seven strides wide. The phantom box was the ::after active-box marker
-- transparent until :focus-within gives it an outline -- sitting in the empty
stride that the off-by-one had opened up.

The input goes last, which is also how daisyUI's own examples order it. A test
counts the boxes ahead of the input so this cannot come back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:16:35 +02:00
fa213d95ee Drop the placeholder from the otp field
allauth sets placeholder="Code" on the field. Grey text sitting inside the otp
boxes reads as an already-typed code, so it goes -- the sr-only label outside the
box still names the field.

Suppressed with placeholder=False rather than by deleting the key: as_widget()
merges the widget's own attrs back in at render time, so popping it from a copy
does nothing. Django's attribute template omits attrs whose value is False.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:11:06 +02:00
addfc61a2c Style every MFA screen through the element system
The MFA pages (manage, TOTP activate/deactivate, recovery codes, security keys,
reauthenticate) are built almost entirely from allauth's `element` primitives, so
they are styled by overriding the elements rather than by rewriting eight page
templates. New allauth pages then inherit the look for free.

- field + img elements were missing entirely, so allauth fell back to bare HTML:
  the TOTP secret and recovery-code list rendered as unstyled inputs. The QR now
  sits on a white plate -- it is dark modules on a transparent ground, so on the
  dark theme it was dark-on-dark and phones could not scan it.
- button now honours the tags allauth sets. They were all flattened to
  btn-primary, which made "Deactivate" look exactly as safe as "View".
- the `code` field renders as a daisyUI otp wherever it appears, so the
  reauthenticate and activate pages get the same input as the login challenge.
  The boxes step aside past six characters: allauth accepts a TOTP code (6) or a
  recovery code (8) in that one field.

Fixes a crash: the security-key list does {% load humanize %}, which raised
TemplateSyntaxError because django.contrib.humanize was not installed. That page
500'd on every request; it is now installed and covered by a test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:07:44 +02:00
2b7b2b64db Fix empty allauth forms, and lay out the 2FA page
The 2FA code input was not invisible -- it was absent, along with the fields of
every other allauth form except login.

Cause: the `fields` element passed `attrs.exclude` straight into a filter. On a
page that never sets it, resolving a filter *argument* raises
VariableDoesNotExist; Django rescues that for the main variable of an expression
but not for a filter argument, and {% if %} then swallows it and reads the
condition as false. So every field was skipped. Login was the one page that
passes `exclude`, which is exactly why it kept working and hid the damage.
`exclude` is now pinned to a real variable first, with tests that render the
login, signup and password-reset forms and assert their inputs exist.

Two dangling buttons fixed while in here: `elements/form.html` dropped the `id`
attribute, so the out-of-band forms allauth generates (webauthn_form,
logout-from-stage) had no id for a button's `form` attribute to point at. "Use a
security key" submitted nothing.

Layout: the code is a daisyUI otp field, Cancel sits beside Sign In as a plain
button, both gain icons, and "Use a security key" becomes an accent button.

The otp boxes yield once more than six characters are typed. allauth accepts a
TOTP code (6) *or* a recovery code (8) in this one field, so hard-boxing it to
six would have locked out every recovery code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:59:00 +02:00
b3f153a2dc Lay out the login card and wire up the passkey button
Sign In and "Sign in with a passkey" (btn-accent) now sit side by side with
"Remember Me" on the same row, and the email/password block is given room above
and below.

The passkey button was dead. It submits a *different* form -- the hidden
`mfa_login` that allauth renders from its `extra_body` block -- and our layout
base never defined that block, so neither the form nor the webauthn script was
ever emitted and clicking the button did nothing. _base.html now has the block,
and there is a test asserting the form and script are on the page.

The `fields` element grows an `exclude`, so a page can lay a field out itself
(here: "remember", moved onto the button row). It splits on commas rather than
testing for a substring -- "password" is a substring of "password2", and a page
excluding one would otherwise silently drop the other.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:47:26 +02:00
7b18b39f49 Put icons in the login fields and drop their labels
The entrance forms lose their visible field labels and gain an icon inside each
field (mail, lock), and allauth's "Forgot your password?" link -- which is the
password field's help_text -- is spaced away from the input instead of sitting
flush against it.

Three things this depends on:

- allauth already passes `unlabeled=True` on the entrance forms and already sets
  a placeholder on every field there, so the visible label was redundant. The
  label is still emitted sr-only: a placeholder is not a label, and it vanishes
  as soon as you type.
- daisyUI's icon-in-field layout puts the `input` class on the *wrapping label*,
  so the input itself must carry only `grow` -- hence the optional css override on
  the daisy filter. `input` on both draws a box inside a box, and the error state
  belongs on the wrapper for the same reason.
- The help text now carries id="<auto_id>_helptext". Django points the input's
  aria-describedby at exactly that id, so without it the reference dangled and a
  screen reader never announced the password-reset link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:43:44 +02:00
e44933330d Give messages an icon, a bold title and soft styling
Each level now renders as a daisyUI soft alert: an icon, a bold heading and the
message text.

Django messages carry a level and a string -- there is no title field -- so the
heading comes from the level ("Done", "Careful", "Something went wrong"), and a
call site that wants a specific one passes it as extra_tags:

    messages.success(request, f"{club} is live.", extra_tags="Club created")

The lookup is keyed on level_tag, not tags. `tags` is extra_tags and level_tag
joined, so the old `message.tags == "error"` test would have stopped matching the
moment any message carried a custom title, and every alert would have quietly
rendered as blue info.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:35:06 +02:00
1a2bf257da Brand the auth screens per tenant
A club member signing in at ajax-united.rosterchief.app now sees their club's
logo, name and colours; the base domain keeps the RosterChief skin for the
control panel and Django admin.

The mechanism is `{% extends base_template %}` -- Django lets the parent be a
context variable, so the `branding` context processor picks the skin from
request.club and *every* auth screen allauth ships (login, password reset, MFA,
passkeys, and whatever it adds next) follows the tenant without a single one of
them knowing that clubs exist.

Templates split three ways: _base.html is the skeleton with no branding, and
_platform_base.html / _club_base.html dress it. The control panel extends the
platform base *explicitly* rather than through the variable, so a bug in
branding resolution can never dress the panel up as a club.

Club gains an optional logo and primary_color. Notes on both:

- No logo falls back to the club's initials, never the RosterChief mark, which
  would pass our branding off as theirs.
- Club colours land in an inline :root. daisyUI declares its theme variables
  inside `@layer base`, and unlayered styles beat every layered rule regardless
  of specificity, so this needs no !important. --color-primary-content is derived
  from WCAG relative luminance, so a club that picks pale yellow gets black text
  instead of invisible white.
- primary_color is a text input, not <input type="color">: a colour picker cannot
  express "no colour", so every club that never touched it would submit #000000
  and silently get a black theme.

"/" now resolves per tenant (club home, or hand off to the control panel), which
is why LOGIN_REDIRECT_URL can stay "/" and allauth needs no redirect adapter.

Also folds in the theme toggle gaining a third "auto" state and the logo
switching from `content:` to background-image (content-replacement on a real
element is not supported in Firefox), both of which lived in the base template
this commit replaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:30:38 +02:00
fc51e1903c Self-host Ubuntu, JetBrains Mono, Roboto and Tourney
Ubuntu becomes the default sans and JetBrains Mono the default mono (via
--font-sans / --font-mono, which Tailwind wires to the body and <code>
defaults). Roboto and Tourney are opt-in utilities: `font-roboto`, and
`font-tourney` for display numerals like jersey numbers.

Self-hosted rather than linked from Google's CDN: a CDN <link> sends every
visitor's IP to a third party on each page load, which is an avoidable GDPR
liability for an EU club platform, and it puts someone else's uptime in our
render path. Files come from the @fontsource packages.

Two subsets each, with unicode-range: plain `latin` cannot render the Polish,
Czech or Turkish letters that turn up in member names, and the range means
latin-ext is only fetched by pages that actually contain those glyphs.

Tourney is variable on two axes, so font-stretch is declared as a range next to
the weight range -- declare only the weight and the browser clamps the width
axis to its default, making `font-stretch: 125%` (a wide shirt number) silently
do nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:30:02 +02:00
334c706aec Reload the browser on template and static changes in dev
Adds django-browser-reload: runserver already restarts on Python changes, but
the browser had to be refreshed by hand for every template or CSS edit. It also
watches static/, so a Tailwind rebuild now refreshes the page on its own.

Mounted only under DEBUG -- it injects a script into every HTML response and
serves an open event stream, neither of which belongs in production; a test
holds that line. Its endpoint is exempt from RequireMFAMiddleware, otherwise a
not-yet-enrolled staff user has the stream redirected away and live reload dies
on the MFA enrolment page, which is exactly a page we are restyling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:32:19 +02:00
268cbe1e06 Manage platform admins and feature flags from the control panel
Features tab: create/edit flags, flip global switches, and toggle a flag per
club from the club detail page. Where `everyone` is set the per-club toggle is
replaced by a badge, because a toggle there would have no effect and so would
lie about what is on.

Admins tab: grant, promote, demote and revoke platform access. Gated on
is_superuser, not is_staff -- the panel itself is staff-accessible, so letting
staff grant is_superuser would collapse the two levels into one and stop
is_superuser being a boundary we can later hang anything on.

Two guardrails, enforced in the service so they hold regardless of caller:
you cannot strip your own access (you would lose the panel mid-click), and the
last superuser can never be demoted (the platform would be locked out of
itself). Granted users get an unusable password and must enrol 2FA before they
can sign in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:23 +02:00
fed24bfee3 Add club-scoped feature flags on django-waffle
Adds a `features` app with a swappable waffle Flag (WAFFLE_FLAG_MODEL) that
gains a m2m to Club, so a feature can be rolled out club by club.

Two things worth calling out:

- `everyone` keeps waffle's contract of overriding *all* other targeting, so
  club targeting is only consulted when `everyone is None`. This keeps
  `everyone = False` usable as a hard kill-switch.
- m2m edits don't call save(), so waffle's per-flag cache would go stale when
  clubs are added or removed. A m2m_changed receiver flushes it from both
  directions, and get_flush_keys() drops the club-set key alongside waffle's own.

Note for future flag tests: waffle's cache is not rolled back with the test
transaction, so tests touching flags must clear it (see features/tests.py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:22 +02:00
eace903f05 chore: rebrand ClubManager -> RosterChief, add lucide icons
The clubmanager.app domain was taken, so the platform is now RosterChief
(rosterchief.app). Renames the Django project package clubmanager/ ->
rosterchief/ (git tracks it as a move, so history follows), every
`from rosterchief.base import ...`, the settings/wsgi/asgi module paths,
env vars (ROSTERCHIEF_BASE_DOMAIN / ROSTERCHIEF_RP_NAME), the MFA adapter
(RosterChiefMFAAdapter), brand text, and the docs.

Two things were deliberately NOT swept:
- club.models.ClubManager stays: it is the Django manager *for Club*, not the
  brand. A blind rename would have silently broken it.
- Migrations are untouched (history is not rewritten). The only reference was a
  cosmetic help_text, so a normal AlterField migration carries the new domain.

Note the WebAuthn RP ID is the base domain, so moving to rosterchief.app
cryptographically invalidates any passkey enrolled under the old one; they
cannot be migrated and must be re-enrolled. Nothing is in production, so the
real cost is zero.

Add django-lucide (from bsiebens/lucide) for icons: the theme toggle now swaps
sun/moon against the effective theme, and the control panel gets icons on its
tabs, actions and stat groups. Its classifiers stop at Django 5.0, but that is
stale metadata — verified rendering on Django 6 / Python 3.14.

Also add formbuilder, shop and controlpanel to ruff's known-first-party list,
which had drifted behind the apps that landed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:42:20 +02:00
6f66df3ba4 feat(ui): platform control panel and styled auth screens
Add the `controlpanel` app: a platform-wide (not club-scoped) admin panel for
creating clubs, archiving/restoring them, managing club admins, and per-club
statistics (members, teams & staff, events, shop). Statistics are annotated in
one query so the club list cannot fan out into N+1, and are returned as stat
*groups* so growing the domain means adding one entry.

Two access rules, both enforced by PlatformStaffRequiredMixin:
- staff only (is_staff/is_superuser); anonymous are sent to login, signed-in
  non-staff get a 403. Staff already need a second factor, so the panel is
  2FA-protected for free.
- base domain only: the panel manages *all* clubs, so it 404s if the tenant
  middleware resolved a club from the subdomain.

Granting admin to an unknown email creates the account (unusable password —
they set one via password reset) and the Member behind it, since a ClubRole
hangs off a Member. A member who already holds a role is promoted in place,
because there is only one role per member per club.

UI is Tailwind + daisyUI. allauth ships an element system, so overriding
allauth/layouts/base.html plus ~13 element partials restyles *every* auth and
2FA screen at once — login, signup, password reset, the 2FA challenge, TOTP
enrolment, passkeys and recovery codes — rather than templating 20+ pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:29:24 +02:00
848a8578de build(ui): add Tailwind v4 + daisyUI 5 pipeline
daisyUI is an npm plugin, so the standalone Tailwind CLI can't load it; this
adds a real build. Themes are configured as `light --default, dark
--prefersdark`, so dark follows the OS with no JavaScript, and an explicit
data-theme (set by the toggle) overrides it.

The built CSS is committed, so `uv run manage.py runserver` remains all a
Python dev needs; `npm run watch` is only for those touching styles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:29:10 +02:00
ebb8bc3db1 feat(club): archive clubs instead of deleting them
Add Club.archived_at with active()/archived() managers, archive() and
restore(). An archived club stops resolving in ClubTenantMiddleware, so its
subdomain behaves as unknown — archiving is a real deactivation, not a
cosmetic flag — while every row it owns is retained.

There is no hard-delete path, deliberately. A club with any data cannot be
deleted anyway (ClubMembership PROTECTs its Season, and the shop chain
PROTECTs more), and invoices generally must be kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:29:10 +02:00
10736fd5ee feat(auth): two-factor authentication (TOTP, passkeys, recovery codes)
Adopt django-allauth with allauth.mfa, giving TOTP, WebAuthn passkeys and
recovery codes — and the signup/password-reset flows we'll need next. There was
no login UI at all before this (only /admin/), so this brings the auth stack.

The critical piece is authentication/adapters.py. A passkey is bound to a
WebAuthn Relying Party ID (a domain), and allauth derives that from the request
host — which under our subdomain tenancy would bind a passkey to a *single* club
(ajax-united.clubmanager.app) and silently fail at every other one. The adapter
pins the RP ID to CLUBMANAGER_BASE_DOMAIN so one passkey works across all clubs.
Note this cuts both ways: changing that base domain invalidates every existing
passkey.

RequireMFAMiddleware makes a second factor mandatory for anyone who can change
other people's data — Django staff/superusers and holders of an elevated
ClubRole (ADMIN/EDITOR), via the access service — while leaving it optional for
regular members. /admin/login/ is routed through allauth, since Django's own
admin login knows nothing about second factors.

allauth is installed WITHOUT django.contrib.sites (optional since allauth 65),
so ARCHITECTURE.md's rejection of the Sites framework stands and no Club.site
bridge is needed. Sessions are shared across club subdomains, matching the
one-passkey-everywhere model; tenancy still scopes what you can see.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:43:55 +02:00
819700ad0c feat(club): ClubRole, RBAC access service and role sync
Add ClubRole (ADMIN / MEMBER / EDITOR, one per member per club) and complete
club/services/access.py — the single module all authorisation routes through:

- teams_managed_by / can_edit_event  -> authority: a *management* StaffAssignment
  in the *current season*; ADMIN overrides club-wide. A StaffAssignment is
  per-season, so a former coach's authority expires with it.
- teams_staffed_by -> visibility: *any* staff position, so support staff (physio,
  kit manager) can see the roster they work with without gaining authority.
- members_visible_to -> ADMIN sees everyone linked to the club; otherwise self +
  children (family graph) + the current-season players and staff of the teams
  they're staffed on.
- can_edit_event -> ADMIN/EDITOR, the event's owner, or a manager of one of its
  teams for that event's season.
- can_manage_shop -> ADMIN.

Fix roles_in_club, which called .unique() — not a QuerySet method, so it would
have raised AttributeError on first use.

Keep ClubRole in sync with membership status: an active ClubMembership grants
the MEMBER role and losing it withdraws that role — but an elevated role
(ADMIN/EDITOR) is never downgraded or removed, so a lapsed membership or a
season rollover can never lock an admin out.

Validate ClubMembership.season against the membership's club.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:43:15 +02:00
78fdcdf138 feat(shop): cart, order, invoice and discounts
Build out the shop domain: Cart/CartItem (one open cart per user per club),
Order/OrderLine, Discount/AppliedDiscount, Payment and Invoice.

Order and Invoice allocate a per-club, per-year sequential number
(ORD-<year>-<seq> / INV-<year>-<seq>) via shared helpers, retrying on collision
with the (club, number) unique constraint as the source of truth.

Fixes found while testing:
- Invoice had no number generator, so a second invoice in a club collided on
  the empty string and could never be created.
- AppliedDiscount printed a "%" suffix even for fixed-amount discounts, and had
  no (order, discount) uniqueness, so a discount could be applied twice.

Every model validates its club-scoped FKs (product/team/discount/order/season/
staff_role) against the owning club, and Member FKs against club membership.
Register all models in the admin, with FK dropdowns scoped to the owning club.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:42:29 +02:00
d43ca0cfa8 feat(formbuilder): answers must belong to the submission's form
An Answer's field could point at a field of a *different* form than its
submission — and since forms are club-scoped, across clubs too. Validate
field.form == submission.form in clean().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:42:17 +02:00
22d971d48c feat(events): event owner and cross-club validation
Add Event.created_by (the owner, used later by the access service to let an
event's creator edit it).

Validate that an event's season/location/opponent — and an EventSeries'
location/opponent — belong to the event's club. The teams M2M cannot be checked
in clean() (M2M rows are written after save), so an m2m_changed pre_add receiver
rejects teams from another club.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:42:17 +02:00
c0816a1add feat(teams): management positions and cross-club validation
Position gains management_position, distinguishing a coach/manager from other
staff (physio, kit manager). A CheckConstraint enforces that a management
position is always a staff position.

TeamMembership and StaffAssignment validate that their season and position
belong to the same club as the team.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:42:06 +02:00
c320931595 feat(core): add cross-club scope validator
Add clubmanager.base.validate_club_scope(instance, owning_club_id, ...): a
shared model-clean() helper that rejects FKs leaking across clubs. Club-scoped
FKs must share the owning club; Member FKs must have a ClubMembership in it.
Unset FKs are skipped.

Nothing enforced tenant consistency on the FKs between club-scoped rows, so an
order could reference another club's product, an event another club's season,
and so on. The following commits wire this into each app's clean().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:41:57 +02:00
54aace8abb feat: auto-populate slug fields on save
Add clubmanager.base.unique_slugify(instance, value, scope=...): slugify a
source value, truncate to the field's max_length, and append -2/-3/... to
stay unique within a scope. ClubScopedModel gains a slug_source hook that
fills a blank slug (unique per club) on save.

Wire it up so every SlugField auto-populates from its natural source when
left blank (explicit values are always kept):
- shop.Product.slug   <- name   (per club)
- formbuilder.Form.slug <- title (per club)
- formbuilder.Field.key <- label (per form)
Club.slug already auto-populated; refactor it onto the shared helper.

Also fix shop.Product.slug's multi-tenancy bug: it was globally unique
(unique=True); make it unique per club like the others. Migrations added.
Full suite at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:42:08 +02:00
57f20fe544 chore(shop): scaffold empty shop app
startapp scaffold registered in INSTALLED_APPS; no models yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:13:32 +02:00
5dd3715c1f feat(formbuilder): fix tenancy bugs, add admin + services
Fix multi-tenancy/integrity bugs in the models: Form.slug and Field.key
were globally unique (unique=True), so two clubs couldn't reuse a form
slug and two forms couldn't reuse a field key — make slug unique per club
(constraint already present) and key unique per form. Add a
(submission, field) uniqueness constraint on Answer.

Register all four models in the admin (Field inline on Form, Answer inline
on Submission) and add formbuilder to the admin registration smoke test.

Add a service layer:
- submit_form(form, member, data): enforces is_active / login_required /
  open window / max_submissions, validates required + choice fields, and
  writes a Submission with Answers atomically (FormSubmissionError carries
  per-field errors).
- build_form(form): a live django.forms.Form built from a Form's active
  fields, mapping each FieldType to the matching form field.
- form_report(form): a tabular overview of every submission's answers plus
  per-value tallies for choice-type fields.

Full suite at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:13:16 +02:00
2653dd6d08 feat(events): formal end date for recurring series
Add EventSeries.until: an explicit series end that caps occurrence
generation (whichever comes first — it, the generation horizon, or the
rule's own COUNT/UNTIL). Blank means open-ended. Surface it in the admin.
100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:22:48 +02:00
c535e5dc8c feat(events): series-level gathering and deadline offsets
EventSeries gains gathering_offset and deadline_offset (durations before
the start). Each generated occurrence derives its gathering/deadline from
them (and clears them when unset); propagate_series pushes offset changes
to non-detached future occurrences. Surface them in the admin. 100%
coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:04:14 +02:00
ca34d26a8e feat(events): recurring event series with occurrence sync
Add EventSeries (club-scoped): an RFC-5545 rrule + dtstart + duration and a
template (kind/title/location/opponent + audience M2M). Concrete Event rows
are materialised occurrences carrying a series FK plus detached/cancelled
flags; the series tracks excluded_dates (EXDATEs) and a generated_until
horizon watermark.

Recurrence service:
- occurrence_datetimes expands the rrule (via python-dateutil) up to a
  horizon, minus EXDATEs.
- generate_occurrences materialises missing rows, copies the template +
  audience (so attendance syncs through the existing signals), and is
  idempotent.
- cancel_occurrence adds an EXDATE and deletes (or soft-cancels) one
  occurrence so it isn't regenerated; detach_occurrence marks an occurrence
  as independently edited; propagate_series re-applies the template to
  non-detached future occurrences.
- extend_event_series management command rolls the horizon forward.

Register EventSeries in the admin and surface series/detached/cancelled on
the Event admin. Add python-dateutil. Full suite at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:51:34 +02:00
866716d196 feat(events): team/invited audience with attendance sync
Rework an event's audience: replace the single team FK with a teams M2M
plus invited_members and excluded_members, and add a season FK (derived
from the start date when blank) so team rosters resolve correctly. A data
migration copies existing team -> teams.

Add an attendance sync service: the effective audience is the union of the
teams' rosters for the event's season plus invited, minus excluded;
sync_event_attendances reconciles Attendance rows for future events only,
adding NO_RESPONSE rows for new members and hard-deleting rows for members
no longer invited. Signals drive it: editing an event or its audience
re-syncs that event, and adding/removing a team-roster member re-syncs
that team's future events.

Register all events models in the admin (with an attendance inline) and
add events to the admin registration smoke test. Add Season.covering()
and pillow (Opponent.logo ImageField). Full suite at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:44:48 +02:00
076a9cacbc feat(teams): add teams app with roster and staff assignments
Introduce the teams app: Team and Position (both club-scoped, with
per-club unique names), TeamMembership (season-scoped roster with jersey
number + captain flags, unique member and jersey per team/season), and
StaffAssignment (coaching/staff, filtered to staff positions). All
uniqueness expressed as UniqueConstraints.

Register every model in the admin (with roster + staff inlines on Team)
and cover the models with tests. Fix two model bugs surfaced by the
system check: StaffAssignment.position reused TeamMembership's reverse
accessor (Position.team_memberships) and duplicated its constraint name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 16:47:52 +02:00
dd2e4b2169 feat: season-scope club memberships and convert to UniqueConstraint
ClubMembership is now tied to a Season (plus status / fee_status / sign-up
dates) and unique per (club, member, season). The CSV importer attaches
each membership to the club's current season (Season.get_current), skipping
the row with a clear error when none exists; the now-unreachable
"clubs created" bookkeeping is removed.

Register SeasonAdmin and rebuild ClubMembershipAdmin for the new fields, and
add an admin smoke test that asserts every model in authentication/club/
members/teams is registered and its changelist + add pages load.

Convert every unique_together to a Meta UniqueConstraint (Season,
ClubMembership, FamilyMembership) per Django's guidance. Regenerate
migrations. club, members, and importer stay at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 16:47:39 +02:00
3914765f90 feat(tenancy): add Season current-season lookup and year name
Complete the Season model:

- name property renders the start/end years as a "YY-YY" label (e.g.
  "25-26") via strftime %y, and __str__ now returns it.
- get_current(date) returns the active club's season covering the given
  date (today by default), inclusive of both boundaries, scoped through
  the tenant queryset so it never crosses clubs.

Rename the tenant queryset's current() to current_club() for clarity and
update callers/tests. Cover the new behaviour; tenancy modules stay at
100%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:38:06 +02:00
cda4960ffc docs: revise shop order-level discount design
Replace the ad-hoc manual order discount with a per-club catalogue of
named OrderDiscountType presets snapshotted onto AppliedDiscount rows,
with stacking, retirement, and an optional value override. Regenerate
ARCHITECTURE.pdf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:32:23 +02:00
3575d8f7b1 chore: document dev environment variables
Add .env.example covering the required settings plus the multi-tenant
dev config (DJANGO_ALLOWED_HOSTS=.localhost and CLUBMANAGER_BASE_DOMAIN),
so *.localhost subdomains resolve to clubs without editing /etc/hosts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:32:23 +02:00
e85a5f6300 test(tenancy): cover club resolution, context, and scoping
Exercise subdomain resolution (base-domain and generic hosts, unknown
slug, www, port stripping, contextvar cleanup), slug derivation and
uniqueness, the require_current_club/current() context helpers, and
ClubScopedModel save/for_club/current via Season. Add a with_club test
helper. Tenancy modules reach 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:32:23 +02:00
72e7b5e070 feat(tenancy): resolve active club from request subdomain
Add row-based multi-tenancy plumbing keyed on Club as the tenant root:

- ClubTenantMiddleware maps the request's subdomain to a Club by slug,
  storing it on request.club and in a contextvar so service-layer code
  and management commands can read it via get_current_club(). Resolution
  honours CLUBMANAGER_BASE_DOMAIN (e.g. ajax-united.clubmanager.app),
  falling back to generic slug.example.com hosts, and ignores the bare
  base domain, www, and unknown slugs.
- Club gains a unique slug (auto-derived from name on save) plus a
  ClubManager.current() accessor for the active tenant.
- ClubScopedModel gets a TenantQuerySet (.for_club()/.current()) and
  auto-fills club from the active context on save.

Contextvar helpers live in club.tenancy; Club is imported lazily there
and in clubmanager.base to avoid an import cycle with club.models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:32:05 +02:00
919a68ed3a build: add coverage dev dependency; drop contextvars backport
Add coverage 7.15 to the dev group for test-coverage reporting. Remove
the erroneous contextvars>=2.4 runtime dependency: contextvars is part of
the standard library on Python 3.14, and the PyPI backport (with its
immutables dependency) would shadow it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:31:55 +02:00
638f85fa07 Remove redundant user re-link branch in CSV importer
update_or_create already persists member.user via defaults, so the
follow-up `if create_account and member.user_id is None` block was
unreachable dead code. Removing it brings importer coverage to 100%
with no behavior change (existing link-existing-user test still passes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:21:48 +02:00
4165729d61 Add tests for uncovered members code paths
Close the small coverage gaps left after the members app move:
- FamilyMembership.__str__ string representation
- FamilyAdmin.member_count display (with and without members)
- CSV import skips a row with an empty required field (was only
  exercising the invalid-date path)
- MemberImportResult.successful_rows property

Coverage 98% -> 99% (69 tests). The only remaining uncovered lines are
the redundant re-link branch in MemberCsvImporter.import_row (131-132),
which is unreachable because update_or_create already sets member.user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:20:27 +02:00
f3b1ae7e82 Complete people-domain move into members app; keep names on Member
Finish relocating Member/Family/FamilyMembership from authentication into
a dedicated members app, and revert the half-applied move of member names
onto the global User.

- members: add first_name/last_name back to Member (the whole codebase —
  tests, CSV importer, club app, admin — assumes them, and login-less
  children in families need a name); restore local ordering/index and the
  member__last_name lookups in Family.__str__ and FamilyMembership.
- authentication: drop first_name/last_name from User; get_full_name/
  get_short_name delegate to the linked member, else fall back to email.
- migrations: create members.0001_initial, repoint club.ClubMembership FK
  (club.0005), delete the models from authentication (0004, rewritten to
  plain DeleteModel ops in child-first order to avoid a SQLite table-remake
  crash).
- fix stale imports across apps (authentication/club tests, CSV importer)
  and missing imports/URLs in members tests; add missing _ import in
  members/admin.py; export MemberCsvImporter from members.services.

Full suite green (64 tests), ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:16:41 +02:00
19b92d61f7 Add ARCHITECTURE.md to define domain and model architecture
- Introduce a comprehensive architecture document outlining the app's domain model, design principles, and shared conventions.
- Establish the foundation for multi-tenancy and tenant scoping via `Club` as the tenant root and `ClubScopedModel`.
- Detail the decomposition of the app into planned sub-apps and their responsibilities.
- Define model structure, relationships, and access control mechanisms (RBAC) through `ClubRole` and service-layer authorization.
- Provide a roadmap for tenant-aware features: seasons, rosters, events, shop, and dynamic forms.
2026-07-11 23:23:36 +02:00
89c12c12b1 Add ClubMembership enhancements, CSV import command, and related tests
- Extend the ClubMembership model with `club`, `member`, and optional `license` fields, along with relevant constraints and ordering.
- Implement verbose names for Club and ClubMembership models and update admin configurations for better display and filtering.
- Add a `import_members_csv` management command for batch importing members, clubs, and memberships from a CSV file.
- Include extensive tests for the `import_members_csv` command, ClubMembership model, and Club model.
- Refactor related migrations, services, and test structure.
2026-07-05 23:51:35 +02:00
44fe658efa Localize authentication models with verbose names and update corresponding migration 2026-07-05 15:50:54 +02:00
b30522d3d0 Extend admin, migrations, and tests for authentication and club apps
- Implement admin configurations for User, Member, Family, and FamilyMembership, with specialized inlines and filtered displays.
- Introduce `UserCreationForm` and `UserChangeForm` for streamlined user management.
- Enhance Family model with improved string representation and made name optional.
- Add `Member.contact_email` property for prioritized email retrieval.
- Include tests for the updated Family string logic, contact email functionality, and admin integration.
- Add initial migration for club models (Club, ClubMembership) and updated migration for Family in the authentication app.
- Configure IntelliJ IDEA for local SQLite database access.
2026-07-02 16:37:56 +02:00
8f71bb74c0 Refactor accounts app into authentication and club apps
- Split the accounts app into new authentication and club apps for better separation of concerns.
- Migrate the custom User, Member, and Family models to the authentication app.
- Introduce Club and ClubMembership models in the club app.
- Refactor Family model to use UUID as the primary key and consolidate family-role relationships into a new FamilyMembership model.
- Update tests, managers, and migrations to align with the new structure.
2026-07-02 09:40:06 +02:00
aa2c6329b9 Simplify family model and add Member.contact_email
- Replace the Guardianship through-model + self-referential M2M with a simple
  is_guardian flag on Member. Guardians in a family look after the family's
  dependents; guardians/dependents are now derived properties.
- Add Member.contact_email: own contact email, falling back to the linked
  user's login email, so a linked member need not store their email twice.
- Update admin (is_guardian in list/filter/inline; drop guardianship inline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:18:35 +02:00
2ead824c5d Add accounts app: custom User, Member, families & guardianship
Introduce the foundational accounts app:
- Custom email-as-username User (AbstractBaseUser + PermissionsMixin) set
  as AUTH_USER_MODEL, decoupled from membership so children can be members
  without a login.
- Member model holding personal/roster data (names, contact email, phone +
  emergency phone via django-phonenumber-field, license number, DOB) with an
  optional link to a User.
- Family household grouping and directional Guardianship (guardian -> child)
  with uniqueness and no-self-guardian constraints.
- Custom UserAdmin plus Member/Family admin with inlines and autocomplete.
- Settings: register apps, AUTH_USER_MODEL, phonenumber defaults (BE/E164).
- Add CLAUDE.md and a tracked static/ directory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:08:51 +02:00
537c023258 Update project dependencies, settings, and environment configuration 2026-06-17 22:37:32 +02:00
597 changed files with 86708 additions and 190 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.venv
node_modules
staticfiles
media
db.sqlite3
.env
*.pyc
__pycache__
.coverage
.idea
.ruff_cache
ARCHITECTURE.pdf

10
.env.compose.example Normal file
View File

@@ -0,0 +1,10 @@
# Copy to .env — read by docker compose itself (not by Django).
ROSTERCHIEF_BASE_DOMAIN=rosterchief.app
ACME_EMAIL=you@example.com
# DNS-01 is the only way to get the *.rosterchief.app wildcard. Token needs DNS:Edit on the zone.
CLOUDFLARE_API_TOKEN=
POSTGRES_DB=rosterchief
POSTGRES_USER=rosterchief
POSTGRES_PASSWORD=

37
.env.example Normal file
View File

@@ -0,0 +1,37 @@
# Copy to .env and fill in. Values below are development-friendly defaults.
# Required. Generate one, e.g. `python -c "import secrets; print(secrets.token_urlsafe(50))"`.
DJANGO_SECRET_KEY=change-me
# Development toggles.
DJANGO_DEBUG=True
# Hosts Django will serve. `.localhost` matches localhost and any *.localhost
# subdomain, which the tenant middleware needs for per-club subdomains.
DJANGO_ALLOWED_HOSTS=.localhost,127.0.0.1,[::1]
# Multi-tenancy: subdomains of this base domain resolve to a club by slug,
# e.g. http://ajax-united.localhost:8000/ -> club with slug "ajax-united".
# In production set this to your real base domain (e.g. rosterchief.app).
ROSTERCHIEF_BASE_DOMAIN=localhost
# Two-factor auth. ROSTERCHIEF_BASE_DOMAIN doubles as the WebAuthn Relying Party
# ID, so ONE passkey works across every club subdomain. Change it and existing
# passkeys stop validating -- they are cryptographically bound to that domain.
# ROSTERCHIEF_RP_NAME is what the browser shows during a passkey prompt.
# ROSTERCHIEF_RP_NAME=RosterChief
# Sessions are shared across club subdomains (log in once, all clubs). Derived
# from ROSTERCHIEF_BASE_DOMAIN in production; left host-only on localhost
# because browsers reject a Domain attribute there. Override if needed.
# DJANGO_SESSION_COOKIE_DOMAIN=.rosterchief.app
# DJANGO_CSRF_COOKIE_DOMAIN=.rosterchief.app
# Optional. Defaults to sqlite:///db.sqlite3 for dev; point at Postgres in prod.
# DJANGO_DATABASE_URL=postgres://user:pass@localhost:5432/rosterchief
# Optional. CSRF trusted origins (needed for subdomains in prod), comma-separated.
# DJANGO_CSRF_TRUSTED_ORIGINS=https://*.rosterchief.app
# Optional.
# DJANGO_TIME_ZONE=Europe/Brussels

61
.env.production.example Normal file
View File

@@ -0,0 +1,61 @@
# Copy to .env.production and fill in. Everything here is read by python-decouple.
# --- Django ---
DJANGO_SECRET_KEY= # python -c "import secrets; print(secrets.token_urlsafe(64))"
DJANGO_DEBUG=False
# The leading dot matches every club subdomain.
DJANGO_ALLOWED_HOSTS=.rosterchief.app
DJANGO_CSRF_TRUSTED_ORIGINS=https://rosterchief.app,https://*.rosterchief.app
DJANGO_TIME_ZONE=Europe/Brussels
# --- Tenancy ---
# Drives subdomain resolution, the shared session cookie, and the WebAuthn RP ID (one passkey
# across every club).
ROSTERCHIEF_BASE_DOMAIN=rosterchief.app
ROSTERCHIEF_RP_NAME=RosterChief
# --- Services ---
DJANGO_DATABASE_URL=postgres://rosterchief:CHANGEME@db:5432/rosterchief
DJANGO_REDIS_URL=redis://redis:6379/0
# --- HTTPS (off by default in code; the deploy is what turns them on) ---
DJANGO_SECURE_SSL_REDIRECT=True
DJANGO_SESSION_COOKIE_SECURE=True
DJANGO_CSRF_COOKIE_SECURE=True
DJANGO_SECURE_HSTS_SECONDS=31536000
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=True
# Preload is a one-way door — turn it on only once the wildcard cert has proven itself.
DJANGO_SECURE_HSTS_PRELOAD=False
# --- Static ---
DJANGO_STATICFILES_BACKEND=whitenoise.storage.CompressedManifestStaticFilesStorage
# --- Uploads: set these and club logos move off local disk (required for >1 app server) ---
# AWS_STORAGE_BUCKET_NAME=rosterchief-media
# AWS_S3_ENDPOINT_URL=https://fsn1.your-objectstorage.com
# 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=

4
.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.
@@ -377,3 +378,6 @@ poetry.toml
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,django%
# Node
node_modules/
staticfiles/

12
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="clubmanager-dev" uuid="840b050e-fdb0-4cf8-a4e7-3edb02bfaacb">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/db.sqlite3</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

6
.idea/markdown.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="fileGroupingEnabled" value="true" />
</component>
</project>

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>

10
.idea/pySourceRootDetection.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PySourceRootDetectionService">
<option name="sourcePathsSet">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

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" />
@@ -14,17 +14,10 @@
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<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>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1416
ARCHITECTURE.md Normal file

File diff suppressed because it is too large Load Diff

BIN
ARCHITECTURE.pdf Normal file

Binary file not shown.

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.

59
CLAUDE.md Normal file
View File

@@ -0,0 +1,59 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
RosterChief is a sport club management app + public website, built on **Django 6.0** (Python 3.14+). As of **2026-07-11 it is designed as a multi-tenant platform** (row-based / shared-schema): one deployment serves many clubs, with `Club` as the tenant root. Every club-owned model carries a `club` FK (via `ClubScopedModel`); `User` is the only global model. This **reverses** the project's earlier single-club stance — treat older "single-club / no `club_id` tenancy" notes (in git history or memory) as obsolete.
**`ARCHITECTURE.md` at the repo root is the authoritative model & domain design** — the tenancy mechanics, the RBAC design, and per-app model sketches all live there. Consult and update it when adding domain models.
The repo is an early build: `authentication` and `club` apps exist (`User`, `Member`, `Family`, `FamilyMembership`, `Club`, `ClubMembership`); the remaining domain apps and the tenancy plumbing (`rosterchief/tenancy.py`, tenant middleware, `ClubScopedModel` upgrade) are **planned, not yet on disk**. Verify against the actual tree before assuming a module exists.
## Commands
Dependencies and the virtualenv are managed with **uv** (`pyproject.toml` at repo root, `uv.lock` committed). Run Django/tools through `uv run` so the project venv is used.
```bash
uv sync # install deps (incl. dev group) into .venv
uv run python manage.py runserver # dev server
uv run python manage.py migrate # apply migrations
uv run python manage.py makemigrations
uv run python manage.py createsuperuser
uv run python manage.py shell
uv run python manage.py test # run all tests (Django test runner)
uv run python manage.py test <app> # one app
uv run python manage.py test <app>.tests.<Case> # one TestCase
uv run python manage.py test <app>.tests.<Case>.<method> # one test
uv run ruff check . # lint
uv run ruff check --fix . # lint + autofix
uv run ruff format . # format
```
## Configuration
Settings live in a single `rosterchief/settings.py` and read from the environment via **python-decouple** (`config(...)`), with a local `.env` file for dev. Key vars: `DJANGO_SECRET_KEY` (required), `DJANGO_DEBUG`, `DJANGO_ALLOWED_HOSTS`, `DJANGO_CSRF_TRUSTED_ORIGINS`, `DJANGO_DATABASE_URL`, `DJANGO_TIME_ZONE`.
The database is configured through a single `DJANGO_DATABASE_URL` (parsed by **dj-database-url**), defaulting to `sqlite:///db.sqlite3` for dev; production is intended to point at PostgreSQL via that URL. Don't hardcode DB settings — go through the env var.
## Planned architecture
**`ARCHITECTURE.md` is the source of truth for the model design; this is a summary.** The app decomposition (`authentication`, `members`, `club`, `teams`, `events`, `news`, `pages`, `home`, `formbuilder`, `shop`, `search`) has grown past the original `pyproject.toml` isort `known-first-party` list — add new labels there as apps land. Note the `accounts` app was split into `authentication` (global login) + `club`, and people models (`Member`, `Family`) are being moved into a dedicated `members` app.
Domain notes (drive modeling decisions):
- **Multi-tenancy is the cross-cutting rule.** `Club` is the tenant root; club-owned models inherit `ClubScopedModel` (a `club` FK). Scope every query to the current tenant (`.for_club()` / `.current()`); previously-global uniqueness (slugs, season names, invoice numbers) becomes **unique per club**. Only `User` is global. See `ARCHITECTURE.md` §2.4.
- **Season** is the central organizing concept, **per club**. Team rosters, events, and attendance are season-scoped — model them with a FK to a season, not as global state.
- A **Member** (a person *within one club*) can play on one or more **Teams**, each with a position + jersey number (unique within a team), always tied to a specific season.
- **RBAC is per-club and service-layer** (not `django-guardian`, not global Django groups): `ClubRole` rows (`MEMBER` / `EDITOR` / `TREASURER` / `BOARD`) plus object-scoped roles (coach via `StaffAssignment`, parent via `FamilyMembership`), all decisions routed through an access service. Django's own permissions are used only for the platform-admin layer.
- Later modules: `formbuilder` (admin-defined dynamic forms → normalized answers → reporting) and `shop` (cart → order → payment → HTML→PDF invoices via WeasyPrint), with season-scoped `ClubMembership` tracking sign-up + fee status per season.
## Conventions
- 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.

627
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,627 @@
# Deploying RosterChief
One server today, several later, with no code changes in between — only environment
variables. This document is the runbook and, more usefully, the list of things that are
specific to *this* app and will bite you if you treat it as a generic Django deploy.
## The five things that make this deployment unusual
**1. You need a wildcard TLS certificate, and that forces DNS-01.**
Tenancy is subdomain-based (`ajax.rosterchief.app`), so the certificate must cover
`*.rosterchief.app`. Let's Encrypt **will not issue a wildcard over HTTP-01** — only over
DNS-01, which means the TLS terminator needs API access to your DNS zone. That is why
`deploy/caddy/Dockerfile` builds Caddy *with* a DNS provider plugin, and why
`CLOUDFLARE_API_TOKEN` is a required variable rather than a nicety. Swap the plugin
(`caddy-dns/route53`, `caddy-dns/digitalocean`, …) if your DNS lives elsewhere.
DNS needs two records, both pointing at the server:
```
A rosterchief.app -> <server ip>
A *.rosterchief.app -> <server ip>
```
**2. Redis is not optional, even on one server.**
`waffle` caches each feature flag's targeting in the Django cache, and `LocMemCache` is
private to a single process. Under several gunicorn workers, toggling a feature in the
control panel flushes **one** worker's cache while the others keep serving the stale flag —
a feature that "sometimes doesn't turn on". A shared cache is the fix.
**3. `SECURE_PROXY_SSL_HEADER` must be set, and Caddy must send the header.**
Caddy terminates TLS, so without it Django believes every request is plain HTTP:
`request.is_secure()` goes false, WebAuthn disagrees with the browser about the origin, and
`SECURE_SSL_REDIRECT` becomes a redirect loop. Both halves are already wired (settings +
`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 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
them too, and a Mac needs Homebrew. This is the main reason to run the container even in
development if you touch invoicing.
## First deploy
```bash
# 1. Configure
cp .env.compose.example .env # read by docker compose
cp .env.production.example .env.production # read by Django
python -c "import secrets; print(secrets.token_urlsafe(64))" # -> DJANGO_SECRET_KEY
# 2. Build and start
docker compose build
docker compose up -d db redis
docker compose run --rm web python manage.py migrate
docker compose run --rm web python manage.py createsuperuser
docker compose up -d
# 3. Verify
curl -fsS https://rosterchief.app/healthz # {"status": "ok", ...}
docker compose run --rm web python manage.py check --deploy
```
`check --deploy` is what catches an env file that forgot the HTTPS flags: they default to
**off** in code, because defaulting them to `not DEBUG` would redirect every test request to
https and break the suite anywhere `DEBUG` is unset.
### Keep DJANGO_DEBUG=False, even on the test server
A test box is still a deployment: it is behind TLS, on a real domain, with real passkeys.
`DEBUG=True` there leaks tracebacks and settings to anyone who can reach a 500, and turns off
several of the protections in this document. Use it locally, not on a server.
The app no longer *crashes* if you set it — `django_browser_reload` is a dev dependency that
the image installs with `--no-dev`, so settings guard on the module being importable rather
than assuming DEBUG implies it is there — but the reason to keep it off is not the crash.
### One dependency comes from git
`django-lucide` is our fork (`[tool.uv.sources]` in `pyproject.toml`, pinned by `uv.lock` to a
commit), so **uv shells out to `git`** to fetch it. `python:*-slim` has no git, which is why
the image builds the virtualenv in a **separate stage** that installs git, and copies the
finished `.venv` into a runtime stage that does not have it — a build tool has no business in
a production image.
Two consequences worth knowing:
- The build needs **network access to GitHub**, and the fork must stay reachable. If that ever
becomes awkward (a private runner, an air-gapped build), publish the fork to a private index
or vendor the wheel, and the git stage disappears.
- `uv.lock` pins the exact commit, so the build is reproducible even though the source is a
branch. Don't build with `--no-frozen`.
The first `docker compose up` will take a minute or two: Caddy is provisioning the wildcard
certificate over DNS-01, and DNS propagation is not instant. Watch it with
`docker compose logs -f caddy`.
## Migrations
Deliberately **not** run by the container's entrypoint. With more than one web container they
would race, and a starting gunicorn worker is a bad place to discover a failed migration.
Run them once, explicitly, as part of the deploy:
```bash
docker compose build
docker compose run --rm web python manage.py migrate
docker compose up -d --no-deps web
```
## Scheduled jobs
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).
| 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 |
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
Control panel → **Features → Maintenance mode**. While it is on:
- every **club subdomain** serves a 503 maintenance page, in that club's own colours;
- the **control panel and the sign-in screens stay open**, because closing them would leave
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** — 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.
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:
```bash
# 1. Close the platform in the control panel (or from a shell):
docker compose run --rm web python manage.py shell -c \
"from features.models import Maintenance; Maintenance.start(message='Upgrading. Back by 21:00.')"
# 2. Do the work — migrate is not blocked.
docker compose build
docker compose run --rm web python manage.py migrate
docker compose up -d --no-deps web
# 3. Reopen from the control panel.
```
The state lives in Redis as well as the database, so it takes effect on **every worker and
every server at once** — a per-process cache would leave some workers still serving clubs.
## Behind an existing Caddy (dev / test server)
If the box already runs Caddy on :80 and :443 — a test server sharing a host with other
sites — do **not** run ours: two Caddies cannot both hold port 80. Run the app only, publish
it on the loopback, and add a site block to the Caddy that is already there.
```bash
docker compose -f compose.behind-proxy.yaml up -d # web + db + redis, no caddy
```
`web` publishes on `127.0.0.1:8001` (override with `WEB_PORT`). **Loopback, not 0.0.0.0**
bound to all interfaces, a test instance is reachable at `http://<server-ip>:8001` with no
TLS, bypassing the proxy and every security header with it.
Then add a site block to the host's Caddyfile. Caddy serves any number of domains on the same
ports — TLS is chosen per connection by SNI — so a second (or tenth) site is just another
block.
### If that Caddy already does Cloudflare DNS-01
Which is the usual case: the box has a domain on Cloudflare and Caddy already has the DNS
plugin. Then set the challenge **once, globally**, and every site inherits it — no `tls`
block per site, and wildcards simply work:
```caddy
{
email you@example.com
# Applies DNS-01 to every site below.
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# --- whatever the box already serves --------------------------------------
existing-thing.example.com {
reverse_proxy 127.0.0.1:3000
}
# --- RosterChief test instance --------------------------------------------
# The bare host AND the wildcard, on one certificate.
test.rosterchief.app, *.test.rosterchief.app {
encode zstd gzip
reverse_proxy 127.0.0.1:8001 {
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
}
```
### If the two domains need different tokens
Different Cloudflare accounts, or tokens scoped per zone. Drop `acme_dns` and give each site
its own `tls`; a snippet keeps it short:
```caddy
{
email you@example.com
}
(cf) {
tls {
dns cloudflare {args[0]}
}
}
existing-thing.example.com {
import cf {env.CF_TOKEN_EXAMPLE}
reverse_proxy 127.0.0.1:3000
}
test.rosterchief.app, *.test.rosterchief.app {
import cf {env.CF_TOKEN_ROSTERCHIEF}
reverse_proxy 127.0.0.1:8001 {
header_up X-Forwarded-Proto {scheme}
}
}
```
### What actually goes wrong
1. **The token must cover the *new* zone.** A Cloudflare token is scoped to named zones, and
an existing one almost certainly grants `Zone:DNS:Edit` on the domain it was made for and
nothing else. The new site then fails its DNS-01 challenge on a permissions error whose
text does not say so. Widen the token, or mint a second one and use the snippet form.
2. **Both hostnames must be listed.** `*.test.rosterchief.app` does **not** match
`test.rosterchief.app` — a wildcard covers exactly one label. Leave the bare host out and
the club subdomains have a certificate while the control panel does not. Hence the comma.
(Wildcards are also only one level deep: `ajax.test.…` yes, `a.b.test.…` no.)
3. **Caddy must have the DNS plugin.** Stock `caddy` cannot answer a DNS-01 challenge at all.
`caddy add-package github.com/caddy-dns/cloudflare`, or run a Caddy built like
`deploy/caddy/Dockerfile`. (If DNS-01 already works on the box, you have it.)
4. **The token must be in *Caddy's* environment**, not your shell's — `{env.…}` reads the
process it runs in:
```ini
# /etc/systemd/system/caddy.service.d/override.conf
[Service]
EnvironmentFile=/etc/caddy/caddy.env # CLOUDFLARE_API_TOKEN=...
```
Then `systemctl daemon-reload && systemctl restart caddy`.
5. **`header_up X-Forwarded-Proto` is not optional**, exactly as in the bundled Caddyfile:
without it Django believes the request behind the proxy is plain HTTP.
6. **Give the test instance its own subdomain tree** and set
`ROSTERCHIEF_BASE_DOMAIN=test.rosterchief.app`. That variable drives tenant resolution,
the shared session cookie *and* the WebAuthn RP ID — point it at the production domain and
test passkeys start colliding with real ones.
### Applying and checking it
```bash
caddy validate --config /etc/caddy/Caddyfile # syntax and modules
systemctl reload caddy # zero downtime; existing certs untouched
journalctl -u caddy -f # watch the DNS-01 challenge
curl -I https://test.rosterchief.app/healthz
curl -I https://any-club-slug.test.rosterchief.app/ # proves the WILDCARD, not just the host
```
Reloading provisions only what is new, so the existing site's certificate is not reissued.
Allow 3060s for the DNS record to propagate before the challenge completes.
DNS needs both records, pointing at the test box:
```
A test.rosterchief.app -> <server ip>
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,
prunes anything older than `KEEP_DAYS`, and — if you set `BACKUP_REMOTE` — copies the lot off
the box with rclone.
```bash
deploy/backup.sh /var/backups/rosterchief
```
It writes to a `.part` file and only moves it into place once `gzip -t` says the archive is
readable and non-empty. A truncated dump that *looks* like a backup is the failure mode worth
engineering against, because you only discover it on the day you need it.
Schedule it as root on the host (single server; on several, run it on the database node):
```cron
# Nightly at 02:30, before the billing and event jobs.
30 2 * * * cd /srv/rosterchief && BACKUP_REMOTE=b2:rosterchief-backups KEEP_DAYS=14 deploy/backup.sh /var/backups/rosterchief
# Weekly restore rehearsal into a throwaway database. This is the only line here that proves
# the others work.
0 4 * * 0 cd /srv/rosterchief && deploy/restore-check.sh
```
Cron mails you on non-zero exit, and the script uses `set -Eeuo pipefail` so it *does* exit
non-zero. A backup script that fails quietly is worse than none, because you will believe you
have backups.
**Offsite matters more than frequency.** A dump sitting on the same disk as the database
survives a bad migration but not the server. `BACKUP_REMOTE` takes any rclone remote (S3,
Backblaze, a second box).
**Once uploads move to S3** (`AWS_STORAGE_BUCKET_NAME`), the script skips the media tarball:
the bucket's own versioning is the backup. Turn versioning on when you create it.
### Restoring
```bash
gunzip -c /var/backups/rosterchief/db-2026-07-14-0230.sql.gz \
| docker compose exec -T db psql -U rosterchief rosterchief
```
The dump is taken with `--clean --if-exists`, so it drops and recreates rather than colliding
with what is there. Rehearse it once, now, against a scratch database — not the first time you
need it.
## Backups (manual)
Two things carry state: Postgres and the uploads.
```bash
# Database
docker compose exec -T db pg_dump -U rosterchief rosterchief | gzip > rosterchief-$(date +%F).sql.gz
# Uploads — until they are on S3, in which case the bucket's own versioning is the backup.
docker compose cp web:/app/media ./media-backup
```
Restore is `gunzip -c dump.sql.gz | docker compose exec -T db psql -U rosterchief rosterchief`.
Test it once, now, rather than the first time you need it.
## Sizing the server
For **15 clubs, ~1000 members, ~10 events per club per week**.
The short answer: **2 vCPU, 4 GB RAM, 40 GB SSD** — a €46/month VPS (Hetzner CX22 or
equivalent). The interesting part is *why*, because the data is not what sizes this box.
### The data is negligible
Row counts for that workload, from the actual schema (attendance dominates: every event
invites a squad, so one event is ~20 rows):
| table | rows/year | MB/year |
|---|---:|---:|
| `events.Attendance` | 52,000 | 16 |
| `events.Event` | 2,600 | 2 |
| `formbuilder` answers | 10,000 | 3 |
| `shop` orders + lines | 3,000 | 1 |
| members, memberships, rosters | ~3,000 | 1 |
| **total, with WAL and bloat** | | **~40 MB/year** |
That is **0.2 GB after five years**. Uploads are club logos — a handful of files. Invoices are
rendered on demand and never stored. Nothing here grows into a problem.
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`, before the tuning below —
`--workers 3`, no `--preload`, Postgres and Redis on their image defaults:
| | memory |
|---|---|
| gunicorn master + 3 workers | **~270 MB** (~54 MB per worker) |
| PostgreSQL (default `shared_buffers`) | ~200400 MB |
| Redis (cache only) | < 50 MB |
| Caddy | ~30 MB |
| 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:
1. **`docker compose build` is the memory spike, not serving.** npm, uv and `collectstatic`
together will OOM a 2 GB box that is also running Postgres. Either take the 4 GB, or build
the image elsewhere and pull it.
2. **Rendering an invoice loads WeasyPrint.** It is imported lazily (which is why the workers
measure 54 MB and not 150), so pango and its fonts land in whichever worker renders a PDF —
expect that worker to grow by ~50100 MB the first time someone downloads an invoice.
3. **Headroom is Postgres's page cache.** With 200 MB of data and 4 GB of RAM, the entire
database lives in cache and the disk is never touched for reads.
### Disk
| | |
|---|---|
| Docker images (app ~1 GB with pango, postgres, redis, caddy) | ~1.5 GB |
| Build cache | 24 GB |
| Database, 5 years | < 0.5 GB |
| Backups: 14 daily compressed dumps | < 0.5 GB |
| Logs | ~1 GB |
| **40 GB is roomy; 20 GB works** | |
### CPU and concurrency
2 vCPU. Three workers × four threads is twelve concurrent requests, against a peak of "the
whole club checks the Saturday line-up at 09:00" — perhaps a few hundred requests over a few
minutes. This workload is not CPU-bound; the one CPU-heavy operation is PDF rendering, which
happens a handful of times a month.
### When to grow
Not at "more members" — at these:
- **Uploads become real content** (photo galleries, documents). Media, not rows, is what makes
storage grow, and it is also the trigger for moving to S3.
- **Attendance passes a few million rows** (~20 clubs at this rate, i.e. several years out).
Add an index before adding a server.
- **You want zero-downtime deploys.** That is a second app node, not a bigger one.
## For fun: three nodes on AWS
Wildly over-engineered for 1000 members, but here is what it looks like — and what it costs.
### The layout
```
Route 53 (rosterchief.app + *.rosterchief.app)
|
ACM certificate (wildcard, free)
|
Application Load Balancer (TLS terminates here)
|
+----+----+----+
| | |
ECS task task task 3 × Fargate, one per AZ, same image
| | |
+----+----+----+
|
+----+---------------+----------------+
| | |
RDS PostgreSQL ElastiCache Redis S3 (media)
(Multi-AZ) (cache.t4g.micro) + CloudFront (optional)
```
**The one genuinely nice thing AWS gives you here: ACM issues the wildcard certificate for
free, with DNS validation in Route 53.** The whole DNS-01 dance disappears — no Caddy plugin,
no API token, no renewal. The ALB terminates TLS and forwards to the tasks. That is the single
biggest simplification versus the VPS.
### What changes in the app
Nothing in the code. Only environment:
| | |
|---|---|
| `DJANGO_DATABASE_URL` | the RDS endpoint |
| `DJANGO_REDIS_URL` | the ElastiCache endpoint |
| `AWS_STORAGE_BUCKET_NAME` | the media bucket — **required** now, three nodes cannot share a disk |
| `SECURE_PROXY_SSL_HEADER` | already set; the ALB sends `X-Forwarded-Proto` |
| health check | point the target group at **`/healthz`** — that is what it is for |
Sessions are database-backed, so **no sticky sessions**: any task can serve any request.
**Scheduled jobs get better here.** EventBridge Scheduler firing a one-off ECS task solves the
"run it on exactly one node" problem properly — no cron on three boxes racing each other:
```
EventBridge (cron: 0 6 * * ? *) -> ECS RunTask -> archive_overdue_clubs --commit
```
Backups become RDS automated snapshots + PITR, and `deploy/backup.sh` retires — though the
*restore rehearsal* does not. Snapshots you have never restored are still a hypothesis.
### Monthly cost (eu-central-1, on-demand, indicative)
| | | $/month |
|---|---|---:|
| ALB | fixed + a little LCU | ~22 |
| ECS Fargate | 3 × (0.5 vCPU, 1 GB) | ~54 |
| RDS PostgreSQL | `db.t4g.micro`, 20 GB gp3, single-AZ | ~17 |
| ElastiCache | `cache.t4g.micro` | ~12 |
| S3 + CloudFront | a few GB, low traffic | ~2 |
| Route 53 | hosted zone + queries | ~1 |
| ECR, CloudWatch logs | small | ~3 |
| | **single-AZ total** | **~110** |
| RDS Multi-AZ | doubles the database | +17 |
| | **highly-available total** | **~130** |
**Watch the NAT Gateway.** If the tasks sit in private subnets and reach the internet through
a NAT Gateway, add **~$32/month per AZ plus data charges** — for three AZs that is more than
the compute. Either put the tasks in public subnets with tight security groups, or use VPC
endpoints for ECR/S3/CloudWatch. It is the single most common surprise on an AWS bill of this
shape.
Prices are indicative and move; check the calculator before committing.
### The honest comparison
| | | |
|---|---|---|
| **Hetzner CX22** | 2 vCPU, 4 GB, 40 GB | **~€5/month** |
| **AWS, three nodes** | as above | **~$110130/month** |
Roughly **25×**, for a workload whose database is 200 MB after five years. What the money buys
is real — managed Postgres with PITR, three AZs, no box to patch, free wildcard certificates —
but it is bought for *resilience*, not for capacity. At 1000 members you are paying for the
insurance, not the compute.
A reasonable middle: one VPS now, and move Postgres to a managed service (RDS, or a €15/month
managed Postgres) the day the data starts to matter more than the uptime. That is the change
that is painful to do late, and everything else in this document is already designed for it.
## Going multi-server
Nothing in the code changes. What changes is where the services live:
| | one server | several |
|---|---|---|
| Postgres | `db` container | `DJANGO_DATABASE_URL` → your central Postgres |
| 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 |
| 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
`web` on as many nodes as you like behind a load balancer pointed at `/healthz`.
The health check tests the database *and* a cache round trip, not just that the process is
listening — a node that cannot reach Postgres, or whose cache silently swallows writes, is
not healthy, and a load balancer must not keep feeding it traffic.
## Rollback
Images are the unit of rollback. Tag on build, keep the last few, and:
```bash
docker compose up -d --no-deps web # with the previous image tag
```
Migrations are the exception: they don't roll back with the image. Prefer additive migrations
(add a column, deploy, backfill, then stop writing the old one) so that yesterday's image
still runs against today's schema.

117
Dockerfile Normal file
View File

@@ -0,0 +1,117 @@
# syntax=docker/dockerfile:1
# --- 1. the stylesheet -------------------------------------------------------
# Tailwind is a build-time concern: the CSS it emits is committed, but building it here means
# the image never depends on someone having remembered to run `npm run build`.
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
# --- 2. the virtualenv -------------------------------------------------------
# Separate from the runtime for one reason: django-lucide is a *git* dependency (our lucide
# fork), so uv shells out to git to fetch it. python:*-slim has no git, and installing it in
# the runtime image would leave a build-time tool — plus its dependency tree — in production
# for the sake of one package that is already vendored into the venv by then.
FROM python:3.14-slim AS venv
RUN apt-get update && apt-get install --no-install-recommends -y git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
WORKDIR /app
# Dependencies first: they change far less often than the code, so this layer caches.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-install-project
# --- 3. the runtime ----------------------------------------------------------
FROM python:3.14-slim AS app
# WeasyPrint binds to these at import: no pango, no invoices. This is also why building the
# PDF path in a container is easier than on a Mac — apt has what Homebrew would have to.
RUN apt-get update && apt-get install --no-install-recommends -y \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libffi8 \
libjpeg62-turbo \
libopenjp2-7 \
shared-mime-info \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
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
# The venv arrives fully built. Same base image, so the compiled wheels inside it are ABI
# compatible; nothing is re-resolved here, and no git is needed to run what git fetched.
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
# 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
# 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", "2", \
"--threads", "4", \
"--preload", \
"--max-requests", "500", \
"--max-requests-jitter", "50", \
"--timeout", "60", \
"--access-logfile", "-", \
"--error-logfile", "-"]

Binary file not shown.

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)

268
assets/app.css Normal file
View File

@@ -0,0 +1,268 @@
@import "tailwindcss";
/* Scan Django templates for utility classes (Tailwind's auto-detection doesn't
know about our template dirs). */
@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. */
@plugin "daisyui" {
themes: light --default, dark --prefersdark;
}
/* Fonts are self-hosted (files copied from the @fontsource packages into
static/fonts/, paths are relative to the built css at /static/css/app.css).
Google's CDN would leak every visitor's IP to a third party on page load,
which we don't want to inherit for an EU club platform.
Two subsets each: `latin` covers western europe, `latin-ext` carries the
polish/czech/turkish letters that turn up in member names. The unicode-range
means a browser only fetches latin-ext when a page actually uses those glyphs.
Ubuntu is static (it has no variable version); its real weights are 400/500/700,
so `font-semibold` (600) is synthesised up to 700 by the browser. JetBrains Mono
and Roboto are variable: one file covers the whole weight axis. */
@font-face {
font-family: "Ubuntu";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../fonts/ubuntu-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: "Ubuntu";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../fonts/ubuntu-latin-ext-400-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Ubuntu";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("../fonts/ubuntu-latin-400-italic.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: "Ubuntu";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("../fonts/ubuntu-latin-ext-400-italic.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Ubuntu";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("../fonts/ubuntu-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: "Ubuntu";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("../fonts/ubuntu-latin-ext-500-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Ubuntu";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../fonts/ubuntu-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: "Ubuntu";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../fonts/ubuntu-latin-ext-700-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url("../fonts/jetbrains-mono-latin-wght-normal.woff2") format("woff2-variations");
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: "JetBrains Mono";
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url("../fonts/jetbrains-mono-latin-ext-wght-normal.woff2") format("woff2-variations");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Roboto";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("../fonts/roboto-latin-standard-normal.woff2") format("woff2-variations");
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: "Roboto";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("../fonts/roboto-latin-ext-standard-normal.woff2") format("woff2-variations");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Tourney is a display face for jersey numbers and the like. Its variable file
carries two axes -- weight 100-900 and width 75-125 -- so font-stretch has to be
declared as a range too, otherwise the browser clamps to the default width and
`font-stretch: 125%` (a wide shirt number) silently does nothing. */
@font-face {
font-family: "Tourney";
font-style: normal;
font-weight: 100 900;
font-stretch: 75% 125%;
font-display: swap;
src: url("../fonts/tourney-latin-standard-normal.woff2") format("woff2-variations");
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: "Tourney";
font-style: normal;
font-weight: 100 900;
font-stretch: 75% 125%;
font-display: swap;
src: url("../fonts/tourney-latin-ext-standard-normal.woff2") format("woff2-variations");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Setting --font-sans / --font-mono changes the body and <code> defaults; every
--font-* also generates a utility, so --font-roboto gives us `font-roboto` to
opt into Roboto where we want it. */
@theme {
--font-sans: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--font-ubuntu: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
--font-roboto: "Roboto", ui-sans-serif, system-ui, sans-serif;
--font-tourney: "Tourney", ui-sans-serif, system-ui, sans-serif;
}
/* The logo is a background image, not `content:` -- content-replacement on a real
element (rather than ::before/::after) isn't supported in Firefox.
Default = dark-ink logo, for a light background. The media query covers "auto",
where the toggle deliberately sets no data-theme at all; the attribute selectors
are more specific, so an explicit choice always beats the OS. */
.logo {
background-image: var(--logo-dark);
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
@media (prefers-color-scheme: dark) {
.logo {
background-image: var(--logo-light);
}
}
[data-theme="light"] .logo {
background-image: var(--logo-dark);
}
[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

View File

@@ -0,0 +1,37 @@
"""allauth adapters.
The MFA adapter exists for one important reason: WebAuthn credentials are bound
to a **Relying Party ID** (a domain). allauth's default RP ID is the request's
host — which under our subdomain tenancy would be ``ajax-united.rosterchief.app``,
binding a passkey to *one club*. A member of two clubs would then need two
passkeys, and a credential registered at one club would silently fail at another.
Pinning the RP ID to the registrable parent domain (``rosterchief.app``) makes a
single passkey work across every club subdomain.
"""
from allauth.mfa.adapter import DefaultMFAAdapter
from django.conf import settings
class RosterChiefMFAAdapter(DefaultMFAAdapter):
def get_public_key_credential_rp_entity(self) -> dict[str, str]:
return {
"id": webauthn_rp_id(),
"name": settings.MFA_WEBAUTHN_RP_NAME,
}
def webauthn_rp_id() -> str:
"""The registrable parent domain that passkeys are bound to.
Falls back to the request host when no base domain is configured (e.g. a
bare ``localhost`` dev server), which keeps WebAuthn usable there.
"""
base_domain = getattr(settings, "ROSTERCHIEF_BASE_DOMAIN", "")
if base_domain:
return base_domain
from allauth.core import context
return context.request.get_host().partition(":")[0]

44
authentication/admin.py Normal file
View File

@@ -0,0 +1,44 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.utils.translation import gettext_lazy as _
from members.models import Member
from .forms import UserChangeForm, UserCreationForm
from .models import User
class MemberInline(admin.StackedInline):
"""Edit the member profile attached to a login from the User page."""
model = Member
can_delete = False
extra = 0
max_num = 1
verbose_name_plural = _("member profile")
fields = ("first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone")
@admin.register(User)
class UserAdmin(BaseUserAdmin):
add_form = UserCreationForm
form = UserChangeForm
model = User
inlines = [MemberInline]
list_display = ("email", "full_name", "is_staff", "is_active")
list_filter = ("is_staff", "is_superuser", "is_active", "groups")
search_fields = ("email", "member__first_name", "member__last_name")
ordering = ("email",)
readonly_fields = ("last_login",)
fieldsets = (
(None, {"fields": ("email", "password")}),
(_("Permissions"), {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
(_("Important dates"), {"fields": ("last_login",)}),
)
add_fieldsets = ((None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}),)
@admin.display(description=_("name"))
def full_name(self, obj):
return obj.get_full_name()

5
authentication/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AuthenticationConfig(AppConfig):
name = "authentication"

20
authentication/forms.py Normal file
View File

@@ -0,0 +1,20 @@
from django.contrib.auth.forms import BaseUserCreationForm
from django.contrib.auth.forms import UserChangeForm as DjangoUserChangeForm
from .models import User
class UserCreationForm(BaseUserCreationForm):
"""Add-user form for the email-based custom User (no ``username`` field)."""
class Meta:
model = User
fields = ("email",)
class UserChangeForm(DjangoUserChangeForm):
"""Change-user form; keeps the read-only password hash widget."""
class Meta:
model = User
fields = "__all__"

View File

@@ -0,0 +1,32 @@
from django.contrib.auth.base_user import BaseUserManager
class UserManager(BaseUserManager):
"""Manager for the email-based custom User model."""
use_in_migrations = True
def _create_user(self, email, password, **extra_fields):
if not email:
raise ValueError("Users must have an email address.")
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_user(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False)
return self._create_user(email, password, **extra_fields)
def create_superuser(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
if extra_fields.get("is_staff") is not True:
raise ValueError("Superuser must have is_staff=True.")
if extra_fields.get("is_superuser") is not True:
raise ValueError("Superuser must have is_superuser=True.")
return self._create_user(email, password, **extra_fields)

View File

@@ -0,0 +1,49 @@
"""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, 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
enrolled is redirected to the MFA setup page until they do.
"""
from allauth.mfa.utils import is_mfa_enabled
from django.conf import settings
from django.shortcuts import redirect
from django.urls import reverse
from club.models import ClubRole
#: Paths a not-yet-enrolled user must still reach (to enrol, or to log out).
#: ``/__reload__/`` is django-browser-reload's event stream, which only exists
#: 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, ClubRole.Roles.MEMBER_ADMIN)
def mfa_required_for(user) -> bool:
"""Privileged users must hold a second factor."""
if user.is_staff or user.is_superuser:
return True
return ClubRole.objects.filter(member__user=user, role__in=ELEVATED_ROLES).exists()
class RequireMFAMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if self.needs_enrolment(request):
return redirect(reverse(settings.MFA_ENROLMENT_URL_NAME))
return self.get_response(request)
def needs_enrolment(self, request) -> bool:
user = getattr(request, "user", None)
if user is None or not user.is_authenticated:
return False
if request.path.startswith(EXEMPT_PREFIXES):
return False
return mfa_required_for(user) and not is_mfa_enabled(user)

View File

@@ -0,0 +1,94 @@
# Generated by Django 6.0.6 on 2026-07-02 07:33
import authentication.managers
import django.db.models.deletion
import phonenumber_field.modelfields
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='Family',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
],
options={
'verbose_name': 'family',
'verbose_name_plural': 'families',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='User',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
('is_staff', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'ordering': ['email'],
},
managers=[
('objects', authentication.managers.UserManager()),
],
),
migrations.CreateModel(
name='Member',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('first_name', models.CharField(max_length=150)),
('last_name', models.CharField(max_length=150)),
('date_of_birth', models.DateField(blank=True, null=True)),
('email', models.EmailField(blank=True, max_length=254)),
('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None)),
('emergency_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None)),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'member',
'verbose_name_plural': 'members',
'ordering': ['last_name', 'first_name'],
},
),
migrations.CreateModel(
name='FamilyMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('parent', 'parent'), ('child', 'child'), ('guardian', 'guardian'), ('other', 'other')], default='parent', max_length=255)),
('family', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='authentication.family')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='family_memberships', to='authentication.member')),
],
options={
'verbose_name': 'family membership',
'verbose_name_plural': 'family memberships',
'ordering': ['family', 'role', 'member__last_name', 'member__first_name'],
},
),
migrations.AddIndex(
model_name='member',
index=models.Index(fields=['last_name', 'first_name'], name='authenticat_last_na_0a0eca_idx'),
),
migrations.AlterUniqueTogether(
name='familymembership',
unique_together={('family', 'member')},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-07-02 14:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='family',
name='name',
field=models.CharField(blank=True, max_length=255),
),
]

View File

@@ -0,0 +1,86 @@
# Generated by Django 6.0.6 on 2026-07-05 13:50
import django.db.models.deletion
import phonenumber_field.modelfields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0002_alter_family_name'),
]
operations = [
migrations.AlterField(
model_name='family',
name='name',
field=models.CharField(blank=True, max_length=255, verbose_name='name'),
),
migrations.AlterField(
model_name='familymembership',
name='family',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='authentication.family', verbose_name='family'),
),
migrations.AlterField(
model_name='familymembership',
name='member',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='family_memberships', to='authentication.member', verbose_name='member'),
),
migrations.AlterField(
model_name='familymembership',
name='role',
field=models.CharField(choices=[('parent', 'parent'), ('child', 'child'), ('guardian', 'guardian'), ('other', 'other')], default='parent', max_length=255, verbose_name='role'),
),
migrations.AlterField(
model_name='member',
name='date_of_birth',
field=models.DateField(blank=True, null=True, verbose_name='date of birth'),
),
migrations.AlterField(
model_name='member',
name='email',
field=models.EmailField(blank=True, max_length=254, verbose_name='email'),
),
migrations.AlterField(
model_name='member',
name='emergency_phone',
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='emergency phone number'),
),
migrations.AlterField(
model_name='member',
name='first_name',
field=models.CharField(max_length=150, verbose_name='first name'),
),
migrations.AlterField(
model_name='member',
name='last_name',
field=models.CharField(max_length=150, verbose_name='last name'),
),
migrations.AlterField(
model_name='member',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='phone number'),
),
migrations.AlterField(
model_name='member',
name='user',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member', to=settings.AUTH_USER_MODEL, verbose_name='user'),
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='email'),
),
migrations.AlterField(
model_name='user',
name='is_active',
field=models.BooleanField(default=True, verbose_name='is active?'),
),
migrations.AlterField(
model_name='user',
name='is_staff',
field=models.BooleanField(default=False, verbose_name='is staff?'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.6 on 2026-07-11 22:09
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('authentication', '0003_alter_family_name_alter_familymembership_family_and_more'),
('club', '0005_alter_clubmembership_member'),
]
operations = [
migrations.DeleteModel(
name='FamilyMembership',
),
migrations.DeleteModel(
name='Family',
),
migrations.DeleteModel(
name='Member',
),
]

View File

41
authentication/models.py Normal file
View File

@@ -0,0 +1,41 @@
import uuid
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import PermissionsMixin
from django.db import models
from django.utils.translation import gettext_lazy as _
from .managers import UserManager
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
email = models.EmailField(_("email"), unique=True, db_index=True)
is_staff = models.BooleanField(_("is staff?"), default=False)
is_active = models.BooleanField(_("is active?"), default=True)
objects = UserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
class Meta:
verbose_name = _("user")
verbose_name_plural = _("users")
ordering = ["email"]
def __str__(self):
return self.get_full_name()
def get_full_name(self):
member = getattr(self, "member", None)
if member is not None:
return member.get_full_name()
return self.email
def get_short_name(self):
member = getattr(self, "member", None)
if member is not None:
return member.get_short_name()
return self.email

591
authentication/tests.py Normal file
View File

@@ -0,0 +1,591 @@
import re
import uuid
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
from allauth.core import context
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
from club.models import Club, ClubRole
from members.models import Member
from .adapters import RosterChiefMFAAdapter, webauthn_rp_id
from .middleware import RequireMFAMiddleware, mfa_required_for
User = get_user_model()
def enrol_mfa(user):
"""Give ``user`` a second factor (enough for is_mfa_enabled)."""
return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
class UserManagerTests(TestCase):
def test_create_user_defaults(self):
user = User.objects.create_user(email="alice@example.com", password="secret123")
self.assertEqual(user.email, "alice@example.com")
self.assertTrue(user.check_password("secret123"))
self.assertFalse(user.is_staff)
self.assertFalse(user.is_superuser)
self.assertTrue(user.is_active)
def test_create_user_requires_email(self):
with self.assertRaises(ValueError):
User.objects.create_user(email="", password="secret123")
def test_create_user_normalizes_email_domain(self):
# BaseUserManager lowercases the domain part of the address.
user = User.objects.create_user(email="Bob@Example.COM", password="secret123")
self.assertEqual(user.email, "Bob@example.com")
def test_create_user_password_is_hashed(self):
user = User.objects.create_user(email="carol@example.com", password="secret123")
self.assertNotEqual(user.password, "secret123")
def test_create_user_without_password_is_unusable(self):
user = User.objects.create_user(email="dave@example.com")
self.assertFalse(user.has_usable_password())
def test_create_superuser_defaults(self):
admin = User.objects.create_superuser(email="admin@example.com", password="secret123")
self.assertTrue(admin.is_staff)
self.assertTrue(admin.is_superuser)
self.assertTrue(admin.is_active)
def test_create_superuser_rejects_non_staff(self):
with self.assertRaises(ValueError):
User.objects.create_superuser(email="admin@example.com", password="x", is_staff=False)
def test_create_superuser_rejects_non_superuser(self):
with self.assertRaises(ValueError):
User.objects.create_superuser(email="admin@example.com", password="x", is_superuser=False)
class UserModelTests(TestCase):
def test_email_is_username_field(self):
self.assertEqual(User.USERNAME_FIELD, "email")
self.assertEqual(User.REQUIRED_FIELDS, [])
def test_email_is_unique(self):
User.objects.create_user(email="dup@example.com", password="x")
with self.assertRaises(IntegrityError):
User.objects.create_user(email="dup@example.com", password="y")
def test_pk_is_uuid(self):
user = User.objects.create_user(email="uuid@example.com", password="x")
self.assertIsInstance(user.pk, uuid.UUID)
def test_str_and_names_fall_back_to_email_without_member(self):
user = User.objects.create_user(email="lonely@example.com", password="x")
self.assertEqual(str(user), "lonely@example.com")
self.assertEqual(user.get_full_name(), "lonely@example.com")
self.assertEqual(user.get_short_name(), "lonely@example.com")
def test_str_and_names_use_linked_member(self):
user = User.objects.create_user(email="linked@example.com", password="x")
Member.objects.create(user=user, first_name="Jane", last_name="Doe")
# Re-fetch so the reverse OneToOne relation is resolved from the DB.
user = User.objects.get(pk=user.pk)
self.assertEqual(str(user), "Jane Doe")
self.assertEqual(user.get_full_name(), "Jane Doe")
self.assertEqual(user.get_short_name(), "Jane")
@override_settings(
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
MFA_WEBAUTHN_RP_NAME="RosterChief",
ALLOWED_HOSTS=[".rosterchief.app", "example.test"],
)
class WebAuthnRelyingPartyTests(TestCase):
"""A passkey is bound to a Relying Party ID (a domain).
allauth's default RP ID is the request host, which under our subdomain
tenancy would bind a passkey to a single club. We pin it to the registrable
parent domain so ONE passkey works across every club.
"""
def rp_entity(self, host):
request = RequestFactory().get("/", HTTP_HOST=host)
with context.request_context(request):
return RosterChiefMFAAdapter().get_public_key_credential_rp_entity()
def test_rp_id_is_the_parent_domain_not_the_club_subdomain(self):
self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["id"], "rosterchief.app")
def test_rp_id_is_identical_across_clubs(self):
# The whole point: a passkey registered at one club works at the others.
here = self.rp_entity("ajax-united.rosterchief.app")
there = self.rp_entity("rival-fc.rosterchief.app")
self.assertEqual(here["id"], there["id"])
def test_rp_name_comes_from_settings(self):
self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["name"], "RosterChief")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="")
def test_falls_back_to_the_request_host_without_a_base_domain(self):
request = RequestFactory().get("/", HTTP_HOST="example.test:8000")
with context.request_context(request):
self.assertEqual(webauthn_rp_id(), "example.test")
class MFARequirementTests(TestCase):
@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)
def with_role(self, user, role):
member = Member.objects.create(user=user, first_name="Ada", last_name="Min")
ClubRole.objects.create(club=self.club, member=member, role=role)
return user
def test_staff_must_have_mfa(self):
self.assertTrue(mfa_required_for(self.make_user("staff@example.com", is_staff=True)))
def test_superuser_must_have_mfa(self):
self.assertTrue(mfa_required_for(User.objects.create_superuser(email="root@example.com", password="pw-secret-123")))
def test_club_admin_must_have_mfa(self):
user = self.with_role(self.make_user("admin@example.com"), ClubRole.Roles.ADMIN)
self.assertTrue(mfa_required_for(user))
def test_editor_must_have_mfa(self):
user = self.with_role(self.make_user("editor@example.com"), ClubRole.Roles.EDITOR)
self.assertTrue(mfa_required_for(user))
def test_plain_member_does_not_need_mfa(self):
user = self.with_role(self.make_user("member@example.com"), ClubRole.Roles.MEMBER)
self.assertFalse(mfa_required_for(user))
def test_user_without_any_role_does_not_need_mfa(self):
self.assertFalse(mfa_required_for(self.make_user("nobody@example.com")))
class RequireMFAMiddlewareTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.middleware = RequireMFAMiddleware(lambda request: HttpResponse("ok"))
def dispatch(self, user, path="/"):
request = self.factory.get(path)
request.user = user
return self.middleware(request)
def make_staff(self):
return User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
def test_anonymous_passes_through(self):
self.assertEqual(self.dispatch(AnonymousUser()).content, b"ok")
def test_unprivileged_user_passes_through(self):
user = User.objects.create_user(email="plain@example.com", password="pw-secret-123")
self.assertEqual(self.dispatch(user).content, b"ok")
def test_privileged_user_without_mfa_is_sent_to_enrolment(self):
response = self.dispatch(self.make_staff())
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("mfa_index"))
def test_privileged_user_can_still_reach_the_enrolment_pages(self):
# Otherwise they'd be redirected in a loop and could never enrol.
response = self.dispatch(self.make_staff(), path="/accounts/2fa/totp/activate/")
self.assertEqual(response.content, b"ok")
def test_enrolled_privileged_user_passes_through(self):
staff = self.make_staff()
enrol_mfa(staff)
self.assertEqual(self.dispatch(staff).content, b"ok")
class AdminLoginRoutingTests(TestCase):
def test_admin_login_is_routed_through_allauth(self):
# Django's own admin login knows nothing about second factors.
response = self.client.get("/admin/login/", {"next": "/admin/"})
self.assertEqual(response.status_code, 302)
redirect = urlparse(response.url)
self.assertEqual(redirect.path, reverse("account_login"))
# The original destination survives the hop (percent-encoded).
self.assertEqual(parse_qs(redirect.query)["next"], ["/admin/"])
class AuthFormRenderingTests(TestCase):
"""Every allauth form must actually render its fields.
Regression: the `fields` element passed `attrs.exclude` straight into a filter.
On a page that never sets it, resolving a filter *argument* raises
VariableDoesNotExist — which Django swallows inside {% if %} and reads as false —
so every field was silently dropped from every form except the login page (the one
page that does pass `exclude`).
"""
def test_the_login_form_renders_its_fields(self):
self.assertContains(self.client.get(reverse("account_login")), 'name="login"')
def test_the_password_reset_form_renders_its_fields(self):
self.assertContains(self.client.get(reverse("account_reset_password")), 'name="email"')
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):
# 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)
def test_the_code_field_renders_as_an_otp_input(self):
self.assertContains(self.response, 'name="code"')
self.assertContains(self.response, "otp otp-lg")
def test_the_input_comes_after_the_boxes(self):
# daisyUI places each box with nth-child, which counts every child. With the input
# first, all six boxes shift a stride right, the container grows to seven strides
# and the ::after focus marker appears as a phantom seventh box.
html = self.response.content.decode()
otp = html[html.index('class="otp otp-lg"') : html.index('name="code"')]
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"')
def test_cancel_sits_beside_sign_in_and_is_not_primary(self):
self.assertContains(self.response, '<button class="btn btn-outline gap-2" type="submit" form="logout-from-stage">')
self.assertContains(self.response, '<button class="btn btn-primary gap-2" type="submit">')
def test_cancel_has_a_form_to_submit(self):
self.assertContains(self.response, 'id="logout-from-stage"')
def test_the_security_key_button_is_an_accent_button_with_a_working_form(self):
self.assertContains(self.response, "btn btn-accent")
self.assertContains(self.response, 'form="webauthn_form"')
# The id lives on the form element — without it the button submits nothing.
self.assertContains(self.response, 'id="webauthn_form"')
self.assertContains(self.response, "allauth.webauthn.forms.authenticateForm")
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):
# 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)
def test_the_manage_page_renders_a_panel_per_authenticator(self):
response = self.client.get(reverse("mfa_index"))
self.assertContains(response, "Authenticator App")
self.assertContains(response, "card border")
def test_the_security_key_list_renders(self):
# Regression: allauth's template does {% load humanize %}, which raised
# TemplateSyntaxError until django.contrib.humanize was installed.
self.assertEqual(self.client.get(reverse("mfa_list_webauthn")).status_code, 200)
def test_the_totp_activate_page_boxes_the_code_and_plates_the_qr(self):
response = self.client.get(reverse("mfa_activate_totp"))
self.assertContains(response, "otp otp-lg")
# The QR is dark-on-transparent: without a white plate it is unscannable on the
# dark theme.
self.assertContains(response, "bg-white p-3")
self.assertContains(response, "font-mono") # the secret, to be copied by hand
def test_the_deactivate_button_is_destructive(self):
enrol_mfa(self.user)
response = self.client.get(reverse("mfa_index"))
# allauth tags it "danger" — it must not look like the safe action.
self.assertContains(response, "btn-error")
def test_reauthenticating_with_a_code_boxes_the_input(self):
enrol_mfa(self.user)
self.assertContains(self.client.get(reverse("mfa_reauthenticate")), "otp otp-lg")
class ActionBarTests(TestCase):
"""A form's action bar is drawn when the actions slot has content.
Regression: it was keyed on `no_visible_fields`, which allauth sets to say a form has
no visible *fields* — logout and TOTP deactivate are a bare csrf token plus a button.
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.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):
response = self.client.get(reverse("account_logout"))
self.assertContains(response, "Sign Out")
self.assertContains(response, 'type="submit"')
def test_the_totp_deactivate_page_has_its_button(self):
enrol_mfa(self.user)
response = self.client.get(reverse("mfa_deactivate_totp"))
self.assertContains(response, "btn-error")
self.assertContains(response, 'type="submit"')
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.client.force_login(self.user)
self.response = self.client.get(reverse("account_logout"))
def test_sign_out_and_cancel_sit_side_by_side_with_icons(self):
html = self.response.content.decode()
cancel = html[html.index('<a class="btn btn-outline gap-2" href="/">') :]
sign_out = html[html.index('<button class="btn btn-primary gap-2"') :]
self.assertIn("<svg", cancel[: cancel.index("</a>")])
self.assertIn("<svg", sign_out[: sign_out.index("</button>")])
def test_cancel_does_not_sign_you_out(self):
# It is a link, not a submit: only the POST logs you out.
self.client.get("/")
self.assertTrue(self.client.session.get("_auth_user_id"))
def test_signing_out_still_works(self):
self.client.post(reverse("account_logout"))
self.assertIsNone(self.client.session.get("_auth_user_id"))
class ChangePasswordPageTests(TestCase):
@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"))
def test_the_fields_have_no_visible_labels(self):
# allauth gives each a placeholder, so the label would only repeat it.
self.assertNotContains(self.response, '<span class="label-text">Current Password</span>')
self.assertContains(self.response, 'name="oldpassword"')
self.assertContains(self.response, 'name="password1"')
def test_the_new_password_keeps_its_help_text(self):
self.assertContains(self.response, "id_password1_helptext")
def test_the_current_password_is_set_apart_from_the_new_one(self):
self.assertContains(self.response, "mt-10")
def test_forgot_password_is_an_accent_button_and_both_actions_have_icons(self):
html = self.response.content.decode()
forgot = html[html.index("btn-accent") :]
submit = html[html.index('class="btn btn-primary gap-2"') :]
self.assertIn("<svg", forgot[: forgot.index("</a>")])
self.assertIn("<svg", submit[: submit.index("</button>")])
class MfaButtonIconTests(TestCase):
"""Every button on the MFA screens carries an icon, and the recovery-code actions are
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):
# 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)
enrol_mfa(self.user)
RecoveryCodes.activate(self.user).instance.save()
def buttons(self, url):
"""Every <a class="btn"> / <button class="btn"> in the page body, minus the navbar."""
html = self.client.get(url, follow=True).content.decode()
body = html[html.index("<main") :]
return re.findall(r'<(?:a|button)[^>]*class="btn[^"]*"[^>]*>(.*?)</(?:a|button)>', body, re.S)
def test_every_button_on_the_manage_page_has_an_icon(self):
found = self.buttons(reverse("mfa_index"))
self.assertTrue(found)
for button in found:
self.assertIn("<svg", button)
def test_download_and_generate_are_outline_buttons(self):
html = self.client.get(reverse("mfa_index"), follow=True).content.decode()
self.assertEqual(html.count("btn-outline"), 2) # Download + Generate, not View
def test_the_panel_actions_are_spaced_off_the_body_text(self):
self.assertContains(self.client.get(reverse("mfa_index"), follow=True), "card-actions mt-4")
def test_every_button_on_the_deactivate_page_has_an_icon(self):
for button in self.buttons(reverse("mfa_deactivate_totp")):
self.assertIn("<svg", button)
def test_every_button_on_the_add_security_key_page_has_an_icon(self):
for button in self.buttons(reverse("mfa_add_webauthn")):
self.assertIn("<svg", button)
def test_the_activate_page_gives_the_code_box_no_visible_label(self):
Authenticator.objects.filter(user=self.user, type=Authenticator.Type.TOTP).delete()
response = self.client.get(reverse("mfa_activate_totp"), follow=True)
self.assertContains(response, "otp otp-lg")
self.assertNotContains(response, '<span class="label-text">Code</span>')

0
billing/__init__.py Normal file
View File

55
billing/admin.py Normal file
View File

@@ -0,0 +1,55 @@
from django.contrib import admin
from .models import Due, DuePayment, Plan, PlanPrice, Subscription
class PlanPriceInline(admin.TabularInline):
model = PlanPrice
extra = 0
@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 = [PlanPriceInline]
@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", "plan", "auto_renew", "auto_archive"]
list_filter = ["plan", "auto_renew", "auto_archive"]
search_fields = ["club__name"]
class DuePaymentInline(admin.TabularInline):
model = DuePayment
extra = 0
readonly_fields = ["recorded_by"]
@admin.register(Due)
class DueAdmin(admin.ModelAdmin):
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.
# 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]
@admin.register(DuePayment)
class DuePaymentAdmin(admin.ModelAdmin):
list_display = ["due", "amount", "method", "paid_at", "recorded_by"]
list_filter = ["method"]
search_fields = ["due__club__name", "reference"]

7
billing/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "billing"
verbose_name = "Billing"

View File

View File

View File

@@ -0,0 +1,38 @@
"""Archive clubs whose billing period has gone unpaid past its grace period.
Reports by default and only acts with --commit. That asymmetry is the point: this command
switches off paying customers, and a cron misconfiguration, a clock skew or a bad import
should cost you a confusing email, not a morning of angry clubs.
"""
from django.utils import timezone
from billing.services.dues import archivable_clubs
from features.commands import MaintenanceAwareCommand
class Command(MaintenanceAwareCommand):
help = "Archive clubs that are unpaid past their grace period (dry run unless --commit)."
def add_arguments(self, parser):
parser.add_argument("--commit", action="store_true", help="Actually archive them. Without this the command only reports.")
def handle(self, *args, **options):
today = timezone.localdate()
overdue = list(archivable_clubs(today))
if not overdue:
self.stdout.write(self.style.SUCCESS("Nothing overdue past grace."))
return
for due in overdue:
days = (today - due.grace_until).days
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."))
return
for due in overdue:
due.club.archive()
self.stdout.write(self.style.SUCCESS(f"\nArchived {len(overdue)} club(s). Their data is kept; restoring re-opens billing."))

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,138 @@
# Generated by Django 6.0.6 on 2026-07-13 23:40
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):
initial = True
dependencies = [
('club', '0013_club_created_club_modified_clubmembership_created_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Tier',
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')),
('slug', models.SlugField(blank=True, max_length=255, unique=True, verbose_name='slug')),
('description', models.TextField(blank=True, verbose_name='description')),
('is_active', models.BooleanField(default=True, help_text='Inactive tiers keep billing existing subscriptions but cannot be chosen for new ones.', verbose_name='active')),
],
options={
'verbose_name': 'tier',
'verbose_name_plural': 'tiers',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Due',
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.00'))], verbose_name='amount')),
('amount_paid', models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Kept in step with the payments by the billing service.', max_digits=10, verbose_name='amount paid')),
('period_start', models.DateField(verbose_name='period start')),
('period_end', models.DateField(blank=True, verbose_name='period end')),
('grace_until', models.DateField(blank=True, help_text='Past this date an unpaid club is archived.', verbose_name='grace until')),
('status', models.CharField(choices=[('unpaid', 'unpaid'), ('partial', 'partially paid'), ('paid', 'paid'), ('waived', 'waived'), ('cancelled', 'cancelled')], default='unpaid', max_length=20, verbose_name='status')),
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='paid at')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dues', to='club.club', verbose_name='club')),
('tier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dues', to='billing.tier', verbose_name='tier')),
],
options={
'verbose_name': 'due',
'verbose_name_plural': 'dues',
'ordering': ['-period_start', 'club__name'],
},
),
migrations.CreateModel(
name='DuePayment',
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'), ('card', 'card'), ('cash', 'cash'), ('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')),
('due', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='billing.due', verbose_name='due')),
('recorded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recorded_due_payments', to=settings.AUTH_USER_MODEL, verbose_name='recorded by')),
],
options={
'verbose_name': 'due payment',
'verbose_name_plural': 'due payments',
'ordering': ['-paid_at'],
},
),
migrations.CreateModel(
name='Invoice',
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=32, unique=True, verbose_name='number')),
('issued_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='issued at')),
('due', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='invoice', to='billing.due', verbose_name='due')),
],
options={
'verbose_name': 'invoice',
'verbose_name_plural': 'invoices',
'ordering': ['-issued_at'],
},
),
migrations.CreateModel(
name='Subscription',
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)),
('auto_archive', models.BooleanField(default=True, help_text='Archive this club when a period goes unpaid past its grace period.', verbose_name='auto archive')),
('notes', models.TextField(blank=True, verbose_name='notes')),
('club', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='club.club', verbose_name='club')),
('tier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='billing.tier', verbose_name='tier')),
],
options={
'verbose_name': 'subscription',
'verbose_name_plural': 'subscriptions',
'ordering': ['club__name'],
},
),
migrations.CreateModel(
name='TierPrice',
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)),
('active_from', models.DateField(help_text='Periods opening on or after this date are billed at this amount.', verbose_name='active from')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='amount')),
('tier', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='billing.tier', verbose_name='tier')),
],
options={
'verbose_name': 'tier price',
'verbose_name_plural': 'tier prices',
'ordering': ['tier__name', '-active_from'],
},
),
migrations.AddConstraint(
model_name='due',
constraint=models.UniqueConstraint(fields=('club', 'period_start'), name='unique_due_per_club_per_period'),
),
migrations.AddConstraint(
model_name='tierprice',
constraint=models.UniqueConstraint(fields=('tier', 'active_from'), name='unique_tier_price_per_start_date'),
),
]

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

370
billing/models.py Normal file
View File

@@ -0,0 +1,370 @@
"""What the platform charges a club.
Deliberately NOT club-scoped. `shop` is a club charging its members — tenant data, owned by
the club. This is RosterChief charging the club: platform-owned, and no club user ever sees
it. 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.
"""
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 _
from rosterchief.base import UUIDModel, unique_slugify
ZERO = Decimal("0.00")
#: 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_months(day: date, months: int) -> date:
return day + relativedelta.relativedelta(months=months)
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 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 = _("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)
super().save(*args, **kwargs)
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 plan had no price yet on that date. Callers must treat that as
"cannot bill", never as free.
"""
day = day or timezone.localdate()
price = self.prices.filter(active_from__lte=day).order_by("-active_from").first()
return price.amount if price else None
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.
"""
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 = _("plan price")
verbose_name_plural = _("plan prices")
ordering = ["plan__name", "-active_from"]
constraints = [
models.UniqueConstraint(fields=["plan", "active_from"], name="unique_plan_price_per_start_date"),
]
def __str__(self):
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"))
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.plan}"
class Due(UUIDModel):
"""One billing period for one club.
``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):
UNPAID = "unpaid", _("unpaid")
PARTIAL = "partial", _("partially paid")
PAID = "paid", _("paid")
WAIVED = "waived", _("waived")
CANCELLED = "cancelled", _("cancelled")
#: Statuses that still owe money.
OWING = (Status.UNPAID, Status.PARTIAL)
club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="dues", verbose_name=_("club"))
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. 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")
ordering = ["-period_start", "club__name"]
constraints = [
models.UniqueConstraint(fields=["club", "period_start"], name="unique_due_per_club_per_period"),
]
def __str__(self):
return f"{self.club}{self.period_start} to {self.period_end}"
def save(self, *args, **kwargs):
# 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_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)
@property
def balance(self) -> Decimal:
return self.amount - self.amount_paid
@property
def is_owing(self) -> bool:
return self.status in self.OWING
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 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."""
today = today or timezone.localdate()
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.
Several may land on one due: a club that pays in two transfers must not read as unpaid,
and the half that did arrive has to be recorded somewhere.
"""
class Method(models.TextChoices):
BANK_TRANSFER = "bank_transfer", _("bank transfer")
CARD = "card", _("card")
CASH = "cash", _("cash")
OTHER = "other", _("other")
due = models.ForeignKey(Due, on_delete=models.CASCADE, related_name="payments", verbose_name=_("due"))
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_due_payments", verbose_name=_("recorded by"))
class Meta:
verbose_name = _("due payment")
verbose_name_plural = _("due payments")
ordering = ["-paid_at"]
def __str__(self):
return f"{self.amount}{self.due}"
class Invoice(UUIDModel):
"""The bill for one period.
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.
"""
due = models.OneToOneField(Due, on_delete=models.CASCADE, related_name="invoice", verbose_name=_("due"))
number = models.CharField(_("number"), max_length=32, unique=True, blank=True)
issued_at = models.DateTimeField(_("issued at"), default=timezone.now)
class Meta:
verbose_name = _("invoice")
verbose_name_plural = _("invoices")
ordering = ["-issued_at"]
def __str__(self):
return self.number
def save(self, *args, **kwargs):
if not self.number:
self.number = self.next_number(self.issued_at.year)
super().save(*args, **kwargs)
@classmethod
def next_number(cls, year: int) -> str:
"""INV-2026-00001, restarting each year.
Platform-wide, unlike the shop's order numbers, which are per club: these are OUR
invoices, and one sequence has to cover every club we bill.
"""
prefix = f"INV-{year}-"
last = cls.objects.filter(number__startswith=prefix).order_by("-number").first()
sequence = int(last.number.removeprefix(prefix)) + 1 if last else 1
return f"{prefix}{sequence:05d}"

View File

@@ -0,0 +1,6 @@
class BillingError(Exception):
"""A billing action that must not silently half-happen.
Lives here rather than in dues.py so invoices.py can raise it without the two modules
importing each other in a circle.
"""

240
billing/services/dues.py Normal file
View File

@@ -0,0 +1,240 @@
"""The billing lifecycle. Views and the archive command go through here, never through the
models directly — a Due whose amount_paid disagrees with its payments is a wrong invoice.
"""
from datetime import date, timedelta
from decimal import Decimal
from django.db import transaction
from django.db.models import DateField, OuterRef, Subquery, Sum
from django.utils import timezone
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, 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.
The day after the last one ended — not today. A club that pays two months late has still
used those two months, and restarting the clock at the payment date would quietly gift
them away. Callers can override; that is what the start field on the renew form is for.
"""
today = today or timezone.localdate()
last = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()
return last.period_end + timedelta(days=1) if last else today
@transaction.atomic
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)
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 = plan.price_on(start)
if amount is None:
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, 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
@transaction.atomic
def record_payment(due: Due, amount: Decimal, *, method=DuePayment.Method.BANK_TRANSFER, reference: str = "", paid_at=None, note: str = "", user=None) -> DuePayment:
"""Log money against a due and re-derive its status from the payments."""
if due.status in (Due.Status.WAIVED, Due.Status.CANCELLED):
raise BillingError(f"This period is {due.get_status_display()}; it cannot take a payment.")
if amount <= ZERO:
raise BillingError("A payment must be for a positive amount.")
payment = DuePayment.objects.create(due=due, amount=amount, method=method, reference=reference, paid_at=paid_at or timezone.now(), note=note, recorded_by=user)
_resettle(due)
return payment
@transaction.atomic
def remove_payment(payment: DuePayment) -> None:
"""Undo a mis-keyed payment, then re-derive the due from what is left."""
due = payment.due
payment.delete()
_resettle(due)
def _resettle(due: Due) -> None:
"""Recompute amount_paid and status from the payments on record.
Summed from the payments rather than incremented: an increment drifts the moment a
payment is edited or deleted, and the drift is invisible — the number still looks like
money.
"""
paid = due.payments.aggregate(total=Sum("amount"))["total"] or ZERO
due.amount_paid = paid
if paid >= due.amount:
due.status = Due.Status.PAID
due.paid_at = due.payments.order_by("-paid_at").first().paid_at
elif paid > ZERO:
due.status = Due.Status.PARTIAL
due.paid_at = None
else:
due.status = Due.Status.UNPAID
due.paid_at = None
due.save(update_fields=["amount_paid", "status", "paid_at", "modified"])
@transaction.atomic
def waive(due: Due, *, note: str = "") -> Due:
"""Write a period off. It stops owing, and stops counting towards archiving."""
if due.payments.exists():
raise BillingError("This period has payments against it; remove them before waiving it.")
due.status = Due.Status.WAIVED
due.save(update_fields=["status", "modified"])
return due
def owing_dues():
return Due.objects.filter(status__in=Due.OWING)
def dues_in_grace(today: date | None = None):
"""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_start__lte=today, grace_until__gte=today)
def dues_overdue(today: date | None = None):
"""Past grace: these are the clubs the archive command would take down."""
today = today or timezone.localdate()
return owing_dues().filter(grace_until__lt=today)
def archivable_clubs(today: date | None = None):
"""Clubs the archive command would act on: overdue, still live, and opted in.
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", "plan").order_by("club__name")
@transaction.atomic
def reactivate(club, *, start: date | None = None) -> Due:
"""Bring an archived club back and bill it again.
The new period defaults to continuing from the last one, so a lapsed year is still owed.
Pass ``start`` to forgive the gap and begin today instead.
"""
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

@@ -0,0 +1,40 @@
"""Invoice PDFs.
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.
"""
from django.template.loader import render_to_string
from billing.models import Due, Invoice
from billing.services import BillingError
def issue_invoice(due: Due) -> Invoice:
"""One invoice per due, allocated once. Re-issuing returns the existing one rather than
burning a number — a gap in an invoice series is a question you do not want to answer."""
invoice, _created = Invoice.objects.get_or_create(due=due)
return invoice
def render_pdf(html: str) -> bytes:
"""HTML to PDF.
WeasyPrint is imported here, not at module scope: it binds to native pango/cairo
libraries, and a machine without them must still be able to run the app, the tests and
every other page — it should only fail when someone actually asks for a PDF, and say why.
"""
try:
from weasyprint import HTML
except (ImportError, OSError) as error:
raise BillingError("PDF rendering needs the native pango/cairo libraries (on macOS: brew install pango).") from error
return HTML(string=html).write_pdf()
def invoice_pdf(invoice: Invoice, base_url: str | None = None) -> bytes:
html = render_to_string("billing/invoice.html", {"invoice": invoice, "due": invoice.due, "club": invoice.due.club, "payments": invoice.due.payments.all()})
return render_pdf(html)

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

@@ -0,0 +1,106 @@
{% load static %}
{% comment %}
Rendered by WeasyPrint, not by a browser: this is a standalone document with its own
print stylesheet. It deliberately does NOT pull in app.css — daisyUI is built for a
screen, and half of it (dark theme, flex layouts) means nothing on paper.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ invoice.number }}</title>
<style>
@page {
size: A4;
margin: 20mm;
@bottom-center {
content: "RosterChief — invoice {{ invoice.number }} — page " counter(page) " of " 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; margin-bottom: 12mm; }
.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>RosterChief</h1>
<div class="muted">Club &amp; team management</div>
</div>
<div class="right">
<h1>Invoice</h1>
<div><strong>{{ invoice.number }}</strong></div>
<div class="muted">Issued {{ invoice.issued_at|date:"j F Y" }}</div>
</div>
</div>
<div class="parties">
<div>
<h2>Billed to</h2>
<div><strong>{{ club.official_name }}</strong></div>
<div class="muted">{{ club.slug }}.rosterchief.app</div>
</div>
<div class="right">
<h2>Period</h2>
<div>{{ due.period_start|date:"j F Y" }} — {{ due.period_end|date:"j F Y" }}</div>
<div class="muted">Payable by {{ due.grace_until|date:"j F Y" }}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th class="right">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<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>
</tr>
{% for payment in payments %}
<tr>
<td class="muted">
Payment received {{ payment.paid_at|date:"j M Y" }} ({{ payment.get_method_display }}{% if payment.reference %}, {{ payment.reference }}{% endif %})
</td>
<td class="right muted">−€{{ payment.amount|floatformat:2 }}</td>
</tr>
{% endfor %}
<tr class="total">
<td>Balance due</td>
<td class="right balance {% if due.balance > 0 %}owed{% else %}paid{% endif %}">€{{ due.balance|floatformat:2 }}</td>
</tr>
</tbody>
</table>
{% if due.status == "paid" %}
<p class="paid"><strong>Paid in full.</strong> Thank you.</p>
{% elif due.status == "waived" %}
<p class="muted"><strong>Waived.</strong> Nothing is owed for this period.</p>
{% else %}
<p class="muted">
Payable by <strong>{{ due.grace_until|date:"j F Y" }}</strong>. Unpaid past that date the club is archived: its
subdomain stops resolving, though nothing is deleted.
</p>
{% endif %}
</body>
</html>

980
billing/tests.py Normal file
View File

@@ -0,0 +1,980 @@
import datetime
import sys
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 authentication.models import User
from club.models import Club, ClubRole
from members.models import Member
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, 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):
# 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.
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, plan=self.plan)
class PlanPriceTests(BillingTestBase):
def test_the_price_in_force_is_the_latest_one_that_has_started(self):
PlanPrice.objects.create(plan=self.plan, active_from=self.today, amount=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):
PlanPrice.objects.create(plan=self.plan, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
self.assertEqual(self.plan.price_on(self.today), Decimal("500.00"))
def test_a_plan_with_no_price_yet_cannot_be_billed(self):
# None must never be read as free.
empty = Plan.objects.create(name="Enterprise")
self.assertIsNone(empty.price_on(self.today))
with self.assertRaises(BillingError):
open_period(self.club, plan=empty)
class PeriodTests(BillingTestBase):
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))
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_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,
# and restarting the clock at the payment date would quietly gift them away.
first = self.bill(start=self.today - datetime.timedelta(days=400))
self.assertEqual(next_period_start(self.club), first.period_end + datetime.timedelta(days=1))
def test_a_first_period_starts_today(self):
self.assertEqual(next_period_start(self.club), self.today)
def test_the_amount_is_snapshotted_at_the_price_of_the_day(self):
due = self.bill()
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.
self.assertEqual(due.amount, Decimal("500.00"))
def test_a_club_cannot_be_billed_twice_for_one_period(self):
self.bill(start=self.today)
with self.assertRaises(BillingError):
self.bill(start=self.today)
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_plan_and_opens_a_period(self):
club = Club.objects.create(name="Feyenoord")
subscribe(club, self.plan)
self.assertEqual(Subscription.objects.get(club=club).plan, self.plan)
self.assertEqual(club.dues.count(), 1)
class PaymentTests(BillingTestBase):
@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"))
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.PARTIAL)
self.assertEqual(self.due.balance, Decimal("300.00"))
self.assertIsNone(self.due.paid_at)
def test_payments_accumulate_until_the_due_is_settled(self):
record_payment(self.due, Decimal("200.00"))
record_payment(self.due, Decimal("300.00"))
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.PAID)
self.assertEqual(self.due.balance, Decimal("0.00"))
self.assertIsNotNone(self.due.paid_at)
def test_an_overpayment_still_settles_the_due(self):
record_payment(self.due, Decimal("600.00"))
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.PAID)
def test_removing_a_payment_re_derives_the_due(self):
# amount_paid is summed from the payments, never incremented: an increment drifts the
# moment one is deleted, and the drift still looks like money.
first = record_payment(self.due, Decimal("200.00"))
record_payment(self.due, Decimal("300.00"))
remove_payment(first)
self.due.refresh_from_db()
self.assertEqual(self.due.amount_paid, Decimal("300.00"))
self.assertEqual(self.due.status, Due.Status.PARTIAL)
def test_removing_the_only_payment_puts_the_due_back_to_unpaid(self):
payment = record_payment(self.due, Decimal("500.00"))
remove_payment(payment)
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.UNPAID)
self.assertEqual(self.due.amount_paid, Decimal("0.00"))
self.assertIsNone(self.due.paid_at)
def test_a_zero_payment_is_refused(self):
with self.assertRaises(BillingError):
record_payment(self.due, Decimal("0.00"))
def test_a_waived_period_cannot_take_a_payment(self):
waive(self.due)
with self.assertRaises(BillingError):
record_payment(self.due, Decimal("100.00"))
def test_a_period_with_payments_cannot_be_waived(self):
record_payment(self.due, Decimal("100.00"))
with self.assertRaises(BillingError):
waive(self.due)
def test_a_waived_period_owes_nothing_and_never_archives_a_club(self):
waive(self.due)
self.due.refresh_from_db()
self.assertFalse(self.due.is_owing)
self.assertFalse(self.due.is_overdue(self.due.grace_until + datetime.timedelta(days=1)))
class GraceAndArchiveTests(BillingTestBase):
LAPSED = DEFAULT_GRACE_DAYS + 10
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))
self.assertTrue(due.is_overdue(self.today))
self.assertFalse(due.is_in_grace(self.today))
self.assertIn(due, dues_overdue(self.today))
def test_a_paid_period_is_never_overdue(self):
due = self.bill(start=self.today - datetime.timedelta(days=self.LAPSED))
record_payment(due, Decimal("500.00"))
due.refresh_from_db()
self.assertFalse(due.is_overdue(self.today))
self.assertNotIn(due, dues_overdue(self.today))
def test_an_overdue_club_is_archivable(self):
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.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.plan, start=self.today - datetime.timedelta(days=self.LAPSED))
self.club.archive()
self.assertEqual(archivable_clubs(self.today).count(), 0)
class ArchiveCommandTests(BillingTestBase):
@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()
call_command("archive_overdue_clubs", *args, stdout=out)
return out.getvalue()
def test_it_reports_without_archiving_by_default(self):
# The asymmetry is the point: this switches off paying customers, so a cron
# misconfiguration or a clock skew must cost an email, not a morning of angry clubs.
output = self.run_command()
self.club.refresh_from_db()
self.assertFalse(self.club.is_archived)
self.assertIn("Dry run", output)
self.assertIn("Ajax United", output)
def test_it_archives_with_commit(self):
self.run_command("--commit")
self.club.refresh_from_db()
self.assertTrue(self.club.is_archived)
def test_it_says_so_when_nothing_is_overdue(self):
record_payment(self.club.dues.first(), Decimal("500.00"))
self.assertIn("Nothing overdue", self.run_command())
class ReactivationTests(BillingTestBase):
@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(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)
self.club.refresh_from_db()
self.assertFalse(self.club.is_archived)
self.assertEqual(due.period_start, self.first.period_end + datetime.timedelta(days=1))
def test_a_chosen_start_forgives_the_gap(self):
due = reactivate(self.club, start=self.today)
self.assertEqual(due.period_start, self.today)
class InvoiceTests(BillingTestBase):
def test_every_period_is_invoiced_when_it_opens(self):
due = self.bill()
self.assertTrue(Invoice.objects.filter(due=due).exists())
def test_numbers_run_in_one_platform_wide_series(self):
# 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"), plan=self.plan).invoice
year = timezone.now().year
self.assertEqual(first.number, f"INV-{year}-00001")
self.assertEqual(second.number, f"INV-{year}-00002")
def test_re_issuing_does_not_burn_a_number(self):
# A gap in an invoice series is a question you do not want to have to answer.
due = self.bill()
self.assertEqual(issue_invoice(due), due.invoice)
self.assertEqual(Invoice.objects.count(), 1)
def test_the_invoice_renders_the_frozen_snapshot(self):
due = self.bill()
record_payment(due, Decimal("200.00"), reference="TRX-9")
due.refresh_from_db()
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("INV-", html)
self.assertIn("Ajax United", html)
self.assertIn("500.00", html) # billed
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.
with mock.patch.dict(sys.modules, {"weasyprint": mock.MagicMock()}):
sys.modules["weasyprint"].HTML.return_value.write_pdf.return_value = b"%PDF-1.7"
self.assertEqual(render_pdf("<p>hi</p>"), b"%PDF-1.7")
def test_a_missing_pdf_library_says_what_is_missing(self):
with mock.patch.dict(sys.modules, {"weasyprint": None}), self.assertRaises(BillingError) as caught:
render_pdf("<p>hi</p>")
self.assertIn("pango", str(caught.exception))
class ModelStringTests(BillingTestBase):
def test_models_describe_themselves(self):
due = self.bill()
payment = record_payment(due, Decimal("10.00"))
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.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)

0
club/__init__.py Normal file
View File

90
club/admin.py Normal file
View File

@@ -0,0 +1,90 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
@admin.register(Club)
class ClubAdmin(admin.ModelAdmin):
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"]
list_filter = ["club"]
search_fields = ["club__name"]
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", "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", "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)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ["club__name", "member__last_name", "member__first_name", "role"]
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]

8
club/apps.py Normal file
View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class ClubConfig(AppConfig):
name = "club"
def ready(self):
from . import signals # noqa: F401

View File

@@ -0,0 +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 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.
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 = "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": 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,36 @@
# Generated by Django 6.0.6 on 2026-07-02 14:24
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Club',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
],
options={
'verbose_name': 'club',
'verbose_name_plural': 'clubs',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ClubMembership',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0.6 on 2026-07-05 13:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0003_alter_family_name_alter_familymembership_family_and_more'),
('club', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='clubmembership',
name='club',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='members', to='club.club'),
preserve_default=False,
),
migrations.AddField(
model_name='clubmembership',
name='license',
field=models.CharField(default=1, max_length=250),
preserve_default=False,
),
migrations.AddField(
model_name='clubmembership',
name='member',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='member_of', to='authentication.member'),
preserve_default=False,
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 6.0.6 on 2026-07-05 20:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0003_alter_family_name_alter_familymembership_family_and_more'),
('club', '0002_clubmembership_club_clubmembership_license_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='clubmembership',
options={'ordering': ['club', 'member__last_name', 'member__first_name'], 'verbose_name': 'club membership', 'verbose_name_plural': 'club memberships'},
),
migrations.AlterField(
model_name='club',
name='name',
field=models.CharField(max_length=255, verbose_name='name'),
),
migrations.AlterField(
model_name='clubmembership',
name='club',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='club.club', verbose_name='club'),
),
migrations.AlterField(
model_name='clubmembership',
name='license',
field=models.CharField(max_length=250, verbose_name='license'),
),
migrations.AlterField(
model_name='clubmembership',
name='member',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_of', to='authentication.member', verbose_name='member'),
),
migrations.AlterUniqueTogether(
name='clubmembership',
unique_together={('club', 'member')},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-07-05 20:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0003_alter_clubmembership_options_alter_club_name_and_more'),
]
operations = [
migrations.AlterField(
model_name='clubmembership',
name='license',
field=models.CharField(blank=True, max_length=250, verbose_name='license'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0.6 on 2026-07-11 22:09
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0004_alter_clubmembership_license'),
('members', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='clubmembership',
name='member',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_of', to='members.member', verbose_name='member'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0.6 on 2026-07-12 13:11
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0005_alter_clubmembership_member'),
]
operations = [
migrations.AddField(
model_name='club',
name='slug',
field=models.SlugField(blank=True, help_text='Drives subdomain / path resolution (e.g. ajax-united.clubmanager.app).', max_length=255, unique=True, verbose_name='slug'),
),
migrations.CreateModel(
name='Season',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('start_date', models.DateField(verbose_name='start date')),
('end_date', models.DateField(verbose_name='end date')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'season',
'verbose_name_plural': 'seasons',
},
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0.6 on 2026-07-12 13:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0006_club_slug_season'),
]
operations = [
migrations.AddField(
model_name='clubmembership',
name='season',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='club.season', verbose_name='season'),
preserve_default=False,
),
migrations.AlterField(
model_name='clubmembership',
name='club',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club'),
),
]

View File

@@ -0,0 +1,46 @@
# Generated by Django 6.0.6 on 2026-07-12 14:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0007_clubmembership_season_alter_clubmembership_club'),
('members', '0002_alter_familymembership_unique_together_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='clubmembership',
unique_together=set(),
),
migrations.AddField(
model_name='clubmembership',
name='activated_at',
field=models.DateField(blank=True, null=True, verbose_name='activated at'),
),
migrations.AddField(
model_name='clubmembership',
name='fee_status',
field=models.CharField(choices=[('unpaid', 'unpaid'), ('paid', 'paid'), ('partially_paid', 'partially paid'), ('waived', 'waived')], default='unpaid', max_length=250, verbose_name='fee status'),
),
migrations.AddField(
model_name='clubmembership',
name='signed_up_at',
field=models.DateField(blank=True, null=True, verbose_name='signed up at'),
),
migrations.AddField(
model_name='clubmembership',
name='status',
field=models.CharField(choices=[('active', 'active'), ('pending', 'pending'), ('lapsed', 'lapsed'), ('cancelled', 'cancelled')], default='pending', max_length=250, verbose_name='status'),
),
migrations.AddConstraint(
model_name='clubmembership',
constraint=models.UniqueConstraint(fields=('club', 'member', 'season'), name='unique_member_per_club_per_season'),
),
migrations.AddConstraint(
model_name='season',
constraint=models.UniqueConstraint(fields=('club', 'start_date', 'end_date'), name='unique_season_dates_per_club'),
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 6.0.6 on 2026-07-12 21:43
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0008_alter_clubmembership_unique_together_and_more'),
('members', '0002_alter_familymembership_unique_together_and_more'),
]
operations = [
migrations.CreateModel(
name='ClubRole',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('role', models.CharField(choices=[('admin', 'admin'), ('member', 'member'), ('editor', 'editor')], default='member', max_length=250, verbose_name='role')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roles', to='members.member', verbose_name='member')),
],
options={
'verbose_name': 'club role',
'verbose_name_plural': 'club roles',
'ordering': ['club', 'member__last_name', 'member__first_name'],
'constraints': [models.UniqueConstraint(fields=('club', 'member'), name='unique_member_per_club')],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-07-13 13:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0009_clubrole'),
]
operations = [
migrations.AddField(
model_name='club',
name='archived_at',
field=models.DateTimeField(blank=True, help_text='Archived clubs stop resolving on their subdomain, but their data is retained.', null=True, verbose_name='archived at'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-07-13 13:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0010_club_archived_at'),
]
operations = [
migrations.AlterField(
model_name='club',
name='slug',
field=models.SlugField(blank=True, help_text='Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app).', max_length=255, unique=True, verbose_name='slug'),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0.6 on 2026-07-13 17:33
import club.models
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0011_alter_club_slug'),
]
operations = [
migrations.AddField(
model_name='club',
name='logo',
field=models.ImageField(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, verbose_name='logo'),
),
migrations.AddField(
model_name='club',
name='primary_color',
field=models.CharField(blank=True, help_text="Hex colour for buttons and links on the club's pages, e.g. #1e40af.", max_length=7, validators=[django.core.validators.RegexValidator('^#[0-9a-fA-F]{6}$', 'Enter a colour as a hex value, e.g. #1e40af.')], verbose_name='primary colour'),
),
]

View File

@@ -0,0 +1,58 @@
# Generated by Django 6.0.6 on 2026-07-13 22:51
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0012_club_logo_club_primary_color'),
]
operations = [
migrations.AddField(
model_name='club',
name='created',
field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 565739, tzinfo=datetime.timezone.utc), verbose_name='created'),
preserve_default=False,
),
migrations.AddField(
model_name='club',
name='modified',
field=models.DateTimeField(auto_now=True, verbose_name='modified'),
),
migrations.AddField(
model_name='clubmembership',
name='created',
field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 565911, tzinfo=datetime.timezone.utc), verbose_name='created'),
preserve_default=False,
),
migrations.AddField(
model_name='clubmembership',
name='modified',
field=models.DateTimeField(auto_now=True, verbose_name='modified'),
),
migrations.AddField(
model_name='clubrole',
name='created',
field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 565939, tzinfo=datetime.timezone.utc), verbose_name='created'),
preserve_default=False,
),
migrations.AddField(
model_name='clubrole',
name='modified',
field=models.DateTimeField(auto_now=True, verbose_name='modified'),
),
migrations.AddField(
model_name='season',
name='created',
field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 565961, tzinfo=datetime.timezone.utc), verbose_name='created'),
preserve_default=False,
),
migrations.AddField(
model_name='season',
name='modified',
field=models.DateTimeField(auto_now=True, verbose_name='modified'),
),
]

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),
]

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