# RosterChief — Model & Domain Architecture

Baseline reference for implementing the domain models. This describes the **intended
shape** of the data model: what exists today, what is planned, and the conventions every
app should follow. It is a living document — update it when the model changes.

> **Tenancy: multi-tenant (row-based / shared-schema).** RosterChief is designed as a
> **multi-tenant platform** — one deployment serves many clubs, with **`Club` as the tenant
> root**. Isolation is **row-based**: a shared database and schema where every club-owned
> row carries a `club` FK (via `ClubScopedModel`), and *all* access is scoped to the
> current tenant. The mechanics — tenant resolution, scoping manager, per-club uniqueness —
> are specified in **§2.4**.
>
> ⚠️ **This supersedes `CLAUDE.md`**, which currently states the app is "deliberately *not*
> multi-tenant … there is no `club_id` tenancy." That guidance and the project memory are
> now **out of date** and must be updated to match this document — see the **banner at the
> foot of this file**. Where the two disagree, this architecture is the intended direction.

---

## 1. App decomposition

The `isort` `known-first-party` roadmap lists nine apps. The build split the original
`accounts` app into `authentication` + `club`. Per the decisions in §7, the people models
(`Member`, `Family`) **move out of `authentication` into a dedicated `members` app**, and
two apps (`formbuilder`, `shop`) are added **beyond the original roadmap** — add all new
labels to `known-first-party` in `pyproject.toml` when they land. The target decomposition:

> Several rows below (`teams`, `events`, `members`, `club`'s `Season`/`ClubRole`) are marked
> "planned" but have since been built out considerably further than this table reflects —
> it hasn't been kept in lockstep with every session's work. Only `formbuilder` and the new
> `evaluations` row have been corrected here; treat the rest as directional, not current,
> and verify against the actual tree (per `CLAUDE.md`) before relying on a "planned" marker.

| App              | Status       | Responsibility                                            | Models |
|------------------|--------------|-----------------------------------------------------------|--------|
| `authentication` | **built**    | Login identity + tenancy/role services (global, cross-club) | `User` |
| `members`        | **planned**  | People: person records, families (extract from `authentication`) | `Member`, `Family`, `FamilyMembership` |
| `club`           | **built**    | Tenant root, **season**, season-scoped affiliation, club roles | `Club`, `Season` *(planned)*, `ClubMembership`, `ClubRole` *(planned)* |
| `teams`          | planned      | Teams and season rosters                                  | `Team`, `TeamMembership`, `StaffAssignment` |
| `events`         | planned      | Training / matches / social events + attendance           | `Event`, `Attendance` |
| `news`           | **built**    | Club news: coach_manager-authored, editor-released         | `News`, `NewsPhoto` |
| `pages`          | planned      | Flat CMS pages for the public site                        | `Page` |
| `home`           | planned      | Homepage composition / featured content                   | `HomeConfig` (per-club) or config-only |
| `formbuilder`    | **partial**  | Admin-defined dynamic forms + submissions + reporting — models, dynamic-form-class builder, and the submit service are built (§5.6); no view/template renders a form for someone to fill in yet | `Form`, `Field`, `Submission`, `Answer` |
| `shop`           | planned      | Cart-like shop, orders, payments, PDF invoices            | `Product`, `Cart`, `CartItem`, `Order`, `OrderLine`, `Payment`, `Invoice` |
| `search`         | planned      | Site search (likely no models; index/config only)         | — |
| `evaluations`    | **design only** | Player evaluations: customizable rubric (reuses `formbuilder`), restricted to a new `EVALUATOR` role/ADMIN, player profile (skills + attendance + notes) (§5.8) | `EvaluationSettings`, `PlayerEvaluation` |

**`User` stays global** (one login identity across the whole platform); everything else
that belongs to a club is tenant-scoped (§2.4). This is why `Member` — a *person within a
club* — lives in its own app and carries a `club` FK, while `User` does not.

**Open decision (§7):** whether to migrate `Member`/`Family` out of `authentication` into
a dedicated `members` app. Recommendation: keep them in `authentication` for now; revisit
only if the app grows unwieldy. The roadmap `members` name is reserved either way.

---

## 2. Shared conventions

These are already established in code — every new model follows them.

- **UUID primary keys.** Inherit `rosterchief.base.UUIDModel` (`id = UUIDField(default=uuid4)`).
  Never expose sequential integer PKs.
- **`ClubScopedModel`** (`rosterchief.base`) adds the tenant `club` FK
  (`related_name="%(class)ss"`) and, under multi-tenancy, a tenant-aware manager + auto
  club-stamping `save()` (§2.4). **Every aggregate-root model inherits it**; leaf rows
  reachable only via a scoped parent (e.g. `Attendance` via `Event`) may inherit scope from
  the parent, though denormalising `club` onto them is recommended (§2.4).
- **i18n everywhere.** Every field gets a `gettext_lazy` verbose name; every model sets
  `verbose_name` / `verbose_name_plural` and a sensible `Meta.ordering`. `TextChoices`
  labels are translated too.
- **`__str__` on every model**, human-readable.
- **Enumerations** use nested `models.TextChoices` (see `FamilyMembership.FamilyRole`).
- **Phone numbers** use `PhoneNumberField` (from `django-phonenumber-field`), nullable.
- **Through models** for many-to-many relationships that carry data (role, jersey number,
  attendance status) — never a bare `ManyToManyField` when the link has attributes.
- **Business logic in `services/`**, not fat models or views (see
  `authentication/services/member_csv_importer.py`). Management commands are thin wrappers
  over services (see `import_members_csv`).
- **Migrations are generated, not hand-edited** (ruff-excluded).

### Naming & relations
- `related_name` is explicit and plural on the "many" side, chosen to read naturally from
  the parent (`club.members`, `family.memberships`, `member.family_memberships`).
- Deletion policy is deliberate per FK: `CASCADE` for owned children,
  `SET_NULL`(+`null=True`) where the child should survive its parent (e.g.
  `Member.user`), `PROTECT` for references that must not silently disappear (planned:
  `Season` on rosters/events — see §5).

### 2.4 Multi-tenancy (row-based, shared schema)

`Club` is the **tenant root**. One deployment, one database, one schema; tenants are
separated by a `club` FK on every owned row and by disciplined scoping of every query.
This is the lightest multi-tenancy model and matches the existing `ClubScopedModel`
scaffolding — no Postgres schemas, no per-tenant databases, no `django-tenants`.

**What gets a `club` FK.** Every *aggregate root* inherits `ClubScopedModel` and so carries
`club` (`Member`, `Family`, `Season`, `Team`, `Event`, `ClubRole`, `Article`, `Category`,
`Page`, `Form`, `Product`, `Cart`, `Order`, `Invoice`, …). Leaf rows reachable only through
a scoped parent (`Answer`→`Submission`, `OrderLine`/`Payment`→`Order`, `CartItem`→`Cart`,
`Attendance`→`Event`, `TeamMembership`/`StaffAssignment`→`Team`) may inherit scope via the
parent — but **denormalising `club` onto them too is recommended** for leak-proof filtering
and DB-level constraints. `User` is the **only** global identity model; it has no `club`.

**People vs. logins under tenancy.** A `User` is one platform-wide login that may belong to
several clubs; a `Member` is that person *within one club*. So:
- `Member.user` becomes a **`ForeignKey`** (not `OneToOneField`) — one user → many members
  (at most one per club): `unique_together (club, user)`.
- `User.get_full_name()` can no longer assume a single member; resolve the member **for the
  current club** (via the tenant context below), falling back to email.

**Tenant resolution → `request.club`.** A `ClubTenantMiddleware` resolves the active club
per request (recommended: **subdomain**, `ajax-united.rosterchief.app`; path-prefix
`/c/<slug>/` is the alternative) and stores it on `request.club` *and* in a context
variable so non-request code (services, management commands) can read it:

```
# rosterchief/tenancy.py
from contextvars import ContextVar
_current_club: ContextVar = ContextVar("current_club", default=None)

def set_current_club(club):  _current_club.set(club)
def get_current_club():      return _current_club.get()
def require_current_club():                        # raises if unset — use in write paths
    club = _current_club.get()
    if club is None: raise RuntimeError("No active club in context")
    return club

class ClubTenantMiddleware:                        # resolves subdomain -> Club, sets both
    ...  # request.club = club; set_current_club(club)
```

**Considered — `django.contrib.sites` for resolution (rejected as the mechanism).**
Django's Sites framework is the obvious "does the batteries-included answer fit?" candidate,
so it was evaluated explicitly:

*What it offers.* A `Site(domain, name)` model, `get_current_site(request)` (Host-header →
`Site`, with a per-process `SITE_CACHE`), `CurrentSiteMiddleware` (sets `request.site`), and
`CurrentSiteManager` (auto-filters models that hold an FK to `Site`). Ecosystem code
(`flatpages`, `redirects`, `sitemaps`, `allauth`) is Site-aware for free.

*Why it does **not** fit as our tenancy mechanism:*
- **Wrong scoping key.** `CurrentSiteManager` filters on a `site` FK; our tenant key is the
  `club` FK on `ClubScopedModel`. Adopting Sites' manager would mean putting a *second* FK on
  every model, or ignoring the manager — either way it buys us nothing over `.for_club()`.
- **A parallel identity table.** `Site` duplicates identity that already lives on `Club`
  (`slug`, domain, name). Two tables to keep in sync, two sources of truth for "which tenant".
- **`SITE_ID` is a single global.** The framework's happy path is *one process = one site*
  (`SITE_ID`). Multi-tenant host resolution requires leaving `SITE_ID` unset and relying on
  `get_current_site`'s exact-domain match — workable, but the setting is a standing foot-gun
  (any library that reads `SITE_ID` silently binds to the wrong tenant), and shells, tasks,
  and tests have no Host header, so they still need our contextvar (`require_current_club()`).
- **Host-only.** Sites cannot express the path-prefix option (`/c/<slug>/`); resolution is
  purely `domain`-based, foreclosing that alternative.

*Verdict.* Keep **`Club` (with `slug` + optional `domain`) as the single tenant root** and
resolve it in `ClubTenantMiddleware` — the resolution logic (Host → `Club`) is a few lines and
avoids the sync/`SITE_ID` hazards. **Optional bridge:** if a Site-aware third party is later
adopted (e.g. `allauth`, `sitemaps`), add a thin `Club.site = OneToOneField(Site)` kept in sync
from `Club.save()`, so the ecosystem gets its `Site` while `Club` stays authoritative — do
this only when such a dependency actually lands, not preemptively.

**Scoping manager.** `ClubScopedModel` gets a tenant-aware manager so day-to-day queries
can't accidentally cross tenants:

```
class TenantQuerySet(models.QuerySet):
    def for_club(self, club): return self.filter(club=club)
    def current(self):        return self.filter(club=require_current_club())

class ClubScopedModel(UUIDModel):
    club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="%(class)ss")
    objects = TenantQuerySet.as_manager()
    def save(self, *args, **kwargs):               # auto-stamp club from context if unset
        if self.club_id is None: self.club = require_current_club()
        super().save(*args, **kwargs)
    class Meta: abstract = True
```

Prefer **explicit** `.for_club(club)` / `.current()` in views and services over a fully
automatic global filter — auto-filtering via context state is convenient but hides tenant
boundaries and bites hard in shells, tasks, and tests. Keep scoping visible.

**Per-club uniqueness.** Every constraint that was globally unique becomes **unique per
club**. Concretely: `Season.name`, all public `slug`s (`Article`, `Page`, `Form`,
`Product`), `Team (season, name)`, jersey numbers `(team, jersey_number)`, and the
human-readable counters `Order.number` / `Invoice.number` are scoped by / allocated per
club. A bare `unique=True` on a tenant model is almost always a bug — use
`UniqueConstraint(fields=["club", …])`.

**Cross-cutting consequences (checklist for every feature):**
- Admin: register a `club` list-filter and scope `get_queryset` for non-superusers.
- Roles are **per-club** — see §3 (global Django Groups don't fit; use `ClubRole`).
- Sequential numbers (invoices) allocate per club inside a transaction — never `count()+1`.
- Tests must set a current club (a `with_club(club)` context-manager helper).
- Config: wildcard host + CSRF for subdomains; see §8.

---

## 3. Roles & access control (RBAC)

Authorization is **service-layer, and per-club** (§2.4). Because roles differ per tenant —
a user can be a treasurer at club A and merely a member at club B — global Django `Group`s
(which are platform-wide) **do not fit**. Roles are stored as tenant-scoped `ClubRole` rows
and *all* permission decisions go through a single service module; no `django-guardian`,
no per-club Group hacks. Django's own permission framework is retained **only** for the
platform-operator layer (`is_staff` / `is_superuser` in Django admin).

### 3.1 Two layers

1. **Platform operators** — `User.is_superuser` / `is_staff`. Global, cross-club; run the
   Django admin, manage the tenant list. Not a club role.
2. **Club roles** — a member's standing *within one club*, stored per tenant. Two kinds:
   - **Club-wide roles** → `ClubRole` rows (below).
   - **Object-scoped roles** → derived from the domain graph, no extra rows:
     - Coach / manager **of a specific team** → `StaffAssignment(team, member, role)`.
     - Parent / guardian **of a specific member** → `FamilyMembership` + the family graph.
     - Purchaser vs. beneficiary → `Order`/`OrderLine` + `ClubMembership`.

### 3.2 `ClubRole` — club-wide role assignments  *(app: `club`)*

```
ClubRole(ClubScopedModel)              # ClubScopedModel -> carries `club` (§2.4)
  member  FK Member (CASCADE, related_name="roles")
  role    CharField (TextChoices: MEMBER | EDITOR | TREASURER | BOARD | EVALUATOR)
  Meta: unique_together (club, member, role)
```

> ⚠️ **This table is aspirational, not current.** The actually-implemented
> `ClubRole.Roles` (`club/models.py`) is `ADMIN | MEMBER | EDITOR | MEMBER_ADMIN` — no
> `TREASURER`/`BOARD` yet, and `MEMBER_ADMIN` (full read/write on people, short of
> Finance/Club identity/role-granting — see `club.services.access.can_manage_members`)
> isn't reflected below either. Treat this table as the target shape; verify the real
> enum before writing code against it. `EVALUATOR` (§5.8) is **new, not yet added** to
> either the aspirational list here or the real code enum.

| Role         | Grants (representative)                                                       |
|--------------|--------------------------------------------------------------------------------|
| *Public*     | Anonymous — no row; read-only public site of that club.                       |
| `MEMBER`     | View own + family data, own rosters/attendance, own orders/invoices, submit member-only forms. |
| `EDITOR`     | Manage that club's `news`, `pages`, `formbuilder` content.                     |
| `TREASURER`  | Manage that club's `shop`: products, orders, payments, issue/void invoices.    |
| `BOARD`      | Full management of that club: members, roles, all of the above.                |
| `EVALUATOR`  | Write + view **every** player evaluation club-wide (§5.8) — independent of `MEMBER_ADMIN`/coach `StaffAssignment`, since evaluating isn't the same trust boundary as either (a technical director might get this without full people-management access; a team's own coach doesn't get it just for coaching that team — see §5.8's own access note). Never granted to the evaluated player or their guardians. |

`news` is the one place a `ClubRole` and a derived role (`COACH_MANAGER`, see below)
share a single workflow rather than each owning a separate permission: drafting is
open to EDITOR/ADMIN *or* any coach_manager, but only EDITOR/ADMIN may publish —
see §5.4.

`COACH` / `TEAM_MANAGER` are deliberately **not** `ClubRole`s — being a coach is always
*of a team*, so it lives on `StaffAssignment` (§5.3). "Is this user a coach at this club?"
= "do they have any `StaffAssignment` on a team in this club?".

### 3.3 The permission service

One module — `club/services/access.py` (or `authentication/services/access.py`) — answers
every authorization question, always taking the club/object as an argument:

```
has_club_role(user, club, role)      -> bool      # ClubRole lookup
roles_in_club(user, club)            -> set[str]  # incl. derived COACH/MANAGER
teams_managed_by(user, club)         -> QuerySet[Team]
members_visible_to(user, club)       -> QuerySet[Member]   # self + family + managed teams
can_edit_event(user, event)          -> bool
can_manage_shop(user, club)          -> bool      # TREASURER or BOARD
```

Views, admin, and templates call these — never re-derive access inline. Each helper scopes
to the given club (§2.4), so a user's powers in club A never leak into club B.

### 3.4 Keeping roles in sync with domain state

`ClubRole` membership is **reconciled from domain facts**, not hand-assigned:

- An **active** `ClubMembership` for the club's current season → grant the `MEMBER` role;
  a lapsed one → revoke. (Season-scoped membership: §5.1.)
- A `StaffAssignment` needs no `ClubRole` — coach status is derived (§3.2).

Implement as `club/services/access.py::reconcile_roles(user, club)`, invoked on the state
changes that matter (membership activation/lapse), so authorization never drifts from data.

---

## 4. Built models (as-is, + planned tenancy changes)

### `authentication`

**`User`** — custom auth model, email is the login (`USERNAME_FIELD = "email"`, no
username). UUID PK. `objects = UserManager()` (email-based `create_user` /
`create_superuser`). **Stays global — the one model with no `club` FK** (§2.4). Because a
user may belong to several clubs, `get_full_name` / `get_short_name` resolve the `Member`
**for the current club** (via tenant context), falling back to email — they can no longer
assume a single member.

### `members` *(planned — extract from `authentication`)*

`Member`, `Family`, `FamilyMembership` **move here** and become tenant-scoped
(`ClubScopedModel`).

**`Member`** — a *person within one club*. `user` becomes a **`ForeignKey`** (was
`OneToOneField`), still nullable (`on_delete=SET_NULL`), so one login maps to one member
*per club* (`unique_together (club, user)`) and members can exist without logins (children,
imports). Holds name, DOB, contact `email`/`phone`/`emergency_phone`. `contact_email`
prefers the member's own email, else the login email. `guardians` returns parent/guardian
members reachable through shared families (all within the same club).

**`Family`** + **`FamilyMembership`** — households, tenant-scoped. `FamilyMembership` carries
a `FamilyRole` (`parent` / `child` / `guardian` / `other`), `unique_together (family,
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,
drives subdomain/path resolution), contact, and branding. Provide `Club.objects.current()`
and resolve it in middleware — never hardcode a PK.

**`ClubMembership`** — links a `Member` to the `Club` with a `license` string. **Being made
season-scoped** (§5.1): it gains a `season` FK and sign-up / fee-status fields, so each row
is one member's affiliation for one season (`unique_together (club, member, season)`). This
is the record the `MEMBER` role and shop fulfilment key off of (§3.4, §5.7).

### `billing` — what the platform charges a club

**Deliberately NOT club-scoped, and the only app that isn't.** `shop` (§5.7) is a club charging
its *members* — tenant data, owned by the club. `billing` is RosterChief charging the *club*:
platform-owned, never visible to a club user except as the one notice described below. Nothing
here inherits `ClubScopedModel` — these rows reference a `Club`, they are not owned by one, and a
tenant-scoped manager would be exactly the wrong default.

**`Plan`** — a duration and three clocks, named for what they measure *from*, which is the easy
thing to get wrong: `duration_months` (period length, from its start), `renewal_lead_days` (how far
*before* a period starts its invoice is raised), `grace_days` (how long *after* a period starts it
may stay unpaid). Two `CheckConstraint`s keep them coherent. `is_trial` marks a plan offered as a
trial; a trial's length is simply its own `duration_months`. `deleted_at` is a soft-delete marker:
`Due.plan` is `PROTECT`, so a plan that has ever billed anyone can't really be removed — "delete"
hides it (`Plan.objects.visible()` excludes it) and unsubscribes every club currently on it instead;
see `billing/services/plans.py` and `BILLING.md` §11.

**`PlanPrice`** — a dated price (`active_from`). A rate change is a new row, never an edit, so
every period already opened keeps what it was billed at.

**`Subscription`** — one per club (`OneToOneField`): its current `plan`, `auto_renew`,
`auto_archive`, and the trial pair (`trial_ends_at` + `post_trial_plan`, constrained to be set
together or not at all).

**`Due`** — one billing period for one club, and **the snapshot boundary**. `plan`, `amount`,
`period_end` and `grace_until` are all frozen when the period opens and never read back through
the plan at display time: raise a price or edit a plan's grace and last year's invoice must still
say what was actually charged. Storing the computed *dates* rather than the plan's *numbers* is
what buys that.

**`DuePayment`** / **`Invoice`** — money received against a due (several may land on one), and the
gapless per-year invoice number. The PDF itself is rendered on demand from the `Due` snapshot;
only the number is stored.

All lifecycle changes go through `billing/services/` — `dues.py` (open, renew, pay, waive,
archive), `notices.py` (the one club-facing warning), `reminders.py` (its email). Never through
the models directly: a `Due` whose `amount_paid` disagrees with its payments is a wrong invoice.

**`BILLING.md` is the authoritative document for this app** — the lifecycle, the worked timelines,
and the migration hazards live there rather than here.

---

## 5. Planned models (design)

Field lists below are **sketches** to implement against, not final migrations.

All sketches below are tenant-scoped: aggregate roots inherit **`ClubScopedModel`** (the
`club` FK is shown implicitly and every listed `unique_together` is *within a club*, §2.4).

### 5.1 `Season` — the central organizing concept  *(app: `club`)*

Everything time-bound hangs off a season. Rosters, events, and attendance are
**season-scoped via FK** — never global state. Seasons are **per club** — each club runs
its own.

```
Season(ClubScopedModel)              # -> carries `club`
  name        CharField           # e.g. "2025–2026"
  start_date  DateField
  end_date    DateField
  is_current  BooleanField        # exactly one true PER CLUB; enforce in save()/service
  Meta: unique_together (club, name); ordering = ["-start_date"]; get_latest_by = "start_date"
```

- Provide `Season.objects.current()` (scoped to the current club, §2.4) rather than
  scattering `is_current=True` filters.
- Referenced by `Team`, `Event`, and `ClubMembership`. Use `on_delete=PROTECT` on those
  FKs — deleting a season with data should be blocked.

**Season-scoped `ClubMembership`** (evolution of the built model, §4):

```
ClubMembership(ClubScopedModel)      # -> carries `club`
  member       FK Member (CASCADE, related_name="club_memberships")
  season       FK Season (PROTECT, related_name="memberships")
  kind         CharField (TextChoices: member | guardian)   # default member
  license      CharField (blank)        # federation license for that season
  status       CharField (TextChoices: pending | active | lapsed | cancelled)
  fee_status   CharField (TextChoices: unpaid | partial | paid | waived)
  signed_up_at DateTimeField (null)     # when the member registered for the season
  activated_at DateTimeField (null)     # when membership became active (admin approval only, never on payment alone)
  Meta: unique_together (club, member, season); ordering = ["-season__start_date", ...]
```

- **`kind` separates a member from a guardian** *(built)*. A guardian is a parent attached
  to the club only through their child: they hold the login, can be contacted and can sit in
  a Group, but they are **not a member** — no fee, absent from the member list, the fee list
  and every member KPI (club + platform), and not eligible for a roster *or* a staff spot. A
  parent who also plays or coaches is a `member` who happens to be a parent; the two facts
  are independent, which is why this is its own field rather than being inferred from
  `members.FamilyMembership.role`. Before this existed, `members/services/family.py` enrolled
  a parent exactly like the child, so every parent counted as a member — a data migration
  reclassifies them, deliberately skipping anyone who plays, is on a team's staff, or holds
  an elevated ClubRole.
- **Onboarding a legacy roster: `members.ParentClaim`** *(built)*. The migration path for a
  club that arrives with a list of children and no parent records at all. Children import
  without logins, each into a **family of their own** — that shape *is* the "nobody is
  responsible for this child" state (`members/services/claims.py::families_awaiting_a_parent`),
  so there is no flag to drift out of step with reality, and the family drops off the worklist
  by itself the moment a parent joins it. `family_role=child` with a blank `family_group` is
  what asks for it; any other lone role is still a mistake in the file.
- **Verification is a human decision, deliberately.** A parent submits a public form
  (`/claim/`) with the child's name and date of birth as **free text — no search, no
  autocomplete, no confirmation of whether the child was found**, because the page is
  reachable without logging in and anything that resolved the child would turn it into a way
  to enumerate the club's children. An admin matches it from a queue against a shortlist that
  only ever contains children with nobody on file, so approving can never quietly re-parent a
  child who already has one. The alternatives were rejected: a claim code needs a delivery
  channel the club may not have, and matching on name plus birthday hands out someone else's
  child to whoever guesses a birthday.
- **The claim form is also the registration.** Open self-registration is closed
  (`club.views.signup_closed` shadows `account_signup` rather than removing the route, so the
  URL name allauth's own templates reverse still resolves). Accounts are created by an admin,
  by the family-registration form, or on claim approval — never by a stranger, which also
  keeps the review queue from being a spam target. The account is created **on approval**, not
  on submission, so a public form can't fill the user table.
- **An approved parent lands as a guardian**, with the login and the family link but no
  membership and no fee. If they also play, an admin flips `kind` on their membership
  afterwards; approving a claim is not the place to decide it. They then get a password-reset
  link and a minimal "my family" page (`members/views.py::MyFamilyView`) — the seam a real
  parent portal would grow from.
- **`Club.contact_email`** *(built)* — the club's own public address, set from the control
  panel next to `legal_name`. Shown to the parent both in the submission flash and in the
  approval email, as somewhere to write if something's wrong — falls back to nothing shown at
  all when unset, same pattern as `legal_name`/`official_name`.
- **The flash after submitting is worded and timed to reveal nothing.** Sent *before* any
  lookup happens, from a fixed string that never varies with whether a matching child was
  actually found (`members/views.py::ParentClaimView.form_valid`) — a message that differed
  would be exactly the enumeration channel free-text matching was built to avoid.
- **Approval emails a real one-time set-password link**, built with allauth's own token
  generator (`default_token_generator`/`user_pk_to_url_str`) so it lands in the same flow the
  login page's own reset would send them to, rather than a second, parallel one that could
  drift out of step with it. Sending is never allowed to fail the approval — the family link
  and the guardian row are real either way, and a briefly unreachable mail server must not
  cost the parent their place in the queue; the admin sees a distinct warning message
  (`management/views.py::ParentClaimApproveView`) telling them the email didn't go and to have
  the parent use "Forgot your password?" instead.
- **A signed-in parent claiming a second child links, never duplicates.**
  `ParentClaim.submitted_by_user` (nullable FK to `authentication.User`) records who was
  actually logged in when a claim was submitted — kept separate from the free-text
  `parent_email`/name fields, which stay exactly as typed as the admin's audit trail even when
  they drift from the account. When set, `members/views.py::ParentClaimView` drops the parent
  fields from the form entirely (shown as read-only "submitting as…" text, not just pre-filled
  inputs — a disabled input doesn't even submit, and a pre-filled-but-editable one still lets a
  typo through) and populates them server-side from the `Member` instead of trusting POST data.
  On approval, `members/services/claims.py::approve_claim` resolves the parent via that FK
  (never re-derived from email) and, if they already belong to a `Family`, moves the newly
  claimed child's `FamilyMembership` onto *that* family rather than leaving them split across
  two households — the child's now-empty solo family is cleaned up the same way
  `members/services/family.py::detach_from_family` already does elsewhere. No existing family →
  falls back to the original behaviour of joining the child's own family. This is the one place
  a parent, once they exist, is the anchor for "one household," not the child.
- **The "already dealt with" history is scoped to the current season.** Last season's approved
  and rejected claims are real history but not something a club needs staring at year after
  year, so `management/views.py::ParentClaimListView` filters `reviewed` to
  `reviewed_at__date__gte=` the current season's start (empty, not erroring, when there's no
  current season) rather than dropping the section outright.
- **Why a field and not a separate model.** Everything that answers "is this person attached
  to this club" already reads through `ClubMembership` — tenancy scoping, group membership,
  the club-wide event audience — and a second kind of link would need a parallel path through
  all of it. What changes is only who *counts*.
- **Guardians are carried forward across seasons.** Their tie to the club isn't really
  seasonal (it lasts as long as the child is there) but it rides on a per-season row, so
  `club/services/seasons.py::_carry_guardians_into` copies them when a season is created and
  `members/services/family.py::carry_guardians_forward` covers a guardian added after later
  seasons already existed. Copied from the *immediately preceding* season only, so a guardian
  an admin deliberately removed stays removed rather than being resurrected from an old row.
- **Excluding guardians is a subtraction, not a narrower filter.**
  `club/services/access.py::members_visible_to` subtracts `_guardians_only(club)` rather than
  matching only member-kind rows: a bare MEMBER `ClubRole` with no `ClubMembership` is a real
  state (someone the club knows but hasn't signed up yet), and narrowing the role branch to
  weed guardians out would take those people with it. Pass `include_guardians=True` where the
  page is about a *person* rather than about members — a guardian's own detail page, editing
  them, the group pickers, or a family page (whose parents are the whole point).
- **The members list makes the member/guardian split visible instead of silent.**
  `management/views.py::MemberListView` reads `?kind=member|guardian|both` (default `member`,
  unchanged from before this filter existed) — without it, a guardian was simply absent from
  the page with nothing explaining why, which read as a bug rather than the intended "guardians
  aren't members" design. `management/views.py::FamilyListView` (`management:family_list`) is
  the complementary household-first view: one row per family, its parents/guardians in one
  column and its children in another, with the family name and a separate "Edit" action both
  landing on `family_detail` — matching the "Edit goes to the overview, not a bare rename form"
  convention the Groups list already established.
- **List views are paginated** (`MemberListView`, `EventListView`, `TeamListView`,
  `GroupListView`, `NewsListView`, `FamilyListView`; `paginate_by = 25`) through one shared
  `management/templates/management/_pagination.html` partial, using Django's `{% querystring %}`
  tag so a page link preserves whatever `?q=`/`?kind=`/etc. filter is active rather than
  resetting it.
- **The sidebar surfaces two counts that would otherwise require opening the page to see**:
  pending parent claims, and games in the next 10 with no referee assigned (the latter shares
  its query with `RefereeManagementDashboardView`'s own KPI, factored out so the two can't drift
  apart). Both are admin-gated the same way the rest of that nav section is, and both render
  `0` explicitly rather than hiding the badge — the point is to make "nothing outstanding" just
  as visible as "something needs attention," not to declutter the zero case away.
- One row per member **per season** — sign-up and fee payment are tracked independently
  each season. `unique_together` moves from `(club, member)` → `(club, member, season)`
  (a data migration must backfill existing rows with the current season).
- `fee_status` is the **source of truth for whether dues are paid**; it is driven by the
  shop (§5.7) — a paid membership `Order` flips `fee_status → paid` and `status → active`.
  Keep it denormalized here (fast to query "who hasn't paid") but only mutate it through a
  service that reconciles against `Payment`s, never by hand.
- `status = active` (for the club's current season) is the fact that grants the member's
  user the `MEMBER` `ClubRole` (§3.4).

### 5.2 `teams`

```
Team(ClubScopedModel)              # -> carries `club`
  season      FK Season (PROTECT, related_name="teams")
  name        CharField           # "U12 A"
  age_group   CharField (choices, optional)
  Meta: unique_together (season, name); ordering = ["season", "name"]

TeamMembership(UUIDModel)          # roster entry — through model, club/season implied by team
  team        FK Team (CASCADE, related_name="roster")
  member      FK Member (CASCADE, related_name="team_memberships")
  position    CharField (TextChoices, optional)
  jersey_number PositiveSmallIntegerField (null=True)
  Meta: unique_together (team, member);
        UniqueConstraint(team, jersey_number) WHERE jersey_number IS NOT NULL

StaffAssignment(UUIDModel)         # coach / manager on a team, per season
  team        FK Team (CASCADE, related_name="staff")
  member      FK Member (CASCADE, related_name="staff_assignments")
  role        CharField (TextChoices: coach | assistant | manager)
  Meta: unique_together (team, member, role)
```

A `Member` plays on one *or more* `Team`s per season, each with its own position + jersey
number — modeled by `TeamMembership`, exactly matching the domain note.

- **Jersey numbers are unique within a team** (decision §7 #4): a partial
  `UniqueConstraint(fields=["team", "jersey_number"], condition=~Q(jersey_number=None))`.
  Nullable so a roster spot can exist before a number is assigned; `NULL`s are exempted so
  several unnumbered entries don't collide. `team` already implies club + season, so no
  extra tenancy field is needed on the constraint.
- `StaffAssignment` drives the coach/manager object-scope (§3.1–3.2) — it *is* the "is a
  coach of this team" fact; no `ClubRole` mirrors it.
- **Bulk add is a row formset, not a table of every member** (`TeamBulkAddView`, and the same
  shape for groups in `GroupBulkAddView`). Each row picks one person from a searchable select
  and one `Position`; the *position* decides what the row means — `Position.staff_position`
  true ⇒ a `StaffAssignment`, otherwise a `TeamMembership` with an optional jersey number and
  captain/alternate-captain flags — so there's no separate "player or staff?" control that
  could disagree with the position picked. Jersey number and captaincy exist only on
  `TeamMembership`, so a staff row rejects them (and the row script greys them out, keyed off
  the `data-staff` marker `PositionSelect` stamps on staff options). Captain *and* alternate
  captain on one row is refused as self-contradictory, but how many captains a team may have
  is left alone: neither the model nor the single-add form constrains it, and inventing the
  rule in one entry path only would be worse than not having it.
  A playing coach is simply two rows. Rows are cloned client-side from the formset's
  `empty_form` (`static/js/bulk-add-rows.js`); removing one deletes the node and deliberately
  leaves `TOTAL_FORMS` alone, since Django reads a form whose fields are absent from the POST
  as an unchanged extra and skips it — safe, unlike re-indexing live inputs. The earlier
  design rendered *every* eligible member as a table row, which a club with a hundred-plus
  members can't use, and its search was a GET round-trip that discarded anything already
  ticked. **All-or-nothing on submit**: one bad row re-renders the page with every row still
  filled in and the offending field flagged, rather than saving the good rows and losing the
  rest (a partial save is far more costly when the rows were typed by hand). Cross-row checks
  no single row can see — the same person twice, two rows claiming one jersey — live on the
  formset's `clean()`; per-row checks (already assigned, jersey already taken by an existing
  entry) live on the row form. Eligibility is never trusted from the POST: the member field's
  queryset is `eligible_roster_members`, so an id that was never offered fails its own lookup.
  The member `choices` are built once in the view and assigned onto each row's field —
  a `ModelChoiceField` otherwise re-runs its queryset per form, i.e. once per row.

**As built, `Team` also carries `referee_management`** (`TextChoices`: `club` | `federation`,
default `club`) — whether the *club* arranges referees for this team's home games, or the
*federation* does. A federation-managed team is left out of the referee tools **entirely**:
no eligibility, no assignment, no entry on the referee management dashboard (§5.3) — see
`events/services/referees.py::needs_referee_management(event)`, the single gate every
referee-facing screen reads through.

**`RefereeLevel`** *(built)* — a club-defined referee qualification tier ("Regional",
"National", ...), admin-managed like `Position` (own name, own ordering, no fixed list).
**Owns which teams it qualifies for** — eligibility is a property of the *level*, not of the
individual referee: a club configures a handful of levels once, each unlocking a tier of
teams, rather than hand-picking teams per referee.

```
RefereeLevel(ClubScopedModel)      # -> carries `club`
  name      CharField
  ordering  PositiveSmallIntegerField (default=0)
  teams     M2M Team (blank=True, related_name="referee_levels")
  Meta: UniqueConstraint(club, name); ordering = ["ordering", "name"]
```

**`RefereeProfile`** *(built)* — a **member-level** fact: which level this member holds and
how long it's valid for. Which teams that translates to is *derived* (`eligible_teams`),
never picked per member. Managed from the member's own page (`management`), read (not
edited) from the team's own page too. Deliberately **not** routed through `members.Group` —
eligibility is a property of a person, not of a group they might belong to; see the note on
`Group` above for why an earlier draft that did this was reworked. It also deliberately does
**not** put `teams` directly on the profile — a later draft of this feature did that too,
before the levels-own-the-teams shape replaced it, matching how real officiating
qualifications actually work (a certification tier unlocks a tier of competitions).

```
RefereeProfile(UUIDModel)          # club reachable via member -- Member itself has no club FK
  member       OneToOneField members.Member (CASCADE, related_name="referee_profile")
  level        FK RefereeLevel (PROTECT, null=True, blank=True, related_name="referees")
  valid_until  DateField (null=True, blank=True)
```

- **`is_currently_valid`** (property): `valid_until` is set and hasn't passed — a pure date
  check, independent of whether a level is even set.
- **`is_eligible`** (property): the full gate every consumer reads through (the event assign
  panel, the team page, the referees list) — `level` is set **and** `is_currently_valid`.
  Once `valid_until` passes, `is_eligible` flips to `False` and the referee drops out of
  every eligibility query until the date is extended; nothing else needs to change.
- **`eligible_teams`** (property): `level.teams.all()` when `is_eligible`, else empty.
- One `RefereeProfile` per member (`OneToOneField`) rather than a field bag on `Member`
  itself, matching this file's general pattern of keeping `Member` a plain identity record
  and hanging every role-specific fact off its own small table (`ClubMembership`,
  `StaffAssignment`, `TeamMembership`, and now this).

### 5.3 `events`

```
Event(ClubScopedModel)             # -> carries `club`
  season      FK Season (PROTECT, related_name="events")
  team        FK Team (SET_NULL, null=True, related_name="events")   # null = club-wide
  kind        CharField (TextChoices: training | match | tournament | social | meeting)
  title       CharField
  location    CharField (blank)
  starts_at   DateTimeField
  ends_at     DateTimeField (null=True)
  opponent    CharField (blank)    # for matches
  Meta: ordering = ["starts_at"]

Attendance(UUIDModel)              # through model Event <-> Member
  event       FK Event (CASCADE, related_name="attendances")
  member      FK Member (CASCADE, related_name="attendances")
  status      CharField (TextChoices: present | absent | excused | maybe)
  note        CharField (blank)
  Meta: unique_together (event, member)
```

`Event.season` is redundant with `team.season` when a team is set, but events can be
club-wide (`team=None`), so `season` stays a first-class FK. Keep it consistent in a
service/clean().

**As built, `Attendance` also carries `showed_up`** (nullable bool, default `None`) —
deliberately separate from `status`: `status` is the RSVP, `showed_up` is whether they
actually turned up, set by a check-in. `None` means "never checked in" (true for every
row today — there's no check-in UI yet, only Django admin); a "no-show" is
`status in (present, selected)` and `showed_up is False`, and is *never* inferred from
a missing check-in. See `events/services/attendance.py::record_check_in` and
`management/views.py::TeamDetailView`'s attendance panel.

**As built, an `Event`'s (and `EventSeries`') audience is teams + groups + invited/excluded
members, or the whole club** — `teams` (existing) sits alongside `groups`
(`M2M members.Group`, blank) and `club_wide` (`BooleanField`, default `False`). All three
feed `events/services/attendance.py::effective_members`: teams contribute their
season-scoped roster, groups contribute *every current* `GroupMembership` (Group has no
season concept, unlike Team, so this isn't season-scoped the way a team roster is), and
`invited_members`/`excluded_members` still layer on top/subtract as before — **unless**
`club_wide` is set, which replaces teams+groups with every member holding an ACTIVE
`ClubMembership` for the event's season instead (`invited_members`/`excluded_members` still
apply on top of that too). `club_wide` can't be combined with `teams`/`groups` — enforced in
`EventForm`/`EventSeriesForm.clean()` (`clean_club_wide_excludes_teams_and_groups`), not the
DB (an M2M can't be checked from a `CheckConstraint`, and can't be validated in `Event.clean()`
either — M2M state isn't available until the row has a PK). `events/signals.py` re-syncs a
group's future events on `GroupMembership` change, and a club_wide event's future rows on
`ClubMembership` change, the same way a `TeamMembership` change already re-syncs team events.
- **Authorization**: `club.services.access.groups_manageable_by(user, club)` mirrors
  `teams_managed_by` — every group for an ADMIN, else only the ones the user is themselves a
  `GroupMembership` of (Group has no manager/owner concept the way Team does via
  `StaffAssignment`, so membership is the only claim there is). A non-admin must select at
  least one team they manage **or** group they belong to when creating/editing an event
  (`EventAudienceFormMixin.clean_audience_requires_a_claim_for_non_admins`) — `club_wide`
  itself stays admin-only (the field is removed from the form entirely for anyone else, not
  just rejected on submit). `club.mixins.EventManagerRequiredMixin` (gates edit/delete/detach/
  etc. on an *existing* event/series) checks the same two claims via `get_teams()`/
  `get_groups()` (the latter defaults to none, so a view that never deals with groups doesn't
  need to override it) — without this, a non-admin who created a group-only event via the
  broader create-time gate would immediately be locked out of managing what they just made.

**As built, a GAME-kind `Event` defaults its own `end`** — `Event.save()` sets
`end = start + events.models.ASSUMED_EVENT_DURATION` (2 hours) whenever a game is saved
with no explicit `end`, and never overwrites one that's already set. Other event kinds are
untouched — `end` stays blank for them unless explicitly given one. The public games API
(`events/api.py`, `GET /games/upcoming/`) reads through this: it returns every non-cancelled
game/tournament that **hasn't finished yet** (`end` — explicit, defaulted, or, for the rare
un-saved-since / non-GAME row still lacking one, `start` within the assumed window — is at or
after now), not just ones that haven't started, so a game already in progress keeps showing up
until its window closes; `GameOut.end` is always populated the same way, and `status` treats
"started but before its (assumed) end, not flagged `is_live`" as `"live"` too, so a game
`/games/upcoming/` still lists never turns around and calls itself `"finished"`.

**As built, `Event` also carries `max_referees`** (`PositiveSmallIntegerField`, default
`2`) and **`EventReferee`** *(built)* — referee sign-up/assignment for a **home game**
only (`Event.is_home_game`), staff-assigned for now (self-service subscribe is a planned
extension, §7). A referee row is either a club member **or** an externally-logged name
(e.g. a federation-appointed referee the club still needs to pay), never both/neither, and
carries its own payment snapshot:

```
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 `events.models.ASSUMED_EVENT_DURATION` (2 hours) for this
  check. External referees have no conflict check (no member to check a schedule against).
  `ASSUMED_EVENT_DURATION` is also what `Event.save()` writes into `end` for a GAME with none
  set (below) — the *other* event kinds still leave `end` blank rather than defaulting it, so
  this read-time fallback still matters for them.
- **`assigned_by` is required for now** (admin-only assignment). A future self-service
  sign-up would make it nullable to mean "the referee signed themself up" rather than adding
  a parallel model — see §7.
- **The referee payment form is a downloadable PDF** (`event_referee_form_pdf`,
  `EventRefereeFormPdfView`, admin-only, WeasyPrint via `management/pdf.py`'s lazy-import
  pattern), modeled directly on the club's existing paper form: game details, referee names,
  a fee+km breakdown per referee, and blank signature lines (referee always; team manager
  left blank — not reliably known at print time). The header uses `Club.official_name`
  (`legal_name` if the club has set one, else plain `name` — §2.2) and the club's home
  `Location` address; the body's payment sentence uses the plain `name` — mirroring the
  original paper form, which itself uses a longer legal form up top and a shorter one in the
  body text.

### 5.4 `news`, `pages`, `home` (public site / editorial)

**`news` is built** (as of the coach_manager-authoring / editor-release-flow work) —
team-tagged instead of categorised, with a two-step release flow rather than a bare
`is_published` flag:

```
news.News(ClubScopedModel)         # -> carries `club`
  title, slug (SlugField, auto from title), body (TextField)
  title_en    CharField (blank) -- optional English translation of `title`
  body_en     TextField (blank) -- optional English translation of `body`
  teams       M2M teams.Team (blank -- empty means club-wide)
  visibility  CharField (TextChoices: internal | external | both)
  status      CharField (TextChoices: draft | published)
  published_at DateTimeField (null) -- may be in the future: a *scheduled* release,
                                       not a cron-flipped field (see below)
  created_by  FK members.Member (SET_NULL, null, related_name="news_items")
  Meta: unique_together (club, slug); ordering = ["-created"]

news.NewsPhoto(UUIDModel)          # club reached via news_item, not directly scoped
  news_item   FK news.News (CASCADE, related_name="photos")
  image       ImageField
  is_main     BooleanField
  ordering    PositiveSmallIntegerField
  Meta: UniqueConstraint(fields=["news_item"], condition=Q(is_main=True))
        -- a partial unique index enforcing "at most one main photo per item"
        at the DB level, the same trick teams.Position uses for
        management_position_implies_staff_position.

pages.Page(ClubScopedModel)        # flat CMS pages: "About", "Contact", ...
  title, slug, body (TextField)
  is_published BooleanField; menu_order (int)
  Meta: unique_together (club, slug)
  # If nested navigation is needed, add: parent = FK self (SET_NULL, null)

home.HomeConfig(ClubScopedModel)   # one row PER CLUB: featured articles/teams, hero content
                                   # (unique_together (club,) — one per tenant). May be config-only.
```

- **Authoring vs. releasing are deliberately separate authorities**
  (`club/services/access.py::can_add_news`/`can_publish_news`/`can_edit_news`): any
  current-season coach_manager (management-position `StaffAssignment`), EDITOR, or
  ADMIN can draft a `News` item and edit it while it's a draft; only EDITOR/ADMIN can
  move it to `published` (or edit it once it is) — a physio or plain staff member can't
  post news, and a coach_manager can't push their own draft live.
- **Scheduling needs no cron job.** `published_at` can be set in the future; `status`
  already reads `PUBLISHED` (it passed the editor's release gate) but `News.is_scheduled`
  is true until that moment passes. A later public/member-facing consumer just filters
  `status=PUBLISHED, published_at__lte=now()` — nothing has to flip a row at the
  scheduled instant.
- **`created_by` links to `members.Member`** (decision §7 #5) — attribution is to a
  club person, not a raw login; `SET_NULL` so deleting a member doesn't erase their posts.
- `slug`s back clean public URLs and feed `search`; they are **unique per club** (§2.4), so
  two clubs can both have `/news/season-kickoff`. Resolve within the request's club.
- `visibility` (internal/external/both) is enforced by the public read-only API
  (`news/api.py`, mounted under `api/`) — only `external`/`both` items, published and
  past their release date, are ever returned. No member-facing internal reading page
  exists yet; that's later work.
- **`title`/`body` are Dutch (the club's own language, and the only one required);
  `title_en`/`body_en` are an optional English translation**, both left blank by
  default. Nothing computes or stores a fallback — `News.effective_title_en` /
  `effective_body_en` resolve it on read (`title_en or title`), so translating a Dutch
  edit later never leaves a stale English copy behind, and every existing row gets
  correct fallback behaviour with no backfill. The public API always returns both
  languages in one call (`title_nl`/`body_nl`/`excerpt_nl` alongside
  `title_en`/`body_en`/`excerpt_en`, the latter three via the `effective_*` properties
  so they're never blank) — no `?lang=` param, the consumer picks what it needs. The
  control panel's news form lays the two languages out in side-by-side columns
  (`management/templates/management/news_form.html`); the detail page only shows an
  "English" section when a translation was actually added, not the fallback-filled
  text under a second heading.
- `NewsPhoto.image` / hero images use `ImageField` → **media storage must be configured**
  (§8). If page/news trees grow, consider a tree library later — start flat.

### 5.5 `search`

Likely **no models** — a search view over `Article`, `Page`, `Team`, `Event`. If moving to
Postgres full-text or an external index, add config here, not domain tables.

### 5.6 `formbuilder` — dynamic forms  *(new app)*

Admins/editors define forms with a **variable number of fields** at runtime; submissions
are stored so they can be **reported on** later. This uses the classic EAV (entity-
attribute-value) shape with **normalized `Answer` rows as the single source of truth**
(decision §7 #9) — one row per answered field, with a JSON `value` to stay flexible across
field types. No parallel JSON blob on the submission — reporting reads `Answer`s directly.

```
Form(ClubScopedModel)                 # -> carries `club`
  title, slug, description (blank)
  is_active      BooleanField
  login_required BooleanField        # members-only vs public submission
  opens_at / closes_at  DateTimeField (null)   # optional submission window
  max_submissions_per_user  PositiveInteger (null)   # null = unlimited
  Meta: unique_together (club, slug); ordering = ["title"]

Field(UUIDModel)                      # a form's field definition (club implied by form)
  form       FK Form (CASCADE, related_name="fields")
  key        SlugField               # stable machine name, unique per form (for reporting)
  label      CharField
  field_type CharField (TextChoices: text | textarea | number | email | date |
                                     choice | multichoice | checkbox | file)
  required   BooleanField
  help_text  CharField (blank)
  order      PositiveSmallIntegerField
  is_active  BooleanField (default=True)   # soft-retire instead of deleting (see below)
  options    JSONField (default=list) # choices for choice/multichoice: [{value,label}]
  Meta: unique_together (form, key); ordering = ["form", "order"]

Submission(UUIDModel)                 # container only — no answer data on it
  form         FK Form (CASCADE, related_name="submissions")
  member       FK Member (SET_NULL, null)   # set when submitter is logged in
  submitted_at DateTimeField
  Meta: ordering = ["-submitted_at"]

Answer(UUIDModel)                     # CANONICAL store — one per answered field
  submission FK Submission (CASCADE, related_name="answers")
  field      FK Field (PROTECT, related_name="answers")
  value      JSONField                # scalar, list (multichoice), or file ref
  Meta: unique_together (submission, field)
```

Design notes:
- **`Answer` is canonical; there is no denormalized JSON snapshot.** A submission's values
  are always read/aggregated from its `Answer` rows. The submit service
  (`formbuilder/services/submit.py`) validates the dynamic form and writes the `Submission`
  + its `Answer`s in one transaction. (If a flat per-submission view ever becomes a
  hotspot, add a *derived, rebuildable* cache later — but the model stays the source.)
- **`Field.key` is immutable once submissions exist** — reporting joins on it. Deleting a
  field with answers is blocked (`PROTECT`); **retire via `is_active=False`** instead.
- **Reporting** = a service/view producing per-field aggregates (counts per choice,
  numeric averages, response rate) plus a wide CSV/Excel export (one column per field,
  one row per submission). No extra model needed; add a saved-report model later only if
  users need to persist report definitions.
- Rendering a `Form` to a Django form (and validating a submission) is a service concern —
  build the form class dynamically from `Field` rows; don't hand-write form classes.

### 5.7 `shop` — cart, orders, payments & PDF invoices  *(new app)*

A cart-like shop where a member (or parent) "buys" products — chiefly a **season
membership** — with payment-status tracking and generated invoices. Fulfilment of a
membership product writes back to the season-scoped `ClubMembership` (§5.1).

```
Product(ClubScopedModel)              # -> carries `club`
  name, slug, description (blank)
  kind        CharField (TextChoices: membership | event_fee | merchandise | donation)
  price       DecimalField(max_digits=8, decimal_places=2)   # list price
  season      FK Season (PROTECT, null)   # set for membership/event products
  is_active   BooleanField
  # early-bird / prompt-payment discount (§5.7.1) — per-product toggle + deadline
  early_bird_enabled    BooleanField (default=False)
  early_bird_deadline   DateField (null)                  # discount valid through this date (inclusive)
  early_bird_disc_type  CharField (TextChoices DiscountType: PERCENT | AMOUNT, blank)
  early_bird_disc_value DecimalField(max_digits=8, decimal_places=2, null)  # 0–100 if PERCENT, else € off unit
  Meta: unique_together (club, slug)
        CheckConstraint: early_bird_enabled ⇒ deadline, disc_type, disc_value all set
  # membership products fulfil into a ClubMembership for the chosen season + beneficiary

Cart(ClubScopedModel)                 # -> carries `club`; one open cart per (club, user)
  user        FK User (CASCADE, related_name="carts")
  status      CharField (TextChoices: open | checked_out | abandoned)
  Meta: UniqueConstraint(club, user) WHERE status = open

CartItem(UUIDModel)                   # club implied by cart
  cart        FK Cart (CASCADE, related_name="items")
  product     FK Product (PROTECT)
  beneficiary FK Member (PROTECT, null)   # who this membership is FOR (parent buys for child)
  quantity    PositiveSmallIntegerField (default=1)
  unit_price  DecimalField               # snapshot of price at add-to-cart time
  Meta: unique_together (cart, product, beneficiary)

Order(ClubScopedModel)                # -> carries `club`; created `pending` at checkout,
                                      # frozen at finalize() (§5.7.1 lifecycle)
  number      CharField                  # human ref, allocated PER CLUB, e.g. "ORD-2026-00042"
  purchaser   FK Member (PROTECT, related_name="orders")
  status      CharField (TextChoices: pending | finalized | paid | partially_paid | cancelled | refunded)
  subtotal    DecimalField               # Σ OrderLine.line_total (after per-line early-bird)
  # order-level discounts are SELECTED from a club catalogue, not typed — see AppliedDiscount
  # below + OrderDiscountType (§5.7.1); applied by a treasurer while status=pending.
  total       DecimalField               # subtotal - Σ applied discounts; the amount invoiced
  created_at  DateTimeField
  finalized_at DateTimeField (null)
  Meta: unique_together (club, number); ordering = ["-created_at"]

OrderLine(UUIDModel)                  # club implied by order
  order       FK Order (CASCADE, related_name="lines")
  product     FK Product (PROTECT)
  beneficiary FK Member (PROTECT, null)
  quantity    PositiveSmallIntegerField
  list_price  DecimalField               # catalogue unit price at checkout (snapshot)
  unit_price  DecimalField               # price actually charged after per-line early-bird (snapshot)
  discount_label CharField (blank)        # e.g. "Early bird (−15%)" — shown on invoice; blank = none
  line_total  DecimalField               # unit_price * quantity
  fulfilled_at DateTimeField (null)       # when this line's ClubMembership was activated

Payment(UUIDModel)                    # club implied by order; an order may have several (partial)
  order       FK Order (CASCADE, related_name="payments")
  amount      DecimalField
  method      CharField (TextChoices: bank_transfer | cash | card | online)
  status      CharField (TextChoices: pending | confirmed | failed | refunded)
  reference   CharField (blank)          # bank/gateway reference
  paid_at     DateTimeField (null)

OrderDiscountType(ClubScopedModel)    # -> carries `club`; club-defined catalogue of presets
  name        CharField                  # "Sibling discount", "Volunteer", "Hardship"
  slug        SlugField
  disc_type   CharField (TextChoices DiscountType: PERCENT | AMOUNT)
  value       DecimalField(max_digits=8, decimal_places=2)   # 0–100 if PERCENT, else € off subtotal
  description CharField (blank)          # optional note shown to the treasurer
  is_active   BooleanField (default=True)   # soft-retire; keeps historical AppliedDiscounts valid
  Meta: unique_together (club, slug); ordering = ["name"]

AppliedDiscount(UUIDModel)            # through: Order <-> OrderDiscountType; club implied by order
  order         FK Order (CASCADE, related_name="discounts")
  discount_type FK OrderDiscountType (PROTECT, related_name="applications")
  # snapshot at apply time — the preset may be edited/retired later without altering past orders
  label       CharField                  # snapshot of name (shown on invoice)
  disc_type   CharField (PERCENT | AMOUNT)   # snapshot
  value       DecimalField               # snapshot (or a treasurer override, if allowed)
  applied_by  FK User (SET_NULL, null, related_name="+")
  applied_at  DateTimeField
  Meta: unique_together (order, discount_type)   # a preset toggles on/off once per order

Invoice(ClubScopedModel)              # -> carries `club`
  number      CharField                  # sequential PER CLUB per year, e.g. "INV-2026-00042"
  order       OneToOneField Order (PROTECT, related_name="invoice")
  issued_at   DateTimeField
  due_date    DateField (null)
  billing_snapshot JSONField             # name/address frozen at issue time
  pdf         FileField (null)           # rendered HTML->PDF, cached in PRIVATE storage (§8)
  Meta: unique_together (club, number)
```

Flow & design notes:
- **Cart → checkout → order.** Checkout converts the open `Cart` into an immutable `Order`
  + `OrderLine`s, snapshotting prices (products may reprice later). The cart is marked
  `checked_out`. All in one transactional service (`shop/services/checkout.py`).
- **Payment status is derived, not typed by hand.** A service sums `confirmed` `Payment`s
  and sets `Order.status` (`pending` → `partially_paid` → `paid`). Online payments arrive
  via a gateway webhook that creates/confirms a `Payment`; manual methods
  (bank transfer/cash) are confirmed by a `TREASURER` (§3.2).
- **Fulfilment writes back to membership.** When an order (or a membership line) reaches
  `paid`, a service creates/activates the `ClubMembership(member=beneficiary, season=…)`,
  flips its `fee_status → paid` / `status → active`, stamps `OrderLine.fulfilled_at`, and
  triggers role reconciliation (§3.4). This is the seam that ties the shop to the domain.
- **Beneficiary vs. purchaser** is first-class: a parent (`purchaser`) buys memberships for
  several children (`beneficiary`) in one order. Both are `Member`s of the same club.
- **Invoice = HTML → PDF.** Render a Django template to HTML, convert with **WeasyPrint**
  (see §8 for the exact dependency + native-library setup). Generate on order confirmation,
  store the file on `Invoice.pdf` in **private** storage, and serve it only through a
  permission-checked view (never a public media URL — invoices are tenant-private, §8).
  `Invoice.number` uses a gap-free counter **per club per year** — allocate it in a
  transaction/service (e.g. a `select_for_update` sequence row), not from `count()`.
- **Money = `DecimalField`**, never float. Snapshot prices onto cart items / order lines /
  invoices so historical records stay correct when `Product.price` changes.

#### 5.7.1 Discounts

Two independent discount mechanisms, applied at different layers and computed by a single
**pricing service** (`shop/services/pricing.py`) so the rules live in one place and never
in views/templates. A shared `DiscountType` enum (`PERCENT` / `AMOUNT`) is reused by both.

**A. Early-bird / prompt-payment discount — per `Product`, automatic.**
A club toggles `early_bird_enabled` on a product, sets an `early_bird_deadline`, and a
`PERCENT` or `AMOUNT` value (§5.7 `Product`). Semantics: *buy in time and the unit price
drops.*
- **Anchor = checkout date (recommended).** The discount is evaluated **once, at checkout**,
  comparing the order's `created_at` date against the deadline, and the result is frozen into
  `OrderLine.unit_price` (+ a human `discount_label`, with `list_price` preserved for
  transparency). This keeps the order/invoice total firm — an invoice can't have a
  conditional amount. To still reward *paying* early, set the membership `Invoice.due_date`
  to the deadline; late non-payment is a dunning concern, not a repricing one.
- **Alternative (payment-date anchor)** — the discount only sticks if a confirmed `Payment`
  lands by the deadline, else the line reprices to `list_price`. This makes the total mutable
  until the deadline and complicates invoicing; it's the literal reading of "paid before
  date" but is deferred unless a club needs it (see open question, §7).
- Only applies when `today <= early_bird_deadline`; otherwise the line charges `list_price`.
  A `CheckConstraint` guarantees an enabled product has a deadline + type + value.

**B. Order-level discount — selected from a club catalogue of presets.**
Rather than typing a type + value + reason per order, each club **defines named presets once**
as `OrderDiscountType` rows (e.g. *Sibling discount −15%*, *Volunteer −€25*, *Hardship*),
managed under the club's shop settings. On a `pending` order a `TREASURER`/`BOARD` (§3.2)
simply **toggles the applicable presets on** — each toggle creates an `AppliedDiscount` row.
No arithmetic is entered at order time; the treasurer picks from a list.
- **Snapshot, like prices.** `AppliedDiscount` copies the preset's `label` / `disc_type` /
  `value` at apply time. Editing or retiring (`is_active=False`) an `OrderDiscountType` later
  never rewrites past orders — historical totals stay correct. `PROTECT` on the FK means a
  used preset can't be hard-deleted; retire it instead.
- **Multiple discounts stack** — several presets can apply to one order (a preset toggles on
  at most once, via `unique_together (order, discount_type)`). See stacking rule below.
- **Optional override.** The default flow enters *zero* numbers. If a club needs a one-off
  amount (e.g. a bespoke hardship figure), allow the treasurer to override the snapshot
  `value` on that `AppliedDiscount` — an opt-in escape hatch, not the primary path. A pure
  ad-hoc discount is then just a generic "Custom" preset with an overridden value.
- **Lifecycle refinement.** Discounts force the order to be *editable before it freezes*:
  checkout creates the order as **`pending`**; a treasurer toggles presets on/off while
  `pending`; `finalize()` then locks the order + its `AppliedDiscount`s, computes the final
  `total`, allocates the `Invoice.number`, and issues the PDF. **After `finalize` everything
  is immutable** — a correction means a credit/refund, not an edit. Gated by
  `can_manage_shop(user, club)`.

**Computation & rounding (both kinds).**
`total = subtotal − Σ applied_discounts`, where `subtotal = Σ line_total` and each
`line_total` already reflects the early-bird price. Order of application: **line-level
early-bird first, then all order-level presets**, each computed against the **same
`subtotal` base** (percentages don't compound on each other — predictable and order-
independent) and summed. Percentages compute on their base, round **`ROUND_HALF_UP` to 2
decimals**; the **summed** order discount is **clamped to `[0, subtotal]`** so an order can
never go negative. Every discounted document (order summary, invoice) itemises each applied
discount by `label` plus the net so members see how the number was reached.

**Extension point.** `OrderDiscountType` is the reusable catalogue for the two required cases.
Coupon *codes* (member-entered), auto-applied promotions (rule-based, e.g. "3+ siblings"), or
per-member entitlements would extend this — add an eligibility rule / code field or an
auto-apply service on top of the same model when that need is real, rather than a parallel
mechanism.

### 5.8 `evaluations` — player evaluations  *(new app, design only)*

Coaching-staff-only assessment of a player: a customizable rubric (skills, ratings, notes),
recorded per team/season, feeding a player profile alongside their attendance history —
**never visible to the evaluated player or their guardians**, regardless of how they're
otherwise permitted (a parent with `EVALUATOR` sees every *other* child's evaluations but
not their own kid's — see the access note below).

**Built on `formbuilder` (§5.6), not a parallel form engine.** `formbuilder`'s `Form`/
`Field`/`Submission`/`Answer` already do everything a rubric needs (admin-defined fields,
normalized answers, a submit service) — reuse them literally. The one thing `formbuilder`
can't express is *who the submission is about*: `Submission.member` is the **submitter**
(here, the evaluator), and a generic form has no notion of a separate subject. Rather than
growing `formbuilder.Submission` an evaluation-specific field, `evaluations` owns a thin
envelope that pairs a `Submission` with the player it was about:

```
EvaluationSettings(club OneToOne)      # which Form is *the* current rubric
  form   FK formbuilder.Form (PROTECT)

PlayerEvaluation(UUIDModel)            # the "this was about whom, on which roster" envelope
  player      FK members.Member (CASCADE, related_name="evaluations_received")
  team        FK teams.Team (CASCADE)
  season      FK club.Season (PROTECT)
  submission  OneToOne FK formbuilder.Submission (CASCADE)  # evaluator + Answers live here
  Meta: ordering = ["-created"]
```

- **Rubric changes don't corrupt history.** Swapping the active rubric re-points
  `EvaluationSettings.form` at a new `Form`; existing `PlayerEvaluation`s keep referencing
  their original `Form`/`Field`s (already immutable/`PROTECT`-ed once submissions exist —
  §5.6's own design notes), so an old evaluation still renders with the questions it was
  actually scored against.
- **One rubric, club-wide** (not per team/age-group) — simplest, and keeps every age group
  on a comparable scale. Revisit only if a club actually needs per-team rubrics.
- **Entry point**: an "Evaluate" action from the team roster (same "act from the page the
  data lives on" pattern as the referee-assignment panel, §5.3) renders the active rubric's
  `Form` for one player (`formbuilder.services.form_factory.build_form`) and, on submit,
  calls `formbuilder.services.submission.submit_form` then wraps the resulting `Submission`
  in a `PlayerEvaluation` — one transaction.
- **Player profile** (a tab on the member detail page, gated the same as everything else
  here): evaluation history (each `PlayerEvaluation`'s answers + evaluator + date), games
  played **per team** ("U12: 14 games · U14: 6 games" — `Attendance` grouped by
  `event__teams`, no new tracking), and attendance rate (already-existing `Attendance`
  data, same definition `team_attendance_rate` uses, §5.2). No trend chart in v1.

**Access.** A new `ClubRole.EVALUATOR` (§3.2), club-wide and independent of `MEMBER_ADMIN`
and coach `StaffAssignment` — evaluating is its own trust boundary, not "manages people" or
"coaches this team." `club/services/access.py` gets `can_evaluate_players(user, club) =
is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EVALUATOR)`. Gated at
the view/mixin level (a `EvaluatorRequiredMixin`, mirroring `MemberAdminRequiredMixin`) —
nothing evaluation-related is ever built into a mobile/member-facing context, not hidden
behind a flag: the member app's views simply never query it.

**Build order**: (1) the `EVALUATOR` role + `can_evaluate_players`; (2) `EvaluationSettings`
+ `PlayerEvaluation` models + migration; (3) the "Evaluate" entry point + submit flow; (4)
the player-profile tab. `formbuilder`'s own model/service layer (§5.6) is **already built**
— no prerequisite work needed there beyond, eventually, its own missing render/submit *view*
if a public-facing form ever needs one (evaluations builds its own player-scoped one instead
of waiting on that).

---

## 6. Entity-relationship overview

```
Club  (TENANT ROOT — every model below except User carries `club`; §2.4)
  └─< Season, Member, Family, Team, Event, ClubRole, Article, Page, Form, Product, Order, Invoice, …

User 1───<  Member  (FK, unique per club)      # User is GLOBAL — no club FK
              │  └───< FamilyMembership >─── Family
              │  └───< ClubRole            (MEMBER | EDITOR | TREASURER | BOARD)
              │
              ├───< ClubMembership ──> Season        (unique: club, member, season)
              │
              ├───< 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)
              │
              └── (purchaser) ──< Order ───< OrderLine >── Product ──> Season
                                  │            └──> Member (beneficiary)
                                  ├──< Payment
                                  ├──< AppliedDiscount >── OrderDiscountType (club preset)
                                  └─1:1─ Invoice   (Cart ──< CartItem >── Product)

Season ──< Team, Event, ClubMembership, (membership/event) Product   # all within one club

news.Article ──> news.Category,  (author) members.Member
pages.Page  (self-parent, optional)
```

Legend: `───<` one-to-many, `>───<` many-to-many via a through model. Everything under
`Club` is one tenant's data; joins never cross clubs (§2.4).

---

## 7. Decisions (resolved) & remaining questions

**Resolved** (this revision):

1. ✅ **`members` app split** — Member/Family/FamilyMembership **move to a dedicated
   `members` app** (§1, §4). Migration reshuffles app labels + tables.
2. ✅ **Season-scoped `ClubMembership`** — yes (§5.1). Data migration backfills existing
   rows with the current season and re-scopes `unique_together`.
3. ✅ **Full multi-tenancy** — adopt **row-based multi-tenancy**, `Club` as tenant root
   (§2.4). Requires: `ClubScopedModel` on every aggregate root, `Member.user` →
   `ForeignKey` (+`unique(club, user)`), tenant middleware + `rosterchief/tenancy.py`
   context, tenant-aware manager, per-club uniqueness, per-club roles (§3). **Supersedes
   `CLAUDE.md`.**
4. ✅ **Jersey uniqueness** — unique **within a team** via a partial `UniqueConstraint`
   (`NULL`s exempt) (§5.2).
5. ✅ **`Article.author`** — links to `members.Member` (§5.4).
6. ✅ **RBAC mechanism** — **service layer**, no `django-guardian`; per-club `ClubRole`
   rows + a single access service (§3). Django's own perms only for platform admin.
7. ✅ **`formbuilder` storage** — **normalized `Answer` is canonical**; no denormalized JSON
   snapshot (§5.6).
8. ✅ **Tenant resolution ≠ `django.contrib.sites`** — Sites evaluated and rejected as the
   mechanism; `Club` stays the single tenant root, resolution in `ClubTenantMiddleware`
   (§2.4). Sites optional only as a later bridge for Site-aware third parties.
9. ✅ **Shop discounts** — two mechanisms (§5.7.1): a per-`Product` early-bird discount
   (toggle + deadline + PERCENT/AMOUNT, frozen at checkout) and **order-level discounts
   selected from a club catalogue of `OrderDiscountType` presets** — a treasurer toggles
   presets on a `pending` order (each = a snapshotting `AppliedDiscount` row) before
   `finalize()`, rather than typing values. Presets stack against the same subtotal base;
   optional per-row value override for one-offs. Adds an `Order.pending → finalized` step.
10. ✅ **Referee self-service sign-up** — **built**, superseding the "still open" note this
    replaces. A `RefereeSignup(event, member, status: invited|accepted|declined)` model
    tracks the invite/response; `sync_referee_invites` (wired from the same signal points
    as attendance sync, §5.3) auto-invites every eligible referee the moment a home game
    needs one. Accepting calls the existing `assign_referee(event, member, assigned_by=
    None)` — capacity-checked in the one place admin assignment already enforces it, so it
    lands as a real `EventReferee` row with no separate sync step (`assigned_by=None`
    marks it self-service, exactly the extension point this doc's earlier note predicted).
    Surfaced on the mobile Calendar and event detail page, scoped to every managed person
    (not just the account's own `self.me`) — a referee-eligible child is exactly as real
    as a referee-eligible parent.

Infrastructure/config for the above (media storage, dependencies + exact setup) is
specified in **§8**.

**Still open:**

- **Tenant resolution mechanism** — subdomain (recommended) vs. path-prefix `/c/<slug>/`.
  Affects DNS/TLS, `ALLOWED_HOSTS`, cookies, and local dev (§8). Pick before building
  `ClubTenantMiddleware`. **`django.contrib.sites` was evaluated and rejected as the
  mechanism** (§2.4) — `Club` stays the single tenant root; Sites is optional only as a
  bridge for Site-aware third parties.
- **Auto-scoping vs. explicit scoping** — should the tenant manager filter *automatically*
  from context, or stay explicit (`.for_club()` / `.current()`)? Doc currently recommends
  **explicit** (§2.4).
- **Cross-club users** — can one person be a `BOARD` member of several clubs, switching
  context in one session? The model allows it; confirm the UX (club switcher) is in scope.
- **Payment gateway** — which provider (Mollie / Stripe / none-yet)? Only needed when online
  payments go live (§8).
- **Early-bird anchor** — is the discount earned by *ordering* before the deadline
  (checkout-date anchor, recommended, frozen total) or by *paying* before it (payment-date
  anchor, mutable total)? Doc implements checkout-date; confirm no club needs the literal
  "paid before date" semantics (§5.7.1).
- **Player evaluations** (§5.8) — designed, not built. Open question worth confirming
  before implementation: should `PlayerEvaluation` be strictly one-per-(player, team,
  season, evaluator) or allow several evaluators to each leave their own entry for the same
  player/season (the design as written allows the latter — no uniqueness constraint — since
  a rubric answered once per submission is `formbuilder`'s own natural shape, and multiple
  coaches' perspectives on the same player seems like a feature, not a bug, but it hasn't
  been explicitly confirmed).

---

## 8. Infrastructure & configuration notes

Config required by the models above — settings + dependencies to add as each app lands.

### 8.1 Media & file storage (needed for `news`, `home`, `shop`, form file fields)

Two classes of files with **different exposure**:

- **Public media** — `Article.cover_image`, home hero images. Served from the normal media
  URL is fine.
- **Private, tenant-scoped files** — `Invoice.pdf` and `formbuilder` file uploads. These
  **must not** be publicly reachable. Serve them only through a permission-checked Django
  view (`X-Accel-Redirect`/`X-Sendfile` in prod), never a guessable public URL, and scope
  access to the file's club (§2.4).

Setup:
- **Dev:** `MEDIA_ROOT`/`MEDIA_URL` on local disk; private files under a non-served path.
- **Prod:** object storage (S3-compatible) via **`django-storages`** (add to deps) with
  **separate public and private buckets/backends** (Django 5.1+ `STORAGES` setting). Keep
  the private backend non-public and generate signed/short-lived URLs or stream via the view.
- Organise keys by club (e.g. `club/<club_id>/invoices/…`) so tenant data is easy to
  isolate, audit, and delete.

### 8.2 HTML → PDF invoices — WeasyPrint

- Add the dependency: `uv add weasyprint`.
- **Native libraries required** (WeasyPrint wraps Pango/Cairo) — install at the OS/image
  level, not via pip:
  - macOS (dev): `brew install pango gdk-pixbuf libffi` (Cairo/GLib come along).
  - Debian/Ubuntu (CI + prod image): `apt-get install libpango-1.0-0 libpangocairo-1.0-0
    libcairo2 libgdk-pixbuf-2.0-0 libffi-dev` (exact names per distro/WeasyPrint version).
  - Document these in the Dockerfile/CI so PDF rendering isn't a "works on my machine" trap.
- Render a Django template → HTML string → `weasyprint.HTML(string=…).write_pdf()`; store
  onto `Invoice.pdf` (private storage, §8.1). Generation is a service, ideally async/queued
  if volume grows.

### 8.3 Tenancy runtime config (needed once `ClubTenantMiddleware` lands)

- **Hosts:** wildcard `ALLOWED_HOSTS` for the chosen base domain (e.g. `.rosterchief.app`)
  if using subdomain resolution; add `DJANGO_ALLOWED_HOSTS` accordingly.
- **CSRF:** `CSRF_TRUSTED_ORIGINS` must cover the wildcard scheme+host set
  (`https://*.rosterchief.app`).
- **Cookies:** to share login across club subdomains, set `SESSION_COOKIE_DOMAIN` /
  `CSRF_COOKIE_DOMAIN` to the base domain; otherwise keep per-subdomain sessions
  (decide with the "cross-club users" question in §7).
- **Local dev:** map a wildcard to localhost (e.g. `*.localhost` resolves on most systems,
  or use `dnsmasq`) so subdomain resolution works without editing `/etc/hosts` per club.
- **Middleware order:** place `ClubTenantMiddleware` after `AuthenticationMiddleware`
  (needs `request.user` to fall back to a user's default club when no subdomain is present).

### 8.4 Optional dependencies

- **Payment gateway** (only if online payments): provider SDK (e.g. `mollie-api-python` or
  `stripe`) + webhook endpoint that creates/confirms `Payment`s (§5.7).
- **Excel export** for form reporting beyond CSV: `openpyxl`.

### 8.5 Email — SMTP or Resend *(built)*

Every Django-sent email — allauth's password reset, `send_billing_reminders`, anything else
that goes through `django.core.mail` — follows whichever `EMAIL_BACKEND` is configured; no
per-feature wiring. Console backend by default (§ "Email" in `settings.py`), so a deployment
that forgets to configure mail prints to the log instead of raising against `localhost:25`.

Two ways to use **Resend** (resend.com), no third-party SDK either way:
- **SMTP relay, zero code**: point the stock `django.core.mail.backends.smtp.EmailBackend` at
  `smtp.resend.com` with `resend` as the username and the API key as the password.
- **HTTP API**: `rosterchief.mail.ResendEmailBackend` (`DJANGO_EMAIL_BACKEND=rosterchief.mail.ResendEmailBackend`,
  `RESEND_API_KEY=…`) posts each message straight to Resend's `/emails` endpoint via `requests`
  (already a dependency, so no new one needed for this). Handles plain text, the HTML
  alternative on an `EmailMultiAlternatives`, cc/bcc/reply-to, and base64-encoded attachments;
  `fail_silently` is honoured the same way Django's own backends honour it. Confirmed against
  Resend's own docs: `POST /emails` accepts `html` alongside `text` (and will derive `text` from
  `html` if only the former is given), so this backend needed no changes to carry the HTML
  templates below.
- **HTML email templates** *(built)*. `templates/email/_base.html` is the one shared shell
  (inline styles only, ~600px single-column table — email clients strip `<style>` blocks and
  ignore linked stylesheets, so daisyUI/Tailwind's compiled CSS is irrelevant here) with a
  `{% include "email/_button.html" %}` for a styled CTA link. Every HTML email extends it and
  supplies a plain-text sibling — `members/services/claims.py::send_claim_approved_email` builds
  an `EmailMultiAlternatives` from `claim_approved.txt` + `claim_approved.html`
  (`.attach_alternative(html, "text/html")`), the same never-fail-the-caller contract as before
  (`except OSError: return False`). `club/templatetags/club_email.py::absolute_media_url` turns
  `Club.logo` (a relative `FileSystemStorage` URL) into something an inbox can actually fetch,
  by resolving it against the `request` carried in the template context.
- **allauth's password-reset email is overridden**, not left at its default plain text —
  `templates/account/email/password_reset_key_message.html` sits next to allauth's own
  `.txt` at the path its `render_mail` already looks for (no Python-side override needed;
  allauth auto-attaches a same-named `.html` as the HTML alternative). Branches on whether a
  `club` is in context (a reset requested from a club subdomain gets that club's branding) and
  falls back to a plain RosterChief wordmark otherwise (the platform-level login has no club to
  brand it with).

---

*Conventions cross-reference:* `rosterchief/base.py` (`UUIDModel`, `ClubScopedModel`),
`rosterchief/tenancy.py` (*to add* — tenant context/middleware, §2.4),
`authentication/managers.py` (`UserManager`), `authentication/services/` (service-layer
pattern).

---

> ### ⚠️ Banner: supersedes `CLAUDE.md`
>
> This architecture adopts **full multi-tenancy** (§2.4), which **directly contradicts**
> the current `CLAUDE.md` ("RosterChief is a **single-club** app … deliberately *not*
> multi-tenant — there is no `club_id` tenancy") and the project memory
> (`project_overview` — "Single-club (NOT multi-tenant)").
>
> **Action required** before/alongside implementation: update `CLAUDE.md` and the memory
> to describe RosterChief as a **multi-tenant platform (row-based, `Club` = tenant root)**.
> Until that is done, where the two disagree **this document is authoritative**.
