Compare commits
91 Commits
e654311321
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 40255805c3 | |||
| 127d0e338e | |||
| 83caa233d7 | |||
| fc7a349f8f | |||
| f403128f57 | |||
| 91270b0cf8 | |||
| 19108407c6 | |||
| 10b113f244 | |||
| 5b51f2c945 | |||
| 4d74f3fbde | |||
| a1266378fc | |||
| a2bcb2f0c8 | |||
| 9bc5377cc5 | |||
| 7c874aa87b | |||
| 5283262e6b | |||
| 65a2f741f6 | |||
| 975426a17f | |||
| 9a616c20e4 | |||
| 2d43b0b903 | |||
| c42963c447 | |||
| 5d42691a10 | |||
| 534d01d6fe | |||
| 98e5f22873 | |||
| d30b163122 | |||
| c0a44093d9 | |||
| 35d1ec45a7 | |||
| e5a93194bf | |||
| 29d61eeea8 | |||
| 60bfac9881 | |||
| 6899e203f6 | |||
| 3a6dc00e05 | |||
| 192fe5ad0e | |||
| 639807b2d2 | |||
| f2b78bb1dd | |||
| 21d3947d08 | |||
| 016206a79e | |||
| 9127be0c42 | |||
| 1dd2e5099f | |||
| bf86654e72 | |||
| 0eb6838cba | |||
| c9935d0a04 | |||
| 7900233f6d | |||
| add1ee87ae | |||
| fa213d95ee | |||
| addfc61a2c | |||
| 2b7b2b64db | |||
| b3f153a2dc | |||
| 7b18b39f49 | |||
| e44933330d | |||
| 1a2bf257da | |||
| fc51e1903c | |||
| 334c706aec | |||
| 268cbe1e06 | |||
| fed24bfee3 | |||
| eace903f05 | |||
| 6f66df3ba4 | |||
| 848a8578de | |||
| ebb8bc3db1 | |||
| 10736fd5ee | |||
| 819700ad0c | |||
| 78fdcdf138 | |||
| d43ca0cfa8 | |||
| 22d971d48c | |||
| c0816a1add | |||
| c320931595 | |||
| 54aace8abb | |||
| 57f20fe544 | |||
| 5dd3715c1f | |||
| 2653dd6d08 | |||
| c535e5dc8c | |||
| ca34d26a8e | |||
| 866716d196 | |||
| 076a9cacbc | |||
| dd2e4b2169 | |||
| 3914765f90 | |||
| cda4960ffc | |||
| 3575d8f7b1 | |||
| e85a5f6300 | |||
| 72e7b5e070 | |||
| 919a68ed3a | |||
| 638f85fa07 | |||
| 4165729d61 | |||
| f3b1ae7e82 | |||
| 19b92d61f7 | |||
| 89c12c12b1 | |||
| 44fe658efa | |||
| b30522d3d0 | |||
| 8f71bb74c0 | |||
| aa2c6329b9 | |||
| 2ead824c5d | |||
| 537c023258 |
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
10
.env.compose.example
Normal 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
37
.env.example
Normal 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
38
.env.production.example
Normal 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
3
.gitignore
vendored
@@ -377,3 +377,6 @@ poetry.toml
|
|||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,django%
|
# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,django%
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
staticfiles/
|
||||||
|
|||||||
1
.idea/ClubManager.iml
generated
1
.idea/ClubManager.iml
generated
@@ -14,6 +14,7 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="uv (ClubManager) (3)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="uv (ClubManager) (3)" jdkType="Python SDK" />
|
||||||
|
|||||||
12
.idea/dataSources.xml
generated
Normal file
12
.idea/dataSources.xml
generated
Normal 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
6
.idea/markdown.xml
generated
Normal 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>
|
||||||
10
.idea/pySourceRootDetection.xml
generated
Normal file
10
.idea/pySourceRootDetection.xml
generated
Normal 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>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal 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>
|
||||||
861
ARCHITECTURE.md
Normal file
861
ARCHITECTURE.md
Normal file
@@ -0,0 +1,861 @@
|
|||||||
|
# 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` | planned | Editorial news for the public site | `Article`, `Category` |
|
||||||
|
| `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. |
|
||||||
|
|
||||||
|
`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. "2025–2026"
|
||||||
|
start_date DateField
|
||||||
|
end_date DateField
|
||||||
|
is_current BooleanField # exactly one true PER CLUB; enforce in save()/service
|
||||||
|
Meta: unique_together (club, name); ordering = ["-start_date"]; get_latest_by = "start_date"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Provide `Season.objects.current()` (scoped to the current club, §2.4) rather than
|
||||||
|
scattering `is_current=True` filters.
|
||||||
|
- Referenced by `Team`, `Event`, and `ClubMembership`. Use `on_delete=PROTECT` on those
|
||||||
|
FKs — deleting a season with data should be blocked.
|
||||||
|
|
||||||
|
**Season-scoped `ClubMembership`** (evolution of the built model, §4):
|
||||||
|
|
||||||
|
```
|
||||||
|
ClubMembership(ClubScopedModel) # -> carries `club`
|
||||||
|
member FK Member (CASCADE, related_name="club_memberships")
|
||||||
|
season FK Season (PROTECT, related_name="memberships")
|
||||||
|
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.1–3.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().
|
||||||
|
|
||||||
|
### 5.4 `news`, `pages`, `home` (public site / editorial)
|
||||||
|
|
||||||
|
```
|
||||||
|
news.Article(ClubScopedModel) # -> carries `club`
|
||||||
|
title, slug (SlugField), body (TextField)
|
||||||
|
excerpt (blank), cover_image (ImageField, null)
|
||||||
|
author FK members.Member (SET_NULL, null, related_name="articles")
|
||||||
|
category FK news.Category (SET_NULL, null)
|
||||||
|
is_published BooleanField; published_at DateTimeField (null)
|
||||||
|
Meta: unique_together (club, slug); ordering = ["-published_at"]
|
||||||
|
|
||||||
|
news.Category(ClubScopedModel): name, slug # Meta: unique_together (club, slug)
|
||||||
|
|
||||||
|
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.
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Article.author` 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.
|
||||||
|
- `cover_image` / hero images use `ImageField` → **media storage must be configured** (§8).
|
||||||
|
If page/news trees grow, consider a tree library later — start flat.
|
||||||
|
|
||||||
|
### 5.5 `search`
|
||||||
|
|
||||||
|
Likely **no models** — a search view over `Article`, `Page`, `Team`, `Event`. If moving to
|
||||||
|
Postgres full-text or an external index, add config here, not domain tables.
|
||||||
|
|
||||||
|
### 5.6 `formbuilder` — dynamic forms *(new app)*
|
||||||
|
|
||||||
|
Admins/editors define forms with a **variable number of fields** at runtime; submissions
|
||||||
|
are stored so they can be **reported on** later. This uses the classic EAV (entity-
|
||||||
|
attribute-value) shape with **normalized `Answer` rows as the single source of truth**
|
||||||
|
(decision §7 #9) — one row per answered field, with a JSON `value` to stay flexible across
|
||||||
|
field types. No parallel JSON blob on the submission — reporting reads `Answer`s directly.
|
||||||
|
|
||||||
|
```
|
||||||
|
Form(ClubScopedModel) # -> carries `club`
|
||||||
|
title, slug, description (blank)
|
||||||
|
is_active BooleanField
|
||||||
|
login_required BooleanField # members-only vs public submission
|
||||||
|
opens_at / closes_at DateTimeField (null) # optional submission window
|
||||||
|
max_submissions_per_user PositiveInteger (null) # null = unlimited
|
||||||
|
Meta: unique_together (club, slug); ordering = ["title"]
|
||||||
|
|
||||||
|
Field(UUIDModel) # a form's field definition (club implied by form)
|
||||||
|
form FK Form (CASCADE, related_name="fields")
|
||||||
|
key SlugField # stable machine name, unique per form (for reporting)
|
||||||
|
label CharField
|
||||||
|
field_type CharField (TextChoices: text | textarea | number | email | date |
|
||||||
|
choice | multichoice | checkbox | file)
|
||||||
|
required BooleanField
|
||||||
|
help_text CharField (blank)
|
||||||
|
order PositiveSmallIntegerField
|
||||||
|
is_active BooleanField (default=True) # soft-retire instead of deleting (see below)
|
||||||
|
options JSONField (default=list) # choices for choice/multichoice: [{value,label}]
|
||||||
|
Meta: unique_together (form, key); ordering = ["form", "order"]
|
||||||
|
|
||||||
|
Submission(UUIDModel) # container only — no answer data on it
|
||||||
|
form FK Form (CASCADE, related_name="submissions")
|
||||||
|
member FK Member (SET_NULL, null) # set when submitter is logged in
|
||||||
|
submitted_at DateTimeField
|
||||||
|
Meta: ordering = ["-submitted_at"]
|
||||||
|
|
||||||
|
Answer(UUIDModel) # CANONICAL store — one per answered field
|
||||||
|
submission FK Submission (CASCADE, related_name="answers")
|
||||||
|
field FK Field (PROTECT, related_name="answers")
|
||||||
|
value JSONField # scalar, list (multichoice), or file ref
|
||||||
|
Meta: unique_together (submission, field)
|
||||||
|
```
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
- **`Answer` is canonical; there is no denormalized JSON snapshot.** A submission's values
|
||||||
|
are always read/aggregated from its `Answer` rows. The submit service
|
||||||
|
(`formbuilder/services/submit.py`) validates the dynamic form and writes the `Submission`
|
||||||
|
+ its `Answer`s in one transaction. (If a flat per-submission view ever becomes a
|
||||||
|
hotspot, add a *derived, rebuildable* cache later — but the model stays the source.)
|
||||||
|
- **`Field.key` is immutable once submissions exist** — reporting joins on it. Deleting a
|
||||||
|
field with answers is blocked (`PROTECT`); **retire via `is_active=False`** instead.
|
||||||
|
- **Reporting** = a service/view producing per-field aggregates (counts per choice,
|
||||||
|
numeric averages, response rate) plus a wide CSV/Excel export (one column per field,
|
||||||
|
one row per submission). No extra model needed; add a saved-report model later only if
|
||||||
|
users need to persist report definitions.
|
||||||
|
- Rendering a `Form` to a Django form (and validating a submission) is a service concern —
|
||||||
|
build the form class dynamically from `Field` rows; don't hand-write form classes.
|
||||||
|
|
||||||
|
### 5.7 `shop` — cart, orders, payments & PDF invoices *(new app)*
|
||||||
|
|
||||||
|
A cart-like shop where a member (or parent) "buys" products — chiefly a **season
|
||||||
|
membership** — with payment-status tracking and generated invoices. Fulfilment of a
|
||||||
|
membership product writes back to the season-scoped `ClubMembership` (§5.1).
|
||||||
|
|
||||||
|
```
|
||||||
|
Product(ClubScopedModel) # -> carries `club`
|
||||||
|
name, slug, description (blank)
|
||||||
|
kind CharField (TextChoices: membership | event_fee | merchandise | donation)
|
||||||
|
price DecimalField(max_digits=8, decimal_places=2) # list price
|
||||||
|
season FK Season (PROTECT, null) # set for membership/event products
|
||||||
|
is_active BooleanField
|
||||||
|
# early-bird / prompt-payment discount (§5.7.1) — per-product toggle + deadline
|
||||||
|
early_bird_enabled BooleanField (default=False)
|
||||||
|
early_bird_deadline DateField (null) # discount valid through this date (inclusive)
|
||||||
|
early_bird_disc_type CharField (TextChoices DiscountType: PERCENT | AMOUNT, blank)
|
||||||
|
early_bird_disc_value DecimalField(max_digits=8, decimal_places=2, null) # 0–100 if PERCENT, else € off unit
|
||||||
|
Meta: unique_together (club, slug)
|
||||||
|
CheckConstraint: early_bird_enabled ⇒ deadline, disc_type, disc_value all set
|
||||||
|
# membership products fulfil into a ClubMembership for the chosen season + beneficiary
|
||||||
|
|
||||||
|
Cart(ClubScopedModel) # -> carries `club`; one open cart per (club, user)
|
||||||
|
user FK User (CASCADE, related_name="carts")
|
||||||
|
status CharField (TextChoices: open | checked_out | abandoned)
|
||||||
|
Meta: UniqueConstraint(club, user) WHERE status = open
|
||||||
|
|
||||||
|
CartItem(UUIDModel) # club implied by cart
|
||||||
|
cart FK Cart (CASCADE, related_name="items")
|
||||||
|
product FK Product (PROTECT)
|
||||||
|
beneficiary FK Member (PROTECT, null) # who this membership is FOR (parent buys for child)
|
||||||
|
quantity PositiveSmallIntegerField (default=1)
|
||||||
|
unit_price DecimalField # snapshot of price at add-to-cart time
|
||||||
|
Meta: unique_together (cart, product, beneficiary)
|
||||||
|
|
||||||
|
Order(ClubScopedModel) # -> carries `club`; created `pending` at checkout,
|
||||||
|
# frozen at finalize() (§5.7.1 lifecycle)
|
||||||
|
number CharField # human ref, allocated PER CLUB, e.g. "ORD-2026-00042"
|
||||||
|
purchaser FK Member (PROTECT, related_name="orders")
|
||||||
|
status CharField (TextChoices: pending | finalized | paid | partially_paid | cancelled | refunded)
|
||||||
|
subtotal DecimalField # Σ OrderLine.line_total (after per-line early-bird)
|
||||||
|
# order-level discounts are SELECTED from a club catalogue, not typed — see AppliedDiscount
|
||||||
|
# below + OrderDiscountType (§5.7.1); applied by a treasurer while status=pending.
|
||||||
|
total DecimalField # subtotal - Σ applied discounts; the amount invoiced
|
||||||
|
created_at DateTimeField
|
||||||
|
finalized_at DateTimeField (null)
|
||||||
|
Meta: unique_together (club, number); ordering = ["-created_at"]
|
||||||
|
|
||||||
|
OrderLine(UUIDModel) # club implied by order
|
||||||
|
order FK Order (CASCADE, related_name="lines")
|
||||||
|
product FK Product (PROTECT)
|
||||||
|
beneficiary FK Member (PROTECT, null)
|
||||||
|
quantity PositiveSmallIntegerField
|
||||||
|
list_price DecimalField # catalogue unit price at checkout (snapshot)
|
||||||
|
unit_price DecimalField # price actually charged after per-line early-bird (snapshot)
|
||||||
|
discount_label CharField (blank) # e.g. "Early bird (−15%)" — shown on invoice; blank = none
|
||||||
|
line_total DecimalField # unit_price * quantity
|
||||||
|
fulfilled_at DateTimeField (null) # when this line's ClubMembership was activated
|
||||||
|
|
||||||
|
Payment(UUIDModel) # club implied by order; an order may have several (partial)
|
||||||
|
order FK Order (CASCADE, related_name="payments")
|
||||||
|
amount DecimalField
|
||||||
|
method CharField (TextChoices: bank_transfer | cash | card | online)
|
||||||
|
status CharField (TextChoices: pending | confirmed | failed | refunded)
|
||||||
|
reference CharField (blank) # bank/gateway reference
|
||||||
|
paid_at DateTimeField (null)
|
||||||
|
|
||||||
|
OrderDiscountType(ClubScopedModel) # -> carries `club`; club-defined catalogue of presets
|
||||||
|
name CharField # "Sibling discount", "Volunteer", "Hardship"
|
||||||
|
slug SlugField
|
||||||
|
disc_type CharField (TextChoices DiscountType: PERCENT | AMOUNT)
|
||||||
|
value DecimalField(max_digits=8, decimal_places=2) # 0–100 if PERCENT, else € off subtotal
|
||||||
|
description CharField (blank) # optional note shown to the treasurer
|
||||||
|
is_active BooleanField (default=True) # soft-retire; keeps historical AppliedDiscounts valid
|
||||||
|
Meta: unique_together (club, slug); ordering = ["name"]
|
||||||
|
|
||||||
|
AppliedDiscount(UUIDModel) # through: Order <-> OrderDiscountType; club implied by order
|
||||||
|
order FK Order (CASCADE, related_name="discounts")
|
||||||
|
discount_type FK OrderDiscountType (PROTECT, related_name="applications")
|
||||||
|
# snapshot at apply time — the preset may be edited/retired later without altering past orders
|
||||||
|
label CharField # snapshot of name (shown on invoice)
|
||||||
|
disc_type CharField (PERCENT | AMOUNT) # snapshot
|
||||||
|
value DecimalField # snapshot (or a treasurer override, if allowed)
|
||||||
|
applied_by FK User (SET_NULL, null, related_name="+")
|
||||||
|
applied_at DateTimeField
|
||||||
|
Meta: unique_together (order, discount_type) # a preset toggles on/off once per order
|
||||||
|
|
||||||
|
Invoice(ClubScopedModel) # -> carries `club`
|
||||||
|
number CharField # sequential PER CLUB per year, e.g. "INV-2026-00042"
|
||||||
|
order OneToOneField Order (PROTECT, related_name="invoice")
|
||||||
|
issued_at DateTimeField
|
||||||
|
due_date DateField (null)
|
||||||
|
billing_snapshot JSONField # name/address frozen at issue time
|
||||||
|
pdf FileField (null) # rendered HTML->PDF, cached in PRIVATE storage (§8)
|
||||||
|
Meta: unique_together (club, number)
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow & design notes:
|
||||||
|
- **Cart → checkout → order.** Checkout converts the open `Cart` into an immutable `Order`
|
||||||
|
+ `OrderLine`s, snapshotting prices (products may reprice later). The cart is marked
|
||||||
|
`checked_out`. All in one transactional service (`shop/services/checkout.py`).
|
||||||
|
- **Payment status is derived, not typed by hand.** A service sums `confirmed` `Payment`s
|
||||||
|
and sets `Order.status` (`pending` → `partially_paid` → `paid`). Online payments arrive
|
||||||
|
via a gateway webhook that creates/confirms a `Payment`; manual methods
|
||||||
|
(bank transfer/cash) are confirmed by a `TREASURER` (§3.2).
|
||||||
|
- **Fulfilment writes back to membership.** When an order (or a membership line) reaches
|
||||||
|
`paid`, a service creates/activates the `ClubMembership(member=beneficiary, season=…)`,
|
||||||
|
flips its `fee_status → paid` / `status → active`, stamps `OrderLine.fulfilled_at`, and
|
||||||
|
triggers role reconciliation (§3.4). This is the seam that ties the shop to the domain.
|
||||||
|
- **Beneficiary vs. purchaser** is first-class: a parent (`purchaser`) buys memberships for
|
||||||
|
several children (`beneficiary`) in one order. Both are `Member`s of the same club.
|
||||||
|
- **Invoice = HTML → PDF.** Render a Django template to HTML, convert with **WeasyPrint**
|
||||||
|
(see §8 for the exact dependency + native-library setup). Generate on order confirmation,
|
||||||
|
store the file on `Invoice.pdf` in **private** storage, and serve it only through a
|
||||||
|
permission-checked view (never a public media URL — invoices are tenant-private, §8).
|
||||||
|
`Invoice.number` uses a gap-free counter **per club per year** — allocate it in a
|
||||||
|
transaction/service (e.g. a `select_for_update` sequence row), not from `count()`.
|
||||||
|
- **Money = `DecimalField`**, never float. Snapshot prices onto cart items / order lines /
|
||||||
|
invoices so historical records stay correct when `Product.price` changes.
|
||||||
|
|
||||||
|
#### 5.7.1 Discounts
|
||||||
|
|
||||||
|
Two independent discount mechanisms, applied at different layers and computed by a single
|
||||||
|
**pricing service** (`shop/services/pricing.py`) so the rules live in one place and never
|
||||||
|
in views/templates. A shared `DiscountType` enum (`PERCENT` / `AMOUNT`) is reused by both.
|
||||||
|
|
||||||
|
**A. Early-bird / prompt-payment discount — per `Product`, automatic.**
|
||||||
|
A club toggles `early_bird_enabled` on a product, sets an `early_bird_deadline`, and a
|
||||||
|
`PERCENT` or `AMOUNT` value (§5.7 `Product`). Semantics: *buy in time and the unit price
|
||||||
|
drops.*
|
||||||
|
- **Anchor = checkout date (recommended).** The discount is evaluated **once, at checkout**,
|
||||||
|
comparing the order's `created_at` date against the deadline, and the result is frozen into
|
||||||
|
`OrderLine.unit_price` (+ a human `discount_label`, with `list_price` preserved for
|
||||||
|
transparency). This keeps the order/invoice total firm — an invoice can't have a
|
||||||
|
conditional amount. To still reward *paying* early, set the membership `Invoice.due_date`
|
||||||
|
to the deadline; late non-payment is a dunning concern, not a repricing one.
|
||||||
|
- **Alternative (payment-date anchor)** — the discount only sticks if a confirmed `Payment`
|
||||||
|
lands by the deadline, else the line reprices to `list_price`. This makes the total mutable
|
||||||
|
until the deadline and complicates invoicing; it's the literal reading of "paid before
|
||||||
|
date" but is deferred unless a club needs it (see open question, §7).
|
||||||
|
- Only applies when `today <= early_bird_deadline`; otherwise the line charges `list_price`.
|
||||||
|
A `CheckConstraint` guarantees an enabled product has a deadline + type + value.
|
||||||
|
|
||||||
|
**B. Order-level discount — selected from a club catalogue of presets.**
|
||||||
|
Rather than typing a type + value + reason per order, each club **defines named presets once**
|
||||||
|
as `OrderDiscountType` rows (e.g. *Sibling discount −15%*, *Volunteer −€25*, *Hardship*),
|
||||||
|
managed under the club's shop settings. On a `pending` order a `TREASURER`/`BOARD` (§3.2)
|
||||||
|
simply **toggles the applicable presets on** — each toggle creates an `AppliedDiscount` row.
|
||||||
|
No arithmetic is entered at order time; the treasurer picks from a list.
|
||||||
|
- **Snapshot, like prices.** `AppliedDiscount` copies the preset's `label` / `disc_type` /
|
||||||
|
`value` at apply time. Editing or retiring (`is_active=False`) an `OrderDiscountType` later
|
||||||
|
never rewrites past orders — historical totals stay correct. `PROTECT` on the FK means a
|
||||||
|
used preset can't be hard-deleted; retire it instead.
|
||||||
|
- **Multiple discounts stack** — several presets can apply to one order (a preset toggles on
|
||||||
|
at most once, via `unique_together (order, discount_type)`). See stacking rule below.
|
||||||
|
- **Optional override.** The default flow enters *zero* numbers. If a club needs a one-off
|
||||||
|
amount (e.g. a bespoke hardship figure), allow the treasurer to override the snapshot
|
||||||
|
`value` on that `AppliedDiscount` — an opt-in escape hatch, not the primary path. A pure
|
||||||
|
ad-hoc discount is then just a generic "Custom" preset with an overridden value.
|
||||||
|
- **Lifecycle refinement.** Discounts force the order to be *editable before it freezes*:
|
||||||
|
checkout creates the order as **`pending`**; a treasurer toggles presets on/off while
|
||||||
|
`pending`; `finalize()` then locks the order + its `AppliedDiscount`s, computes the final
|
||||||
|
`total`, allocates the `Invoice.number`, and issues the PDF. **After `finalize` everything
|
||||||
|
is immutable** — a correction means a credit/refund, not an edit. Gated by
|
||||||
|
`can_manage_shop(user, club)`.
|
||||||
|
|
||||||
|
**Computation & rounding (both kinds).**
|
||||||
|
`total = subtotal − Σ applied_discounts`, where `subtotal = Σ line_total` and each
|
||||||
|
`line_total` already reflects the early-bird price. Order of application: **line-level
|
||||||
|
early-bird first, then all order-level presets**, each computed against the **same
|
||||||
|
`subtotal` base** (percentages don't compound on each other — predictable and order-
|
||||||
|
independent) and summed. Percentages compute on their base, round **`ROUND_HALF_UP` to 2
|
||||||
|
decimals**; the **summed** order discount is **clamped to `[0, subtotal]`** so an order can
|
||||||
|
never go negative. Every discounted document (order summary, invoice) itemises each applied
|
||||||
|
discount by `label` plus the net so members see how the number was reached.
|
||||||
|
|
||||||
|
**Extension point.** `OrderDiscountType` is the reusable catalogue for the two required cases.
|
||||||
|
Coupon *codes* (member-entered), auto-applied promotions (rule-based, e.g. "3+ siblings"), or
|
||||||
|
per-member entitlements would extend this — add an eligibility rule / code field or an
|
||||||
|
auto-apply service on top of the same model when that need is real, rather than a parallel
|
||||||
|
mechanism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
BIN
ARCHITECTURE.pdf
Normal file
Binary file not shown.
55
CLAUDE.md
Normal file
55
CLAUDE.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 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.
|
||||||
594
DEPLOYMENT.md
Normal file
594
DEPLOYMENT.md
Normal 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 30–60s 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 **1–5 clubs, ~1000 members, ~10 events per club per week**.
|
||||||
|
|
||||||
|
The short answer: **2 vCPU, 4 GB RAM, 40 GB SSD** — a €4–6/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`) | ~200–400 MB |
|
||||||
|
| Redis (cache only) | < 50 MB |
|
||||||
|
| Caddy | ~30 MB |
|
||||||
|
| OS + Docker daemon | ~400 MB |
|
||||||
|
| **steady state** | **~1.0–1.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 ~50–100 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 | 2–4 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 | **~$110–130/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
94
Dockerfile
Normal 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", "-"]
|
||||||
195
assets/app.css
Normal file
195
assets/app.css
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
@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";
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
37
authentication/adapters.py
Normal file
37
authentication/adapters.py
Normal 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
44
authentication/admin.py
Normal 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
5
authentication/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationConfig(AppConfig):
|
||||||
|
name = "authentication"
|
||||||
20
authentication/forms.py
Normal file
20
authentication/forms.py
Normal 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__"
|
||||||
32
authentication/managers.py
Normal file
32
authentication/managers.py
Normal 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)
|
||||||
49
authentication/middleware.py
Normal file
49
authentication/middleware.py
Normal 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)
|
||||||
94
authentication/migrations/0001_initial.py
Normal file
94
authentication/migrations/0001_initial.py
Normal 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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
authentication/migrations/0002_alter_family_name.py
Normal file
18
authentication/migrations/0002_alter_family_name.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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?'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
0
authentication/migrations/__init__.py
Normal file
0
authentication/migrations/__init__.py
Normal file
41
authentication/models.py
Normal file
41
authentication/models.py
Normal 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
|
||||||
470
authentication/tests.py
Normal file
470
authentication/tests.py
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
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_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
0
billing/__init__.py
Normal file
53
billing/admin.py
Normal file
53
billing/admin.py
Normal 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
7
billing/apps.py
Normal 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"
|
||||||
0
billing/management/__init__.py
Normal file
0
billing/management/__init__.py
Normal file
0
billing/management/commands/__init__.py
Normal file
0
billing/management/commands/__init__.py
Normal file
38
billing/management/commands/archive_overdue_clubs.py
Normal file
38
billing/management/commands/archive_overdue_clubs.py
Normal 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."))
|
||||||
57
billing/management/commands/renew_subscriptions.py
Normal file
57
billing/management/commands/renew_subscriptions.py
Normal 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))
|
||||||
138
billing/migrations/0001_initial.py
Normal file
138
billing/migrations/0001_initial.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
billing/migrations/0002_subscription_auto_renew.py
Normal file
18
billing/migrations/0002_subscription_auto_renew.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
billing/migrations/__init__.py
Normal file
0
billing/migrations/__init__.py
Normal file
253
billing/models.py
Normal file
253
billing/models.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""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 django.conf import settings
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from django.db import models
|
||||||
|
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:
|
||||||
|
"""The day one year on. 29 February has no counterpart in a common year, so it falls
|
||||||
|
back to the 28th rather than raising."""
|
||||||
|
try:
|
||||||
|
return day.replace(year=day.year + 1)
|
||||||
|
except ValueError:
|
||||||
|
return day.replace(year=day.year + 1, day=28)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("subscription")
|
||||||
|
verbose_name_plural = _("subscriptions")
|
||||||
|
ordering = ["club__name"]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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}"
|
||||||
6
billing/services/__init__.py
Normal file
6
billing/services/__init__.py
Normal 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.
|
||||||
|
"""
|
||||||
181
billing/services/dues.py
Normal file
181
billing/services/dues.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""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
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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) -> Due:
|
||||||
|
"""Issue the next due for a club, snapshotting the tier and the price of the day."""
|
||||||
|
subscription = getattr(club, "subscription", None)
|
||||||
|
tier = tier or (subscription.tier if subscription else None)
|
||||||
|
if tier is None:
|
||||||
|
raise BillingError(f"{club} has no tier: put it on a subscription before billing it.")
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
40
billing/services/invoices.py
Normal file
40
billing/services/invoices.py
Normal 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)
|
||||||
106
billing/templates/billing/invoice.html
Normal file
106
billing/templates/billing/invoice.html
Normal 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 & 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>
|
||||||
490
billing/tests.py
Normal file
490
billing/tests.py
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
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, 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)])
|
||||||
0
club/__init__.py
Normal file
0
club/__init__.py
Normal file
41
club/admin.py
Normal file
41
club/admin.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from .models import Club, ClubMembership, ClubRole, Season
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Club)
|
||||||
|
class ClubAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "slug"]
|
||||||
|
search_fields = ["name", "slug"]
|
||||||
|
prepopulated_fields = {"slug": ["name"]}
|
||||||
|
ordering = ["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"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ClubMembership)
|
||||||
|
class ClubMembershipAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "license"]
|
||||||
|
search_fields = ["club__name", "member__last_name", "member__first_name", "license"]
|
||||||
|
list_filter = ["club", "season", "status", "fee_status"]
|
||||||
|
raw_id_fields = ["member"]
|
||||||
|
fieldsets = [
|
||||||
|
[None, {"fields": ["club", "season", "member"]}],
|
||||||
|
[_("Membership"), {"fields": ["license", "status", "fee_status"]}],
|
||||||
|
[_("Dates"), {"fields": ["signed_up_at", "activated_at"]}],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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"]
|
||||||
8
club/apps.py
Normal file
8
club/apps.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ClubConfig(AppConfig):
|
||||||
|
name = "club"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from . import signals # noqa: F401
|
||||||
23
club/context_processors.py
Normal file
23
club/context_processors.py
Normal 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,
|
||||||
|
}
|
||||||
36
club/migrations/0001_initial.py
Normal file
36
club/migrations/0001_initial.py
Normal 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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
club/migrations/0004_alter_clubmembership_license.py
Normal file
18
club/migrations/0004_alter_clubmembership_license.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
club/migrations/0005_alter_clubmembership_member.py
Normal file
20
club/migrations/0005_alter_clubmembership_member.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
club/migrations/0006_club_slug_season.py
Normal file
33
club/migrations/0006_club_slug_season.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
31
club/migrations/0009_clubrole.py
Normal file
31
club/migrations/0009_clubrole.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
club/migrations/0010_club_archived_at.py
Normal file
18
club/migrations/0010_club_archived_at.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
club/migrations/0011_alter_club_slug.py
Normal file
18
club/migrations/0011_alter_club_slug.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
25
club/migrations/0012_club_logo_club_primary_color.py
Normal file
25
club/migrations/0012_club_logo_club_primary_color.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
club/migrations/__init__.py
Normal file
0
club/migrations/__init__.py
Normal file
195
club/models.py
Normal file
195
club/models.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.core.validators import 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):
|
||||||
|
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.ImageField(_("logo"), upload_to=club_logo_path, blank=True, 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."),
|
||||||
|
)
|
||||||
|
|
||||||
|
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))
|
||||||
|
|
||||||
|
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``.
|
||||||
|
|
||||||
|
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 self.primary_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(self.primary_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"])
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 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
10
club/services/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
141
club/services/access.py
Normal file
141
club/services/access.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""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 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)
|
||||||
39
club/signals.py
Normal file
39
club/signals.py
Normal 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()
|
||||||
18
club/templates/club/home.html
Normal file
18
club/templates/club/home.html
Normal 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
104
club/tenancy.py
Normal 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
|
||||||
1070
club/tests.py
Normal file
1070
club/tests.py
Normal file
File diff suppressed because it is too large
Load Diff
23
club/views.py
Normal file
23
club/views.py
Normal 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)
|
||||||
@@ -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/'
|
|
||||||
@@ -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
55
compose.behind-proxy.yaml
Normal 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
72
compose.yaml
Normal 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
0
controlpanel/__init__.py
Normal file
5
controlpanel/apps.py
Normal file
5
controlpanel/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ControlpanelConfig(AppConfig):
|
||||||
|
name = "controlpanel"
|
||||||
117
controlpanel/forms.py
Normal file
117
controlpanel/forms.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from waffle import get_waffle_flag_model
|
||||||
|
|
||||||
|
from billing.models import DuePayment, Subscription, Tier, TierPrice
|
||||||
|
from club.models import Club
|
||||||
|
|
||||||
|
from .services.admins import find_member_by_email
|
||||||
|
|
||||||
|
|
||||||
|
class ClubForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Club
|
||||||
|
fields = ["name", "slug", "logo", "primary_color"]
|
||||||
|
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
|
||||||
|
# Deliberately a text input, not <input type="color">: a colour picker cannot
|
||||||
|
# express "no colour" -- it would submit #000000 for every club that never
|
||||||
|
# touched it, and every club would silently get a black theme.
|
||||||
|
widgets = {"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"})}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["slug"].required = False
|
||||||
|
|
||||||
|
|
||||||
|
class ClubAdminForm(forms.Form):
|
||||||
|
"""Grant club-admin rights to an email address, creating the person if new."""
|
||||||
|
|
||||||
|
email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
|
||||||
|
first_name = forms.CharField(label=_("First name"), required=False)
|
||||||
|
last_name = forms.CharField(label=_("Last name"), required=False)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
email = cleaned.get("email")
|
||||||
|
|
||||||
|
# Only a brand-new person needs a name; an existing member already has one.
|
||||||
|
if email and find_member_by_email(email) is None:
|
||||||
|
for field in ("first_name", "last_name"):
|
||||||
|
if not cleaned.get(field):
|
||||||
|
self.add_error(field, _("Required: this email has no account yet."))
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformAdminForm(forms.Form):
|
||||||
|
"""Grant platform access to an email address, creating the account if new."""
|
||||||
|
|
||||||
|
email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
|
||||||
|
is_superuser = forms.BooleanField(label=_("Superuser"), required=False, help_text=_("Superusers can manage platform admins. Everyone granted access is staff."))
|
||||||
|
|
||||||
|
|
||||||
|
class FlagForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = get_waffle_flag_model()
|
||||||
|
fields = ["name", "note", "everyone", "superusers", "staff", "percent"]
|
||||||
|
help_texts = {
|
||||||
|
"everyone": _("Yes = on for all clubs, No = off everywhere (overrides club targeting). Leave unknown to target clubs."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TierForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Tier
|
||||||
|
fields = ["name", "description", "is_active"]
|
||||||
|
|
||||||
|
|
||||||
|
class TierPriceForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = TierPrice
|
||||||
|
fields = ["active_from", "amount"]
|
||||||
|
widgets = {"active_from": forms.DateInput(attrs={"type": "date"})}
|
||||||
|
help_texts = {"active_from": _("Periods opening on or after this date are billed at this amount. Existing periods keep the amount they were billed at.")}
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionForm(forms.ModelForm):
|
||||||
|
"""Put a club on a tier. The first period opens when the subscription is created."""
|
||||||
|
|
||||||
|
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("First period starts"), help_text=_("Left blank, the period starts today."))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Subscription
|
||||||
|
fields = ["tier", "auto_renew", "auto_archive", "notes"]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# An inactive tier still bills its existing subscriptions, but must not be picked up
|
||||||
|
# by a new one — which is the whole point of retiring a tier.
|
||||||
|
self.fields["tier"].queryset = Tier.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DuePaymentForm(forms.Form):
|
||||||
|
amount = forms.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal("0.01"), label=_("Amount"))
|
||||||
|
method = forms.ChoiceField(choices=DuePayment.Method.choices, initial=DuePayment.Method.BANK_TRANSFER, label=_("Method"))
|
||||||
|
reference = forms.CharField(required=False, label=_("Reference"), help_text=_("Bank reference, transaction id — whatever lets you find this again."))
|
||||||
|
paid_at = forms.DateTimeField(required=False, widget=forms.DateTimeInput(attrs={"type": "datetime-local"}), label=_("Received"), help_text=_("Left blank, now."))
|
||||||
|
note = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 2}), label=_("Note"))
|
||||||
|
|
||||||
|
|
||||||
|
class OpenPeriodForm(forms.Form):
|
||||||
|
"""Renew, or reactivate an archived club."""
|
||||||
|
|
||||||
|
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("Period starts"), help_text=_("Left blank, it continues from the end of the last period — so a lapsed year is still owed."))
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceForm(forms.Form):
|
||||||
|
"""Closing the platform is a deliberate act, so it takes a sentence explaining itself —
|
||||||
|
that message is the only thing a club will see."""
|
||||||
|
|
||||||
|
message = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 2}),
|
||||||
|
label=_("Message"),
|
||||||
|
help_text=_("Shown to every club while the platform is closed. Left blank, they get a generic notice."),
|
||||||
|
)
|
||||||
35
controlpanel/messages.py
Normal file
35
controlpanel/messages.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""A compact way to queue a Django message that carries its own title.
|
||||||
|
|
||||||
|
Django's messages framework has no title field — a call site that wants one passes it
|
||||||
|
as ``extra_tags`` (``messages.success(request, body, extra_tags="Club created")``), which
|
||||||
|
reads fine written out but is easy to forget, so in practice every message ends up on
|
||||||
|
the generic per-level heading (`as_alert`'s "Done" / "Careful" / "Something went wrong").
|
||||||
|
|
||||||
|
``notify`` folds level, title and body into one string instead: ``"<level>|<title>|<body>"``.
|
||||||
|
One call, title included, nothing to forget. `as_alert` (controlpanel/templatetags/ui.py)
|
||||||
|
reads the title back off ``extra_tags`` at render time — unchanged from before.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
|
#: One letter per Django message level. `notify` picks the level from the spec string;
|
||||||
|
#: `as_alert` picks the icon/colour/fallback-title from the level the message actually
|
||||||
|
#: carries (via ``level_tag``), so the two stay in step by construction.
|
||||||
|
LEVELS = {
|
||||||
|
"s": messages.SUCCESS,
|
||||||
|
"i": messages.INFO,
|
||||||
|
"w": messages.WARNING,
|
||||||
|
"e": messages.ERROR,
|
||||||
|
"d": messages.DEBUG,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def notify(request, spec: str, **kwargs) -> None:
|
||||||
|
"""Queue a message from a ``"<level>|<title>|<body>"`` spec.
|
||||||
|
|
||||||
|
``level`` is one of ``s`` (success), ``i`` (info), ``w`` (warning), ``e`` (error),
|
||||||
|
``d`` (debug). An empty title (``"s||Body text"``) falls back to the generic
|
||||||
|
per-level heading, same as never passing ``extra_tags`` at all.
|
||||||
|
"""
|
||||||
|
level_code, title, body = spec.split("|", 2)
|
||||||
|
messages.add_message(request, LEVELS[level_code], body, extra_tags=title, **kwargs)
|
||||||
0
controlpanel/migrations/__init__.py
Normal file
0
controlpanel/migrations/__init__.py
Normal file
61
controlpanel/mixins.py
Normal file
61
controlpanel/mixins.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
|
from django.http import Http404
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
|
||||||
|
from .messages import notify
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformStaffRequiredMixin(UserPassesTestMixin):
|
||||||
|
"""Gate for the platform control panel.
|
||||||
|
|
||||||
|
Two rules:
|
||||||
|
|
||||||
|
* **Staff only.** ``is_staff`` or ``is_superuser``. Anonymous visitors are
|
||||||
|
sent to the login page; signed-in non-staff get a 403 (Django's
|
||||||
|
AccessMixin already distinguishes those two cases). Staff must also hold a
|
||||||
|
second factor — ``RequireMFAMiddleware`` enforces that, so the panel is
|
||||||
|
2FA-protected for free.
|
||||||
|
* **Base domain only.** The panel manages *all* clubs, so it must not be
|
||||||
|
reachable from inside one. If the tenant middleware resolved a club from
|
||||||
|
the subdomain, the panel does not exist here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if getattr(request, "club", None) is not None:
|
||||||
|
raise Http404("The control panel is not available on a club subdomain.")
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
user = self.request.user
|
||||||
|
return user.is_staff or user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformSuperuserRequiredMixin(PlatformStaffRequiredMixin):
|
||||||
|
"""Superusers only.
|
||||||
|
|
||||||
|
Managing platform admins is the one thing staff may not do. The panel is
|
||||||
|
gated on ``is_staff or is_superuser``, so if a staff member could grant
|
||||||
|
themselves ``is_superuser`` the two would collapse into the same thing and
|
||||||
|
``is_superuser`` would stop being a security boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
return self.request.user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectOnInvalidMixin:
|
||||||
|
"""A form submitted from a modal has nowhere sensible to re-render on error: the page
|
||||||
|
that opened it has already moved on, and the view has no standalone template of its
|
||||||
|
own. Redirect back to ``invalid_redirect_url_name`` instead, with the errors flattened
|
||||||
|
into messages, rather than Django's default of re-rendering ``template_name``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
invalid_redirect_url_name = None
|
||||||
|
|
||||||
|
def get_invalid_redirect_kwargs(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
for error in form.errors.values():
|
||||||
|
notify(self.request, f"e|Couldn't save|{' '.join(error)}")
|
||||||
|
return redirect(self.invalid_redirect_url_name, **self.get_invalid_redirect_kwargs())
|
||||||
0
controlpanel/services/__init__.py
Normal file
0
controlpanel/services/__init__.py
Normal file
47
controlpanel/services/admins.py
Normal file
47
controlpanel/services/admins.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Granting and revoking club-admin rights from the platform panel."""
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from club.models import ClubRole
|
||||||
|
from members.models import Member
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def find_member_by_email(email):
|
||||||
|
"""The Member behind a login email, if that account exists at all."""
|
||||||
|
return Member.objects.filter(user__email__iexact=email).first()
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def grant_club_admin(club, email, first_name="", last_name=""):
|
||||||
|
"""Make the holder of ``email`` an ADMIN of ``club``, creating them if new.
|
||||||
|
|
||||||
|
A ClubRole hangs off a Member, and a Member optionally links to a User — so
|
||||||
|
an admin who has never existed needs both. The account is created without a
|
||||||
|
usable password; they set one via the password-reset flow.
|
||||||
|
"""
|
||||||
|
email = email.lower()
|
||||||
|
user, created_user = User.objects.get_or_create(email=email, defaults={"is_active": True})
|
||||||
|
if created_user:
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save(update_fields=["password"])
|
||||||
|
|
||||||
|
member, _ = Member.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
defaults={"first_name": first_name, "last_name": last_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
# One role per member per club, so promote rather than add a second row.
|
||||||
|
role, created_role = ClubRole.objects.get_or_create(club=club, member=member, defaults={"role": ClubRole.Roles.ADMIN})
|
||||||
|
if not created_role and role.role != ClubRole.Roles.ADMIN:
|
||||||
|
role.role = ClubRole.Roles.ADMIN
|
||||||
|
role.save(update_fields=["role"])
|
||||||
|
|
||||||
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_club_admin(role):
|
||||||
|
"""Remove admin rights. The membership-status sync never re-adds ADMIN."""
|
||||||
|
role.delete()
|
||||||
76
controlpanel/services/platform_admins.py
Normal file
76
controlpanel/services/platform_admins.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Granting and revoking platform access (is_staff / is_superuser).
|
||||||
|
|
||||||
|
The guardrails matter more than the plumbing here: it must be impossible to lock
|
||||||
|
the platform out of itself. Two rules are enforced for every change:
|
||||||
|
|
||||||
|
* you cannot strip your **own** access (you would lose the panel mid-click);
|
||||||
|
* the **last superuser** can never be demoted, or nobody could administer the
|
||||||
|
platform again without shell access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformAdminError(Exception):
|
||||||
|
"""A change that would leave the platform unadministrable."""
|
||||||
|
|
||||||
|
|
||||||
|
def platform_admins():
|
||||||
|
return User.objects.filter(Q(is_staff=True) | Q(is_superuser=True)).order_by("email")
|
||||||
|
|
||||||
|
|
||||||
|
def is_last_superuser(user) -> bool:
|
||||||
|
return user.is_superuser and not User.objects.filter(is_superuser=True).exclude(pk=user.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def check_access_change(actor, user, *, is_staff: bool, is_superuser: bool) -> None:
|
||||||
|
"""Raise PlatformAdminError if this change would lock someone out."""
|
||||||
|
losing_access = not (is_staff or is_superuser)
|
||||||
|
|
||||||
|
if actor.pk == user.pk and losing_access:
|
||||||
|
raise PlatformAdminError("You cannot remove your own platform access.")
|
||||||
|
|
||||||
|
if actor.pk == user.pk and user.is_superuser and not is_superuser:
|
||||||
|
raise PlatformAdminError("You cannot remove your own superuser rights.")
|
||||||
|
|
||||||
|
if user.is_superuser and not is_superuser and is_last_superuser(user):
|
||||||
|
raise PlatformAdminError("At least one superuser must remain.")
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def set_platform_access(actor, user, *, is_staff: bool, is_superuser: bool):
|
||||||
|
check_access_change(actor, user, is_staff=is_staff, is_superuser=is_superuser)
|
||||||
|
|
||||||
|
# A superuser without is_staff cannot reach the panel, which is a confusing
|
||||||
|
# half-state; superuser implies staff.
|
||||||
|
user.is_staff = is_staff or is_superuser
|
||||||
|
user.is_superuser = is_superuser
|
||||||
|
user.save(update_fields=["is_staff", "is_superuser"])
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_platform_access(actor, user):
|
||||||
|
return set_platform_access(actor, user, is_staff=False, is_superuser=False)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def grant_platform_access(email, *, is_superuser: bool = False):
|
||||||
|
"""Give ``email`` platform access, creating the account if it is new.
|
||||||
|
|
||||||
|
New accounts get an unusable password — they set one through the password
|
||||||
|
reset flow — and, being staff, must enrol a second factor before they can
|
||||||
|
sign in at all.
|
||||||
|
"""
|
||||||
|
email = email.lower()
|
||||||
|
user, created = User.objects.get_or_create(email=email, defaults={"is_active": True})
|
||||||
|
if created:
|
||||||
|
user.set_unusable_password()
|
||||||
|
|
||||||
|
user.is_staff = True
|
||||||
|
user.is_superuser = is_superuser
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
473
controlpanel/services/statistics.py
Normal file
473
controlpanel/services/statistics.py
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
"""Platform and per-club statistics.
|
||||||
|
|
||||||
|
``club_statistics`` returns a list of stat *groups*, so growing the model later
|
||||||
|
means adding an entry here and nothing else. ``clubs_with_totals`` annotates in
|
||||||
|
a single query — the club list must not fan out into N+1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from allauth.mfa.models import Authenticator
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Count, DateField, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value
|
||||||
|
from django.db.models.functions import Coalesce, TruncMonth
|
||||||
|
from django.utils import timezone
|
||||||
|
from waffle import get_waffle_flag_model
|
||||||
|
|
||||||
|
from authentication.middleware import ELEVATED_ROLES
|
||||||
|
from billing.models import Due, DuePayment, Subscription
|
||||||
|
from billing.services.dues import dues_in_grace, dues_overdue, subscriptions_due_for_renewal
|
||||||
|
from club.models import Club, ClubMembership, ClubRole, Season
|
||||||
|
from events.models import Attendance, Event
|
||||||
|
from members.models import Member
|
||||||
|
from shop.models import Cart, Order
|
||||||
|
from teams.models import StaffAssignment, Team, TeamMembership
|
||||||
|
|
||||||
|
ZERO = Decimal("0.00")
|
||||||
|
|
||||||
|
PAID_STATUSES = (Order.OrderStatus.PAID, Order.OrderStatus.DELIVERED)
|
||||||
|
OWED_STATUSES = (Order.OrderStatus.PENDING, Order.OrderStatus.PARTIALLY_PAID)
|
||||||
|
|
||||||
|
#: A club with nothing scheduled inside this window has stopped using the product.
|
||||||
|
DORMANT_DAYS = 30
|
||||||
|
MONTHS_OF_HISTORY = 12
|
||||||
|
|
||||||
|
|
||||||
|
def clubs_with_totals(queryset=None):
|
||||||
|
"""Clubs annotated with headline counts (one query, no N+1)."""
|
||||||
|
clubs = Club.objects.all() if queryset is None else queryset
|
||||||
|
return clubs.annotate(
|
||||||
|
member_count=Count("clubmemberships__member", distinct=True),
|
||||||
|
team_count=Count("teams", distinct=True),
|
||||||
|
event_count=Count("events", distinct=True),
|
||||||
|
admin_count=Count("clubroles", filter=Q(clubroles__role=ClubRole.Roles.ADMIN), distinct=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _subquery(queryset, expression, output_field):
|
||||||
|
"""One aggregate, in its own subquery.
|
||||||
|
|
||||||
|
Deliberately not a pile of annotate(Count(...), Sum(...)) on one queryset: aggregates
|
||||||
|
that span *different* joins multiply each other's rows, so a club's outstanding total
|
||||||
|
would come back doubled for every membership it happens to have. Subqueries each stand
|
||||||
|
alone, so nothing can inflate anything else.
|
||||||
|
"""
|
||||||
|
return Coalesce(Subquery(queryset.filter(club=OuterRef("pk")).values("club").annotate(value=expression).values("value"), output_field=output_field), Value(0), output_field=output_field)
|
||||||
|
|
||||||
|
|
||||||
|
def clubs_with_health(queryset=None, today=None, now=None):
|
||||||
|
"""Clubs annotated with the health of each — for the dashboard table, in one query."""
|
||||||
|
today = today or timezone.localdate()
|
||||||
|
now = now or timezone.now()
|
||||||
|
clubs = Club.objects.active() if queryset is None else queryset
|
||||||
|
|
||||||
|
in_season = Q(season__start_date__lte=today, season__end_date__gte=today)
|
||||||
|
# A period the club is covered for, most recent first — paid or waived, both settled.
|
||||||
|
_covered = Due.objects.filter(club=OuterRef("pk"), status__in=(Due.Status.PAID, Due.Status.WAIVED)).order_by("-period_end")
|
||||||
|
managed_this_season = Q(
|
||||||
|
staff_assignments__season__start_date__lte=today,
|
||||||
|
staff_assignments__season__end_date__gte=today,
|
||||||
|
staff_assignments__position__management_position=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
clubs.annotate(
|
||||||
|
has_season=Exists(Season.objects.filter(club=OuterRef("pk"), start_date__lte=today, end_date__gte=today)),
|
||||||
|
active_members=_subquery(ClubMembership.objects.filter(in_season, status=ClubMembership.StatusChoices.ACTIVE), Count("pk"), IntegerField()),
|
||||||
|
unpaid_members=_subquery(ClubMembership.objects.filter(in_season, fee_status=ClubMembership.FeeStatus.UNPAID), Count("pk"), IntegerField()),
|
||||||
|
outstanding=_subquery(Order.objects.filter(status__in=OWED_STATUSES), Sum("total"), DecimalField(max_digits=10, decimal_places=2)),
|
||||||
|
upcoming_events=_subquery(Event.objects.filter(start__gte=now, start__lte=now + timedelta(days=DORMANT_DAYS)), Count("pk"), IntegerField()),
|
||||||
|
team_count=_subquery(Team.objects.all(), Count("pk"), IntegerField()),
|
||||||
|
teams_managed=_subquery(Team.objects.filter(managed_this_season), Count("pk", distinct=True), IntegerField()),
|
||||||
|
admin_count=_subquery(ClubRole.objects.filter(role=ClubRole.Roles.ADMIN), Count("pk"), IntegerField()),
|
||||||
|
tier_name=Subquery(Subscription.objects.filter(club=OuterRef("pk")).values("tier__name")[:1]),
|
||||||
|
dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)),
|
||||||
|
dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]),
|
||||||
|
dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]),
|
||||||
|
# How far the club is covered: the furthest-out period that is settled. PAID and
|
||||||
|
# WAIVED both mean nothing is owed for that period, and its end is the day grace
|
||||||
|
# would start if nothing renews — so both count. `covered_status` is read from the
|
||||||
|
# same top row, so the table can badge "paid" vs "waived". Null when the club owes
|
||||||
|
# or was never billed.
|
||||||
|
covered_until=Subquery(_covered.values("period_end")[:1], output_field=DateField()),
|
||||||
|
covered_status=Subquery(_covered.values("status")[:1]),
|
||||||
|
)
|
||||||
|
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
|
||||||
|
.order_by("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def platform_totals():
|
||||||
|
return {
|
||||||
|
"clubs": Club.objects.active().count(),
|
||||||
|
"archived_clubs": Club.objects.archived().count(),
|
||||||
|
"members": Member.objects.count(),
|
||||||
|
"admins": ClubRole.objects.filter(role=ClubRole.Roles.ADMIN).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clubs_without_a_season(today=None):
|
||||||
|
"""Clubs with no season covering today.
|
||||||
|
|
||||||
|
Not cosmetic: seasons scope memberships, rosters and events, so a club without
|
||||||
|
one cannot take a signup or schedule a match. It fails silently — nothing errors,
|
||||||
|
the club is simply inert — which is exactly why it belongs on a dashboard.
|
||||||
|
"""
|
||||||
|
today = today or timezone.localdate()
|
||||||
|
return Club.objects.active().exclude(seasons__start_date__lte=today, seasons__end_date__gte=today)
|
||||||
|
|
||||||
|
|
||||||
|
def dormant_clubs(days=DORMANT_DAYS):
|
||||||
|
"""Active clubs with nothing on the calendar in the next ``days``. Churn signal."""
|
||||||
|
now = timezone.now()
|
||||||
|
return Club.objects.active().exclude(events__start__gte=now, events__start__lte=now + timedelta(days=days))
|
||||||
|
|
||||||
|
|
||||||
|
def admins_pending_mfa():
|
||||||
|
"""Privileged users who have not enrolled a second factor.
|
||||||
|
|
||||||
|
They are locked out until they do (RequireMFAMiddleware redirects them to the
|
||||||
|
enrolment page), so this is a support queue rather than a statistic. The rule is
|
||||||
|
the middleware's own: platform staff, plus anyone holding an elevated ClubRole.
|
||||||
|
"""
|
||||||
|
User = get_user_model()
|
||||||
|
elevated = User.objects.filter(Q(is_staff=True) | Q(is_superuser=True) | Q(member__roles__role__in=ELEVATED_ROLES))
|
||||||
|
|
||||||
|
return elevated.exclude(pk__in=Authenticator.objects.values("user")).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def onboarding_funnel():
|
||||||
|
"""How far each active club got: created → has members → has a team → has events.
|
||||||
|
|
||||||
|
Separates working clubs from empty shells someone created and walked away from,
|
||||||
|
and shows which step people stall on.
|
||||||
|
"""
|
||||||
|
clubs = clubs_with_totals(Club.objects.active())
|
||||||
|
total = len(clubs)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"label": "Clubs", "count": total, "icon": "building-2"},
|
||||||
|
{"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
|
||||||
|
{"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "trophy"},
|
||||||
|
{"label": "With events", "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def flags_for_club(club):
|
||||||
|
"""Every flag, annotated with whether it is on for this club and why."""
|
||||||
|
enabled_ids = set(club.flags.values_list("pk", flat=True))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"flag": flag,
|
||||||
|
"enabled": flag.pk in enabled_ids,
|
||||||
|
# `everyone` overrides club targeting, so the per-club toggle is moot.
|
||||||
|
"overridden": flag.everyone is not None,
|
||||||
|
}
|
||||||
|
for flag in get_waffle_flag_model().objects.order_by("name")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def flag_adoption():
|
||||||
|
"""Clubs per feature flag. `everyone` overrides club targeting, so a flag set that
|
||||||
|
way is on (or off) everywhere and its club count says nothing — hence `overridden`."""
|
||||||
|
Flag = get_waffle_flag_model()
|
||||||
|
|
||||||
|
return [{"name": flag.name, "clubs": flag.clubs.count(), "everyone": flag.everyone, "overridden": flag.everyone is not None} for flag in Flag.objects.annotate(club_total=Count("clubs")).order_by("name")]
|
||||||
|
|
||||||
|
|
||||||
|
def platform_attention():
|
||||||
|
"""The numbers that are supposed to be zero. A dashboard of healthy counts is a
|
||||||
|
dashboard nobody opens."""
|
||||||
|
members = Member.objects.count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"clubs_without_season": clubs_without_a_season().count(),
|
||||||
|
"dormant_clubs": dormant_clubs().count(),
|
||||||
|
"admins_pending_mfa": admins_pending_mfa().count(),
|
||||||
|
"outstanding": _money(Order.objects.filter(status__in=OWED_STATUSES)),
|
||||||
|
"members_without_login": Member.objects.filter(user__isnull=True).count(),
|
||||||
|
"members": members,
|
||||||
|
# Platform billing: what the clubs owe US. Distinct from `outstanding`, which is
|
||||||
|
# what members owe their clubs — that money is never ours.
|
||||||
|
"dues_owed": _dues_owed(),
|
||||||
|
"dues_in_grace": dues_in_grace().count(),
|
||||||
|
"dues_overdue": dues_overdue().count(),
|
||||||
|
"clubs_unbilled": Club.objects.active().filter(subscription__isnull=True).count(),
|
||||||
|
# Normally ~0: the renewal job keeps it there. A number that sits here means cron is
|
||||||
|
# dead, and a club is about to use the platform for free — silently, because nothing is
|
||||||
|
# owed, so no other number on this page would go red.
|
||||||
|
"renewals_pending": len(subscriptions_due_for_renewal()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dues_owed():
|
||||||
|
"""What clubs owe the platform right now."""
|
||||||
|
owed = Due.objects.filter(status__in=Due.OWING).aggregate(total=Sum(F("amount") - F("amount_paid")))["total"]
|
||||||
|
|
||||||
|
return owed or ZERO
|
||||||
|
|
||||||
|
|
||||||
|
def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY):
|
||||||
|
"""A dense month-by-month series — zero-filled, because a chart that silently skips
|
||||||
|
empty months draws a smooth line over a month where nothing happened."""
|
||||||
|
start = (timezone.now() - timedelta(days=30 * months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
rows = queryset.filter(**{f"{field}__gte": start}).annotate(month=TruncMonth(field)).values("month").annotate(value=value).order_by("month")
|
||||||
|
found = {row["month"].strftime("%Y-%m"): row["value"] or 0 for row in rows if row["month"]}
|
||||||
|
|
||||||
|
series, cursor = [], start
|
||||||
|
while cursor <= timezone.now():
|
||||||
|
key = cursor.strftime("%Y-%m")
|
||||||
|
series.append({"month": cursor.strftime("%b %Y"), "value": float(found.get(key, 0))})
|
||||||
|
cursor = (cursor + timedelta(days=32)).replace(day=1)
|
||||||
|
|
||||||
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
def platform_charts():
|
||||||
|
return {
|
||||||
|
"signups": signup_split(),
|
||||||
|
# Two different pots of money: `dues` is platform income (clubs paying us), while
|
||||||
|
# `club_revenue` is members paying their clubs — never ours, and labelling it
|
||||||
|
# "revenue" on our dashboard would be a lie.
|
||||||
|
"dues": _monthly(DuePayment.objects.all(), "paid_at", Sum("amount")),
|
||||||
|
"club_revenue": _monthly(Order.objects.filter(status__in=PAID_STATUSES), "created", Sum("total")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _money(queryset):
|
||||||
|
return queryset.aggregate(total=Sum("total"))["total"] or ZERO
|
||||||
|
|
||||||
|
|
||||||
|
def previous_season(club, season):
|
||||||
|
"""The season immediately before ``season``. Seasons are ordered by name (which is
|
||||||
|
derived from the years), so go by the date instead — a club may skip a year."""
|
||||||
|
if season is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return Season.objects.filter(club=club, end_date__lt=season.start_date).order_by("-end_date").first()
|
||||||
|
|
||||||
|
|
||||||
|
def renewal_rate(club, season):
|
||||||
|
"""Share of last season's active members who signed up again.
|
||||||
|
|
||||||
|
The single best health signal a club has, and it is exactly computable here because
|
||||||
|
memberships are season-scoped. Returns None when there is no season to compare
|
||||||
|
against — a first-season club has not failed to renew anyone, and rendering that as
|
||||||
|
0% would libel it.
|
||||||
|
"""
|
||||||
|
previous = previous_season(club, season)
|
||||||
|
if previous is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
was_active = ClubMembership.objects.filter(club=club, season=previous, status=ClubMembership.StatusChoices.ACTIVE)
|
||||||
|
total = was_active.count()
|
||||||
|
if not total:
|
||||||
|
return None
|
||||||
|
|
||||||
|
returned = ClubMembership.objects.filter(club=club, season=season, member__in=was_active.values("member")).count()
|
||||||
|
|
||||||
|
return round(100 * returned / total)
|
||||||
|
|
||||||
|
|
||||||
|
def new_members(club, season):
|
||||||
|
"""Members whose first-ever season at this club is ``season``.
|
||||||
|
|
||||||
|
Keyed on "has no membership in an earlier season", not on "signed up recently" — a
|
||||||
|
member who lapsed for a year and came back is a renewal, not a new member, and
|
||||||
|
counting them as new would flatter every recovery into growth.
|
||||||
|
"""
|
||||||
|
if season is None:
|
||||||
|
return Member.objects.none()
|
||||||
|
|
||||||
|
seen_before = ClubMembership.objects.filter(club=club, season__start_date__lt=season.start_date).values("member")
|
||||||
|
|
||||||
|
return Member.objects.filter(member_of__club=club, member_of__season=season).exclude(pk__in=seen_before).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def signup_split(club=None, months=MONTHS_OF_HISTORY):
|
||||||
|
"""Signups per month, split into first-timers and returners. ``club=None`` is platform-wide.
|
||||||
|
|
||||||
|
"First" is keyed on (club, member), never on the member alone — the same person can be
|
||||||
|
new at one club while renewing at another, and collapsing that would mark their second
|
||||||
|
club's very first signup as a renewal.
|
||||||
|
|
||||||
|
Each member's earliest season is resolved once up front rather than per row: the same
|
||||||
|
question asked inside a loop is one query per membership.
|
||||||
|
"""
|
||||||
|
start = (timezone.now() - timedelta(days=30 * months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
memberships = ClubMembership.objects.all() if club is None else ClubMembership.objects.filter(club=club)
|
||||||
|
|
||||||
|
first_season = {}
|
||||||
|
for club_id, member_id, season_start in memberships.values_list("club_id", "member_id", "season__start_date"):
|
||||||
|
key = (club_id, member_id)
|
||||||
|
if key not in first_season or season_start < first_season[key]:
|
||||||
|
first_season[key] = season_start
|
||||||
|
|
||||||
|
counts = defaultdict(lambda: {"new": 0, "returning": 0})
|
||||||
|
for club_id, member_id, season_start, signed_up_at in memberships.filter(signed_up_at__isnull=False, signed_up_at__gte=start).values_list("club_id", "member_id", "season__start_date", "signed_up_at"):
|
||||||
|
kind = "new" if season_start == first_season[(club_id, member_id)] else "returning"
|
||||||
|
counts[signed_up_at.strftime("%Y-%m")][kind] += 1
|
||||||
|
|
||||||
|
series, cursor = [], start
|
||||||
|
while cursor <= timezone.now():
|
||||||
|
month = counts[cursor.strftime("%Y-%m")]
|
||||||
|
series.append({"month": cursor.strftime("%b %Y"), "new": month["new"], "returning": month["returning"]})
|
||||||
|
cursor = (cursor + timedelta(days=32)).replace(day=1)
|
||||||
|
|
||||||
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
def teams_without_a_manager(club, season):
|
||||||
|
"""Teams with nobody in a management position this season.
|
||||||
|
|
||||||
|
A defect in the club's own setup, not a statistic: without a coach or manager the
|
||||||
|
access service grants nobody authority over that team, so nobody can pick the squad.
|
||||||
|
"""
|
||||||
|
if season is None:
|
||||||
|
return Team.objects.none()
|
||||||
|
|
||||||
|
return Team.objects.filter(club=club).exclude(staff_assignments__season=season, staff_assignments__position__management_position=True)
|
||||||
|
|
||||||
|
|
||||||
|
def unrostered_members(club, season):
|
||||||
|
"""Active members who are on no team this season — people who paid and play nowhere."""
|
||||||
|
if season is None:
|
||||||
|
return Member.objects.none()
|
||||||
|
|
||||||
|
rostered = TeamMembership.objects.filter(team__club=club, season=season).values("member")
|
||||||
|
|
||||||
|
return Member.objects.filter(member_of__club=club, member_of__season=season, member_of__status=ClubMembership.StatusChoices.ACTIVE).exclude(pk__in=rostered).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def fee_aging(club):
|
||||||
|
"""Unpaid orders bucketed by age. "€2,400 overdue past 60 days" drives a phone call;
|
||||||
|
"€2,400 outstanding" does not."""
|
||||||
|
now = timezone.now()
|
||||||
|
owed = Order.objects.filter(club=club, status__in=OWED_STATUSES)
|
||||||
|
|
||||||
|
buckets = []
|
||||||
|
for label, older_than, newer_than in (("0-30 days", 0, 30), ("30-60 days", 30, 60), ("60+ days", 60, None)):
|
||||||
|
rows = owed.filter(created__lte=now - timedelta(days=older_than))
|
||||||
|
if newer_than is not None:
|
||||||
|
rows = rows.filter(created__gt=now - timedelta(days=newer_than))
|
||||||
|
buckets.append({"label": label, "total": _money(rows), "count": rows.count(), "overdue": newer_than is None})
|
||||||
|
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
|
def attendance_rates(club, season):
|
||||||
|
"""Turnout, and how many never answered.
|
||||||
|
|
||||||
|
The no-response share is the leading indicator: it measures whether members are using
|
||||||
|
the app at all, which every other number here depends on.
|
||||||
|
"""
|
||||||
|
if season is None:
|
||||||
|
return {"turnout": None, "no_response": None, "responses": 0}
|
||||||
|
|
||||||
|
counts = Attendance.objects.filter(event__club=club, event__season=season, event__start__lt=timezone.now()).aggregate(
|
||||||
|
present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)),
|
||||||
|
absent=Count("id", filter=Q(status=Attendance.AttendanceStatus.ABSENT)),
|
||||||
|
silent=Count("id", filter=Q(status=Attendance.AttendanceStatus.NO_RESPONSE)),
|
||||||
|
total=Count("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
answered = counts["present"] + counts["absent"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"turnout": round(100 * counts["present"] / answered) if answered else None,
|
||||||
|
"no_response": round(100 * counts["silent"] / counts["total"]) if counts["total"] else None,
|
||||||
|
"responses": counts["total"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def club_attention(club):
|
||||||
|
"""A club's own numbers that are supposed to be zero."""
|
||||||
|
season = Season.covering(club, timezone.localdate())
|
||||||
|
memberships = ClubMembership.objects.filter(club=club)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"season": season,
|
||||||
|
"no_season": season is None,
|
||||||
|
"outstanding": _money(Order.objects.filter(club=club, status__in=OWED_STATUSES)),
|
||||||
|
"aging": fee_aging(club),
|
||||||
|
"unpaid_members": memberships.filter(season=season, fee_status=ClubMembership.FeeStatus.UNPAID).count() if season else 0,
|
||||||
|
"pending_approvals": memberships.filter(status=ClubMembership.StatusChoices.PENDING).count(),
|
||||||
|
"teams_without_manager": teams_without_a_manager(club, season).count(),
|
||||||
|
"unrostered": unrostered_members(club, season).count(),
|
||||||
|
"new_members": new_members(club, season).count(),
|
||||||
|
"renewal_rate": renewal_rate(club, season),
|
||||||
|
"attendance": attendance_rates(club, season),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def club_charts(club):
|
||||||
|
season = Season.covering(club, timezone.localdate())
|
||||||
|
memberships = ClubMembership.objects.filter(club=club, season=season) if season else ClubMembership.objects.none()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"signups": signup_split(club),
|
||||||
|
# Fee status this season, in the order a treasurer cares about.
|
||||||
|
"fees": [
|
||||||
|
{"label": label, "value": memberships.filter(fee_status=status).count()}
|
||||||
|
for status, label in (
|
||||||
|
(ClubMembership.FeeStatus.PAID, "Paid"),
|
||||||
|
(ClubMembership.FeeStatus.PARTIALLY_PAID, "Partial"),
|
||||||
|
(ClubMembership.FeeStatus.UNPAID, "Unpaid"),
|
||||||
|
(ClubMembership.FeeStatus.WAIVED, "Waived"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def club_statistics(club):
|
||||||
|
"""Stat groups for one club. Add new groups here as the domain grows."""
|
||||||
|
season = Season.covering(club, timezone.localdate())
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
memberships = ClubMembership.objects.filter(club=club)
|
||||||
|
events = Event.objects.filter(club=club)
|
||||||
|
orders = Order.objects.filter(club=club)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": "Members",
|
||||||
|
"icon": "users",
|
||||||
|
"stats": [
|
||||||
|
("Members", memberships.values("member").distinct().count()),
|
||||||
|
("Active this season", memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0),
|
||||||
|
("Pending", memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()),
|
||||||
|
("Lapsed", memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Teams & staff",
|
||||||
|
"icon": "shield",
|
||||||
|
"stats": [
|
||||||
|
("Teams", Team.objects.filter(club=club).count()),
|
||||||
|
("Players this season", TeamMembership.objects.filter(team__club=club, season=season).count() if season else 0),
|
||||||
|
("Staff this season", StaffAssignment.objects.filter(team__club=club, season=season).count() if season else 0),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Events",
|
||||||
|
"icon": "calendar-days",
|
||||||
|
"stats": [
|
||||||
|
("Upcoming", events.filter(start__gte=now).count()),
|
||||||
|
("This season", events.filter(season=season).count() if season else 0),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Shop",
|
||||||
|
"icon": "shopping-cart",
|
||||||
|
"stats": [
|
||||||
|
("Orders", orders.count()),
|
||||||
|
("Revenue", _money(orders.filter(status__in=PAID_STATUSES))),
|
||||||
|
("Outstanding", _money(orders.filter(status__in=OWED_STATUSES))),
|
||||||
|
("Open carts", Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
49
controlpanel/templates/controlpanel/_club_admins_card.html
Normal file
49
controlpanel/templates/controlpanel/_club_admins_card.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{% load lucide ui %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Club-scoped admins, and the modals to add one / confirm removing one. Included with
|
||||||
|
`club`, `admins`, `admin_form` already in context.
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-base">{% lucide "shield-user" size=18 %} Club admins</h2>
|
||||||
|
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('club_admin_add_modal').showModal()">{% lucide "user-plus" size=16 %} Add admin</button>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for role in admins %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ role.member }}</td>
|
||||||
|
<td>{{ role.member.user.email|default:"—" }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ role.pk|dom_id:"admin_remove_modal" }}').showModal()">{% lucide "trash-2" size=14 %} Remove</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center opacity-60">No admins yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% url 'controlpanel:club_admin_add' club.pk as club_admin_add_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id="club_admin_add_modal" title="Add admin" form=admin_form action_url=club_admin_add_url submit_label="Grant admin" submit_icon="user-plus" blurb="A club admin can manage everything in this club. They will be required to set up two-factor authentication before they can sign in." %}
|
||||||
|
|
||||||
|
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||||
|
{% for role in admins %}
|
||||||
|
{% url 'controlpanel:club_admin_remove' club.pk role.pk as admin_remove_url %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id=role.pk|dom_id:"admin_remove_modal" title="Remove admin" body="Remove "|add:role.member.get_full_name|add:" as an admin of this club? They keep their membership — only admin rights are revoked." action_url=admin_remove_url submit_label="Remove" %}
|
||||||
|
{% endfor %}
|
||||||
124
controlpanel/templates/controlpanel/_club_billing_card.html
Normal file
124
controlpanel/templates/controlpanel/_club_billing_card.html
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
{% load lucide ui %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
What the platform bills this club: plan, periods, and the modals for changing plan,
|
||||||
|
opening a period, and recording a payment. Included with `club`, `subscription`,
|
||||||
|
`dues`, `today`, `subscription_form`, `open_period_form`, `open_period_blurb` already
|
||||||
|
in context.
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="card mb-6 bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
|
||||||
|
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
|
||||||
|
</button>
|
||||||
|
{% if subscription %}
|
||||||
|
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('open_period_modal').showModal()">
|
||||||
|
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not subscription %}
|
||||||
|
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a tier to start.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
On plan <strong>{{ subscription.tier.name }}</strong>.
|
||||||
|
{% if subscription.auto_renew %}
|
||||||
|
Renews automatically 30 days before the period ends.
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning badge-sm">Auto-renew off</span> — you must open each period by hand, or this club uses the platform for free.
|
||||||
|
{% endif %}
|
||||||
|
{% if subscription.auto_archive %}
|
||||||
|
Archived automatically when a period goes unpaid past its grace period.
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning badge-sm">Auto-archive off</span> — it will never be archived for non-payment.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Period</th>
|
||||||
|
<th class="text-right">Billed</th>
|
||||||
|
<th class="text-right">Paid</th>
|
||||||
|
<th class="text-right">Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for due in dues %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
|
||||||
|
<div class="text-xs opacity-60">{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td>
|
||||||
|
<td class="text-right tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{% if due.status == "paid" %}
|
||||||
|
<span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span>
|
||||||
|
{% elif due.status == "waived" %}
|
||||||
|
<span class="badge badge-outline gap-1">{% lucide "check" size=12 %} Waived</span>
|
||||||
|
{% elif due.grace_until < today %}
|
||||||
|
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
|
||||||
|
{% elif due.period_end < today %}
|
||||||
|
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-outline">{{ due.get_status_display }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right flex flex-row gap-2 justify-end">
|
||||||
|
{% if due.is_owing %}
|
||||||
|
<button class="btn btn-primary btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Add payment</button>
|
||||||
|
{% if not due.payments.all %}
|
||||||
|
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-outline btn-sm gap-1" type="submit">{% lucide "ban" size=14 %} Waive payment</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% for payment in due.payments.all %}
|
||||||
|
<tr class="text-xs opacity-70">
|
||||||
|
<td colspan="2" class="pl-8">
|
||||||
|
{% lucide "corner-down-right" size=12 %}
|
||||||
|
{{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">€{{ payment.amount|floatformat:2 }}</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center opacity-60">No periods billed yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||||
|
{% for due in dues %}
|
||||||
|
{% if due.is_owing %}
|
||||||
|
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment" form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% url 'controlpanel:club_subscribe' club.pk as subscribe_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id="subscription_modal" title=subscription|yesno:"Change plan,Start billing" form=subscription_form action_url=subscribe_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %}
|
||||||
|
|
||||||
|
{% if subscription %}
|
||||||
|
{% url 'controlpanel:club_open_period' club.pk as open_period_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id="open_period_modal" title=club.is_archived|yesno:"Reactivate,Open period" form=open_period_form action_url=open_period_url submit_label="Open period" submit_icon="calendar-plus" blurb=open_period_blurb %}
|
||||||
|
{% endif %}
|
||||||
45
controlpanel/templates/controlpanel/_club_features_card.html
Normal file
45
controlpanel/templates/controlpanel/_club_features_card.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{% load lucide %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Which feature flags apply to this club. Included with `club`, `flags` (from
|
||||||
|
`flags_for_club`) already in context.
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="card mb-6 bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
|
||||||
|
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<tbody>
|
||||||
|
{% for entry in flags %}
|
||||||
|
<tr>
|
||||||
|
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
|
||||||
|
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{% if entry.overridden %}
|
||||||
|
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
|
||||||
|
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
|
||||||
|
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
||||||
|
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center opacity-60">No features defined yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
135
controlpanel/templates/controlpanel/_club_health_table.html
Normal file
135
controlpanel/templates/controlpanel/_club_health_table.html
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
{% load lucide %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
The club table, shared by the dashboard and the clubs list so the two cannot drift apart.
|
||||||
|
Health, not vanity: a member total says nothing you can act on, while "no coach",
|
||||||
|
"nothing scheduled", "€ owed" and "no admins" each name something somebody has to go and
|
||||||
|
fix. Every column is annotated by clubs_with_health() in a single query.
|
||||||
|
|
||||||
|
Expects: clubs (from clubs_with_health), and optionally empty_message.
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Club</th>
|
||||||
|
<th></th>
|
||||||
|
<th class="text-right">Members</th>
|
||||||
|
<th class="text-right">Admins</th>
|
||||||
|
<th class="text-right">Teams</th>
|
||||||
|
<th class="text-right">Events</th>
|
||||||
|
<th class="text-right">Plan</th>
|
||||||
|
<th class="text-right">Dues</th>
|
||||||
|
<th class="text-right">Plan end</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for club in clubs %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="flex flex-row items-center gap-4">
|
||||||
|
<div>
|
||||||
|
{% if club.logo %}
|
||||||
|
<img class="h-12 w-12 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="avatar avatar-placeholder">
|
||||||
|
<div class="w-12 rounded-full bg-neutral text-neutral-content">
|
||||||
|
<span>{{ club.initials }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<a class="link link-hover font-semibold tracking-wide" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
|
||||||
|
<div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
{% if club.is_archived %}
|
||||||
|
<span class="badge badge-warning">{% lucide "archive" size=14 %} archived</span>
|
||||||
|
{% else %}
|
||||||
|
{% if not club.has_season %}
|
||||||
|
<span class="badge badge-warning">{% lucide "calendar-x" size=14 %} no seasons</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not club.upcoming_events %}
|
||||||
|
<span class="badge badge-ghost badge-outline">{% lucide "moon-star" size=14 %}dormant</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-right tabular-nums">{{ club.active_members }}</td>
|
||||||
|
<td class="text-right tabular-nums">
|
||||||
|
<div class="flex flex-row gap-2 items-center justify-end">
|
||||||
|
{% if not club.admin_count %}
|
||||||
|
<span class="text-error">{% lucide "triangle-alert" size=16 %}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="{% if not club.admin_count %}font-bold text-error{% endif %}">{{ club.admin_count }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">{{ club.team_count }}</td>
|
||||||
|
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
|
||||||
|
|
||||||
|
<td class="text-right">
|
||||||
|
{% if club.tier_name %}
|
||||||
|
<span class="badge badge-accent">{{ club.tier_name|lower }}</span>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="flex flex-row gap-2 items-center justify-end">
|
||||||
|
{% if not club.dues_owed %}
|
||||||
|
{% if club.tier_name %}
|
||||||
|
{% comment %}
|
||||||
|
Not owing and on a plan. covered_until is the settled period's end — the day
|
||||||
|
grace would start if nothing renews — shown on its own row under the badge,
|
||||||
|
for a paid period AND a waived one (both cover the club, they just differ in
|
||||||
|
how). No covered period at all (only cancelled dues, say) shows a dash.
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="flex flex-col items-end gap-1">
|
||||||
|
{% if club.covered_status == "waived" %}
|
||||||
|
<span class="badge badge-ghost badge-outline">waived</span>
|
||||||
|
{% elif club.covered_until %}
|
||||||
|
<span class="badge badge-success">paid</span>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
|
||||||
|
{% if club.dues_grace_until < today %}
|
||||||
|
<span class="badge badge-error">overdue</span>
|
||||||
|
{% elif club.dues_period_end < today %}
|
||||||
|
<span class="badge badge-warning">grace</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-right">
|
||||||
|
<span class="whitespace-nowrap">{{ club.covered_until|date:"j M Y"|default:"-" }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<a class="btn btn-sm btn-outline gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="text-center opacity-60">{{ empty_message|default:"No clubs yet." }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
28
controlpanel/templates/controlpanel/_confirm_modal.html
Normal file
28
controlpanel/templates/controlpanel/_confirm_modal.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% load lucide %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
A daisyUI native <dialog> confirmation modal for a destructive POST action with no
|
||||||
|
fields of its own. Included with `modal_id`, `title`, `body`, `action_url`, and
|
||||||
|
optional `submit_label` (default "Confirm"), `submit_icon` (default "trash-2"). The
|
||||||
|
submit button sits outside the form tag (linked via the `form` attribute), same as
|
||||||
|
`_modal_form.html`, so it can share the `modal-action` row with the dialog-closing
|
||||||
|
Cancel button without nesting one <form> inside another.
|
||||||
|
{% endcomment %}
|
||||||
|
<dialog id="{{ modal_id }}" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold">{{ title }}</h3>
|
||||||
|
<p class="py-2 text-sm opacity-70">{{ body }}</p>
|
||||||
|
<form method="post" action="{{ action_url }}" id="{{ modal_id }}-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
</form>
|
||||||
|
<div class="modal-action">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} Cancel</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-error gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"trash-2" size=16 %} {{ submit_label|default:"Confirm" }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
29
controlpanel/templates/controlpanel/_form_fields.html
Normal file
29
controlpanel/templates/controlpanel/_form_fields.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% load ui %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
The field loop every card-form and modal-form wrapper shares: label, daisyUI-styled
|
||||||
|
widget, help text, errors — with checkboxes laid out label-beside-input instead of
|
||||||
|
label-above. Included with `form`.
|
||||||
|
{% endcomment %}
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="form-control my-3 w-full">
|
||||||
|
{% if field.field.widget.input_type == "checkbox" %}
|
||||||
|
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
|
||||||
|
{{ field|daisy }}
|
||||||
|
<span class="label-text">{{ field.label }}</span>
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<label class="label" for="{{ field.id_for_label }}">
|
||||||
|
<span class="label-text">{{ field.label }}</span>
|
||||||
|
</label>
|
||||||
|
{{ field|daisy }}
|
||||||
|
{% endif %}
|
||||||
|
{% if field.help_text %}<span class="label-text-alt mt-1 text-xs block text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||||
|
{% for error in field.errors %}<span class="label-text-alt text-xs mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
28
controlpanel/templates/controlpanel/_modal_form.html
Normal file
28
controlpanel/templates/controlpanel/_modal_form.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% load lucide %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
A daisyUI native <dialog> modal wrapping a Django form that posts straight to
|
||||||
|
`action_url`. Included with `modal_id`, `title`, `form`, `action_url`, `submit_label`
|
||||||
|
and `submit_icon`, plus an optional `blurb`. The submit button sits outside the form
|
||||||
|
tag (linked via the `form` attribute) so it can share the `modal-action` row with the
|
||||||
|
dialog-closing Cancel button without nesting one <form> inside another.
|
||||||
|
{% endcomment %}
|
||||||
|
<dialog id="{{ modal_id }}" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold">{{ title }}</h3>
|
||||||
|
{% if blurb %}<p class="py-2 text-sm opacity-70">{{ blurb }}</p>{% endif %}
|
||||||
|
<form method="post" action="{{ action_url }}" id="{{ modal_id }}-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include "controlpanel/_form_fields.html" %}
|
||||||
|
</form>
|
||||||
|
<div class="modal-action">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} Cancel</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:"Save" }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
37
controlpanel/templates/controlpanel/_nav_items.html
Normal file
37
controlpanel/templates/controlpanel/_nav_items.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% load lucide %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
The panel's navigation, in one place: the sidebar renders it on a wide screen and the
|
||||||
|
collapsed menu renders it on a narrow one. Two copies of a link list is how a new section
|
||||||
|
ends up reachable on a desktop and invisible on a phone.
|
||||||
|
|
||||||
|
`menu-active` is daisyUI 5's active state; hover and focus come with `.menu` itself.
|
||||||
|
{% endcomment %}
|
||||||
|
<li>
|
||||||
|
<a class="{% if nav == 'dashboard' %}menu-active{% endif %}" href="{% url 'controlpanel:dashboard' %}">
|
||||||
|
{% lucide "layout-dashboard" size=16 %} Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="{% if nav == 'clubs' %}menu-active{% endif %}" href="{% url 'controlpanel:club_list' %}">
|
||||||
|
{% lucide "building-2" size=16 %} Clubs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="{% if nav == 'billing' %}menu-active{% endif %}" href="{% url 'controlpanel:billing' %}">
|
||||||
|
{% lucide "receipt-euro" size=16 %} Billing
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="{% if nav == 'features' %}menu-active{% endif %}" href="{% url 'controlpanel:features' %}">
|
||||||
|
{% lucide "toggle-right" size=16 %} Features
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if user.is_superuser %}
|
||||||
|
{# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #}
|
||||||
|
<li>
|
||||||
|
<a class="{% if nav == 'admins' %}menu-active{% endif %}" href="{% url 'controlpanel:admins' %}">
|
||||||
|
{% lucide "user-cog" size=16 %} Platform admins
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
80
controlpanel/templates/controlpanel/admins.html
Normal file
80
controlpanel/templates/controlpanel/admins.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends "controlpanel/base.html" %}
|
||||||
|
{% load lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}Platform admins{% endblock heading %}
|
||||||
|
|
||||||
|
{% block subheading %}
|
||||||
|
<p class="text-sm opacity-70">Staff run the panel. Superusers additionally manage this list.</p>
|
||||||
|
{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('admin_add_modal').showModal()">{% lucide "user-plus" size=16 %} Grant access</button>
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
{% url 'controlpanel:admin_add' as admin_add_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id="admin_add_modal" title="Grant platform access" form=admin_form action_url=admin_add_url submit_label="Grant access" submit_icon="user-plus" blurb="Platform admins can manage every club. They must set up two-factor authentication before they can sign in." %}
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Staff</th>
|
||||||
|
<th>Superuser</th>
|
||||||
|
<th>Last login</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for admin in admins %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="font-medium">{{ admin.email }}</div>
|
||||||
|
{% if admin.pk == user.pk %}
|
||||||
|
<div class="text-xs opacity-60">That's you</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}0{% else %}1{% endif %}">
|
||||||
|
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}1{% else %}0{% endif %}">
|
||||||
|
<button class="btn btn-sm gap-1 {% if admin.is_staff %}btn-success{% else %}btn-outline{% endif %}" type="submit">
|
||||||
|
{% if admin.is_staff %}{% lucide "user" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}1{% else %}0{% endif %}">
|
||||||
|
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}0{% else %}1{% endif %}">
|
||||||
|
<button class="btn btn-sm gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-outline{% endif %}" type="submit">
|
||||||
|
{% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ admin.pk|dom_id:"admin_revoke_modal" }}').showModal()">{% lucide "user-minus" size=14 %} Revoke</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center opacity-60">No platform admins.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||||
|
{% for admin in admins %}
|
||||||
|
{% url 'controlpanel:admin_revoke' admin.pk as admin_revoke_url %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id=admin.pk|dom_id:"admin_revoke_modal" title="Revoke platform access" body="Revoke platform access for "|add:admin.email|add:"? They will no longer be able to reach the control panel." action_url=admin_revoke_url submit_label="Revoke" submit_icon="user-minus" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
52
controlpanel/templates/controlpanel/base.html
Normal file
52
controlpanel/templates/controlpanel/base.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "_platform_base.html" %}
|
||||||
|
{% load lucide %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block menu %}
|
||||||
|
{% comment %}
|
||||||
|
Outside <main>, so it never scrolls with the content. Its own overflow-y-auto is for
|
||||||
|
the day the menu itself grows taller than the screen.
|
||||||
|
{% endcomment %}
|
||||||
|
<aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block">
|
||||||
|
<ul class="menu w-full gap-1 p-3 mt-4">
|
||||||
|
{% include "controlpanel/_nav_items.html" %}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
{% endblock menu %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% if maintenance_on %}
|
||||||
|
<div class="alert alert-error mb-6">
|
||||||
|
{% lucide "wrench" size=20 %}
|
||||||
|
<span>
|
||||||
|
<strong>The platform is currently closed for maintenance.</strong>
|
||||||
|
Clubs see a maintenance page and the scheduled jobs are standing down.
|
||||||
|
</span>
|
||||||
|
<a class="btn btn-sm gap-2 btn-error btn-soft" href="{% url 'controlpanel:features' %}">{% lucide "unlock" size=16 %} Reopen platform</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-6 flex flex-wrap flex-row items-center justify-between gap-3">
|
||||||
|
{% block logo %}{% endblock logo %}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 grow">
|
||||||
|
<h1 class="text-3xl font-bold">
|
||||||
|
{% block heading %}Control panel{% endblock heading %}
|
||||||
|
</h1>
|
||||||
|
<span class="text-sm text-base-content/50">{% block subheading %}{% endblock subheading %}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% block actions %}{% endblock actions %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #}
|
||||||
|
<ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden">
|
||||||
|
{% include "controlpanel/_nav_items.html" %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% block panel %}{% endblock panel %}
|
||||||
|
{% endblock main %}
|
||||||
135
controlpanel/templates/controlpanel/billing.html
Normal file
135
controlpanel/templates/controlpanel/billing.html
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
{% extends "controlpanel/base.html" %}
|
||||||
|
{% load lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}Billing{% endblock heading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('tier_create_modal').showModal()">{% lucide "plus" size=16 %} New plan</button>
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
{% url 'controlpanel:tier_create' as tier_create_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id="tier_create_modal" title="New plan" form=tier_form action_url=tier_create_url submit_label="Create plan" submit_icon="plus" %}
|
||||||
|
|
||||||
|
<div class="card mb-6 bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Plans</h2>
|
||||||
|
{% comment %}
|
||||||
|
Prices are dated, not edited. A rate change is a new row with a future
|
||||||
|
active_from; every period already opened keeps the amount it was billed at,
|
||||||
|
so raising the price cannot rewrite an invoice you have already sent.
|
||||||
|
{% endcomment %}
|
||||||
|
<p class="text-sm opacity-70">A rate change only takes effect as of a certain date. Periods already billed keep the amount they were issued at.</p>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th class="text-right">Clubs</th>
|
||||||
|
<th>Prices</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tier in tiers %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="font-medium">{{ tier.name }}</div>
|
||||||
|
{% if not tier.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %}
|
||||||
|
{% if tier.description %}
|
||||||
|
<div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">{{ tier.club_count }}</td>
|
||||||
|
<td>
|
||||||
|
{% for price in tier.prices.all %}
|
||||||
|
<div class="text-sm tabular-nums">
|
||||||
|
€{{ price.amount|floatformat:2 }}
|
||||||
|
<span class="opacity-60">from {{ price.active_from|date:"j M Y" }}</span>
|
||||||
|
{% if price.active_from > today %}<span class="badge badge-info badge-xs">Scheduled</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<span class="badge badge-error badge-sm">No price — cannot be billed</span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="flex flex-row gap-2 justify-end">
|
||||||
|
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
|
||||||
|
<button class="btn btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center opacity-60">No plans yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||||
|
{% for tier in tiers %}
|
||||||
|
{% url 'controlpanel:tier_price_create' tier.pk as tier_price_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_price_modal" title="New price — "|add:tier.name form=tier.price_form action_url=tier_price_url submit_label="Add price" submit_icon="euro" %}
|
||||||
|
|
||||||
|
{% url 'controlpanel:tier_update' tier.pk as tier_update_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_edit_modal" title="Edit "|add:tier.name form=tier.edit_form action_url=tier_update_url submit_label="Save" submit_icon="check" %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Club</th>
|
||||||
|
<th>Period</th>
|
||||||
|
<th class="text-right">Owed</th>
|
||||||
|
<th class="text-right">Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for due in owing %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' due.club.pk %}">{{ due.club.name }}</a>
|
||||||
|
<div class="text-xs opacity-60">{{ due.tier.name }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
|
||||||
|
<div class="text-xs opacity-60">Grace to {{ due.grace_until|date:"j M Y" }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-semibold tabular-nums">€{{ due.balance|floatformat:2 }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{% if due.grace_until < today %}
|
||||||
|
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
|
||||||
|
{% elif due.period_end < today %}
|
||||||
|
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-outline">{{ due.get_status_display }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right flex flex-row gap-2 justify-end">
|
||||||
|
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Record payment</button>
|
||||||
|
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center opacity-60">Nothing outstanding.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||||
|
{% for due in owing %}
|
||||||
|
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment — "|add:due.club.name form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock panel %}
|
||||||
255
controlpanel/templates/controlpanel/club_detail.html
Normal file
255
controlpanel/templates/controlpanel/club_detail.html
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
{% extends "controlpanel/base.html" %}
|
||||||
|
{% load static lucide %}
|
||||||
|
|
||||||
|
{% block logo %}
|
||||||
|
{% if club.logo %}
|
||||||
|
<img class="h-16 w-16 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="avatar avatar-placeholder">
|
||||||
|
<div class="w-16 text-xl rounded-full bg-neutral text-neutral-content">
|
||||||
|
<span>{{ club.initials }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock logo %}
|
||||||
|
|
||||||
|
{% block heading %}{{ club.name }}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block subheading %}
|
||||||
|
{{ club.slug }}.rosterchief.app
|
||||||
|
{% if club.is_archived %}
|
||||||
|
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a>
|
||||||
|
<a class="btn btn-primary gap-2" href="https://{{ club.slug }}.rosterchief.app">{% lucide "external-link" size=16 %} Open</a>
|
||||||
|
{% if club.is_archived %}
|
||||||
|
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-success gap-2" type="submit">{% lucide "archive-restore" size=16 %} Restore</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{% url 'controlpanel:club_archive' club.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-warning gap-2" type="submit">{% lucide "archive" size=16 %} Archive</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
{% if club.is_archived %}
|
||||||
|
<div class="alert alert-warning mb-6">
|
||||||
|
{% lucide "alert-triangle" size=20 %}
|
||||||
|
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if attention.no_season %}
|
||||||
|
<div class="alert alert-warning mb-6">
|
||||||
|
{% lucide "calendar-x" size=20 %}
|
||||||
|
<span>
|
||||||
|
No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
The club's own numbers that should be zero. Teams without a manager is a defect in
|
||||||
|
the club's setup, not a statistic: with nobody in a management position the access
|
||||||
|
service grants no authority over that team, so nobody can pick the squad.
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||||
|
{% comment %}<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
|
||||||
|
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div>
|
||||||
|
<div class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
|
||||||
|
</div>
|
||||||
|
</div>{% endcomment %}
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} Teams without coach</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.teams_without_manager }}</div>
|
||||||
|
<div class="text-xs opacity-60">Teams nobody can pick a squad for</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.unrostered %}border-warning{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} Unrostered members</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div>
|
||||||
|
<div class="text-xs opacity-60">Active members on no team</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}border-warning{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} Pending</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div>
|
||||||
|
<div class="text-xs opacity-60">Memberships awaiting approval</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} New members</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
|
||||||
|
{# First season at this club — someone returning after a year away is a renewal. #}
|
||||||
|
<div class="text-xs opacity-60">First season at this club</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.renewal_rate is None %}border-info{% elif attention.renewal_rate < 30 %}border-error{% elif attention.renewal_rate < 65 %}border-warning{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} Renewal rate</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">
|
||||||
|
{% if attention.renewal_rate is None %}
|
||||||
|
N/A
|
||||||
|
{% else %}
|
||||||
|
{{ attention.renewal_rate }}%
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs opacity-60">
|
||||||
|
{% if attention.renewal_rate is None %}
|
||||||
|
No previous season
|
||||||
|
{% else %}
|
||||||
|
<progress class="progress w-full {% if attention.renewal_rate < 30 %}progress-error{% elif attention.renewal_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.renewal_rate }}"
|
||||||
|
max="100"></progress>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} Attendance rate</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">
|
||||||
|
{% if attention.attendance.turnout is None %}
|
||||||
|
N/A
|
||||||
|
{% else %}
|
||||||
|
{{ attention.attendance.turnout }}%
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs opacity-60">
|
||||||
|
{% if attention.attendance.turnout is None %}
|
||||||
|
No events this season
|
||||||
|
{% else %}
|
||||||
|
<progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}"
|
||||||
|
value="{{ attention.attendance.turnout }}" max="100"></progress>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2>
|
||||||
|
<p class="text-sm opacity-70">New members against returning ones.</p>
|
||||||
|
<div class="h-56">
|
||||||
|
<canvas id="signups-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} Club fee status this season</h2>
|
||||||
|
<div class="h-56">
|
||||||
|
<canvas id="fees-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 grid gap-4 md:grid-cols-4">
|
||||||
|
{% for group in groups %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2>
|
||||||
|
<dl class="divide-y divide-base-200">
|
||||||
|
{% for label, value in group.stats %}
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{{ label }}</dt>
|
||||||
|
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% include "controlpanel/_club_features_card.html" %}
|
||||||
|
{% include "controlpanel/_club_billing_card.html" %}
|
||||||
|
{% include "controlpanel/_club_admins_card.html" %}
|
||||||
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
{{ charts|json_script:"chart-data" }}
|
||||||
|
<script src="{% static 'js/chart.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
||||||
|
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
const ink = css("--color-base-content", "#333");
|
||||||
|
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
|
||||||
|
|
||||||
|
// Stacked: the bar height stays "signups this month" while the split shows where
|
||||||
|
// they came from. Side-by-side bars would answer a different question.
|
||||||
|
const signups = new Chart(document.getElementById("signups-chart"), {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: data.signups.map((point) => point.month),
|
||||||
|
datasets: [
|
||||||
|
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
|
||||||
|
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {legend: {position: "bottom", labels: {color: ink}}},
|
||||||
|
scales: {
|
||||||
|
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
|
||||||
|
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Colour carries the meaning here — unpaid must read as a problem, waived must
|
||||||
|
// not — so the slices are pinned to the semantic theme colours, in order.
|
||||||
|
const fees = new Chart(document.getElementById("fees-chart"), {
|
||||||
|
type: "pie",
|
||||||
|
data: {
|
||||||
|
labels: data.fees.map((slice) => slice.label),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: data.fees.map((slice) => slice.value),
|
||||||
|
backgroundColor: [css("--color-success", "#16a34a"), css("--color-warning", "#f59e0b"), css("--color-error", "#dc2626"), css("--color-neutral", "#6b7280")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {legend: {position: "right", labels: {color: ink}}},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return [signups, fees];
|
||||||
|
};
|
||||||
|
|
||||||
|
let charts = render();
|
||||||
|
|
||||||
|
// "auto" removes data-theme entirely, so watch the attribute rather than a click.
|
||||||
|
new MutationObserver(() => {
|
||||||
|
charts.forEach((chart) => chart.destroy());
|
||||||
|
charts = render();
|
||||||
|
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock extra_body %}
|
||||||
38
controlpanel/templates/controlpanel/club_form.html
Normal file
38
controlpanel/templates/controlpanel/club_form.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "controlpanel/base.html" %}
|
||||||
|
{% load lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="my-3 w-full">
|
||||||
|
<label class="label" for="{{ field.id_for_label }}">
|
||||||
|
<span class="label-text">{{ field.label }}</span>
|
||||||
|
</label>
|
||||||
|
{{ field|daisy }}
|
||||||
|
{% if field.help_text and not field.errors %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||||
|
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "controlpanel:club_detail" object.pk %}{% else %}{% url "controlpanel:club_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user