Merge pull request #1 from bsiebens/development

Initial deployment
This commit is contained in:
2026-08-06 20:00:22 +02:00
committed by GitHub
375 changed files with 36924 additions and 1011 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

13
.dockerignore Normal file
View File

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

10
.env.compose.example Normal file
View File

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

37
.env.example Normal file
View File

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

38
.env.production.example Normal file
View File

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

3
.gitignore vendored
View File

@@ -377,3 +377,6 @@ poetry.toml
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,django%
# Node
node_modules/
staticfiles/

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

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

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

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

2
.idea/modules.xml generated
View File

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

13
.idea/pyLspTools.xml generated
View File

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

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

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

View File

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

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

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

902
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,902 @@
# 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:
| 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` | planned | Admin-defined dynamic forms + submissions + reporting | `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) | — |
**`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)
Meta: unique_together (club, member, role)
```
| Role | Grants (representative) |
|-------------|------------------------------------------------------------------------------|
| *Public* | Anonymous — no row; read-only public site of that club. |
| `MEMBER` | View own + family data, own rosters/attendance, own orders/invoices, submit member-only forms. |
| `EDITOR` | Manage that club's `news`, `pages`, `formbuilder` content. |
| `TREASURER` | Manage that club's `shop`: products, orders, payments, issue/void invoices. |
| `BOARD` | Full management of that club: members, roles, all of the above. |
`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).
### `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).
---
## 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. "20252026"
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")
license CharField (blank) # federation license for that season
status CharField (TextChoices: pending | active | lapsed | cancelled)
fee_status CharField (TextChoices: unpaid | partial | paid | waived)
signed_up_at DateTimeField (null) # when the member registered for the season
activated_at DateTimeField (null) # when membership became active (usually on payment)
Meta: unique_together (club, member, season); ordering = ["-season__start_date", ...]
```
- 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.13.2) — it *is* the "is a
coach of this team" fact; no `ClubRole` mirrors it.
### 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.
### 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)
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 stored and enforced nowhere yet — no
member-facing reading page or public API exists. Both are later work; the field is
there so they don't need a backfill when they land.
- `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) # 0100 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) # 0100 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.
---
## 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)
├───< Attendance >─── Event ───> Season
│ └───> Team (nullable)
├───< 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.
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).
---
## 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`.
---
*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**.

BIN
ARCHITECTURE.pdf Normal file

Binary file not shown.

59
CLAUDE.md Normal file
View File

@@ -0,0 +1,59 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
RosterChief is a sport club management app + public website, built on **Django 6.0** (Python 3.14+). As of **2026-07-11 it is designed as a multi-tenant platform** (row-based / shared-schema): one deployment serves many clubs, with `Club` as the tenant root. Every club-owned model carries a `club` FK (via `ClubScopedModel`); `User` is the only global model. This **reverses** the project's earlier single-club stance — treat older "single-club / no `club_id` tenancy" notes (in git history or memory) as obsolete.
**`ARCHITECTURE.md` at the repo root is the authoritative model & domain design** — the tenancy mechanics, the RBAC design, and per-app model sketches all live there. Consult and update it when adding domain models.
The repo is an early build: `authentication` and `club` apps exist (`User`, `Member`, `Family`, `FamilyMembership`, `Club`, `ClubMembership`); the remaining domain apps and the tenancy plumbing (`rosterchief/tenancy.py`, tenant middleware, `ClubScopedModel` upgrade) are **planned, not yet on disk**. Verify against the actual tree before assuming a module exists.
## Commands
Dependencies and the virtualenv are managed with **uv** (`pyproject.toml` at repo root, `uv.lock` committed). Run Django/tools through `uv run` so the project venv is used.
```bash
uv sync # install deps (incl. dev group) into .venv
uv run python manage.py runserver # dev server
uv run python manage.py migrate # apply migrations
uv run python manage.py makemigrations
uv run python manage.py createsuperuser
uv run python manage.py shell
uv run python manage.py test # run all tests (Django test runner)
uv run python manage.py test <app> # one app
uv run python manage.py test <app>.tests.<Case> # one TestCase
uv run python manage.py test <app>.tests.<Case>.<method> # one test
uv run ruff check . # lint
uv run ruff check --fix . # lint + autofix
uv run ruff format . # format
```
## Configuration
Settings live in a single `rosterchief/settings.py` and read from the environment via **python-decouple** (`config(...)`), with a local `.env` file for dev. Key vars: `DJANGO_SECRET_KEY` (required), `DJANGO_DEBUG`, `DJANGO_ALLOWED_HOSTS`, `DJANGO_CSRF_TRUSTED_ORIGINS`, `DJANGO_DATABASE_URL`, `DJANGO_TIME_ZONE`.
The database is configured through a single `DJANGO_DATABASE_URL` (parsed by **dj-database-url**), defaulting to `sqlite:///db.sqlite3` for dev; production is intended to point at PostgreSQL via that URL. Don't hardcode DB settings — go through the env var.
## Planned architecture
**`ARCHITECTURE.md` is the source of truth for the model design; this is a summary.** The app decomposition (`authentication`, `members`, `club`, `teams`, `events`, `news`, `pages`, `home`, `formbuilder`, `shop`, `search`) has grown past the original `pyproject.toml` isort `known-first-party` list — add new labels there as apps land. Note the `accounts` app was split into `authentication` (global login) + `club`, and people models (`Member`, `Family`) are being moved into a dedicated `members` app.
Domain notes (drive modeling decisions):
- **Multi-tenancy is the cross-cutting rule.** `Club` is the tenant root; club-owned models inherit `ClubScopedModel` (a `club` FK). Scope every query to the current tenant (`.for_club()` / `.current()`); previously-global uniqueness (slugs, season names, invoice numbers) becomes **unique per club**. Only `User` is global. See `ARCHITECTURE.md` §2.4.
- **Season** is the central organizing concept, **per club**. Team rosters, events, and attendance are season-scoped — model them with a FK to a season, not as global state.
- A **Member** (a person *within one club*) can play on one or more **Teams**, each with a position + jersey number (unique within a team), always tied to a specific season.
- **RBAC is per-club and service-layer** (not `django-guardian`, not global Django groups): `ClubRole` rows (`MEMBER` / `EDITOR` / `TREASURER` / `BOARD`) plus object-scoped roles (coach via `StaffAssignment`, parent via `FamilyMembership`), all decisions routed through an access service. Django's own permissions are used only for the platform-admin layer.
- Later modules: `formbuilder` (admin-defined dynamic forms → normalized answers → reporting) and `shop` (cart → order → payment → HTML→PDF invoices via WeasyPrint), with season-scoped `ClubMembership` tracking sign-up + fee status per season.
## Conventions
- Ruff config anticipates a Wagtail-style codebase (`DJ` Django rules; `RUF012`/`RUF005` ignored for framework idioms; `line-length = 250`). Migrations are excluded from linting — don't hand-edit them to satisfy ruff.
- Settings files are exempt from `F403/F405/E501` (star imports allowed) under `rosterchief/settings/*` — note the config expects a settings *package*, though the current code is a single `settings.py`. If you split settings, match that path.
- **Every user-facing string must be translatable** (`USE_I18N = True`; no `.po` files exist yet, but the codebase is kept translation-ready as it's written, not audited later). This applies to templates, models, forms, and views alike:
- Templates: `{% load i18n %}`, then `{% trans "..." %}` for literal text and `{% blocktrans %}...{% endblocktrans %}` for text containing a variable (bind filter chains to a plain name first via `{% blocktrans with x=some.filtered|value %}`, or `{% blocktrans count counter=n %}...{% plural %}...{% endblocktrans %}` for pluralized counts — never hand-roll pluralization with `|pluralize`, real languages have more than two plural forms). Covers headings, buttons, table headers, empty-state text, `placeholder=`/`aria-label=` attributes — not URL names, CSS classes, icon names, `dom_id` arguments, or raw data interpolated on its own.
- Python (models, forms, views, services): `from django.utils.translation import gettext_lazy as _`, wrapping `verbose_name`, `help_text`, `Meta.verbose_name(_plural)`, form field `label`/`help_text`, `TextChoices`/`IntegerChoices` labels (never the choice *value*), and any user-facing string built in a method/view (`notify()` messages, raised `ValidationError`/`ValueError` text, dict labels rendered directly in a template).
- Never bake an interpolated value directly into a translatable string (an f-string with `{variable}` *inside* the translated text) — word order isn't guaranteed to survive translation. Use `%(name)s` placeholders instead: `_("“%(name)s” updated.") % {"name": obj}`. This matters most for `controlpanel.messages.notify(request, spec)`, whose `"<level>|<title>|<body>"` spec is assembled via f-string — translate the title and body as separate `_()`/`%()` expressions, then drop the already-resolved strings into the f-string skeleton.

594
DEPLOYMENT.md Normal file
View File

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

94
Dockerfile Normal file
View File

@@ -0,0 +1,94 @@
# syntax=docker/dockerfile:1
# --- 1. the stylesheet -------------------------------------------------------
# Tailwind is a build-time concern: the CSS it emits is committed, but building it here means
# the image never depends on someone having remembered to run `npm run build`.
FROM node:22-slim AS css
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY assets ./assets
COPY templates ./templates
COPY controlpanel ./controlpanel
COPY billing ./billing
RUN npm run build
# --- 2. the virtualenv -------------------------------------------------------
# Separate from the runtime for one reason: django-lucide is a *git* dependency (our lucide
# fork), so uv shells out to git to fetch it. python:*-slim has no git, and installing it in
# the runtime image would leave a build-time tool — plus its dependency tree — in production
# for the sake of one package that is already vendored into the venv by then.
FROM python:3.14-slim AS venv
RUN apt-get update && apt-get install --no-install-recommends -y git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
WORKDIR /app
# Dependencies first: they change far less often than the code, so this layer caches.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-install-project
# --- 3. the runtime ----------------------------------------------------------
FROM python:3.14-slim AS app
# WeasyPrint binds to these at import: no pango, no invoices. This is also why building the
# PDF path in a container is easier than on a Mac — apt has what Homebrew would have to.
RUN apt-get update && apt-get install --no-install-recommends -y \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libffi8 \
libjpeg62-turbo \
libopenjp2-7 \
shared-mime-info \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/app/.venv/bin:$PATH" \
# gunicorn 26's control server puts a socket in $HOME. The app user has no home dir, so
# without this it logs "Permission denied: /home/rosterchief" on every boot. /app is
# already the workdir and owned by the app user, so point HOME there.
HOME="/app"
WORKDIR /app
# The venv arrives fully built. Same base image, so the compiled wheels inside it are ABI
# compatible; nothing is re-resolved here, and no git is needed to run what git fetched.
COPY --from=venv /app/.venv ./.venv
COPY . .
COPY --from=css /build/static/css/app.css ./static/css/app.css
# collectstatic needs a settings module that imports: a throwaway key, never used at runtime.
RUN DJANGO_SECRET_KEY=build-only-not-a-secret \
DJANGO_STATICFILES_BACKEND=whitenoise.storage.CompressedManifestStaticFilesStorage \
python manage.py collectstatic --noinput
RUN useradd --system --uid 1000 rosterchief && chown -R rosterchief /app
USER rosterchief
EXPOSE 8000
# Migrations are NOT run here. With more than one app container they would race, and a failed
# migration inside a starting web process is a bad place to find out — deploy runs them once,
# explicitly (see DEPLOYMENT.md).
CMD ["gunicorn", "rosterchief.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "3", \
"--threads", "4", \
"--timeout", "60", \
"--access-logfile", "-", \
"--error-logfile", "-"]

6
api/apps.py Normal file
View File

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

17
api/errors.py Normal file
View File

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

31
api/middleware.py Normal file
View File

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

451
api/tests.py Normal file
View File

@@ -0,0 +1,451 @@
import datetime
from django.test import TestCase, override_settings
from django.utils import timezone
from club.models import Club, Season, Sponsor
from events.models import Event, Location, Opponent
from members.models import Member
from news.models import News, NewsPhoto
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
@override_settings(
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"],
)
class ApiTestBase(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
def api_get(self, path, **params):
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="ajax-united.rosterchief.app")
def api_get_base_domain(self, path, **params):
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="rosterchief.app")
class NewsApiTests(ApiTestBase):
def make_news(self, **overrides):
defaults = {"club": self.club, "title": "News", "body": "body", "status": News.Status.PUBLISHED, "published_at": timezone.now() - datetime.timedelta(hours=1), "visibility": News.Visibility.EXTERNAL}
defaults.update(overrides)
return News.objects.create(**defaults)
def test_a_draft_is_excluded(self):
self.make_news(status=News.Status.DRAFT, published_at=None)
self.assertEqual(self.api_get("/news/").json()["count"], 0)
def test_a_scheduled_but_not_yet_released_item_is_excluded(self):
self.make_news(published_at=timezone.now() + datetime.timedelta(days=1))
self.assertEqual(self.api_get("/news/").json()["count"], 0)
def test_an_internal_only_item_is_excluded(self):
self.make_news(visibility=News.Visibility.INTERNAL)
self.assertEqual(self.api_get("/news/").json()["count"], 0)
def test_an_external_item_is_included(self):
item = self.make_news(visibility=News.Visibility.EXTERNAL)
data = self.api_get("/news/").json()
self.assertEqual(data["count"], 1)
self.assertEqual(data["results"][0]["id"], str(item.pk))
def test_a_both_visibility_item_is_included(self):
self.make_news(visibility=News.Visibility.BOTH)
self.assertEqual(self.api_get("/news/").json()["count"], 1)
def test_newest_first(self):
older = self.make_news(title="Older", published_at=timezone.now() - datetime.timedelta(days=2))
newer = self.make_news(title="Newer", published_at=timezone.now() - datetime.timedelta(hours=1))
results = self.api_get("/news/").json()["results"]
self.assertEqual([r["id"] for r in results], [str(newer.pk), str(older.pk)])
def test_photos_get_absolute_urls(self):
item = self.make_news()
NewsPhoto.objects.create(news_item=item, image="clubs/ajax-united/news/x/pic.jpg", is_main=True)
photo = self.api_get("/news/").json()["results"][0]["photos"][0]
self.assertTrue(photo["url"].startswith("http://ajax-united.rosterchief.app/media/"))
self.assertTrue(photo["is_main"])
def test_pagination_limit_and_offset(self):
for i in range(3):
self.make_news(title=f"Item {i}", published_at=timezone.now() - datetime.timedelta(hours=1, minutes=i))
data = self.api_get("/news/", limit=1, offset=1).json()
self.assertEqual(data["count"], 3)
self.assertEqual(len(data["results"]), 1)
def test_limit_is_capped(self):
self.assertEqual(self.api_get("/news/", limit=1000).json()["limit"], 100)
def test_no_news_is_an_empty_list_not_an_error(self):
response = self.api_get("/news/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["results"], [])
class TeamsApiTests(ApiTestBase):
def setUp(self):
super().setUp()
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW", ordering=1)
self.defense = Position.objects.create(club=self.club, name="Defense", short_name="DF", ordering=2)
self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
def test_list_teams(self):
data = self.api_get("/teams/").json()
self.assertEqual(data, [{"id": str(self.team.pk), "name": "First Team", "short_name": "1st", "photo_url": None}])
def test_roster_groups_players_by_position(self):
alice = Member.objects.create(first_name="Alice", last_name="Ash")
bob = Member.objects.create(first_name="Bob", last_name="Birch")
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
# Two forwards (ordering 1) at different jersey numbers, one defense (ordering 2).
TeamMembership.objects.create(team=self.team, member=bob, season=self.season, position=self.forward, jersey_number=9)
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
TeamMembership.objects.create(team=self.team, member=carol, season=self.season, position=self.defense, jersey_number=1)
groups = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
# Groups in Position.ordering order (Forward before Defense); within a
# group, sorted by jersey number.
self.assertEqual([g["position"] for g in groups], ["Forward", "Defense"])
self.assertEqual([p["first_name"] for p in groups[0]["players"]], ["Alice", "Bob"])
self.assertEqual([p["first_name"] for p in groups[1]["players"]], ["Carol"])
def test_a_player_entry_no_longer_repeats_its_position(self):
# The position is now the group key, not a per-player field.
alice = Member.objects.create(first_name="Alice", last_name="Ash")
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
player = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"][0]["players"][0]
self.assertNotIn("position", player)
def test_roster_includes_staff(self):
dana = Member.objects.create(first_name="Dana", last_name="Dean")
StaffAssignment.objects.create(team=self.team, member=dana, season=self.season, position=self.coach_position)
staff = self.api_get(f"/teams/{self.team.pk}/roster/").json()["staff"]
self.assertEqual(staff, [{"id": str(dana.pk), "first_name": "Dana", "last_name": "Dean", "position": "Head Coach"}])
def test_roster_is_current_season_only(self):
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
eve = Member.objects.create(first_name="Eve", last_name="Elm")
TeamMembership.objects.create(team=self.team, member=eve, season=other_season, position=self.forward, jersey_number=1)
players = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
self.assertEqual(players, [])
def test_roster_is_empty_with_no_current_season(self):
self.season.delete()
team_without_season = self.team
data = self.api_get(f"/teams/{team_without_season.pk}/roster/").json()
self.assertEqual(data["season"], None)
self.assertEqual(data["players"], [])
self.assertEqual(data["staff"], [])
def test_list_teams_includes_the_current_seasons_photo(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
photo_url = self.api_get("/teams/").json()[0]["photo_url"]
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
def test_roster_includes_the_current_seasons_photo(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
photo_url = self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"]
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
def test_a_photo_from_a_different_season_does_not_leak(self):
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
TeamPhoto.objects.create(team=self.team, season=other_season, image="clubs/ajax-united/teams/x/00-00/pic.jpg")
self.assertIsNone(self.api_get("/teams/").json()[0]["photo_url"])
self.assertIsNone(self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"])
def test_a_team_from_another_club_404s(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
response = self.api_get(f"/teams/{other_team.pk}/roster/")
self.assertEqual(response.status_code, 404)
class GamesApiTests(ApiTestBase):
def setUp(self):
super().setUp()
self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
self.opponent = Opponent.objects.create(club=self.club, name="Rivals FC")
def make_game(self, **overrides):
defaults = {"club": self.club, "title": "Game", "kind": Event.EventKind.GAME, "start": timezone.now() + datetime.timedelta(days=1), "opponent": self.opponent}
defaults.update(overrides)
event = Event.objects.create(**defaults)
event.teams.add(self.team)
return event
def test_upcoming_games_are_listed_with_location_and_teams(self):
self.make_game(location=self.home_location)
games = self.api_get("/games/upcoming/").json()
self.assertEqual(len(games), 1)
self.assertEqual(games[0]["home_team"], "First Team")
self.assertEqual(games[0]["away_team"], "Rivals FC")
self.assertEqual(games[0]["location"]["name"], "Home Arena")
self.assertEqual(games[0]["status"], "upcoming")
def test_upcoming_excludes_cancelled_games(self):
self.make_game(cancelled=True)
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_upcoming_excludes_past_games(self):
self.make_game(start=timezone.now() - datetime.timedelta(days=1))
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_upcoming_includes_tournaments(self):
self.make_game(kind=Event.EventKind.TOURNAMENT)
self.assertEqual(len(self.api_get("/games/upcoming/").json()), 1)
def test_upcoming_excludes_other_kinds(self):
self.make_game(kind=Event.EventKind.TRAINING)
self.make_game(kind=Event.EventKind.SOCIAL)
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_live_endpoint_stays_game_only(self):
# is_live/scores are game-specific -- a tournament wouldn't have
# anything meaningful to show here even if flagged live.
self.make_game(kind=Event.EventKind.TOURNAMENT, start=timezone.now() - datetime.timedelta(minutes=10), is_live=True)
self.assertEqual(self.api_get("/games/live/").json(), [])
def test_count_is_respected(self):
for i in range(3):
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
self.assertEqual(len(self.api_get("/games/upcoming/", count=2).json()), 2)
def test_count_is_capped(self):
for i in range(3):
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
# Cap is 50, well above the 3 created -- just confirm an oversized
# request doesn't error and doesn't somehow exceed what exists.
response = self.api_get("/games/upcoming/", count=1000)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 3)
def test_live_games_are_listed_with_scores(self):
self.make_game(start=timezone.now() - datetime.timedelta(minutes=10), is_live=True, score_for=2, score_against=1, location=self.home_location)
games = self.api_get("/games/live/").json()
self.assertEqual(len(games), 1)
self.assertEqual(games[0]["status"], "live")
self.assertEqual(games[0]["home_score"], 2)
self.assertEqual(games[0]["away_score"], 1)
def test_live_excludes_cancelled_games(self):
self.make_game(is_live=True, cancelled=True)
self.assertEqual(self.api_get("/games/live/").json(), [])
def test_non_live_games_are_excluded_from_live_endpoint(self):
self.make_game()
self.assertEqual(self.api_get("/games/live/").json(), [])
def test_score_relabelling_for_an_away_game(self):
# No location (or a non-home one) -- is_home_game is False, so our
# team's score_for/score_against map to the away side.
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3)
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
self.assertEqual(games[0]["home_team"], "Rivals FC")
self.assertEqual(games[0]["away_team"], "First Team")
self.assertEqual(games[0]["home_score"], 3)
self.assertEqual(games[0]["away_score"], 4)
self.assertEqual(games[0]["status"], "finished")
def test_score_relabelling_for_a_home_game(self):
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3, location=self.home_location)
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
self.assertEqual(games[0]["home_team"], "First Team")
self.assertEqual(games[0]["away_team"], "Rivals FC")
self.assertEqual(games[0]["home_score"], 4)
self.assertEqual(games[0]["away_score"], 3)
def test_team_games_includes_past_and_upcoming_for_the_current_season(self):
self.make_game(title="Past", start=timezone.now() - datetime.timedelta(days=1), score_for=1, score_against=0)
self.make_game(title="Future", start=timezone.now() + datetime.timedelta(days=1))
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
self.assertEqual(len(games), 2)
def test_team_games_excludes_a_different_season(self):
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
self.make_game(start=datetime.datetime(2000, 6, 1, tzinfo=datetime.UTC), season=other_season)
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
def test_team_games_excludes_cancelled(self):
self.make_game(cancelled=True)
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
def test_team_games_is_empty_with_no_current_season(self):
self.make_game()
self.season.delete()
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
def test_a_team_from_another_club_404s_on_games(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
response = self.api_get(f"/teams/{other_team.pk}/games/")
self.assertEqual(response.status_code, 404)
class TenancyAndCorsTests(ApiTestBase):
def test_the_base_domain_404s(self):
response = self.api_get_base_domain("/news/")
self.assertEqual(response.status_code, 404)
def test_a_get_response_carries_the_cors_header(self):
response = self.api_get("/news/")
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
def test_an_options_preflight_gets_a_204_with_cors_headers(self):
response = self.client.options("/api/v1/news/", HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 204)
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
self.assertIn("GET", response["Access-Control-Allow-Methods"])
def test_cors_headers_are_not_added_outside_the_api(self):
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotIn("Access-Control-Allow-Origin", response)
def test_docs_page_resolves(self):
self.assertEqual(self.api_get("/docs").status_code, 200)
def test_openapi_schema_resolves(self):
self.assertEqual(self.api_get("/openapi.json").status_code, 200)
class SponsorApiTests(ApiTestBase):
def make_sponsor(self, **overrides):
today = timezone.localdate()
defaults = {"club": self.club, "name": "Acme Corp", "start_date": today - datetime.timedelta(days=10), "end_date": today + datetime.timedelta(days=10)}
defaults.update(overrides)
return Sponsor.objects.create(**defaults)
def test_a_sponsor_covering_today_is_included(self):
self.make_sponsor()
data = self.api_get("/sponsors/").json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["name"], "Acme Corp")
def test_a_sponsor_starting_in_the_future_is_excluded(self):
today = timezone.localdate()
self.make_sponsor(start_date=today + datetime.timedelta(days=1), end_date=None)
self.assertEqual(self.api_get("/sponsors/").json(), [])
def test_a_sponsor_that_already_ended_is_excluded(self):
today = timezone.localdate()
self.make_sponsor(start_date=today - datetime.timedelta(days=20), end_date=today - datetime.timedelta(days=1))
self.assertEqual(self.api_get("/sponsors/").json(), [])
def test_a_sponsor_with_no_end_date_and_a_past_start_is_included(self):
today = timezone.localdate()
self.make_sponsor(start_date=today - datetime.timedelta(days=100), end_date=None)
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
def test_a_sponsor_starting_today_is_included(self):
today = timezone.localdate()
self.make_sponsor(start_date=today, end_date=None)
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
def test_a_sponsor_ending_today_is_included(self):
today = timezone.localdate()
self.make_sponsor(start_date=today - datetime.timedelta(days=10), end_date=today)
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
def test_another_clubs_sponsor_never_leaks_in(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
self.make_sponsor(club=other_club)
self.assertEqual(self.api_get("/sponsors/").json(), [])
def test_logo_url_is_absolute_when_set(self):
self.make_sponsor(logo="clubs/ajax-united/sponsors/x/logo.png")
logo_url = self.api_get("/sponsors/").json()[0]["logo_url"]
self.assertTrue(logo_url.startswith("http://ajax-united.rosterchief.app/media/"))
def test_logo_url_is_null_when_not_set(self):
self.make_sponsor()
self.assertIsNone(self.api_get("/sponsors/").json()[0]["logo_url"])
def test_randomize_returns_the_same_set_of_sponsors(self):
for i in range(5):
self.make_sponsor(name=f"Sponsor {i}")
stable = {s["id"] for s in self.api_get("/sponsors/").json()}
randomized = {s["id"] for s in self.api_get("/sponsors/", randomize="true").json()}
self.assertEqual(stable, randomized)
self.assertEqual(len(stable), 5)
def test_default_order_is_stable_and_alphabetical(self):
self.make_sponsor(name="Zulu Corp")
self.make_sponsor(name="Acme Corp")
names = [s["name"] for s in self.api_get("/sponsors/").json()]
self.assertEqual(names, ["Acme Corp", "Zulu Corp"])

28
api/urls.py Normal file
View File

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

211
assets/app.css Normal file
View File

@@ -0,0 +1,211 @@
@import "tailwindcss";
/* Scan Django templates for utility classes (Tailwind's auto-detection doesn't
know about our template dirs). */
@source "../templates";
@source "../controlpanel";
@source "../billing";
@source "../management";
/* daisyUI: light is the default, dark applies automatically when the OS asks
for it. An explicit data-theme on <html> (set by the toggle) overrides both. */
@plugin "daisyui" {
themes: light --default, dark --prefersdark;
}
/* Fonts are self-hosted (files copied from the @fontsource packages into
static/fonts/, paths are relative to the built css at /static/css/app.css).
Google's CDN would leak every visitor's IP to a third party on page load,
which we don't want to inherit for an EU club platform.
Two subsets each: `latin` covers western europe, `latin-ext` carries the
polish/czech/turkish letters that turn up in member names. The unicode-range
means a browser only fetches latin-ext when a page actually uses those glyphs.
Ubuntu is static (it has no variable version); its real weights are 400/500/700,
so `font-semibold` (600) is synthesised up to 700 by the browser. JetBrains Mono
and Roboto are variable: one file covers the whole weight axis. */
@font-face {
font-family: "Ubuntu";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../fonts/ubuntu-latin-400-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Ubuntu";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../fonts/ubuntu-latin-ext-400-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Ubuntu";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("../fonts/ubuntu-latin-400-italic.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Ubuntu";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("../fonts/ubuntu-latin-ext-400-italic.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Ubuntu";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("../fonts/ubuntu-latin-500-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Ubuntu";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("../fonts/ubuntu-latin-ext-500-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Ubuntu";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../fonts/ubuntu-latin-700-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Ubuntu";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../fonts/ubuntu-latin-ext-700-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url("../fonts/jetbrains-mono-latin-wght-normal.woff2") format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url("../fonts/jetbrains-mono-latin-ext-wght-normal.woff2") format("woff2-variations");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Roboto";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("../fonts/roboto-latin-standard-normal.woff2") format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Roboto";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("../fonts/roboto-latin-ext-standard-normal.woff2") format("woff2-variations");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Tourney is a display face for jersey numbers and the like. Its variable file
carries two axes -- weight 100-900 and width 75-125 -- so font-stretch has to be
declared as a range too, otherwise the browser clamps to the default width and
`font-stretch: 125%` (a wide shirt number) silently does nothing. */
@font-face {
font-family: "Tourney";
font-style: normal;
font-weight: 100 900;
font-stretch: 75% 125%;
font-display: swap;
src: url("../fonts/tourney-latin-standard-normal.woff2") format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Tourney";
font-style: normal;
font-weight: 100 900;
font-stretch: 75% 125%;
font-display: swap;
src: url("../fonts/tourney-latin-ext-standard-normal.woff2") format("woff2-variations");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Setting --font-sans / --font-mono changes the body and <code> defaults; every
--font-* also generates a utility, so --font-roboto gives us `font-roboto` to
opt into Roboto where we want it. */
@theme {
--font-sans: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--font-ubuntu: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
--font-roboto: "Roboto", ui-sans-serif, system-ui, sans-serif;
--font-tourney: "Tourney", ui-sans-serif, system-ui, sans-serif;
}
/* The logo is a background image, not `content:` -- content-replacement on a real
element (rather than ::before/::after) isn't supported in Firefox.
Default = dark-ink logo, for a light background. The media query covers "auto",
where the toggle deliberately sets no data-theme at all; the attribute selectors
are more specific, so an explicit choice always beats the OS. */
.logo {
background-image: var(--logo-dark);
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
@media (prefers-color-scheme: dark) {
.logo {
background-image: var(--logo-light);
}
}
[data-theme="light"] .logo {
background-image: var(--logo-dark);
}
[data-theme="dark"] .logo {
background-image: var(--logo-light);
}
/* Club-uploaded logos are arbitrary raster files (often much smaller than the
badge they're displayed in), so the browser is upscaling them -- and the
default resampling some engines fall back to for that reads as pixelated.
Forcing the element onto its own GPU-composited layer makes Chrome/Safari
pick their higher-quality scaler; the image-rendering hints are a no-op
where unsupported, degrading gracefully to the browser default. Doesn't
apply to SVG logos, which scale losslessly regardless. */
.club-logo {
image-rendering: -webkit-optimize-contrast;
image-rendering: smooth;
image-rendering: high-quality;
backface-visibility: hidden;
transform: translateZ(0);
}

View File

View File

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

44
authentication/admin.py Normal file
View File

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

5
authentication/apps.py Normal file
View File

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

20
authentication/forms.py Normal file
View File

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

View File

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

View File

@@ -0,0 +1,49 @@
"""Force MFA enrolment for privileged users.
Anyone who can change other people's data must have a second factor: Django
staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN or EDITOR)
in *any* club. Regular members may enrol, but aren't forced to.
Enrolled users are challenged for their second factor by allauth at login; this
middleware only handles the other half — a privileged user who has never
enrolled is redirected to the MFA setup page until they do.
"""
from allauth.mfa.utils import is_mfa_enabled
from django.conf import settings
from django.shortcuts import redirect
from django.urls import reverse
from club.models import ClubRole
#: Paths a not-yet-enrolled user must still reach (to enrol, or to log out).
#: ``/__reload__/`` is django-browser-reload's event stream, which only exists
#: under DEBUG — without it, live reload dies on the enrolment page itself.
EXEMPT_PREFIXES = ("/accounts/", "/static/", "/media/", "/__reload__/")
ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)
def mfa_required_for(user) -> bool:
"""Privileged users must hold a second factor."""
if user.is_staff or user.is_superuser:
return True
return ClubRole.objects.filter(member__user=user, role__in=ELEVATED_ROLES).exists()
class RequireMFAMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if self.needs_enrolment(request):
return redirect(reverse(settings.MFA_ENROLMENT_URL_NAME))
return self.get_response(request)
def needs_enrolment(self, request) -> bool:
user = getattr(request, "user", None)
if user is None or not user.is_authenticated:
return False
if request.path.startswith(EXEMPT_PREFIXES):
return False
return mfa_required_for(user) and not is_mfa_enabled(user)

View File

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

View File

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

View File

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

View File

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

View File

41
authentication/models.py Normal file
View File

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

484
authentication/tests.py Normal file
View File

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

0
billing/__init__.py Normal file
View File

53
billing/admin.py Normal file
View File

@@ -0,0 +1,53 @@
from django.contrib import admin
from .models import Due, DuePayment, Subscription, Tier, TierPrice
class TierPriceInline(admin.TabularInline):
model = TierPrice
extra = 0
@admin.register(Tier)
class TierAdmin(admin.ModelAdmin):
list_display = ["name", "is_active"]
list_filter = ["is_active"]
search_fields = ["name"]
prepopulated_fields = {"slug": ["name"]}
inlines = [TierPriceInline]
@admin.register(TierPrice)
class TierPriceAdmin(admin.ModelAdmin):
list_display = ["tier", "amount", "active_from"]
list_filter = ["tier"]
@admin.register(Subscription)
class SubscriptionAdmin(admin.ModelAdmin):
list_display = ["club", "tier", "auto_renew", "auto_archive"]
list_filter = ["tier", "auto_renew", "auto_archive"]
search_fields = ["club__name"]
class DuePaymentInline(admin.TabularInline):
model = DuePayment
extra = 0
readonly_fields = ["recorded_by"]
@admin.register(Due)
class DueAdmin(admin.ModelAdmin):
list_display = ["club", "tier", "period_start", "period_end", "amount", "amount_paid", "status"]
list_filter = ["status", "tier"]
search_fields = ["club__name"]
# Money is settled by the billing service, which re-derives these from the payments.
readonly_fields = ["amount_paid", "status", "paid_at"]
inlines = [DuePaymentInline]
@admin.register(DuePayment)
class DuePaymentAdmin(admin.ModelAdmin):
list_display = ["due", "amount", "method", "paid_at", "recorded_by"]
list_filter = ["method"]
search_fields = ["due__club__name", "reference"]

7
billing/apps.py Normal file
View File

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

View File

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

267
billing/models.py Normal file
View File

@@ -0,0 +1,267 @@
"""What the platform charges a club.
Deliberately NOT club-scoped. `shop` is a club charging its members — tenant data, owned by
the club. This is RosterChief charging the club: platform-owned, and no club user ever sees
it. Nothing here inherits ClubScopedModel: these rows reference a Club, they are not owned
by one, and a tenant-scoped manager would be exactly the wrong default.
"""
from datetime import date, timedelta
from decimal import Decimal
from dateutil import relativedelta
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from rosterchief.base import UUIDModel, unique_slugify
ZERO = Decimal("0.00")
# A club stays live for six weeks past the end of an unpaid period before it is archived.
GRACE_DAYS = 45
# The next period is issued this long before the current one ends, so the invoice reaches the
# club — and can be paid — before the old period lapses. Grace then only matters for genuine
# non-payers, rather than for everyone who takes a fortnight to pay a bank transfer.
RENEWAL_LEAD_DAYS = 30
def add_one_year(day: date) -> date:
return day + relativedelta.relativedelta(years=1)
def add_months(day: date, months: int) -> date:
return day + relativedelta.relativedelta(months=months)
class Tier(UUIDModel):
"""A price band. The price itself lives in TierPrice, which is dated."""
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True)
description = models.TextField(_("description"), blank=True)
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive tiers keep billing existing subscriptions but cannot be chosen for new ones."))
class Meta:
verbose_name = _("tier")
verbose_name_plural = _("tiers")
ordering = ["name"]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = unique_slugify(self, self.name)
super().save(*args, **kwargs)
def price_on(self, day: date | None = None) -> Decimal | None:
"""The price in force on ``day`` — the latest one that had started by then.
None means the tier had no price yet on that date. Callers must treat that as
"cannot bill", never as free.
"""
day = day or timezone.localdate()
price = self.prices.filter(active_from__lte=day).order_by("-active_from").first()
return price.amount if price else None
class TierPrice(UUIDModel):
"""A dated price for a tier.
Dated rather than keyed by year: a rate change is one new row with a future
``active_from``, and every period already opened keeps the amount it was billed at.
"""
tier = models.ForeignKey(Tier, on_delete=models.CASCADE, related_name="prices", verbose_name=_("tier"))
active_from = models.DateField(_("active from"), help_text=_("Periods opening on or after this date are billed at this amount."))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
class Meta:
verbose_name = _("tier price")
verbose_name_plural = _("tier prices")
ordering = ["tier__name", "-active_from"]
constraints = [
models.UniqueConstraint(fields=["tier", "active_from"], name="unique_tier_price_per_start_date"),
]
def __str__(self):
return f"{self.tier}{self.amount} from {self.active_from}"
class Subscription(UUIDModel):
"""A club's current plan. The periods it is billed for are Dues."""
club = models.OneToOneField("club.Club", on_delete=models.CASCADE, related_name="subscription", verbose_name=_("club"))
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("tier"))
auto_renew = models.BooleanField(_("auto renew"), default=True, help_text=_("Issue the next period automatically before this one ends. Off means you invoice this club by hand."))
auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period."))
notes = models.TextField(_("notes"), blank=True)
trial_ends_at = models.DateField(_("trial ends at"), null=True, blank=True, help_text=_("Set while this club is on a trial. The tier switches to post_trial_tier the next time a period is opened after this date."))
post_trial_tier = models.ForeignKey(Tier, on_delete=models.PROTECT, null=True, blank=True, related_name="+", verbose_name=_("post-trial tier"), help_text=_("The plan this club switches to automatically once its trial ends."))
class Meta:
verbose_name = _("subscription")
verbose_name_plural = _("subscriptions")
ordering = ["club__name"]
constraints = [
# Both set together or neither -- a trial with no target plan (or a target
# plan with no trial end date) is a half-configured state nothing should read.
models.CheckConstraint(
condition=Q(trial_ends_at__isnull=True, post_trial_tier__isnull=True) | Q(trial_ends_at__isnull=False, post_trial_tier__isnull=False),
name="trial_fields_set_together",
),
]
def __str__(self):
return f"{self.club}{self.tier}"
class Due(UUIDModel):
"""One billing period for one club.
``tier`` and ``amount`` are snapshots taken when the period opens, never read back
through the tier at display time: raise the price and last year's period must still say
what was actually charged. A live lookup would rewrite financial history.
"""
class Status(models.TextChoices):
UNPAID = "unpaid", _("unpaid")
PARTIAL = "partial", _("partially paid")
PAID = "paid", _("paid")
WAIVED = "waived", _("waived")
CANCELLED = "cancelled", _("cancelled")
#: Statuses that still owe money.
OWING = (Status.UNPAID, Status.PARTIAL)
club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="dues", verbose_name=_("club"))
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="dues", verbose_name=_("tier"))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=ZERO, help_text=_("Kept in step with the payments by the billing service."))
period_start = models.DateField(_("period start"))
period_end = models.DateField(_("period end"), blank=True)
grace_until = models.DateField(_("grace until"), blank=True, help_text=_("Past this date an unpaid club is archived."))
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.UNPAID)
paid_at = models.DateTimeField(_("paid at"), null=True, blank=True)
is_trial = models.BooleanField(_("trial period"), default=False, help_text=_("This period was opened as a trial. A durable marker on the row itself -- the subscription's own trial fields are cleared once it converts."))
class Meta:
verbose_name = _("due")
verbose_name_plural = _("dues")
ordering = ["-period_start", "club__name"]
constraints = [
models.UniqueConstraint(fields=["club", "period_start"], name="unique_due_per_club_per_period"),
]
def __str__(self):
return f"{self.club}{self.period_start} to {self.period_end}"
def save(self, *args, **kwargs):
# A period runs a rolling year from its start and the grace hangs off its end.
# Derived here so no caller can open a period without them.
if not self.period_end:
self.period_end = add_one_year(self.period_start) - timedelta(days=1)
if not self.grace_until:
self.grace_until = self.period_end + timedelta(days=GRACE_DAYS)
super().save(*args, **kwargs)
@property
def balance(self) -> Decimal:
return self.amount - self.amount_paid
@property
def is_owing(self) -> bool:
return self.status in self.OWING
def is_in_grace(self, today: date | None = None) -> bool:
"""The period has ended unpaid, but the club is not archivable yet."""
today = today or timezone.localdate()
return self.is_owing and self.period_end < today <= self.grace_until
def is_overdue(self, today: date | None = None) -> bool:
"""Unpaid past grace — this is what makes a club archivable."""
today = today or timezone.localdate()
return self.is_owing and self.grace_until < today
class DuePayment(UUIDModel):
"""Money received against a due.
Several may land on one due: a club that pays in two transfers must not read as unpaid,
and the half that did arrive has to be recorded somewhere.
"""
class Method(models.TextChoices):
BANK_TRANSFER = "bank_transfer", _("bank transfer")
CARD = "card", _("card")
CASH = "cash", _("cash")
OTHER = "other", _("other")
due = models.ForeignKey(Due, on_delete=models.CASCADE, related_name="payments", verbose_name=_("due"))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
note = models.TextField(_("note"), blank=True)
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_due_payments", verbose_name=_("recorded by"))
class Meta:
verbose_name = _("due payment")
verbose_name_plural = _("due payments")
ordering = ["-paid_at"]
def __str__(self):
return f"{self.amount}{self.due}"
class Invoice(UUIDModel):
"""The bill for one period.
Only the number and the issue date are stored: the money, the tier and the dates are
already frozen on the Due, so the PDF is rendered from those snapshots on demand. The
number, though, must be stable and gapless — it is the thing an accountant reconciles
against, so it is allocated once and never recomputed.
"""
due = models.OneToOneField(Due, on_delete=models.CASCADE, related_name="invoice", verbose_name=_("due"))
number = models.CharField(_("number"), max_length=32, unique=True, blank=True)
issued_at = models.DateTimeField(_("issued at"), default=timezone.now)
class Meta:
verbose_name = _("invoice")
verbose_name_plural = _("invoices")
ordering = ["-issued_at"]
def __str__(self):
return self.number
def save(self, *args, **kwargs):
if not self.number:
self.number = self.next_number(self.issued_at.year)
super().save(*args, **kwargs)
@classmethod
def next_number(cls, year: int) -> str:
"""INV-2026-00001, restarting each year.
Platform-wide, unlike the shop's order numbers, which are per club: these are OUR
invoices, and one sequence has to cover every club we bill.
"""
prefix = f"INV-{year}-"
last = cls.objects.filter(number__startswith=prefix).order_by("-number").first()
sequence = int(last.number.removeprefix(prefix)) + 1 if last else 1
return f"{prefix}{sequence:05d}"

View File

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

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

@@ -0,0 +1,219 @@
"""The billing lifecycle. Views and the archive command go through here, never through the
models directly — a Due whose amount_paid disagrees with its payments is a wrong invoice.
"""
from datetime import date, timedelta
from decimal import Decimal
from django.db import transaction
from django.db.models import DateField, OuterRef, Subquery, Sum
from django.utils import timezone
from billing.models import RENEWAL_LEAD_DAYS, ZERO, Due, DuePayment, Subscription, Tier, add_months
from billing.services import BillingError
from billing.services.invoices import issue_invoice
def subscribe(club, tier: Tier, *, start: date | None = None, auto_archive: bool = True, auto_renew: bool = True) -> Subscription:
"""Put a club on a tier and open its first period."""
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"tier": tier, "auto_archive": auto_archive, "auto_renew": auto_renew})
open_period(club, start=start)
return subscription
@transaction.atomic
def start_trial(club, trial_tier: Tier, *, post_trial_tier: Tier, trial_months: int, start: date | None = None, auto_renew: bool = True, auto_archive: bool = True) -> Due:
"""Put a club on a short trial that switches itself to ``post_trial_tier`` the moment
the trial period is renewed -- see open_period()'s trial-conversion check.
Only for a club with no subscription yet -- converting an existing paying subscription
into a trial is a different, deliberately unsupported operation for now.
"""
if trial_months <= 0:
raise BillingError("Trial length must be at least 1 month.")
if getattr(club, "subscription", None) is not None:
raise BillingError(f"{club} is already subscribed -- use Change plan instead.")
start = start or next_period_start(club)
trial_end = add_months(start, trial_months) - timedelta(days=1)
Subscription.objects.create(club=club, tier=trial_tier, trial_ends_at=trial_end, post_trial_tier=post_trial_tier, auto_renew=auto_renew, auto_archive=auto_archive)
return open_period(club, start=start, period_end=trial_end, is_trial=True)
def next_period_start(club, today: date | None = None) -> date:
"""Where the club's next period begins.
The day after the last one ended — not today. A club that pays two months late has still
used those two months, and restarting the clock at the payment date would quietly gift
them away. Callers can override; that is what the start field on the renew form is for.
"""
today = today or timezone.localdate()
last = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()
return last.period_end + timedelta(days=1) if last else today
@transaction.atomic
def open_period(club, *, start: date | None = None, tier: Tier | None = None, period_end: date | None = None, is_trial: bool = False) -> Due:
"""Issue the next due for a club, snapshotting the tier and the price of the day."""
subscription = getattr(club, "subscription", None)
if tier is None:
if subscription is None:
raise BillingError(f"{club} has no tier: put it on a subscription before billing it.")
# A trial that has run its course: swap onto the pre-selected plan before billing
# the next period, rather than silently renewing the trial tier forever. Checked
# here (not in renew()) so it fires whether this period was opened by the renewal
# command or by a platform admin clicking "Open period"/"Reactivate" by hand --
# both call open_period() directly.
if subscription.trial_ends_at is not None and (start or next_period_start(club)) > subscription.trial_ends_at:
subscription.tier = subscription.post_trial_tier
subscription.trial_ends_at = None
subscription.post_trial_tier = None
subscription.save(update_fields=["tier", "trial_ends_at", "post_trial_tier"])
tier = subscription.tier
start = start or next_period_start(club)
amount = tier.price_on(start)
if amount is None:
raise BillingError(f"{tier} has no price in force on {start:%d %b %Y}. Add one before opening the period.")
if club.dues.filter(period_start=start).exists():
raise BillingError(f"{club} is already billed for a period starting {start:%d %b %Y}.")
due = Due.objects.create(club=club, tier=tier, amount=amount, period_start=start, period_end=period_end, is_trial=is_trial)
if amount == ZERO:
# Nothing is actually owed -- left at the default UNPAID, this would eventually
# trip is_overdue() and get a free club archived for non-payment of nothing.
due.status = Due.Status.PAID
due.paid_at = timezone.now()
due.save(update_fields=["status", "paid_at"])
issue_invoice(due) # every period is billable the moment it opens
return due
@transaction.atomic
def record_payment(due: Due, amount: Decimal, *, method=DuePayment.Method.BANK_TRANSFER, reference: str = "", paid_at=None, note: str = "", user=None) -> DuePayment:
"""Log money against a due and re-derive its status from the payments."""
if due.status in (Due.Status.WAIVED, Due.Status.CANCELLED):
raise BillingError(f"This period is {due.get_status_display()}; it cannot take a payment.")
if amount <= ZERO:
raise BillingError("A payment must be for a positive amount.")
payment = DuePayment.objects.create(due=due, amount=amount, method=method, reference=reference, paid_at=paid_at or timezone.now(), note=note, recorded_by=user)
_resettle(due)
return payment
@transaction.atomic
def remove_payment(payment: DuePayment) -> None:
"""Undo a mis-keyed payment, then re-derive the due from what is left."""
due = payment.due
payment.delete()
_resettle(due)
def _resettle(due: Due) -> None:
"""Recompute amount_paid and status from the payments on record.
Summed from the payments rather than incremented: an increment drifts the moment a
payment is edited or deleted, and the drift is invisible — the number still looks like
money.
"""
paid = due.payments.aggregate(total=Sum("amount"))["total"] or ZERO
due.amount_paid = paid
if paid >= due.amount:
due.status = Due.Status.PAID
due.paid_at = due.payments.order_by("-paid_at").first().paid_at
elif paid > ZERO:
due.status = Due.Status.PARTIAL
due.paid_at = None
else:
due.status = Due.Status.UNPAID
due.paid_at = None
due.save(update_fields=["amount_paid", "status", "paid_at", "modified"])
@transaction.atomic
def waive(due: Due, *, note: str = "") -> Due:
"""Write a period off. It stops owing, and stops counting towards archiving."""
if due.payments.exists():
raise BillingError("This period has payments against it; remove them before waiving it.")
due.status = Due.Status.WAIVED
due.save(update_fields=["status", "modified"])
return due
def owing_dues():
return Due.objects.filter(status__in=Due.OWING)
def dues_in_grace(today: date | None = None):
"""Period over, unpaid, not yet archivable."""
today = today or timezone.localdate()
return owing_dues().filter(period_end__lt=today, grace_until__gte=today)
def dues_overdue(today: date | None = None):
"""Past grace: these are the clubs the archive command would take down."""
today = today or timezone.localdate()
return owing_dues().filter(grace_until__lt=today)
def archivable_clubs(today: date | None = None):
"""Clubs the archive command would act on: overdue, still live, and opted in.
A club with auto_archive off is deliberately spared — that flag is how you keep a club
you are negotiating with from being switched off overnight.
"""
return dues_overdue(today).filter(club__archived_at__isnull=True, club__subscription__auto_archive=True).select_related("club", "tier").order_by("club__name")
@transaction.atomic
def reactivate(club, *, start: date | None = None) -> Due:
"""Bring an archived club back and bill it again.
The new period defaults to continuing from the last one, so a lapsed year is still owed.
Pass ``start`` to forgive the gap and begin today instead.
"""
club.restore()
return open_period(club, start=start)
def subscriptions_due_for_renewal(today: date | None = None, lead_days: int = RENEWAL_LEAD_DAYS):
"""Clubs whose next period should be issued now.
Idempotent by construction: a club that has just been renewed has a latest period ending a
year out, which is past the horizon, so it cannot be picked up twice. Running the job twice
a day is harmless.
A subscription with no period at all (its only due was cancelled) counts too — a club on a
plan and billed for nothing is the leak this whole job exists to close.
"""
today = today or timezone.localdate()
horizon = today + timedelta(days=lead_days)
latest_period_end = Subquery(
Due.objects.filter(club=OuterRef("club")).exclude(status=Due.Status.CANCELLED).order_by("-period_end").values("period_end")[:1],
output_field=DateField(),
)
subscriptions = Subscription.objects.filter(auto_renew=True, club__archived_at__isnull=True).select_related("club", "tier").annotate(latest_period_end=latest_period_end).order_by("club__name")
return [subscription for subscription in subscriptions if subscription.latest_period_end is None or subscription.latest_period_end <= horizon]
def renew(subscription: Subscription) -> Due:
"""Open the club's next period, continuing from the last one."""
return open_period(subscription.club)

View File

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

View File

@@ -0,0 +1,106 @@
{% load static %}
{% comment %}
Rendered by WeasyPrint, not by a browser: this is a standalone document with its own
print stylesheet. It deliberately does NOT pull in app.css — daisyUI is built for a
screen, and half of it (dark theme, flex layouts) means nothing on paper.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ invoice.number }}</title>
<style>
@page {
size: A4;
margin: 20mm;
@bottom-center {
content: "RosterChief — invoice {{ invoice.number }} — page " counter(page) " of " counter(pages);
font-size: 8pt;
color: #666;
}
}
body { font-family: sans-serif; font-size: 10pt; color: #111; }
h1 { font-size: 20pt; margin: 0 0 2mm; }
.muted { color: #666; }
.header { display: flex; justify-content: space-between; margin-bottom: 12mm; }
.parties { display: flex; justify-content: space-between; margin-bottom: 10mm; }
.parties h2 { font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; margin: 0 0 2mm; }
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
th { text-align: left; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; border-bottom: 1px solid #ccc; padding: 2mm 0; }
td { padding: 2mm 0; border-bottom: 1px solid #eee; }
.right { text-align: right; }
.total td { font-weight: bold; border-bottom: 2px solid #111; border-top: 1px solid #111; }
.balance { font-size: 12pt; font-weight: bold; }
.paid { color: #15803d; }
.owed { color: #b91c1c; }
</style>
</head>
<body>
<div class="header">
<div>
<h1>RosterChief</h1>
<div class="muted">Club &amp; team management</div>
</div>
<div class="right">
<h1>Invoice</h1>
<div><strong>{{ invoice.number }}</strong></div>
<div class="muted">Issued {{ invoice.issued_at|date:"j F Y" }}</div>
</div>
</div>
<div class="parties">
<div>
<h2>Billed to</h2>
<div><strong>{{ club.name }}</strong></div>
<div class="muted">{{ club.slug }}.rosterchief.app</div>
</div>
<div class="right">
<h2>Period</h2>
<div>{{ due.period_start|date:"j F Y" }} — {{ due.period_end|date:"j F Y" }}</div>
<div class="muted">Payable by {{ due.grace_until|date:"j F Y" }}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th class="right">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<strong>{{ due.tier.name }}</strong> — platform subscription
<div class="muted">{{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }}</div>
</td>
<td class="right">€{{ due.amount|floatformat:2 }}</td>
</tr>
{% for payment in payments %}
<tr>
<td class="muted">
Payment received {{ payment.paid_at|date:"j M Y" }} ({{ payment.get_method_display }}{% if payment.reference %}, {{ payment.reference }}{% endif %})
</td>
<td class="right muted">−€{{ payment.amount|floatformat:2 }}</td>
</tr>
{% endfor %}
<tr class="total">
<td>Balance due</td>
<td class="right balance {% if due.balance > 0 %}owed{% else %}paid{% endif %}">€{{ due.balance|floatformat:2 }}</td>
</tr>
</tbody>
</table>
{% if due.status == "paid" %}
<p class="paid"><strong>Paid in full.</strong> Thank you.</p>
{% elif due.status == "waived" %}
<p class="muted"><strong>Waived.</strong> Nothing is owed for this period.</p>
{% else %}
<p class="muted">
Payable by <strong>{{ due.grace_until|date:"j F Y" }}</strong>. Unpaid past that date the club is archived: its
subdomain stops resolving, though nothing is deleted.
</p>
{% endif %}
</body>
</html>

563
billing/tests.py Normal file
View File

@@ -0,0 +1,563 @@
import datetime
import sys
from decimal import Decimal
from io import StringIO
from unittest import mock
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
from django.utils import timezone
from club.models import Club
from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year
from .services import BillingError
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, renew, start_trial, subscribe, subscriptions_due_for_renewal, waive
from .services.invoices import invoice_pdf, issue_invoice, render_pdf
class BillingTestBase(TestCase):
def setUp(self):
self.today = timezone.localdate()
self.club = Club.objects.create(name="Ajax United")
self.tier = Tier.objects.create(name="Standard")
# Priced well back, so a backdated (lapsed) period still has a price in force —
# opening one before any price existed is refused, and rightly so.
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def bill(self, start=None, club=None):
return open_period(club or self.club, start=start, tier=self.tier)
class TierPriceTests(BillingTestBase):
def test_the_price_in_force_is_the_latest_one_that_has_started(self):
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("600.00"))
self.assertEqual(self.tier.price_on(self.today - datetime.timedelta(days=1)), Decimal("500.00"))
self.assertEqual(self.tier.price_on(self.today), Decimal("600.00"))
def test_a_future_price_does_not_apply_yet(self):
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
self.assertEqual(self.tier.price_on(self.today), Decimal("500.00"))
def test_a_tier_with_no_price_yet_cannot_be_billed(self):
# None must never be read as free.
empty = Tier.objects.create(name="Enterprise")
self.assertIsNone(empty.price_on(self.today))
with self.assertRaises(BillingError):
open_period(self.club, tier=empty)
class PeriodTests(BillingTestBase):
def test_a_period_runs_a_rolling_year_with_a_grace_tail(self):
due = self.bill(start=datetime.date(2026, 3, 1))
self.assertEqual(due.period_end, datetime.date(2027, 2, 28))
self.assertEqual(due.grace_until, due.period_end + datetime.timedelta(days=GRACE_DAYS))
def test_a_leap_day_period_does_not_explode(self):
# 29 February has no counterpart in a common year.
self.assertEqual(add_one_year(datetime.date(2028, 2, 29)), datetime.date(2029, 2, 28))
def test_the_next_period_continues_from_the_last_one(self):
# Not from today: a club that pays two months late has still used those two months,
# and restarting the clock at the payment date would quietly gift them away.
first = self.bill(start=self.today - datetime.timedelta(days=400))
self.assertEqual(next_period_start(self.club), first.period_end + datetime.timedelta(days=1))
def test_a_first_period_starts_today(self):
self.assertEqual(next_period_start(self.club), self.today)
def test_the_amount_is_snapshotted_at_the_price_of_the_day(self):
due = self.bill()
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=1), amount=Decimal("900.00"))
due.refresh_from_db()
# Raising the rate must not rewrite what was already billed.
self.assertEqual(due.amount, Decimal("500.00"))
def test_a_club_cannot_be_billed_twice_for_one_period(self):
self.bill(start=self.today)
with self.assertRaises(BillingError):
self.bill(start=self.today)
def test_a_club_with_no_tier_cannot_be_billed(self):
with self.assertRaises(BillingError):
open_period(Club.objects.create(name="Feyenoord"))
def test_subscribing_puts_a_club_on_a_tier_and_opens_a_period(self):
club = Club.objects.create(name="Feyenoord")
subscribe(club, self.tier)
self.assertEqual(Subscription.objects.get(club=club).tier, self.tier)
self.assertEqual(club.dues.count(), 1)
class PaymentTests(BillingTestBase):
def setUp(self):
super().setUp()
self.due = self.bill()
def test_a_part_payment_leaves_the_due_partially_paid(self):
record_payment(self.due, Decimal("200.00"))
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.PARTIAL)
self.assertEqual(self.due.balance, Decimal("300.00"))
self.assertIsNone(self.due.paid_at)
def test_payments_accumulate_until_the_due_is_settled(self):
record_payment(self.due, Decimal("200.00"))
record_payment(self.due, Decimal("300.00"))
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.PAID)
self.assertEqual(self.due.balance, Decimal("0.00"))
self.assertIsNotNone(self.due.paid_at)
def test_an_overpayment_still_settles_the_due(self):
record_payment(self.due, Decimal("600.00"))
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.PAID)
def test_removing_a_payment_re_derives_the_due(self):
# amount_paid is summed from the payments, never incremented: an increment drifts the
# moment one is deleted, and the drift still looks like money.
first = record_payment(self.due, Decimal("200.00"))
record_payment(self.due, Decimal("300.00"))
remove_payment(first)
self.due.refresh_from_db()
self.assertEqual(self.due.amount_paid, Decimal("300.00"))
self.assertEqual(self.due.status, Due.Status.PARTIAL)
def test_removing_the_only_payment_puts_the_due_back_to_unpaid(self):
payment = record_payment(self.due, Decimal("500.00"))
remove_payment(payment)
self.due.refresh_from_db()
self.assertEqual(self.due.status, Due.Status.UNPAID)
self.assertEqual(self.due.amount_paid, Decimal("0.00"))
self.assertIsNone(self.due.paid_at)
def test_a_zero_payment_is_refused(self):
with self.assertRaises(BillingError):
record_payment(self.due, Decimal("0.00"))
def test_a_waived_period_cannot_take_a_payment(self):
waive(self.due)
with self.assertRaises(BillingError):
record_payment(self.due, Decimal("100.00"))
def test_a_period_with_payments_cannot_be_waived(self):
record_payment(self.due, Decimal("100.00"))
with self.assertRaises(BillingError):
waive(self.due)
def test_a_waived_period_owes_nothing_and_never_archives_a_club(self):
waive(self.due)
self.due.refresh_from_db()
self.assertFalse(self.due.is_owing)
self.assertFalse(self.due.is_overdue(self.due.grace_until + datetime.timedelta(days=1)))
class GraceAndArchiveTests(BillingTestBase):
LAPSED = 365 + GRACE_DAYS + 10
def test_a_period_past_its_end_but_inside_grace_is_in_grace(self):
due = self.bill(start=self.today - datetime.timedelta(days=370))
self.assertTrue(due.is_in_grace(self.today))
self.assertFalse(due.is_overdue(self.today))
self.assertIn(due, dues_in_grace(self.today))
def test_a_period_past_grace_is_overdue(self):
due = self.bill(start=self.today - datetime.timedelta(days=self.LAPSED))
self.assertTrue(due.is_overdue(self.today))
self.assertFalse(due.is_in_grace(self.today))
self.assertIn(due, dues_overdue(self.today))
def test_a_paid_period_is_never_overdue(self):
due = self.bill(start=self.today - datetime.timedelta(days=self.LAPSED))
record_payment(due, Decimal("500.00"))
due.refresh_from_db()
self.assertFalse(due.is_overdue(self.today))
self.assertNotIn(due, dues_overdue(self.today))
def test_an_overdue_club_is_archivable(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
self.assertEqual(archivable_clubs(self.today).count(), 1)
def test_a_club_that_opted_out_is_never_archived(self):
# auto_archive off is how you stop a club you are negotiating with from being
# switched off overnight.
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED), auto_archive=False)
self.assertEqual(archivable_clubs(self.today).count(), 0)
def test_an_already_archived_club_is_not_archived_again(self):
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
self.club.archive()
self.assertEqual(archivable_clubs(self.today).count(), 0)
class ArchiveCommandTests(BillingTestBase):
def setUp(self):
super().setUp()
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=365 + GRACE_DAYS + 10))
def run_command(self, *args):
out = StringIO()
call_command("archive_overdue_clubs", *args, stdout=out)
return out.getvalue()
def test_it_reports_without_archiving_by_default(self):
# The asymmetry is the point: this switches off paying customers, so a cron
# misconfiguration or a clock skew must cost an email, not a morning of angry clubs.
output = self.run_command()
self.club.refresh_from_db()
self.assertFalse(self.club.is_archived)
self.assertIn("Dry run", output)
self.assertIn("Ajax United", output)
def test_it_archives_with_commit(self):
self.run_command("--commit")
self.club.refresh_from_db()
self.assertTrue(self.club.is_archived)
def test_it_says_so_when_nothing_is_overdue(self):
record_payment(self.club.dues.first(), Decimal("500.00"))
self.assertIn("Nothing overdue", self.run_command())
class ReactivationTests(BillingTestBase):
def setUp(self):
super().setUp()
# Through subscribe(), not open_period(): reactivating reads the club's tier off its
# subscription, and a club billed without one cannot be re-billed later.
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
self.first = self.club.dues.first()
self.club.archive()
def test_reactivating_continues_from_the_lapsed_period_by_default(self):
due = reactivate(self.club)
self.club.refresh_from_db()
self.assertFalse(self.club.is_archived)
self.assertEqual(due.period_start, self.first.period_end + datetime.timedelta(days=1))
def test_a_chosen_start_forgives_the_gap(self):
due = reactivate(self.club, start=self.today)
self.assertEqual(due.period_start, self.today)
class InvoiceTests(BillingTestBase):
def test_every_period_is_invoiced_when_it_opens(self):
due = self.bill()
self.assertTrue(Invoice.objects.filter(due=due).exists())
def test_numbers_run_in_one_platform_wide_series(self):
# Unlike the shop's per-club order numbers: these are OUR invoices, and one sequence
# covers every club we bill.
first = self.bill(start=self.today).invoice
second = open_period(Club.objects.create(name="Feyenoord"), tier=self.tier).invoice
year = timezone.now().year
self.assertEqual(first.number, f"INV-{year}-00001")
self.assertEqual(second.number, f"INV-{year}-00002")
def test_re_issuing_does_not_burn_a_number(self):
# A gap in an invoice series is a question you do not want to have to answer.
due = self.bill()
self.assertEqual(issue_invoice(due), due.invoice)
self.assertEqual(Invoice.objects.count(), 1)
def test_the_invoice_renders_the_frozen_snapshot(self):
due = self.bill()
record_payment(due, Decimal("200.00"), reference="TRX-9")
due.refresh_from_db()
with mock.patch("billing.services.invoices.render_pdf", return_value=b"%PDF-fake") as renderer:
invoice_pdf(due.invoice)
html = renderer.call_args.args[0]
self.assertIn("INV-", html)
self.assertIn("Ajax United", html)
self.assertIn("500.00", html) # billed
self.assertIn("200.00", html) # paid
self.assertIn("300.00", html) # balance
def test_the_pdf_library_is_only_needed_when_a_pdf_is_asked_for(self):
# WeasyPrint binds to native pango/cairo. The app, the tests and every other page must
# run without them; only this call may fail.
with mock.patch.dict(sys.modules, {"weasyprint": mock.MagicMock()}):
sys.modules["weasyprint"].HTML.return_value.write_pdf.return_value = b"%PDF-1.7"
self.assertEqual(render_pdf("<p>hi</p>"), b"%PDF-1.7")
def test_a_missing_pdf_library_says_what_is_missing(self):
with mock.patch.dict(sys.modules, {"weasyprint": None}), self.assertRaises(BillingError) as caught:
render_pdf("<p>hi</p>")
self.assertIn("pango", str(caught.exception))
class ModelStringTests(BillingTestBase):
def test_models_describe_themselves(self):
due = self.bill()
payment = record_payment(due, Decimal("10.00"))
self.assertEqual(str(self.tier), "Standard")
self.assertIn("500.00", str(self.tier.prices.first()))
self.assertIn("Ajax United", str(due))
self.assertIn("10.00", str(payment))
self.assertIn("INV-", str(due.invoice))
self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.tier)))
class RenewalTests(BillingTestBase):
"""The leak this closes: a club whose period lapses with its last due PAID owes nothing,
so dues_overdue() is empty, so archive_overdue_clubs never fires — and the club keeps
using the platform for free while every number on the dashboard stays green."""
def ending_in(self, days, **kwargs):
"""A club whose current period ends `days` from now."""
club = Club.objects.create(name=f"Club {days}")
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=365 - days), **kwargs)
return club
def test_a_club_nearing_its_end_date_is_picked_up(self):
club = self.ending_in(20)
due = [s.club for s in subscriptions_due_for_renewal()]
self.assertIn(club, due)
def test_a_club_with_a_period_beyond_the_horizon_is_left_alone(self):
club = self.ending_in(200)
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_renewing_continues_from_the_last_period(self):
club = self.ending_in(20)
first = club.dues.first()
renew(club.subscription)
latest = club.dues.order_by("-period_start").first()
self.assertEqual(latest.period_start, first.period_end + datetime.timedelta(days=1))
self.assertEqual(club.dues.count(), 2)
def test_running_twice_does_not_bill_twice(self):
# Idempotent by construction: once renewed, the club's latest period ends a year out,
# which is past the horizon.
club = self.ending_in(20)
call_command("renew_subscriptions", stdout=StringIO())
call_command("renew_subscriptions", stdout=StringIO())
self.assertEqual(club.dues.count(), 2)
def test_a_club_that_opted_out_is_not_renewed(self):
club = self.ending_in(20, auto_renew=False)
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_an_archived_club_is_not_renewed(self):
# Reactivation is the way back, and it opens a period of its own.
club = self.ending_in(20)
club.archive()
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_the_new_period_is_billed_at_the_price_in_force_then(self):
club = self.ending_in(20)
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("900.00"))
due = renew(club.subscription)
self.assertEqual(due.amount, Decimal("900.00")) # the new rate
self.assertEqual(club.dues.order_by("period_start").first().amount, Decimal("500.00")) # the old one, untouched
def test_the_new_period_is_invoiced(self):
club = self.ending_in(20)
due = renew(club.subscription)
self.assertTrue(due.invoice.number.startswith("INV-"))
def test_a_dry_run_issues_nothing(self):
club = self.ending_in(20)
out = StringIO()
call_command("renew_subscriptions", "--dry-run", stdout=out)
self.assertEqual(club.dues.count(), 1)
self.assertIn("would renew", out.getvalue())
def test_the_command_issues_by_default(self):
# The opposite asymmetry to archiving: NOT acting is the expensive failure here,
# because a club that is never billed is never chased either.
club = self.ending_in(20)
call_command("renew_subscriptions", stdout=StringIO())
self.assertEqual(club.dues.count(), 2)
def test_an_unpriced_tier_fails_loudly_without_stopping_the_others(self):
priced = self.ending_in(20)
broken = Club.objects.create(name="Unpriced FC")
subscribe(broken, self.tier, start=self.today - datetime.timedelta(days=350))
# Its next period starts beyond the last price... by removing every price, it cannot bill.
TierPrice.objects.all().delete()
cheap = Tier.objects.create(name="Cheap")
TierPrice.objects.create(tier=cheap, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
priced.subscription.tier = cheap
priced.subscription.save()
with self.assertRaises(CommandError):
call_command("renew_subscriptions", stdout=StringIO(), stderr=StringIO())
# ...and the club that COULD be billed still was.
self.assertEqual(priced.dues.count(), 2)
def test_a_subscription_with_no_period_at_all_is_renewed(self):
club = Club.objects.create(name="Orphan FC")
Subscription.objects.create(club=club, tier=self.tier)
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal()])
def test_it_says_so_when_there_is_nothing_to_renew(self):
self.assertIn("Nothing to renew", self.run_renewal())
def run_renewal(self, *args):
out = StringIO()
call_command("renew_subscriptions", *args, stdout=out)
return out.getvalue()
class RenewedButUnpaidTests(BillingTestBase):
"""A club auto-renewed that never pays the new fee flows through the ordinary
unpaid -> grace -> overdue -> archive path. Renewal creates a normal Due; it does not
create a special case, and the safety net that the never-billed club slipped past now
fires, because there IS an unpaid due."""
def lapsed_club(self):
"""A club on its first, PAID period — far enough back that a renewal from its end is
itself already past grace, so only the renewal's payment state decides the outcome."""
club = Club.objects.create(name="Renewed FC")
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=800))
first = club.dues.first()
record_payment(first, first.amount) # the FIRST period is settled; only the renewal is in question
return club
def test_an_unpaid_renewal_becomes_overdue_and_archivable(self):
club = self.lapsed_club()
renewed = renew(club.subscription) # continues from the first period's end, unpaid
self.assertTrue(renewed.is_overdue(self.today))
self.assertIn(renewed, dues_overdue(self.today))
self.assertIn(club, [d.club for d in archivable_clubs(self.today)])
def test_a_paid_renewal_is_not_chased(self):
club = self.lapsed_club()
renewed = renew(club.subscription)
record_payment(renewed, renewed.amount)
self.assertNotIn(club, [d.club for d in archivable_clubs(self.today)])
class TrialTests(BillingTestBase):
"""A club with no subscription yet can be started on a short trial that switches
itself to a pre-selected plan automatically once the trial period is renewed --
see billing.services.dues.start_trial and the trial-conversion check in
open_period()."""
def setUp(self):
super().setUp()
self.trial_tier = Tier.objects.create(name="Trial")
TierPrice.objects.create(tier=self.trial_tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
def test_start_trial_creates_a_short_trial_period(self):
due = start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
subscription = self.club.subscription
self.assertEqual(subscription.tier, self.trial_tier)
self.assertEqual(subscription.post_trial_tier, self.tier)
self.assertEqual(subscription.trial_ends_at, due.period_end)
self.assertTrue(due.is_trial)
# Roughly 2 months, nowhere near the standard ~1-year period.
self.assertLess((due.period_end - due.period_start).days, 65)
def test_start_trial_refuses_if_already_subscribed(self):
subscribe(self.club, self.tier)
with self.assertRaises(BillingError):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
def test_start_trial_refuses_a_non_positive_length(self):
with self.assertRaises(BillingError):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=0)
def test_renewing_after_the_trial_switches_to_the_post_trial_tier(self):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
due = renew(self.club.subscription)
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.tier, self.tier)
self.assertIsNone(self.club.subscription.trial_ends_at)
self.assertIsNone(self.club.subscription.post_trial_tier)
self.assertEqual(due.tier, self.tier)
self.assertFalse(due.is_trial)
self.assertEqual(due.amount, Decimal("500.00"))
def test_manually_opening_the_next_period_also_switches_tier(self):
# Same conversion must fire via the control panel's "Open period" button, which
# calls open_period() directly rather than renew().
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
open_period(self.club)
self.club.refresh_from_db()
self.assertEqual(self.club.subscription.tier, self.tier)
def test_a_trial_nearing_its_end_is_picked_up_for_renewal(self):
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2, start=self.today - datetime.timedelta(days=50))
self.assertIn(self.club, [s.club for s in subscriptions_due_for_renewal()])
def test_a_zero_amount_trial_is_created_already_paid(self):
free_tier = Tier.objects.create(name="Free Trial")
TierPrice.objects.create(tier=free_tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
due = start_trial(self.club, free_tier, post_trial_tier=self.tier, trial_months=2)
self.assertEqual(due.status, Due.Status.PAID)
self.assertIsNotNone(due.paid_at)
far_future = due.grace_until + datetime.timedelta(days=100)
self.assertNotIn(due, dues_overdue(far_future))
self.assertNotIn(self.club, [d.club for d in archivable_clubs(far_future)])

0
club/__init__.py Normal file
View File

65
club/admin.py Normal file
View File

@@ -0,0 +1,65 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor
@admin.register(Club)
class ClubAdmin(admin.ModelAdmin):
list_display = ["name", "slug", "sport_type"]
list_filter = ["sport_type"]
search_fields = ["name", "slug"]
prepopulated_fields = {"slug": ["name"]}
ordering = ["name"]
@admin.register(Sponsor)
class SponsorAdmin(admin.ModelAdmin):
list_display = ["name", "club", "start_date", "end_date"]
list_filter = ["club"]
search_fields = ["name"]
@admin.register(Season)
class SeasonAdmin(admin.ModelAdmin):
list_display = ["__str__", "club", "start_date", "end_date"]
list_filter = ["club"]
search_fields = ["club__name"]
ordering = ["club", "-start_date"]
class FeePaymentInline(admin.TabularInline):
model = FeePayment
extra = 0
readonly_fields = ["recorded_by"]
@admin.register(ClubMembership)
class ClubMembershipAdmin(admin.ModelAdmin):
list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "fee_amount", "amount_paid", "license"]
search_fields = ["club__name", "member__last_name", "member__first_name", "license"]
list_filter = ["club", "season", "status", "fee_status"]
raw_id_fields = ["member"]
# Money is settled by club.services.fees, which re-derives fee_status from the payments.
readonly_fields = ["amount_paid", "fee_status"]
fieldsets = [
[None, {"fields": ["club", "season", "member"]}],
[_("Membership"), {"fields": ["license", "status", "fee_status", "fee_amount", "amount_paid"]}],
[_("Dates"), {"fields": ["signed_up_at", "activated_at"]}],
]
inlines = [FeePaymentInline]
@admin.register(FeePayment)
class FeePaymentAdmin(admin.ModelAdmin):
list_display = ["membership", "amount", "method", "paid_at", "recorded_by"]
list_filter = ["method"]
search_fields = ["membership__club__name", "membership__member__last_name", "reference"]
@admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ["club__name", "member__last_name", "member__first_name", "role"]
search_fields = ["club__name", "member__last_name", "member__first_name"]
list_filter = ["club", "role"]
raw_id_fields = ["member"]

58
club/api.py Normal file
View File

@@ -0,0 +1,58 @@
"""Public read-only sponsors endpoint -- see api/urls.py for how this is
mounted.
"""
import random
import uuid
from datetime import date
from django.db.models import Q
from django.utils import timezone
from ninja import Router, Schema
from api.errors import require_club
from .models import Sponsor
router = Router(tags=["sponsors"])
class SponsorOut(Schema):
id: uuid.UUID
name: str
logo_url: str | None
url: str | None
start_date: date
end_date: date | None
def _to_sponsor_out(sponsor, request) -> SponsorOut:
return SponsorOut(
id=sponsor.pk,
name=sponsor.name,
logo_url=request.build_absolute_uri(sponsor.logo.url) if sponsor.logo else None,
url=sponsor.url or None,
start_date=sponsor.start_date,
end_date=sponsor.end_date,
)
@router.get("/", response=list[SponsorOut], summary="Active sponsors")
def list_sponsors(request, randomize: bool = False):
"""Sponsors currently "live": start_date has passed and either there's no
end_date (runs indefinitely once started) or it hasn't passed yet. Both
bounds are inclusive of today.
`randomize=true` shuffles the result (e.g. for a sponsor strip that
shouldn't always lead with the same one) -- shuffled in Python after a
stable-ordered fetch rather than an ORDER BY RANDOM(), which sponsor
counts are far too small to need and which SQLite/Postgres don't even
express the same way."""
club = require_club(request)
today = timezone.localdate()
sponsors = list(Sponsor.objects.filter(club=club, start_date__lte=today).filter(Q(end_date__isnull=True) | Q(end_date__gte=today)).order_by("name"))
if randomize:
random.shuffle(sponsors)
return [_to_sponsor_out(sponsor, request) for sponsor in sponsors]

8
club/apps.py Normal file
View File

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

View File

@@ -0,0 +1,23 @@
"""Tenant-aware page branding.
Every page inherits its chrome from ``base_template``. On a club subdomain that
resolves to the club-branded skin, on the base domain to the RosterChief one, so
the auth screens (login, password reset, MFA, passkeys — anything allauth ships,
now or later) follow the tenant without a single template of their own knowing
that clubs exist.
The control panel deliberately does *not* use this: it hardcodes the platform
base, so no branding bug can ever dress the platform panel up as a club.
"""
PLATFORM_BASE_TEMPLATE = "_platform_base.html"
CLUB_BASE_TEMPLATE = "_club_base.html"
def branding(request):
club = getattr(request, "club", None) # set by ClubTenantMiddleware
return {
"club": club,
"base_template": CLUB_BASE_TEMPLATE if club else PLATFORM_BASE_TEMPLATE,
}

View File

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

128
club/mixins.py Normal file
View File

@@ -0,0 +1,128 @@
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import Http404
from waffle import flag_is_active
from .services.access import can_add_news, can_edit_news, can_publish_news, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
"""Gate for the club-facing management UI.
Two rules, the mirror image of ``controlpanel.mixins.PlatformStaffRequiredMixin``:
* **Club subdomain only.** This UI manages *one* club, so it doesn't exist on the
base domain — same reasoning as the control panel refusing to exist on a club
subdomain, just inverted.
* **Staff only.** ADMIN/EDITOR, or a current-season ``StaffAssignment`` (coach,
team manager, ...) — see ``has_management_access``. The plain MEMBER role every
active player/club member holds automatically does *not* count: a club member
with neither is a player/parent, and belongs in the separate app that serves
them.
"""
def dispatch(self, request, *args, **kwargs):
if getattr(request, "club", None) is None:
raise Http404("The management app is not available on the base domain.")
return super().dispatch(request, *args, **kwargs)
def test_func(self):
return has_management_access(self.request.user, self.request.club)
class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN role only — club-wide settings that aren't scoped to a single team:
seasons, positions, roles, shop configuration."""
def test_func(self):
return is_club_admin(self.request.user, self.request.club)
class FeatureRequiredMixin(ClubAdminRequiredMixin):
"""Gate for a whole management section (shop, forms, ...) this club doesn't
have at all unless its waffle Flag (see the ``features`` app, set per-club
from the control panel's Features page) is active for it. Checked before
the admin-only test below and as a plain 404 rather than folded into
``test_func``'s 403: a club with the feature off doesn't have a permissions
problem, the section just doesn't exist there, same reasoning as
``ClubStaffRequiredMixin`` 404ing the whole app off the base domain.
Subclasses set ``feature_flag`` to the Flag's name, e.g. ``"shop"``.
"""
feature_flag: str = ""
def dispatch(self, request, *args, **kwargs):
club = getattr(request, "club", None)
if club is not None and not flag_is_active(request, self.feature_flag):
raise Http404(f"The “{self.feature_flag}” feature isn't enabled for this club.")
return super().dispatch(request, *args, **kwargs)
class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
"""A manager of *this* team, or a club ADMIN. ``self.get_team()`` must return the
``Team`` the view acts on (e.g. from the URL's ``pk``) before ``test_func`` runs.
"""
def get_team(self):
raise NotImplementedError("Subclasses must return the Team this view acts on.")
def test_func(self):
user, club = self.request.user, self.request.club
if is_club_admin(user, club):
return True
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
class EventManagerRequiredMixin(ClubStaffRequiredMixin):
"""Admin, or a manager of at least one of this event's/series' *current*
teams. ``self.get_teams()`` must return the Team queryset/iterable the
view acts on (e.g. ``self.get_object().teams.all()``) before ``test_func``
runs. Events/series aren't single-team like a roster entry -- ``teams`` is
M2M, so authority is "manages at least one", not "manages the one"."""
def get_teams(self):
raise NotImplementedError("Subclasses must return the Teams this view acts on.")
def test_func(self):
user, club = self.request.user, self.request.club
if is_club_admin(user, club):
return True
return teams_managed_by(user, club).filter(pk__in=self.get_teams().values_list("pk", flat=True)).exists()
class ManagementPositionRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN, or anyone with a current-season *management*-position
StaffAssignment on any team -- unlike ``TeamManagerRequiredMixin``, the
entity here (Location, Opponent, ...) isn't scoped to one team, so "manager
of this team" doesn't apply; any management position qualifies."""
def test_func(self):
return is_club_admin(self.request.user, self.request.club) or is_coach_manager(self.request.user, self.request.club)
class NewsAuthorRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
author club content in the first place (creating a draft)."""
def test_func(self):
return can_add_news(self.request.user, self.request.club)
class NewsPublisherRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN/EDITOR only -- the release-flow gate for pushing a news item live
(or pulling it back)."""
def test_func(self):
return can_publish_news(self.request.user, self.request.club)
class NewsEditRequiredMixin(ClubStaffRequiredMixin):
"""Whoever may edit *this* news item right now: broad while it's a draft,
editor/admin-only once published. ``self.get_news_item()`` must return the
News the view acts on before ``test_func`` runs."""
def get_news_item(self):
raise NotImplementedError("Subclasses must return the News item this view acts on.")
def test_func(self):
return can_edit_news(self.request.user, self.get_news_item())

322
club/models.py Normal file
View File

@@ -0,0 +1,322 @@
import datetime
from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
class ClubManager(models.Manager):
def current(self):
"""Return the club for the active tenant context, if any."""
from .tenancy import get_current_club
return get_current_club()
def active(self):
return self.filter(archived_at__isnull=True)
def archived(self):
return self.filter(archived_at__isnull=False)
def club_logo_path(instance: Club, filename: str) -> str:
return f"clubs/{instance.slug}/{filename}"
class Club(UUIDModel):
class SportType(models.TextChoices):
"""Which sport this club plays. Only two options for now -- expand this as
more sport-specific competition fetchers (see events.competition) are added."""
ICE_HOCKEY = "ice_hockey", _("Ice hockey")
OTHER = "other", _("Other")
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
logo = models.FileField(
_("logo"),
upload_to=club_logo_path,
blank=True,
# A plain FileField, not ImageField: Pillow (which ImageField validates through)
# cannot read SVGs, and club crests are commonly vector logos.
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
help_text=_("Shown on the club's own pages. Without one, the club's initials are used."),
)
primary_color = models.CharField(
_("primary colour"),
max_length=7,
blank=True,
validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$", _("Enter a colour as a hex value, e.g. #1e40af."))],
help_text=_("Hex colour for buttons and links on the club's pages, e.g. #1e40af."),
)
secondary_color = models.CharField(
_("secondary colour"),
max_length=7,
blank=True,
validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$", _("Enter a colour as a hex value, e.g. #be185d."))],
help_text=_("Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour."),
)
sport_type = models.CharField(
_("sport"),
max_length=20,
choices=SportType.choices,
default=SportType.OTHER,
help_text=_("Which sport this club plays -- determines which competitions and score fetchers are relevant to it."),
)
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))
season_start = models.DateField(
_("season start"),
default=datetime.date(2000, 8, 1),
help_text=_("Which day of the year a season begins — only the month and day are used, the year is ignored."),
)
season_duration_months = models.PositiveSmallIntegerField(
_("season duration (months)"),
default=12,
validators=[MinValueValidator(1), MaxValueValidator(24)],
help_text=_("How many months a season lasts, counted from its start date."),
)
objects = ClubManager()
class Meta:
verbose_name = _("club")
verbose_name_plural = _("clubs")
ordering = ["name"]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = unique_slugify(self, self.name)
super().save(*args, **kwargs)
@property
def is_archived(self) -> bool:
return self.archived_at is not None
@property
def initials(self) -> str:
"""Stand-in for a missing logo. Never the RosterChief mark — that would
pass our branding off as the club's own."""
return "".join(word[0] for word in self.name.split()[:2]).upper()
@property
def primary_content_color(self) -> str:
"""Readable text colour to sit *on* ``primary_color``. See ``_content_color_for``."""
return self._content_color_for(self.primary_color)
@property
def secondary_content_color(self) -> str:
"""Readable text colour to sit *on* ``secondary_color``. See ``_content_color_for``."""
return self._content_color_for(self.secondary_color)
@staticmethod
def _content_color_for(hex_color: str) -> str:
"""Black or white, whichever reads on ``hex_color``.
A club picking a pale yellow would otherwise get white-on-yellow buttons.
Relative luminance per WCAG, with its 0.179 threshold for black vs white.
"""
if not hex_color:
return ""
def channel(value: int) -> float:
fraction = value / 255
return fraction / 12.92 if fraction <= 0.04045 else ((fraction + 0.055) / 1.055) ** 2.4
red, green, blue = (channel(int(hex_color[index : index + 2], 16)) for index in (1, 3, 5))
luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
return "#000000" if luminance > 0.179 else "#ffffff"
def archive(self):
"""Soft-delete: the club stops resolving, but nothing is destroyed.
Clubs are never hard-deleted — a club with any data cannot be removed
anyway (ClubMembership PROTECTs its Season), and financial records must
be retained.
"""
if not self.is_archived:
self.archived_at = timezone.now()
self.save(update_fields=["archived_at"])
def restore(self):
if self.is_archived:
self.archived_at = None
self.save(update_fields=["archived_at"])
def sponsor_logo_path(instance: Sponsor, filename: str) -> str:
return f"clubs/{instance.club.slug}/sponsors/{instance.pk}/{filename}"
class Sponsor(ClubScopedModel):
name = models.CharField(_("name"), max_length=255)
logo = models.FileField(
_("logo"),
upload_to=sponsor_logo_path,
blank=True,
# A plain FileField, not ImageField: same reasoning as Club.logo -- a
# sponsor's own logo is just as commonly a vector file, and ImageField's
# Pillow validation can't read those.
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
)
url = models.URLField(_("URL"), blank=True, help_text=_("The sponsor's own website, if they have one."))
start_date = models.DateField(_("start date"))
end_date = models.DateField(_("end date"), null=True, blank=True, help_text=_("Leave blank to keep this sponsor active indefinitely once it starts."))
class Meta:
verbose_name = _("sponsor")
verbose_name_plural = _("sponsors")
ordering = ["name"]
def __str__(self):
return self.name
def clean(self):
if self.end_date is not None and self.start_date is not None and self.end_date < self.start_date:
raise ValidationError({"end_date": _("End date can't be before the start date.")})
class Season(ClubScopedModel):
start_date = models.DateField(_("start date"))
end_date = models.DateField(_("end date"))
def __str__(self):
return self.name
class Meta:
verbose_name = _("season")
verbose_name_plural = _("seasons")
constraints = [
models.UniqueConstraint(fields=["club", "start_date", "end_date"], name="unique_season_dates_per_club"),
]
@property
def name(self):
"""Short label built from the start/end years, e.g. "25-26"."""
return f"{self.start_date:%y}-{self.end_date:%y}"
@classmethod
def get_current(cls, date: datetime.date | None = None):
"""Return the current club's season covering ``date`` (today by default)."""
if date is None:
date = timezone.now().date()
return cls.objects.current_club().filter(start_date__lte=date, end_date__gte=date).first()
@classmethod
def covering(cls, club, date: datetime.date):
"""Return ``club``'s season covering ``date`` (no tenant context needed)."""
return cls.objects.filter(club=club, start_date__lte=date, end_date__gte=date).first()
@classmethod
def next_after(cls, club, date: datetime.date):
"""Return ``club``'s soonest season starting after ``date`` (no tenant
context needed) -- the season that follows the one covering ``date``."""
return cls.objects.filter(club=club, start_date__gt=date).order_by("start_date").first()
class ClubMembership(ClubScopedModel):
class StatusChoices(models.TextChoices):
ACTIVE = "active", _("active")
PENDING = "pending", _("pending")
LAPSED = "lapsed", _("lapsed")
CANCELLED = "cancelled", _("cancelled")
class FeeStatus(models.TextChoices):
UNPAID = "unpaid", _("unpaid")
PAID = "paid", _("paid")
PARTIALLY_PAID = "partially_paid", _("partially paid")
WAIVED = "waived", _("waived")
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="member_of", verbose_name=_("member"))
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="memberships", verbose_name=_("season"))
license = models.CharField(_("license"), max_length=250, blank=True)
status = models.CharField(_("status"), max_length=250, choices=StatusChoices.choices, default=StatusChoices.PENDING)
fee_status = models.CharField(_("fee status"), max_length=250, choices=FeeStatus.choices, default=FeeStatus.UNPAID)
fee_amount = models.DecimalField(_("fee amount"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True)
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True, help_text=_("Kept in step with payments by the fee service; not hand-edited."))
signed_up_at = models.DateField(_("signed up at"), blank=True, null=True)
activated_at = models.DateField(_("activated at"), blank=True, null=True)
class Meta:
verbose_name = _("club membership")
verbose_name_plural = _("club memberships")
ordering = ["club", "member__last_name", "member__first_name"]
constraints = [
models.UniqueConstraint(fields=["club", "member", "season"], name="unique_member_per_club_per_season"),
]
def __str__(self):
return f"{self.club} - {self.member}"
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("season",))
class FeePayment(UUIDModel):
"""Money received against one membership's fee. Several may land on one
membership: a family paying in two installments must not read as unpaid, and
the part that did arrive has to be recorded somewhere. Not itself club-scoped
-- its club is reached through ``membership``, same as DuePayment/Due."""
class Method(models.TextChoices):
BANK_TRANSFER = "bank_transfer", _("bank transfer")
CASH = "cash", _("cash")
CARD = "card", _("card")
OTHER = "other", _("other")
membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="payments", verbose_name=_("membership"))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
note = models.TextField(_("note"), blank=True)
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_fee_payments", verbose_name=_("recorded by"))
class Meta:
verbose_name = _("fee payment")
verbose_name_plural = _("fee payments")
ordering = ["-paid_at"]
def __str__(self):
return f"{self.membership}{self.amount}"
class ClubRole(ClubScopedModel):
class Roles(models.TextChoices):
ADMIN = "admin", _("admin")
MEMBER = "member", _("member")
EDITOR = "editor", _("editor")
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="roles", verbose_name=_("member"))
role = models.CharField(_("role"), max_length=250, choices=Roles.choices, default=Roles.MEMBER)
class Meta:
verbose_name = _("club role")
verbose_name_plural = _("club roles")
ordering = ["club", "member__last_name", "member__first_name"]
constraints = [
models.UniqueConstraint(fields=["club", "member"], name="unique_member_per_club"),
]
def __str__(self):
return f"{self.club} - {self.member}"

10
club/services/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
from .access import can_edit_event, can_manage_shop, has_club_role, members_visible_to, roles_in_club, teams_managed_by
__all__ = [
"can_edit_event",
"can_manage_shop",
"has_club_role",
"members_visible_to",
"roles_in_club",
"teams_managed_by",
]

172
club/services/access.py Normal file
View File

@@ -0,0 +1,172 @@
"""Per-club access decisions.
All authorisation goes through this module (ARCHITECTURE §3): stored
``ClubRole`` rows plus object-scoped facts — coach/manager is *derived* from
``StaffAssignment`` (never a ClubRole), and parent/guardian from the family
graph. ADMIN is the club-wide override.
Two axes, deliberately separate:
* **Authority** (``teams_managed_by``, ``can_edit_event``) requires a
*management* position, and only for the **current season** — a StaffAssignment
is per-season, so a former coach's authority expires with it. (A ``ClubRole``,
by contrast, is permanent and survives a lapsed membership.)
* **Visibility** (``teams_staffed_by`` → ``members_visible_to``) covers *any*
staff position, so support staff (physio, kit manager) can see the roster they
work with without gaining any authority over it.
"""
from django.db.models import Q, QuerySet
from django.utils import timezone
from authentication.models import User
from club.models import Club, ClubRole, Season
from events.models import Event
from members.models import FamilyMembership, Member
from teams.models import StaffAssignment, Team
#: Derived (never stored) roles.
COACH = "coach"
MANAGER = "manager"
COACH_MANAGER = "coach_manager"
def current_season(club: Club) -> Season | None:
"""The club's season covering today. Staff authority is scoped to it."""
return Season.covering(club, timezone.localdate())
def event_season(event: Event) -> Season | None:
"""The season an event belongs to (explicit, else derived from its start)."""
return event.season or Season.covering(event.club, event.start.date())
def has_club_role(user: User, club: Club, role: ClubRole.Roles) -> bool:
return ClubRole.objects.filter(member__user=user, club=club, role=role).exists()
def is_club_admin(user: User, club: Club) -> bool:
return has_club_role(user, club, ClubRole.Roles.ADMIN)
def has_management_access(user: User, club: Club) -> bool:
"""Anyone with real authority in the club: ADMIN/EDITOR, or *any* current-season
staff assignment (coach, team manager, physio, ...).
Deliberately excludes the plain MEMBER role -- every signed-up player (or club
member generally) holds that automatically the moment their ClubMembership goes
active (club/signals.py), so it says nothing about whether someone is staff.
"""
elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)).exists()
return elevated or teams_staffed_by(user, club).exists()
def is_coach_manager(user: User, club: Club) -> bool:
"""Derived from a current-season StaffAssignment in a *management* position."""
return StaffAssignment.objects.filter(
member__user=user,
team__club=club,
position__management_position=True,
season=current_season(club),
).exists()
def roles_in_club(user: User, club: Club) -> set[str]:
"""The user's roles in ``club``, including the derived COACH_MANAGER role."""
roles = set(ClubRole.objects.filter(member__user=user, club=club).values_list("role", flat=True))
if is_coach_manager(user, club):
roles.add(COACH_MANAGER)
return roles
def teams_managed_by(user: User, club: Club) -> QuerySet[Team]:
"""Teams the user has authority over: all for an ADMIN, else the ones they
manage *this season* (management position only)."""
if is_club_admin(user, club):
return Team.objects.filter(club=club)
return Team.objects.filter(
club=club,
staff_assignments__member__user=user,
staff_assignments__position__management_position=True,
staff_assignments__season=current_season(club),
).distinct()
def teams_staffed_by(user: User, club: Club) -> QuerySet[Team]:
"""Teams the user is on the staff of this season, management or not.
Visibility only — being a team's physio grants sight of the roster, never
authority over it.
"""
return Team.objects.filter(
club=club,
staff_assignments__member__user=user,
staff_assignments__season=current_season(club),
).distinct()
def members_visible_to(user: User, club: Club) -> QuerySet[Member]:
"""Members the user may see.
ADMIN: everyone linked to the club (membership, roster, staff or role).
Otherwise: themselves, their children, and the current-season players *and*
staff of every team they're staffed on.
"""
if is_club_admin(user, club):
return Member.objects.filter(Q(member_of__club=club) | Q(team_memberships__team__club=club) | Q(staff_assignments__team__club=club) | Q(roles__club=club)).distinct()
me = Member.objects.filter(user=user).first()
if me is None:
return Member.objects.none()
children = Member.objects.filter(
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
family_memberships__family__memberships__member=me,
family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
)
season = current_season(club)
teams = teams_staffed_by(user, club)
roster = Member.objects.filter(Q(team_memberships__team__in=teams, team_memberships__season=season) | Q(staff_assignments__team__in=teams, staff_assignments__season=season))
visible = {me.pk} | set(children.values_list("pk", flat=True)) | set(roster.values_list("pk", flat=True))
return Member.objects.filter(pk__in=visible)
def can_edit_event(user: User, event: Event) -> bool:
"""ADMIN/EDITOR in the club, the event's owner, or a manager of one of its
teams *for that event's season*."""
club = event.club
if is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR):
return True
if event.created_by_id is not None and event.created_by.user_id == user.pk:
return True
return StaffAssignment.objects.filter(
member__user=user,
team__in=event.teams.all(),
position__management_position=True,
season=event_season(event),
).exists()
def can_manage_shop(user: User, club: Club) -> bool:
return is_club_admin(user, club)
def can_add_news(user: User, club: Club) -> bool:
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
author club content, not just anyone on staff (a physio shouldn't post news)."""
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR) or is_coach_manager(user, club)
def can_publish_news(user: User, club: Club) -> bool:
"""Only ADMIN/EDITOR may push a news item live -- the release-flow gate."""
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR)
def can_edit_news(user: User, news_item) -> bool:
"""Broad while it's a draft (anyone who could create one); editor/admin-only
once published -- an editor is accountable for what's actually live."""
if news_item.status == news_item.Status.PUBLISHED:
return can_publish_news(user, news_item.club)
return can_add_news(user, news_item.club)

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

@@ -0,0 +1,69 @@
"""Recording money received against a membership's fee.
Mirrors billing.services.dues.record_payment for a different kind of money: a
member's own club fee, not the club's platform subscription. amount_paid is kept in
step here, never recomputed by re-aggregating FeePayment on every read.
"""
from decimal import Decimal
from django.db.models import F
from django.utils import timezone
from club.models import ClubMembership, FeePayment
def remaining_balance(membership):
return max(membership.fee_amount - membership.amount_paid, Decimal("0.00"))
def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
"""Record money received against one membership's fee. Several payments may
land on one membership -- a family paying in two installments must not read as
unpaid. Updates amount_paid and re-syncs fee_status/status to match."""
payment = FeePayment.objects.create(membership=membership, amount=amount, method=method, reference=reference, note=note, recorded_by=recorded_by)
membership.amount_paid = F("amount_paid") + amount
membership.save(update_fields=["amount_paid"])
membership.refresh_from_db(fields=["amount_paid"])
_sync_fee_status(membership)
return payment
def mark_as_paid(membership, *, recorded_by=None):
"""The "settle this one" action behind both the per-row and bulk buttons. If
there's a real remaining balance, records it as a payment (auditable, shows up
in history); if fee_amount was never priced (remaining is 0), just flips the
flags directly -- there's no real transaction to log."""
remaining = remaining_balance(membership)
if remaining > 0:
record_payment(membership, amount=remaining, method=FeePayment.Method.OTHER, note="Marked as paid", recorded_by=recorded_by)
else:
_sync_fee_status(membership, force_paid=True)
def _sync_fee_status(membership, *, force_paid=False):
if membership.fee_status == ClubMembership.FeeStatus.WAIVED:
return # manual, independent of payments -- this never overrides it
if force_paid or (membership.fee_amount > 0 and membership.amount_paid >= membership.fee_amount):
new_status = ClubMembership.FeeStatus.PAID
elif membership.amount_paid > 0:
new_status = ClubMembership.FeeStatus.PARTIALLY_PAID
else:
new_status = ClubMembership.FeeStatus.UNPAID
membership.fee_status = new_status
update_fields = ["fee_status"]
# Same "become a full member" behavior the bulk action already had: settling
# the fee in full also activates the membership, once, first time only.
if new_status == ClubMembership.FeeStatus.PAID:
membership.status = ClubMembership.StatusChoices.ACTIVE
update_fields.append("status")
if membership.activated_at is None:
membership.activated_at = timezone.localdate()
update_fields.append("activated_at")
membership.save(update_fields=update_fields)

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

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

39
club/signals.py Normal file
View File

@@ -0,0 +1,39 @@
"""Keep ClubRole in sync with membership status.
An **active** ClubMembership grants the member a ``MEMBER`` ClubRole; when no
active membership remains in that club (status changed away from active, or the
membership was deleted), the ``MEMBER`` role is withdrawn.
A member holds at most one ClubRole per club (``unique_member_per_club``), so an
elevated role (ADMIN / EDITOR) is never downgraded or removed by this sync — it
simply takes precedence.
"""
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .models import ClubMembership, ClubRole
@receiver(post_save, sender=ClubMembership)
@receiver(post_delete, sender=ClubMembership)
def sync_member_role(sender, instance, **kwargs):
has_active = ClubMembership.objects.filter(
club_id=instance.club_id,
member_id=instance.member_id,
status=ClubMembership.StatusChoices.ACTIVE,
).exists()
if has_active:
# get_or_create keeps an existing ADMIN/EDITOR role untouched.
ClubRole.objects.get_or_create(
club_id=instance.club_id,
member_id=instance.member_id,
defaults={"role": ClubRole.Roles.MEMBER},
)
else:
ClubRole.objects.filter(
club_id=instance.club_id,
member_id=instance.member_id,
role=ClubRole.Roles.MEMBER,
).delete()

View File

@@ -0,0 +1,18 @@
{% extends "_club_base.html" %}
{% load lucide %}
{% block head_title %}Home{% endblock head_title %}
{% block main %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body">
<h1 class="card-title">{% lucide "party-popper" size=20 %} Welcome to {{ club.name }}</h1>
<p>
You are signed in as <span class="font-semibold">{{ user.get_full_name|default:user.email }}</span>.
</p>
<p class="text-sm opacity-70">The club site lands here. For now this page exists so signing in has somewhere to go.</p>
</div>
</div>
</div>
{% endblock main %}

104
club/tenancy.py Normal file
View File

@@ -0,0 +1,104 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from typing import TYPE_CHECKING
from django.conf import settings
if TYPE_CHECKING:
from .models import Club
__current_club: ContextVar = ContextVar("current_club", default=None)
def set_current_club(club: Club | None) -> Token:
"""Bind ``club`` to the current context and return a reset token."""
return __current_club.set(club)
def reset_current_club(token: Token) -> None:
"""Restore the club that was active before ``set_current_club``."""
__current_club.reset(token)
def get_current_club() -> Club | None:
return __current_club.get()
def require_current_club() -> Club:
club = get_current_club()
if club is None:
raise RuntimeError("No active club in context.")
return club
class ClubTenantMiddleware:
"""Resolve the active club from the request's subdomain.
The club whose ``slug`` matches the left-most host label (below the
configured base domain) is stored on ``request.club`` and pushed onto the
``current_club`` context variable for the duration of the request, so
service-layer code and managers can read it via ``get_current_club()``.
Requests that don't map to a club (bare base domain, ``www``, localhost,
an unknown slug) get ``request.club = None``.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
club = self.get_club(request)
request.club = club
token = set_current_club(club)
try:
return self.get_response(request)
finally:
reset_current_club(token)
def get_club(self, request) -> Club | None:
# Imported lazily: club.models imports rosterchief.base, which imports
# this module, so a top-level import would be circular.
from .models import Club
subdomain = self.get_subdomain(request)
if not subdomain:
return None
# Archived clubs stop resolving: their subdomain behaves as unknown.
return Club.objects.active().filter(slug=subdomain).first()
@staticmethod
def get_subdomain(request) -> str | None:
"""Extract the tenant slug from the request host, or ``None``."""
host = request.get_host().split(":")[0].lower().rstrip(".")
if not host:
return None
base_domain = getattr(settings, "ROSTERCHIEF_BASE_DOMAIN", "").lower().strip(".")
if base_domain:
# Only hosts under the configured base domain carry a tenant slug.
if host == base_domain:
return None
suffix = f".{base_domain}"
if not host.endswith(suffix):
return None
label = host[: -len(suffix)]
else:
# No base domain configured: treat "slug.example.com" style hosts
# (3+ labels) as tenant-bearing; leave bare/localhost hosts alone.
labels = host.split(".")
if len(labels) < 3:
return None
label = ".".join(labels[:-2])
# Use the left-most label only; ignore the marketing/www host.
subdomain = label.split(".")[0]
if subdomain in ("", "www"):
return None
return subdomain

1517
club/tests.py Normal file

File diff suppressed because it is too large Load Diff

23
club/views.py Normal file
View File

@@ -0,0 +1,23 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
from django.views.generic import TemplateView
class ClubHomeView(LoginRequiredMixin, TemplateView):
"""Placeholder landing page for a club subdomain — where members land after
signing in, until the club-facing site is built."""
template_name = "club/home.html"
def root(request):
"""``/`` means different things per tenant.
This is why allauth needs no login-redirect adapter: LOGIN_REDIRECT_URL is "/",
and "/" resolves itself — a club subdomain lands on the club, the base domain
hands off to the platform control panel.
"""
if request.club is None:
return redirect("controlpanel:dashboard")
return ClubHomeView.as_view()(request)

View File

@@ -1,118 +0,0 @@
"""
Django settings for clubmanager project.
Generated by 'django-admin startproject' using Django 6.0.6.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-dp#5%d=#&us!*zt&v(ve48!$wb4$%j$4lk8_jx#k8p!i2w^sut'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'clubmanager.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates']
,
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'clubmanager.wsgi.application'
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'

View File

@@ -1,22 +0,0 @@
"""
URL configuration for clubmanager project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/6.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
urlpatterns = [
path('admin/', admin.site.urls),
]

55
compose.behind-proxy.yaml Normal file
View File

@@ -0,0 +1,55 @@
# A dev/test deployment on a server that ALREADY runs Caddy on :80/:443.
#
# docker compose -f compose.behind-proxy.yaml up -d
#
# The difference from compose.yaml is only what listens on the network: no caddy service, and
# web publishes on the loopback instead of the public interface. The host's Caddy reverse
# proxies to it (see DEPLOYMENT.md, "Behind an existing Caddy").
#
# Publishing on 127.0.0.1 and not 0.0.0.0 is the point: bound to all interfaces, a test
# instance is reachable on http://<server-ip>:8001 with no TLS, bypassing the proxy and every
# security header with it.
name: rosterchief-test
services:
web:
build: .
restart: unless-stopped
env_file: .env.production
ports:
- "127.0.0.1:${WEB_PORT:-8001}:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-rosterchief}
POSTGRES_USER: ${POSTGRES_USER:-rosterchief}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a database password}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rosterchief}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
volumes:
pgdata:

72
compose.yaml Normal file
View File

@@ -0,0 +1,72 @@
# One server. The same image and the same environment variables run a multi-server
# deployment: point DJANGO_DATABASE_URL / DJANGO_REDIS_URL at your central services, set a
# bucket, drop the `db` and `redis` services, and run several `web` containers behind a load
# balancer. Nothing in the code changes.
name: rosterchief
services:
caddy:
build:
context: ./deploy/caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
# A wildcard certificate for *.rosterchief.app cannot be issued over HTTP-01 — Let's
# Encrypt only does wildcards via DNS-01. That is why Caddy needs a DNS API token, and
# why this image is built with the provider's DNS plugin rather than pulled as-is.
ROSTERCHIEF_BASE_DOMAIN: ${ROSTERCHIEF_BASE_DOMAIN:?set the base domain, e.g. rosterchief.app}
ACME_EMAIL: ${ACME_EMAIL:?set an email for Let's Encrypt}
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:?DNS-01 needs an API token with DNS:Edit on the zone}
volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
web:
build: .
restart: unless-stopped
env_file: .env.production
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-rosterchief}
POSTGRES_USER: ${POSTGRES_USER:-rosterchief}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a database password}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rosterchief}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
# Cache only, so nothing here needs to survive a restart. It is not optional though: it
# is what keeps every gunicorn worker agreeing about which feature flags are on.
volumes:
pgdata:
caddy_data:
caddy_config:

0
controlpanel/__init__.py Normal file
View File

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