Commit Graph

207 Commits

Author SHA1 Message Date
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