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>
This commit is contained in:
2026-08-09 21:39:19 +02:00
parent 86e28c317f
commit 309bd4d83e
54 changed files with 4574 additions and 88 deletions

View File

@@ -296,6 +296,27 @@ a `FamilyRole` (`parent` / `child` / `guardian` / `other`), `unique_together (fa
member)`; `Family.guardians` / `Family.children` are role-derived querysets. Powers the
"parents see their children's data" object-scope (§3.1).
**`Group`** *(built)* + **`GroupMembership`** — a generic, tenant-scoped, **opaque** named
collection of members: "all coaches", "all team managers", an ad-hoc committee. Deliberately
minimal (`name` + a through-membership, same shape as `Family`/`FamilyMembership`) — it
carries **no knowledge of any specific consumer** (not team-scoped, not referee-scoped, not
anything-scoped). Any feature wanting to use "a named set of people" for something specific
builds its own connective model elsewhere rather than teaching `Group` about that use case —
see `teams.RefereeProfile` (§5.2), which deliberately does **not** go through `Group` even
though an earlier draft of that feature did; referee eligibility is a fact about a *member*,
not about group membership.
```
Group(ClubScopedModel) # -> carries `club`
name CharField
Meta: UniqueConstraint(club, name)
GroupMembership(UUIDModel) # club implied by group
group FK Group (CASCADE, related_name="memberships")
member FK Member (CASCADE, related_name="group_memberships")
Meta: UniqueConstraint(group, member)
```
### `club`
**`Club`** — **the tenant root** (§2.4). Currently just `name`; extend with `slug` (unique,
@@ -436,6 +457,56 @@ number — modeled by `TeamMembership`, exactly matching the domain note.
- `StaffAssignment` drives the coach/manager object-scope (§3.13.2) — it *is* the "is a
coach of this team" fact; no `ClubRole` mirrors it.
**As built, `Team` also carries `referee_management`** (`TextChoices`: `club` | `federation`,
default `club`) — whether the *club* arranges referees for this team's home games, or the
*federation* does. A federation-managed team is left out of the referee tools **entirely**:
no eligibility, no assignment, no entry on the referee management dashboard (§5.3) — see
`events/services/referees.py::needs_referee_management(event)`, the single gate every
referee-facing screen reads through.
**`RefereeLevel`** *(built)* — a club-defined referee qualification tier ("Regional",
"National", ...), admin-managed like `Position` (own name, own ordering, no fixed list).
**Owns which teams it qualifies for** — eligibility is a property of the *level*, not of the
individual referee: a club configures a handful of levels once, each unlocking a tier of
teams, rather than hand-picking teams per referee.
```
RefereeLevel(ClubScopedModel) # -> carries `club`
name CharField
ordering PositiveSmallIntegerField (default=0)
teams M2M Team (blank=True, related_name="referee_levels")
Meta: UniqueConstraint(club, name); ordering = ["ordering", "name"]
```
**`RefereeProfile`** *(built)* — a **member-level** fact: which level this member holds and
how long it's valid for. Which teams that translates to is *derived* (`eligible_teams`),
never picked per member. Managed from the member's own page (`management`), read (not
edited) from the team's own page too. Deliberately **not** routed through `members.Group`
eligibility is a property of a person, not of a group they might belong to; see the note on
`Group` above for why an earlier draft that did this was reworked. It also deliberately does
**not** put `teams` directly on the profile — a later draft of this feature did that too,
before the levels-own-the-teams shape replaced it, matching how real officiating
qualifications actually work (a certification tier unlocks a tier of competitions).
```
RefereeProfile(UUIDModel) # club reachable via member -- Member itself has no club FK
member OneToOneField members.Member (CASCADE, related_name="referee_profile")
level FK RefereeLevel (PROTECT, null=True, blank=True, related_name="referees")
valid_until DateField (null=True, blank=True)
```
- **`is_currently_valid`** (property): `valid_until` is set and hasn't passed — a pure date
check, independent of whether a level is even set.
- **`is_eligible`** (property): the full gate every consumer reads through (the event assign
panel, the team page, the referees list) — `level` is set **and** `is_currently_valid`.
Once `valid_until` passes, `is_eligible` flips to `False` and the referee drops out of
every eligibility query until the date is extended; nothing else needs to change.
- **`eligible_teams`** (property): `level.teams.all()` when `is_eligible`, else empty.
- One `RefereeProfile` per member (`OneToOneField`) rather than a field bag on `Member`
itself, matching this file's general pattern of keeping `Member` a plain identity record
and hanging every role-specific fact off its own small table (`ClubMembership`,
`StaffAssignment`, `TeamMembership`, and now this).
### 5.3 `events`
```
@@ -470,6 +541,78 @@ row today — there's no check-in UI yet, only Django admin); a "no-show" is
a missing check-in. See `events/services/attendance.py::record_check_in` and
`management/views.py::TeamDetailView`'s attendance panel.
**As built, `Event` also carries `max_referees`** (`PositiveSmallIntegerField`, default
`2`) and **`EventReferee`** *(built)* — referee sign-up/assignment for a **home game**
only (`Event.is_home_game`), staff-assigned for now (self-service subscribe is a planned
extension, §7). A referee row is either a club member **or** an externally-logged name
(e.g. a federation-appointed referee the club still needs to pay), never both/neither, and
carries its own payment snapshot:
```
EventReferee(UUIDModel) # club implied by event
event FK Event (CASCADE, related_name="referees")
member FK Member (CASCADE, null=True, blank=True, related_name="referee_assignments")
external_name CharField (blank=True) # set instead of member for a non-member referee
assigned_by FK Member (SET_NULL, null=True, related_name="+")
fee DecimalField (default 0.00)
km DecimalField (null=True, blank=True)
km_rate DecimalField (null=True, blank=True) # snapshotted per assignment, not a
# live club-wide setting
Meta: unique_together (event, member); CheckConstraint XOR(member, external_name)
display_name / is_external / km_total / total_payable # computed properties
```
- **Eligibility** comes from `teams.RefereeProfile.is_eligible`/`eligible_teams` (§5.2): a
member is eligible to referee an event if their profile is currently eligible (a level is
set and its validity hasn't passed) and that level qualifies for one of the event's
`teams`. `events/services/referees.py::eligible_referees(event)` computes this, and is
empty for anything `needs_referee_management(event)` says no to — not a home game, or a
home game whose team(s) are all federation-managed (§5.2). External referees bypass
eligibility entirely (`add_external_referee`) — they're logged by name only, not vetted
against a level.
- **Assignment is admin-only for now**, stricter than most event actions (a team
manager/coach can edit the event itself, but not the referee panel's assign/remove/fee
controls) — see `EventRefereeAssignView`/`EventRefereeRemoveView`/
`EventRefereeAddExternalView`/`EventRefereeFeeUpdateView` (all `ClubAdminRequiredMixin`)
and `EventDetailView`'s separate `can_manage_referees` flag. A team manager still **sees**
the panel (who's assigned, capacity, fees) — visibility and authority are deliberately
split here, same reasoning as §3's "coach visibility ≠ coach authority" for team rosters.
- **The referee management dashboard** (`management:referee_management`, admin-only) is the
one-stop alternative to hunting through individual events: every upcoming home game
`needs_referee_management`, with inline assign/remove/add-external/fee-editing (posting to
the same views the event detail page uses, returning to the dashboard via a `next` param
rather than the event detail page). It leads with KPI tiles (games in view, without a
referee, partially staffed, fully staffed) and a button-based range filter (this
week/this+next week/next 10/25/50 — an ISO-week window for the calendar options, a flat
slice for the count ones), then lists games grouped by date as compact tiles; each tile's
"Manage" button opens a `<dialog>` with the full assign/remove/external/fee panel so the
list itself stays scannable. Both the dashboard and the event detail page share one
`_referee_assignment_panel.html` include so this UI never drifts out of sync between them.
- **`max_referees` is a hard ceiling everywhere** — staff and external assignment included.
Enforced in `_lock_and_check_capacity()` (shared by `assign_referee()` and
`add_external_referee()`), which locks the `Event` row (`select_for_update`) for the
duration of the count-check + write so two admins assigning at the same moment can't both
squeeze past the ceiling.
- **Schedule conflicts are a soft warning, never a block.** `conflicting_events(member,
event)` finds other events overlapping this one's time window where the member is part of
the expected audience (`effective_members`, reused from the attendance service above) — the
UI shows it (⚠ + tooltip on the assign control) but a human decides; an event with no
explicit `end` is assumed to run `ASSUMED_EVENT_DURATION` (2 hours) for this check only,
never written back to the event. External referees have no conflict check (no member to
check a schedule against).
- **`assigned_by` is required for now** (admin-only assignment). A future self-service
sign-up would make it nullable to mean "the referee signed themself up" rather than adding
a parallel model — see §7.
- **The referee payment form is a downloadable PDF** (`event_referee_form_pdf`,
`EventRefereeFormPdfView`, admin-only, WeasyPrint via `management/pdf.py`'s lazy-import
pattern), modeled directly on the club's existing paper form: game details, referee names,
a fee+km breakdown per referee, and blank signature lines (referee always; team manager
left blank — not reliably known at print time). The header uses `Club.official_name`
(`legal_name` if the club has set one, else plain `name` — §2.2) and the club's home
`Location` address; the body's payment sentence uses the plain `name` — mirroring the
original paper form, which itself uses a longer legal form up top and a shorter one in the
body text.
### 5.4 `news`, `pages`, `home` (public site / editorial)
**`news` is built** (as of the coach_manager-authoring / editor-release-flow work) —
@@ -789,9 +932,13 @@ User 1───< Member (FK, unique per club) # User is GLOBAL — no clu
├───< TeamMembership >─── Team ───> Season
├───< StaffAssignment >─── Team (= "coach of this team", §3.2)
├───< GroupMembership >─── Group (opaque -- no team/referee link)
├─1:1─ RefereeProfile ──> RefereeLevel >──< Team
│ (profile's valid_until gates eligibility; level owns teams)
├───< Attendance >─── Event ───> Season
│ └───> Team (nullable)
├───< EventReferee >─── Event (assigned_by another Member; home games only)
├───< Submission >─── Form ───< Field (Submission ──< Answer >── Field)
@@ -863,6 +1010,12 @@ specified in **§8**.
(checkout-date anchor, recommended, frozen total) or by *paying* before it (payment-date
anchor, mutable total)? Doc implements checkout-date; confirm no club needs the literal
"paid before date" semantics (§5.7.1).
- **Referee self-service sign-up** — `EventReferee` (§5.3) is admin-assigned only for now
(a team manager/coach can see the panel but not use it); a referee cannot yet subscribe
themself to a game. Adding it later means making `assigned_by` nullable (null =
self-subscribed) and a permission mixin scoping a referee to their own eligible games — no
new model needed. Not built because this app has no self-service (member-facing) surface
of any kind yet; the first one deserves its own pass rather than riding along here.
---