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=
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -376,4 +376,7 @@ poetry.toml
|
||||
# LSP config files
|
||||
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/
|
||||
|
||||
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>
|
||||
2
.idea/modules.xml
generated
2
.idea/modules.xml
generated
@@ -2,7 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/ClubManager.iml" filepath="$PROJECT_DIR$/.idea/ClubManager.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/rosterchief.iml" filepath="$PROJECT_DIR$/.idea/rosterchief.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
13
.idea/pyLspTools.xml
generated
13
.idea/pyLspTools.xml
generated
@@ -1,5 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PyToolsState">
|
||||
<option name="tools">
|
||||
<map>
|
||||
<entry key="ruff">
|
||||
<value>
|
||||
<ToolEntry>
|
||||
<option name="enabled" value="true" />
|
||||
</ToolEntry>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
<component name="RuffConfiguration">
|
||||
<option name="enabled" value="true" />
|
||||
</component>
|
||||
|
||||
10
.idea/pySourceRootDetection.xml
generated
Normal file
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>
|
||||
17
.idea/ClubManager.iml → .idea/rosterchief.iml
generated
17
.idea/ClubManager.iml → .idea/rosterchief.iml
generated
@@ -1,10 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
|
||||
<component name="FacetManager">
|
||||
<facet type="django" name="Django">
|
||||
<configuration>
|
||||
<option name="rootFolder" value="$MODULE_DIR$" />
|
||||
<option name="settingsModule" value="clubmanager/settings.py" />
|
||||
<option name="settingsModule" value="rosterchief/settings.py" />
|
||||
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
|
||||
<option name="environment" value="<map/>" />
|
||||
<option name="doNotUseTestRunner" value="false" />
|
||||
@@ -14,17 +14,14 @@
|
||||
</component>
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="uv (ClubManager) (3)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_CONFIGURATION" value="Django" />
|
||||
<option name="TEMPLATE_FOLDERS">
|
||||
<list>
|
||||
<option value="$MODULE_DIR$/templates" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
Normal file
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>
|
||||
902
ARCHITECTURE.md
Normal file
902
ARCHITECTURE.md
Normal file
@@ -0,0 +1,902 @@
|
||||
# RosterChief — Model & Domain Architecture
|
||||
|
||||
Baseline reference for implementing the domain models. This describes the **intended
|
||||
shape** of the data model: what exists today, what is planned, and the conventions every
|
||||
app should follow. It is a living document — update it when the model changes.
|
||||
|
||||
> **Tenancy: multi-tenant (row-based / shared-schema).** RosterChief is designed as a
|
||||
> **multi-tenant platform** — one deployment serves many clubs, with **`Club` as the tenant
|
||||
> root**. Isolation is **row-based**: a shared database and schema where every club-owned
|
||||
> row carries a `club` FK (via `ClubScopedModel`), and *all* access is scoped to the
|
||||
> current tenant. The mechanics — tenant resolution, scoping manager, per-club uniqueness —
|
||||
> are specified in **§2.4**.
|
||||
>
|
||||
> ⚠️ **This supersedes `CLAUDE.md`**, which currently states the app is "deliberately *not*
|
||||
> multi-tenant … there is no `club_id` tenancy." That guidance and the project memory are
|
||||
> now **out of date** and must be updated to match this document — see the **banner at the
|
||||
> foot of this file**. Where the two disagree, this architecture is the intended direction.
|
||||
|
||||
---
|
||||
|
||||
## 1. App decomposition
|
||||
|
||||
The `isort` `known-first-party` roadmap lists nine apps. The build split the original
|
||||
`accounts` app into `authentication` + `club`. Per the decisions in §7, the people models
|
||||
(`Member`, `Family`) **move out of `authentication` into a dedicated `members` app**, and
|
||||
two apps (`formbuilder`, `shop`) are added **beyond the original roadmap** — add all new
|
||||
labels to `known-first-party` in `pyproject.toml` when they land. The target decomposition:
|
||||
|
||||
| App | Status | Responsibility | Models |
|
||||
|------------------|--------------|-----------------------------------------------------------|--------|
|
||||
| `authentication` | **built** | Login identity + tenancy/role services (global, cross-club) | `User` |
|
||||
| `members` | **planned** | People: person records, families (extract from `authentication`) | `Member`, `Family`, `FamilyMembership` |
|
||||
| `club` | **built** | Tenant root, **season**, season-scoped affiliation, club roles | `Club`, `Season` *(planned)*, `ClubMembership`, `ClubRole` *(planned)* |
|
||||
| `teams` | planned | Teams and season rosters | `Team`, `TeamMembership`, `StaffAssignment` |
|
||||
| `events` | planned | Training / matches / social events + attendance | `Event`, `Attendance` |
|
||||
| `news` | **built** | Club news: coach_manager-authored, editor-released | `News`, `NewsPhoto` |
|
||||
| `pages` | planned | Flat CMS pages for the public site | `Page` |
|
||||
| `home` | planned | Homepage composition / featured content | `HomeConfig` (per-club) or config-only |
|
||||
| `formbuilder` | planned | Admin-defined dynamic forms + submissions + reporting | `Form`, `Field`, `Submission`, `Answer` |
|
||||
| `shop` | planned | Cart-like shop, orders, payments, PDF invoices | `Product`, `Cart`, `CartItem`, `Order`, `OrderLine`, `Payment`, `Invoice` |
|
||||
| `search` | planned | Site search (likely no models; index/config only) | — |
|
||||
|
||||
**`User` stays global** (one login identity across the whole platform); everything else
|
||||
that belongs to a club is tenant-scoped (§2.4). This is why `Member` — a *person within a
|
||||
club* — lives in its own app and carries a `club` FK, while `User` does not.
|
||||
|
||||
**Open decision (§7):** whether to migrate `Member`/`Family` out of `authentication` into
|
||||
a dedicated `members` app. Recommendation: keep them in `authentication` for now; revisit
|
||||
only if the app grows unwieldy. The roadmap `members` name is reserved either way.
|
||||
|
||||
---
|
||||
|
||||
## 2. Shared conventions
|
||||
|
||||
These are already established in code — every new model follows them.
|
||||
|
||||
- **UUID primary keys.** Inherit `rosterchief.base.UUIDModel` (`id = UUIDField(default=uuid4)`).
|
||||
Never expose sequential integer PKs.
|
||||
- **`ClubScopedModel`** (`rosterchief.base`) adds the tenant `club` FK
|
||||
(`related_name="%(class)ss"`) and, under multi-tenancy, a tenant-aware manager + auto
|
||||
club-stamping `save()` (§2.4). **Every aggregate-root model inherits it**; leaf rows
|
||||
reachable only via a scoped parent (e.g. `Attendance` via `Event`) may inherit scope from
|
||||
the parent, though denormalising `club` onto them is recommended (§2.4).
|
||||
- **i18n everywhere.** Every field gets a `gettext_lazy` verbose name; every model sets
|
||||
`verbose_name` / `verbose_name_plural` and a sensible `Meta.ordering`. `TextChoices`
|
||||
labels are translated too.
|
||||
- **`__str__` on every model**, human-readable.
|
||||
- **Enumerations** use nested `models.TextChoices` (see `FamilyMembership.FamilyRole`).
|
||||
- **Phone numbers** use `PhoneNumberField` (from `django-phonenumber-field`), nullable.
|
||||
- **Through models** for many-to-many relationships that carry data (role, jersey number,
|
||||
attendance status) — never a bare `ManyToManyField` when the link has attributes.
|
||||
- **Business logic in `services/`**, not fat models or views (see
|
||||
`authentication/services/member_csv_importer.py`). Management commands are thin wrappers
|
||||
over services (see `import_members_csv`).
|
||||
- **Migrations are generated, not hand-edited** (ruff-excluded).
|
||||
|
||||
### Naming & relations
|
||||
- `related_name` is explicit and plural on the "many" side, chosen to read naturally from
|
||||
the parent (`club.members`, `family.memberships`, `member.family_memberships`).
|
||||
- Deletion policy is deliberate per FK: `CASCADE` for owned children,
|
||||
`SET_NULL`(+`null=True`) where the child should survive its parent (e.g.
|
||||
`Member.user`), `PROTECT` for references that must not silently disappear (planned:
|
||||
`Season` on rosters/events — see §5).
|
||||
|
||||
### 2.4 Multi-tenancy (row-based, shared schema)
|
||||
|
||||
`Club` is the **tenant root**. One deployment, one database, one schema; tenants are
|
||||
separated by a `club` FK on every owned row and by disciplined scoping of every query.
|
||||
This is the lightest multi-tenancy model and matches the existing `ClubScopedModel`
|
||||
scaffolding — no Postgres schemas, no per-tenant databases, no `django-tenants`.
|
||||
|
||||
**What gets a `club` FK.** Every *aggregate root* inherits `ClubScopedModel` and so carries
|
||||
`club` (`Member`, `Family`, `Season`, `Team`, `Event`, `ClubRole`, `Article`, `Category`,
|
||||
`Page`, `Form`, `Product`, `Cart`, `Order`, `Invoice`, …). Leaf rows reachable only through
|
||||
a scoped parent (`Answer`→`Submission`, `OrderLine`/`Payment`→`Order`, `CartItem`→`Cart`,
|
||||
`Attendance`→`Event`, `TeamMembership`/`StaffAssignment`→`Team`) may inherit scope via the
|
||||
parent — but **denormalising `club` onto them too is recommended** for leak-proof filtering
|
||||
and DB-level constraints. `User` is the **only** global identity model; it has no `club`.
|
||||
|
||||
**People vs. logins under tenancy.** A `User` is one platform-wide login that may belong to
|
||||
several clubs; a `Member` is that person *within one club*. So:
|
||||
- `Member.user` becomes a **`ForeignKey`** (not `OneToOneField`) — one user → many members
|
||||
(at most one per club): `unique_together (club, user)`.
|
||||
- `User.get_full_name()` can no longer assume a single member; resolve the member **for the
|
||||
current club** (via the tenant context below), falling back to email.
|
||||
|
||||
**Tenant resolution → `request.club`.** A `ClubTenantMiddleware` resolves the active club
|
||||
per request (recommended: **subdomain**, `ajax-united.rosterchief.app`; path-prefix
|
||||
`/c/<slug>/` is the alternative) and stores it on `request.club` *and* in a context
|
||||
variable so non-request code (services, management commands) can read it:
|
||||
|
||||
```
|
||||
# rosterchief/tenancy.py
|
||||
from contextvars import ContextVar
|
||||
_current_club: ContextVar = ContextVar("current_club", default=None)
|
||||
|
||||
def set_current_club(club): _current_club.set(club)
|
||||
def get_current_club(): return _current_club.get()
|
||||
def require_current_club(): # raises if unset — use in write paths
|
||||
club = _current_club.get()
|
||||
if club is None: raise RuntimeError("No active club in context")
|
||||
return club
|
||||
|
||||
class ClubTenantMiddleware: # resolves subdomain -> Club, sets both
|
||||
... # request.club = club; set_current_club(club)
|
||||
```
|
||||
|
||||
**Considered — `django.contrib.sites` for resolution (rejected as the mechanism).**
|
||||
Django's Sites framework is the obvious "does the batteries-included answer fit?" candidate,
|
||||
so it was evaluated explicitly:
|
||||
|
||||
*What it offers.* A `Site(domain, name)` model, `get_current_site(request)` (Host-header →
|
||||
`Site`, with a per-process `SITE_CACHE`), `CurrentSiteMiddleware` (sets `request.site`), and
|
||||
`CurrentSiteManager` (auto-filters models that hold an FK to `Site`). Ecosystem code
|
||||
(`flatpages`, `redirects`, `sitemaps`, `allauth`) is Site-aware for free.
|
||||
|
||||
*Why it does **not** fit as our tenancy mechanism:*
|
||||
- **Wrong scoping key.** `CurrentSiteManager` filters on a `site` FK; our tenant key is the
|
||||
`club` FK on `ClubScopedModel`. Adopting Sites' manager would mean putting a *second* FK on
|
||||
every model, or ignoring the manager — either way it buys us nothing over `.for_club()`.
|
||||
- **A parallel identity table.** `Site` duplicates identity that already lives on `Club`
|
||||
(`slug`, domain, name). Two tables to keep in sync, two sources of truth for "which tenant".
|
||||
- **`SITE_ID` is a single global.** The framework's happy path is *one process = one site*
|
||||
(`SITE_ID`). Multi-tenant host resolution requires leaving `SITE_ID` unset and relying on
|
||||
`get_current_site`'s exact-domain match — workable, but the setting is a standing foot-gun
|
||||
(any library that reads `SITE_ID` silently binds to the wrong tenant), and shells, tasks,
|
||||
and tests have no Host header, so they still need our contextvar (`require_current_club()`).
|
||||
- **Host-only.** Sites cannot express the path-prefix option (`/c/<slug>/`); resolution is
|
||||
purely `domain`-based, foreclosing that alternative.
|
||||
|
||||
*Verdict.* Keep **`Club` (with `slug` + optional `domain`) as the single tenant root** and
|
||||
resolve it in `ClubTenantMiddleware` — the resolution logic (Host → `Club`) is a few lines and
|
||||
avoids the sync/`SITE_ID` hazards. **Optional bridge:** if a Site-aware third party is later
|
||||
adopted (e.g. `allauth`, `sitemaps`), add a thin `Club.site = OneToOneField(Site)` kept in sync
|
||||
from `Club.save()`, so the ecosystem gets its `Site` while `Club` stays authoritative — do
|
||||
this only when such a dependency actually lands, not preemptively.
|
||||
|
||||
**Scoping manager.** `ClubScopedModel` gets a tenant-aware manager so day-to-day queries
|
||||
can't accidentally cross tenants:
|
||||
|
||||
```
|
||||
class TenantQuerySet(models.QuerySet):
|
||||
def for_club(self, club): return self.filter(club=club)
|
||||
def current(self): return self.filter(club=require_current_club())
|
||||
|
||||
class ClubScopedModel(UUIDModel):
|
||||
club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="%(class)ss")
|
||||
objects = TenantQuerySet.as_manager()
|
||||
def save(self, *args, **kwargs): # auto-stamp club from context if unset
|
||||
if self.club_id is None: self.club = require_current_club()
|
||||
super().save(*args, **kwargs)
|
||||
class Meta: abstract = True
|
||||
```
|
||||
|
||||
Prefer **explicit** `.for_club(club)` / `.current()` in views and services over a fully
|
||||
automatic global filter — auto-filtering via context state is convenient but hides tenant
|
||||
boundaries and bites hard in shells, tasks, and tests. Keep scoping visible.
|
||||
|
||||
**Per-club uniqueness.** Every constraint that was globally unique becomes **unique per
|
||||
club**. Concretely: `Season.name`, all public `slug`s (`Article`, `Page`, `Form`,
|
||||
`Product`), `Team (season, name)`, jersey numbers `(team, jersey_number)`, and the
|
||||
human-readable counters `Order.number` / `Invoice.number` are scoped by / allocated per
|
||||
club. A bare `unique=True` on a tenant model is almost always a bug — use
|
||||
`UniqueConstraint(fields=["club", …])`.
|
||||
|
||||
**Cross-cutting consequences (checklist for every feature):**
|
||||
- Admin: register a `club` list-filter and scope `get_queryset` for non-superusers.
|
||||
- Roles are **per-club** — see §3 (global Django Groups don't fit; use `ClubRole`).
|
||||
- Sequential numbers (invoices) allocate per club inside a transaction — never `count()+1`.
|
||||
- Tests must set a current club (a `with_club(club)` context-manager helper).
|
||||
- Config: wildcard host + CSRF for subdomains; see §8.
|
||||
|
||||
---
|
||||
|
||||
## 3. Roles & access control (RBAC)
|
||||
|
||||
Authorization is **service-layer, and per-club** (§2.4). Because roles differ per tenant —
|
||||
a user can be a treasurer at club A and merely a member at club B — global Django `Group`s
|
||||
(which are platform-wide) **do not fit**. Roles are stored as tenant-scoped `ClubRole` rows
|
||||
and *all* permission decisions go through a single service module; no `django-guardian`,
|
||||
no per-club Group hacks. Django's own permission framework is retained **only** for the
|
||||
platform-operator layer (`is_staff` / `is_superuser` in Django admin).
|
||||
|
||||
### 3.1 Two layers
|
||||
|
||||
1. **Platform operators** — `User.is_superuser` / `is_staff`. Global, cross-club; run the
|
||||
Django admin, manage the tenant list. Not a club role.
|
||||
2. **Club roles** — a member's standing *within one club*, stored per tenant. Two kinds:
|
||||
- **Club-wide roles** → `ClubRole` rows (below).
|
||||
- **Object-scoped roles** → derived from the domain graph, no extra rows:
|
||||
- Coach / manager **of a specific team** → `StaffAssignment(team, member, role)`.
|
||||
- Parent / guardian **of a specific member** → `FamilyMembership` + the family graph.
|
||||
- Purchaser vs. beneficiary → `Order`/`OrderLine` + `ClubMembership`.
|
||||
|
||||
### 3.2 `ClubRole` — club-wide role assignments *(app: `club`)*
|
||||
|
||||
```
|
||||
ClubRole(ClubScopedModel) # ClubScopedModel -> carries `club` (§2.4)
|
||||
member FK Member (CASCADE, related_name="roles")
|
||||
role CharField (TextChoices: MEMBER | EDITOR | TREASURER | BOARD)
|
||||
Meta: unique_together (club, member, role)
|
||||
```
|
||||
|
||||
| Role | Grants (representative) |
|
||||
|-------------|------------------------------------------------------------------------------|
|
||||
| *Public* | Anonymous — no row; read-only public site of that club. |
|
||||
| `MEMBER` | View own + family data, own rosters/attendance, own orders/invoices, submit member-only forms. |
|
||||
| `EDITOR` | Manage that club's `news`, `pages`, `formbuilder` content. |
|
||||
| `TREASURER` | Manage that club's `shop`: products, orders, payments, issue/void invoices. |
|
||||
| `BOARD` | Full management of that club: members, roles, all of the above. |
|
||||
|
||||
`news` is the one place a `ClubRole` and a derived role (`COACH_MANAGER`, see below)
|
||||
share a single workflow rather than each owning a separate permission: drafting is
|
||||
open to EDITOR/ADMIN *or* any coach_manager, but only EDITOR/ADMIN may publish —
|
||||
see §5.4.
|
||||
|
||||
`COACH` / `TEAM_MANAGER` are deliberately **not** `ClubRole`s — being a coach is always
|
||||
*of a team*, so it lives on `StaffAssignment` (§5.3). "Is this user a coach at this club?"
|
||||
= "do they have any `StaffAssignment` on a team in this club?".
|
||||
|
||||
### 3.3 The permission service
|
||||
|
||||
One module — `club/services/access.py` (or `authentication/services/access.py`) — answers
|
||||
every authorization question, always taking the club/object as an argument:
|
||||
|
||||
```
|
||||
has_club_role(user, club, role) -> bool # ClubRole lookup
|
||||
roles_in_club(user, club) -> set[str] # incl. derived COACH/MANAGER
|
||||
teams_managed_by(user, club) -> QuerySet[Team]
|
||||
members_visible_to(user, club) -> QuerySet[Member] # self + family + managed teams
|
||||
can_edit_event(user, event) -> bool
|
||||
can_manage_shop(user, club) -> bool # TREASURER or BOARD
|
||||
```
|
||||
|
||||
Views, admin, and templates call these — never re-derive access inline. Each helper scopes
|
||||
to the given club (§2.4), so a user's powers in club A never leak into club B.
|
||||
|
||||
### 3.4 Keeping roles in sync with domain state
|
||||
|
||||
`ClubRole` membership is **reconciled from domain facts**, not hand-assigned:
|
||||
|
||||
- An **active** `ClubMembership` for the club's current season → grant the `MEMBER` role;
|
||||
a lapsed one → revoke. (Season-scoped membership: §5.1.)
|
||||
- A `StaffAssignment` needs no `ClubRole` — coach status is derived (§3.2).
|
||||
|
||||
Implement as `club/services/access.py::reconcile_roles(user, club)`, invoked on the state
|
||||
changes that matter (membership activation/lapse), so authorization never drifts from data.
|
||||
|
||||
---
|
||||
|
||||
## 4. Built models (as-is, + planned tenancy changes)
|
||||
|
||||
### `authentication`
|
||||
|
||||
**`User`** — custom auth model, email is the login (`USERNAME_FIELD = "email"`, no
|
||||
username). UUID PK. `objects = UserManager()` (email-based `create_user` /
|
||||
`create_superuser`). **Stays global — the one model with no `club` FK** (§2.4). Because a
|
||||
user may belong to several clubs, `get_full_name` / `get_short_name` resolve the `Member`
|
||||
**for the current club** (via tenant context), falling back to email — they can no longer
|
||||
assume a single member.
|
||||
|
||||
### `members` *(planned — extract from `authentication`)*
|
||||
|
||||
`Member`, `Family`, `FamilyMembership` **move here** and become tenant-scoped
|
||||
(`ClubScopedModel`).
|
||||
|
||||
**`Member`** — a *person within one club*. `user` becomes a **`ForeignKey`** (was
|
||||
`OneToOneField`), still nullable (`on_delete=SET_NULL`), so one login maps to one member
|
||||
*per club* (`unique_together (club, user)`) and members can exist without logins (children,
|
||||
imports). Holds name, DOB, contact `email`/`phone`/`emergency_phone`. `contact_email`
|
||||
prefers the member's own email, else the login email. `guardians` returns parent/guardian
|
||||
members reachable through shared families (all within the same club).
|
||||
|
||||
**`Family`** + **`FamilyMembership`** — households, tenant-scoped. `FamilyMembership` carries
|
||||
a `FamilyRole` (`parent` / `child` / `guardian` / `other`), `unique_together (family,
|
||||
member)`; `Family.guardians` / `Family.children` are role-derived querysets. Powers the
|
||||
"parents see their children's data" object-scope (§3.1).
|
||||
|
||||
### `club`
|
||||
|
||||
**`Club`** — **the tenant root** (§2.4). Currently just `name`; extend with `slug` (unique,
|
||||
drives subdomain/path resolution), contact, and branding. Provide `Club.objects.current()`
|
||||
and resolve it in middleware — never hardcode a PK.
|
||||
|
||||
**`ClubMembership`** — links a `Member` to the `Club` with a `license` string. **Being made
|
||||
season-scoped** (§5.1): it gains a `season` FK and sign-up / fee-status fields, so each row
|
||||
is one member's affiliation for one season (`unique_together (club, member, season)`). This
|
||||
is the record the `MEMBER` role and shop fulfilment key off of (§3.4, §5.7).
|
||||
|
||||
---
|
||||
|
||||
## 5. Planned models (design)
|
||||
|
||||
Field lists below are **sketches** to implement against, not final migrations.
|
||||
|
||||
All sketches below are tenant-scoped: aggregate roots inherit **`ClubScopedModel`** (the
|
||||
`club` FK is shown implicitly and every listed `unique_together` is *within a club*, §2.4).
|
||||
|
||||
### 5.1 `Season` — the central organizing concept *(app: `club`)*
|
||||
|
||||
Everything time-bound hangs off a season. Rosters, events, and attendance are
|
||||
**season-scoped via FK** — never global state. Seasons are **per club** — each club runs
|
||||
its own.
|
||||
|
||||
```
|
||||
Season(ClubScopedModel) # -> carries `club`
|
||||
name CharField # e.g. "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().
|
||||
|
||||
**As built, `Attendance` also carries `showed_up`** (nullable bool, default `None`) —
|
||||
deliberately separate from `status`: `status` is the RSVP, `showed_up` is whether they
|
||||
actually turned up, set by a check-in. `None` means "never checked in" (true for every
|
||||
row today — there's no check-in UI yet, only Django admin); a "no-show" is
|
||||
`status in (present, selected)` and `showed_up is False`, and is *never* inferred from
|
||||
a missing check-in. See `events/services/attendance.py::record_check_in` and
|
||||
`management/views.py::TeamDetailView`'s attendance panel.
|
||||
|
||||
### 5.4 `news`, `pages`, `home` (public site / editorial)
|
||||
|
||||
**`news` is built** (as of the coach_manager-authoring / editor-release-flow work) —
|
||||
team-tagged instead of categorised, with a two-step release flow rather than a bare
|
||||
`is_published` flag:
|
||||
|
||||
```
|
||||
news.News(ClubScopedModel) # -> carries `club`
|
||||
title, slug (SlugField, auto from title), body (TextField)
|
||||
teams M2M teams.Team (blank -- empty means club-wide)
|
||||
visibility CharField (TextChoices: internal | external | both)
|
||||
status CharField (TextChoices: draft | published)
|
||||
published_at DateTimeField (null) -- may be in the future: a *scheduled* release,
|
||||
not a cron-flipped field (see below)
|
||||
created_by FK members.Member (SET_NULL, null, related_name="news_items")
|
||||
Meta: unique_together (club, slug); ordering = ["-created"]
|
||||
|
||||
news.NewsPhoto(UUIDModel) # club reached via news_item, not directly scoped
|
||||
news_item FK news.News (CASCADE, related_name="photos")
|
||||
image ImageField
|
||||
is_main BooleanField
|
||||
ordering PositiveSmallIntegerField
|
||||
Meta: UniqueConstraint(fields=["news_item"], condition=Q(is_main=True))
|
||||
-- a partial unique index enforcing "at most one main photo per item"
|
||||
at the DB level, the same trick teams.Position uses for
|
||||
management_position_implies_staff_position.
|
||||
|
||||
pages.Page(ClubScopedModel) # flat CMS pages: "About", "Contact", ...
|
||||
title, slug, body (TextField)
|
||||
is_published BooleanField; menu_order (int)
|
||||
Meta: unique_together (club, slug)
|
||||
# If nested navigation is needed, add: parent = FK self (SET_NULL, null)
|
||||
|
||||
home.HomeConfig(ClubScopedModel) # one row PER CLUB: featured articles/teams, hero content
|
||||
# (unique_together (club,) — one per tenant). May be config-only.
|
||||
```
|
||||
|
||||
- **Authoring vs. releasing are deliberately separate authorities**
|
||||
(`club/services/access.py::can_add_news`/`can_publish_news`/`can_edit_news`): any
|
||||
current-season coach_manager (management-position `StaffAssignment`), EDITOR, or
|
||||
ADMIN can draft a `News` item and edit it while it's a draft; only EDITOR/ADMIN can
|
||||
move it to `published` (or edit it once it is) — a physio or plain staff member can't
|
||||
post news, and a coach_manager can't push their own draft live.
|
||||
- **Scheduling needs no cron job.** `published_at` can be set in the future; `status`
|
||||
already reads `PUBLISHED` (it passed the editor's release gate) but `News.is_scheduled`
|
||||
is true until that moment passes. A later public/member-facing consumer just filters
|
||||
`status=PUBLISHED, published_at__lte=now()` — nothing has to flip a row at the
|
||||
scheduled instant.
|
||||
- **`created_by` links to `members.Member`** (decision §7 #5) — attribution is to a
|
||||
club person, not a raw login; `SET_NULL` so deleting a member doesn't erase their posts.
|
||||
- `slug`s back clean public URLs and feed `search`; they are **unique per club** (§2.4), so
|
||||
two clubs can both have `/news/season-kickoff`. Resolve within the request's club.
|
||||
- `visibility` (internal/external/both) is stored and enforced nowhere yet — no
|
||||
member-facing reading page or public API exists. Both are later work; the field is
|
||||
there so they don't need a backfill when they land.
|
||||
- `NewsPhoto.image` / hero images use `ImageField` → **media storage must be configured**
|
||||
(§8). If page/news trees grow, consider a tree library later — start flat.
|
||||
|
||||
### 5.5 `search`
|
||||
|
||||
Likely **no models** — a search view over `Article`, `Page`, `Team`, `Event`. If moving to
|
||||
Postgres full-text or an external index, add config here, not domain tables.
|
||||
|
||||
### 5.6 `formbuilder` — dynamic forms *(new app)*
|
||||
|
||||
Admins/editors define forms with a **variable number of fields** at runtime; submissions
|
||||
are stored so they can be **reported on** later. This uses the classic EAV (entity-
|
||||
attribute-value) shape with **normalized `Answer` rows as the single source of truth**
|
||||
(decision §7 #9) — one row per answered field, with a JSON `value` to stay flexible across
|
||||
field types. No parallel JSON blob on the submission — reporting reads `Answer`s directly.
|
||||
|
||||
```
|
||||
Form(ClubScopedModel) # -> carries `club`
|
||||
title, slug, description (blank)
|
||||
is_active BooleanField
|
||||
login_required BooleanField # members-only vs public submission
|
||||
opens_at / closes_at DateTimeField (null) # optional submission window
|
||||
max_submissions_per_user PositiveInteger (null) # null = unlimited
|
||||
Meta: unique_together (club, slug); ordering = ["title"]
|
||||
|
||||
Field(UUIDModel) # a form's field definition (club implied by form)
|
||||
form FK Form (CASCADE, related_name="fields")
|
||||
key SlugField # stable machine name, unique per form (for reporting)
|
||||
label CharField
|
||||
field_type CharField (TextChoices: text | textarea | number | email | date |
|
||||
choice | multichoice | checkbox | file)
|
||||
required BooleanField
|
||||
help_text CharField (blank)
|
||||
order PositiveSmallIntegerField
|
||||
is_active BooleanField (default=True) # soft-retire instead of deleting (see below)
|
||||
options JSONField (default=list) # choices for choice/multichoice: [{value,label}]
|
||||
Meta: unique_together (form, key); ordering = ["form", "order"]
|
||||
|
||||
Submission(UUIDModel) # container only — no answer data on it
|
||||
form FK Form (CASCADE, related_name="submissions")
|
||||
member FK Member (SET_NULL, null) # set when submitter is logged in
|
||||
submitted_at DateTimeField
|
||||
Meta: ordering = ["-submitted_at"]
|
||||
|
||||
Answer(UUIDModel) # CANONICAL store — one per answered field
|
||||
submission FK Submission (CASCADE, related_name="answers")
|
||||
field FK Field (PROTECT, related_name="answers")
|
||||
value JSONField # scalar, list (multichoice), or file ref
|
||||
Meta: unique_together (submission, field)
|
||||
```
|
||||
|
||||
Design notes:
|
||||
- **`Answer` is canonical; there is no denormalized JSON snapshot.** A submission's values
|
||||
are always read/aggregated from its `Answer` rows. The submit service
|
||||
(`formbuilder/services/submit.py`) validates the dynamic form and writes the `Submission`
|
||||
+ its `Answer`s in one transaction. (If a flat per-submission view ever becomes a
|
||||
hotspot, add a *derived, rebuildable* cache later — but the model stays the source.)
|
||||
- **`Field.key` is immutable once submissions exist** — reporting joins on it. Deleting a
|
||||
field with answers is blocked (`PROTECT`); **retire via `is_active=False`** instead.
|
||||
- **Reporting** = a service/view producing per-field aggregates (counts per choice,
|
||||
numeric averages, response rate) plus a wide CSV/Excel export (one column per field,
|
||||
one row per submission). No extra model needed; add a saved-report model later only if
|
||||
users need to persist report definitions.
|
||||
- Rendering a `Form` to a Django form (and validating a submission) is a service concern —
|
||||
build the form class dynamically from `Field` rows; don't hand-write form classes.
|
||||
|
||||
### 5.7 `shop` — cart, orders, payments & PDF invoices *(new app)*
|
||||
|
||||
A cart-like shop where a member (or parent) "buys" products — chiefly a **season
|
||||
membership** — with payment-status tracking and generated invoices. Fulfilment of a
|
||||
membership product writes back to the season-scoped `ClubMembership` (§5.1).
|
||||
|
||||
```
|
||||
Product(ClubScopedModel) # -> carries `club`
|
||||
name, slug, description (blank)
|
||||
kind CharField (TextChoices: membership | event_fee | merchandise | donation)
|
||||
price DecimalField(max_digits=8, decimal_places=2) # list price
|
||||
season FK Season (PROTECT, null) # set for membership/event products
|
||||
is_active BooleanField
|
||||
# early-bird / prompt-payment discount (§5.7.1) — per-product toggle + deadline
|
||||
early_bird_enabled BooleanField (default=False)
|
||||
early_bird_deadline DateField (null) # discount valid through this date (inclusive)
|
||||
early_bird_disc_type CharField (TextChoices DiscountType: PERCENT | AMOUNT, blank)
|
||||
early_bird_disc_value DecimalField(max_digits=8, decimal_places=2, null) # 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.
59
CLAUDE.md
Normal file
59
CLAUDE.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
RosterChief is a sport club management app + public website, built on **Django 6.0** (Python 3.14+). As of **2026-07-11 it is designed as a multi-tenant platform** (row-based / shared-schema): one deployment serves many clubs, with `Club` as the tenant root. Every club-owned model carries a `club` FK (via `ClubScopedModel`); `User` is the only global model. This **reverses** the project's earlier single-club stance — treat older "single-club / no `club_id` tenancy" notes (in git history or memory) as obsolete.
|
||||
|
||||
**`ARCHITECTURE.md` at the repo root is the authoritative model & domain design** — the tenancy mechanics, the RBAC design, and per-app model sketches all live there. Consult and update it when adding domain models.
|
||||
|
||||
The repo is an early build: `authentication` and `club` apps exist (`User`, `Member`, `Family`, `FamilyMembership`, `Club`, `ClubMembership`); the remaining domain apps and the tenancy plumbing (`rosterchief/tenancy.py`, tenant middleware, `ClubScopedModel` upgrade) are **planned, not yet on disk**. Verify against the actual tree before assuming a module exists.
|
||||
|
||||
## Commands
|
||||
|
||||
Dependencies and the virtualenv are managed with **uv** (`pyproject.toml` at repo root, `uv.lock` committed). Run Django/tools through `uv run` so the project venv is used.
|
||||
|
||||
```bash
|
||||
uv sync # install deps (incl. dev group) into .venv
|
||||
uv run python manage.py runserver # dev server
|
||||
uv run python manage.py migrate # apply migrations
|
||||
uv run python manage.py makemigrations
|
||||
uv run python manage.py createsuperuser
|
||||
uv run python manage.py shell
|
||||
|
||||
uv run python manage.py test # run all tests (Django test runner)
|
||||
uv run python manage.py test <app> # one app
|
||||
uv run python manage.py test <app>.tests.<Case> # one TestCase
|
||||
uv run python manage.py test <app>.tests.<Case>.<method> # one test
|
||||
|
||||
uv run ruff check . # lint
|
||||
uv run ruff check --fix . # lint + autofix
|
||||
uv run ruff format . # format
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings live in a single `rosterchief/settings.py` and read from the environment via **python-decouple** (`config(...)`), with a local `.env` file for dev. Key vars: `DJANGO_SECRET_KEY` (required), `DJANGO_DEBUG`, `DJANGO_ALLOWED_HOSTS`, `DJANGO_CSRF_TRUSTED_ORIGINS`, `DJANGO_DATABASE_URL`, `DJANGO_TIME_ZONE`.
|
||||
|
||||
The database is configured through a single `DJANGO_DATABASE_URL` (parsed by **dj-database-url**), defaulting to `sqlite:///db.sqlite3` for dev; production is intended to point at PostgreSQL via that URL. Don't hardcode DB settings — go through the env var.
|
||||
|
||||
## Planned architecture
|
||||
|
||||
**`ARCHITECTURE.md` is the source of truth for the model design; this is a summary.** The app decomposition (`authentication`, `members`, `club`, `teams`, `events`, `news`, `pages`, `home`, `formbuilder`, `shop`, `search`) has grown past the original `pyproject.toml` isort `known-first-party` list — add new labels there as apps land. Note the `accounts` app was split into `authentication` (global login) + `club`, and people models (`Member`, `Family`) are being moved into a dedicated `members` app.
|
||||
|
||||
Domain notes (drive modeling decisions):
|
||||
- **Multi-tenancy is the cross-cutting rule.** `Club` is the tenant root; club-owned models inherit `ClubScopedModel` (a `club` FK). Scope every query to the current tenant (`.for_club()` / `.current()`); previously-global uniqueness (slugs, season names, invoice numbers) becomes **unique per club**. Only `User` is global. See `ARCHITECTURE.md` §2.4.
|
||||
- **Season** is the central organizing concept, **per club**. Team rosters, events, and attendance are season-scoped — model them with a FK to a season, not as global state.
|
||||
- A **Member** (a person *within one club*) can play on one or more **Teams**, each with a position + jersey number (unique within a team), always tied to a specific season.
|
||||
- **RBAC is per-club and service-layer** (not `django-guardian`, not global Django groups): `ClubRole` rows (`MEMBER` / `EDITOR` / `TREASURER` / `BOARD`) plus object-scoped roles (coach via `StaffAssignment`, parent via `FamilyMembership`), all decisions routed through an access service. Django's own permissions are used only for the platform-admin layer.
|
||||
- Later modules: `formbuilder` (admin-defined dynamic forms → normalized answers → reporting) and `shop` (cart → order → payment → HTML→PDF invoices via WeasyPrint), with season-scoped `ClubMembership` tracking sign-up + fee status per season.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Ruff config anticipates a Wagtail-style codebase (`DJ` Django rules; `RUF012`/`RUF005` ignored for framework idioms; `line-length = 250`). Migrations are excluded from linting — don't hand-edit them to satisfy ruff.
|
||||
- Settings files are exempt from `F403/F405/E501` (star imports allowed) under `rosterchief/settings/*` — note the config expects a settings *package*, though the current code is a single `settings.py`. If you split settings, match that path.
|
||||
- **Every user-facing string must be translatable** (`USE_I18N = True`; no `.po` files exist yet, but the codebase is kept translation-ready as it's written, not audited later). This applies to templates, models, forms, and views alike:
|
||||
- Templates: `{% load i18n %}`, then `{% trans "..." %}` for literal text and `{% blocktrans %}...{% endblocktrans %}` for text containing a variable (bind filter chains to a plain name first via `{% blocktrans with x=some.filtered|value %}`, or `{% blocktrans count counter=n %}...{% plural %}...{% endblocktrans %}` for pluralized counts — never hand-roll pluralization with `|pluralize`, real languages have more than two plural forms). Covers headings, buttons, table headers, empty-state text, `placeholder=`/`aria-label=` attributes — not URL names, CSS classes, icon names, `dom_id` arguments, or raw data interpolated on its own.
|
||||
- Python (models, forms, views, services): `from django.utils.translation import gettext_lazy as _`, wrapping `verbose_name`, `help_text`, `Meta.verbose_name(_plural)`, form field `label`/`help_text`, `TextChoices`/`IntegerChoices` labels (never the choice *value*), and any user-facing string built in a method/view (`notify()` messages, raised `ValidationError`/`ValueError` text, dict labels rendered directly in a template).
|
||||
- Never bake an interpolated value directly into a translatable string (an f-string with `{variable}` *inside* the translated text) — word order isn't guaranteed to survive translation. Use `%(name)s` placeholders instead: `_("“%(name)s” updated.") % {"name": obj}`. This matters most for `controlpanel.messages.notify(request, spec)`, whose `"<level>|<title>|<body>"` spec is assembled via f-string — translate the title and body as separate `_()`/`%()` expressions, then drop the already-resolved strings into the f-string skeleton.
|
||||
594
DEPLOYMENT.md
Normal file
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", "-"]
|
||||
6
api/apps.py
Normal file
6
api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
name = "api"
|
||||
verbose_name = "Public API"
|
||||
17
api/errors.py
Normal file
17
api/errors.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Shared error helpers for the public API -- see api/urls.py.
|
||||
|
||||
Every endpoint is club-scoped via the same subdomain-based tenant resolution
|
||||
the rest of the platform uses (club.tenancy.ClubTenantMiddleware sets
|
||||
request.club before any view runs). A request with no club on the host --
|
||||
the bare base domain, an unknown slug, an archived club -- has nothing to
|
||||
serve, so it 404s the same way club.mixins.ClubStaffRequiredMixin already
|
||||
404s the staff-facing app off the base domain.
|
||||
"""
|
||||
|
||||
from ninja.errors import HttpError
|
||||
|
||||
|
||||
def require_club(request):
|
||||
if request.club is None:
|
||||
raise HttpError(404, "No club found for this host.")
|
||||
return request.club
|
||||
31
api/middleware.py
Normal file
31
api/middleware.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""CORS for the public API only.
|
||||
|
||||
Every route under /api/v1/ is public, read-only, and unauthenticated -- no
|
||||
cookies or credentials are ever involved, so there's no CSRF/session risk in
|
||||
answering any origin. That's the whole reason this is a few lines here
|
||||
instead of pulling in django-cors-headers for a handful of GET routes: the
|
||||
rest of the site keeps Django's ordinary same-origin behaviour untouched.
|
||||
"""
|
||||
|
||||
from django.http import HttpResponse
|
||||
|
||||
API_PATH_PREFIX = "/api/v1/"
|
||||
|
||||
|
||||
class PublicApiCorsMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if not request.path.startswith(API_PATH_PREFIX):
|
||||
return self.get_response(request)
|
||||
|
||||
if request.method == "OPTIONS":
|
||||
response = HttpResponse(status=204)
|
||||
else:
|
||||
response = self.get_response(request)
|
||||
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
response["Access-Control-Allow-Methods"] = "GET, OPTIONS"
|
||||
response["Access-Control-Allow-Headers"] = "Content-Type"
|
||||
return response
|
||||
451
api/tests.py
Normal file
451
api/tests.py
Normal file
@@ -0,0 +1,451 @@
|
||||
import datetime
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Club, Season, Sponsor
|
||||
from events.models import Event, Location, Opponent
|
||||
from members.models import Member
|
||||
from news.models import News, NewsPhoto
|
||||
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||
|
||||
|
||||
@override_settings(
|
||||
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"],
|
||||
)
|
||||
class ApiTestBase(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
today = timezone.localdate()
|
||||
self.season = Season.objects.create(club=self.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
|
||||
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
|
||||
|
||||
def api_get(self, path, **params):
|
||||
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
def api_get_base_domain(self, path, **params):
|
||||
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="rosterchief.app")
|
||||
|
||||
|
||||
class NewsApiTests(ApiTestBase):
|
||||
def make_news(self, **overrides):
|
||||
defaults = {"club": self.club, "title": "News", "body": "body", "status": News.Status.PUBLISHED, "published_at": timezone.now() - datetime.timedelta(hours=1), "visibility": News.Visibility.EXTERNAL}
|
||||
defaults.update(overrides)
|
||||
return News.objects.create(**defaults)
|
||||
|
||||
def test_a_draft_is_excluded(self):
|
||||
self.make_news(status=News.Status.DRAFT, published_at=None)
|
||||
|
||||
self.assertEqual(self.api_get("/news/").json()["count"], 0)
|
||||
|
||||
def test_a_scheduled_but_not_yet_released_item_is_excluded(self):
|
||||
self.make_news(published_at=timezone.now() + datetime.timedelta(days=1))
|
||||
|
||||
self.assertEqual(self.api_get("/news/").json()["count"], 0)
|
||||
|
||||
def test_an_internal_only_item_is_excluded(self):
|
||||
self.make_news(visibility=News.Visibility.INTERNAL)
|
||||
|
||||
self.assertEqual(self.api_get("/news/").json()["count"], 0)
|
||||
|
||||
def test_an_external_item_is_included(self):
|
||||
item = self.make_news(visibility=News.Visibility.EXTERNAL)
|
||||
|
||||
data = self.api_get("/news/").json()
|
||||
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertEqual(data["results"][0]["id"], str(item.pk))
|
||||
|
||||
def test_a_both_visibility_item_is_included(self):
|
||||
self.make_news(visibility=News.Visibility.BOTH)
|
||||
|
||||
self.assertEqual(self.api_get("/news/").json()["count"], 1)
|
||||
|
||||
def test_newest_first(self):
|
||||
older = self.make_news(title="Older", published_at=timezone.now() - datetime.timedelta(days=2))
|
||||
newer = self.make_news(title="Newer", published_at=timezone.now() - datetime.timedelta(hours=1))
|
||||
|
||||
results = self.api_get("/news/").json()["results"]
|
||||
|
||||
self.assertEqual([r["id"] for r in results], [str(newer.pk), str(older.pk)])
|
||||
|
||||
def test_photos_get_absolute_urls(self):
|
||||
item = self.make_news()
|
||||
NewsPhoto.objects.create(news_item=item, image="clubs/ajax-united/news/x/pic.jpg", is_main=True)
|
||||
|
||||
photo = self.api_get("/news/").json()["results"][0]["photos"][0]
|
||||
|
||||
self.assertTrue(photo["url"].startswith("http://ajax-united.rosterchief.app/media/"))
|
||||
self.assertTrue(photo["is_main"])
|
||||
|
||||
def test_pagination_limit_and_offset(self):
|
||||
for i in range(3):
|
||||
self.make_news(title=f"Item {i}", published_at=timezone.now() - datetime.timedelta(hours=1, minutes=i))
|
||||
|
||||
data = self.api_get("/news/", limit=1, offset=1).json()
|
||||
|
||||
self.assertEqual(data["count"], 3)
|
||||
self.assertEqual(len(data["results"]), 1)
|
||||
|
||||
def test_limit_is_capped(self):
|
||||
self.assertEqual(self.api_get("/news/", limit=1000).json()["limit"], 100)
|
||||
|
||||
def test_no_news_is_an_empty_list_not_an_error(self):
|
||||
response = self.api_get("/news/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["results"], [])
|
||||
|
||||
|
||||
class TeamsApiTests(ApiTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW", ordering=1)
|
||||
self.defense = Position.objects.create(club=self.club, name="Defense", short_name="DF", ordering=2)
|
||||
self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
|
||||
|
||||
def test_list_teams(self):
|
||||
data = self.api_get("/teams/").json()
|
||||
|
||||
self.assertEqual(data, [{"id": str(self.team.pk), "name": "First Team", "short_name": "1st", "photo_url": None}])
|
||||
|
||||
def test_roster_groups_players_by_position(self):
|
||||
alice = Member.objects.create(first_name="Alice", last_name="Ash")
|
||||
bob = Member.objects.create(first_name="Bob", last_name="Birch")
|
||||
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
||||
# Two forwards (ordering 1) at different jersey numbers, one defense (ordering 2).
|
||||
TeamMembership.objects.create(team=self.team, member=bob, season=self.season, position=self.forward, jersey_number=9)
|
||||
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
|
||||
TeamMembership.objects.create(team=self.team, member=carol, season=self.season, position=self.defense, jersey_number=1)
|
||||
|
||||
groups = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
|
||||
|
||||
# Groups in Position.ordering order (Forward before Defense); within a
|
||||
# group, sorted by jersey number.
|
||||
self.assertEqual([g["position"] for g in groups], ["Forward", "Defense"])
|
||||
self.assertEqual([p["first_name"] for p in groups[0]["players"]], ["Alice", "Bob"])
|
||||
self.assertEqual([p["first_name"] for p in groups[1]["players"]], ["Carol"])
|
||||
|
||||
def test_a_player_entry_no_longer_repeats_its_position(self):
|
||||
# The position is now the group key, not a per-player field.
|
||||
alice = Member.objects.create(first_name="Alice", last_name="Ash")
|
||||
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
|
||||
|
||||
player = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"][0]["players"][0]
|
||||
|
||||
self.assertNotIn("position", player)
|
||||
|
||||
def test_roster_includes_staff(self):
|
||||
dana = Member.objects.create(first_name="Dana", last_name="Dean")
|
||||
StaffAssignment.objects.create(team=self.team, member=dana, season=self.season, position=self.coach_position)
|
||||
|
||||
staff = self.api_get(f"/teams/{self.team.pk}/roster/").json()["staff"]
|
||||
|
||||
self.assertEqual(staff, [{"id": str(dana.pk), "first_name": "Dana", "last_name": "Dean", "position": "Head Coach"}])
|
||||
|
||||
def test_roster_is_current_season_only(self):
|
||||
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
|
||||
eve = Member.objects.create(first_name="Eve", last_name="Elm")
|
||||
TeamMembership.objects.create(team=self.team, member=eve, season=other_season, position=self.forward, jersey_number=1)
|
||||
|
||||
players = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
|
||||
|
||||
self.assertEqual(players, [])
|
||||
|
||||
def test_roster_is_empty_with_no_current_season(self):
|
||||
self.season.delete()
|
||||
team_without_season = self.team
|
||||
|
||||
data = self.api_get(f"/teams/{team_without_season.pk}/roster/").json()
|
||||
|
||||
self.assertEqual(data["season"], None)
|
||||
self.assertEqual(data["players"], [])
|
||||
self.assertEqual(data["staff"], [])
|
||||
|
||||
def test_list_teams_includes_the_current_seasons_photo(self):
|
||||
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
|
||||
|
||||
photo_url = self.api_get("/teams/").json()[0]["photo_url"]
|
||||
|
||||
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
|
||||
|
||||
def test_roster_includes_the_current_seasons_photo(self):
|
||||
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
|
||||
|
||||
photo_url = self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"]
|
||||
|
||||
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
|
||||
|
||||
def test_a_photo_from_a_different_season_does_not_leak(self):
|
||||
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
|
||||
TeamPhoto.objects.create(team=self.team, season=other_season, image="clubs/ajax-united/teams/x/00-00/pic.jpg")
|
||||
|
||||
self.assertIsNone(self.api_get("/teams/").json()[0]["photo_url"])
|
||||
self.assertIsNone(self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"])
|
||||
|
||||
def test_a_team_from_another_club_404s(self):
|
||||
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
|
||||
|
||||
response = self.api_get(f"/teams/{other_team.pk}/roster/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class GamesApiTests(ApiTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
|
||||
self.opponent = Opponent.objects.create(club=self.club, name="Rivals FC")
|
||||
|
||||
def make_game(self, **overrides):
|
||||
defaults = {"club": self.club, "title": "Game", "kind": Event.EventKind.GAME, "start": timezone.now() + datetime.timedelta(days=1), "opponent": self.opponent}
|
||||
defaults.update(overrides)
|
||||
event = Event.objects.create(**defaults)
|
||||
event.teams.add(self.team)
|
||||
return event
|
||||
|
||||
def test_upcoming_games_are_listed_with_location_and_teams(self):
|
||||
self.make_game(location=self.home_location)
|
||||
|
||||
games = self.api_get("/games/upcoming/").json()
|
||||
|
||||
self.assertEqual(len(games), 1)
|
||||
self.assertEqual(games[0]["home_team"], "First Team")
|
||||
self.assertEqual(games[0]["away_team"], "Rivals FC")
|
||||
self.assertEqual(games[0]["location"]["name"], "Home Arena")
|
||||
self.assertEqual(games[0]["status"], "upcoming")
|
||||
|
||||
def test_upcoming_excludes_cancelled_games(self):
|
||||
self.make_game(cancelled=True)
|
||||
|
||||
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
|
||||
|
||||
def test_upcoming_excludes_past_games(self):
|
||||
self.make_game(start=timezone.now() - datetime.timedelta(days=1))
|
||||
|
||||
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
|
||||
|
||||
def test_upcoming_includes_tournaments(self):
|
||||
self.make_game(kind=Event.EventKind.TOURNAMENT)
|
||||
|
||||
self.assertEqual(len(self.api_get("/games/upcoming/").json()), 1)
|
||||
|
||||
def test_upcoming_excludes_other_kinds(self):
|
||||
self.make_game(kind=Event.EventKind.TRAINING)
|
||||
self.make_game(kind=Event.EventKind.SOCIAL)
|
||||
|
||||
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
|
||||
|
||||
def test_live_endpoint_stays_game_only(self):
|
||||
# is_live/scores are game-specific -- a tournament wouldn't have
|
||||
# anything meaningful to show here even if flagged live.
|
||||
self.make_game(kind=Event.EventKind.TOURNAMENT, start=timezone.now() - datetime.timedelta(minutes=10), is_live=True)
|
||||
|
||||
self.assertEqual(self.api_get("/games/live/").json(), [])
|
||||
|
||||
def test_count_is_respected(self):
|
||||
for i in range(3):
|
||||
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
|
||||
|
||||
self.assertEqual(len(self.api_get("/games/upcoming/", count=2).json()), 2)
|
||||
|
||||
def test_count_is_capped(self):
|
||||
for i in range(3):
|
||||
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
|
||||
|
||||
# Cap is 50, well above the 3 created -- just confirm an oversized
|
||||
# request doesn't error and doesn't somehow exceed what exists.
|
||||
response = self.api_get("/games/upcoming/", count=1000)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.json()), 3)
|
||||
|
||||
def test_live_games_are_listed_with_scores(self):
|
||||
self.make_game(start=timezone.now() - datetime.timedelta(minutes=10), is_live=True, score_for=2, score_against=1, location=self.home_location)
|
||||
|
||||
games = self.api_get("/games/live/").json()
|
||||
|
||||
self.assertEqual(len(games), 1)
|
||||
self.assertEqual(games[0]["status"], "live")
|
||||
self.assertEqual(games[0]["home_score"], 2)
|
||||
self.assertEqual(games[0]["away_score"], 1)
|
||||
|
||||
def test_live_excludes_cancelled_games(self):
|
||||
self.make_game(is_live=True, cancelled=True)
|
||||
|
||||
self.assertEqual(self.api_get("/games/live/").json(), [])
|
||||
|
||||
def test_non_live_games_are_excluded_from_live_endpoint(self):
|
||||
self.make_game()
|
||||
|
||||
self.assertEqual(self.api_get("/games/live/").json(), [])
|
||||
|
||||
def test_score_relabelling_for_an_away_game(self):
|
||||
# No location (or a non-home one) -- is_home_game is False, so our
|
||||
# team's score_for/score_against map to the away side.
|
||||
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3)
|
||||
|
||||
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
|
||||
|
||||
self.assertEqual(games[0]["home_team"], "Rivals FC")
|
||||
self.assertEqual(games[0]["away_team"], "First Team")
|
||||
self.assertEqual(games[0]["home_score"], 3)
|
||||
self.assertEqual(games[0]["away_score"], 4)
|
||||
self.assertEqual(games[0]["status"], "finished")
|
||||
|
||||
def test_score_relabelling_for_a_home_game(self):
|
||||
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3, location=self.home_location)
|
||||
|
||||
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
|
||||
|
||||
self.assertEqual(games[0]["home_team"], "First Team")
|
||||
self.assertEqual(games[0]["away_team"], "Rivals FC")
|
||||
self.assertEqual(games[0]["home_score"], 4)
|
||||
self.assertEqual(games[0]["away_score"], 3)
|
||||
|
||||
def test_team_games_includes_past_and_upcoming_for_the_current_season(self):
|
||||
self.make_game(title="Past", start=timezone.now() - datetime.timedelta(days=1), score_for=1, score_against=0)
|
||||
self.make_game(title="Future", start=timezone.now() + datetime.timedelta(days=1))
|
||||
|
||||
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
|
||||
|
||||
self.assertEqual(len(games), 2)
|
||||
|
||||
def test_team_games_excludes_a_different_season(self):
|
||||
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
|
||||
self.make_game(start=datetime.datetime(2000, 6, 1, tzinfo=datetime.UTC), season=other_season)
|
||||
|
||||
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
|
||||
|
||||
def test_team_games_excludes_cancelled(self):
|
||||
self.make_game(cancelled=True)
|
||||
|
||||
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
|
||||
|
||||
def test_team_games_is_empty_with_no_current_season(self):
|
||||
self.make_game()
|
||||
self.season.delete()
|
||||
|
||||
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
|
||||
|
||||
def test_a_team_from_another_club_404s_on_games(self):
|
||||
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
|
||||
|
||||
response = self.api_get(f"/teams/{other_team.pk}/games/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class TenancyAndCorsTests(ApiTestBase):
|
||||
def test_the_base_domain_404s(self):
|
||||
response = self.api_get_base_domain("/news/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_a_get_response_carries_the_cors_header(self):
|
||||
response = self.api_get("/news/")
|
||||
|
||||
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
|
||||
|
||||
def test_an_options_preflight_gets_a_204_with_cors_headers(self):
|
||||
response = self.client.options("/api/v1/news/", HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
|
||||
self.assertIn("GET", response["Access-Control-Allow-Methods"])
|
||||
|
||||
def test_cors_headers_are_not_added_outside_the_api(self):
|
||||
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertNotIn("Access-Control-Allow-Origin", response)
|
||||
|
||||
def test_docs_page_resolves(self):
|
||||
self.assertEqual(self.api_get("/docs").status_code, 200)
|
||||
|
||||
def test_openapi_schema_resolves(self):
|
||||
self.assertEqual(self.api_get("/openapi.json").status_code, 200)
|
||||
|
||||
|
||||
class SponsorApiTests(ApiTestBase):
|
||||
def make_sponsor(self, **overrides):
|
||||
today = timezone.localdate()
|
||||
defaults = {"club": self.club, "name": "Acme Corp", "start_date": today - datetime.timedelta(days=10), "end_date": today + datetime.timedelta(days=10)}
|
||||
defaults.update(overrides)
|
||||
return Sponsor.objects.create(**defaults)
|
||||
|
||||
def test_a_sponsor_covering_today_is_included(self):
|
||||
self.make_sponsor()
|
||||
|
||||
data = self.api_get("/sponsors/").json()
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]["name"], "Acme Corp")
|
||||
|
||||
def test_a_sponsor_starting_in_the_future_is_excluded(self):
|
||||
today = timezone.localdate()
|
||||
self.make_sponsor(start_date=today + datetime.timedelta(days=1), end_date=None)
|
||||
|
||||
self.assertEqual(self.api_get("/sponsors/").json(), [])
|
||||
|
||||
def test_a_sponsor_that_already_ended_is_excluded(self):
|
||||
today = timezone.localdate()
|
||||
self.make_sponsor(start_date=today - datetime.timedelta(days=20), end_date=today - datetime.timedelta(days=1))
|
||||
|
||||
self.assertEqual(self.api_get("/sponsors/").json(), [])
|
||||
|
||||
def test_a_sponsor_with_no_end_date_and_a_past_start_is_included(self):
|
||||
today = timezone.localdate()
|
||||
self.make_sponsor(start_date=today - datetime.timedelta(days=100), end_date=None)
|
||||
|
||||
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
|
||||
|
||||
def test_a_sponsor_starting_today_is_included(self):
|
||||
today = timezone.localdate()
|
||||
self.make_sponsor(start_date=today, end_date=None)
|
||||
|
||||
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
|
||||
|
||||
def test_a_sponsor_ending_today_is_included(self):
|
||||
today = timezone.localdate()
|
||||
self.make_sponsor(start_date=today - datetime.timedelta(days=10), end_date=today)
|
||||
|
||||
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
|
||||
|
||||
def test_another_clubs_sponsor_never_leaks_in(self):
|
||||
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
self.make_sponsor(club=other_club)
|
||||
|
||||
self.assertEqual(self.api_get("/sponsors/").json(), [])
|
||||
|
||||
def test_logo_url_is_absolute_when_set(self):
|
||||
self.make_sponsor(logo="clubs/ajax-united/sponsors/x/logo.png")
|
||||
|
||||
logo_url = self.api_get("/sponsors/").json()[0]["logo_url"]
|
||||
|
||||
self.assertTrue(logo_url.startswith("http://ajax-united.rosterchief.app/media/"))
|
||||
|
||||
def test_logo_url_is_null_when_not_set(self):
|
||||
self.make_sponsor()
|
||||
|
||||
self.assertIsNone(self.api_get("/sponsors/").json()[0]["logo_url"])
|
||||
|
||||
def test_randomize_returns_the_same_set_of_sponsors(self):
|
||||
for i in range(5):
|
||||
self.make_sponsor(name=f"Sponsor {i}")
|
||||
|
||||
stable = {s["id"] for s in self.api_get("/sponsors/").json()}
|
||||
randomized = {s["id"] for s in self.api_get("/sponsors/", randomize="true").json()}
|
||||
|
||||
self.assertEqual(stable, randomized)
|
||||
self.assertEqual(len(stable), 5)
|
||||
|
||||
def test_default_order_is_stable_and_alphabetical(self):
|
||||
self.make_sponsor(name="Zulu Corp")
|
||||
self.make_sponsor(name="Acme Corp")
|
||||
|
||||
names = [s["name"] for s in self.api_get("/sponsors/").json()]
|
||||
|
||||
self.assertEqual(names, ["Acme Corp", "Zulu Corp"])
|
||||
28
api/urls.py
Normal file
28
api/urls.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""The public read-only API -- see ARCHITECTURE.md and the plan this shipped
|
||||
under. Mounted at /api/v1/ (rosterchief/urls.py), club-scoped by the same
|
||||
subdomain-based tenant resolution every other view uses
|
||||
(club.tenancy.ClubTenantMiddleware sets request.club before this ever runs).
|
||||
|
||||
Each domain app owns its own router and schemas (news/api.py, teams/api.py,
|
||||
events/api.py) -- this module only wires them together, same reasoning as
|
||||
management/controlpanel never owning domain logic themselves.
|
||||
"""
|
||||
|
||||
from ninja import NinjaAPI
|
||||
|
||||
from club.api import router as club_router
|
||||
from events.api import router as events_router
|
||||
from news.api import router as news_router
|
||||
from teams.api import router as teams_router
|
||||
|
||||
api = NinjaAPI(
|
||||
title="RosterChief public API",
|
||||
version="1.0.0",
|
||||
description="Public, read-only data for a club's own external website: news, team rosters, fixtures, and sponsors.",
|
||||
urls_namespace="api",
|
||||
)
|
||||
|
||||
api.add_router("/news", news_router)
|
||||
api.add_router("/teams", teams_router)
|
||||
api.add_router("/", events_router)
|
||||
api.add_router("/sponsors", club_router)
|
||||
211
assets/app.css
Normal file
211
assets/app.css
Normal file
@@ -0,0 +1,211 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Scan Django templates for utility classes (Tailwind's auto-detection doesn't
|
||||
know about our template dirs). */
|
||||
@source "../templates";
|
||||
@source "../controlpanel";
|
||||
@source "../billing";
|
||||
@source "../management";
|
||||
|
||||
/* daisyUI: light is the default, dark applies automatically when the OS asks
|
||||
for it. An explicit data-theme on <html> (set by the toggle) overrides both. */
|
||||
@plugin "daisyui" {
|
||||
themes: light --default, dark --prefersdark;
|
||||
}
|
||||
|
||||
/* Fonts are self-hosted (files copied from the @fontsource packages into
|
||||
static/fonts/, paths are relative to the built css at /static/css/app.css).
|
||||
Google's CDN would leak every visitor's IP to a third party on page load,
|
||||
which we don't want to inherit for an EU club platform.
|
||||
|
||||
Two subsets each: `latin` covers western europe, `latin-ext` carries the
|
||||
polish/czech/turkish letters that turn up in member names. The unicode-range
|
||||
means a browser only fetches latin-ext when a page actually uses those glyphs.
|
||||
|
||||
Ubuntu is static (it has no variable version); its real weights are 400/500/700,
|
||||
so `font-semibold` (600) is synthesised up to 700 by the browser. JetBrains Mono
|
||||
and Roboto are variable: one file covers the whole weight axis. */
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../fonts/ubuntu-latin-400-normal.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../fonts/ubuntu-latin-ext-400-normal.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../fonts/ubuntu-latin-400-italic.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../fonts/ubuntu-latin-ext-400-italic.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("../fonts/ubuntu-latin-500-normal.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("../fonts/ubuntu-latin-ext-500-normal.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("../fonts/ubuntu-latin-700-normal.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("../fonts/ubuntu-latin-ext-700-normal.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url("../fonts/jetbrains-mono-latin-wght-normal.woff2") format("woff2-variations");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url("../fonts/jetbrains-mono-latin-ext-wght-normal.woff2") format("woff2-variations");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("../fonts/roboto-latin-standard-normal.woff2") format("woff2-variations");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("../fonts/roboto-latin-ext-standard-normal.woff2") format("woff2-variations");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* Tourney is a display face for jersey numbers and the like. Its variable file
|
||||
carries two axes -- weight 100-900 and width 75-125 -- so font-stretch has to be
|
||||
declared as a range too, otherwise the browser clamps to the default width and
|
||||
`font-stretch: 125%` (a wide shirt number) silently does nothing. */
|
||||
@font-face {
|
||||
font-family: "Tourney";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-stretch: 75% 125%;
|
||||
font-display: swap;
|
||||
src: url("../fonts/tourney-latin-standard-normal.woff2") format("woff2-variations");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Tourney";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-stretch: 75% 125%;
|
||||
font-display: swap;
|
||||
src: url("../fonts/tourney-latin-ext-standard-normal.woff2") format("woff2-variations");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* Setting --font-sans / --font-mono changes the body and <code> defaults; every
|
||||
--font-* also generates a utility, so --font-roboto gives us `font-roboto` to
|
||||
opt into Roboto where we want it. */
|
||||
@theme {
|
||||
--font-sans: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
--font-ubuntu: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-roboto: "Roboto", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-tourney: "Tourney", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* The logo is a background image, not `content:` -- content-replacement on a real
|
||||
element (rather than ::before/::after) isn't supported in Firefox.
|
||||
|
||||
Default = dark-ink logo, for a light background. The media query covers "auto",
|
||||
where the toggle deliberately sets no data-theme at all; the attribute selectors
|
||||
are more specific, so an explicit choice always beats the OS. */
|
||||
.logo {
|
||||
background-image: var(--logo-dark);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.logo {
|
||||
background-image: var(--logo-light);
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="light"] .logo {
|
||||
background-image: var(--logo-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .logo {
|
||||
background-image: var(--logo-light);
|
||||
}
|
||||
|
||||
/* Club-uploaded logos are arbitrary raster files (often much smaller than the
|
||||
badge they're displayed in), so the browser is upscaling them -- and the
|
||||
default resampling some engines fall back to for that reads as pixelated.
|
||||
Forcing the element onto its own GPU-composited layer makes Chrome/Safari
|
||||
pick their higher-quality scaler; the image-rendering hints are a no-op
|
||||
where unsupported, degrading gracefully to the browser default. Doesn't
|
||||
apply to SVG logos, which scale losslessly regardless. */
|
||||
.club-logo {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: smooth;
|
||||
image-rendering: high-quality;
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
0
authentication/__init__.py
Normal file
0
authentication/__init__.py
Normal file
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
|
||||
484
authentication/tests.py
Normal file
484
authentication/tests.py
Normal file
@@ -0,0 +1,484 @@
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from allauth.core import context
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db import IntegrityError
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from club.models import Club, ClubRole
|
||||
from members.models import Member
|
||||
|
||||
from .adapters import RosterChiefMFAAdapter, webauthn_rp_id
|
||||
from .middleware import RequireMFAMiddleware, mfa_required_for
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def enrol_mfa(user):
|
||||
"""Give ``user`` a second factor (enough for is_mfa_enabled)."""
|
||||
return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
|
||||
|
||||
|
||||
class UserManagerTests(TestCase):
|
||||
def test_create_user_defaults(self):
|
||||
user = User.objects.create_user(email="alice@example.com", password="secret123")
|
||||
|
||||
self.assertEqual(user.email, "alice@example.com")
|
||||
self.assertTrue(user.check_password("secret123"))
|
||||
self.assertFalse(user.is_staff)
|
||||
self.assertFalse(user.is_superuser)
|
||||
self.assertTrue(user.is_active)
|
||||
|
||||
def test_create_user_requires_email(self):
|
||||
with self.assertRaises(ValueError):
|
||||
User.objects.create_user(email="", password="secret123")
|
||||
|
||||
def test_create_user_normalizes_email_domain(self):
|
||||
# BaseUserManager lowercases the domain part of the address.
|
||||
user = User.objects.create_user(email="Bob@Example.COM", password="secret123")
|
||||
|
||||
self.assertEqual(user.email, "Bob@example.com")
|
||||
|
||||
def test_create_user_password_is_hashed(self):
|
||||
user = User.objects.create_user(email="carol@example.com", password="secret123")
|
||||
|
||||
self.assertNotEqual(user.password, "secret123")
|
||||
|
||||
def test_create_user_without_password_is_unusable(self):
|
||||
user = User.objects.create_user(email="dave@example.com")
|
||||
|
||||
self.assertFalse(user.has_usable_password())
|
||||
|
||||
def test_create_superuser_defaults(self):
|
||||
admin = User.objects.create_superuser(email="admin@example.com", password="secret123")
|
||||
|
||||
self.assertTrue(admin.is_staff)
|
||||
self.assertTrue(admin.is_superuser)
|
||||
self.assertTrue(admin.is_active)
|
||||
|
||||
def test_create_superuser_rejects_non_staff(self):
|
||||
with self.assertRaises(ValueError):
|
||||
User.objects.create_superuser(email="admin@example.com", password="x", is_staff=False)
|
||||
|
||||
def test_create_superuser_rejects_non_superuser(self):
|
||||
with self.assertRaises(ValueError):
|
||||
User.objects.create_superuser(email="admin@example.com", password="x", is_superuser=False)
|
||||
|
||||
|
||||
class UserModelTests(TestCase):
|
||||
def test_email_is_username_field(self):
|
||||
self.assertEqual(User.USERNAME_FIELD, "email")
|
||||
self.assertEqual(User.REQUIRED_FIELDS, [])
|
||||
|
||||
def test_email_is_unique(self):
|
||||
User.objects.create_user(email="dup@example.com", password="x")
|
||||
with self.assertRaises(IntegrityError):
|
||||
User.objects.create_user(email="dup@example.com", password="y")
|
||||
|
||||
def test_pk_is_uuid(self):
|
||||
user = User.objects.create_user(email="uuid@example.com", password="x")
|
||||
self.assertIsInstance(user.pk, uuid.UUID)
|
||||
|
||||
def test_str_and_names_fall_back_to_email_without_member(self):
|
||||
user = User.objects.create_user(email="lonely@example.com", password="x")
|
||||
|
||||
self.assertEqual(str(user), "lonely@example.com")
|
||||
self.assertEqual(user.get_full_name(), "lonely@example.com")
|
||||
self.assertEqual(user.get_short_name(), "lonely@example.com")
|
||||
|
||||
def test_str_and_names_use_linked_member(self):
|
||||
user = User.objects.create_user(email="linked@example.com", password="x")
|
||||
Member.objects.create(user=user, first_name="Jane", last_name="Doe")
|
||||
|
||||
# Re-fetch so the reverse OneToOne relation is resolved from the DB.
|
||||
user = User.objects.get(pk=user.pk)
|
||||
|
||||
self.assertEqual(str(user), "Jane Doe")
|
||||
self.assertEqual(user.get_full_name(), "Jane Doe")
|
||||
self.assertEqual(user.get_short_name(), "Jane")
|
||||
|
||||
|
||||
@override_settings(
|
||||
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||
MFA_WEBAUTHN_RP_NAME="RosterChief",
|
||||
ALLOWED_HOSTS=[".rosterchief.app", "example.test"],
|
||||
)
|
||||
class WebAuthnRelyingPartyTests(TestCase):
|
||||
"""A passkey is bound to a Relying Party ID (a domain).
|
||||
|
||||
allauth's default RP ID is the request host, which under our subdomain
|
||||
tenancy would bind a passkey to a single club. We pin it to the registrable
|
||||
parent domain so ONE passkey works across every club.
|
||||
"""
|
||||
|
||||
def rp_entity(self, host):
|
||||
request = RequestFactory().get("/", HTTP_HOST=host)
|
||||
with context.request_context(request):
|
||||
return RosterChiefMFAAdapter().get_public_key_credential_rp_entity()
|
||||
|
||||
def test_rp_id_is_the_parent_domain_not_the_club_subdomain(self):
|
||||
self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["id"], "rosterchief.app")
|
||||
|
||||
def test_rp_id_is_identical_across_clubs(self):
|
||||
# The whole point: a passkey registered at one club works at the others.
|
||||
here = self.rp_entity("ajax-united.rosterchief.app")
|
||||
there = self.rp_entity("rival-fc.rosterchief.app")
|
||||
|
||||
self.assertEqual(here["id"], there["id"])
|
||||
|
||||
def test_rp_name_comes_from_settings(self):
|
||||
self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["name"], "RosterChief")
|
||||
|
||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="")
|
||||
def test_falls_back_to_the_request_host_without_a_base_domain(self):
|
||||
request = RequestFactory().get("/", HTTP_HOST="example.test:8000")
|
||||
|
||||
with context.request_context(request):
|
||||
self.assertEqual(webauthn_rp_id(), "example.test")
|
||||
|
||||
|
||||
class MFARequirementTests(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
|
||||
def make_user(self, email, **kwargs):
|
||||
return User.objects.create_user(email=email, password="pw-secret-123", **kwargs)
|
||||
|
||||
def with_role(self, user, role):
|
||||
member = Member.objects.create(user=user, first_name="Ada", last_name="Min")
|
||||
ClubRole.objects.create(club=self.club, member=member, role=role)
|
||||
return user
|
||||
|
||||
def test_staff_must_have_mfa(self):
|
||||
self.assertTrue(mfa_required_for(self.make_user("staff@example.com", is_staff=True)))
|
||||
|
||||
def test_superuser_must_have_mfa(self):
|
||||
self.assertTrue(mfa_required_for(User.objects.create_superuser(email="root@example.com", password="pw-secret-123")))
|
||||
|
||||
def test_club_admin_must_have_mfa(self):
|
||||
user = self.with_role(self.make_user("admin@example.com"), ClubRole.Roles.ADMIN)
|
||||
|
||||
self.assertTrue(mfa_required_for(user))
|
||||
|
||||
def test_editor_must_have_mfa(self):
|
||||
user = self.with_role(self.make_user("editor@example.com"), ClubRole.Roles.EDITOR)
|
||||
|
||||
self.assertTrue(mfa_required_for(user))
|
||||
|
||||
def test_plain_member_does_not_need_mfa(self):
|
||||
user = self.with_role(self.make_user("member@example.com"), ClubRole.Roles.MEMBER)
|
||||
|
||||
self.assertFalse(mfa_required_for(user))
|
||||
|
||||
def test_user_without_any_role_does_not_need_mfa(self):
|
||||
self.assertFalse(mfa_required_for(self.make_user("nobody@example.com")))
|
||||
|
||||
|
||||
class RequireMFAMiddlewareTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.middleware = RequireMFAMiddleware(lambda request: HttpResponse("ok"))
|
||||
|
||||
def dispatch(self, user, path="/"):
|
||||
request = self.factory.get(path)
|
||||
request.user = user
|
||||
return self.middleware(request)
|
||||
|
||||
def make_staff(self):
|
||||
return User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
|
||||
|
||||
def test_anonymous_passes_through(self):
|
||||
self.assertEqual(self.dispatch(AnonymousUser()).content, b"ok")
|
||||
|
||||
def test_unprivileged_user_passes_through(self):
|
||||
user = User.objects.create_user(email="plain@example.com", password="pw-secret-123")
|
||||
|
||||
self.assertEqual(self.dispatch(user).content, b"ok")
|
||||
|
||||
def test_privileged_user_without_mfa_is_sent_to_enrolment(self):
|
||||
response = self.dispatch(self.make_staff())
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("mfa_index"))
|
||||
|
||||
def test_privileged_user_can_still_reach_the_enrolment_pages(self):
|
||||
# Otherwise they'd be redirected in a loop and could never enrol.
|
||||
response = self.dispatch(self.make_staff(), path="/accounts/2fa/totp/activate/")
|
||||
|
||||
self.assertEqual(response.content, b"ok")
|
||||
|
||||
def test_enrolled_privileged_user_passes_through(self):
|
||||
staff = self.make_staff()
|
||||
enrol_mfa(staff)
|
||||
|
||||
self.assertEqual(self.dispatch(staff).content, b"ok")
|
||||
|
||||
|
||||
class AdminLoginRoutingTests(TestCase):
|
||||
def test_admin_login_is_routed_through_allauth(self):
|
||||
# Django's own admin login knows nothing about second factors.
|
||||
response = self.client.get("/admin/login/", {"next": "/admin/"})
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
redirect = urlparse(response.url)
|
||||
self.assertEqual(redirect.path, reverse("account_login"))
|
||||
# The original destination survives the hop (percent-encoded).
|
||||
self.assertEqual(parse_qs(redirect.query)["next"], ["/admin/"])
|
||||
|
||||
def test_allauth_login_page_loads(self):
|
||||
self.assertEqual(self.client.get(reverse("account_login")).status_code, 200)
|
||||
|
||||
|
||||
class AuthFormRenderingTests(TestCase):
|
||||
"""Every allauth form must actually render its fields.
|
||||
|
||||
Regression: the `fields` element passed `attrs.exclude` straight into a filter.
|
||||
On a page that never sets it, resolving a filter *argument* raises
|
||||
VariableDoesNotExist — which Django swallows inside {% if %} and reads as false —
|
||||
so every field was silently dropped from every form except the login page (the one
|
||||
page that does pass `exclude`).
|
||||
"""
|
||||
|
||||
def test_the_login_form_renders_its_fields(self):
|
||||
self.assertContains(self.client.get(reverse("account_login")), 'name="login"')
|
||||
|
||||
def test_the_password_reset_form_renders_its_fields(self):
|
||||
self.assertContains(self.client.get(reverse("account_reset_password")), 'name="email"')
|
||||
|
||||
def test_the_signup_form_renders_its_fields(self):
|
||||
self.assertContains(self.client.get(reverse("account_signup")), 'name="password1"')
|
||||
|
||||
|
||||
class TwoFactorPageTests(TestCase):
|
||||
def setUp(self):
|
||||
user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||
enrol_mfa(user)
|
||||
# Password accepted, second factor still owed: this is the 2FA challenge page.
|
||||
self.response = self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||
|
||||
def test_the_code_field_renders_as_an_otp_input(self):
|
||||
self.assertContains(self.response, 'name="code"')
|
||||
self.assertContains(self.response, "otp otp-lg")
|
||||
|
||||
def test_the_input_comes_after_the_boxes(self):
|
||||
# daisyUI places each box with nth-child, which counts every child. With the input
|
||||
# first, all six boxes shift a stride right, the container grows to seven strides
|
||||
# and the ::after focus marker appears as a phantom seventh box.
|
||||
html = self.response.content.decode()
|
||||
otp = html[html.index('class="otp otp-lg"') : html.index('name="code"')]
|
||||
|
||||
self.assertEqual(otp.count("<span></span>"), 6)
|
||||
|
||||
def test_the_boxes_are_wrapped_in_a_label_so_tapping_focuses_the_input(self):
|
||||
# daisyUI's overlaid otp input carries `pointer-events: none` (so clicks land on the
|
||||
# boxes, not a naked input) — which also means a tap on the boxes never reaches the
|
||||
# input directly. A <label for> is what closes that gap: browsers focus a labelled
|
||||
# control on click regardless of the control's own pointer-events. Without this
|
||||
# wrapper the field cannot be entered on a touchscreen, which has no Tab key to fall
|
||||
# back on.
|
||||
html = self.response.content.decode()
|
||||
label_start = html.index('<label class="contents"')
|
||||
otp_start = html.index('class="otp otp-lg"')
|
||||
|
||||
self.assertLess(label_start, otp_start)
|
||||
self.assertIn('for="id_code"', html[label_start : label_start + 60])
|
||||
|
||||
def test_the_otp_field_has_no_placeholder(self):
|
||||
# allauth sets placeholder="Code"; inside the boxes it reads as a typed-in code.
|
||||
self.assertNotContains(self.response, 'placeholder="Code"')
|
||||
|
||||
def test_cancel_sits_beside_sign_in_and_is_not_primary(self):
|
||||
self.assertContains(self.response, '<button class="btn btn-outline gap-2" type="submit" form="logout-from-stage">')
|
||||
self.assertContains(self.response, '<button class="btn btn-primary gap-2" type="submit">')
|
||||
|
||||
def test_cancel_has_a_form_to_submit(self):
|
||||
self.assertContains(self.response, 'id="logout-from-stage"')
|
||||
|
||||
def test_the_security_key_button_is_an_accent_button_with_a_working_form(self):
|
||||
self.assertContains(self.response, "btn btn-accent")
|
||||
self.assertContains(self.response, 'form="webauthn_form"')
|
||||
# The id lives on the form element — without it the button submits nothing.
|
||||
self.assertContains(self.response, 'id="webauthn_form"')
|
||||
self.assertContains(self.response, "allauth.webauthn.forms.authenticateForm")
|
||||
|
||||
|
||||
class MfaPageTests(TestCase):
|
||||
"""Every MFA screen must render. They are built from allauth's `element` primitives,
|
||||
so styling lives in the element overrides rather than in eight page templates."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||
# A real password login (not force_login) so allauth counts it as a recent
|
||||
# authentication and doesn't bounce the sensitive pages to reauthenticate.
|
||||
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||
|
||||
def test_the_manage_page_renders_a_panel_per_authenticator(self):
|
||||
response = self.client.get(reverse("mfa_index"))
|
||||
|
||||
self.assertContains(response, "Authenticator App")
|
||||
self.assertContains(response, "card border")
|
||||
|
||||
def test_the_security_key_list_renders(self):
|
||||
# Regression: allauth's template does {% load humanize %}, which raised
|
||||
# TemplateSyntaxError until django.contrib.humanize was installed.
|
||||
self.assertEqual(self.client.get(reverse("mfa_list_webauthn")).status_code, 200)
|
||||
|
||||
def test_the_totp_activate_page_boxes_the_code_and_plates_the_qr(self):
|
||||
response = self.client.get(reverse("mfa_activate_totp"))
|
||||
|
||||
self.assertContains(response, "otp otp-lg")
|
||||
# The QR is dark-on-transparent: without a white plate it is unscannable on the
|
||||
# dark theme.
|
||||
self.assertContains(response, "bg-white p-3")
|
||||
self.assertContains(response, "font-mono") # the secret, to be copied by hand
|
||||
|
||||
def test_the_deactivate_button_is_destructive(self):
|
||||
enrol_mfa(self.user)
|
||||
|
||||
response = self.client.get(reverse("mfa_index"))
|
||||
|
||||
# allauth tags it "danger" — it must not look like the safe action.
|
||||
self.assertContains(response, "btn-error")
|
||||
|
||||
def test_reauthenticating_with_a_code_boxes_the_input(self):
|
||||
enrol_mfa(self.user)
|
||||
|
||||
self.assertContains(self.client.get(reverse("mfa_reauthenticate")), "otp otp-lg")
|
||||
|
||||
|
||||
class ActionBarTests(TestCase):
|
||||
"""A form's action bar is drawn when the actions slot has content.
|
||||
|
||||
Regression: it was keyed on `no_visible_fields`, which allauth sets to say a form has
|
||||
no visible *fields* — logout and TOTP deactivate are a bare csrf token plus a button.
|
||||
Keying the bar on it hid the button on exactly the pages that are nothing but a button.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||
|
||||
def test_the_sign_out_page_has_its_button(self):
|
||||
response = self.client.get(reverse("account_logout"))
|
||||
|
||||
self.assertContains(response, "Sign Out")
|
||||
self.assertContains(response, 'type="submit"')
|
||||
|
||||
def test_the_totp_deactivate_page_has_its_button(self):
|
||||
enrol_mfa(self.user)
|
||||
|
||||
response = self.client.get(reverse("mfa_deactivate_totp"))
|
||||
|
||||
self.assertContains(response, "btn-error")
|
||||
self.assertContains(response, 'type="submit"')
|
||||
|
||||
|
||||
class SignOutPageTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||
self.client.force_login(self.user)
|
||||
self.response = self.client.get(reverse("account_logout"))
|
||||
|
||||
def test_sign_out_and_cancel_sit_side_by_side_with_icons(self):
|
||||
html = self.response.content.decode()
|
||||
cancel = html[html.index('<a class="btn btn-outline gap-2" href="/">') :]
|
||||
sign_out = html[html.index('<button class="btn btn-primary gap-2"') :]
|
||||
|
||||
self.assertIn("<svg", cancel[: cancel.index("</a>")])
|
||||
self.assertIn("<svg", sign_out[: sign_out.index("</button>")])
|
||||
|
||||
def test_cancel_does_not_sign_you_out(self):
|
||||
# It is a link, not a submit: only the POST logs you out.
|
||||
self.client.get("/")
|
||||
|
||||
self.assertTrue(self.client.session.get("_auth_user_id"))
|
||||
|
||||
def test_signing_out_still_works(self):
|
||||
self.client.post(reverse("account_logout"))
|
||||
|
||||
self.assertIsNone(self.client.session.get("_auth_user_id"))
|
||||
|
||||
|
||||
class ChangePasswordPageTests(TestCase):
|
||||
def setUp(self):
|
||||
User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||
self.response = self.client.get(reverse("account_change_password"))
|
||||
|
||||
def test_the_fields_have_no_visible_labels(self):
|
||||
# allauth gives each a placeholder, so the label would only repeat it.
|
||||
self.assertNotContains(self.response, '<span class="label-text">Current Password</span>')
|
||||
self.assertContains(self.response, 'name="oldpassword"')
|
||||
self.assertContains(self.response, 'name="password1"')
|
||||
|
||||
def test_the_new_password_keeps_its_help_text(self):
|
||||
self.assertContains(self.response, "id_password1_helptext")
|
||||
|
||||
def test_the_current_password_is_set_apart_from_the_new_one(self):
|
||||
self.assertContains(self.response, "mt-10")
|
||||
|
||||
def test_forgot_password_is_an_accent_button_and_both_actions_have_icons(self):
|
||||
html = self.response.content.decode()
|
||||
forgot = html[html.index("btn-accent") :]
|
||||
submit = html[html.index('class="btn btn-primary gap-2"') :]
|
||||
|
||||
self.assertIn("<svg", forgot[: forgot.index("</a>")])
|
||||
self.assertIn("<svg", submit[: submit.index("</button>")])
|
||||
|
||||
|
||||
class MfaButtonIconTests(TestCase):
|
||||
"""Every button on the MFA screens carries an icon, and the recovery-code actions are
|
||||
ranked: View is primary, Download and Generate are outline. Generate throws away the
|
||||
codes you already have, so it must not read as the obvious thing to click."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||
# Sign in *before* enrolling: a user who already holds a second factor is stopped at
|
||||
# the 2FA challenge and never reaches these pages.
|
||||
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||
enrol_mfa(self.user)
|
||||
RecoveryCodes.activate(self.user).instance.save()
|
||||
|
||||
def buttons(self, url):
|
||||
"""Every <a class="btn"> / <button class="btn"> in the page body, minus the navbar."""
|
||||
html = self.client.get(url, follow=True).content.decode()
|
||||
body = html[html.index("<main") :]
|
||||
return re.findall(r'<(?:a|button)[^>]*class="btn[^"]*"[^>]*>(.*?)</(?:a|button)>', body, re.S)
|
||||
|
||||
def test_every_button_on_the_manage_page_has_an_icon(self):
|
||||
found = self.buttons(reverse("mfa_index"))
|
||||
|
||||
self.assertTrue(found)
|
||||
for button in found:
|
||||
self.assertIn("<svg", button)
|
||||
|
||||
def test_download_and_generate_are_outline_buttons(self):
|
||||
html = self.client.get(reverse("mfa_index"), follow=True).content.decode()
|
||||
|
||||
self.assertEqual(html.count("btn-outline"), 2) # Download + Generate, not View
|
||||
|
||||
def test_the_panel_actions_are_spaced_off_the_body_text(self):
|
||||
self.assertContains(self.client.get(reverse("mfa_index"), follow=True), "card-actions mt-4")
|
||||
|
||||
def test_every_button_on_the_deactivate_page_has_an_icon(self):
|
||||
for button in self.buttons(reverse("mfa_deactivate_totp")):
|
||||
self.assertIn("<svg", button)
|
||||
|
||||
def test_every_button_on_the_add_security_key_page_has_an_icon(self):
|
||||
for button in self.buttons(reverse("mfa_add_webauthn")):
|
||||
self.assertIn("<svg", button)
|
||||
|
||||
def test_the_activate_page_gives_the_code_box_no_visible_label(self):
|
||||
Authenticator.objects.filter(user=self.user, type=Authenticator.Type.TOTP).delete()
|
||||
|
||||
response = self.client.get(reverse("mfa_activate_totp"), follow=True)
|
||||
|
||||
self.assertContains(response, "otp otp-lg")
|
||||
self.assertNotContains(response, '<span class="label-text">Code</span>')
|
||||
0
billing/__init__.py
Normal file
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,0 +1,34 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-04 10:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0002_subscription_auto_renew'),
|
||||
('club', '0017_club_season_duration_months_club_season_start'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='due',
|
||||
name='is_trial',
|
||||
field=models.BooleanField(default=False, help_text="This period was opened as a trial. A durable marker on the row itself -- the subscription's own trial fields are cleared once it converts.", verbose_name='trial period'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscription',
|
||||
name='post_trial_tier',
|
||||
field=models.ForeignKey(blank=True, help_text='The plan this club switches to automatically once its trial ends.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='billing.tier', verbose_name='post-trial tier'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscription',
|
||||
name='trial_ends_at',
|
||||
field=models.DateField(blank=True, help_text='Set while this club is on a trial. The tier switches to post_trial_tier the next time a period is opened after this date.', null=True, verbose_name='trial ends at'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='subscription',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('post_trial_tier__isnull', True), ('trial_ends_at__isnull', True)), models.Q(('post_trial_tier__isnull', False), ('trial_ends_at__isnull', False)), _connector='OR'), name='trial_fields_set_together'),
|
||||
),
|
||||
]
|
||||
0
billing/migrations/__init__.py
Normal file
0
billing/migrations/__init__.py
Normal file
267
billing/models.py
Normal file
267
billing/models.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""What the platform charges a club.
|
||||
|
||||
Deliberately NOT club-scoped. `shop` is a club charging its members — tenant data, owned by
|
||||
the club. This is RosterChief charging the club: platform-owned, and no club user ever sees
|
||||
it. Nothing here inherits ClubScopedModel: these rows reference a Club, they are not owned
|
||||
by one, and a tenant-scoped manager would be exactly the wrong default.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from dateutil import relativedelta
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rosterchief.base import UUIDModel, unique_slugify
|
||||
|
||||
ZERO = Decimal("0.00")
|
||||
|
||||
# A club stays live for six weeks past the end of an unpaid period before it is archived.
|
||||
GRACE_DAYS = 45
|
||||
|
||||
# The next period is issued this long before the current one ends, so the invoice reaches the
|
||||
# club — and can be paid — before the old period lapses. Grace then only matters for genuine
|
||||
# non-payers, rather than for everyone who takes a fortnight to pay a bank transfer.
|
||||
RENEWAL_LEAD_DAYS = 30
|
||||
|
||||
|
||||
def add_one_year(day: date) -> date:
|
||||
return day + relativedelta.relativedelta(years=1)
|
||||
|
||||
|
||||
def add_months(day: date, months: int) -> date:
|
||||
return day + relativedelta.relativedelta(months=months)
|
||||
|
||||
|
||||
class Tier(UUIDModel):
|
||||
"""A price band. The price itself lives in TierPrice, which is dated."""
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True)
|
||||
description = models.TextField(_("description"), blank=True)
|
||||
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive tiers keep billing existing subscriptions but cannot be chosen for new ones."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("tier")
|
||||
verbose_name_plural = _("tiers")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = unique_slugify(self, self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def price_on(self, day: date | None = None) -> Decimal | None:
|
||||
"""The price in force on ``day`` — the latest one that had started by then.
|
||||
|
||||
None means the tier had no price yet on that date. Callers must treat that as
|
||||
"cannot bill", never as free.
|
||||
"""
|
||||
day = day or timezone.localdate()
|
||||
price = self.prices.filter(active_from__lte=day).order_by("-active_from").first()
|
||||
|
||||
return price.amount if price else None
|
||||
|
||||
|
||||
class TierPrice(UUIDModel):
|
||||
"""A dated price for a tier.
|
||||
|
||||
Dated rather than keyed by year: a rate change is one new row with a future
|
||||
``active_from``, and every period already opened keeps the amount it was billed at.
|
||||
"""
|
||||
|
||||
tier = models.ForeignKey(Tier, on_delete=models.CASCADE, related_name="prices", verbose_name=_("tier"))
|
||||
active_from = models.DateField(_("active from"), help_text=_("Periods opening on or after this date are billed at this amount."))
|
||||
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("tier price")
|
||||
verbose_name_plural = _("tier prices")
|
||||
ordering = ["tier__name", "-active_from"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["tier", "active_from"], name="unique_tier_price_per_start_date"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tier} — {self.amount} from {self.active_from}"
|
||||
|
||||
|
||||
class Subscription(UUIDModel):
|
||||
"""A club's current plan. The periods it is billed for are Dues."""
|
||||
|
||||
club = models.OneToOneField("club.Club", on_delete=models.CASCADE, related_name="subscription", verbose_name=_("club"))
|
||||
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("tier"))
|
||||
auto_renew = models.BooleanField(_("auto renew"), default=True, help_text=_("Issue the next period automatically before this one ends. Off means you invoice this club by hand."))
|
||||
auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period."))
|
||||
notes = models.TextField(_("notes"), blank=True)
|
||||
|
||||
trial_ends_at = models.DateField(_("trial ends at"), null=True, blank=True, help_text=_("Set while this club is on a trial. The tier switches to post_trial_tier the next time a period is opened after this date."))
|
||||
post_trial_tier = models.ForeignKey(Tier, on_delete=models.PROTECT, null=True, blank=True, related_name="+", verbose_name=_("post-trial tier"), help_text=_("The plan this club switches to automatically once its trial ends."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("subscription")
|
||||
verbose_name_plural = _("subscriptions")
|
||||
ordering = ["club__name"]
|
||||
constraints = [
|
||||
# Both set together or neither -- a trial with no target plan (or a target
|
||||
# plan with no trial end date) is a half-configured state nothing should read.
|
||||
models.CheckConstraint(
|
||||
condition=Q(trial_ends_at__isnull=True, post_trial_tier__isnull=True) | Q(trial_ends_at__isnull=False, post_trial_tier__isnull=False),
|
||||
name="trial_fields_set_together",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.club} — {self.tier}"
|
||||
|
||||
|
||||
class Due(UUIDModel):
|
||||
"""One billing period for one club.
|
||||
|
||||
``tier`` and ``amount`` are snapshots taken when the period opens, never read back
|
||||
through the tier at display time: raise the price and last year's period must still say
|
||||
what was actually charged. A live lookup would rewrite financial history.
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
UNPAID = "unpaid", _("unpaid")
|
||||
PARTIAL = "partial", _("partially paid")
|
||||
PAID = "paid", _("paid")
|
||||
WAIVED = "waived", _("waived")
|
||||
CANCELLED = "cancelled", _("cancelled")
|
||||
|
||||
#: Statuses that still owe money.
|
||||
OWING = (Status.UNPAID, Status.PARTIAL)
|
||||
|
||||
club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="dues", verbose_name=_("club"))
|
||||
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="dues", verbose_name=_("tier"))
|
||||
|
||||
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
|
||||
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=ZERO, help_text=_("Kept in step with the payments by the billing service."))
|
||||
|
||||
period_start = models.DateField(_("period start"))
|
||||
period_end = models.DateField(_("period end"), blank=True)
|
||||
grace_until = models.DateField(_("grace until"), blank=True, help_text=_("Past this date an unpaid club is archived."))
|
||||
|
||||
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.UNPAID)
|
||||
paid_at = models.DateTimeField(_("paid at"), null=True, blank=True)
|
||||
|
||||
is_trial = models.BooleanField(_("trial period"), default=False, help_text=_("This period was opened as a trial. A durable marker on the row itself -- the subscription's own trial fields are cleared once it converts."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("due")
|
||||
verbose_name_plural = _("dues")
|
||||
ordering = ["-period_start", "club__name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["club", "period_start"], name="unique_due_per_club_per_period"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.club} — {self.period_start} to {self.period_end}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# A period runs a rolling year from its start and the grace hangs off its end.
|
||||
# Derived here so no caller can open a period without them.
|
||||
if not self.period_end:
|
||||
self.period_end = add_one_year(self.period_start) - timedelta(days=1)
|
||||
if not self.grace_until:
|
||||
self.grace_until = self.period_end + timedelta(days=GRACE_DAYS)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
return self.amount - self.amount_paid
|
||||
|
||||
@property
|
||||
def is_owing(self) -> bool:
|
||||
return self.status in self.OWING
|
||||
|
||||
def is_in_grace(self, today: date | None = None) -> bool:
|
||||
"""The period has ended unpaid, but the club is not archivable yet."""
|
||||
today = today or timezone.localdate()
|
||||
|
||||
return self.is_owing and self.period_end < today <= self.grace_until
|
||||
|
||||
def is_overdue(self, today: date | None = None) -> bool:
|
||||
"""Unpaid past grace — this is what makes a club archivable."""
|
||||
today = today or timezone.localdate()
|
||||
|
||||
return self.is_owing and self.grace_until < today
|
||||
|
||||
|
||||
class DuePayment(UUIDModel):
|
||||
"""Money received against a due.
|
||||
|
||||
Several may land on one due: a club that pays in two transfers must not read as unpaid,
|
||||
and the half that did arrive has to be recorded somewhere.
|
||||
"""
|
||||
|
||||
class Method(models.TextChoices):
|
||||
BANK_TRANSFER = "bank_transfer", _("bank transfer")
|
||||
CARD = "card", _("card")
|
||||
CASH = "cash", _("cash")
|
||||
OTHER = "other", _("other")
|
||||
|
||||
due = models.ForeignKey(Due, on_delete=models.CASCADE, related_name="payments", verbose_name=_("due"))
|
||||
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
|
||||
method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
|
||||
reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
|
||||
paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
|
||||
note = models.TextField(_("note"), blank=True)
|
||||
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_due_payments", verbose_name=_("recorded by"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("due payment")
|
||||
verbose_name_plural = _("due payments")
|
||||
ordering = ["-paid_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.amount} — {self.due}"
|
||||
|
||||
|
||||
class Invoice(UUIDModel):
|
||||
"""The bill for one period.
|
||||
|
||||
Only the number and the issue date are stored: the money, the tier and the dates are
|
||||
already frozen on the Due, so the PDF is rendered from those snapshots on demand. The
|
||||
number, though, must be stable and gapless — it is the thing an accountant reconciles
|
||||
against, so it is allocated once and never recomputed.
|
||||
"""
|
||||
|
||||
due = models.OneToOneField(Due, on_delete=models.CASCADE, related_name="invoice", verbose_name=_("due"))
|
||||
number = models.CharField(_("number"), max_length=32, unique=True, blank=True)
|
||||
issued_at = models.DateTimeField(_("issued at"), default=timezone.now)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("invoice")
|
||||
verbose_name_plural = _("invoices")
|
||||
ordering = ["-issued_at"]
|
||||
|
||||
def __str__(self):
|
||||
return self.number
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.number:
|
||||
self.number = self.next_number(self.issued_at.year)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def next_number(cls, year: int) -> str:
|
||||
"""INV-2026-00001, restarting each year.
|
||||
|
||||
Platform-wide, unlike the shop's order numbers, which are per club: these are OUR
|
||||
invoices, and one sequence has to cover every club we bill.
|
||||
"""
|
||||
prefix = f"INV-{year}-"
|
||||
last = cls.objects.filter(number__startswith=prefix).order_by("-number").first()
|
||||
sequence = int(last.number.removeprefix(prefix)) + 1 if last else 1
|
||||
|
||||
return f"{prefix}{sequence:05d}"
|
||||
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.
|
||||
"""
|
||||
219
billing/services/dues.py
Normal file
219
billing/services/dues.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""The billing lifecycle. Views and the archive command go through here, never through the
|
||||
models directly — a Due whose amount_paid disagrees with its payments is a wrong invoice.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import DateField, OuterRef, Subquery, Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from billing.models import RENEWAL_LEAD_DAYS, ZERO, Due, DuePayment, Subscription, Tier, add_months
|
||||
from billing.services import BillingError
|
||||
from billing.services.invoices import issue_invoice
|
||||
|
||||
|
||||
def subscribe(club, tier: Tier, *, start: date | None = None, auto_archive: bool = True, auto_renew: bool = True) -> Subscription:
|
||||
"""Put a club on a tier and open its first period."""
|
||||
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"tier": tier, "auto_archive": auto_archive, "auto_renew": auto_renew})
|
||||
open_period(club, start=start)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def start_trial(club, trial_tier: Tier, *, post_trial_tier: Tier, trial_months: int, start: date | None = None, auto_renew: bool = True, auto_archive: bool = True) -> Due:
|
||||
"""Put a club on a short trial that switches itself to ``post_trial_tier`` the moment
|
||||
the trial period is renewed -- see open_period()'s trial-conversion check.
|
||||
|
||||
Only for a club with no subscription yet -- converting an existing paying subscription
|
||||
into a trial is a different, deliberately unsupported operation for now.
|
||||
"""
|
||||
if trial_months <= 0:
|
||||
raise BillingError("Trial length must be at least 1 month.")
|
||||
if getattr(club, "subscription", None) is not None:
|
||||
raise BillingError(f"{club} is already subscribed -- use Change plan instead.")
|
||||
|
||||
start = start or next_period_start(club)
|
||||
trial_end = add_months(start, trial_months) - timedelta(days=1)
|
||||
|
||||
Subscription.objects.create(club=club, tier=trial_tier, trial_ends_at=trial_end, post_trial_tier=post_trial_tier, auto_renew=auto_renew, auto_archive=auto_archive)
|
||||
|
||||
return open_period(club, start=start, period_end=trial_end, is_trial=True)
|
||||
|
||||
|
||||
def next_period_start(club, today: date | None = None) -> date:
|
||||
"""Where the club's next period begins.
|
||||
|
||||
The day after the last one ended — not today. A club that pays two months late has still
|
||||
used those two months, and restarting the clock at the payment date would quietly gift
|
||||
them away. Callers can override; that is what the start field on the renew form is for.
|
||||
"""
|
||||
today = today or timezone.localdate()
|
||||
last = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()
|
||||
|
||||
return last.period_end + timedelta(days=1) if last else today
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def open_period(club, *, start: date | None = None, tier: Tier | None = None, period_end: date | None = None, is_trial: bool = False) -> Due:
|
||||
"""Issue the next due for a club, snapshotting the tier and the price of the day."""
|
||||
subscription = getattr(club, "subscription", None)
|
||||
if tier is None:
|
||||
if subscription is None:
|
||||
raise BillingError(f"{club} has no tier: put it on a subscription before billing it.")
|
||||
# A trial that has run its course: swap onto the pre-selected plan before billing
|
||||
# the next period, rather than silently renewing the trial tier forever. Checked
|
||||
# here (not in renew()) so it fires whether this period was opened by the renewal
|
||||
# command or by a platform admin clicking "Open period"/"Reactivate" by hand --
|
||||
# both call open_period() directly.
|
||||
if subscription.trial_ends_at is not None and (start or next_period_start(club)) > subscription.trial_ends_at:
|
||||
subscription.tier = subscription.post_trial_tier
|
||||
subscription.trial_ends_at = None
|
||||
subscription.post_trial_tier = None
|
||||
subscription.save(update_fields=["tier", "trial_ends_at", "post_trial_tier"])
|
||||
tier = subscription.tier
|
||||
|
||||
start = start or next_period_start(club)
|
||||
|
||||
amount = tier.price_on(start)
|
||||
if amount is None:
|
||||
raise BillingError(f"{tier} has no price in force on {start:%d %b %Y}. Add one before opening the period.")
|
||||
|
||||
if club.dues.filter(period_start=start).exists():
|
||||
raise BillingError(f"{club} is already billed for a period starting {start:%d %b %Y}.")
|
||||
|
||||
due = Due.objects.create(club=club, tier=tier, amount=amount, period_start=start, period_end=period_end, is_trial=is_trial)
|
||||
if amount == ZERO:
|
||||
# Nothing is actually owed -- left at the default UNPAID, this would eventually
|
||||
# trip is_overdue() and get a free club archived for non-payment of nothing.
|
||||
due.status = Due.Status.PAID
|
||||
due.paid_at = timezone.now()
|
||||
due.save(update_fields=["status", "paid_at"])
|
||||
issue_invoice(due) # every period is billable the moment it opens
|
||||
|
||||
return due
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def record_payment(due: Due, amount: Decimal, *, method=DuePayment.Method.BANK_TRANSFER, reference: str = "", paid_at=None, note: str = "", user=None) -> DuePayment:
|
||||
"""Log money against a due and re-derive its status from the payments."""
|
||||
if due.status in (Due.Status.WAIVED, Due.Status.CANCELLED):
|
||||
raise BillingError(f"This period is {due.get_status_display()}; it cannot take a payment.")
|
||||
if amount <= ZERO:
|
||||
raise BillingError("A payment must be for a positive amount.")
|
||||
|
||||
payment = DuePayment.objects.create(due=due, amount=amount, method=method, reference=reference, paid_at=paid_at or timezone.now(), note=note, recorded_by=user)
|
||||
_resettle(due)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def remove_payment(payment: DuePayment) -> None:
|
||||
"""Undo a mis-keyed payment, then re-derive the due from what is left."""
|
||||
due = payment.due
|
||||
payment.delete()
|
||||
_resettle(due)
|
||||
|
||||
|
||||
def _resettle(due: Due) -> None:
|
||||
"""Recompute amount_paid and status from the payments on record.
|
||||
|
||||
Summed from the payments rather than incremented: an increment drifts the moment a
|
||||
payment is edited or deleted, and the drift is invisible — the number still looks like
|
||||
money.
|
||||
"""
|
||||
paid = due.payments.aggregate(total=Sum("amount"))["total"] or ZERO
|
||||
|
||||
due.amount_paid = paid
|
||||
if paid >= due.amount:
|
||||
due.status = Due.Status.PAID
|
||||
due.paid_at = due.payments.order_by("-paid_at").first().paid_at
|
||||
elif paid > ZERO:
|
||||
due.status = Due.Status.PARTIAL
|
||||
due.paid_at = None
|
||||
else:
|
||||
due.status = Due.Status.UNPAID
|
||||
due.paid_at = None
|
||||
due.save(update_fields=["amount_paid", "status", "paid_at", "modified"])
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def waive(due: Due, *, note: str = "") -> Due:
|
||||
"""Write a period off. It stops owing, and stops counting towards archiving."""
|
||||
if due.payments.exists():
|
||||
raise BillingError("This period has payments against it; remove them before waiving it.")
|
||||
|
||||
due.status = Due.Status.WAIVED
|
||||
due.save(update_fields=["status", "modified"])
|
||||
|
||||
return due
|
||||
|
||||
|
||||
def owing_dues():
|
||||
return Due.objects.filter(status__in=Due.OWING)
|
||||
|
||||
|
||||
def dues_in_grace(today: date | None = None):
|
||||
"""Period over, unpaid, not yet archivable."""
|
||||
today = today or timezone.localdate()
|
||||
|
||||
return owing_dues().filter(period_end__lt=today, grace_until__gte=today)
|
||||
|
||||
|
||||
def dues_overdue(today: date | None = None):
|
||||
"""Past grace: these are the clubs the archive command would take down."""
|
||||
today = today or timezone.localdate()
|
||||
|
||||
return owing_dues().filter(grace_until__lt=today)
|
||||
|
||||
|
||||
def archivable_clubs(today: date | None = None):
|
||||
"""Clubs the archive command would act on: overdue, still live, and opted in.
|
||||
|
||||
A club with auto_archive off is deliberately spared — that flag is how you keep a club
|
||||
you are negotiating with from being switched off overnight.
|
||||
"""
|
||||
return dues_overdue(today).filter(club__archived_at__isnull=True, club__subscription__auto_archive=True).select_related("club", "tier").order_by("club__name")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def reactivate(club, *, start: date | None = None) -> Due:
|
||||
"""Bring an archived club back and bill it again.
|
||||
|
||||
The new period defaults to continuing from the last one, so a lapsed year is still owed.
|
||||
Pass ``start`` to forgive the gap and begin today instead.
|
||||
"""
|
||||
club.restore()
|
||||
|
||||
return open_period(club, start=start)
|
||||
|
||||
|
||||
def subscriptions_due_for_renewal(today: date | None = None, lead_days: int = RENEWAL_LEAD_DAYS):
|
||||
"""Clubs whose next period should be issued now.
|
||||
|
||||
Idempotent by construction: a club that has just been renewed has a latest period ending a
|
||||
year out, which is past the horizon, so it cannot be picked up twice. Running the job twice
|
||||
a day is harmless.
|
||||
|
||||
A subscription with no period at all (its only due was cancelled) counts too — a club on a
|
||||
plan and billed for nothing is the leak this whole job exists to close.
|
||||
"""
|
||||
today = today or timezone.localdate()
|
||||
horizon = today + timedelta(days=lead_days)
|
||||
|
||||
latest_period_end = Subquery(
|
||||
Due.objects.filter(club=OuterRef("club")).exclude(status=Due.Status.CANCELLED).order_by("-period_end").values("period_end")[:1],
|
||||
output_field=DateField(),
|
||||
)
|
||||
|
||||
subscriptions = Subscription.objects.filter(auto_renew=True, club__archived_at__isnull=True).select_related("club", "tier").annotate(latest_period_end=latest_period_end).order_by("club__name")
|
||||
|
||||
return [subscription for subscription in subscriptions if subscription.latest_period_end is None or subscription.latest_period_end <= horizon]
|
||||
|
||||
|
||||
def renew(subscription: Subscription) -> Due:
|
||||
"""Open the club's next period, continuing from the last one."""
|
||||
return open_period(subscription.club)
|
||||
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>
|
||||
563
billing/tests.py
Normal file
563
billing/tests.py
Normal file
@@ -0,0 +1,563 @@
|
||||
import datetime
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from io import StringIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Club
|
||||
|
||||
from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year
|
||||
from .services import BillingError
|
||||
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, renew, start_trial, subscribe, subscriptions_due_for_renewal, waive
|
||||
from .services.invoices import invoice_pdf, issue_invoice, render_pdf
|
||||
|
||||
|
||||
class BillingTestBase(TestCase):
|
||||
def setUp(self):
|
||||
self.today = timezone.localdate()
|
||||
self.club = Club.objects.create(name="Ajax United")
|
||||
self.tier = Tier.objects.create(name="Standard")
|
||||
# Priced well back, so a backdated (lapsed) period still has a price in force —
|
||||
# opening one before any price existed is refused, and rightly so.
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
|
||||
|
||||
def bill(self, start=None, club=None):
|
||||
return open_period(club or self.club, start=start, tier=self.tier)
|
||||
|
||||
|
||||
class TierPriceTests(BillingTestBase):
|
||||
def test_the_price_in_force_is_the_latest_one_that_has_started(self):
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("600.00"))
|
||||
|
||||
self.assertEqual(self.tier.price_on(self.today - datetime.timedelta(days=1)), Decimal("500.00"))
|
||||
self.assertEqual(self.tier.price_on(self.today), Decimal("600.00"))
|
||||
|
||||
def test_a_future_price_does_not_apply_yet(self):
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
|
||||
|
||||
self.assertEqual(self.tier.price_on(self.today), Decimal("500.00"))
|
||||
|
||||
def test_a_tier_with_no_price_yet_cannot_be_billed(self):
|
||||
# None must never be read as free.
|
||||
empty = Tier.objects.create(name="Enterprise")
|
||||
|
||||
self.assertIsNone(empty.price_on(self.today))
|
||||
|
||||
with self.assertRaises(BillingError):
|
||||
open_period(self.club, tier=empty)
|
||||
|
||||
|
||||
class PeriodTests(BillingTestBase):
|
||||
def test_a_period_runs_a_rolling_year_with_a_grace_tail(self):
|
||||
due = self.bill(start=datetime.date(2026, 3, 1))
|
||||
|
||||
self.assertEqual(due.period_end, datetime.date(2027, 2, 28))
|
||||
self.assertEqual(due.grace_until, due.period_end + datetime.timedelta(days=GRACE_DAYS))
|
||||
|
||||
def test_a_leap_day_period_does_not_explode(self):
|
||||
# 29 February has no counterpart in a common year.
|
||||
self.assertEqual(add_one_year(datetime.date(2028, 2, 29)), datetime.date(2029, 2, 28))
|
||||
|
||||
def test_the_next_period_continues_from_the_last_one(self):
|
||||
# Not from today: a club that pays two months late has still used those two months,
|
||||
# and restarting the clock at the payment date would quietly gift them away.
|
||||
first = self.bill(start=self.today - datetime.timedelta(days=400))
|
||||
|
||||
self.assertEqual(next_period_start(self.club), first.period_end + datetime.timedelta(days=1))
|
||||
|
||||
def test_a_first_period_starts_today(self):
|
||||
self.assertEqual(next_period_start(self.club), self.today)
|
||||
|
||||
def test_the_amount_is_snapshotted_at_the_price_of_the_day(self):
|
||||
due = self.bill()
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=1), amount=Decimal("900.00"))
|
||||
due.refresh_from_db()
|
||||
|
||||
# Raising the rate must not rewrite what was already billed.
|
||||
self.assertEqual(due.amount, Decimal("500.00"))
|
||||
|
||||
def test_a_club_cannot_be_billed_twice_for_one_period(self):
|
||||
self.bill(start=self.today)
|
||||
|
||||
with self.assertRaises(BillingError):
|
||||
self.bill(start=self.today)
|
||||
|
||||
def test_a_club_with_no_tier_cannot_be_billed(self):
|
||||
with self.assertRaises(BillingError):
|
||||
open_period(Club.objects.create(name="Feyenoord"))
|
||||
|
||||
def test_subscribing_puts_a_club_on_a_tier_and_opens_a_period(self):
|
||||
club = Club.objects.create(name="Feyenoord")
|
||||
|
||||
subscribe(club, self.tier)
|
||||
|
||||
self.assertEqual(Subscription.objects.get(club=club).tier, self.tier)
|
||||
self.assertEqual(club.dues.count(), 1)
|
||||
|
||||
|
||||
class PaymentTests(BillingTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.due = self.bill()
|
||||
|
||||
def test_a_part_payment_leaves_the_due_partially_paid(self):
|
||||
record_payment(self.due, Decimal("200.00"))
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.due.status, Due.Status.PARTIAL)
|
||||
self.assertEqual(self.due.balance, Decimal("300.00"))
|
||||
self.assertIsNone(self.due.paid_at)
|
||||
|
||||
def test_payments_accumulate_until_the_due_is_settled(self):
|
||||
record_payment(self.due, Decimal("200.00"))
|
||||
record_payment(self.due, Decimal("300.00"))
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.due.status, Due.Status.PAID)
|
||||
self.assertEqual(self.due.balance, Decimal("0.00"))
|
||||
self.assertIsNotNone(self.due.paid_at)
|
||||
|
||||
def test_an_overpayment_still_settles_the_due(self):
|
||||
record_payment(self.due, Decimal("600.00"))
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.due.status, Due.Status.PAID)
|
||||
|
||||
def test_removing_a_payment_re_derives_the_due(self):
|
||||
# amount_paid is summed from the payments, never incremented: an increment drifts the
|
||||
# moment one is deleted, and the drift still looks like money.
|
||||
first = record_payment(self.due, Decimal("200.00"))
|
||||
record_payment(self.due, Decimal("300.00"))
|
||||
|
||||
remove_payment(first)
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.due.amount_paid, Decimal("300.00"))
|
||||
self.assertEqual(self.due.status, Due.Status.PARTIAL)
|
||||
|
||||
def test_removing_the_only_payment_puts_the_due_back_to_unpaid(self):
|
||||
payment = record_payment(self.due, Decimal("500.00"))
|
||||
|
||||
remove_payment(payment)
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.due.status, Due.Status.UNPAID)
|
||||
self.assertEqual(self.due.amount_paid, Decimal("0.00"))
|
||||
self.assertIsNone(self.due.paid_at)
|
||||
|
||||
def test_a_zero_payment_is_refused(self):
|
||||
with self.assertRaises(BillingError):
|
||||
record_payment(self.due, Decimal("0.00"))
|
||||
|
||||
def test_a_waived_period_cannot_take_a_payment(self):
|
||||
waive(self.due)
|
||||
|
||||
with self.assertRaises(BillingError):
|
||||
record_payment(self.due, Decimal("100.00"))
|
||||
|
||||
def test_a_period_with_payments_cannot_be_waived(self):
|
||||
record_payment(self.due, Decimal("100.00"))
|
||||
|
||||
with self.assertRaises(BillingError):
|
||||
waive(self.due)
|
||||
|
||||
def test_a_waived_period_owes_nothing_and_never_archives_a_club(self):
|
||||
waive(self.due)
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertFalse(self.due.is_owing)
|
||||
self.assertFalse(self.due.is_overdue(self.due.grace_until + datetime.timedelta(days=1)))
|
||||
|
||||
|
||||
class GraceAndArchiveTests(BillingTestBase):
|
||||
LAPSED = 365 + GRACE_DAYS + 10
|
||||
|
||||
def test_a_period_past_its_end_but_inside_grace_is_in_grace(self):
|
||||
due = self.bill(start=self.today - datetime.timedelta(days=370))
|
||||
|
||||
self.assertTrue(due.is_in_grace(self.today))
|
||||
self.assertFalse(due.is_overdue(self.today))
|
||||
self.assertIn(due, dues_in_grace(self.today))
|
||||
|
||||
def test_a_period_past_grace_is_overdue(self):
|
||||
due = self.bill(start=self.today - datetime.timedelta(days=self.LAPSED))
|
||||
|
||||
self.assertTrue(due.is_overdue(self.today))
|
||||
self.assertFalse(due.is_in_grace(self.today))
|
||||
self.assertIn(due, dues_overdue(self.today))
|
||||
|
||||
def test_a_paid_period_is_never_overdue(self):
|
||||
due = self.bill(start=self.today - datetime.timedelta(days=self.LAPSED))
|
||||
record_payment(due, Decimal("500.00"))
|
||||
due.refresh_from_db()
|
||||
|
||||
self.assertFalse(due.is_overdue(self.today))
|
||||
self.assertNotIn(due, dues_overdue(self.today))
|
||||
|
||||
def test_an_overdue_club_is_archivable(self):
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
|
||||
|
||||
self.assertEqual(archivable_clubs(self.today).count(), 1)
|
||||
|
||||
def test_a_club_that_opted_out_is_never_archived(self):
|
||||
# auto_archive off is how you stop a club you are negotiating with from being
|
||||
# switched off overnight.
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED), auto_archive=False)
|
||||
|
||||
self.assertEqual(archivable_clubs(self.today).count(), 0)
|
||||
|
||||
def test_an_already_archived_club_is_not_archived_again(self):
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
|
||||
self.club.archive()
|
||||
|
||||
self.assertEqual(archivable_clubs(self.today).count(), 0)
|
||||
|
||||
|
||||
class ArchiveCommandTests(BillingTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=365 + GRACE_DAYS + 10))
|
||||
|
||||
def run_command(self, *args):
|
||||
out = StringIO()
|
||||
call_command("archive_overdue_clubs", *args, stdout=out)
|
||||
return out.getvalue()
|
||||
|
||||
def test_it_reports_without_archiving_by_default(self):
|
||||
# The asymmetry is the point: this switches off paying customers, so a cron
|
||||
# misconfiguration or a clock skew must cost an email, not a morning of angry clubs.
|
||||
output = self.run_command()
|
||||
|
||||
self.club.refresh_from_db()
|
||||
self.assertFalse(self.club.is_archived)
|
||||
self.assertIn("Dry run", output)
|
||||
self.assertIn("Ajax United", output)
|
||||
|
||||
def test_it_archives_with_commit(self):
|
||||
self.run_command("--commit")
|
||||
|
||||
self.club.refresh_from_db()
|
||||
self.assertTrue(self.club.is_archived)
|
||||
|
||||
def test_it_says_so_when_nothing_is_overdue(self):
|
||||
record_payment(self.club.dues.first(), Decimal("500.00"))
|
||||
|
||||
self.assertIn("Nothing overdue", self.run_command())
|
||||
|
||||
|
||||
class ReactivationTests(BillingTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Through subscribe(), not open_period(): reactivating reads the club's tier off its
|
||||
# subscription, and a club billed without one cannot be re-billed later.
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
|
||||
self.first = self.club.dues.first()
|
||||
self.club.archive()
|
||||
|
||||
def test_reactivating_continues_from_the_lapsed_period_by_default(self):
|
||||
due = reactivate(self.club)
|
||||
|
||||
self.club.refresh_from_db()
|
||||
self.assertFalse(self.club.is_archived)
|
||||
self.assertEqual(due.period_start, self.first.period_end + datetime.timedelta(days=1))
|
||||
|
||||
def test_a_chosen_start_forgives_the_gap(self):
|
||||
due = reactivate(self.club, start=self.today)
|
||||
|
||||
self.assertEqual(due.period_start, self.today)
|
||||
|
||||
|
||||
class InvoiceTests(BillingTestBase):
|
||||
def test_every_period_is_invoiced_when_it_opens(self):
|
||||
due = self.bill()
|
||||
|
||||
self.assertTrue(Invoice.objects.filter(due=due).exists())
|
||||
|
||||
def test_numbers_run_in_one_platform_wide_series(self):
|
||||
# Unlike the shop's per-club order numbers: these are OUR invoices, and one sequence
|
||||
# covers every club we bill.
|
||||
first = self.bill(start=self.today).invoice
|
||||
second = open_period(Club.objects.create(name="Feyenoord"), tier=self.tier).invoice
|
||||
|
||||
year = timezone.now().year
|
||||
self.assertEqual(first.number, f"INV-{year}-00001")
|
||||
self.assertEqual(second.number, f"INV-{year}-00002")
|
||||
|
||||
def test_re_issuing_does_not_burn_a_number(self):
|
||||
# A gap in an invoice series is a question you do not want to have to answer.
|
||||
due = self.bill()
|
||||
|
||||
self.assertEqual(issue_invoice(due), due.invoice)
|
||||
self.assertEqual(Invoice.objects.count(), 1)
|
||||
|
||||
def test_the_invoice_renders_the_frozen_snapshot(self):
|
||||
due = self.bill()
|
||||
record_payment(due, Decimal("200.00"), reference="TRX-9")
|
||||
due.refresh_from_db()
|
||||
|
||||
with mock.patch("billing.services.invoices.render_pdf", return_value=b"%PDF-fake") as renderer:
|
||||
invoice_pdf(due.invoice)
|
||||
|
||||
html = renderer.call_args.args[0]
|
||||
self.assertIn("INV-", html)
|
||||
self.assertIn("Ajax United", html)
|
||||
self.assertIn("500.00", html) # billed
|
||||
self.assertIn("200.00", html) # paid
|
||||
self.assertIn("300.00", html) # balance
|
||||
|
||||
def test_the_pdf_library_is_only_needed_when_a_pdf_is_asked_for(self):
|
||||
# WeasyPrint binds to native pango/cairo. The app, the tests and every other page must
|
||||
# run without them; only this call may fail.
|
||||
with mock.patch.dict(sys.modules, {"weasyprint": mock.MagicMock()}):
|
||||
sys.modules["weasyprint"].HTML.return_value.write_pdf.return_value = b"%PDF-1.7"
|
||||
|
||||
self.assertEqual(render_pdf("<p>hi</p>"), b"%PDF-1.7")
|
||||
|
||||
def test_a_missing_pdf_library_says_what_is_missing(self):
|
||||
with mock.patch.dict(sys.modules, {"weasyprint": None}), self.assertRaises(BillingError) as caught:
|
||||
render_pdf("<p>hi</p>")
|
||||
|
||||
self.assertIn("pango", str(caught.exception))
|
||||
|
||||
|
||||
class ModelStringTests(BillingTestBase):
|
||||
def test_models_describe_themselves(self):
|
||||
due = self.bill()
|
||||
payment = record_payment(due, Decimal("10.00"))
|
||||
|
||||
self.assertEqual(str(self.tier), "Standard")
|
||||
self.assertIn("500.00", str(self.tier.prices.first()))
|
||||
self.assertIn("Ajax United", str(due))
|
||||
self.assertIn("10.00", str(payment))
|
||||
self.assertIn("INV-", str(due.invoice))
|
||||
self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.tier)))
|
||||
|
||||
|
||||
class RenewalTests(BillingTestBase):
|
||||
"""The leak this closes: a club whose period lapses with its last due PAID owes nothing,
|
||||
so dues_overdue() is empty, so archive_overdue_clubs never fires — and the club keeps
|
||||
using the platform for free while every number on the dashboard stays green."""
|
||||
|
||||
def ending_in(self, days, **kwargs):
|
||||
"""A club whose current period ends `days` from now."""
|
||||
club = Club.objects.create(name=f"Club {days}")
|
||||
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=365 - days), **kwargs)
|
||||
return club
|
||||
|
||||
def test_a_club_nearing_its_end_date_is_picked_up(self):
|
||||
club = self.ending_in(20)
|
||||
|
||||
due = [s.club for s in subscriptions_due_for_renewal()]
|
||||
|
||||
self.assertIn(club, due)
|
||||
|
||||
def test_a_club_with_a_period_beyond_the_horizon_is_left_alone(self):
|
||||
club = self.ending_in(200)
|
||||
|
||||
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||
|
||||
def test_renewing_continues_from_the_last_period(self):
|
||||
club = self.ending_in(20)
|
||||
first = club.dues.first()
|
||||
|
||||
renew(club.subscription)
|
||||
|
||||
latest = club.dues.order_by("-period_start").first()
|
||||
self.assertEqual(latest.period_start, first.period_end + datetime.timedelta(days=1))
|
||||
self.assertEqual(club.dues.count(), 2)
|
||||
|
||||
def test_running_twice_does_not_bill_twice(self):
|
||||
# Idempotent by construction: once renewed, the club's latest period ends a year out,
|
||||
# which is past the horizon.
|
||||
club = self.ending_in(20)
|
||||
|
||||
call_command("renew_subscriptions", stdout=StringIO())
|
||||
call_command("renew_subscriptions", stdout=StringIO())
|
||||
|
||||
self.assertEqual(club.dues.count(), 2)
|
||||
|
||||
def test_a_club_that_opted_out_is_not_renewed(self):
|
||||
club = self.ending_in(20, auto_renew=False)
|
||||
|
||||
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||
|
||||
def test_an_archived_club_is_not_renewed(self):
|
||||
# Reactivation is the way back, and it opens a period of its own.
|
||||
club = self.ending_in(20)
|
||||
club.archive()
|
||||
|
||||
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||
|
||||
def test_the_new_period_is_billed_at_the_price_in_force_then(self):
|
||||
club = self.ending_in(20)
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("900.00"))
|
||||
|
||||
due = renew(club.subscription)
|
||||
|
||||
self.assertEqual(due.amount, Decimal("900.00")) # the new rate
|
||||
self.assertEqual(club.dues.order_by("period_start").first().amount, Decimal("500.00")) # the old one, untouched
|
||||
|
||||
def test_the_new_period_is_invoiced(self):
|
||||
club = self.ending_in(20)
|
||||
|
||||
due = renew(club.subscription)
|
||||
|
||||
self.assertTrue(due.invoice.number.startswith("INV-"))
|
||||
|
||||
def test_a_dry_run_issues_nothing(self):
|
||||
club = self.ending_in(20)
|
||||
out = StringIO()
|
||||
|
||||
call_command("renew_subscriptions", "--dry-run", stdout=out)
|
||||
|
||||
self.assertEqual(club.dues.count(), 1)
|
||||
self.assertIn("would renew", out.getvalue())
|
||||
|
||||
def test_the_command_issues_by_default(self):
|
||||
# The opposite asymmetry to archiving: NOT acting is the expensive failure here,
|
||||
# because a club that is never billed is never chased either.
|
||||
club = self.ending_in(20)
|
||||
|
||||
call_command("renew_subscriptions", stdout=StringIO())
|
||||
|
||||
self.assertEqual(club.dues.count(), 2)
|
||||
|
||||
def test_an_unpriced_tier_fails_loudly_without_stopping_the_others(self):
|
||||
priced = self.ending_in(20)
|
||||
broken = Club.objects.create(name="Unpriced FC")
|
||||
subscribe(broken, self.tier, start=self.today - datetime.timedelta(days=350))
|
||||
# Its next period starts beyond the last price... by removing every price, it cannot bill.
|
||||
TierPrice.objects.all().delete()
|
||||
cheap = Tier.objects.create(name="Cheap")
|
||||
TierPrice.objects.create(tier=cheap, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
|
||||
priced.subscription.tier = cheap
|
||||
priced.subscription.save()
|
||||
|
||||
with self.assertRaises(CommandError):
|
||||
call_command("renew_subscriptions", stdout=StringIO(), stderr=StringIO())
|
||||
|
||||
# ...and the club that COULD be billed still was.
|
||||
self.assertEqual(priced.dues.count(), 2)
|
||||
|
||||
def test_a_subscription_with_no_period_at_all_is_renewed(self):
|
||||
club = Club.objects.create(name="Orphan FC")
|
||||
Subscription.objects.create(club=club, tier=self.tier)
|
||||
|
||||
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||
|
||||
def test_it_says_so_when_there_is_nothing_to_renew(self):
|
||||
self.assertIn("Nothing to renew", self.run_renewal())
|
||||
|
||||
def run_renewal(self, *args):
|
||||
out = StringIO()
|
||||
call_command("renew_subscriptions", *args, stdout=out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
class RenewedButUnpaidTests(BillingTestBase):
|
||||
"""A club auto-renewed that never pays the new fee flows through the ordinary
|
||||
unpaid -> grace -> overdue -> archive path. Renewal creates a normal Due; it does not
|
||||
create a special case, and the safety net that the never-billed club slipped past now
|
||||
fires, because there IS an unpaid due."""
|
||||
|
||||
def lapsed_club(self):
|
||||
"""A club on its first, PAID period — far enough back that a renewal from its end is
|
||||
itself already past grace, so only the renewal's payment state decides the outcome."""
|
||||
club = Club.objects.create(name="Renewed FC")
|
||||
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=800))
|
||||
first = club.dues.first()
|
||||
record_payment(first, first.amount) # the FIRST period is settled; only the renewal is in question
|
||||
return club
|
||||
|
||||
def test_an_unpaid_renewal_becomes_overdue_and_archivable(self):
|
||||
club = self.lapsed_club()
|
||||
renewed = renew(club.subscription) # continues from the first period's end, unpaid
|
||||
|
||||
self.assertTrue(renewed.is_overdue(self.today))
|
||||
self.assertIn(renewed, dues_overdue(self.today))
|
||||
self.assertIn(club, [d.club for d in archivable_clubs(self.today)])
|
||||
|
||||
def test_a_paid_renewal_is_not_chased(self):
|
||||
club = self.lapsed_club()
|
||||
renewed = renew(club.subscription)
|
||||
record_payment(renewed, renewed.amount)
|
||||
|
||||
self.assertNotIn(club, [d.club for d in archivable_clubs(self.today)])
|
||||
|
||||
|
||||
class TrialTests(BillingTestBase):
|
||||
"""A club with no subscription yet can be started on a short trial that switches
|
||||
itself to a pre-selected plan automatically once the trial period is renewed --
|
||||
see billing.services.dues.start_trial and the trial-conversion check in
|
||||
open_period()."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.trial_tier = Tier.objects.create(name="Trial")
|
||||
TierPrice.objects.create(tier=self.trial_tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
|
||||
|
||||
def test_start_trial_creates_a_short_trial_period(self):
|
||||
due = start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
|
||||
|
||||
subscription = self.club.subscription
|
||||
self.assertEqual(subscription.tier, self.trial_tier)
|
||||
self.assertEqual(subscription.post_trial_tier, self.tier)
|
||||
self.assertEqual(subscription.trial_ends_at, due.period_end)
|
||||
self.assertTrue(due.is_trial)
|
||||
# Roughly 2 months, nowhere near the standard ~1-year period.
|
||||
self.assertLess((due.period_end - due.period_start).days, 65)
|
||||
|
||||
def test_start_trial_refuses_if_already_subscribed(self):
|
||||
subscribe(self.club, self.tier)
|
||||
|
||||
with self.assertRaises(BillingError):
|
||||
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
|
||||
|
||||
def test_start_trial_refuses_a_non_positive_length(self):
|
||||
with self.assertRaises(BillingError):
|
||||
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=0)
|
||||
|
||||
def test_renewing_after_the_trial_switches_to_the_post_trial_tier(self):
|
||||
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
|
||||
|
||||
due = renew(self.club.subscription)
|
||||
|
||||
self.club.refresh_from_db()
|
||||
self.assertEqual(self.club.subscription.tier, self.tier)
|
||||
self.assertIsNone(self.club.subscription.trial_ends_at)
|
||||
self.assertIsNone(self.club.subscription.post_trial_tier)
|
||||
self.assertEqual(due.tier, self.tier)
|
||||
self.assertFalse(due.is_trial)
|
||||
self.assertEqual(due.amount, Decimal("500.00"))
|
||||
|
||||
def test_manually_opening_the_next_period_also_switches_tier(self):
|
||||
# Same conversion must fire via the control panel's "Open period" button, which
|
||||
# calls open_period() directly rather than renew().
|
||||
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2)
|
||||
|
||||
open_period(self.club)
|
||||
|
||||
self.club.refresh_from_db()
|
||||
self.assertEqual(self.club.subscription.tier, self.tier)
|
||||
|
||||
def test_a_trial_nearing_its_end_is_picked_up_for_renewal(self):
|
||||
start_trial(self.club, self.trial_tier, post_trial_tier=self.tier, trial_months=2, start=self.today - datetime.timedelta(days=50))
|
||||
|
||||
self.assertIn(self.club, [s.club for s in subscriptions_due_for_renewal()])
|
||||
|
||||
def test_a_zero_amount_trial_is_created_already_paid(self):
|
||||
free_tier = Tier.objects.create(name="Free Trial")
|
||||
TierPrice.objects.create(tier=free_tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
|
||||
|
||||
due = start_trial(self.club, free_tier, post_trial_tier=self.tier, trial_months=2)
|
||||
|
||||
self.assertEqual(due.status, Due.Status.PAID)
|
||||
self.assertIsNotNone(due.paid_at)
|
||||
far_future = due.grace_until + datetime.timedelta(days=100)
|
||||
self.assertNotIn(due, dues_overdue(far_future))
|
||||
self.assertNotIn(self.club, [d.club for d in archivable_clubs(far_future)])
|
||||
0
club/__init__.py
Normal file
0
club/__init__.py
Normal file
65
club/admin.py
Normal file
65
club/admin.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor
|
||||
|
||||
|
||||
@admin.register(Club)
|
||||
class ClubAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "slug", "sport_type"]
|
||||
list_filter = ["sport_type"]
|
||||
search_fields = ["name", "slug"]
|
||||
prepopulated_fields = {"slug": ["name"]}
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
@admin.register(Sponsor)
|
||||
class SponsorAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "club", "start_date", "end_date"]
|
||||
list_filter = ["club"]
|
||||
search_fields = ["name"]
|
||||
|
||||
|
||||
@admin.register(Season)
|
||||
class SeasonAdmin(admin.ModelAdmin):
|
||||
list_display = ["__str__", "club", "start_date", "end_date"]
|
||||
list_filter = ["club"]
|
||||
search_fields = ["club__name"]
|
||||
ordering = ["club", "-start_date"]
|
||||
|
||||
|
||||
class FeePaymentInline(admin.TabularInline):
|
||||
model = FeePayment
|
||||
extra = 0
|
||||
readonly_fields = ["recorded_by"]
|
||||
|
||||
|
||||
@admin.register(ClubMembership)
|
||||
class ClubMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "fee_amount", "amount_paid", "license"]
|
||||
search_fields = ["club__name", "member__last_name", "member__first_name", "license"]
|
||||
list_filter = ["club", "season", "status", "fee_status"]
|
||||
raw_id_fields = ["member"]
|
||||
# Money is settled by club.services.fees, which re-derives fee_status from the payments.
|
||||
readonly_fields = ["amount_paid", "fee_status"]
|
||||
fieldsets = [
|
||||
[None, {"fields": ["club", "season", "member"]}],
|
||||
[_("Membership"), {"fields": ["license", "status", "fee_status", "fee_amount", "amount_paid"]}],
|
||||
[_("Dates"), {"fields": ["signed_up_at", "activated_at"]}],
|
||||
]
|
||||
inlines = [FeePaymentInline]
|
||||
|
||||
|
||||
@admin.register(FeePayment)
|
||||
class FeePaymentAdmin(admin.ModelAdmin):
|
||||
list_display = ["membership", "amount", "method", "paid_at", "recorded_by"]
|
||||
list_filter = ["method"]
|
||||
search_fields = ["membership__club__name", "membership__member__last_name", "reference"]
|
||||
|
||||
|
||||
@admin.register(ClubRole)
|
||||
class ClubRoleAdmin(admin.ModelAdmin):
|
||||
list_display = ["club__name", "member__last_name", "member__first_name", "role"]
|
||||
search_fields = ["club__name", "member__last_name", "member__first_name"]
|
||||
list_filter = ["club", "role"]
|
||||
raw_id_fields = ["member"]
|
||||
58
club/api.py
Normal file
58
club/api.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Public read-only sponsors endpoint -- see api/urls.py for how this is
|
||||
mounted.
|
||||
"""
|
||||
|
||||
import random
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from ninja import Router, Schema
|
||||
|
||||
from api.errors import require_club
|
||||
|
||||
from .models import Sponsor
|
||||
|
||||
router = Router(tags=["sponsors"])
|
||||
|
||||
|
||||
class SponsorOut(Schema):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
logo_url: str | None
|
||||
url: str | None
|
||||
start_date: date
|
||||
end_date: date | None
|
||||
|
||||
|
||||
def _to_sponsor_out(sponsor, request) -> SponsorOut:
|
||||
return SponsorOut(
|
||||
id=sponsor.pk,
|
||||
name=sponsor.name,
|
||||
logo_url=request.build_absolute_uri(sponsor.logo.url) if sponsor.logo else None,
|
||||
url=sponsor.url or None,
|
||||
start_date=sponsor.start_date,
|
||||
end_date=sponsor.end_date,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response=list[SponsorOut], summary="Active sponsors")
|
||||
def list_sponsors(request, randomize: bool = False):
|
||||
"""Sponsors currently "live": start_date has passed and either there's no
|
||||
end_date (runs indefinitely once started) or it hasn't passed yet. Both
|
||||
bounds are inclusive of today.
|
||||
|
||||
`randomize=true` shuffles the result (e.g. for a sponsor strip that
|
||||
shouldn't always lead with the same one) -- shuffled in Python after a
|
||||
stable-ordered fetch rather than an ORDER BY RANDOM(), which sponsor
|
||||
counts are far too small to need and which SQLite/Postgres don't even
|
||||
express the same way."""
|
||||
club = require_club(request)
|
||||
today = timezone.localdate()
|
||||
|
||||
sponsors = list(Sponsor.objects.filter(club=club, start_date__lte=today).filter(Q(end_date__isnull=True) | Q(end_date__gte=today)).order_by("name"))
|
||||
if randomize:
|
||||
random.shuffle(sponsors)
|
||||
|
||||
return [_to_sponsor_out(sponsor, request) for sponsor in sponsors]
|
||||
8
club/apps.py
Normal file
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,
|
||||
}
|
||||
0
club/management/__init__.py
Normal file
0
club/management/__init__.py
Normal file
0
club/management/commands/__init__.py
Normal file
0
club/management/commands/__init__.py
Normal file
57
club/management/commands/generate_seasons.py
Normal file
57
club/management/commands/generate_seasons.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Generate seasons ahead of time for every active club.
|
||||
|
||||
Meant to run on a schedule (cron): safe to call repeatedly, since generate_seasons
|
||||
skips whatever already exists. No --dry-run/--commit gate on generation itself --
|
||||
unlike archiving a club or billing it, creating a future season row is additive
|
||||
and idempotent, same reasoning as extend_event_series (materialising occurrences).
|
||||
|
||||
--resync is different: it can delete rows (any season that doesn't match a
|
||||
club's *current* season_start/season_duration_months, and isn't referenced by a
|
||||
membership), so it defaults to reporting only -- pass --commit alongside it to
|
||||
actually remove anything.
|
||||
"""
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Club
|
||||
from club.services.seasons import generate_seasons, resync_seasons
|
||||
from features.commands import MaintenanceAwareCommand
|
||||
|
||||
|
||||
class Command(MaintenanceAwareCommand):
|
||||
help = "Generate seasons up to N years ahead for every active club (default 2)."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--years", type=int, default=2, help="How many years ahead to generate seasons for (default 2).")
|
||||
parser.add_argument("--resync", action="store_true", help="Before generating, remove any existing seasons that don't match the club's current settings (skips any still referenced by a membership).")
|
||||
parser.add_argument("--commit", action="store_true", help="With --resync, actually delete the seasons found to be wrong. Without it, --resync only reports what it would remove.")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
until = timezone.localdate() + relativedelta(years=options["years"])
|
||||
clubs = Club.objects.active()
|
||||
|
||||
if options["resync"]:
|
||||
total_removed, total_kept = 0, 0
|
||||
for club in clubs:
|
||||
removed, kept = resync_seasons(club, until, commit=options["commit"])
|
||||
total_removed += len(removed)
|
||||
total_kept += len(kept)
|
||||
if removed:
|
||||
verb = "Removed" if options["commit"] else "Would remove"
|
||||
self.stdout.write(f"{club}: {verb} {len(removed)} season(s) that no longer match its settings.")
|
||||
if kept:
|
||||
self.stdout.write(self.style.WARNING(f"{club}: {len(kept)} season(s) don't match its settings but are still in use, left alone."))
|
||||
|
||||
if not options["commit"] and total_removed:
|
||||
self.stdout.write(self.style.WARNING("Dry run -- pass --commit to actually delete these."))
|
||||
self.stdout.write(self.style.SUCCESS(f"Resync: {total_removed} season(s) {'removed' if options['commit'] else 'to remove'}, {total_kept} kept (in use)."))
|
||||
|
||||
total = 0
|
||||
for club in clubs:
|
||||
created = generate_seasons(club, until)
|
||||
total += len(created)
|
||||
if created:
|
||||
self.stdout.write(f"{club}: generated {len(created)} season(s).")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Done. Generated {total} season(s) across {clubs.count()} club(s)."))
|
||||
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'),
|
||||
),
|
||||
]
|
||||
19
club/migrations/0014_club_secondary_color.py
Normal file
19
club/migrations/0014_club_secondary_color.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-24 16:30
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0013_club_created_club_modified_clubmembership_created_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='secondary_color',
|
||||
field=models.CharField(blank=True, help_text="Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour.", max_length=7, validators=[django.core.validators.RegexValidator('^#[0-9a-fA-F]{6}$', 'Enter a colour as a hex value, e.g. #be185d.')], verbose_name='secondary colour'),
|
||||
),
|
||||
]
|
||||
20
club/migrations/0015_alter_club_logo.py
Normal file
20
club/migrations/0015_alter_club_logo.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-24 16:40
|
||||
|
||||
import club.models
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0014_club_secondary_color'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='club',
|
||||
name='logo',
|
||||
field=models.FileField(blank=True, help_text="Shown on the club's own pages. Without one, the club's initials are used.", upload_to=club.models.club_logo_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'])], verbose_name='logo'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-03 11:22
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0015_alter_club_logo'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='clubmembership',
|
||||
name='amount_paid',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), help_text='Kept in step with payments by the fee service; not hand-edited.', max_digits=10, verbose_name='amount paid'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clubmembership',
|
||||
name='fee_amount',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='fee amount'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FeePayment',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')),
|
||||
('method', models.CharField(choices=[('bank_transfer', 'bank transfer'), ('cash', 'cash'), ('card', 'card'), ('other', 'other')], default='bank_transfer', max_length=20, verbose_name='method')),
|
||||
('reference', models.CharField(blank=True, help_text='Bank reference, transaction id — whatever lets you find this again.', max_length=255, verbose_name='reference')),
|
||||
('paid_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='paid at')),
|
||||
('note', models.TextField(blank=True, verbose_name='note')),
|
||||
('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='club.clubmembership', verbose_name='membership')),
|
||||
('recorded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recorded_fee_payments', to=settings.AUTH_USER_MODEL, verbose_name='recorded by')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'fee payment',
|
||||
'verbose_name_plural': 'fee payments',
|
||||
'ordering': ['-paid_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-03 14:30
|
||||
|
||||
import datetime
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='season_duration_months',
|
||||
field=models.PositiveSmallIntegerField(default=12, help_text='How many months a season lasts, counted from its start date.', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(24)], verbose_name='season duration (months)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='season_start',
|
||||
field=models.DateField(default=datetime.date(2000, 8, 1), help_text='Which day of the year a season begins — only the month and day are used, the year is ignored.', verbose_name='season start'),
|
||||
),
|
||||
]
|
||||
18
club/migrations/0018_club_sport_type.py
Normal file
18
club/migrations/0018_club_sport_type.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-04 22:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0017_club_season_duration_months_club_season_start'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='sport_type',
|
||||
field=models.CharField(choices=[('ice_hockey', 'Ice hockey'), ('other', 'Other')], default='other', help_text='Which sport this club plays -- determines which competitions and score fetchers are relevant to it.', max_length=20, verbose_name='sport'),
|
||||
),
|
||||
]
|
||||
36
club/migrations/0019_sponsor.py
Normal file
36
club/migrations/0019_sponsor.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-06 15:02
|
||||
|
||||
import club.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0018_club_sport_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Sponsor',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('logo', models.FileField(blank=True, upload_to=club.models.sponsor_logo_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'])], verbose_name='logo')),
|
||||
('url', models.URLField(blank=True, help_text="The sponsor's own website, if they have one.", verbose_name='URL')),
|
||||
('start_date', models.DateField(verbose_name='start date')),
|
||||
('end_date', models.DateField(blank=True, help_text='Leave blank to keep this sponsor active indefinitely once it starts.', null=True, verbose_name='end date')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'sponsor',
|
||||
'verbose_name_plural': 'sponsors',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
club/migrations/__init__.py
Normal file
0
club/migrations/__init__.py
Normal file
128
club/mixins.py
Normal file
128
club/mixins.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.http import Http404
|
||||
from waffle import flag_is_active
|
||||
|
||||
from .services.access import can_add_news, can_edit_news, can_publish_news, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
|
||||
|
||||
|
||||
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
|
||||
"""Gate for the club-facing management UI.
|
||||
|
||||
Two rules, the mirror image of ``controlpanel.mixins.PlatformStaffRequiredMixin``:
|
||||
|
||||
* **Club subdomain only.** This UI manages *one* club, so it doesn't exist on the
|
||||
base domain — same reasoning as the control panel refusing to exist on a club
|
||||
subdomain, just inverted.
|
||||
* **Staff only.** ADMIN/EDITOR, or a current-season ``StaffAssignment`` (coach,
|
||||
team manager, ...) — see ``has_management_access``. The plain MEMBER role every
|
||||
active player/club member holds automatically does *not* count: a club member
|
||||
with neither is a player/parent, and belongs in the separate app that serves
|
||||
them.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if getattr(request, "club", None) is None:
|
||||
raise Http404("The management app is not available on the base domain.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def test_func(self):
|
||||
return has_management_access(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""ADMIN role only — club-wide settings that aren't scoped to a single team:
|
||||
seasons, positions, roles, shop configuration."""
|
||||
|
||||
def test_func(self):
|
||||
return is_club_admin(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class FeatureRequiredMixin(ClubAdminRequiredMixin):
|
||||
"""Gate for a whole management section (shop, forms, ...) this club doesn't
|
||||
have at all unless its waffle Flag (see the ``features`` app, set per-club
|
||||
from the control panel's Features page) is active for it. Checked before
|
||||
the admin-only test below and as a plain 404 rather than folded into
|
||||
``test_func``'s 403: a club with the feature off doesn't have a permissions
|
||||
problem, the section just doesn't exist there, same reasoning as
|
||||
``ClubStaffRequiredMixin`` 404ing the whole app off the base domain.
|
||||
|
||||
Subclasses set ``feature_flag`` to the Flag's name, e.g. ``"shop"``.
|
||||
"""
|
||||
|
||||
feature_flag: str = ""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
club = getattr(request, "club", None)
|
||||
if club is not None and not flag_is_active(request, self.feature_flag):
|
||||
raise Http404(f"The “{self.feature_flag}” feature isn't enabled for this club.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""A manager of *this* team, or a club ADMIN. ``self.get_team()`` must return the
|
||||
``Team`` the view acts on (e.g. from the URL's ``pk``) before ``test_func`` runs.
|
||||
"""
|
||||
|
||||
def get_team(self):
|
||||
raise NotImplementedError("Subclasses must return the Team this view acts on.")
|
||||
|
||||
def test_func(self):
|
||||
user, club = self.request.user, self.request.club
|
||||
if is_club_admin(user, club):
|
||||
return True
|
||||
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
|
||||
|
||||
|
||||
class EventManagerRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""Admin, or a manager of at least one of this event's/series' *current*
|
||||
teams. ``self.get_teams()`` must return the Team queryset/iterable the
|
||||
view acts on (e.g. ``self.get_object().teams.all()``) before ``test_func``
|
||||
runs. Events/series aren't single-team like a roster entry -- ``teams`` is
|
||||
M2M, so authority is "manages at least one", not "manages the one"."""
|
||||
|
||||
def get_teams(self):
|
||||
raise NotImplementedError("Subclasses must return the Teams this view acts on.")
|
||||
|
||||
def test_func(self):
|
||||
user, club = self.request.user, self.request.club
|
||||
if is_club_admin(user, club):
|
||||
return True
|
||||
return teams_managed_by(user, club).filter(pk__in=self.get_teams().values_list("pk", flat=True)).exists()
|
||||
|
||||
|
||||
class ManagementPositionRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""ADMIN, or anyone with a current-season *management*-position
|
||||
StaffAssignment on any team -- unlike ``TeamManagerRequiredMixin``, the
|
||||
entity here (Location, Opponent, ...) isn't scoped to one team, so "manager
|
||||
of this team" doesn't apply; any management position qualifies."""
|
||||
|
||||
def test_func(self):
|
||||
return is_club_admin(self.request.user, self.request.club) or is_coach_manager(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class NewsAuthorRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
|
||||
author club content in the first place (creating a draft)."""
|
||||
|
||||
def test_func(self):
|
||||
return can_add_news(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class NewsPublisherRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""ADMIN/EDITOR only -- the release-flow gate for pushing a news item live
|
||||
(or pulling it back)."""
|
||||
|
||||
def test_func(self):
|
||||
return can_publish_news(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class NewsEditRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""Whoever may edit *this* news item right now: broad while it's a draft,
|
||||
editor/admin-only once published. ``self.get_news_item()`` must return the
|
||||
News the view acts on before ``test_func`` runs."""
|
||||
|
||||
def get_news_item(self):
|
||||
raise NotImplementedError("Subclasses must return the News item this view acts on.")
|
||||
|
||||
def test_func(self):
|
||||
return can_edit_news(self.request.user, self.get_news_item())
|
||||
322
club/models.py
Normal file
322
club/models.py
Normal file
@@ -0,0 +1,322 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from members.models import Member
|
||||
from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
|
||||
|
||||
|
||||
class ClubManager(models.Manager):
|
||||
def current(self):
|
||||
"""Return the club for the active tenant context, if any."""
|
||||
from .tenancy import get_current_club
|
||||
|
||||
return get_current_club()
|
||||
|
||||
def active(self):
|
||||
return self.filter(archived_at__isnull=True)
|
||||
|
||||
def archived(self):
|
||||
return self.filter(archived_at__isnull=False)
|
||||
|
||||
|
||||
def club_logo_path(instance: Club, filename: str) -> str:
|
||||
return f"clubs/{instance.slug}/{filename}"
|
||||
|
||||
|
||||
class Club(UUIDModel):
|
||||
class SportType(models.TextChoices):
|
||||
"""Which sport this club plays. Only two options for now -- expand this as
|
||||
more sport-specific competition fetchers (see events.competition) are added."""
|
||||
|
||||
ICE_HOCKEY = "ice_hockey", _("Ice hockey")
|
||||
OTHER = "other", _("Other")
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
|
||||
|
||||
logo = models.FileField(
|
||||
_("logo"),
|
||||
upload_to=club_logo_path,
|
||||
blank=True,
|
||||
# A plain FileField, not ImageField: Pillow (which ImageField validates through)
|
||||
# cannot read SVGs, and club crests are commonly vector logos.
|
||||
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
|
||||
help_text=_("Shown on the club's own pages. Without one, the club's initials are used."),
|
||||
)
|
||||
primary_color = models.CharField(
|
||||
_("primary colour"),
|
||||
max_length=7,
|
||||
blank=True,
|
||||
validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$", _("Enter a colour as a hex value, e.g. #1e40af."))],
|
||||
help_text=_("Hex colour for buttons and links on the club's pages, e.g. #1e40af."),
|
||||
)
|
||||
|
||||
secondary_color = models.CharField(
|
||||
_("secondary colour"),
|
||||
max_length=7,
|
||||
blank=True,
|
||||
validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$", _("Enter a colour as a hex value, e.g. #be185d."))],
|
||||
help_text=_("Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour."),
|
||||
)
|
||||
|
||||
sport_type = models.CharField(
|
||||
_("sport"),
|
||||
max_length=20,
|
||||
choices=SportType.choices,
|
||||
default=SportType.OTHER,
|
||||
help_text=_("Which sport this club plays -- determines which competitions and score fetchers are relevant to it."),
|
||||
)
|
||||
|
||||
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))
|
||||
|
||||
season_start = models.DateField(
|
||||
_("season start"),
|
||||
default=datetime.date(2000, 8, 1),
|
||||
help_text=_("Which day of the year a season begins — only the month and day are used, the year is ignored."),
|
||||
)
|
||||
season_duration_months = models.PositiveSmallIntegerField(
|
||||
_("season duration (months)"),
|
||||
default=12,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(24)],
|
||||
help_text=_("How many months a season lasts, counted from its start date."),
|
||||
)
|
||||
|
||||
objects = ClubManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("club")
|
||||
verbose_name_plural = _("clubs")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = unique_slugify(self, self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_archived(self) -> bool:
|
||||
return self.archived_at is not None
|
||||
|
||||
@property
|
||||
def initials(self) -> str:
|
||||
"""Stand-in for a missing logo. Never the RosterChief mark — that would
|
||||
pass our branding off as the club's own."""
|
||||
return "".join(word[0] for word in self.name.split()[:2]).upper()
|
||||
|
||||
@property
|
||||
def primary_content_color(self) -> str:
|
||||
"""Readable text colour to sit *on* ``primary_color``. See ``_content_color_for``."""
|
||||
return self._content_color_for(self.primary_color)
|
||||
|
||||
@property
|
||||
def secondary_content_color(self) -> str:
|
||||
"""Readable text colour to sit *on* ``secondary_color``. See ``_content_color_for``."""
|
||||
return self._content_color_for(self.secondary_color)
|
||||
|
||||
@staticmethod
|
||||
def _content_color_for(hex_color: str) -> str:
|
||||
"""Black or white, whichever reads on ``hex_color``.
|
||||
|
||||
A club picking a pale yellow would otherwise get white-on-yellow buttons.
|
||||
Relative luminance per WCAG, with its 0.179 threshold for black vs white.
|
||||
"""
|
||||
if not hex_color:
|
||||
return ""
|
||||
|
||||
def channel(value: int) -> float:
|
||||
fraction = value / 255
|
||||
return fraction / 12.92 if fraction <= 0.04045 else ((fraction + 0.055) / 1.055) ** 2.4
|
||||
|
||||
red, green, blue = (channel(int(hex_color[index : index + 2], 16)) for index in (1, 3, 5))
|
||||
luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
||||
|
||||
return "#000000" if luminance > 0.179 else "#ffffff"
|
||||
|
||||
def archive(self):
|
||||
"""Soft-delete: the club stops resolving, but nothing is destroyed.
|
||||
|
||||
Clubs are never hard-deleted — a club with any data cannot be removed
|
||||
anyway (ClubMembership PROTECTs its Season), and financial records must
|
||||
be retained.
|
||||
"""
|
||||
if not self.is_archived:
|
||||
self.archived_at = timezone.now()
|
||||
self.save(update_fields=["archived_at"])
|
||||
|
||||
def restore(self):
|
||||
if self.is_archived:
|
||||
self.archived_at = None
|
||||
self.save(update_fields=["archived_at"])
|
||||
|
||||
|
||||
def sponsor_logo_path(instance: Sponsor, filename: str) -> str:
|
||||
return f"clubs/{instance.club.slug}/sponsors/{instance.pk}/{filename}"
|
||||
|
||||
|
||||
class Sponsor(ClubScopedModel):
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
logo = models.FileField(
|
||||
_("logo"),
|
||||
upload_to=sponsor_logo_path,
|
||||
blank=True,
|
||||
# A plain FileField, not ImageField: same reasoning as Club.logo -- a
|
||||
# sponsor's own logo is just as commonly a vector file, and ImageField's
|
||||
# Pillow validation can't read those.
|
||||
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
|
||||
)
|
||||
url = models.URLField(_("URL"), blank=True, help_text=_("The sponsor's own website, if they have one."))
|
||||
|
||||
start_date = models.DateField(_("start date"))
|
||||
end_date = models.DateField(_("end date"), null=True, blank=True, help_text=_("Leave blank to keep this sponsor active indefinitely once it starts."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("sponsor")
|
||||
verbose_name_plural = _("sponsors")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
if self.end_date is not None and self.start_date is not None and self.end_date < self.start_date:
|
||||
raise ValidationError({"end_date": _("End date can't be before the start date.")})
|
||||
|
||||
|
||||
class Season(ClubScopedModel):
|
||||
start_date = models.DateField(_("start date"))
|
||||
end_date = models.DateField(_("end date"))
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("season")
|
||||
verbose_name_plural = _("seasons")
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["club", "start_date", "end_date"], name="unique_season_dates_per_club"),
|
||||
]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Short label built from the start/end years, e.g. "25-26"."""
|
||||
return f"{self.start_date:%y}-{self.end_date:%y}"
|
||||
|
||||
@classmethod
|
||||
def get_current(cls, date: datetime.date | None = None):
|
||||
"""Return the current club's season covering ``date`` (today by default)."""
|
||||
if date is None:
|
||||
date = timezone.now().date()
|
||||
|
||||
return cls.objects.current_club().filter(start_date__lte=date, end_date__gte=date).first()
|
||||
|
||||
@classmethod
|
||||
def covering(cls, club, date: datetime.date):
|
||||
"""Return ``club``'s season covering ``date`` (no tenant context needed)."""
|
||||
return cls.objects.filter(club=club, start_date__lte=date, end_date__gte=date).first()
|
||||
|
||||
@classmethod
|
||||
def next_after(cls, club, date: datetime.date):
|
||||
"""Return ``club``'s soonest season starting after ``date`` (no tenant
|
||||
context needed) -- the season that follows the one covering ``date``."""
|
||||
return cls.objects.filter(club=club, start_date__gt=date).order_by("start_date").first()
|
||||
|
||||
|
||||
class ClubMembership(ClubScopedModel):
|
||||
class StatusChoices(models.TextChoices):
|
||||
ACTIVE = "active", _("active")
|
||||
PENDING = "pending", _("pending")
|
||||
LAPSED = "lapsed", _("lapsed")
|
||||
CANCELLED = "cancelled", _("cancelled")
|
||||
|
||||
class FeeStatus(models.TextChoices):
|
||||
UNPAID = "unpaid", _("unpaid")
|
||||
PAID = "paid", _("paid")
|
||||
PARTIALLY_PAID = "partially_paid", _("partially paid")
|
||||
WAIVED = "waived", _("waived")
|
||||
|
||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="member_of", verbose_name=_("member"))
|
||||
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="memberships", verbose_name=_("season"))
|
||||
|
||||
license = models.CharField(_("license"), max_length=250, blank=True)
|
||||
status = models.CharField(_("status"), max_length=250, choices=StatusChoices.choices, default=StatusChoices.PENDING)
|
||||
fee_status = models.CharField(_("fee status"), max_length=250, choices=FeeStatus.choices, default=FeeStatus.UNPAID)
|
||||
|
||||
fee_amount = models.DecimalField(_("fee amount"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True)
|
||||
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True, help_text=_("Kept in step with payments by the fee service; not hand-edited."))
|
||||
|
||||
signed_up_at = models.DateField(_("signed up at"), blank=True, null=True)
|
||||
activated_at = models.DateField(_("activated at"), blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("club membership")
|
||||
verbose_name_plural = _("club memberships")
|
||||
ordering = ["club", "member__last_name", "member__first_name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["club", "member", "season"], name="unique_member_per_club_per_season"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.club} - {self.member}"
|
||||
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.club_id, same_club_fields=("season",))
|
||||
|
||||
|
||||
class FeePayment(UUIDModel):
|
||||
"""Money received against one membership's fee. Several may land on one
|
||||
membership: a family paying in two installments must not read as unpaid, and
|
||||
the part that did arrive has to be recorded somewhere. Not itself club-scoped
|
||||
-- its club is reached through ``membership``, same as DuePayment/Due."""
|
||||
|
||||
class Method(models.TextChoices):
|
||||
BANK_TRANSFER = "bank_transfer", _("bank transfer")
|
||||
CASH = "cash", _("cash")
|
||||
CARD = "card", _("card")
|
||||
OTHER = "other", _("other")
|
||||
|
||||
membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="payments", verbose_name=_("membership"))
|
||||
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
|
||||
method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
|
||||
reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
|
||||
paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
|
||||
note = models.TextField(_("note"), blank=True)
|
||||
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_fee_payments", verbose_name=_("recorded by"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("fee payment")
|
||||
verbose_name_plural = _("fee payments")
|
||||
ordering = ["-paid_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.membership} — {self.amount}"
|
||||
|
||||
|
||||
class ClubRole(ClubScopedModel):
|
||||
class Roles(models.TextChoices):
|
||||
ADMIN = "admin", _("admin")
|
||||
MEMBER = "member", _("member")
|
||||
EDITOR = "editor", _("editor")
|
||||
|
||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="roles", verbose_name=_("member"))
|
||||
role = models.CharField(_("role"), max_length=250, choices=Roles.choices, default=Roles.MEMBER)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("club role")
|
||||
verbose_name_plural = _("club roles")
|
||||
ordering = ["club", "member__last_name", "member__first_name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["club", "member"], name="unique_member_per_club"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.club} - {self.member}"
|
||||
10
club/services/__init__.py
Normal file
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",
|
||||
]
|
||||
172
club/services/access.py
Normal file
172
club/services/access.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Per-club access decisions.
|
||||
|
||||
All authorisation goes through this module (ARCHITECTURE §3): stored
|
||||
``ClubRole`` rows plus object-scoped facts — coach/manager is *derived* from
|
||||
``StaffAssignment`` (never a ClubRole), and parent/guardian from the family
|
||||
graph. ADMIN is the club-wide override.
|
||||
|
||||
Two axes, deliberately separate:
|
||||
|
||||
* **Authority** (``teams_managed_by``, ``can_edit_event``) requires a
|
||||
*management* position, and only for the **current season** — a StaffAssignment
|
||||
is per-season, so a former coach's authority expires with it. (A ``ClubRole``,
|
||||
by contrast, is permanent and survives a lapsed membership.)
|
||||
* **Visibility** (``teams_staffed_by`` → ``members_visible_to``) covers *any*
|
||||
staff position, so support staff (physio, kit manager) can see the roster they
|
||||
work with without gaining any authority over it.
|
||||
"""
|
||||
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.utils import timezone
|
||||
|
||||
from authentication.models import User
|
||||
from club.models import Club, ClubRole, Season
|
||||
from events.models import Event
|
||||
from members.models import FamilyMembership, Member
|
||||
from teams.models import StaffAssignment, Team
|
||||
|
||||
#: Derived (never stored) roles.
|
||||
COACH = "coach"
|
||||
MANAGER = "manager"
|
||||
COACH_MANAGER = "coach_manager"
|
||||
|
||||
|
||||
def current_season(club: Club) -> Season | None:
|
||||
"""The club's season covering today. Staff authority is scoped to it."""
|
||||
return Season.covering(club, timezone.localdate())
|
||||
|
||||
|
||||
def event_season(event: Event) -> Season | None:
|
||||
"""The season an event belongs to (explicit, else derived from its start)."""
|
||||
return event.season or Season.covering(event.club, event.start.date())
|
||||
|
||||
|
||||
def has_club_role(user: User, club: Club, role: ClubRole.Roles) -> bool:
|
||||
return ClubRole.objects.filter(member__user=user, club=club, role=role).exists()
|
||||
|
||||
|
||||
def is_club_admin(user: User, club: Club) -> bool:
|
||||
return has_club_role(user, club, ClubRole.Roles.ADMIN)
|
||||
|
||||
|
||||
def has_management_access(user: User, club: Club) -> bool:
|
||||
"""Anyone with real authority in the club: ADMIN/EDITOR, or *any* current-season
|
||||
staff assignment (coach, team manager, physio, ...).
|
||||
|
||||
Deliberately excludes the plain MEMBER role -- every signed-up player (or club
|
||||
member generally) holds that automatically the moment their ClubMembership goes
|
||||
active (club/signals.py), so it says nothing about whether someone is staff.
|
||||
"""
|
||||
elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)).exists()
|
||||
return elevated or teams_staffed_by(user, club).exists()
|
||||
|
||||
|
||||
def is_coach_manager(user: User, club: Club) -> bool:
|
||||
"""Derived from a current-season StaffAssignment in a *management* position."""
|
||||
return StaffAssignment.objects.filter(
|
||||
member__user=user,
|
||||
team__club=club,
|
||||
position__management_position=True,
|
||||
season=current_season(club),
|
||||
).exists()
|
||||
|
||||
|
||||
def roles_in_club(user: User, club: Club) -> set[str]:
|
||||
"""The user's roles in ``club``, including the derived COACH_MANAGER role."""
|
||||
roles = set(ClubRole.objects.filter(member__user=user, club=club).values_list("role", flat=True))
|
||||
if is_coach_manager(user, club):
|
||||
roles.add(COACH_MANAGER)
|
||||
return roles
|
||||
|
||||
|
||||
def teams_managed_by(user: User, club: Club) -> QuerySet[Team]:
|
||||
"""Teams the user has authority over: all for an ADMIN, else the ones they
|
||||
manage *this season* (management position only)."""
|
||||
if is_club_admin(user, club):
|
||||
return Team.objects.filter(club=club)
|
||||
return Team.objects.filter(
|
||||
club=club,
|
||||
staff_assignments__member__user=user,
|
||||
staff_assignments__position__management_position=True,
|
||||
staff_assignments__season=current_season(club),
|
||||
).distinct()
|
||||
|
||||
|
||||
def teams_staffed_by(user: User, club: Club) -> QuerySet[Team]:
|
||||
"""Teams the user is on the staff of this season, management or not.
|
||||
|
||||
Visibility only — being a team's physio grants sight of the roster, never
|
||||
authority over it.
|
||||
"""
|
||||
return Team.objects.filter(
|
||||
club=club,
|
||||
staff_assignments__member__user=user,
|
||||
staff_assignments__season=current_season(club),
|
||||
).distinct()
|
||||
|
||||
|
||||
def members_visible_to(user: User, club: Club) -> QuerySet[Member]:
|
||||
"""Members the user may see.
|
||||
|
||||
ADMIN: everyone linked to the club (membership, roster, staff or role).
|
||||
Otherwise: themselves, their children, and the current-season players *and*
|
||||
staff of every team they're staffed on.
|
||||
"""
|
||||
if is_club_admin(user, club):
|
||||
return Member.objects.filter(Q(member_of__club=club) | Q(team_memberships__team__club=club) | Q(staff_assignments__team__club=club) | Q(roles__club=club)).distinct()
|
||||
|
||||
me = Member.objects.filter(user=user).first()
|
||||
if me is None:
|
||||
return Member.objects.none()
|
||||
|
||||
children = Member.objects.filter(
|
||||
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
|
||||
family_memberships__family__memberships__member=me,
|
||||
family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
|
||||
)
|
||||
|
||||
season = current_season(club)
|
||||
teams = teams_staffed_by(user, club)
|
||||
roster = Member.objects.filter(Q(team_memberships__team__in=teams, team_memberships__season=season) | Q(staff_assignments__team__in=teams, staff_assignments__season=season))
|
||||
|
||||
visible = {me.pk} | set(children.values_list("pk", flat=True)) | set(roster.values_list("pk", flat=True))
|
||||
return Member.objects.filter(pk__in=visible)
|
||||
|
||||
|
||||
def can_edit_event(user: User, event: Event) -> bool:
|
||||
"""ADMIN/EDITOR in the club, the event's owner, or a manager of one of its
|
||||
teams *for that event's season*."""
|
||||
club = event.club
|
||||
if is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR):
|
||||
return True
|
||||
if event.created_by_id is not None and event.created_by.user_id == user.pk:
|
||||
return True
|
||||
return StaffAssignment.objects.filter(
|
||||
member__user=user,
|
||||
team__in=event.teams.all(),
|
||||
position__management_position=True,
|
||||
season=event_season(event),
|
||||
).exists()
|
||||
|
||||
|
||||
def can_manage_shop(user: User, club: Club) -> bool:
|
||||
return is_club_admin(user, club)
|
||||
|
||||
|
||||
def can_add_news(user: User, club: Club) -> bool:
|
||||
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
|
||||
author club content, not just anyone on staff (a physio shouldn't post news)."""
|
||||
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR) or is_coach_manager(user, club)
|
||||
|
||||
|
||||
def can_publish_news(user: User, club: Club) -> bool:
|
||||
"""Only ADMIN/EDITOR may push a news item live -- the release-flow gate."""
|
||||
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR)
|
||||
|
||||
|
||||
def can_edit_news(user: User, news_item) -> bool:
|
||||
"""Broad while it's a draft (anyone who could create one); editor/admin-only
|
||||
once published -- an editor is accountable for what's actually live."""
|
||||
if news_item.status == news_item.Status.PUBLISHED:
|
||||
return can_publish_news(user, news_item.club)
|
||||
return can_add_news(user, news_item.club)
|
||||
69
club/services/fees.py
Normal file
69
club/services/fees.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Recording money received against a membership's fee.
|
||||
|
||||
Mirrors billing.services.dues.record_payment for a different kind of money: a
|
||||
member's own club fee, not the club's platform subscription. amount_paid is kept in
|
||||
step here, never recomputed by re-aggregating FeePayment on every read.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import F
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import ClubMembership, FeePayment
|
||||
|
||||
|
||||
def remaining_balance(membership):
|
||||
return max(membership.fee_amount - membership.amount_paid, Decimal("0.00"))
|
||||
|
||||
|
||||
def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
|
||||
"""Record money received against one membership's fee. Several payments may
|
||||
land on one membership -- a family paying in two installments must not read as
|
||||
unpaid. Updates amount_paid and re-syncs fee_status/status to match."""
|
||||
payment = FeePayment.objects.create(membership=membership, amount=amount, method=method, reference=reference, note=note, recorded_by=recorded_by)
|
||||
|
||||
membership.amount_paid = F("amount_paid") + amount
|
||||
membership.save(update_fields=["amount_paid"])
|
||||
membership.refresh_from_db(fields=["amount_paid"])
|
||||
_sync_fee_status(membership)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
def mark_as_paid(membership, *, recorded_by=None):
|
||||
"""The "settle this one" action behind both the per-row and bulk buttons. If
|
||||
there's a real remaining balance, records it as a payment (auditable, shows up
|
||||
in history); if fee_amount was never priced (remaining is 0), just flips the
|
||||
flags directly -- there's no real transaction to log."""
|
||||
remaining = remaining_balance(membership)
|
||||
if remaining > 0:
|
||||
record_payment(membership, amount=remaining, method=FeePayment.Method.OTHER, note="Marked as paid", recorded_by=recorded_by)
|
||||
else:
|
||||
_sync_fee_status(membership, force_paid=True)
|
||||
|
||||
|
||||
def _sync_fee_status(membership, *, force_paid=False):
|
||||
if membership.fee_status == ClubMembership.FeeStatus.WAIVED:
|
||||
return # manual, independent of payments -- this never overrides it
|
||||
|
||||
if force_paid or (membership.fee_amount > 0 and membership.amount_paid >= membership.fee_amount):
|
||||
new_status = ClubMembership.FeeStatus.PAID
|
||||
elif membership.amount_paid > 0:
|
||||
new_status = ClubMembership.FeeStatus.PARTIALLY_PAID
|
||||
else:
|
||||
new_status = ClubMembership.FeeStatus.UNPAID
|
||||
|
||||
membership.fee_status = new_status
|
||||
update_fields = ["fee_status"]
|
||||
|
||||
# Same "become a full member" behavior the bulk action already had: settling
|
||||
# the fee in full also activates the membership, once, first time only.
|
||||
if new_status == ClubMembership.FeeStatus.PAID:
|
||||
membership.status = ClubMembership.StatusChoices.ACTIVE
|
||||
update_fields.append("status")
|
||||
if membership.activated_at is None:
|
||||
membership.activated_at = timezone.localdate()
|
||||
update_fields.append("activated_at")
|
||||
|
||||
membership.save(update_fields=update_fields)
|
||||
104
club/services/seasons.py
Normal file
104
club/services/seasons.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Generating a club's seasons ahead of time.
|
||||
|
||||
Season (club/models.py) has no stored notion of "when a season starts" -- that
|
||||
lives on Club instead (season_start, season_duration_months), since different
|
||||
clubs run their year on different cycles. Same shape as
|
||||
events/services/recurrence.py's generate_occurrences: materialise missing rows
|
||||
up to a horizon, get_or_create per row, safe to call repeatedly.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.db.models import ProtectedError
|
||||
from django.db.models.deletion import Collector
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Season
|
||||
|
||||
|
||||
def _initial_season_start(club, today):
|
||||
"""The most recent occurrence of the club's configured season_start that is
|
||||
not later than ``today`` -- so a club with no seasons yet gets one covering
|
||||
"now" (or the most recently completed one), not an arbitrary future year."""
|
||||
anchor = club.season_start
|
||||
start = datetime.date(today.year, anchor.month, anchor.day)
|
||||
if start > today:
|
||||
start = datetime.date(today.year - 1, anchor.month, anchor.day)
|
||||
return start
|
||||
|
||||
|
||||
def _season_end(start, club):
|
||||
return start + relativedelta(months=club.season_duration_months) - datetime.timedelta(days=1)
|
||||
|
||||
|
||||
def generate_seasons(club, until):
|
||||
"""Materialise seasons for ``club`` from wherever it last left off -- the day
|
||||
after its latest season's end_date, or its configured season_start if it has
|
||||
none yet -- through ``until``. get_or_create per row (matches the
|
||||
unique_season_dates_per_club constraint exactly), safe to call repeatedly.
|
||||
"""
|
||||
latest = Season.objects.filter(club=club).order_by("-end_date").first()
|
||||
start = latest.end_date + datetime.timedelta(days=1) if latest else _initial_season_start(club, timezone.localdate())
|
||||
|
||||
created = []
|
||||
while start <= until:
|
||||
end = _season_end(start, club)
|
||||
season, was_created = Season.objects.get_or_create(club=club, start_date=start, end_date=end)
|
||||
if was_created:
|
||||
created.append(season)
|
||||
start = end + datetime.timedelta(days=1)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def _expected_season_dates(club, until):
|
||||
"""The (start_date, end_date) pairs generate_seasons would produce for
|
||||
``club`` from scratch, ignoring whatever already exists -- used by
|
||||
resync_seasons to tell "matches the club's current settings" from "doesn't"."""
|
||||
start = _initial_season_start(club, timezone.localdate())
|
||||
expected = set()
|
||||
while start <= until:
|
||||
end = _season_end(start, club)
|
||||
expected.add((start, end))
|
||||
start = end + datetime.timedelta(days=1)
|
||||
return expected
|
||||
|
||||
|
||||
def _is_referenced(season):
|
||||
"""Whether deleting ``season`` would hit a PROTECT on any of its relations
|
||||
(ClubMembership, StaffAssignment, TeamMembership, Event, ...) without
|
||||
actually deleting anything."""
|
||||
collector = Collector(using=season._state.db)
|
||||
try:
|
||||
collector.collect([season])
|
||||
except ProtectedError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resync_seasons(club, until, *, commit=False):
|
||||
"""Find seasons for ``club`` that don't match what its *current*
|
||||
season_start/season_duration_months would produce (e.g. left over from a
|
||||
different rule, or from before those settings were changed), within the
|
||||
same horizon generate_seasons would cover.
|
||||
|
||||
A season is only ever removed if nothing references it through a PROTECTed
|
||||
relation -- a season already in use is reported as kept, never silently
|
||||
dropped. With commit=False (the default) nothing is deleted; the caller
|
||||
gets back what *would* happen.
|
||||
"""
|
||||
expected = _expected_season_dates(club, until)
|
||||
|
||||
removed, kept = [], []
|
||||
for season in Season.objects.filter(club=club):
|
||||
if (season.start_date, season.end_date) in expected:
|
||||
continue
|
||||
if _is_referenced(season):
|
||||
kept.append(season)
|
||||
else:
|
||||
removed.append(season)
|
||||
if commit:
|
||||
season.delete()
|
||||
|
||||
return removed, kept
|
||||
39
club/signals.py
Normal file
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
|
||||
1517
club/tests.py
Normal file
1517
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user