diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..3ba24ff Binary files /dev/null and b/.DS_Store differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..54cdb12 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.venv +node_modules +staticfiles +media +db.sqlite3 +.env +*.pyc +__pycache__ +.coverage +.idea +.ruff_cache +ARCHITECTURE.pdf diff --git a/.env.compose.example b/.env.compose.example new file mode 100644 index 0000000..247d84c --- /dev/null +++ b/.env.compose.example @@ -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= diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..746e21e --- /dev/null +++ b/.env.example @@ -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 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..5a86d9b --- /dev/null +++ b/.env.production.example @@ -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= diff --git a/.gitignore b/.gitignore index dc2ed57..b987436 100644 --- a/.gitignore +++ b/.gitignore @@ -376,4 +376,7 @@ poetry.toml # LSP config files pyrightconfig.json -# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,django% \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,django% +# Node +node_modules/ +staticfiles/ diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..e3a051f --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/db.sqlite3 + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..00405d5 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index 12f5d46..34d3806 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,7 +2,7 @@ - + \ No newline at end of file diff --git a/.idea/pyLspTools.xml b/.idea/pyLspTools.xml index 0a5598d..f39483f 100644 --- a/.idea/pyLspTools.xml +++ b/.idea/pyLspTools.xml @@ -1,5 +1,18 @@ + + + diff --git a/.idea/pySourceRootDetection.xml b/.idea/pySourceRootDetection.xml new file mode 100644 index 0000000..089a2ac --- /dev/null +++ b/.idea/pySourceRootDetection.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/ClubManager.iml b/.idea/rosterchief.iml similarity index 67% rename from .idea/ClubManager.iml rename to .idea/rosterchief.iml index cf6280b..f20c0e6 100644 --- a/.idea/ClubManager.iml +++ b/.idea/rosterchief.iml @@ -1,10 +1,10 @@ - + + + + + + - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c76be18 --- /dev/null +++ b/ARCHITECTURE.md @@ -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//` 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//`); 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//`. + 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//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**. diff --git a/ARCHITECTURE.pdf b/ARCHITECTURE.pdf new file mode 100644 index 0000000..c9c9ae1 Binary files /dev/null and b/ARCHITECTURE.pdf differ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f63f0b4 --- /dev/null +++ b/CLAUDE.md @@ -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 # one app +uv run python manage.py test .tests. # one TestCase +uv run python manage.py test .tests.. # 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 `"||<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. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..eda23d4 --- /dev/null +++ b/DEPLOYMENT.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..86771e5 --- /dev/null +++ b/Dockerfile @@ -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", "-"] diff --git a/clubmanager/__init__.py b/api/__init__.py similarity index 100% rename from clubmanager/__init__.py rename to api/__init__.py diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..e1d9dc1 --- /dev/null +++ b/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = "api" + verbose_name = "Public API" diff --git a/api/errors.py b/api/errors.py new file mode 100644 index 0000000..ddd37bc --- /dev/null +++ b/api/errors.py @@ -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 diff --git a/api/middleware.py b/api/middleware.py new file mode 100644 index 0000000..34ef327 --- /dev/null +++ b/api/middleware.py @@ -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 diff --git a/api/tests.py b/api/tests.py new file mode 100644 index 0000000..5d37276 --- /dev/null +++ b/api/tests.py @@ -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"]) diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000..f6fc8f6 --- /dev/null +++ b/api/urls.py @@ -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) diff --git a/assets/app.css b/assets/app.css new file mode 100644 index 0000000..224321f --- /dev/null +++ b/assets/app.css @@ -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); +} diff --git a/authentication/__init__.py b/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authentication/adapters.py b/authentication/adapters.py new file mode 100644 index 0000000..d9857e9 --- /dev/null +++ b/authentication/adapters.py @@ -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] diff --git a/authentication/admin.py b/authentication/admin.py new file mode 100644 index 0000000..28b106b --- /dev/null +++ b/authentication/admin.py @@ -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() diff --git a/authentication/apps.py b/authentication/apps.py new file mode 100644 index 0000000..372ba81 --- /dev/null +++ b/authentication/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + name = "authentication" diff --git a/authentication/forms.py b/authentication/forms.py new file mode 100644 index 0000000..b922d84 --- /dev/null +++ b/authentication/forms.py @@ -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__" diff --git a/authentication/managers.py b/authentication/managers.py new file mode 100644 index 0000000..9f49068 --- /dev/null +++ b/authentication/managers.py @@ -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) diff --git a/authentication/middleware.py b/authentication/middleware.py new file mode 100644 index 0000000..8cbe9ac --- /dev/null +++ b/authentication/middleware.py @@ -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) diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..5e98af2 --- /dev/null +++ b/authentication/migrations/0001_initial.py @@ -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')}, + ), + ] diff --git a/authentication/migrations/0002_alter_family_name.py b/authentication/migrations/0002_alter_family_name.py new file mode 100644 index 0000000..ea2c70a --- /dev/null +++ b/authentication/migrations/0002_alter_family_name.py @@ -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), + ), + ] diff --git a/authentication/migrations/0003_alter_family_name_alter_familymembership_family_and_more.py b/authentication/migrations/0003_alter_family_name_alter_familymembership_family_and_more.py new file mode 100644 index 0000000..cbf8e1d --- /dev/null +++ b/authentication/migrations/0003_alter_family_name_alter_familymembership_family_and_more.py @@ -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?'), + ), + ] diff --git a/authentication/migrations/0004_remove_familymembership_family_and_more.py b/authentication/migrations/0004_remove_familymembership_family_and_more.py new file mode 100644 index 0000000..886aa84 --- /dev/null +++ b/authentication/migrations/0004_remove_familymembership_family_and_more.py @@ -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', + ), + ] diff --git a/authentication/migrations/__init__.py b/authentication/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authentication/models.py b/authentication/models.py new file mode 100644 index 0000000..f4d8c83 --- /dev/null +++ b/authentication/models.py @@ -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 diff --git a/authentication/tests.py b/authentication/tests.py new file mode 100644 index 0000000..a3b62d7 --- /dev/null +++ b/authentication/tests.py @@ -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>') diff --git a/billing/__init__.py b/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billing/admin.py b/billing/admin.py new file mode 100644 index 0000000..82be11f --- /dev/null +++ b/billing/admin.py @@ -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"] diff --git a/billing/apps.py b/billing/apps.py new file mode 100644 index 0000000..33b9fa5 --- /dev/null +++ b/billing/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class BillingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "billing" + verbose_name = "Billing" diff --git a/billing/management/__init__.py b/billing/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billing/management/commands/__init__.py b/billing/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billing/management/commands/archive_overdue_clubs.py b/billing/management/commands/archive_overdue_clubs.py new file mode 100644 index 0000000..113c57a --- /dev/null +++ b/billing/management/commands/archive_overdue_clubs.py @@ -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.")) diff --git a/billing/management/commands/renew_subscriptions.py b/billing/management/commands/renew_subscriptions.py new file mode 100644 index 0000000..d642f57 --- /dev/null +++ b/billing/management/commands/renew_subscriptions.py @@ -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)) diff --git a/billing/migrations/0001_initial.py b/billing/migrations/0001_initial.py new file mode 100644 index 0000000..71943f4 --- /dev/null +++ b/billing/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/billing/migrations/0002_subscription_auto_renew.py b/billing/migrations/0002_subscription_auto_renew.py new file mode 100644 index 0000000..4746ac6 --- /dev/null +++ b/billing/migrations/0002_subscription_auto_renew.py @@ -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'), + ), + ] diff --git a/billing/migrations/0003_due_is_trial_subscription_post_trial_tier_and_more.py b/billing/migrations/0003_due_is_trial_subscription_post_trial_tier_and_more.py new file mode 100644 index 0000000..5871aba --- /dev/null +++ b/billing/migrations/0003_due_is_trial_subscription_post_trial_tier_and_more.py @@ -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'), + ), + ] diff --git a/billing/migrations/__init__.py b/billing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billing/models.py b/billing/models.py new file mode 100644 index 0000000..b00866d --- /dev/null +++ b/billing/models.py @@ -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}" diff --git a/billing/services/__init__.py b/billing/services/__init__.py new file mode 100644 index 0000000..b96f3b2 --- /dev/null +++ b/billing/services/__init__.py @@ -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. + """ diff --git a/billing/services/dues.py b/billing/services/dues.py new file mode 100644 index 0000000..d60367e --- /dev/null +++ b/billing/services/dues.py @@ -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) diff --git a/billing/services/invoices.py b/billing/services/invoices.py new file mode 100644 index 0000000..18cde7a --- /dev/null +++ b/billing/services/invoices.py @@ -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) diff --git a/billing/templates/billing/invoice.html b/billing/templates/billing/invoice.html new file mode 100644 index 0000000..e4f98fe --- /dev/null +++ b/billing/templates/billing/invoice.html @@ -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 }} + + + +
+
+

RosterChief

+
Club & team management
+
+
+

Invoice

+
{{ invoice.number }}
+
Issued {{ invoice.issued_at|date:"j F Y" }}
+
+
+ +
+
+

Billed to

+
{{ club.name }}
+
{{ club.slug }}.rosterchief.app
+
+
+

Period

+
{{ due.period_start|date:"j F Y" }} — {{ due.period_end|date:"j F Y" }}
+
Payable by {{ due.grace_until|date:"j F Y" }}
+
+
+ + + + + + + + + + + + + + {% for payment in payments %} + + + + + {% endfor %} + + + + + +
DescriptionAmount
+ {{ due.tier.name }} — platform subscription +
{{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }}
+
€{{ due.amount|floatformat:2 }}
+ Payment received {{ payment.paid_at|date:"j M Y" }} ({{ payment.get_method_display }}{% if payment.reference %}, {{ payment.reference }}{% endif %}) + −€{{ payment.amount|floatformat:2 }}
Balance due€{{ due.balance|floatformat:2 }}
+ + {% if due.status == "paid" %} + + {% elif due.status == "waived" %} +

Waived. Nothing is owed for this period.

+ {% else %} +

+ Payable by {{ due.grace_until|date:"j F Y" }}. Unpaid past that date the club is archived: its + subdomain stops resolving, though nothing is deleted. +

+ {% endif %} + + diff --git a/billing/tests.py b/billing/tests.py new file mode 100644 index 0000000..b00d53f --- /dev/null +++ b/billing/tests.py @@ -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("

hi

"), 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("

hi

") + + 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)]) diff --git a/club/__init__.py b/club/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/club/admin.py b/club/admin.py new file mode 100644 index 0000000..6579f44 --- /dev/null +++ b/club/admin.py @@ -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"] diff --git a/club/api.py b/club/api.py new file mode 100644 index 0000000..71e2e28 --- /dev/null +++ b/club/api.py @@ -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] diff --git a/club/apps.py b/club/apps.py new file mode 100644 index 0000000..48e3b96 --- /dev/null +++ b/club/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class ClubConfig(AppConfig): + name = "club" + + def ready(self): + from . import signals # noqa: F401 diff --git a/club/context_processors.py b/club/context_processors.py new file mode 100644 index 0000000..8d2f0bc --- /dev/null +++ b/club/context_processors.py @@ -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, + } diff --git a/club/management/__init__.py b/club/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/club/management/commands/__init__.py b/club/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/club/management/commands/generate_seasons.py b/club/management/commands/generate_seasons.py new file mode 100644 index 0000000..0f679db --- /dev/null +++ b/club/management/commands/generate_seasons.py @@ -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).")) diff --git a/club/migrations/0001_initial.py b/club/migrations/0001_initial.py new file mode 100644 index 0000000..139ad1e --- /dev/null +++ b/club/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/club/migrations/0002_clubmembership_club_clubmembership_license_and_more.py b/club/migrations/0002_clubmembership_club_clubmembership_license_and_more.py new file mode 100644 index 0000000..cb993cf --- /dev/null +++ b/club/migrations/0002_clubmembership_club_clubmembership_license_and_more.py @@ -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, + ), + ] diff --git a/club/migrations/0003_alter_clubmembership_options_alter_club_name_and_more.py b/club/migrations/0003_alter_clubmembership_options_alter_club_name_and_more.py new file mode 100644 index 0000000..d909263 --- /dev/null +++ b/club/migrations/0003_alter_clubmembership_options_alter_club_name_and_more.py @@ -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')}, + ), + ] diff --git a/club/migrations/0004_alter_clubmembership_license.py b/club/migrations/0004_alter_clubmembership_license.py new file mode 100644 index 0000000..87ffff5 --- /dev/null +++ b/club/migrations/0004_alter_clubmembership_license.py @@ -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'), + ), + ] diff --git a/club/migrations/0005_alter_clubmembership_member.py b/club/migrations/0005_alter_clubmembership_member.py new file mode 100644 index 0000000..34d2657 --- /dev/null +++ b/club/migrations/0005_alter_clubmembership_member.py @@ -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'), + ), + ] diff --git a/club/migrations/0006_club_slug_season.py b/club/migrations/0006_club_slug_season.py new file mode 100644 index 0000000..ddb6cdb --- /dev/null +++ b/club/migrations/0006_club_slug_season.py @@ -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', + }, + ), + ] diff --git a/club/migrations/0007_clubmembership_season_alter_clubmembership_club.py b/club/migrations/0007_clubmembership_season_alter_clubmembership_club.py new file mode 100644 index 0000000..425d503 --- /dev/null +++ b/club/migrations/0007_clubmembership_season_alter_clubmembership_club.py @@ -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'), + ), + ] diff --git a/club/migrations/0008_alter_clubmembership_unique_together_and_more.py b/club/migrations/0008_alter_clubmembership_unique_together_and_more.py new file mode 100644 index 0000000..24e8130 --- /dev/null +++ b/club/migrations/0008_alter_clubmembership_unique_together_and_more.py @@ -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'), + ), + ] diff --git a/club/migrations/0009_clubrole.py b/club/migrations/0009_clubrole.py new file mode 100644 index 0000000..1c1d660 --- /dev/null +++ b/club/migrations/0009_clubrole.py @@ -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')], + }, + ), + ] diff --git a/club/migrations/0010_club_archived_at.py b/club/migrations/0010_club_archived_at.py new file mode 100644 index 0000000..a07d5ec --- /dev/null +++ b/club/migrations/0010_club_archived_at.py @@ -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'), + ), + ] diff --git a/club/migrations/0011_alter_club_slug.py b/club/migrations/0011_alter_club_slug.py new file mode 100644 index 0000000..f00b4e8 --- /dev/null +++ b/club/migrations/0011_alter_club_slug.py @@ -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'), + ), + ] diff --git a/club/migrations/0012_club_logo_club_primary_color.py b/club/migrations/0012_club_logo_club_primary_color.py new file mode 100644 index 0000000..90a7b79 --- /dev/null +++ b/club/migrations/0012_club_logo_club_primary_color.py @@ -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'), + ), + ] diff --git a/club/migrations/0013_club_created_club_modified_clubmembership_created_and_more.py b/club/migrations/0013_club_created_club_modified_clubmembership_created_and_more.py new file mode 100644 index 0000000..fcc113a --- /dev/null +++ b/club/migrations/0013_club_created_club_modified_clubmembership_created_and_more.py @@ -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'), + ), + ] diff --git a/club/migrations/0014_club_secondary_color.py b/club/migrations/0014_club_secondary_color.py new file mode 100644 index 0000000..8829a7b --- /dev/null +++ b/club/migrations/0014_club_secondary_color.py @@ -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'), + ), + ] diff --git a/club/migrations/0015_alter_club_logo.py b/club/migrations/0015_alter_club_logo.py new file mode 100644 index 0000000..8c68c38 --- /dev/null +++ b/club/migrations/0015_alter_club_logo.py @@ -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'), + ), + ] diff --git a/club/migrations/0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more.py b/club/migrations/0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more.py new file mode 100644 index 0000000..eb2056a --- /dev/null +++ b/club/migrations/0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more.py @@ -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'], + }, + ), + ] diff --git a/club/migrations/0017_club_season_duration_months_club_season_start.py b/club/migrations/0017_club_season_duration_months_club_season_start.py new file mode 100644 index 0000000..3f17ec9 --- /dev/null +++ b/club/migrations/0017_club_season_duration_months_club_season_start.py @@ -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'), + ), + ] diff --git a/club/migrations/0018_club_sport_type.py b/club/migrations/0018_club_sport_type.py new file mode 100644 index 0000000..8be9640 --- /dev/null +++ b/club/migrations/0018_club_sport_type.py @@ -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'), + ), + ] diff --git a/club/migrations/0019_sponsor.py b/club/migrations/0019_sponsor.py new file mode 100644 index 0000000..d5191f7 --- /dev/null +++ b/club/migrations/0019_sponsor.py @@ -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'], + }, + ), + ] diff --git a/club/migrations/__init__.py b/club/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/club/mixins.py b/club/mixins.py new file mode 100644 index 0000000..6e83a16 --- /dev/null +++ b/club/mixins.py @@ -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()) diff --git a/club/models.py b/club/models.py new file mode 100644 index 0000000..4123466 --- /dev/null +++ b/club/models.py @@ -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}" diff --git a/club/services/__init__.py b/club/services/__init__.py new file mode 100644 index 0000000..0c594c5 --- /dev/null +++ b/club/services/__init__.py @@ -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", +] diff --git a/club/services/access.py b/club/services/access.py new file mode 100644 index 0000000..c5e68b6 --- /dev/null +++ b/club/services/access.py @@ -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) diff --git a/club/services/fees.py b/club/services/fees.py new file mode 100644 index 0000000..5974410 --- /dev/null +++ b/club/services/fees.py @@ -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) diff --git a/club/services/seasons.py b/club/services/seasons.py new file mode 100644 index 0000000..fb31fa1 --- /dev/null +++ b/club/services/seasons.py @@ -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 diff --git a/club/signals.py b/club/signals.py new file mode 100644 index 0000000..6a142d6 --- /dev/null +++ b/club/signals.py @@ -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() diff --git a/club/templates/club/home.html b/club/templates/club/home.html new file mode 100644 index 0000000..f6ce595 --- /dev/null +++ b/club/templates/club/home.html @@ -0,0 +1,18 @@ +{% extends "_club_base.html" %} +{% load lucide %} + +{% block head_title %}Home{% endblock head_title %} + +{% block main %} +
+
+
+

{% lucide "party-popper" size=20 %} Welcome to {{ club.name }}

+

+ You are signed in as {{ user.get_full_name|default:user.email }}. +

+

The club site lands here. For now this page exists so signing in has somewhere to go.

+
+
+
+{% endblock main %} diff --git a/club/tenancy.py b/club/tenancy.py new file mode 100644 index 0000000..94f0e0b --- /dev/null +++ b/club/tenancy.py @@ -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 diff --git a/club/tests.py b/club/tests.py new file mode 100644 index 0000000..e2c4a8e --- /dev/null +++ b/club/tests.py @@ -0,0 +1,1517 @@ +import datetime +import uuid +from contextlib import contextmanager +from decimal import Decimal +from io import StringIO + +from allauth.mfa.models import Authenticator +from dateutil.relativedelta import relativedelta +from django.contrib import admin as django_admin +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.core.management import call_command +from django.db import IntegrityError +from django.db.models import ProtectedError +from django.test import RequestFactory, TestCase, override_settings +from django.urls import reverse +from django.utils import timezone + +from events.models import Event +from members.models import Family, FamilyMembership, Member +from teams.models import Position, StaffAssignment, Team, TeamMembership + +from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor, club_logo_path +from .services.access import ( + COACH_MANAGER, + can_edit_event, + can_manage_shop, + has_club_role, + members_visible_to, + roles_in_club, + teams_managed_by, + teams_staffed_by, +) +from .services.fees import mark_as_paid, record_payment, remaining_balance +from .services.seasons import _initial_season_start, _season_end, generate_seasons, resync_seasons +from .tenancy import ( + ClubTenantMiddleware, + get_current_club, + require_current_club, + reset_current_club, + set_current_club, +) + + +@contextmanager +def with_club(club): + """Bind ``club`` as the active tenant for the duration of the block.""" + token = set_current_club(club) + try: + yield club + finally: + reset_current_club(token) + + +class ClubModelTests(TestCase): + def test_str_returns_name(self): + club = Club.objects.create(name="City Swim Club") + + self.assertEqual(str(club), "City Swim Club") + + def test_pk_is_uuid(self): + club = Club.objects.create(name="City Swim Club") + + self.assertIsInstance(club.pk, uuid.UUID) + + def test_clubs_are_ordered_by_name(self): + Club.objects.create(name="Zulu Club") + Club.objects.create(name="Alpha Club") + Club.objects.create(name="Middle Club") + + self.assertEqual( + list(Club.objects.values_list("name", flat=True)), + ["Alpha Club", "Middle Club", "Zulu Club"], + ) + + def test_verbose_names(self): + self.assertEqual(Club._meta.verbose_name, "club") + self.assertEqual(Club._meta.verbose_name_plural, "clubs") + + +def make_season(club, start_year=2026): + return Season.objects.create( + club=club, + start_date=datetime.date(start_year, 8, 1), + end_date=datetime.date(start_year + 1, 5, 31), + ) + + +class ClubMembershipModelTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="City Swim Club") + self.season = make_season(self.club) + self.member = Member.objects.create( + first_name="Jane", + last_name="Doe", + email="jane@example.com", + ) + + def test_str_returns_club_and_member(self): + membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member, license="LIC-001") + + self.assertEqual(str(membership), "City Swim Club - Jane Doe") + + def test_defaults_are_pending_and_unpaid(self): + membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) + + self.assertEqual(membership.status, ClubMembership.StatusChoices.PENDING) + self.assertEqual(membership.fee_status, ClubMembership.FeeStatus.UNPAID) + self.assertEqual(membership.license, "") + self.assertIsNone(membership.signed_up_at) + + def test_pk_is_uuid(self): + membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) + + self.assertIsInstance(membership.pk, uuid.UUID) + + def test_club_is_filled_from_active_tenant(self): + with with_club(self.club): + membership = ClubMembership.objects.create(season=self.season, member=self.member) + + self.assertEqual(membership.club, self.club) + + def test_member_is_unique_per_club_and_season(self): + ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) + + with self.assertRaises(IntegrityError): + ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) + + def test_same_member_can_join_consecutive_seasons(self): + next_season = make_season(self.club, start_year=2027) + + first = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) + second = ClubMembership.objects.create(club=self.club, season=next_season, member=self.member) + + self.assertEqual(self.member.member_of.count(), 2) + self.assertNotEqual(first.season, second.season) + + def test_same_member_can_join_different_clubs(self): + other_club = Club.objects.create(name="Other Swim Club") + other_season = make_season(other_club) + + ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) + ClubMembership.objects.create(club=other_club, season=other_season, member=self.member) + + self.assertEqual(self.member.member_of.count(), 2) + + def test_deleting_club_is_blocked_while_a_season_has_memberships(self): + # Club -> Season is CASCADE, but ClubMembership -> Season is PROTECT, so + # the club can't be deleted while one of its seasons is still referenced. + ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) + + with self.assertRaises(ProtectedError): + self.club.delete() + + self.assertTrue(ClubMembership.objects.exists()) + + def test_deleting_empty_club_cascades_to_its_seasons(self): + self.club.delete() + + self.assertFalse(Club.objects.filter(pk=self.club.pk).exists()) + self.assertFalse(Season.objects.filter(pk=self.season.pk).exists()) + + def test_deleting_member_deletes_membership_but_keeps_club(self): + ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) + + self.member.delete() + + self.assertFalse(ClubMembership.objects.exists()) + self.assertTrue(Club.objects.filter(pk=self.club.pk).exists()) + + def test_season_is_protected_while_referenced(self): + ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) + + with self.assertRaises(ProtectedError): + self.season.delete() + + def test_memberships_are_ordered_by_club_then_member_name(self): + alpha_club = Club.objects.create(name="Alpha Club") + zulu_club = Club.objects.create(name="Zulu Club") + alpha_season = make_season(alpha_club) + zulu_season = make_season(zulu_club) + + jane = Member.objects.create(first_name="Jane", last_name="Doe") + alice = Member.objects.create(first_name="Alice", last_name="Smith") + bob = Member.objects.create(first_name="Bob", last_name="Smith") + + ClubMembership.objects.create(club=zulu_club, season=zulu_season, member=bob) + ClubMembership.objects.create(club=alpha_club, season=alpha_season, member=bob) + ClubMembership.objects.create(club=alpha_club, season=alpha_season, member=alice) + ClubMembership.objects.create(club=alpha_club, season=alpha_season, member=jane) + + self.assertEqual( + [(membership.club.name, membership.member.last_name, membership.member.first_name) for membership in ClubMembership.objects.all()], + [ + ("Alpha Club", "Doe", "Jane"), + ("Alpha Club", "Smith", "Alice"), + ("Alpha Club", "Smith", "Bob"), + ("Zulu Club", "Smith", "Bob"), + ], + ) + + def test_reverse_relations(self): + membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) + + self.assertEqual(list(self.club.clubmemberships.all()), [membership]) + self.assertEqual(list(self.member.member_of.all()), [membership]) + self.assertEqual(list(self.season.memberships.all()), [membership]) + + def test_verbose_names(self): + self.assertEqual(ClubMembership._meta.verbose_name, "club membership") + self.assertEqual(ClubMembership._meta.verbose_name_plural, "club memberships") + + +class ClubSlugTests(TestCase): + def test_slug_is_derived_from_name(self): + club = Club.objects.create(name="City Swim Club") + + self.assertEqual(club.slug, "city-swim-club") + + def test_explicit_slug_is_kept(self): + club = Club.objects.create(name="City Swim Club", slug="ajax-united") + + self.assertEqual(club.slug, "ajax-united") + + def test_derived_slugs_are_made_unique(self): + first = Club.objects.create(name="City Swim Club") + second = Club.objects.create(name="City Swim Club") + + self.assertEqual(first.slug, "city-swim-club") + self.assertEqual(second.slug, "city-swim-club-2") + + def test_slug_is_unique(self): + Club.objects.create(name="First", slug="shared") + + with self.assertRaises(IntegrityError): + Club.objects.create(name="Second", slug="shared") + + +@override_settings( + ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", + ALLOWED_HOSTS=[".rosterchief.app", ".example.com", ".example.org"], +) +class ClubTenantMiddlewareTests(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.captured = {} + self.middleware = ClubTenantMiddleware(self._capture) + + def _capture(self, request): + # Runs inside the middleware, while the context var is set. + self.captured["context_club"] = get_current_club() + return "response" + + def _run(self, host): + request = self.factory.get("/", HTTP_HOST=host) + response = self.middleware(request) + return request, response + + def test_subdomain_resolves_to_club(self): + request, response = self._run("ajax-united.rosterchief.app") + + self.assertEqual(response, "response") + self.assertEqual(request.club, self.club) + self.assertEqual(self.captured["context_club"], self.club) + + def test_subdomain_resolution_ignores_port(self): + request, _ = self._run("ajax-united.rosterchief.app:8000") + + self.assertEqual(request.club, self.club) + + def test_unknown_subdomain_sets_none(self): + request, _ = self._run("unknown-club.rosterchief.app") + + self.assertIsNone(request.club) + + def test_bare_base_domain_has_no_club(self): + request, _ = self._run("rosterchief.app") + + self.assertIsNone(request.club) + + def test_www_is_treated_as_no_club(self): + request, _ = self._run("www.rosterchief.app") + + self.assertIsNone(request.club) + + def test_foreign_domain_has_no_club(self): + request, _ = self._run("ajax-united.example.org") + + self.assertIsNone(request.club) + + def test_context_var_is_reset_after_request(self): + self._run("ajax-united.rosterchief.app") + + self.assertIsNone(get_current_club()) + + @override_settings(ROSTERCHIEF_BASE_DOMAIN="") + def test_generic_host_resolution_without_base_domain(self): + request, _ = self._run("ajax-united.example.com") + + self.assertEqual(request.club, self.club) + + @override_settings(ROSTERCHIEF_BASE_DOMAIN="") + def test_two_label_host_has_no_club_without_base_domain(self): + request, _ = self._run("example.com") + + self.assertIsNone(request.club) + + +class _FakeRequest: + def __init__(self, host): + self._host = host + + def get_host(self): + return self._host + + +@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app") +class GetSubdomainTests(TestCase): + def subdomain(self, host): + return ClubTenantMiddleware.get_subdomain(_FakeRequest(host)) + + def test_empty_host_returns_none(self): + self.assertIsNone(self.subdomain("")) + + def test_trailing_dot_is_stripped(self): + self.assertEqual(self.subdomain("ajax-united.rosterchief.app."), "ajax-united") + + def test_nested_subdomain_uses_leftmost_label(self): + self.assertEqual(self.subdomain("a.b.rosterchief.app"), "a") + + +class TenantContextTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + + def test_require_current_club_returns_active_club(self): + with with_club(self.club): + self.assertEqual(require_current_club(), self.club) + + def test_require_current_club_raises_without_context(self): + with self.assertRaises(RuntimeError): + require_current_club() + + def test_club_manager_current_returns_active_club(self): + with with_club(self.club): + self.assertEqual(Club.objects.current(), self.club) + + def test_club_manager_current_is_none_without_context(self): + self.assertIsNone(Club.objects.current()) + + +class TenantScopedModelTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.other = Club.objects.create(name="Rival FC", slug="rival-fc") + self.dates = { + "start_date": datetime.date(2026, 8, 1), + "end_date": datetime.date(2027, 5, 31), + } + + def test_save_keeps_explicit_club(self): + season = Season.objects.create(club=self.club, **self.dates) + + self.assertEqual(season.club, self.club) + + def test_save_fills_club_from_context(self): + with with_club(self.club): + season = Season.objects.create(**self.dates) + + self.assertEqual(season.club, self.club) + + def test_save_without_club_or_context_raises(self): + with self.assertRaises(RuntimeError): + Season.objects.create(**self.dates) + + def test_for_club_filters_by_club(self): + mine = Season.objects.create(club=self.club, **self.dates) + Season.objects.create(club=self.other, **self.dates) + + self.assertEqual(list(Season.objects.for_club(self.club)), [mine]) + + def test_current_club_filters_by_active_club(self): + mine = Season.objects.create(club=self.club, **self.dates) + Season.objects.create(club=self.other, **self.dates) + + with with_club(self.club): + self.assertEqual(list(Season.objects.current_club()), [mine]) + + def test_name_is_two_digit_year_range(self): + season = Season.objects.create(club=self.club, **self.dates) + + self.assertEqual(season.name, "26-27") + + def test_name_zero_pads_years(self): + season = Season.objects.create( + club=self.club, + start_date=datetime.date(2008, 8, 1), + end_date=datetime.date(2009, 5, 31), + ) + + self.assertEqual(season.name, "08-09") + + def test_str_is_the_name(self): + season = Season.objects.create(club=self.club, **self.dates) + + self.assertEqual(str(season), "26-27") + + +class SeasonGetCurrentTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.other = Club.objects.create(name="Rival FC", slug="rival-fc") + self.season = Season.objects.create( + club=self.club, + start_date=datetime.date(2026, 8, 1), + end_date=datetime.date(2027, 5, 31), + ) + + def test_returns_season_covering_the_given_date(self): + with with_club(self.club): + found = Season.get_current(datetime.date(2026, 12, 25)) + + self.assertEqual(found, self.season) + + def test_includes_boundary_dates(self): + with with_club(self.club): + self.assertEqual(Season.get_current(datetime.date(2026, 8, 1)), self.season) + self.assertEqual(Season.get_current(datetime.date(2027, 5, 31)), self.season) + + def test_returns_none_when_no_season_covers_the_date(self): + with with_club(self.club): + self.assertIsNone(Season.get_current(datetime.date(2027, 7, 1))) + + def test_is_scoped_to_the_active_club(self): + # The other club's season covers the same date but must not leak. + Season.objects.create( + club=self.other, + start_date=datetime.date(2026, 8, 1), + end_date=datetime.date(2027, 5, 31), + ) + + with with_club(self.other): + found = Season.get_current(datetime.date(2026, 12, 25)) + + self.assertEqual(found.club, self.other) + + def test_defaults_to_today(self): + # self.other, not self.club -- setUp's self.season (2026-08-01 to 2027-05-31) + # would otherwise also cover "today" once real dates reach that window, + # colliding with the one created here. + today = timezone.now().date() + current = Season.objects.create( + club=self.other, + start_date=today - datetime.timedelta(days=10), + end_date=today + datetime.timedelta(days=10), + ) + + with with_club(self.other): + self.assertEqual(Season.get_current(), current) + + def test_requires_an_active_club(self): + with self.assertRaises(RuntimeError): + Season.get_current(datetime.date(2026, 12, 25)) + + +class SeasonNextAfterTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.current = Season.objects.create(club=self.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31)) + self.next_season = Season.objects.create(club=self.club, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31)) + + def test_returns_the_soonest_season_starting_after_the_date(self): + self.assertEqual(Season.next_after(self.club, datetime.date(2026, 12, 25)), self.next_season) + + def test_returns_none_when_there_is_no_later_season(self): + self.assertIsNone(Season.next_after(self.club, datetime.date(2027, 12, 25))) + + def test_is_scoped_to_the_given_club(self): + other = Club.objects.create(name="Rival FC", slug="rival-fc") + Season.objects.create(club=other, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31)) + + self.assertEqual(Season.next_after(other, datetime.date(2026, 12, 25)).club, other) + + +class SponsorModelTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + + def test_str_returns_name(self): + sponsor = Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1)) + + self.assertEqual(str(sponsor), "Acme Corp") + + def test_a_blank_end_date_is_valid(self): + sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1)) + sponsor.full_clean() + + def test_an_end_date_on_the_same_day_as_start_is_valid(self): + sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1), end_date=datetime.date(2026, 1, 1)) + sponsor.full_clean() + + def test_an_end_date_before_start_is_rejected(self): + sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 6, 1), end_date=datetime.date(2026, 1, 1)) + + with self.assertRaises(ValidationError) as ctx: + sponsor.full_clean() + self.assertIn("end_date", ctx.exception.error_dict) + + def test_sponsors_are_ordered_by_name(self): + Sponsor.objects.create(club=self.club, name="Zulu Corp", start_date=datetime.date(2026, 1, 1)) + Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1)) + + self.assertEqual(list(Sponsor.objects.values_list("name", flat=True)), ["Acme Corp", "Zulu Corp"]) + + +class AdminRegistrationSmokeTests(TestCase): + """Every registered model across all apps must have a working admin: load + each changelist and add page to catch bad list_display / search_fields / + fieldsets / autocomplete targets in any app's admin config.""" + + def setUp(self): + self.admin = get_user_model().objects.create_superuser(email="root@club.test", password="pw-secret-123") + # Staff must hold a second factor (RequireMFAMiddleware), else they are + # redirected to enrolment instead of reaching the admin. + Authenticator.objects.create(user=self.admin, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) + self.client.force_login(self.admin) + + def test_every_model_is_registered_in_admin(self): + from django.apps import apps + + registered = set(django_admin.site._registry) + # Concrete, non-auto-created models in these apps should all be registered. + project_apps = {"authentication", "club", "members", "teams", "events", "formbuilder", "shop"} + for model in apps.get_models(): + if model._meta.app_label not in project_apps or model._meta.auto_created: + continue + with self.subTest(model=model.__name__): + self.assertIn(model, registered, f"{model.__name__} is not registered in the admin") + + def test_all_changelists_load(self): + for model in django_admin.site._registry: + url = reverse(f"admin:{model._meta.app_label}_{model._meta.model_name}_changelist") + with self.subTest(model=model.__name__): + self.assertEqual(self.client.get(url).status_code, 200) + + def test_all_add_pages_load(self): + for model in django_admin.site._registry: + url = reverse(f"admin:{model._meta.app_label}_{model._meta.model_name}_add") + with self.subTest(model=model.__name__): + self.assertEqual(self.client.get(url).status_code, 200) + + +class ClubArchivingTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + + def test_a_new_club_is_active(self): + self.assertFalse(self.club.is_archived) + self.assertIn(self.club, Club.objects.active()) + self.assertNotIn(self.club, Club.objects.archived()) + + def test_archive_and_restore(self): + self.club.archive() + + self.assertTrue(self.club.is_archived) + self.assertIn(self.club, Club.objects.archived()) + self.assertNotIn(self.club, Club.objects.active()) + + self.club.restore() + + self.assertFalse(self.club.is_archived) + self.assertIn(self.club, Club.objects.active()) + + def test_archiving_twice_keeps_the_original_timestamp(self): + self.club.archive() + first = self.club.archived_at + + self.club.archive() + + self.assertEqual(self.club.archived_at, first) + + def test_restoring_an_active_club_is_a_no_op(self): + self.club.restore() + + self.assertFalse(self.club.is_archived) + + def test_archiving_destroys_nothing(self): + season = make_season(self.club) + member = Member.objects.create(first_name="Jane", last_name="Doe") + ClubMembership.objects.create(club=self.club, member=member, season=season) + + self.club.archive() + + self.assertTrue(ClubMembership.objects.filter(club=self.club).exists()) + self.assertTrue(Season.objects.filter(club=self.club).exists()) + + +@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=[".rosterchief.app"]) +class ArchivedClubTenancyTests(TestCase): + """An archived club's subdomain must stop resolving — that is what makes + archiving a real deactivation rather than a cosmetic flag.""" + + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.middleware = ClubTenantMiddleware(lambda request: "response") + + def resolve(self): + request = RequestFactory().get("/", HTTP_HOST="ajax-united.rosterchief.app") + self.middleware(request) + return request.club + + def test_active_club_resolves(self): + self.assertEqual(self.resolve(), self.club) + + def test_archived_club_stops_resolving(self): + self.club.archive() + + self.assertIsNone(self.resolve()) + + def test_restored_club_resolves_again(self): + self.club.archive() + self.club.restore() + + self.assertEqual(self.resolve(), self.club) + + +class ClubRoleTests(TestCase): + def test_str(self): + club = Club.objects.create(name="Ajax United", slug="ajax-united") + member = Member.objects.create(first_name="Jane", last_name="Doe") + role = ClubRole.objects.create(club=club, member=member) + + self.assertEqual(str(role), f"{club} - {member}") + + +class ClubMembershipCleanTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.other = Club.objects.create(name="Rival FC", slug="rival-fc") + today = timezone.localdate() + self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + datetime.timedelta(days=300)) + self.other_season = Season.objects.create(club=self.other, start_date=today, end_date=today + datetime.timedelta(days=300)) + self.member = Member.objects.create(first_name="Jane", last_name="Doe") + + def test_rejects_cross_club_season(self): + membership = ClubMembership(club=self.club, member=self.member, season=self.other_season) + with self.assertRaises(ValidationError) as ctx: + membership.full_clean() + self.assertIn("season", ctx.exception.error_dict) + + def test_accepts_same_club_season(self): + ClubMembership(club=self.club, member=self.member, season=self.season).full_clean() + + +class AccessServiceTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + today = timezone.localdate() + self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + datetime.timedelta(days=300)) + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.second_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW") + # Management staff (coach / team manager) vs. non-management staff (e.g. physio). + self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + self.physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False) + + def make_user_member(self, email): + user = get_user_model().objects.create_user(email=email, password="pw") + member = Member.objects.create(user=user, first_name=email.split("@")[0].title(), last_name="Doe") + return user, member + + def grant(self, member, role): + return ClubRole.objects.create(club=self.club, member=member, role=role) + + def make_coach(self, member, team=None): + return StaffAssignment.objects.create(team=team or self.team, member=member, season=self.season, position=self.coach_position) + + def make_support_staff(self, member, team=None): + """Staff on the team, but in a non-management position.""" + return StaffAssignment.objects.create(team=team or self.team, member=member, season=self.season, position=self.physio_position) + + def make_event(self, **kwargs): + kwargs.setdefault("club", self.club) + kwargs.setdefault("title", "Match") + kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1)) + return Event.objects.create(**kwargs) + + # --- has_club_role / roles_in_club --- + def test_has_club_role_is_scoped_to_role_and_club(self): + user, member = self.make_user_member("admin@example.com") + self.grant(member, ClubRole.Roles.ADMIN) + + self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN)) + self.assertFalse(has_club_role(user, self.club, ClubRole.Roles.EDITOR)) + self.assertFalse(has_club_role(user, self.other_club, ClubRole.Roles.ADMIN)) + + def test_roles_in_club_includes_derived_coach_manager(self): + user, member = self.make_user_member("editor@example.com") + self.grant(member, ClubRole.Roles.EDITOR) + self.make_coach(member) + + self.assertEqual(roles_in_club(user, self.club), {ClubRole.Roles.EDITOR, COACH_MANAGER}) + + def test_non_management_staff_gets_no_derived_role(self): + user, member = self.make_user_member("physio@example.com") + self.make_support_staff(member) + + self.assertEqual(roles_in_club(user, self.club), set()) + + def test_roles_in_club_is_empty_for_outsider(self): + user, _ = self.make_user_member("nobody@example.com") + + self.assertEqual(roles_in_club(user, self.club), set()) + + # --- teams_managed_by --- + def test_admin_manages_every_team(self): + user, member = self.make_user_member("admin@example.com") + self.grant(member, ClubRole.Roles.ADMIN) + + self.assertEqual(set(teams_managed_by(user, self.club)), {self.team, self.second_team}) + + def test_coach_manages_only_their_team(self): + user, member = self.make_user_member("coach@example.com") + self.make_coach(member) + + self.assertEqual(list(teams_managed_by(user, self.club)), [self.team]) + + def test_plain_member_manages_no_teams(self): + user, _ = self.make_user_member("plain@example.com") + + self.assertEqual(list(teams_managed_by(user, self.club)), []) + + def test_non_management_staff_manages_no_teams(self): + user, member = self.make_user_member("physio@example.com") + self.make_support_staff(member) + + self.assertEqual(list(teams_managed_by(user, self.club)), []) + + def test_non_management_staff_does_not_inherit_a_managers_team(self): + # The team has BOTH a manager and a non-management staffer. The staffer + # must not pick up the team just because *someone else* manages it. + _, manager = self.make_user_member("coach@example.com") + self.make_coach(manager) + physio_user, physio = self.make_user_member("physio@example.com") + self.make_support_staff(physio) + + self.assertEqual(list(teams_managed_by(physio_user, self.club)), []) + + def make_past_season(self): + return Season.objects.create( + club=self.club, + start_date=self.season.start_date - datetime.timedelta(days=400), + end_date=self.season.start_date - datetime.timedelta(days=1), + ) + + def test_a_former_seasons_coach_no_longer_manages_the_team(self): + # StaffAssignment is per-season: authority expires with it. + user, member = self.make_user_member("coach@example.com") + StaffAssignment.objects.create(team=self.team, member=member, season=self.make_past_season(), position=self.coach_position) + + self.assertEqual(list(teams_managed_by(user, self.club)), []) + self.assertEqual(list(teams_staffed_by(user, self.club)), []) + self.assertFalse(roles_in_club(user, self.club)) + + def test_a_former_seasons_coach_cannot_edit_a_current_event(self): + user, member = self.make_user_member("coach@example.com") + StaffAssignment.objects.create(team=self.team, member=member, season=self.make_past_season(), position=self.coach_position) + event = self.make_event() + event.teams.add(self.team) + + self.assertFalse(can_edit_event(user, event)) + + # --- members_visible_to --- + def test_admin_sees_all_club_members(self): + user, member = self.make_user_member("admin@example.com") + self.grant(member, ClubRole.Roles.ADMIN) + other = Member.objects.create(first_name="Other", last_name="Member") + ClubMembership.objects.create(club=self.club, member=member, season=self.season) + ClubMembership.objects.create(club=self.club, member=other, season=self.season) + + self.assertEqual(set(members_visible_to(user, self.club)), {member, other}) + + def test_coach_sees_self_and_managed_roster(self): + user, member = self.make_user_member("coach@example.com") + self.make_coach(member) + player = Member.objects.create(first_name="Player", last_name="One") + TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=self.forward) + unrelated = Member.objects.create(first_name="Un", last_name="Related") + + visible = set(members_visible_to(user, self.club)) + + self.assertEqual(visible, {member, player}) + self.assertNotIn(unrelated, visible) + + def test_parent_sees_self_and_children(self): + user, parent = self.make_user_member("parent@example.com") + child = Member.objects.create(first_name="Kid", last_name="Doe") + family = Family.objects.create(name="Doe") + FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT) + FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD) + + self.assertEqual(set(members_visible_to(user, self.club)), {parent, child}) + + def test_user_without_a_member_sees_nobody(self): + user = get_user_model().objects.create_user(email="ghost@example.com", password="pw") + + self.assertEqual(list(members_visible_to(user, self.club)), []) + + def test_non_management_staff_sees_the_roster_but_holds_no_authority(self): + # A physio can see the team they work with, but manages nothing. + user, member = self.make_user_member("physio@example.com") + self.make_support_staff(member) + player = Member.objects.create(first_name="Player", last_name="One") + TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=self.forward) + + self.assertEqual(set(members_visible_to(user, self.club)), {member, player}) + self.assertEqual(list(teams_managed_by(user, self.club)), []) + self.assertEqual(list(teams_staffed_by(user, self.club)), [self.team]) + + def test_manager_also_sees_the_teams_other_staff(self): + user, manager = self.make_user_member("coach@example.com") + self.make_coach(manager) + _, physio = self.make_user_member("physio@example.com") + self.make_support_staff(physio) + + self.assertIn(physio, set(members_visible_to(user, self.club))) + + def test_admin_sees_members_without_a_club_membership(self): + user, admin = self.make_user_member("admin@example.com") + self.grant(admin, ClubRole.Roles.ADMIN) + _, coach = self.make_user_member("coach@example.com") + self.make_coach(coach) # staff, but no ClubMembership + + visible = set(members_visible_to(user, self.club)) + + self.assertIn(coach, visible) + self.assertIn(admin, visible) # the admin sees themselves via their ClubRole + + def test_roster_visibility_is_scoped_to_the_current_season(self): + user, member = self.make_user_member("coach@example.com") + self.make_coach(member) + old_season = Season.objects.create( + club=self.club, + start_date=self.season.start_date - datetime.timedelta(days=400), + end_date=self.season.start_date - datetime.timedelta(days=1), + ) + former_player = Member.objects.create(first_name="Former", last_name="Player") + TeamMembership.objects.create(team=self.team, member=former_player, season=old_season, position=self.forward) + + self.assertNotIn(former_player, set(members_visible_to(user, self.club))) + + # --- can_edit_event --- + def test_admin_can_edit_event(self): + user, member = self.make_user_member("admin@example.com") + self.grant(member, ClubRole.Roles.ADMIN) + + self.assertTrue(can_edit_event(user, self.make_event())) + + def test_editor_can_edit_event(self): + user, member = self.make_user_member("editor@example.com") + self.grant(member, ClubRole.Roles.EDITOR) + + self.assertTrue(can_edit_event(user, self.make_event())) + + def test_owner_can_edit_their_event(self): + user, member = self.make_user_member("owner@example.com") + + self.assertTrue(can_edit_event(user, self.make_event(created_by=member))) + + def test_coach_can_edit_their_teams_event(self): + user, member = self.make_user_member("coach@example.com") + self.make_coach(member) + event = self.make_event() + event.teams.add(self.team) + + self.assertTrue(can_edit_event(user, event)) + + def test_coach_cannot_edit_another_teams_event(self): + user, member = self.make_user_member("coach@example.com") + self.make_coach(member) + event = self.make_event() + event.teams.add(self.second_team) + + self.assertFalse(can_edit_event(user, event)) + + def test_plain_member_cannot_edit_event(self): + user, _ = self.make_user_member("plain@example.com") + + self.assertFalse(can_edit_event(user, self.make_event())) + + def test_non_management_staff_cannot_edit_their_teams_event(self): + user, member = self.make_user_member("physio@example.com") + self.make_support_staff(member) + event = self.make_event() + event.teams.add(self.team) + + self.assertFalse(can_edit_event(user, event)) + + def test_non_management_staff_on_a_managed_team_cannot_edit_its_event(self): + # Same escalation shape as teams_managed_by: a manager exists on the team, + # but the physio must not inherit edit rights from them. + _, manager = self.make_user_member("coach@example.com") + self.make_coach(manager) + physio_user, physio = self.make_user_member("physio@example.com") + self.make_support_staff(physio) + event = self.make_event() + event.teams.add(self.team) + + self.assertFalse(can_edit_event(physio_user, event)) + + # --- can_manage_shop --- + def test_only_admin_can_manage_shop(self): + admin_user, admin_member = self.make_user_member("admin@example.com") + self.grant(admin_member, ClubRole.Roles.ADMIN) + editor_user, editor_member = self.make_user_member("editor@example.com") + self.grant(editor_member, ClubRole.Roles.EDITOR) + + self.assertTrue(can_manage_shop(admin_user, self.club)) + self.assertFalse(can_manage_shop(editor_user, self.club)) + + +class ClubRoleStatusSyncTests(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, end_date=today + datetime.timedelta(days=300)) + self.member = Member.objects.create(first_name="Jane", last_name="Doe") + + def roles(self): + return ClubRole.objects.filter(club=self.club, member=self.member) + + def make_membership(self, status=ClubMembership.StatusChoices.ACTIVE): + return ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=status) + + def test_active_membership_grants_member_role(self): + self.make_membership() + + self.assertEqual(self.roles().get().role, ClubRole.Roles.MEMBER) + + def test_pending_membership_grants_no_role(self): + self.make_membership(status=ClubMembership.StatusChoices.PENDING) + + self.assertFalse(self.roles().exists()) + + def test_deactivating_membership_withdraws_member_role(self): + membership = self.make_membership() + self.assertTrue(self.roles().exists()) + + membership.status = ClubMembership.StatusChoices.LAPSED + membership.save() + + self.assertFalse(self.roles().exists()) + + def test_deleting_membership_withdraws_member_role(self): + membership = self.make_membership() + + membership.delete() + + self.assertFalse(self.roles().exists()) + + def test_elevated_role_is_never_downgraded_or_removed(self): + ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN) + + membership = self.make_membership() + self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN) + + membership.status = ClubMembership.StatusChoices.CANCELLED + membership.save() + + self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN) + + def test_editor_role_survives_a_lapsed_membership(self): + ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.EDITOR) + membership = self.make_membership() + + membership.status = ClubMembership.StatusChoices.LAPSED + membership.save() + + self.assertEqual(self.roles().get().role, ClubRole.Roles.EDITOR) + + def test_elevated_role_survives_membership_deletion(self): + ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN) + membership = self.make_membership() + + membership.delete() + + self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN) + + def test_elevated_role_survives_a_season_rollover(self): + # Last season's membership lapses and the new season's is still pending: + # the admin must not lose their role in the gap. + ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN) + last_season = self.make_membership() + next_season = Season.objects.create( + club=self.club, + start_date=self.season.end_date + datetime.timedelta(days=1), + end_date=self.season.end_date + datetime.timedelta(days=300), + ) + + last_season.status = ClubMembership.StatusChoices.LAPSED + last_season.save() + ClubMembership.objects.create(club=self.club, member=self.member, season=next_season, status=ClubMembership.StatusChoices.PENDING) + + self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN) + + def test_elevated_access_and_login_survive_a_lapsed_membership(self): + # The whole point: a lapsed membership must not lock an admin out. + user = get_user_model().objects.create_user(email="admin@example.com", password="pw") + admin = Member.objects.create(user=user, first_name="Ada", last_name="Min") + ClubRole.objects.create(club=self.club, member=admin, role=ClubRole.Roles.ADMIN) + membership = ClubMembership.objects.create(club=self.club, member=admin, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + membership.status = ClubMembership.StatusChoices.CANCELLED + membership.save() + + self.assertTrue(user.is_active) # can still log in + self.assertIn(ClubRole.Roles.ADMIN, roles_in_club(user, self.club)) + self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN)) + self.assertTrue(can_manage_shop(user, self.club)) + + +@override_settings( + ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", + ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], +) +class BrandingTests(TestCase): + """The auth screens are shared; only the skin they inherit differs per tenant.""" + + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + + def login_page(self, host): + return self.client.get(reverse("account_login"), HTTP_HOST=host) + + def test_the_base_domain_gets_the_platform_skin(self): + response = self.login_page("rosterchief.app") + + self.assertTemplateUsed(response, "_platform_base.html") + self.assertTemplateNotUsed(response, "_club_base.html") + self.assertContains(response, "Club & Team Management") + self.assertIsNone(response.context["club"]) + + def test_a_club_subdomain_gets_the_club_skin(self): + response = self.login_page("ajax-united.rosterchief.app") + + self.assertTemplateUsed(response, "_club_base.html") + self.assertTemplateNotUsed(response, "_platform_base.html") + self.assertContains(response, "Ajax United") + self.assertEqual(response.context["club"], self.club) + + def test_an_archived_club_falls_back_to_the_platform_skin(self): + # The subdomain stops resolving, so there is no club to brand with. + self.club.archive() + + self.assertTemplateUsed(self.login_page("ajax-united.rosterchief.app"), "_platform_base.html") + + def test_a_club_without_a_logo_shows_its_initials_not_our_mark(self): + response = self.login_page("ajax-united.rosterchief.app") + + self.assertContains(response, "AU") + self.assertNotContains(response, "rosterchief-dark.svg") + + def test_a_club_logo_is_rendered_when_set(self): + self.club.logo = "clubs/ajax-united/crest.png" + self.club.save() + + self.assertContains(self.login_page("ajax-united.rosterchief.app"), "clubs/ajax-united/crest.png") + + def test_a_club_logo_gets_a_primary_coloured_ring(self): + self.club.logo = "clubs/ajax-united/crest.png" + self.club.save() + + self.assertContains(self.login_page("ajax-united.rosterchief.app"), "ring-primary") + + def test_a_club_colour_overrides_the_theme(self): + self.club.primary_color = "#1e40af" + self.club.save() + + self.assertContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary: #1e40af") + + def test_no_colour_means_no_override(self): + self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary") + + def test_a_club_secondary_colour_overrides_the_theme(self): + self.club.secondary_color = "#be185d" + self.club.save() + + self.assertContains(self.login_page("ajax-united.rosterchief.app"), "--color-secondary: #be185d") + + def test_no_secondary_colour_means_no_override(self): + self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-secondary") + + +@override_settings( + ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", + ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], +) +class Custom403PageTests(TestCase): + """Django's default 403 handler picks up templates/403.html automatically -- + branded per tenant (base_template, same as maintenance.html) so a permission + error still looks like the app, not a bare Django error page, and the navbar + (sign out, theme toggle, home link) stays reachable.""" + + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + + def test_a_club_subdomain_403_gets_the_club_skin(self): + member = get_user_model().objects.create_user(email="member-403@example.com", password="pw-secret-123") + self.client.force_login(member) + + response = self.client.get(reverse("management:position_list"), HTTP_HOST="ajax-united.rosterchief.app") + + self.assertEqual(response.status_code, 403) + self.assertContains(response, "Access denied", status_code=403) + self.assertContains(response, "Ajax United", status_code=403) + self.assertContains(response, "Sign out", status_code=403) + + def test_the_base_domain_403_gets_the_platform_skin(self): + self.client.force_login(get_user_model().objects.create_user(email="platform-403@example.com", password="pw-secret-123")) + + response = self.client.get(reverse("controlpanel:dashboard")) + + self.assertEqual(response.status_code, 403) + self.assertContains(response, "Access denied", status_code=403) + self.assertContains(response, "Club & Team Management", status_code=403) + + +class ClubBrandingModelTests(TestCase): + def test_initials_use_the_first_two_words(self): + self.assertEqual(Club(name="Ajax United Football Club").initials, "AU") + self.assertEqual(Club(name="Ajax").initials, "A") + + def test_text_on_a_pale_colour_is_black_and_on_a_dark_one_white(self): + # A club picking pale yellow must not get white-on-yellow buttons. + self.assertEqual(Club(primary_color="#fef08a").primary_content_color, "#000000") + self.assertEqual(Club(primary_color="#1e40af").primary_content_color, "#ffffff") + + def test_no_colour_means_no_contrast_colour(self): + self.assertEqual(Club(primary_color="").primary_content_color, "") + + def test_a_colour_must_be_a_hex_value(self): + club = Club(name="Ajax United", primary_color="blue") + + with self.assertRaises(ValidationError): + club.full_clean() + + def test_logos_are_stored_per_club(self): + self.assertEqual(club_logo_path(Club(slug="ajax-united"), "crest.png"), "clubs/ajax-united/crest.png") + + +@override_settings( + ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", + ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], +) +class RootViewTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.user = get_user_model().objects.create_user(email="member@example.com", password="pw-secret-123") + + def test_the_base_domain_hands_off_to_the_control_panel(self): + response = self.client.get("/", HTTP_HOST="rosterchief.app") + + self.assertRedirects(response, reverse("controlpanel:dashboard"), fetch_redirect_response=False) + + def test_a_club_subdomain_lands_on_the_club_home(self): + self.client.force_login(self.user) + + response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertTemplateUsed(response, "club/home.html") + self.assertContains(response, "Ajax United") + + def test_the_club_home_requires_a_login(self): + response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertRedirects(response, f"{reverse('account_login')}?next=/", fetch_redirect_response=False) + + +class FeeServiceTests(TestCase): + """club.services.fees -- record_payment/mark_as_paid/remaining_balance, the + service layer behind the Memberships page's per-row payment actions.""" + + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.season = make_season(self.club) + self.member = Member.objects.create(first_name="Jane", last_name="Doe") + self.membership = ClubMembership.objects.create( + club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00") + ) + + def roles(self): + return ClubRole.objects.filter(club=self.club, member=self.member) + + def test_remaining_balance_starts_at_the_full_fee(self): + self.assertEqual(remaining_balance(self.membership), Decimal("150.00")) + + def test_remaining_balance_is_never_negative(self): + record_payment(self.membership, amount=Decimal("200.00")) + + self.assertEqual(remaining_balance(self.membership), Decimal("0.00")) + + def test_a_partial_payment_creates_a_record_and_updates_the_running_total(self): + payment = record_payment(self.membership, amount=Decimal("50.00"), method=FeePayment.Method.CASH, reference="R1", note="first installment") + + self.assertEqual(payment.membership, self.membership) + self.assertEqual(payment.amount, Decimal("50.00")) + self.membership.refresh_from_db() + self.assertEqual(self.membership.amount_paid, Decimal("50.00")) + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID) + # Not yet settled -- status doesn't change on a partial payment. + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING) + + def test_multiple_partial_payments_accumulate(self): + record_payment(self.membership, amount=Decimal("50.00")) + record_payment(self.membership, amount=Decimal("60.00")) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.amount_paid, Decimal("110.00")) + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID) + self.assertEqual(FeePayment.objects.filter(membership=self.membership).count(), 2) + + def test_reaching_the_full_amount_settles_and_activates(self): + record_payment(self.membership, amount=Decimal("100.00")) + record_payment(self.membership, amount=Decimal("50.00")) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(self.membership.activated_at, timezone.localdate()) + self.assertTrue(self.roles().filter(role=ClubRole.Roles.MEMBER).exists()) + + def test_settling_in_full_does_not_overwrite_an_earlier_activated_at(self): + earlier = datetime.date(2026, 1, 1) + self.membership.activated_at = earlier + self.membership.save() + + record_payment(self.membership, amount=Decimal("150.00")) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.activated_at, earlier) + + def test_a_waived_membership_is_untouched_by_a_payment(self): + self.membership.fee_status = ClubMembership.FeeStatus.WAIVED + self.membership.save() + + record_payment(self.membership, amount=Decimal("50.00")) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.WAIVED) + + def test_mark_as_paid_records_the_exact_remaining_balance(self): + record_payment(self.membership, amount=Decimal("100.00")) + + mark_as_paid(self.membership) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) + payment = FeePayment.objects.get(membership=self.membership, amount=Decimal("50.00")) + self.assertEqual(payment.note, "Marked as paid") + + def test_mark_as_paid_with_no_fee_amount_set_skips_creating_a_zero_payment(self): + # FeePayment.amount has a MinValueValidator(0.01) -- a $0 "payment" isn't a + # real transaction, so this must flip the flags directly instead. + unpriced = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="No", last_name="Price"), season=self.season, status=ClubMembership.StatusChoices.PENDING) + + mark_as_paid(unpriced) + + unpriced.refresh_from_db() + self.assertEqual(unpriced.fee_status, ClubMembership.FeeStatus.PAID) + self.assertEqual(unpriced.status, ClubMembership.StatusChoices.ACTIVE) + self.assertFalse(FeePayment.objects.filter(membership=unpriced).exists()) + + def test_recorded_by_is_stored_on_the_payment(self): + user = get_user_model().objects.create_user(email="admin-fees@example.com", password="pw-secret-123") + + payment = record_payment(self.membership, amount=Decimal("50.00"), recorded_by=user) + + self.assertEqual(payment.recorded_by, user) + + +class SeasonStartEndTests(TestCase): + """club.services.seasons._initial_season_start / _season_end -- the + per-club rules generate_seasons chains off, now that a club's own + season_start/season_duration_months drive them instead of a fixed Aug-May + window.""" + + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + + def test_a_date_past_this_years_anchor_uses_this_year(self): + self.club.season_start = datetime.date(2000, 8, 1) + + start = _initial_season_start(self.club, datetime.date(2026, 8, 15)) + + self.assertEqual(start, datetime.date(2026, 8, 1)) + + def test_a_date_before_this_years_anchor_uses_last_year(self): + self.club.season_start = datetime.date(2000, 8, 1) + + start = _initial_season_start(self.club, datetime.date(2027, 2, 1)) + + self.assertEqual(start, datetime.date(2026, 8, 1)) + + def test_the_anchor_date_itself_uses_this_year(self): + self.club.season_start = datetime.date(2000, 8, 1) + + start = _initial_season_start(self.club, datetime.date(2026, 8, 1)) + + self.assertEqual(start, datetime.date(2026, 8, 1)) + + def test_season_end_is_the_day_before_the_start_plus_the_duration(self): + self.club.season_duration_months = 12 + + end = _season_end(datetime.date(2026, 8, 1), self.club) + + self.assertEqual(end, datetime.date(2027, 7, 31)) + + def test_a_shorter_duration_produces_a_shorter_season(self): + self.club.season_duration_months = 6 + + end = _season_end(datetime.date(2026, 8, 1), self.club) + + self.assertEqual(end, datetime.date(2027, 1, 31)) + + +class GenerateSeasonsTests(TestCase): + """club.services.seasons.generate_seasons -- the service behind the + generate_seasons management command.""" + + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + + def test_generates_a_season_covering_today(self): + today = timezone.localdate() + + generate_seasons(self.club, today) + + self.assertTrue(Season.objects.filter(club=self.club, start_date__lte=today, end_date__gte=today).exists()) + + def test_generates_every_window_through_the_horizon(self): + today = timezone.localdate() + until = today + relativedelta(years=2) + + created = generate_seasons(self.club, until) + + self.assertGreaterEqual(len(created), 2) + for season in created: + self.assertLessEqual(season.start_date, until) + + def test_is_idempotent_on_a_second_run(self): + until = timezone.localdate() + relativedelta(years=2) + generate_seasons(self.club, until) + count_after_first = Season.objects.filter(club=self.club).count() + + second_run = generate_seasons(self.club, until) + + self.assertEqual(second_run, []) + self.assertEqual(Season.objects.filter(club=self.club).count(), count_after_first) + + def test_a_new_season_starts_the_day_after_the_last_one_ends(self): + today = timezone.localdate() + generate_seasons(self.club, today) + latest = Season.objects.filter(club=self.club).order_by("-end_date").first() + + generate_seasons(self.club, latest.end_date + relativedelta(months=self.club.season_duration_months)) + + next_season = Season.objects.filter(club=self.club, start_date=latest.end_date + datetime.timedelta(days=1)).first() + self.assertIsNotNone(next_season) + + def test_changing_the_duration_only_affects_the_next_generated_season(self): + today = timezone.localdate() + generate_seasons(self.club, today) + first = Season.objects.filter(club=self.club).order_by("-end_date").first() + + self.club.season_duration_months = 6 + self.club.save() + generate_seasons(self.club, first.end_date + relativedelta(months=6)) + + first_end_before = first.end_date + first.refresh_from_db() + self.assertEqual(first.end_date, first_end_before) # existing season untouched + second = Season.objects.get(club=self.club, start_date=first.end_date + datetime.timedelta(days=1)) + self.assertEqual(second.end_date, second.start_date + relativedelta(months=6) - datetime.timedelta(days=1)) + + def test_does_not_disturb_a_pre_existing_irregular_season(self): + odd = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) + + generate_seasons(self.club, timezone.localdate()) + + odd.refresh_from_db() + self.assertEqual(odd.start_date, datetime.date(2020, 3, 1)) + self.assertEqual(odd.end_date, datetime.date(2020, 9, 1)) + + +class ResyncSeasonsTests(TestCase): + """club.services.seasons.resync_seasons -- cleaning up seasons that don't + match a club's current settings (e.g. left over from a since-changed + season_start/season_duration_months).""" + + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.until = timezone.localdate() + relativedelta(years=2) + + def test_a_wrong_and_unreferenced_season_is_reported_as_removable(self): + wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) + + removed, kept = resync_seasons(self.club, self.until) + + self.assertIn(wrong, removed) + self.assertEqual(kept, []) + + def test_commit_actually_deletes_a_wrong_unreferenced_season(self): + wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) + + resync_seasons(self.club, self.until, commit=True) + + self.assertFalse(Season.objects.filter(pk=wrong.pk).exists()) + + def test_without_commit_nothing_is_actually_deleted(self): + wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) + + resync_seasons(self.club, self.until, commit=False) + + self.assertTrue(Season.objects.filter(pk=wrong.pk).exists()) + + def test_a_wrong_but_referenced_season_is_kept_not_removed(self): + wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) + member = Member.objects.create(first_name="Jane", last_name="Doe") + ClubMembership.objects.create(club=self.club, member=member, season=wrong) + + removed, kept = resync_seasons(self.club, self.until, commit=True) + + self.assertEqual(removed, []) + self.assertIn(wrong, kept) + self.assertTrue(Season.objects.filter(pk=wrong.pk).exists()) + + def test_a_season_matching_current_settings_is_left_alone(self): + generate_seasons(self.club, timezone.localdate()) + + removed, kept = resync_seasons(self.club, self.until) + + self.assertEqual(removed, []) + self.assertEqual(kept, []) + + def test_a_season_referenced_only_via_staff_assignment_is_kept_not_removed(self): + # Season is PROTECTed by more than just ClubMembership -- a season kept + # alive only through a StaffAssignment must not be silently deleted either. + wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) + team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True) + member = Member.objects.create(first_name="Jane", last_name="Doe") + StaffAssignment.objects.create(team=team, member=member, season=wrong, position=position) + + removed, kept = resync_seasons(self.club, self.until, commit=True) + + self.assertEqual(removed, []) + self.assertIn(wrong, kept) + self.assertTrue(Season.objects.filter(pk=wrong.pk).exists()) + + +class GenerateSeasonsCommandTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + + def test_default_years_is_two(self): + call_command("generate_seasons", stdout=StringIO()) + + today = timezone.localdate() + until = today + relativedelta(years=2) + seasons = Season.objects.filter(club=self.club).order_by("start_date") + self.assertTrue(seasons.exists()) + for season in seasons: + self.assertLessEqual(season.start_date, until) + self.assertTrue(seasons.filter(start_date__lte=today, end_date__gte=today).exists()) + + def test_years_argument_controls_the_horizon(self): + call_command("generate_seasons", "--years", "1", stdout=StringIO()) + + until = timezone.localdate() + relativedelta(years=1) + for season in Season.objects.filter(club=self.club): + self.assertLessEqual(season.start_date, until) + + def test_archived_clubs_are_skipped(self): + self.club.archive() + + call_command("generate_seasons", stdout=StringIO()) + + self.assertFalse(Season.objects.filter(club=self.club).exists()) + + def test_second_run_creates_nothing_new(self): + call_command("generate_seasons", stdout=StringIO()) + count_after_first = Season.objects.filter(club=self.club).count() + + out = StringIO() + call_command("generate_seasons", stdout=out) + + self.assertEqual(Season.objects.filter(club=self.club).count(), count_after_first) + self.assertIn("Generated 0 season", out.getvalue()) + + def test_resync_without_commit_reports_but_does_not_delete(self): + wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) + + out = StringIO() + call_command("generate_seasons", "--resync", stdout=out) + + self.assertTrue(Season.objects.filter(pk=wrong.pk).exists()) + self.assertIn("Would remove", out.getvalue()) + + def test_resync_with_commit_deletes_the_wrong_season(self): + wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) + + call_command("generate_seasons", "--resync", "--commit", stdout=StringIO()) + + self.assertFalse(Season.objects.filter(pk=wrong.pk).exists()) + diff --git a/club/views.py b/club/views.py new file mode 100644 index 0000000..3a5a18b --- /dev/null +++ b/club/views.py @@ -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) diff --git a/clubmanager/settings.py b/clubmanager/settings.py deleted file mode 100644 index af184fd..0000000 --- a/clubmanager/settings.py +++ /dev/null @@ -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/' diff --git a/clubmanager/urls.py b/clubmanager/urls.py deleted file mode 100644 index 1491602..0000000 --- a/clubmanager/urls.py +++ /dev/null @@ -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), -] diff --git a/compose.behind-proxy.yaml b/compose.behind-proxy.yaml new file mode 100644 index 0000000..47d4303 --- /dev/null +++ b/compose.behind-proxy.yaml @@ -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://: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: diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..53210c9 --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/controlpanel/__init__.py b/controlpanel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controlpanel/apps.py b/controlpanel/apps.py new file mode 100644 index 0000000..36fad22 --- /dev/null +++ b/controlpanel/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ControlpanelConfig(AppConfig): + name = "controlpanel" diff --git a/controlpanel/forms.py b/controlpanel/forms.py new file mode 100644 index 0000000..c837a60 --- /dev/null +++ b/controlpanel/forms.py @@ -0,0 +1,155 @@ +from decimal import Decimal + +from django import forms +from django.utils.translation import gettext_lazy as _ +from waffle import get_waffle_flag_model + +from billing.models import DuePayment, Subscription, Tier, TierPrice +from club.models import Club +from events.models import Location + +from .services.admins import find_member_by_email + + +class ClubForm(forms.ModelForm): + class Meta: + model = Club + fields = ["name", "slug", "sport_type", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"] + help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")} + # Deliberately a text input, not : a colour picker cannot + # express "no colour" -- it would submit #000000 for every club that never + # touched it, and every club would silently get a black theme. + widgets = { + "primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}), + "secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}), + "logo": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp,image/svg+xml"}), + "season_start": forms.DateInput(attrs={"type": "date"}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["slug"].required = False + + +class HomeLocationForm(forms.ModelForm): + """Create or update the club's home ground -- this *is* an events.Location row + (flagged ``is_home``), the same one that shows up under the club's own + Teams > Locations page, so the two stay in sync by construction rather than + needing anything to keep them that way.""" + + class Meta: + model = Location + fields = ["name", "address", "city", "zip_code", "country"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["country"].widget.attrs.update({"data-searchable": "true", "data-search-placeholder": _("Type a country to search...")}) + + +class ClubAdminForm(forms.Form): + """Grant club-admin rights to an email address, creating the person if new.""" + + email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link.")) + first_name = forms.CharField(label=_("First name"), required=False) + last_name = forms.CharField(label=_("Last name"), required=False) + + def clean(self): + cleaned = super().clean() + email = cleaned.get("email") + + # Only a brand-new person needs a name; an existing member already has one. + if email and find_member_by_email(email) is None: + for field in ("first_name", "last_name"): + if not cleaned.get(field): + self.add_error(field, _("Required: this email has no account yet.")) + + return cleaned + + +class PlatformAdminForm(forms.Form): + """Grant platform access to an email address, creating the account if new.""" + + email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link.")) + is_superuser = forms.BooleanField(label=_("Superuser"), required=False, help_text=_("Superusers can manage platform admins. Everyone granted access is staff.")) + + +class FlagForm(forms.ModelForm): + class Meta: + model = get_waffle_flag_model() + fields = ["name", "note", "everyone", "superusers", "staff", "percent"] + help_texts = { + "everyone": _("Yes = on for all clubs, No = off everywhere (overrides club targeting). Leave unknown to target clubs."), + } + + +class TierForm(forms.ModelForm): + class Meta: + model = Tier + fields = ["name", "description", "is_active"] + + +class TierPriceForm(forms.ModelForm): + class Meta: + model = TierPrice + fields = ["active_from", "amount"] + widgets = {"active_from": forms.DateInput(attrs={"type": "date"})} + help_texts = {"active_from": _("Periods opening on or after this date are billed at this amount. Existing periods keep the amount they were billed at.")} + + +class SubscriptionForm(forms.ModelForm): + """Put a club on a tier. The first period opens when the subscription is created.""" + + start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("First period starts"), help_text=_("Left blank, the period starts today.")) + + class Meta: + model = Subscription + fields = ["tier", "auto_renew", "auto_archive", "notes"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # An inactive tier still bills its existing subscriptions, but must not be picked up + # by a new one — which is the whole point of retiring a tier. + self.fields["tier"].queryset = Tier.objects.filter(is_active=True) + + +class TrialForm(forms.Form): + """Put a club with no subscription yet on a short trial that switches itself to + ``post_trial_tier`` automatically once it ends -- see billing.services.dues.start_trial.""" + + trial_tier = forms.ModelChoiceField(queryset=Tier.objects.none(), label=_("Trial tier"), help_text=_("What this club is billed on during the trial.")) + post_trial_tier = forms.ModelChoiceField(queryset=Tier.objects.none(), label=_("Then switch to"), help_text=_("The plan it lands on automatically once the trial ends.")) + trial_months = forms.IntegerField(min_value=1, initial=2, label=_("Trial length (months)")) + start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("Trial starts"), help_text=_("Left blank, the trial starts today.")) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Same reasoning as SubscriptionForm: a retired tier keeps billing whoever is + # already on it, but must not be offered for a new trial or a new plan either. + self.fields["trial_tier"].queryset = Tier.objects.filter(is_active=True) + self.fields["post_trial_tier"].queryset = Tier.objects.filter(is_active=True) + + +class DuePaymentForm(forms.Form): + amount = forms.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal("0.01"), label=_("Amount")) + method = forms.ChoiceField(choices=DuePayment.Method.choices, initial=DuePayment.Method.BANK_TRANSFER, label=_("Method")) + reference = forms.CharField(required=False, label=_("Reference"), help_text=_("Bank reference, transaction id — whatever lets you find this again.")) + paid_at = forms.DateTimeField(required=False, widget=forms.DateTimeInput(attrs={"type": "datetime-local"}), label=_("Received"), help_text=_("Left blank, now.")) + note = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 2}), label=_("Note")) + + +class OpenPeriodForm(forms.Form): + """Renew, or reactivate an archived club.""" + + start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("Period starts"), help_text=_("Left blank, it continues from the end of the last period — so a lapsed year is still owed.")) + + +class MaintenanceForm(forms.Form): + """Closing the platform is a deliberate act, so it takes a sentence explaining itself — + that message is the only thing a club will see.""" + + message = forms.CharField( + required=False, + widget=forms.Textarea(attrs={"rows": 2}), + label=_("Message"), + help_text=_("Shown to every club while the platform is closed. Left blank, they get a generic notice."), + ) diff --git a/controlpanel/messages.py b/controlpanel/messages.py new file mode 100644 index 0000000..f7f4efb --- /dev/null +++ b/controlpanel/messages.py @@ -0,0 +1,35 @@ +"""A compact way to queue a Django message that carries its own title. + +Django's messages framework has no title field — a call site that wants one passes it +as ``extra_tags`` (``messages.success(request, body, extra_tags="Club created")``), which +reads fine written out but is easy to forget, so in practice every message ends up on +the generic per-level heading (`as_alert`'s "Done" / "Careful" / "Something went wrong"). + +``notify`` folds level, title and body into one string instead: ``"||<body>"``. +One call, title included, nothing to forget. `as_alert` (controlpanel/templatetags/ui.py) +reads the title back off ``extra_tags`` at render time — unchanged from before. +""" + +from django.contrib import messages + +#: One letter per Django message level. `notify` picks the level from the spec string; +#: `as_alert` picks the icon/colour/fallback-title from the level the message actually +#: carries (via ``level_tag``), so the two stay in step by construction. +LEVELS = { + "s": messages.SUCCESS, + "i": messages.INFO, + "w": messages.WARNING, + "e": messages.ERROR, + "d": messages.DEBUG, +} + + +def notify(request, spec: str, **kwargs) -> None: + """Queue a message from a ``"<level>|<title>|<body>"`` spec. + + ``level`` is one of ``s`` (success), ``i`` (info), ``w`` (warning), ``e`` (error), + ``d`` (debug). An empty title (``"s||Body text"``) falls back to the generic + per-level heading, same as never passing ``extra_tags`` at all. + """ + level_code, title, body = spec.split("|", 2) + messages.add_message(request, LEVELS[level_code], body, extra_tags=title, **kwargs) diff --git a/controlpanel/migrations/__init__.py b/controlpanel/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controlpanel/mixins.py b/controlpanel/mixins.py new file mode 100644 index 0000000..9e8f326 --- /dev/null +++ b/controlpanel/mixins.py @@ -0,0 +1,61 @@ +from django.contrib.auth.mixins import UserPassesTestMixin +from django.http import Http404 +from django.shortcuts import redirect + +from .messages import notify + + +class PlatformStaffRequiredMixin(UserPassesTestMixin): + """Gate for the platform control panel. + + Two rules: + + * **Staff only.** ``is_staff`` or ``is_superuser``. Anonymous visitors are + sent to the login page; signed-in non-staff get a 403 (Django's + AccessMixin already distinguishes those two cases). Staff must also hold a + second factor — ``RequireMFAMiddleware`` enforces that, so the panel is + 2FA-protected for free. + * **Base domain only.** The panel manages *all* clubs, so it must not be + reachable from inside one. If the tenant middleware resolved a club from + the subdomain, the panel does not exist here. + """ + + def dispatch(self, request, *args, **kwargs): + if getattr(request, "club", None) is not None: + raise Http404("The control panel is not available on a club subdomain.") + return super().dispatch(request, *args, **kwargs) + + def test_func(self): + user = self.request.user + return user.is_staff or user.is_superuser + + +class PlatformSuperuserRequiredMixin(PlatformStaffRequiredMixin): + """Superusers only. + + Managing platform admins is the one thing staff may not do. The panel is + gated on ``is_staff or is_superuser``, so if a staff member could grant + themselves ``is_superuser`` the two would collapse into the same thing and + ``is_superuser`` would stop being a security boundary. + """ + + def test_func(self): + return self.request.user.is_superuser + + +class RedirectOnInvalidMixin: + """A form submitted from a modal has nowhere sensible to re-render on error: the page + that opened it has already moved on, and the view has no standalone template of its + own. Redirect back to ``invalid_redirect_url_name`` instead, with the errors flattened + into messages, rather than Django's default of re-rendering ``template_name``. + """ + + invalid_redirect_url_name = None + + def get_invalid_redirect_kwargs(self): + return {} + + def form_invalid(self, form): + for error in form.errors.values(): + notify(self.request, f"e|Couldn't save|{' '.join(error)}") + return redirect(self.invalid_redirect_url_name, **self.get_invalid_redirect_kwargs()) diff --git a/controlpanel/services/__init__.py b/controlpanel/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controlpanel/services/admins.py b/controlpanel/services/admins.py new file mode 100644 index 0000000..89ba187 --- /dev/null +++ b/controlpanel/services/admins.py @@ -0,0 +1,47 @@ +"""Granting and revoking club-admin rights from the platform panel.""" + +from django.contrib.auth import get_user_model +from django.db import transaction + +from club.models import ClubRole +from members.models import Member + +User = get_user_model() + + +def find_member_by_email(email): + """The Member behind a login email, if that account exists at all.""" + return Member.objects.filter(user__email__iexact=email).first() + + +@transaction.atomic +def grant_club_admin(club, email, first_name="", last_name=""): + """Make the holder of ``email`` an ADMIN of ``club``, creating them if new. + + A ClubRole hangs off a Member, and a Member optionally links to a User — so + an admin who has never existed needs both. The account is created without a + usable password; they set one via the password-reset flow. + """ + email = email.lower() + user, created_user = User.objects.get_or_create(email=email, defaults={"is_active": True}) + if created_user: + user.set_unusable_password() + user.save(update_fields=["password"]) + + member, _ = Member.objects.get_or_create( + user=user, + defaults={"first_name": first_name, "last_name": last_name}, + ) + + # One role per member per club, so promote rather than add a second row. + role, created_role = ClubRole.objects.get_or_create(club=club, member=member, defaults={"role": ClubRole.Roles.ADMIN}) + if not created_role and role.role != ClubRole.Roles.ADMIN: + role.role = ClubRole.Roles.ADMIN + role.save(update_fields=["role"]) + + return role + + +def revoke_club_admin(role): + """Remove admin rights. The membership-status sync never re-adds ADMIN.""" + role.delete() diff --git a/controlpanel/services/platform_admins.py b/controlpanel/services/platform_admins.py new file mode 100644 index 0000000..1bf55f0 --- /dev/null +++ b/controlpanel/services/platform_admins.py @@ -0,0 +1,76 @@ +"""Granting and revoking platform access (is_staff / is_superuser). + +The guardrails matter more than the plumbing here: it must be impossible to lock +the platform out of itself. Two rules are enforced for every change: + +* you cannot strip your **own** access (you would lose the panel mid-click); +* the **last superuser** can never be demoted, or nobody could administer the + platform again without shell access. +""" + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import Q + +User = get_user_model() + + +class PlatformAdminError(Exception): + """A change that would leave the platform unadministrable.""" + + +def platform_admins(): + return User.objects.filter(Q(is_staff=True) | Q(is_superuser=True)).order_by("email") + + +def is_last_superuser(user) -> bool: + return user.is_superuser and not User.objects.filter(is_superuser=True).exclude(pk=user.pk).exists() + + +def check_access_change(actor, user, *, is_staff: bool, is_superuser: bool) -> None: + """Raise PlatformAdminError if this change would lock someone out.""" + losing_access = not (is_staff or is_superuser) + + if actor.pk == user.pk and losing_access: + raise PlatformAdminError("You cannot remove your own platform access.") + + if actor.pk == user.pk and user.is_superuser and not is_superuser: + raise PlatformAdminError("You cannot remove your own superuser rights.") + + if user.is_superuser and not is_superuser and is_last_superuser(user): + raise PlatformAdminError("At least one superuser must remain.") + + +@transaction.atomic +def set_platform_access(actor, user, *, is_staff: bool, is_superuser: bool): + check_access_change(actor, user, is_staff=is_staff, is_superuser=is_superuser) + + # A superuser without is_staff cannot reach the panel, which is a confusing + # half-state; superuser implies staff. + user.is_staff = is_staff or is_superuser + user.is_superuser = is_superuser + user.save(update_fields=["is_staff", "is_superuser"]) + return user + + +def revoke_platform_access(actor, user): + return set_platform_access(actor, user, is_staff=False, is_superuser=False) + + +@transaction.atomic +def grant_platform_access(email, *, is_superuser: bool = False): + """Give ``email`` platform access, creating the account if it is new. + + New accounts get an unusable password — they set one through the password + reset flow — and, being staff, must enrol a second factor before they can + sign in at all. + """ + email = email.lower() + user, created = User.objects.get_or_create(email=email, defaults={"is_active": True}) + if created: + user.set_unusable_password() + + user.is_staff = True + user.is_superuser = is_superuser + user.save() + return user diff --git a/controlpanel/services/statistics.py b/controlpanel/services/statistics.py new file mode 100644 index 0000000..8fc84db --- /dev/null +++ b/controlpanel/services/statistics.py @@ -0,0 +1,475 @@ +"""Platform and per-club statistics. + +``club_statistics`` returns a list of stat *groups*, so growing the model later +means adding an entry here and nothing else. ``clubs_with_totals`` annotates in +a single query — the club list must not fan out into N+1. +""" + +from collections import defaultdict +from datetime import timedelta +from decimal import Decimal + +from allauth.mfa.models import Authenticator +from dateutil.relativedelta import relativedelta +from django.contrib.auth import get_user_model +from django.db.models import Count, DateField, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value +from django.db.models.functions import Coalesce, TruncMonth +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from waffle import get_waffle_flag_model + +from authentication.middleware import ELEVATED_ROLES +from billing.models import Due, DuePayment, Subscription +from billing.services.dues import dues_in_grace, dues_overdue, subscriptions_due_for_renewal +from club.models import Club, ClubMembership, ClubRole, Season +from events.models import Attendance, Event +from members.models import Member +from shop.models import Cart, Order +from teams.models import StaffAssignment, Team, TeamMembership + +ZERO = Decimal("0.00") + +PAID_STATUSES = (Order.OrderStatus.PAID, Order.OrderStatus.DELIVERED) +OWED_STATUSES = (Order.OrderStatus.PENDING, Order.OrderStatus.PARTIALLY_PAID) + +#: A club with nothing scheduled inside this window has stopped using the product. +DORMANT_DAYS = 30 +MONTHS_OF_HISTORY = 12 + + +def clubs_with_totals(queryset=None): + """Clubs annotated with headline counts (one query, no N+1).""" + clubs = Club.objects.all() if queryset is None else queryset + return clubs.annotate( + member_count=Count("clubmemberships__member", distinct=True), + team_count=Count("teams", distinct=True), + event_count=Count("events", distinct=True), + admin_count=Count("clubroles", filter=Q(clubroles__role=ClubRole.Roles.ADMIN), distinct=True), + ) + + +def _subquery(queryset, expression, output_field): + """One aggregate, in its own subquery. + + Deliberately not a pile of annotate(Count(...), Sum(...)) on one queryset: aggregates + that span *different* joins multiply each other's rows, so a club's outstanding total + would come back doubled for every membership it happens to have. Subqueries each stand + alone, so nothing can inflate anything else. + """ + return Coalesce(Subquery(queryset.filter(club=OuterRef("pk")).values("club").annotate(value=expression).values("value"), output_field=output_field), Value(0), output_field=output_field) + + +def clubs_with_health(queryset=None, today=None, now=None): + """Clubs annotated with the health of each — for the dashboard table, in one query.""" + today = today or timezone.localdate() + now = now or timezone.now() + clubs = Club.objects.active() if queryset is None else queryset + + in_season = Q(season__start_date__lte=today, season__end_date__gte=today) + # A period the club is covered for, most recent first — paid or waived, both settled. + _covered = Due.objects.filter(club=OuterRef("pk"), status__in=(Due.Status.PAID, Due.Status.WAIVED)).order_by("-period_end") + managed_this_season = Q( + staff_assignments__season__start_date__lte=today, + staff_assignments__season__end_date__gte=today, + staff_assignments__position__management_position=True, + ) + + return ( + clubs.annotate( + has_season=Exists(Season.objects.filter(club=OuterRef("pk"), start_date__lte=today, end_date__gte=today)), + active_members=_subquery(ClubMembership.objects.filter(in_season, status=ClubMembership.StatusChoices.ACTIVE), Count("pk"), IntegerField()), + unpaid_members=_subquery(ClubMembership.objects.filter(in_season, fee_status=ClubMembership.FeeStatus.UNPAID), Count("pk"), IntegerField()), + outstanding=_subquery(Order.objects.filter(status__in=OWED_STATUSES), Sum("total"), DecimalField(max_digits=10, decimal_places=2)), + upcoming_events=_subquery(Event.objects.filter(start__gte=now, start__lte=now + timedelta(days=DORMANT_DAYS)), Count("pk"), IntegerField()), + team_count=_subquery(Team.objects.all(), Count("pk"), IntegerField()), + teams_managed=_subquery(Team.objects.filter(managed_this_season), Count("pk", distinct=True), IntegerField()), + admin_count=_subquery(ClubRole.objects.filter(role=ClubRole.Roles.ADMIN), Count("pk"), IntegerField()), + tier_name=Subquery(Subscription.objects.filter(club=OuterRef("pk")).values("tier__name")[:1]), + dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)), + dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]), + dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]), + # How far the club is covered: the furthest-out period that is settled. PAID and + # WAIVED both mean nothing is owed for that period, and its end is the day grace + # would start if nothing renews — so both count. `covered_status` is read from the + # same top row, so the table can badge "paid" vs "waived". Null when the club owes + # or was never billed. + covered_until=Subquery(_covered.values("period_end")[:1], output_field=DateField()), + covered_status=Subquery(_covered.values("status")[:1]), + ) + .annotate(teams_without_coach=F("team_count") - F("teams_managed")) + .order_by("name") + ) + + +def platform_totals(): + return { + "clubs": Club.objects.active().count(), + "archived_clubs": Club.objects.archived().count(), + "members": Member.objects.count(), + "admins": ClubRole.objects.filter(role=ClubRole.Roles.ADMIN).count(), + } + + +def clubs_without_a_season(today=None): + """Clubs with no season covering today. + + Not cosmetic: seasons scope memberships, rosters and events, so a club without + one cannot take a signup or schedule a match. It fails silently — nothing errors, + the club is simply inert — which is exactly why it belongs on a dashboard. + """ + today = today or timezone.localdate() + return Club.objects.active().exclude(seasons__start_date__lte=today, seasons__end_date__gte=today) + + +def dormant_clubs(days=DORMANT_DAYS): + """Active clubs with nothing on the calendar in the next ``days``. Churn signal.""" + now = timezone.now() + return Club.objects.active().exclude(events__start__gte=now, events__start__lte=now + timedelta(days=days)) + + +def admins_pending_mfa(): + """Privileged users who have not enrolled a second factor. + + They are locked out until they do (RequireMFAMiddleware redirects them to the + enrolment page), so this is a support queue rather than a statistic. The rule is + the middleware's own: platform staff, plus anyone holding an elevated ClubRole. + """ + User = get_user_model() + elevated = User.objects.filter(Q(is_staff=True) | Q(is_superuser=True) | Q(member__roles__role__in=ELEVATED_ROLES)) + + return elevated.exclude(pk__in=Authenticator.objects.values("user")).distinct() + + +def onboarding_funnel(): + """How far each active club got: created → has members → has a team → has events. + + Separates working clubs from empty shells someone created and walked away from, + and shows which step people stall on. + """ + clubs = clubs_with_totals(Club.objects.active()) + total = len(clubs) + + return [ + {"label": _("Clubs"), "count": total, "icon": "building-2"}, + {"label": _("With members"), "count": sum(1 for club in clubs if club.member_count), "icon": "users"}, + {"label": _("With a team"), "count": sum(1 for club in clubs if club.team_count), "icon": "trophy"}, + {"label": _("With events"), "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"}, + ] + + +def flags_for_club(club): + """Every flag, annotated with whether it is on for this club and why.""" + enabled_ids = set(club.flags.values_list("pk", flat=True)) + return [ + { + "flag": flag, + "enabled": flag.pk in enabled_ids, + # `everyone` overrides club targeting, so the per-club toggle is moot. + "overridden": flag.everyone is not None, + } + for flag in get_waffle_flag_model().objects.order_by("name") + ] + + +def flag_adoption(): + """Clubs per feature flag. `everyone` overrides club targeting, so a flag set that + way is on (or off) everywhere and its club count says nothing — hence `overridden`.""" + Flag = get_waffle_flag_model() + + return [{"name": flag.name, "clubs": flag.clubs.count(), "everyone": flag.everyone, "overridden": flag.everyone is not None} for flag in Flag.objects.annotate(club_total=Count("clubs")).order_by("name")] + + +def platform_attention(): + """The numbers that are supposed to be zero. A dashboard of healthy counts is a + dashboard nobody opens.""" + members = Member.objects.count() + + return { + "clubs_without_season": clubs_without_a_season().count(), + "dormant_clubs": dormant_clubs().count(), + "admins_pending_mfa": admins_pending_mfa().count(), + "outstanding": _money(Order.objects.filter(status__in=OWED_STATUSES)), + "members_without_login": Member.objects.filter(user__isnull=True).count(), + "members": members, + # Platform billing: what the clubs owe US. Distinct from `outstanding`, which is + # what members owe their clubs — that money is never ours. + "dues_owed": _dues_owed(), + "dues_in_grace": dues_in_grace().count(), + "dues_overdue": dues_overdue().count(), + "clubs_unbilled": Club.objects.active().filter(subscription__isnull=True).count(), + # Normally ~0: the renewal job keeps it there. A number that sits here means cron is + # dead, and a club is about to use the platform for free — silently, because nothing is + # owed, so no other number on this page would go red. + "renewals_pending": len(subscriptions_due_for_renewal()), + } + + +def _dues_owed(): + """What clubs owe the platform right now.""" + owed = Due.objects.filter(status__in=Due.OWING).aggregate(total=Sum(F("amount") - F("amount_paid")))["total"] + + return owed or ZERO + + +def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY): + """A dense month-by-month series — zero-filled, because a chart that silently skips + empty months draws a smooth line over a month where nothing happened.""" + start = (timezone.now() - relativedelta(months=months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + rows = queryset.filter(**{f"{field}__gte": start}).annotate(month=TruncMonth(field)).values("month").annotate(value=value).order_by("month") + found = {row["month"].strftime("%Y-%m"): row["value"] or 0 for row in rows if row["month"]} + + series, cursor = [], start + while cursor <= timezone.now(): + key = cursor.strftime("%Y-%m") + series.append({"month": cursor.strftime("%b %Y"), "value": float(found.get(key, 0))}) + cursor = (cursor + timedelta(days=32)).replace(day=1) + + return series + + +def platform_charts(): + return { + "signups": signup_split(), + # Two different pots of money: `dues` is platform income (clubs paying us), while + # `club_revenue` is members paying their clubs — never ours, and labelling it + # "revenue" on our dashboard would be a lie. + "dues": _monthly(DuePayment.objects.all(), "paid_at", Sum("amount")), + "club_revenue": _monthly(Order.objects.filter(status__in=PAID_STATUSES), "created", Sum("total")), + } + + +def _money(queryset): + return queryset.aggregate(total=Sum("total"))["total"] or ZERO + + +def previous_season(club, season): + """The season immediately before ``season``. Seasons are ordered by name (which is + derived from the years), so go by the date instead — a club may skip a year.""" + if season is None: + return None + + return Season.objects.filter(club=club, end_date__lt=season.start_date).order_by("-end_date").first() + + +def renewal_rate(club, season): + """Share of last season's active members who signed up again. + + The single best health signal a club has, and it is exactly computable here because + memberships are season-scoped. Returns None when there is no season to compare + against — a first-season club has not failed to renew anyone, and rendering that as + 0% would libel it. + """ + previous = previous_season(club, season) + if previous is None: + return None + + was_active = ClubMembership.objects.filter(club=club, season=previous, status=ClubMembership.StatusChoices.ACTIVE) + total = was_active.count() + if not total: + return None + + returned = ClubMembership.objects.filter(club=club, season=season, member__in=was_active.values("member")).count() + + return round(100 * returned / total) + + +def new_members(club, season): + """Members whose first-ever season at this club is ``season``. + + Keyed on "has no membership in an earlier season", not on "signed up recently" — a + member who lapsed for a year and came back is a renewal, not a new member, and + counting them as new would flatter every recovery into growth. + """ + if season is None: + return Member.objects.none() + + seen_before = ClubMembership.objects.filter(club=club, season__start_date__lt=season.start_date).values("member") + + return Member.objects.filter(member_of__club=club, member_of__season=season).exclude(pk__in=seen_before).distinct() + + +def signup_split(club=None, months=MONTHS_OF_HISTORY): + """Signups per month, split into first-timers and returners. ``club=None`` is platform-wide. + + "First" is keyed on (club, member), never on the member alone — the same person can be + new at one club while renewing at another, and collapsing that would mark their second + club's very first signup as a renewal. + + Each member's earliest season is resolved once up front rather than per row: the same + question asked inside a loop is one query per membership. + """ + start = (timezone.now() - relativedelta(months=months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + memberships = ClubMembership.objects.all() if club is None else ClubMembership.objects.filter(club=club) + + first_season = {} + for club_id, member_id, season_start in memberships.values_list("club_id", "member_id", "season__start_date"): + key = (club_id, member_id) + if key not in first_season or season_start < first_season[key]: + first_season[key] = season_start + + counts = defaultdict(lambda: {"new": 0, "returning": 0}) + for club_id, member_id, season_start, signed_up_at in memberships.filter(signed_up_at__isnull=False, signed_up_at__gte=start).values_list("club_id", "member_id", "season__start_date", "signed_up_at"): + kind = "new" if season_start == first_season[(club_id, member_id)] else "returning" + counts[signed_up_at.strftime("%Y-%m")][kind] += 1 + + series, cursor = [], start + while cursor <= timezone.now(): + month = counts[cursor.strftime("%Y-%m")] + series.append({"month": cursor.strftime("%b %Y"), "new": month["new"], "returning": month["returning"]}) + cursor = (cursor + timedelta(days=32)).replace(day=1) + + return series + + +def teams_without_a_manager(club, season): + """Teams with nobody in a management position this season. + + A defect in the club's own setup, not a statistic: without a coach or manager the + access service grants nobody authority over that team, so nobody can pick the squad. + """ + if season is None: + return Team.objects.none() + + return Team.objects.filter(club=club).exclude(staff_assignments__season=season, staff_assignments__position__management_position=True) + + +def unrostered_members(club, season): + """Active members who are on no team this season — people who paid and play nowhere.""" + if season is None: + return Member.objects.none() + + rostered = TeamMembership.objects.filter(team__club=club, season=season).values("member") + + return Member.objects.filter(member_of__club=club, member_of__season=season, member_of__status=ClubMembership.StatusChoices.ACTIVE).exclude(pk__in=rostered).distinct() + + +def fee_aging(club): + """Unpaid orders bucketed by age. "€2,400 overdue past 60 days" drives a phone call; + "€2,400 outstanding" does not.""" + now = timezone.now() + owed = Order.objects.filter(club=club, status__in=OWED_STATUSES) + + buckets = [] + for label, older_than, newer_than in ((_("0-30 days"), 0, 30), (_("30-60 days"), 30, 60), (_("60+ days"), 60, None)): + rows = owed.filter(created__lte=now - timedelta(days=older_than)) + if newer_than is not None: + rows = rows.filter(created__gt=now - timedelta(days=newer_than)) + buckets.append({"label": label, "total": _money(rows), "count": rows.count(), "overdue": newer_than is None}) + + return buckets + + +def attendance_rates(club, season): + """Turnout, and how many never answered. + + The no-response share is the leading indicator: it measures whether members are using + the app at all, which every other number here depends on. + """ + if season is None: + return {"turnout": None, "no_response": None, "responses": 0} + + counts = Attendance.objects.filter(event__club=club, event__season=season, event__start__lt=timezone.now()).aggregate( + present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)), + absent=Count("id", filter=Q(status=Attendance.AttendanceStatus.ABSENT)), + silent=Count("id", filter=Q(status=Attendance.AttendanceStatus.NO_RESPONSE)), + total=Count("id"), + ) + + answered = counts["present"] + counts["absent"] + + return { + "turnout": round(100 * counts["present"] / answered) if answered else None, + "no_response": round(100 * counts["silent"] / counts["total"]) if counts["total"] else None, + "responses": counts["total"], + } + + +def club_attention(club): + """A club's own numbers that are supposed to be zero.""" + season = Season.covering(club, timezone.localdate()) + memberships = ClubMembership.objects.filter(club=club) + + return { + "season": season, + "no_season": season is None, + "outstanding": _money(Order.objects.filter(club=club, status__in=OWED_STATUSES)), + "aging": fee_aging(club), + "unpaid_members": memberships.filter(season=season, fee_status=ClubMembership.FeeStatus.UNPAID).count() if season else 0, + "pending_approvals": memberships.filter(status=ClubMembership.StatusChoices.PENDING).count(), + "teams_without_manager": teams_without_a_manager(club, season).count(), + "unrostered": unrostered_members(club, season).count(), + "new_members": new_members(club, season).count(), + "renewal_rate": renewal_rate(club, season), + "attendance": attendance_rates(club, season), + } + + +def club_charts(club): + season = Season.covering(club, timezone.localdate()) + memberships = ClubMembership.objects.filter(club=club, season=season) if season else ClubMembership.objects.none() + + return { + "signups": signup_split(club), + # Fee status this season, in the order a treasurer cares about. + "fees": [ + {"label": label, "value": memberships.filter(fee_status=status).count()} + for status, label in ( + (ClubMembership.FeeStatus.PAID, _("Paid")), + (ClubMembership.FeeStatus.PARTIALLY_PAID, _("Partial")), + (ClubMembership.FeeStatus.UNPAID, _("Unpaid")), + (ClubMembership.FeeStatus.WAIVED, _("Waived")), + ) + ], + } + + +def club_statistics(club): + """Stat groups for one club. Add new groups here as the domain grows.""" + season = Season.covering(club, timezone.localdate()) + now = timezone.now() + + memberships = ClubMembership.objects.filter(club=club) + events = Event.objects.filter(club=club) + orders = Order.objects.filter(club=club) + + return [ + { + "title": _("Members"), + "icon": "users", + "stats": [ + (_("Members"), memberships.values("member").distinct().count()), + (_("Active this season"), memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0), + (_("Pending"), memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()), + (_("Lapsed"), memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()), + ], + }, + { + "title": _("Teams & staff"), + "icon": "shield", + "stats": [ + (_("Teams"), Team.objects.filter(club=club).count()), + (_("Players this season"), TeamMembership.objects.filter(team__club=club, season=season).count() if season else 0), + (_("Staff this season"), StaffAssignment.objects.filter(team__club=club, season=season).count() if season else 0), + ], + }, + { + "title": _("Events"), + "icon": "calendar-days", + "stats": [ + (_("Upcoming"), events.filter(start__gte=now).count()), + (_("This season"), events.filter(season=season).count() if season else 0), + ], + }, + { + "title": _("Shop"), + "icon": "shopping-cart", + "stats": [ + (_("Orders"), orders.count()), + (_("Revenue"), _money(orders.filter(status__in=PAID_STATUSES))), + (_("Outstanding"), _money(orders.filter(status__in=OWED_STATUSES))), + (_("Open carts"), Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()), + ], + }, + ] diff --git a/controlpanel/templates/controlpanel/_club_admins_card.html b/controlpanel/templates/controlpanel/_club_admins_card.html new file mode 100644 index 0000000..d88fa87 --- /dev/null +++ b/controlpanel/templates/controlpanel/_club_admins_card.html @@ -0,0 +1,49 @@ +{% load lucide ui %} + +{% comment %} + Club-scoped admins, and the modals to add one / confirm removing one. Included with + `club`, `admins`, `admin_form` already in context. +{% endcomment %} +<div class="card bg-base-100 shadow"> + <div class="card-body"> + <div class="flex items-center justify-between"> + <h2 class="card-title text-base">{% lucide "shield-user" size=18 %} Club admins</h2> + <button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('club_admin_add_modal').showModal()">{% lucide "user-plus" size=16 %} Add admin</button> + </div> + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>Name</th> + <th>Email</th> + <th></th> + </tr> + </thead> + <tbody> + {% for role in admins %} + <tr> + <td>{{ role.member }}</td> + <td>{{ role.member.user.email|default:"—" }}</td> + <td class="text-right"> + <button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ role.pk|dom_id:"admin_remove_modal" }}').showModal()">{% lucide "trash-2" size=14 %} Remove</button> + </td> + </tr> + {% empty %} + <tr> + <td colspan="3" class="text-center opacity-60">No admins yet.</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> +</div> + +{% url 'controlpanel:club_admin_add' club.pk as club_admin_add_url %} +{% include "controlpanel/_modal_form.html" with modal_id="club_admin_add_modal" title="Add admin" form=admin_form action_url=club_admin_add_url submit_label="Grant admin" submit_icon="user-plus" blurb="A club admin can manage everything in this club. They will be required to set up two-factor authentication before they can sign in." %} + +{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %} +{% for role in admins %} + {% url 'controlpanel:club_admin_remove' club.pk role.pk as admin_remove_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id=role.pk|dom_id:"admin_remove_modal" title="Remove admin" body="Remove "|add:role.member.get_full_name|add:" as an admin of this club? They keep their membership — only admin rights are revoked." action_url=admin_remove_url submit_label="Remove" %} +{% endfor %} diff --git a/controlpanel/templates/controlpanel/_club_billing_card.html b/controlpanel/templates/controlpanel/_club_billing_card.html new file mode 100644 index 0000000..5fcbfe2 --- /dev/null +++ b/controlpanel/templates/controlpanel/_club_billing_card.html @@ -0,0 +1,138 @@ +{% load lucide ui %} + +{% comment %} + What the platform bills this club: plan, periods, and the modals for changing plan, + opening a period, and recording a payment. Included with `club`, `subscription`, + `dues`, `today`, `subscription_form`, `open_period_form`, `open_period_blurb` already + in context. +{% endcomment %} +<div class="card mb-6 bg-base-100 shadow"> + <div class="card-body"> + <div class="flex flex-wrap items-center justify-between gap-2"> + <h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2> + <div class="flex flex-wrap gap-2"> + <button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()"> + {% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %} + </button> + {% if not subscription %} + <button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('trial_modal').showModal()"> + {% lucide "hourglass" size=14 %} Start trial + </button> + {% endif %} + {% if subscription %} + <button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('open_period_modal').showModal()"> + {% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %} + </button> + {% endif %} + </div> + </div> + + {% if not subscription %} + <p class="text-sm opacity-70">This club is not billed for anything. Put it on a tier to start.</p> + {% else %} + <p class="text-sm opacity-70"> + On plan <strong>{{ subscription.tier.name }}</strong>. + {% if subscription.trial_ends_at %} + <span class="badge badge-info badge-sm gap-1">{% lucide "hourglass" size=12 %} Trial</span> + On trial until {{ subscription.trial_ends_at|date:"j M Y" }}, then switches to <strong>{{ subscription.post_trial_tier.name }}</strong>. + {% endif %} + {% if subscription.auto_renew %} + Renews automatically 30 days before the period ends. + {% else %} + <span class="badge badge-warning badge-sm">Auto-renew off</span> — you must open each period by hand, or this club uses the platform for free. + {% endif %} + {% if subscription.auto_archive %} + Archived automatically when a period goes unpaid past its grace period. + {% else %} + <span class="badge badge-warning badge-sm">Auto-archive off</span> — it will never be archived for non-payment. + {% endif %} + </p> + + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>Period</th> + <th class="text-right">Billed</th> + <th class="text-right">Paid</th> + <th class="text-right">Status</th> + <th></th> + </tr> + </thead> + <tbody> + {% for due in dues %} + <tr> + <td> + {{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }} + <div class="text-xs opacity-60">{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div> + </td> + <td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td> + <td class="text-right tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td> + <td class="text-right"> + {% if due.status == "paid" %} + <span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span> + {% elif due.status == "waived" %} + <span class="badge badge-outline gap-1">{% lucide "check" size=12 %} Waived</span> + {% elif due.grace_until < today %} + <span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span> + {% elif due.period_end < today %} + <span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span> + {% else %} + <span class="badge badge-outline">{{ due.get_status_display }}</span> + {% endif %} + </td> + <td class="text-right flex flex-row gap-2 justify-end"> + {% if due.is_owing %} + <button class="btn btn-primary btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Add payment</button> + {% if not due.payments.all %} + <form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}"> + {% csrf_token %} + <button class="btn btn-outline btn-sm gap-1" type="submit">{% lucide "ban" size=14 %} Waive payment</button> + </form> + {% endif %} + {% endif %} + <a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a> + </td> + </tr> + {% for payment in due.payments.all %} + <tr class="text-xs opacity-70"> + <td colspan="2" class="pl-8"> + {% lucide "corner-down-right" size=12 %} + {{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %} + </td> + <td class="text-right tabular-nums">€{{ payment.amount|floatformat:2 }}</td> + <td colspan="2"></td> + </tr> + {% endfor %} + {% empty %} + <tr> + <td colspan="5" class="text-center opacity-60">No periods billed yet.</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + + {% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %} + {% for due in dues %} + {% if due.is_owing %} + {% url 'controlpanel:due_pay' due.pk as due_pay_url %} + {% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment" form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %} + {% endif %} + {% endfor %} + {% endif %} + </div> +</div> + +{% url 'controlpanel:club_subscribe' club.pk as subscribe_url %} +{% include "controlpanel/_modal_form.html" with modal_id="subscription_modal" title=subscription|yesno:"Change plan,Start billing" form=subscription_form action_url=subscribe_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %} + +{% if not subscription %} + {% url 'controlpanel:club_trial_start' club.pk as trial_start_url %} + {% include "controlpanel/_modal_form.html" with modal_id="trial_modal" title="Start trial" form=trial_form action_url=trial_start_url submit_label="Start trial" submit_icon="hourglass" blurb="The trial period is billed like any other, on the trial tier you pick. It switches to the plan you pick here automatically the next time a period is opened after it ends -- no follow-up needed." %} +{% endif %} + +{% if subscription %} + {% url 'controlpanel:club_open_period' club.pk as open_period_url %} + {% include "controlpanel/_modal_form.html" with modal_id="open_period_modal" title=club.is_archived|yesno:"Reactivate,Open period" form=open_period_form action_url=open_period_url submit_label="Open period" submit_icon="calendar-plus" blurb=open_period_blurb %} +{% endif %} diff --git a/controlpanel/templates/controlpanel/_club_features_card.html b/controlpanel/templates/controlpanel/_club_features_card.html new file mode 100644 index 0000000..e335918 --- /dev/null +++ b/controlpanel/templates/controlpanel/_club_features_card.html @@ -0,0 +1,45 @@ +{% load lucide %} + +{% comment %} + Which feature flags apply to this club. Included with `club`, `flags` (from + `flags_for_club`) already in context. +{% endcomment %} +<div class="card mb-6 bg-base-100 shadow"> + <div class="card-body"> + <div class="flex items-center justify-between"> + <h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2> + <a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a> + </div> + <div class="overflow-x-auto"> + <table class="table"> + <tbody> + {% for entry in flags %} + <tr> + <td class="font-mono font-medium">{{ entry.flag.name }}</td> + <td class="opacity-70">{{ entry.flag.note|default:"—" }}</td> + <td class="text-right"> + {% if entry.overridden %} + {# `everyone` overrides club targeting, so a per-club toggle would be a lie. #} + <span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}"> + {% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %} + </span> + {% else %} + <form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}"> + {% csrf_token %} + <button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit"> + {% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %} + </button> + </form> + {% endif %} + </td> + </tr> + {% empty %} + <tr> + <td class="text-center opacity-60">No features defined yet.</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> +</div> diff --git a/controlpanel/templates/controlpanel/_club_health_table.html b/controlpanel/templates/controlpanel/_club_health_table.html new file mode 100644 index 0000000..3613f14 --- /dev/null +++ b/controlpanel/templates/controlpanel/_club_health_table.html @@ -0,0 +1,135 @@ +{% load lucide %} + +{% comment %} + The club table, shared by the dashboard and the clubs list so the two cannot drift apart. + Health, not vanity: a member total says nothing you can act on, while "no coach", + "nothing scheduled", "€ owed" and "no admins" each name something somebody has to go and + fix. Every column is annotated by clubs_with_health() in a single query. + + Expects: clubs (from clubs_with_health), and optionally empty_message. +{% endcomment %} +<div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>Club</th> + <th></th> + <th class="text-right">Members</th> + <th class="text-right">Admins</th> + <th class="text-right">Teams</th> + <th class="text-right">Events</th> + <th class="text-right">Plan</th> + <th class="text-right">Dues</th> + <th class="text-right">Plan end</th> + <th></th> + </tr> + </thead> + <tbody> + {% for club in clubs %} + <tr> + <td> + <div class="flex flex-row items-center gap-4"> + <div> + {% if club.logo %} + <img class="h-12 w-12 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}"> + {% else %} + <div class="avatar avatar-placeholder"> + <div class="w-12 rounded-full bg-neutral text-neutral-content"> + <span>{{ club.initials }}</span> + </div> + </div> + {% endif %} + </div> + + <div class="flex flex-col gap-1"> + <a class="link link-hover font-semibold tracking-wide" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a> + <div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}</div> + </div> + </div> + </td> + + <td> + <div class="flex flex-row gap-2"> + {% if club.is_archived %} + <span class="badge badge-warning">{% lucide "archive" size=14 %} archived</span> + {% else %} + {% if not club.has_season %} + <span class="badge badge-warning">{% lucide "calendar-x" size=14 %} no seasons</span> + {% endif %} + + {% if not club.upcoming_events %} + <span class="badge badge-ghost badge-outline">{% lucide "moon-star" size=14 %}dormant</span> + {% endif %} + {% endif %} + </div> + </td> + + <td class="text-right tabular-nums">{{ club.active_members }}</td> + <td class="text-right tabular-nums"> + <div class="flex flex-row gap-2 items-center justify-end"> + {% if not club.admin_count %} + <span class="text-error">{% lucide "triangle-alert" size=16 %}</span> + {% endif %} + <span class="{% if not club.admin_count %}font-bold text-error{% endif %}">{{ club.admin_count }}</span> + </div> + </td> + <td class="text-right tabular-nums">{{ club.team_count }}</td> + <td class="text-right tabular-nums">{{ club.upcoming_events }}</td> + + <td class="text-right"> + {% if club.tier_name %} + <span class="badge badge-accent">{{ club.tier_name|lower }}</span> + {% else %} + - + {% endif %} + </td> + + <td class="text-right"> + <div class="flex flex-row gap-2 items-center justify-end"> + {% if not club.dues_owed %} + {% if club.tier_name %} + {% comment %} + Not owing and on a plan. covered_until is the settled period's end — the day + grace would start if nothing renews — shown on its own row under the badge, + for a paid period AND a waived one (both cover the club, they just differ in + how). No covered period at all (only cancelled dues, say) shows a dash. + {% endcomment %} + <div class="flex flex-col items-end gap-1"> + {% if club.covered_status == "waived" %} + <span class="badge badge-ghost badge-outline">waived</span> + {% elif club.covered_until %} + <span class="badge badge-success">paid</span> + {% else %} + - + {% endif %} + </div> + {% else %} + - + {% endif %} + {% else %} + <span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span> + {% if club.dues_grace_until < today %} + <span class="badge badge-error">overdue</span> + {% elif club.dues_period_end < today %} + <span class="badge badge-warning">grace</span> + {% endif %} + {% endif %} + </div> + </td> + + <td class="text-right"> + <span class="whitespace-nowrap">{{ club.covered_until|date:"j M Y"|default:"-" }}</span> + </td> + + <td> + <a class="btn btn-outline btn-sm gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a> + </td> + </tr> + {% empty %} + <tr> + <td colspan="9" class="text-center opacity-60">{{ empty_message|default:"No clubs yet." }}</td> + </tr> + {% endfor %} + </tbody> + </table> +</div> diff --git a/controlpanel/templates/controlpanel/_club_home_location_card.html b/controlpanel/templates/controlpanel/_club_home_location_card.html new file mode 100644 index 0000000..6e0a449 --- /dev/null +++ b/controlpanel/templates/controlpanel/_club_home_location_card.html @@ -0,0 +1,39 @@ +{% load lucide %} + +{% comment %} + The club's home ground -- an events.Location row flagged is_home, so setting or + editing it here creates/updates the very same Location the club's own Teams > + Locations page shows, no separate sync step involved. Included with `club`, + `home_location`, `home_location_form` already in context. +{% endcomment %} +<div class="card mb-6 bg-base-100 shadow"> + <div class="card-body"> + <div class="flex items-center justify-between"> + <h2 class="card-title text-base">{% lucide "map-pin" size=18 %} Home location</h2> + <button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('club_home_location_modal').showModal()"> + {% if home_location %} + {% lucide "pencil" size=16 %} Edit + {% else %} + {% lucide "plus" size=16 %} Set home location + {% endif %} + </button> + </div> + {% if home_location %} + <dl class="divide-y divide-base-200"> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">Name</dt> + <dd class="font-semibold">{{ home_location.name }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">Address</dt> + <dd>{{ home_location.address }}, {{ home_location.zip_code }} {{ home_location.city }}, {{ home_location.country }}</dd> + </div> + </dl> + {% else %} + <p class="text-sm opacity-60">Not set yet. Once set, events at this location can be recognised as home games.</p> + {% endif %} + </div> +</div> + +{% url 'controlpanel:club_home_location_set' club.pk as home_location_url %} +{% include "controlpanel/_modal_form.html" with modal_id="club_home_location_modal" title="Home location" form=home_location_form action_url=home_location_url submit_label="Save" submit_icon="save" blurb="This creates or updates a Location for the club -- the same one that shows up on its own Teams > Locations page." %} diff --git a/controlpanel/templates/controlpanel/_confirm_modal.html b/controlpanel/templates/controlpanel/_confirm_modal.html new file mode 100644 index 0000000..3b89361 --- /dev/null +++ b/controlpanel/templates/controlpanel/_confirm_modal.html @@ -0,0 +1,29 @@ +{% load i18n lucide %} + +{% comment %} + A daisyUI native <dialog> confirmation modal for a destructive POST action with no + fields of its own. Included with `modal_id`, `title`, `body`, `action_url`, and + optional `submit_label` (default "Confirm"), `submit_icon` (default "trash-2"). The + submit button sits outside the form tag (linked via the `form` attribute), same as + `_modal_form.html`, so it can share the `modal-action` row with the dialog-closing + Cancel button without nesting one <form> inside another. +{% endcomment %} +{% trans "Confirm" as default_submit_label %} +<dialog id="{{ modal_id }}" class="modal"> + <div class="modal-box"> + <h3 class="text-lg font-bold">{{ title }}</h3> + <p class="py-2 text-sm opacity-70">{{ body }}</p> + <form method="post" action="{{ action_url }}" id="{{ modal_id }}-form"> + {% csrf_token %} + </form> + <div class="modal-action"> + <form method="dialog"> + <button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button> + </form> + <button class="btn btn-error gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"trash-2" size=16 %} {{ submit_label|default:default_submit_label }}</button> + </div> + </div> + <form method="dialog" class="modal-backdrop"> + <button>close</button> + </form> +</dialog> diff --git a/controlpanel/templates/controlpanel/_form_fields.html b/controlpanel/templates/controlpanel/_form_fields.html new file mode 100644 index 0000000..75b4c70 --- /dev/null +++ b/controlpanel/templates/controlpanel/_form_fields.html @@ -0,0 +1,17 @@ +{% load ui %} + +{% comment %} + The field loop every card-form and modal-form wrapper shares: label, daisyUI-styled + widget, help text, errors — with checkboxes laid out label-beside-input instead of + label-above. Included with `form`. +{% endcomment %} +{% for error in form.non_field_errors %} + <div class="alert alert-error my-2"> + <span>{{ error }}</span> + </div> +{% endfor %} +{% for field in form %} + <div class="form-control my-3 w-full"> + {% form_field field %} + </div> +{% endfor %} diff --git a/controlpanel/templates/controlpanel/_modal_form.html b/controlpanel/templates/controlpanel/_modal_form.html new file mode 100644 index 0000000..9b545a1 --- /dev/null +++ b/controlpanel/templates/controlpanel/_modal_form.html @@ -0,0 +1,30 @@ +{% load i18n lucide %} + +{% comment %} + A daisyUI native <dialog> modal wrapping a Django form that posts straight to + `action_url`. Included with `modal_id`, `title`, `form`, `action_url`, `submit_label` + and `submit_icon`, plus an optional `blurb`. The submit button sits outside the form + tag (linked via the `form` attribute) so it can share the `modal-action` row with the + dialog-closing Cancel button without nesting one <form> inside another. `enctype` is + always multipart -- harmless for a form with no file field, required for one that has. +{% endcomment %} +{% trans "Save" as default_submit_label %} +<dialog id="{{ modal_id }}" class="modal"> + <div class="modal-box"> + <h3 class="text-lg font-bold">{{ title }}</h3> + {% if blurb %}<p class="py-2 text-sm opacity-70">{{ blurb }}</p>{% endif %} + <form method="post" action="{{ action_url }}" id="{{ modal_id }}-form" enctype="multipart/form-data"> + {% csrf_token %} + {% include "controlpanel/_form_fields.html" %} + </form> + <div class="modal-action"> + <form method="dialog"> + <button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button> + </form> + <button class="btn btn-primary gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:default_submit_label }}</button> + </div> + </div> + <form method="dialog" class="modal-backdrop"> + <button>close</button> + </form> +</dialog> diff --git a/controlpanel/templates/controlpanel/_nav_items.html b/controlpanel/templates/controlpanel/_nav_items.html new file mode 100644 index 0000000..c3d9465 --- /dev/null +++ b/controlpanel/templates/controlpanel/_nav_items.html @@ -0,0 +1,37 @@ +{% load lucide %} + +{% comment %} + The panel's navigation, in one place: the sidebar renders it on a wide screen and the + collapsed menu renders it on a narrow one. Two copies of a link list is how a new section + ends up reachable on a desktop and invisible on a phone. + + `menu-active` is daisyUI 5's active state; hover and focus come with `.menu` itself. +{% endcomment %} +<li> + <a class="{% if nav == 'dashboard' %}menu-active{% endif %}" href="{% url 'controlpanel:dashboard' %}"> + {% lucide "layout-dashboard" size=16 %} Dashboard + </a> +</li> +<li> + <a class="{% if nav == 'clubs' %}menu-active{% endif %}" href="{% url 'controlpanel:club_list' %}"> + {% lucide "building-2" size=16 %} Clubs + </a> +</li> +<li> + <a class="{% if nav == 'billing' %}menu-active{% endif %}" href="{% url 'controlpanel:billing' %}"> + {% lucide "receipt-euro" size=16 %} Billing + </a> +</li> +<li> + <a class="{% if nav == 'features' %}menu-active{% endif %}" href="{% url 'controlpanel:features' %}"> + {% lucide "toggle-right" size=16 %} Features + </a> +</li> +{% if user.is_superuser %} + {# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #} + <li> + <a class="{% if nav == 'admins' %}menu-active{% endif %}" href="{% url 'controlpanel:admins' %}"> + {% lucide "user-cog" size=16 %} Platform admins + </a> + </li> +{% endif %} diff --git a/controlpanel/templates/controlpanel/admins.html b/controlpanel/templates/controlpanel/admins.html new file mode 100644 index 0000000..2d2d9ba --- /dev/null +++ b/controlpanel/templates/controlpanel/admins.html @@ -0,0 +1,80 @@ +{% extends "controlpanel/base.html" %} +{% load lucide ui %} + +{% block heading %}Platform admins{% endblock heading %} + +{% block subheading %} + <p class="text-sm opacity-70">Staff run the panel. Superusers additionally manage this list.</p> +{% endblock subheading %} + +{% block actions %} + <button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('admin_add_modal').showModal()">{% lucide "user-plus" size=16 %} Grant access</button> +{% endblock actions %} + +{% block panel %} + {% url 'controlpanel:admin_add' as admin_add_url %} + {% include "controlpanel/_modal_form.html" with modal_id="admin_add_modal" title="Grant platform access" form=admin_form action_url=admin_add_url submit_label="Grant access" submit_icon="user-plus" blurb="Platform admins can manage every club. They must set up two-factor authentication before they can sign in." %} + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>User</th> + <th>Staff</th> + <th>Superuser</th> + <th>Last login</th> + <th></th> + </tr> + </thead> + <tbody> + {% for admin in admins %} + <tr> + <td> + <div class="font-medium">{{ admin.email }}</div> + {% if admin.pk == user.pk %} + <div class="text-xs opacity-60">That's you</div>{% endif %} + </td> + <td> + <form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}"> + {% csrf_token %} + <input type="hidden" name="is_staff" value="{% if admin.is_staff %}0{% else %}1{% endif %}"> + <input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}1{% else %}0{% endif %}"> + <button class="btn btn-sm gap-1 {% if admin.is_staff %}btn-success{% else %}btn-outline{% endif %}" type="submit"> + {% if admin.is_staff %}{% lucide "user" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %} + </button> + </form> + </td> + <td> + <form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}"> + {% csrf_token %} + <input type="hidden" name="is_staff" value="{% if admin.is_staff %}1{% else %}0{% endif %}"> + <input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}0{% else %}1{% endif %}"> + <button class="btn btn-sm gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-outline{% endif %}" type="submit"> + {% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %} + </button> + </form> + </td> + <td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td> + <td class="text-right"> + <button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ admin.pk|dom_id:"admin_revoke_modal" }}').showModal()">{% lucide "user-minus" size=14 %} Revoke</button> + </td> + </tr> + {% empty %} + <tr> + <td colspan="5" class="text-center opacity-60">No platform admins.</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + + {% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %} + {% for admin in admins %} + {% url 'controlpanel:admin_revoke' admin.pk as admin_revoke_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id=admin.pk|dom_id:"admin_revoke_modal" title="Revoke platform access" body="Revoke platform access for "|add:admin.email|add:"? They will no longer be able to reach the control panel." action_url=admin_revoke_url submit_label="Revoke" submit_icon="user-minus" %} + {% endfor %} + </div> + </div> +{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/base.html b/controlpanel/templates/controlpanel/base.html new file mode 100644 index 0000000..b1e1133 --- /dev/null +++ b/controlpanel/templates/controlpanel/base.html @@ -0,0 +1,52 @@ +{% extends "_platform_base.html" %} +{% load lucide %} + +{% block title %} + {% block panel_title %}Control panel{% endblock panel_title %} · RosterChief +{% endblock title %} + +{% block menu %} + {% comment %} + Outside <main>, so it never scrolls with the content. Its own overflow-y-auto is for + the day the menu itself grows taller than the screen. + {% endcomment %} + <aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block"> + <ul class="menu w-full gap-1 p-3 mt-4"> + {% include "controlpanel/_nav_items.html" %} + </ul> + </aside> +{% endblock menu %} + +{% block main %} + {% if maintenance_on %} + <div class="alert alert-error mb-6"> + {% lucide "wrench" size=20 %} + <span> + <strong>The platform is currently closed for maintenance.</strong> + Clubs see a maintenance page and the scheduled jobs are standing down. + </span> + <a class="btn btn-sm gap-2 btn-error btn-soft" href="{% url 'controlpanel:features' %}">{% lucide "unlock" size=16 %} Reopen platform</a> + </div> + {% endif %} + <div class="mb-6 flex flex-wrap flex-row items-center justify-between gap-3"> + {% block logo %}{% endblock logo %} + + <div class="flex flex-col gap-2 grow"> + <h1 class="text-3xl font-bold"> + {% block heading %}Control panel{% endblock heading %} + </h1> + <span class="text-sm text-base-content/50">{% block subheading %}{% endblock subheading %}</span> + </div> + + <div class="flex gap-2"> + {% block actions %}{% endblock actions %} + </div> + </div> + + {# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #} + <ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden"> + {% include "controlpanel/_nav_items.html" %} + </ul> + + {% block panel %}{% endblock panel %} +{% endblock main %} diff --git a/controlpanel/templates/controlpanel/billing.html b/controlpanel/templates/controlpanel/billing.html new file mode 100644 index 0000000..2487280 --- /dev/null +++ b/controlpanel/templates/controlpanel/billing.html @@ -0,0 +1,135 @@ +{% extends "controlpanel/base.html" %} +{% load lucide ui %} + +{% block heading %}Billing{% endblock heading %} + +{% block actions %} + <button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('tier_create_modal').showModal()">{% lucide "plus" size=16 %} New plan</button> +{% endblock actions %} + +{% block panel %} + {% url 'controlpanel:tier_create' as tier_create_url %} + {% include "controlpanel/_modal_form.html" with modal_id="tier_create_modal" title="New plan" form=tier_form action_url=tier_create_url submit_label="Create plan" submit_icon="plus" %} + + <div class="card mb-6 bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "layers" size=18 %} Plans</h2> + {% comment %} + Prices are dated, not edited. A rate change is a new row with a future + active_from; every period already opened keeps the amount it was billed at, + so raising the price cannot rewrite an invoice you have already sent. + {% endcomment %} + <p class="text-sm opacity-70">A rate change only takes effect as of a certain date. Periods already billed keep the amount they were issued at.</p> + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>Plan</th> + <th class="text-right">Clubs</th> + <th>Prices</th> + <th></th> + </tr> + </thead> + <tbody> + {% for tier in tiers %} + <tr> + <td> + <div class="font-medium">{{ tier.name }}</div> + {% if not tier.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %} + {% if tier.description %} + <div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %} + </td> + <td class="text-right tabular-nums">{{ tier.club_count }}</td> + <td> + {% for price in tier.prices.all %} + <div class="text-sm tabular-nums"> + €{{ price.amount|floatformat:2 }} + <span class="opacity-60">from {{ price.active_from|date:"j M Y" }}</span> + {% if price.active_from > today %}<span class="badge badge-info badge-xs">Scheduled</span>{% endif %} + </div> + {% empty %} + <span class="badge badge-error badge-sm">No price — cannot be billed</span> + {% endfor %} + </td> + <td class="flex flex-row gap-2 justify-end"> + <button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button> + <button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button> + </td> + </tr> + {% empty %} + <tr> + <td colspan="4" class="text-center opacity-60">No plans yet.</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> + + {% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %} + {% for tier in tiers %} + {% url 'controlpanel:tier_price_create' tier.pk as tier_price_url %} + {% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_price_modal" title="New price — "|add:tier.name form=tier.price_form action_url=tier_price_url submit_label="Add price" submit_icon="euro" %} + + {% url 'controlpanel:tier_update' tier.pk as tier_update_url %} + {% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_edit_modal" title="Edit "|add:tier.name form=tier.edit_form action_url=tier_update_url submit_label="Save" submit_icon="check" %} + {% endfor %} + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2> + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>Club</th> + <th>Period</th> + <th class="text-right">Owed</th> + <th class="text-right">Status</th> + <th></th> + </tr> + </thead> + <tbody> + {% for due in owing %} + <tr> + <td> + <a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' due.club.pk %}">{{ due.club.name }}</a> + <div class="text-xs opacity-60">{{ due.tier.name }}</div> + </td> + <td class="text-sm"> + {{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }} + <div class="text-xs opacity-60">Grace to {{ due.grace_until|date:"j M Y" }}</div> + </td> + <td class="text-right font-semibold tabular-nums">€{{ due.balance|floatformat:2 }}</td> + <td class="text-right"> + {% if due.grace_until < today %} + <span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span> + {% elif due.period_end < today %} + <span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span> + {% else %} + <span class="badge badge-outline">{{ due.get_status_display }}</span> + {% endif %} + </td> + <td class="text-right flex flex-row gap-2 justify-end"> + <button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Record payment</button> + <a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a> + </td> + </tr> + {% empty %} + <tr> + <td colspan="5" class="text-center opacity-60">Nothing outstanding.</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> + + {% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %} + {% for due in owing %} + {% url 'controlpanel:due_pay' due.pk as due_pay_url %} + {% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment — "|add:due.club.name form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %} + {% endfor %} +{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/club_detail.html b/controlpanel/templates/controlpanel/club_detail.html new file mode 100644 index 0000000..384699d --- /dev/null +++ b/controlpanel/templates/controlpanel/club_detail.html @@ -0,0 +1,268 @@ +{% extends "controlpanel/base.html" %} +{% load static lucide %} + +{% block logo %} + {% if club.logo %} + {% comment %} + The ring is the club's own primary colour, same as on the club's own subdomain -- + but the control panel never injects a page-wide --color-primary override (it must + not dress itself up as the club), so it's set here as a locally-scoped custom + property instead: it only reaches this element and its children, not the rest of + the panel's buttons and badges. + {% endcomment %} + <div class="avatar"> + <div class="w-16 rounded-full bg-base-100 ring-2 ring-primary ring-offset-2 ring-offset-base-100" {% if club.primary_color %}style="--color-primary: {{ club.primary_color }};"{% endif %}> + <img class="club-logo object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}"> + </div> + </div> + {% else %} + <div class="avatar avatar-placeholder"> + <div class="w-16 text-xl rounded-full bg-neutral text-neutral-content"> + <span>{{ club.initials }}</span> + </div> + </div> + {% endif %} +{% endblock logo %} + +{% block heading %}{{ club.name }}{% endblock heading %} + +{% block subheading %} + {{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }} + {% if club.is_archived %} + <span class="badge badge-warning badge-sm ml-2">Archived</span> + {% endif %} +{% endblock subheading %} + +{% block actions %} + <a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a> + <a class="btn btn-primary gap-2" href="https://{{ club.slug }}.rosterchief.app">{% lucide "external-link" size=16 %} Open</a> + {% if club.is_archived %} + <form method="post" action="{% url 'controlpanel:club_restore' club.pk %}"> + {% csrf_token %} + <button class="btn btn-success gap-2" type="submit">{% lucide "archive-restore" size=16 %} Restore</button> + </form> + {% else %} + <form method="post" action="{% url 'controlpanel:club_archive' club.pk %}"> + {% csrf_token %} + <button class="btn btn-warning gap-2" type="submit">{% lucide "archive" size=16 %} Archive</button> + </form> + {% endif %} +{% endblock actions %} + +{% block panel %} + {% if club.is_archived %} + <div class="alert alert-warning mb-6"> + {% lucide "alert-triangle" size=20 %} + <span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span> + </div> + {% endif %} + {% if attention.no_season %} + <div class="alert alert-warning mb-6"> + {% lucide "calendar-x" size=20 %} + <span> + No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert. + </span> + </div> + {% endif %} + + {% comment %} + The club's own numbers that should be zero. Teams without a manager is a defect in + the club's setup, not a statistic: with nobody in a management position the access + service grants no authority over that team, so nobody can pick the squad. + {% endcomment %} + <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6"> + {% comment %}<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div> + <div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div> + <div class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div> + </div> + </div>{% endcomment %} + <div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} Teams without coach</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.teams_without_manager }}</div> + <div class="text-xs opacity-60">Teams nobody can pick a squad for</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 {% if attention.unrostered %}border-warning{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} Unrostered members</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div> + <div class="text-xs opacity-60">Active members on no team</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}border-warning{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} Pending</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div> + <div class="text-xs opacity-60">Memberships awaiting approval</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 border-info"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} New members</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div> + {# First season at this club — someone returning after a year away is a renewal. #} + <div class="text-xs opacity-60">First season at this club</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 {% if attention.renewal_rate is None %}border-info{% elif attention.renewal_rate < 30 %}border-error{% elif attention.renewal_rate < 65 %}border-warning{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} Renewal rate</div> + <div class="text-4xl font-bold tabular-nums font-mono"> + {% if attention.renewal_rate is None %} + N/A + {% else %} + {{ attention.renewal_rate }}% + {% endif %} + </div> + <div class="text-xs opacity-60"> + {% if attention.renewal_rate is None %} + No previous season + {% else %} + <progress class="progress w-full {% if attention.renewal_rate < 30 %}progress-error{% elif attention.renewal_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.renewal_rate }}" + max="100"></progress> + {% endif %} + </div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} Attendance rate</div> + <div class="text-4xl font-bold tabular-nums font-mono"> + {% if attention.attendance.turnout is None %} + N/A + {% else %} + {{ attention.attendance.turnout }}% + {% endif %} + </div> + <div class="text-xs opacity-60"> + {% if attention.attendance.turnout is None %} + No events this season + {% else %} + <progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}" + value="{{ attention.attendance.turnout }}" max="100"></progress> + {% endif %} + </div> + </div> + </div> + </div> + + + <div class="mb-6 grid gap-4 lg:grid-cols-2"> + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2> + <p class="text-sm opacity-70">New members against returning ones.</p> + <div class="h-56"> + <canvas id="signups-chart"></canvas> + </div> + </div> + </div> + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "wallet" size=18 %} Club fee status this season</h2> + <div class="h-56"> + <canvas id="fees-chart"></canvas> + </div> + </div> + </div> + </div> + + <div class="mb-6 grid gap-4 md:grid-cols-4"> + {% for group in groups %} + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2> + <dl class="divide-y divide-base-200"> + {% for label, value in group.stats %} + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{{ label }}</dt> + <dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd> + </div> + {% endfor %} + </dl> + </div> + </div> + {% endfor %} + </div> + {% include "controlpanel/_club_features_card.html" %} + {% include "controlpanel/_club_billing_card.html" %} + {% include "controlpanel/_club_home_location_card.html" %} + {% include "controlpanel/_club_admins_card.html" %} +{% endblock panel %} + +{% block extra_body %} + {{ charts|json_script:"chart-data" }} + <script src="{% static 'js/chart.js' %}"></script> + <script src="{% static 'js/searchable-select.js' %}"></script> + <script> + (() => { + const data = JSON.parse(document.getElementById("chart-data").textContent); + const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; + + const render = () => { + const ink = css("--color-base-content", "#333"); + const grid = "color-mix(in oklab, " + ink + " 15%, transparent)"; + + // Stacked: the bar height stays "signups this month" while the split shows where + // they came from. Side-by-side bars would answer a different question. + const signups = new Chart(document.getElementById("signups-chart"), { + type: "bar", + data: { + labels: data.signups.map((point) => point.month), + datasets: [ + {label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")}, + {label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")}, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: {legend: {position: "bottom", labels: {color: ink}}}, + scales: { + x: {stacked: true, ticks: {color: ink}, grid: {color: grid}}, + y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}}, + }, + }, + }); + + // Colour carries the meaning here — unpaid must read as a problem, waived must + // not — so the slices are pinned to the semantic theme colours, in order. + const fees = new Chart(document.getElementById("fees-chart"), { + type: "pie", + data: { + labels: data.fees.map((slice) => slice.label), + datasets: [ + { + data: data.fees.map((slice) => slice.value), + backgroundColor: [css("--color-success", "#16a34a"), css("--color-warning", "#f59e0b"), css("--color-error", "#dc2626"), css("--color-neutral", "#6b7280")], + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: {legend: {position: "right", labels: {color: ink}}}, + }, + }); + + return [signups, fees]; + }; + + let charts = render(); + + // "auto" removes data-theme entirely, so watch the attribute rather than a click. + new MutationObserver(() => { + charts.forEach((chart) => chart.destroy()); + charts = render(); + }).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]}); + })(); + </script> +{% endblock extra_body %} diff --git a/controlpanel/templates/controlpanel/club_form.html b/controlpanel/templates/controlpanel/club_form.html new file mode 100644 index 0000000..6aa9878 --- /dev/null +++ b/controlpanel/templates/controlpanel/club_form.html @@ -0,0 +1,46 @@ +{% extends "controlpanel/base.html" %} +{% load lucide ui %} + +{% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %} + +{% block panel %} + <div class="card w-full bg-base-100 shadow"> + <div class="card-body"> + <form method="post" enctype="multipart/form-data"> + {% csrf_token %} + + {% for error in form.non_field_errors %} + <div class="alert alert-error my-2"> + <span>{{ error }}</span> + </div> + {% endfor %} + + <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> + {% form_field form.name %} + {% form_field form.slug %} + {% form_field form.sport_type %} + </div> + + <div class="divider"></div> + + <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> + {% form_field form.logo %} + {% form_field form.primary_color %} + {% form_field form.secondary_color %} + </div> + + <div class="divider"></div> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + {% form_field form.season_start %} + {% form_field form.season_duration_months %} + </div> + + <div class="card-actions justify-start pt-2 mt-2"> + <a class="btn btn-outline gap-2" href="{% if update_view %}{% url "controlpanel:club_detail" object.pk %}{% else %}{% url "controlpanel:club_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} Cancel</a> + <button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} Save</button> + </div> + </form> + </div> + </div> +{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/club_list.html b/controlpanel/templates/controlpanel/club_list.html new file mode 100644 index 0000000..a815aa4 --- /dev/null +++ b/controlpanel/templates/controlpanel/club_list.html @@ -0,0 +1,41 @@ +{% extends "controlpanel/base.html" %} +{% load lucide %} + +{% block heading %}{% if show_archived %}Archived clubs{% else %}Clubs{% endif %}{% endblock heading %} + +{% block actions %} + {% if show_archived %} + <a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}">{% lucide "archive-x" size=16 %} Hide archived clubs</a> + {% else %} + <a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}?archived=1">{% lucide "archive" size=16 %} Show archived clubs</a> + {% endif %} + <a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a> +{% endblock actions %} + +{% block panel %} + <form method="get" class="mb-4 flex gap-2"> + {% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %} + <label class="input"> + <span class="opacity-50">{% lucide "search" size=16 %}</span> + <input type="search" + name="q" + value="{{ search }}" + placeholder="Search clubs…" + class="input input-bordered w-full max-w-xs"> + </label> + + <button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=16 %} Search</button> + {% if search %} + <a class="btn btn-primary gap-2" href="{% url "controlpanel:club_list" %}">{% lucide "x" size=16 %} Clear filter</a> + {% endif %} + </form> + <div class="card bg-base-100 shadow"> + <div class="card-body"> + {% if show_archived %} + {% include "controlpanel/_club_health_table.html" with empty_message="No archived clubs." %} + {% else %} + {% include "controlpanel/_club_health_table.html" %} + {% endif %} + </div> + </div> +{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/dashboard.html b/controlpanel/templates/controlpanel/dashboard.html new file mode 100644 index 0000000..8dd7260 --- /dev/null +++ b/controlpanel/templates/controlpanel/dashboard.html @@ -0,0 +1,199 @@ +{% extends "controlpanel/base.html" %} +{% load static lucide %} + +{% block heading %}RosterChief Platform Dashboard{% endblock heading %} +{% block subheading %}Welcome back {{ user.member.first_name }} · {% now "d b Y" %}{% endblock subheading %} + +{% block actions %} + <a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a> +{% endblock actions %} + +{% block panel %} + <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6"> + <div class="card bg-base-100 shadow border-l-4 border-info"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "building-2" size=16 %} Clubs</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ totals.clubs }}</div> + <div class="text-xs opacity-60">Managing {{ totals.members }} member{{ totals.members|pluralize }}</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 border-info"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "archive" size=16 %} Archived clubs</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ totals.archived_clubs }}</div> + <div class="text-xs opacity-60">Not accessible but data maintained</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 border-success {% if attention.clubs_without_season %}border-warning{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "calendar-x" size=16 %} No current season</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.clubs_without_season }}</div> + <div class="text-xs opacity-60">Clubs that cannot take signups</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dormant_clubs %}border-warning{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "moon-star" size=16 %} Dormant clubs</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.dormant_clubs }}</div> + <div class="text-xs opacity-60">No events scheduled next 30 days</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 border-success {% if attention.admins_pending_mfa %}border-warning{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "shield-alert" size=16 %} MFA pending</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.admins_pending_mfa }}</div> + <div class="text-xs opacity-60">Admins without MFA configured</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dues_owed %}border-warning{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "receipt-euro" size=16 %} Payment pending</div> + <div class="text-4xl font-bold tabular-nums font-mono">€{{ attention.dues_owed|floatformat:2 }}</div> + <div class="text-xs opacity-60"> + {{ attention.dues_in_grace }} in grace · + <span class="{% if attention.dues_overdue %}font-semibold text-error{% endif %}">{{ attention.dues_overdue }} overdue</span> + {% comment %} + Renewals pending should sit at ~0: the cron job renews clubs 30 days out and + then they fall past the horizon. A number that lingers here means the job has + stopped and a club is about to use the platform for free — which no other + figure on this page reveals, because nothing has been billed yet. + {% endcomment %} + {% if attention.renewals_pending %} + · <span class="font-semibold text-warning">{{ attention.renewals_pending }} awaiting renewal</span> + {% endif %} + </div> + </div> + </div> + </div> + + <div class="mb-6 grid gap-4 lg:grid-cols-2"> + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2> + <p class="text-sm opacity-70">New members against returning ones, across every club.</p> + <div class="h-56"> + <canvas id="signups-chart"></canvas> + </div> + </div> + </div> + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2> + <p class="text-sm opacity-70">Tracking club onboarding to ensure a smooth start</p> + <div class="mt-2 space-y-3"> + {% for step in funnel %} + <div> + <div class="mb-1 flex items-center justify-between text-sm"> + <span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span> + <span class="font-semibold tabular-nums font-mono">{{ step.count }}</span> + </div> + <progress class="progress {% if step.count == funnel.0.count %}progress-success{% elif step.count == 0 %}progress-error{% else %}progress-warning{% endif %} w-full" value="{{ step.count }}" + max="{{ funnel.0.count }}"></progress> + </div> + {% endfor %} + </div> + </div> + </div> + </div> + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title">{% lucide "building-2" size=18 %} Clubs</h2> + {% include "controlpanel/_club_health_table.html" %} + </div> + </div> +{% endblock panel %} + +{% block extra_body %} + {{ charts|json_script:"chart-data" }} + <script src="{% static 'js/chart.js' %}"></script> + <script> + (() => { + const data = JSON.parse(document.getElementById("chart-data").textContent); + + // Chart.js paints to a canvas, so it cannot inherit daisyUI's colours the way the + // rest of the page does — they are CSS variables. Read the computed values, and + // rebuild when the theme attribute changes, or the charts keep the light palette + // after a switch to dark. + const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; + + const render = () => { + const ink = css("--color-base-content", "#333"); + const grid = "color-mix(in oklab, " + ink + " 15%, transparent)"; + + // Locale-aware, so 1234.5 reads as "€ 1.234,50" rather than "€1,234.5". Two of + // them: the axis is rounded to keep the labels short, but the tooltip keeps the + // cents — rounding a euro amount someone is reading off a chart is a lie. + const axisEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", maximumFractionDigits: 0}); + const exactEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", minimumFractionDigits: 2}); + + const build = (id, label, series, colour, type, money) => + new Chart(document.getElementById(id), { + type, + data: { + labels: series.map((point) => point.month), + datasets: [{label, data: series.map((point) => point.value), borderColor: colour, backgroundColor: colour, tension: 0.3}], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: {display: false}, + // The tooltip carries the unit too: an axis in euros and a bare + // number on hover reads as two different quantities. + tooltip: money ? {callbacks: {label: (item) => exactEuros.format(item.parsed.y)}} : {}, + }, + scales: { + x: {ticks: {color: ink}, grid: {color: grid}}, + y: { + beginAtZero: true, + grid: {color: grid}, + ticks: {color: ink, precision: 0, callback: money ? (value) => axisEuros.format(value) : undefined}, + }, + }, + }, + }); + + // Stacked, so the bar height stays "signups this month" while the split shows + // where they came from. New is per club: joining a second club is a new + // membership there, even for someone who has been on the platform for years. + const signups = new Chart(document.getElementById("signups-chart"), { + type: "bar", + data: { + labels: data.signups.map((point) => point.month), + datasets: [ + {label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")}, + {label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")}, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: {legend: {position: "bottom", labels: {color: ink}}}, + scales: { + x: {stacked: true, ticks: {color: ink}, grid: {color: grid}}, + y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}}, + }, + }, + }); + + return [signups]; // build("revenue-chart", "Dues", data.dues, css("--color-accent", "#0ea5e9"), "bar", true)]; + }; + + let charts = render(); + + // The theme toggle sets data-theme on <html>; "auto" removes it entirely, so watch + // the attribute rather than listening for a click. + new MutationObserver(() => { + charts.forEach((chart) => chart.destroy()); + charts = render(); + }).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]}); + })(); + </script> +{% endblock extra_body %} diff --git a/controlpanel/templates/controlpanel/features.html b/controlpanel/templates/controlpanel/features.html new file mode 100644 index 0000000..64fa957 --- /dev/null +++ b/controlpanel/templates/controlpanel/features.html @@ -0,0 +1,145 @@ +{% extends "controlpanel/base.html" %} +{% load lucide ui %} + +{% block heading %}Features{% endblock heading %} + +{% block actions %} + <button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('flag_create_modal').showModal()">{% lucide "plus" size=16 %} New feature</button> +{% endblock actions %} + +{% block panel %} + {% url 'controlpanel:flag_create' as flag_create_url %} + {% include "controlpanel/_modal_form.html" with modal_id="flag_create_modal" title="New feature" form=flag_form action_url=flag_create_url submit_label="Create" submit_icon="plus" %} + + {% comment %} + The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the + control panel and the auth screens stay open — otherwise you could not sign in to + turn it back off. + {% endcomment %} + <div class="card mb-6 bg-base-100 shadow {% if maintenance.is_active %}border-l-4 border-error{% endif %}"> + <div class="card-body"> + <div class="flex flex-wrap items-start justify-between gap-4"> + <div class="w-full"> + <h2 class="card-title text-base mb-2">{% lucide "wrench" size=18 %} Maintenance mode</h2> + {% if maintenance.is_active %} + <div class="flex flex-row gap-2 text-sm"> + <span class="badge badge-error gap-1">{% lucide "lock" size=12 %} Platform closed</span> + <div>·</div> + <div>since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}</div> + </div> + + {% if maintenance.message %} + <div class="my-4"> + <div class="font-semibold mb-1">Message</div> + <div class="p-4 bg-base-300 border-l-4 border-info w-full font-mono">{{ maintenance.message }}</div> + </div> + {% endif %} + {% else %} + <p class="text-sm opacity-70"> + Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open. + </p> + {% endif %} + </div> + </div> + + <form class="mt-2" method="post" action="{% url 'controlpanel:maintenance' %}"> + {% csrf_token %} + {% if not maintenance.is_active %} + <div class="form-control my-2 w-full pb-2"> + <label class="label" for="{{ maintenance_form.message.id_for_label }}"> + <span class="label-text">{{ maintenance_form.message.label }}</span> + </label> + {{ maintenance_form.message|daisy }} + <span class="label-text-alt mt-1 block text-xs text-base-content/70">{{ maintenance_form.message.help_text }}</span> + </div> + <button class="btn btn-error gap-2" type="submit">{% lucide "lock" size=16 %} Close the platform</button> + {% else %} + <button class="btn btn-success gap-2" type="submit">{% lucide "lock-open" size=16 %} Reopen the platform</button> + {% endif %} + </form> + </div> + </div> + + <div class="card mb-6 bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "flag" size=18 %} Flags</h2> + <p class="text-sm opacity-70"> + Flags are turned on per club. Setting <em>Everyone</em> to Yes or No overrides club targeting entirely. + </p> + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>Name</th> + <th>Everyone</th> + <th>Clubs</th> + <th>Note</th> + <th></th> + </tr> + </thead> + <tbody> + {% for flag in flags %} + <tr> + <td class="font-mono font-medium">{{ flag.name }}</td> + <td> + {% if flag.everyone is True %} + <span class="badge badge-success">On for all</span> + {% elif flag.everyone is False %} + <span class="badge badge-error">Off everywhere</span> + {% else %} + <span class="badge badge-info">Per club</span> + {% endif %} + </td> + <td>{{ flag.clubs.count }}</td> + <td class="max-w-xs truncate opacity-70">{{ flag.note|default:"-" }}</td> + <td class="text-right"> + <button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ flag.pk|dom_id:"flag_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button> + </td> + </tr> + {% empty %} + <tr> + <td colspan="5" class="text-center opacity-60">No features yet.</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + + {% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %} + {% for flag in flags %} + {% url 'controlpanel:flag_update' flag.pk as flag_update_url %} + {% include "controlpanel/_modal_form.html" with modal_id=flag.pk|dom_id:"flag_edit_modal" title="Edit "|add:flag.name form=flag.edit_form action_url=flag_update_url submit_label="Save" submit_icon="check" %} + {% endfor %} + </div> + </div> + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "power" size=18 %} Switches</h2> + <p class="text-sm opacity-70">Global on/off for the whole platform — kill-switches, maintenance, infra rollouts.</p> + <div class="overflow-x-auto"> + <table class="table"> + <tbody> + {% for switch in switches %} + <tr> + <td class="font-mono font-medium">{{ switch.name }}</td> + <td class="opacity-70">{{ switch.note|default:"-" }}</td> + <td class="text-right"> + <form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}"> + {% csrf_token %} + <button class="btn btn-sm gap-1 {% if switch.active %}btn-success{% else %}btn-ghost{% endif %}" type="submit"> + {% if switch.active %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %} + </button> + </form> + </td> + </tr> + {% empty %} + <tr> + <td class="text-center opacity-60">No switches yet — add one in the Django admin.</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> +{% endblock panel %} diff --git a/controlpanel/templates/templatetags/field.html b/controlpanel/templates/templatetags/field.html new file mode 100644 index 0000000..db9360b --- /dev/null +++ b/controlpanel/templates/templatetags/field.html @@ -0,0 +1,239 @@ +{% load i18n %} + +<div class="w-full max-w-6xl {% if size_modifier %}lg:w-fit{% endif %}"> + <div class="flex flex-col gap-y-2"> + {% if show_label %} + <div class="flex flex-row items-end min-h-6"> + <div class="ml-1 grow text-sm font-semibold {% if field.errors %}text-error{% else %}text-base-content{% endif %}"> + {{ field.label }} + </div> + + {% if field.field.required %} + <div class="mr-1"> + <span class="badge badge-sm {% if field.errors %}badge-error text-error-content{% else %}badge-neutral text-neutral-content{% endif %}">{% translate "required" %}</span> + </div> + {% endif %} + </div> + {% endif %} + + <div> + {% if field_type == "input" %} + <input + type="{% if field.widget_type == "regionalphonenumber" %}tel{% elif field.widget_type == "datetime" %}datetime-local{% else %}{{ field.widget_type }}{% endif %}" + class="input w-full {% if field.errors %}input-error text-error{% endif %} {% if size_modifier %}input-{{ size_modifier }}{% endif %}" + name="{{ field.html_name }}" + value="{% if field.widget_type == "date" or field.widget_type == "datetime" %}{% if field.value|date:"c"|default:"" != "" %}{{ field.value|date:"c"|default:"" }}{% else %}{{ field.value|default:"" }}{% endif %}{% else %}{% if field.value == None %}{{ field.value|default:"" }}{% else %}{{ field.value }}{% endif %}{% endif %}" + {% if show_placeholder %}placeholder="{{ field.label|capfirst }}"{% endif %} + /> + + {% elif field_type == "checkbox" %} + <div class="flex flex-row items-center gap-2"> + <input + type="checkbox" + {% if show_as_toggle %} + class="toggle {% if size_modifier %}toggle-{{ size_modifier }}{% endif %}" + {% else %} + class="checkbox {% if size_modifier %}checkbox-{{ size_modifier }}{% endif %}" + {% endif %} + name="{{ field.html_name }}" + {% if field.value %}checked="checked"{% endif %} + /> + + {% if show_help_text %} + <div class="text-sm"> + {{ field.help_text }} + </div> + {% endif %} + </div> + + {% elif field_type == "textarea" %} + <textarea + name="{{ field.html_name }}" + class="{% if type == "markdownx" %}markdownx{% endif %} textarea textarea-bordered h-72 w-full {% if field.errors %}textarea-error text-error{% endif %} {% if size_modifier %}textarea-{{ size_modifier }}{% endif %}" + {% if show_placeholder %}placeholder="{{ field.label }}"{% endif %} + >{{ field.value|default:"" }}</textarea> + + {% elif field_type == "select" %} + {% comment %} + field.field.widget.choices, not field.field.choices: a plain ChoiceField + exposes both (kept in sync), but NullBooleanField (the "everyone" tri-state + Yes/No/Unknown field) only ever has them on the widget -- the field itself + has no .choices attribute at all. Reading from the field silently rendered + zero <option> tags for it, an empty dropdown, since a template attribute + lookup that raises AttributeError just resolves to nothing rather than + erroring. + {% endcomment %} + <select + class="select w-full {% if size_modifier %}select-{{ size_modifier }}{% endif %} {% if field.errors %}select-error text-error{% endif %}" + name="{{ field.html_name }}" + id="{{ field.auto_id }}" + {% if field.widget_type == "selectmultiple" %}multiple{% endif %} + {% for attr, value in field.field.widget.attrs.items %}{{ attr }}="{{ value }}" {% endfor %} + > + + {% if field.widget_type == "selectmultiple" %} + {% if not show_label %} + <option selected disabled>{{ field.label|capfirst }}</option> + {% endif %} + + {% for option_value, option_label in field.field.widget.choices %} + <option value="{{ option_value }}" {% if option_value in field.value %}selected="selected"{% endif %}> + {{ option_label }} + </option> + {% endfor %} + {% else %} + {% if not show_label %} + <option selected disabled>{{ field.label|capfirst }}</option> + {% endif %} + + {% for option_value, option_label in field.field.widget.choices %} + <option value="{{ option_value }}" {% if field.value|stringformat:"s" == option_value|stringformat:"s" %}selected="selected"{% endif %}> + {{ option_label }} + </option> + {% endfor %} + {% endif %} + + </select> + + {% elif field_type == "file" %} + <input + type="file" + class="file-input w-full {% if size_modifier %}file-input-{{ size_modifier }}{% endif %}" + name="{{ field.html_name }}" + {% if field.field.widget.attrs.accept %}accept="{{ field.field.widget.attrs.accept }}"{% endif %} + {% if field.field.widget.attrs.multiple %}multiple{% endif %} + /> + + {% if field.value %} + <span class="my-1 text-xs opacity-50">{% translate "Current file:" %} {{ field.value }}</span> + {% endif %} + {% endif %} + </div> + + {% if field_type != "checkbox" and field.help_text and show_help_text or field.errors %} + <div class="flex flex-col gap-1 ml-2"> + {% if field.errors %} + <div class="text-xs text-error"> + {{ field.errors }} + </div> + {% endif %} + + {% if field.help_text %} + <div class="text-xs self-start opacity-50 {% if field.errors %}text-error{% endif %}"> + {{ field.help_text }} + </div> + {% endif %} + </div> + {% endif %} + </div> +</div> + + +{% comment %} <div class="w-full max-w-6xl {% if size_modifier %}lg:w-fit{% endif %}"> + <label class="w-full form-control"> + {% if show_label %} + <div class="label"> + <span class="label-text opacity-50 {% if field.errors %}text-error{% else %}text-base-content{% endif %}">{{ field.label }}</span> + + {% if field.field.required %} + <span class="label-text-alt badge badge-sm {% if field.errors %}badge-error text-error-content{% else %}badge-neutral text-neutral-content{% endif %}">{% translate "required" %}</span> + {% endif %} + </div> + {% endif %} + + {% if field_type == "select" %} + <select + class="select select-bordered {% if size_modifier %}select-{{ size_modifier }}{% endif %} {% if field.errors %}select-error text-error{% endif %}" + name="{{ field.html_name }}" + id="{{ field.auto_id }}" + {% if field.widget_type == "selectmultiple" %}multiple{% endif %} + > + + {% if field.widget_type == "selectmultiple" %} + {% if not show_label %} + <option selected disabled>{{ field.label|capfirst }}</option> + {% endif %} + + {% for option_value, option_label in field.field.choices %} + <option value="{{ option_value }}" {% if option_value in field.value %}selected="selected"{% endif %}> + {{ option_label }} + </option> + {% endfor %} + {% else %} + {% if not show_label %} + <option selected disabled>{{ field.label|capfirst }}</option> + {% endif %} + + {% for option_value, option_label in field.field.choices %} + <option value="{{ option_value }}" {% if field.value|stringformat:"s" == option_value|stringformat:"s" %}selected="selected"{% endif %}> + {{ option_label }} + </option> + {% endfor %} + {% endif %} + + </select> + + {% elif field_type == "checkbox" %} + <div class="flex flex-row items-center gap-2"> + <input + type="checkbox" + {% if show_as_toggle %} + class="toggle {% if size_modifier %}toggle-{{ size_modifier }}{% endif %}" + {% else %} + class="checkbox {% if size_modifier %}checkbox-{{ size_modifier }}{% endif %}" + {% endif %} + name="{{ field.html_name }}" + {% if field.value %}checked="checked"{% endif %} + /> + + {% if show_help_text %} + <div class="label-text"> + {{ field.help_text }} + </div> + {% endif %} + </div> + + {% elif field_type == "textarea" %} + <textarea + name="{{ field.html_name }}" + class="{% if type == "markdownx" %}markdownx{% endif %} textarea textarea-bordered h-72 {% if field.errors %}textarea-error text-error{% endif %} {% if size_modifier %}textarea-{{ size_modifier }}{% endif %}" + {% if show_placeholder %}placeholder="{{ field.label }}"{% endif %} + >{{ field.value|default:"" }}</textarea> + + {% elif field_type == "file" %} + <input + type="file" + class="file-input file-input-bordered {% if size_modifier %}file-input-{{ size_modifier }}{% endif %}" + name="{{ field.html_name }}" + /> + {% if field.value %} + <span class="my-1 text-sm text-gray-500">{% translate "Current file:" %} {{ field.value }}</span> + {% endif %} + + {% elif field_type == "input" %} + <input + type="{% if field.widget_type == "regionalphonenumber" %}tel{% elif field.widget_type == "datetime" %}datetime-local{% else %}{{ field.widget_type }}{% endif %}" + class="input input-bordered {% if field.errors %}input-error text-error{% endif %} {% if size_modifier %}input-{{ size_modifier }}{% endif %}" + name="{{ field.html_name }}" + value="{% if field.widget_type == "date" or field.widget_type == "datetime" %}{{ field.value|date:"c"|default:"" }}{% else %}{{ field.value|default:"" }}{% endif %}" + {% if show_placeholder %}placeholder="{{ field.label|capfirst }}"{% endif %} + /> + {% endif %} + + {% if field.help_text and show_help_text or field.errors %} + <div class="label"> + {% if field.errors %} + <div class="label-text-alt text-error"> + {{ field.errors }} + </div> + {% endif %} + + {% if field.help_text and field_type != "checkbox" %} + <div class="label-text-alt self-start {% if field.errors %}text-error{% endif %}"> + {{ field.help_text }} + </div> + {% endif %} + </div> + {% endif %} + </label> +</div> {% endcomment %} \ No newline at end of file diff --git a/controlpanel/templatetags/__init__.py b/controlpanel/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controlpanel/templatetags/ui.py b/controlpanel/templatetags/ui.py new file mode 100644 index 0000000..6ae53bd --- /dev/null +++ b/controlpanel/templatetags/ui.py @@ -0,0 +1,192 @@ +"""Template helpers for the daisyUI-based UI.""" + +from django import forms, template +from django.forms import BoundField + +register = template.Library() + +#: daisyUI class per widget kind. Django renders widgets unstyled, so this is +#: what makes every allauth and control-panel form field look right. +WIDGET_CLASSES = ( + (forms.CheckboxInput, "checkbox"), + (forms.FileInput, "file-input file-input-bordered w-full"), + (forms.RadioSelect, "radio"), + (forms.Select, "select select-bordered w-full"), + (forms.Textarea, "textarea textarea-bordered w-full"), +) +DEFAULT_WIDGET_CLASS = "input input-bordered w-full" + + +#: Icon, default heading and daisyUI colour per message level. +MESSAGE_ALERTS = { + "debug": ("bug", "Debug", "alert-info border-info"), + "info": ("info", "Heads up", "alert-info border-info"), + "success": ("circle-check", "Done", "alert-success border-success"), + "warning": ("triangle-alert", "Careful", "alert-warning border-warning"), + "error": ("circle-x", "Something went wrong", "alert-error border-error"), +} +DEFAULT_MESSAGE_ALERT = MESSAGE_ALERTS["info"] + + +@register.filter +def as_alert(message): + """Presentation for one Django message: icon, bold title, body, colour. + + Django messages carry a level and a string — there is no title field — so the + title comes from the level, unless the message carries one as ``extra_tags``. + Call sites queue messages with ``notify`` (controlpanel/messages.py), which sets + exactly that from a compact ``"<level>|<title>|<body>"`` spec:: + + notify(request, f"s|Club created|{club} is live.") + + Keyed on ``level_tag``, never ``tags``: ``tags`` is extra_tags and level_tag + joined, so a message carrying a custom title would stop matching its own level + and quietly render as info. + """ + icon, title, css = MESSAGE_ALERTS.get(message.level_tag, DEFAULT_MESSAGE_ALERT) + + return {"icon": icon, "title": message.extra_tags or title, "body": message.message, "css": css} + + +#: Icon shown inside the field, by form field name. Anything unlisted gets none. +FIELD_ICONS = { + "login": "mail", + "email": "mail", + "email2": "mail", + "oldpassword": "lock-keyhole", + "password": "lock-keyhole", + "password1": "lock-keyhole", + "password2": "lock-keyhole", + "code": "shield-check", +} + + +#: Fields rendered as a boxed one-time-code input rather than a plain text field. +OTP_FIELDS = {"code"} + + +@register.filter +def is_otp(field): + return field.name in OTP_FIELDS + + +@register.filter +def as_otp(field): + """Render the code input for the boxed otp layout. + + No daisy classes — the wrapping div carries them — and no placeholder: allauth sets + one ("Code"), and grey text sitting inside the boxes reads as an already-typed code. + The label lives outside the box as an sr-only element, so nothing is lost. + + False, not a pop: as_widget() merges the widget's own attrs back in, so deleting the + placeholder from a copy achieves nothing. Django's attribute template omits any attr + whose value is False, which is the only way to actually suppress one. + """ + return field.as_widget(attrs={"class": "", "placeholder": False}) + + +@register.filter +def field_icon(field): + return FIELD_ICONS.get(field.name, "") + + +@register.filter +def excluded(field, names): + """Is this field in a comma-separated exclude list? + + Split rather than a substring test: ``"password" in "password2"`` is true, and the + login page excluding "remember" must not silently drop a field whose name happens + to contain it. + """ + return field.name in (names or "").split(",") + + +@register.filter +def dom_id(pk, prefix): + """A stable per-row DOM id, e.g. ``{{ due.pk|dom_id:"due_pay_modal" }}``. + + Django templates cannot concatenate a string literal with a non-string filter + argument directly (``"prefix_"|add:some_uuid`` raises), which is what a per-row modal + id needs. This is the one place that builds one, so every call site reads the same way. + """ + return f"{prefix}_{pk}" + + +@register.filter +def daisy(field, css=None): + """Render a bound form field with the right daisyUI classes. + + Pass ``css`` to override them — the icon-in-field layout wraps the input in a + ``label.input``, and there the input itself must NOT carry the ``input`` class + (daisyUI styles the wrapper instead), so it is rendered with ``grow``. The error + state then belongs on the wrapper too, which is why an override skips it here. + """ + widget = field.field.widget + + if css is None: + css = next((css for widget_type, css in WIDGET_CLASSES if isinstance(widget, widget_type)), DEFAULT_WIDGET_CLASS) + if field.errors: + css = f"{css} {css.split()[0]}-error" + + attrs = dict(widget.attrs) + attrs["class"] = " ".join(part for part in [widget.attrs.get("class", ""), css] if part) + return field.as_widget(attrs=attrs) + + +@register.inclusion_tag("templatetags/field.html") +def form_field( + field: BoundField, + label: str | None = None, + help_text: str | None = None, + show_label: bool = True, + show_help_text: bool = True, + show_placeholder: bool = True, + show_as_toggle: bool = False, + size: str = "full", +) -> dict: + if label is not None: + field.label = label + + if help_text is not None: + field.help_text = help_text + + field_type = None + match field.widget_type: + # "lazyselect" is django_countries' CountryField widget (a Select subclass + # with a lazily-translated choice list) -- without it here, this fell + # through to the "input" case below and rendered as a plain text box + # (`<input type="lazyselect">`, which browsers just treat as text). + case "select" | "nullbooleanselect" | "radioselect" | "lazyselect": + field_type = "select" + case "checkbox": + field_type = "checkbox" + case "textarea" | "markdownx": + field_type = "textarea" + case "clearablefile" | "multiplefile": + field_type = "file" + case "selectmultiple": + field_type = "select" + case _: + field_type = "input" + + size_modifier = None + match size: + case "extra-small": + size_modifier = "xs" + case "small": + size_modifier = "sm" + case _: + pass + + # if field.widget_type == "number": + # field.value = str(field.value()) + + return { + "field": field, + "show_label": show_label, + "show_help_text": show_help_text, + "show_placeholder": show_placeholder, + "show_as_toggle": show_as_toggle, + "field_type": field_type, + "size_modifier": size_modifier, + } diff --git a/controlpanel/tests.py b/controlpanel/tests.py new file mode 100644 index 0000000..d8be903 --- /dev/null +++ b/controlpanel/tests.py @@ -0,0 +1,1679 @@ +import datetime +import pathlib +from decimal import Decimal +from unittest import mock + +from allauth.mfa.models import Authenticator +from django import forms +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import get_user_model +from django.contrib.messages.storage.base import Message +from django.contrib.messages.storage.fallback import FallbackStorage +from django.core.cache import cache +from django.test import RequestFactory, TestCase, override_settings +from django.urls import reverse +from django.utils import timezone +from waffle import get_waffle_flag_model, get_waffle_switch_model + +from billing.models import GRACE_DAYS, Due, Tier, TierPrice +from billing.services import BillingError +from billing.services.dues import record_payment, subscribe, waive +from club.models import Club, ClubMembership, ClubRole, Season +from events.models import Attendance, Event, Location +from features.models import Maintenance +from members.models import Member +from shop.models import Order +from teams.models import Position, StaffAssignment, Team, TeamMembership + +from .messages import LEVELS, notify +from .services.admins import grant_club_admin +from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access +from .services.statistics import ( + admins_pending_mfa, + attendance_rates, + club_attention, + club_statistics, + clubs_with_health, + clubs_with_totals, + clubs_without_a_season, + dormant_clubs, + fee_aging, + flag_adoption, + new_members, + onboarding_funnel, + platform_attention, + platform_charts, + platform_totals, + renewal_rate, + signup_split, + teams_without_a_manager, + unrostered_members, +) +from .templatetags.ui import as_alert, daisy, excluded, field_icon + +User = get_user_model() +Flag = get_waffle_flag_model() +Switch = get_waffle_switch_model() + + +def enrol_mfa(user): + return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) + + +class ControlPanelTestBase(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United") + self.staff = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True) + # Staff must hold a second factor, else RequireMFAMiddleware redirects. + enrol_mfa(self.staff) + self.client.force_login(self.staff) + + +class AccessTests(ControlPanelTestBase): + def test_staff_can_reach_the_panel(self): + self.assertEqual(self.client.get(reverse("controlpanel:dashboard")).status_code, 200) + + def test_anonymous_is_sent_to_login(self): + self.client.logout() + + response = self.client.get(reverse("controlpanel:dashboard")) + + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("account_login"), response.url) + + def test_signed_in_non_staff_gets_403(self): + self.client.force_login(User.objects.create_user(email="member@example.com", password="pw-secret-123")) + + self.assertEqual(self.client.get(reverse("controlpanel:dashboard")).status_code, 403) + + def test_superuser_can_reach_the_panel(self): + root = User.objects.create_superuser(email="super@example.com", password="pw-secret-123") + enrol_mfa(root) + self.client.force_login(root) + + self.assertEqual(self.client.get(reverse("controlpanel:dashboard")).status_code, 200) + + @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=[".rosterchief.app"]) + def test_panel_does_not_exist_on_a_club_subdomain(self): + # It manages *all* clubs, so it must not be reachable from inside one. + Club.objects.create(name="Rival FC", slug="rival-fc") + + response = self.client.get(reverse("controlpanel:dashboard"), headers={"host": "rival-fc.rosterchief.app"}) + + self.assertEqual(response.status_code, 404) + + def test_staff_without_a_second_factor_is_sent_to_enrolment(self): + self.client.force_login(User.objects.create_user(email="nomfa@example.com", password="pw-secret-123", is_staff=True)) + + response = self.client.get(reverse("controlpanel:dashboard")) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse("mfa_index")) + + +class ClubManagementTests(ControlPanelTestBase): + def test_dashboard_lists_clubs(self): + self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "Ajax United") + + def test_the_club_rows_subtitle_shows_the_sport_type_behind_the_url(self): + self.club.sport_type = Club.SportType.ICE_HOCKEY + self.club.save() + + response = self.client.get(reverse("controlpanel:dashboard")) + + self.assertContains(response, f"{self.club.slug}.rosterchief.app · Ice hockey", html=False) + + def test_create_club_derives_the_slug(self): + response = self.client.post(reverse("controlpanel:club_create"), {"name": "New Club", "slug": "", "sport_type": "other", "season_start": "2000-08-01", "season_duration_months": "12"}) + + club = Club.objects.get(name="New Club") + self.assertEqual(club.slug, "new-club") + self.assertRedirects(response, reverse("controlpanel:club_detail", args=[club.pk])) + + def test_update_club(self): + self.client.post( + reverse("controlpanel:club_update", args=[self.club.pk]), + {"name": "Renamed", "slug": self.club.slug, "sport_type": "other", "season_start": "2000-08-01", "season_duration_months": "12"}, + ) + + self.club.refresh_from_db() + self.assertEqual(self.club.name, "Renamed") + + def test_update_club_sport_type(self): + self.client.post( + reverse("controlpanel:club_update", args=[self.club.pk]), + {"name": self.club.name, "slug": self.club.slug, "sport_type": "ice_hockey", "season_start": "2000-08-01", "season_duration_months": "12"}, + ) + + self.club.refresh_from_db() + self.assertEqual(self.club.sport_type, "ice_hockey") + + def test_club_detail_shows_statistics(self): + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, "Members") + self.assertContains(response, "Teams & staff") + self.assertContains(response, "Shop") + + def test_club_detail_subheading_shows_the_sport_type_behind_the_url(self): + self.club.sport_type = Club.SportType.ICE_HOCKEY + self.club.save() + + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, f"{self.club.slug}.rosterchief.app · Ice hockey", html=False) + + def test_archive_then_restore(self): + self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk])) + self.club.refresh_from_db() + self.assertTrue(self.club.is_archived) + + self.client.post(reverse("controlpanel:club_restore", args=[self.club.pk])) + self.club.refresh_from_db() + self.assertFalse(self.club.is_archived) + + def test_list_separates_active_from_archived(self): + Club.objects.create(name="Gone FC").archive() + + active = self.client.get(reverse("controlpanel:club_list")) + self.assertContains(active, "Ajax United") + self.assertNotContains(active, "Gone FC") + + archived = self.client.get(reverse("controlpanel:club_list"), {"archived": "1"}) + self.assertContains(archived, "Gone FC") + self.assertNotContains(archived, "Ajax United") + + def test_list_search(self): + Club.objects.create(name="Rival FC") + + response = self.client.get(reverse("controlpanel:club_list"), {"q": "Ajax"}) + + self.assertContains(response, "Ajax United") + self.assertNotContains(response, "Rival FC") + + +class ClubAdminManagementTests(ControlPanelTestBase): + def add_admin(self, **data): + return self.client.post(reverse("controlpanel:club_admin_add", args=[self.club.pk]), data) + + def test_granting_admin_to_a_new_email_creates_the_account(self): + self.add_admin(email="New.Admin@Example.com", first_name="Ada", last_name="Min") + + user = User.objects.get(email="new.admin@example.com") + self.assertFalse(user.has_usable_password()) # they set one via password reset + self.assertEqual(ClubRole.objects.get(club=self.club, member__user=user).role, ClubRole.Roles.ADMIN) + + def test_a_new_email_must_come_with_a_name(self): + # Reachable only via the "Add admin" modal on the club detail page, so a rejected + # submission bounces back there with the error as a message. + response = self.client.post(reverse("controlpanel:club_admin_add", args=[self.club.pk]), {"email": "nameless@example.com", "first_name": "", "last_name": ""}, follow=True) + + self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk])) + self.assertFalse(ClubRole.objects.exists()) + self.assertContains(response, "Required: this email has no account yet.") + + def test_add_admin_is_post_only(self): + response = self.client.get(reverse("controlpanel:club_admin_add", args=[self.club.pk])) + + self.assertEqual(response.status_code, 405) + + def test_an_existing_member_is_promoted_rather_than_duplicated(self): + user = User.objects.create_user(email="existing@example.com", password="pw-secret-123") + member = Member.objects.create(user=user, first_name="Ex", last_name="Isting") + ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER) + + self.add_admin(email="existing@example.com") + + # One role per member per club, so the MEMBER role is upgraded in place. + self.assertEqual(ClubRole.objects.get(club=self.club, member=member).role, ClubRole.Roles.ADMIN) + self.assertEqual(Member.objects.filter(user=user).count(), 1) + + def test_granting_twice_is_idempotent(self): + self.add_admin(email="ada@example.com", first_name="Ada", last_name="Min") + self.add_admin(email="ada@example.com", first_name="Ada", last_name="Min") + + self.assertEqual(ClubRole.objects.filter(club=self.club).count(), 1) + + def test_remove_admin(self): + role = grant_club_admin(self.club, "ada@example.com", "Ada", "Min") + + self.client.post(reverse("controlpanel:club_admin_remove", args=[self.club.pk, role.pk])) + + self.assertFalse(ClubRole.objects.filter(pk=role.pk).exists()) + + def test_admins_are_listed_on_the_club(self): + grant_club_admin(self.club, "ada@example.com", "Ada", "Min") + + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, "Ada Min") + self.assertContains(response, "ada@example.com") + + +class ClubHomeLocationTests(ControlPanelTestBase): + """The club detail page's "Home location" box -- see + controlpanel.views.ClubHomeLocationSetView. Setting it here creates/updates + an events.Location row flagged is_home, which is also what shows up on the + club's own Teams > Locations page (management app) -- there's no separate + sync step, it's the same row.""" + + def set_home_location(self, **data): + data = {"name": "Home Ground", "address": "1 Main St", "city": "Town", "zip_code": "1000", "country": "BE"} | data + return self.client.post(reverse("controlpanel:club_home_location_set", args=[self.club.pk]), data) + + def test_setting_it_creates_a_location(self): + response = self.set_home_location() + + self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk])) + location = Location.objects.get(club=self.club, is_home=True) + self.assertEqual(location.name, "Home Ground") + + def test_the_home_location_modal_renders_country_as_a_dropdown(self): + # Regression: CountryField's widget_type ("lazyselect") wasn't recognised + # by the form_field templatetag and fell through to a broken plain + # <input type="lazyselect">, not a <select> -- see form_field in + # controlpanel/templatetags/ui.py. + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertNotContains(response, 'type="lazyselect"') + self.assertContains(response, "Belgium") + self.assertContains(response, '<select') + + def test_the_club_detail_page_shows_the_home_location(self): + self.set_home_location(name="Home Ground") + + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, "Home Ground") + + def test_setting_it_again_updates_the_same_location_instead_of_creating_another(self): + self.set_home_location(name="Home Ground") + + self.set_home_location(name="Renamed Ground") + + self.assertEqual(Location.objects.filter(club=self.club, is_home=True).count(), 1) + self.assertEqual(Location.objects.get(club=self.club, is_home=True).name, "Renamed Ground") + + def test_the_home_location_is_visible_on_the_clubs_own_locations_page(self): + # Same row, no sync needed -- the club-facing management app reads straight + # from events.Location, same as this view writes to. + self.set_home_location(name="Home Ground") + self.client.logout() + admin_user = User.objects.create_user(email="clubadmin@example.com", password="pw-secret-123") + admin_member = Member.objects.create(user=admin_user, first_name="Ada", last_name="Admin") + ClubMembership.objects.create(club=self.club, member=admin_member, season=Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)), status=ClubMembership.StatusChoices.ACTIVE) + ClubRole.objects.filter(club=self.club, member=admin_member).update(role=ClubRole.Roles.ADMIN) + enrol_mfa(admin_user) + self.client.force_login(admin_user) + + with override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=[".rosterchief.app"]): + response = self.client.get(reverse("management:location_list"), headers={"host": f"{self.club.slug}.rosterchief.app"}) + + self.assertContains(response, "Home Ground") + + def test_invalid_submission_redirects_back_with_an_error(self): + response = self.set_home_location(name="") + + self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk])) + self.assertFalse(Location.objects.filter(club=self.club, is_home=True).exists()) + + +class StatisticsTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United") + today = timezone.localdate() + self.season = Season.objects.create(club=self.club, start_date=today, end_date=today) + self.member = Member.objects.create(first_name="Jane", last_name="Doe") + + def groups_for(self, club): + return {group["title"]: dict(group["stats"]) for group in club_statistics(club)} + + def test_platform_totals_split_active_and_archived(self): + Club.objects.create(name="Gone FC").archive() + + totals = platform_totals() + + self.assertEqual(totals["clubs"], 1) + self.assertEqual(totals["archived_clubs"], 1) + + def test_club_totals_are_annotated(self): + Team.objects.create(club=self.club, name="First", short_name="1st") + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season) + + club = clubs_with_totals().get(pk=self.club.pk) + + self.assertEqual(club.member_count, 1) + self.assertEqual(club.team_count, 1) + self.assertEqual(club.admin_count, 0) + + def test_club_statistics_count_members_teams_and_money(self): + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + team = Team.objects.create(club=self.club, name="First", short_name="1st") + position = Position.objects.create(club=self.club, name="Forward", short_name="FW") + TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=position) + Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("50.00"), status=Order.OrderStatus.PAID) + Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("20.00"), status=Order.OrderStatus.PENDING) + + groups = self.groups_for(self.club) + + self.assertEqual(groups["Members"]["Active this season"], 1) + self.assertEqual(groups["Teams & staff"]["Players this season"], 1) + self.assertEqual(groups["Shop"]["Revenue"], Decimal("50.00")) + self.assertEqual(groups["Shop"]["Outstanding"], Decimal("20.00")) + + def test_statistics_cope_with_no_current_season(self): + # A brand-new club has no season covering today; it must not blow up. + groups = self.groups_for(Club.objects.create(name="Seasonless FC")) + + self.assertEqual(groups["Members"]["Active this season"], 0) + self.assertEqual(groups["Teams & staff"]["Players this season"], 0) + self.assertEqual(groups["Events"]["This season"], 0) + self.assertEqual(groups["Shop"]["Revenue"], Decimal("0.00")) + + +class SampleForm(forms.Form): + text = forms.CharField() + choice = forms.ChoiceField(choices=[("a", "A")]) + note = forms.CharField(widget=forms.Textarea) + agree = forms.BooleanField() + + +class DaisyFilterTests(TestCase): + def rendered(self, field_name, data=None): + form = SampleForm(data) + if data is not None: + form.is_valid() + return str(daisy(form[field_name])) + + def test_widgets_get_the_right_daisyui_class(self): + self.assertIn("input input-bordered", self.rendered("text")) + self.assertIn("select select-bordered", self.rendered("choice")) + self.assertIn("textarea textarea-bordered", self.rendered("note")) + self.assertIn("checkbox", self.rendered("agree")) + + def test_invalid_fields_get_an_error_class(self): + self.assertIn("input-error", self.rendered("text", data={})) + + +class PlatformAdminAccessTests(ControlPanelTestBase): + """Managing platform admins is superuser-only: the panel is gated on + is_staff OR is_superuser, so letting staff grant superuser would collapse + the two into one privilege level.""" + + def test_staff_cannot_reach_the_admins_section(self): + self.assertEqual(self.client.get(reverse("controlpanel:admins")).status_code, 403) + + def test_staff_cannot_grant_platform_access(self): + self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 403) + + def test_the_admins_tab_is_hidden_from_staff(self): + self.assertNotContains(self.client.get(reverse("controlpanel:dashboard")), reverse("controlpanel:admins")) + + +class PlatformAdminTests(TestCase): + def setUp(self): + self.root = User.objects.create_superuser(email="root@example.com", password="pw-secret-123") + enrol_mfa(self.root) + self.client.force_login(self.root) + + def test_superuser_sees_the_admins_section(self): + self.assertEqual(self.client.get(reverse("controlpanel:admins")).status_code, 200) + + def test_the_grant_form_is_post_only(self): + # Reachable only via the "Grant access" modal on the admins page: there is no + # standalone template to render on a GET. + self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 405) + + def test_grant_access_to_a_new_email_creates_a_staff_account(self): + self.client.post(reverse("controlpanel:admin_add"), {"email": "New.Admin@Example.com"}) + + user = User.objects.get(email="new.admin@example.com") + self.assertTrue(user.is_staff) + self.assertFalse(user.is_superuser) + self.assertFalse(user.has_usable_password()) # set via password reset + + def test_grant_superuser(self): + self.client.post(reverse("controlpanel:admin_add"), {"email": "boss@example.com", "is_superuser": "1"}) + + user = User.objects.get(email="boss@example.com") + self.assertTrue(user.is_superuser) + self.assertTrue(user.is_staff) # superuser implies staff, else they can't reach the panel + + def test_promote_and_demote_another_admin(self): + other = User.objects.create_user(email="other@example.com", password="pw-secret-123", is_staff=True) + + self.client.post(reverse("controlpanel:admin_update", args=[other.pk]), {"is_staff": "1", "is_superuser": "1"}) + other.refresh_from_db() + self.assertTrue(other.is_superuser) + + self.client.post(reverse("controlpanel:admin_update", args=[other.pk]), {"is_staff": "1", "is_superuser": "0"}) + other.refresh_from_db() + self.assertFalse(other.is_superuser) + + def test_revoke_another_admins_access(self): + other = User.objects.create_user(email="other@example.com", password="pw-secret-123", is_staff=True) + + self.client.post(reverse("controlpanel:admin_revoke", args=[other.pk])) + + other.refresh_from_db() + self.assertFalse(other.is_staff) + self.assertFalse(other.is_superuser) + + # --- guardrails: it must be impossible to lock the platform out of itself --- + def test_cannot_revoke_your_own_access(self): + response = self.client.post(reverse("controlpanel:admin_revoke", args=[self.root.pk]), follow=True) + + self.root.refresh_from_db() + self.assertTrue(self.root.is_superuser) + self.assertContains(response, "cannot remove your own platform access") + + def test_cannot_remove_your_own_superuser_rights(self): + # Keep another superuser around so this is blocked by the self-rule, not + # by the last-superuser rule. + User.objects.create_superuser(email="spare@example.com", password="pw-secret-123") + + response = self.client.post(reverse("controlpanel:admin_update", args=[self.root.pk]), {"is_staff": "1", "is_superuser": "0"}, follow=True) + + self.root.refresh_from_db() + self.assertTrue(self.root.is_superuser) + self.assertContains(response, "cannot remove your own superuser rights") + + def test_the_last_superuser_cannot_be_demoted(self): + other = User.objects.create_superuser(email="other@example.com", password="pw-secret-123") + # Now demote self is blocked by the self-rule; demote `other` is fine... + self.client.post(reverse("controlpanel:admin_update", args=[other.pk]), {"is_staff": "1", "is_superuser": "0"}) + other.refresh_from_db() + self.assertFalse(other.is_superuser) + + # ...leaving root as the last superuser, who now cannot be demoted by anyone. + self.assertTrue(is_last_superuser(self.root)) + with self.assertRaises(PlatformAdminError): + set_platform_access(other, self.root, is_staff=True, is_superuser=False) + + def test_last_superuser_rule_is_enforced_for_other_actors_too(self): + response = self.client.post(reverse("controlpanel:admin_revoke", args=[self.root.pk]), follow=True) + + self.root.refresh_from_db() + self.assertTrue(self.root.is_superuser) + self.assertContains(response, "cannot remove your own platform access") + + +class FeatureViewTests(ControlPanelTestBase): + def setUp(self): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + self.flag = Flag.objects.create(name="shop") + + def test_features_page_lists_flags_and_switches(self): + Switch.objects.create(name="maintenance", active=False) + + response = self.client.get(reverse("controlpanel:features")) + + self.assertContains(response, "shop") + self.assertContains(response, "maintenance") + + def test_the_everyone_field_renders_as_a_populated_dropdown(self): + # Regression: NullBooleanField has no .choices on the field itself (only + # on field.widget.choices) -- the shared form_field templatetag read the + # wrong attribute and silently rendered the "everyone" tri-state dropdown + # with zero <option> tags. See controlpanel/templates/templatetags/field.html. + response = self.client.get(reverse("controlpanel:features")) + + self.assertContains(response, '<option value="unknown">Unknown</option>', html=True) + self.assertContains(response, '<option value="true">Yes</option>', html=True) + self.assertContains(response, '<option value="false">No</option>', html=True) + + def test_the_flag_forms_are_post_only(self): + # Reachable only through a modal on the features page: there is no standalone + # template to render on a GET. + self.assertEqual(self.client.get(reverse("controlpanel:flag_create")).status_code, 405) + self.assertEqual(self.client.get(reverse("controlpanel:flag_update", args=[self.flag.pk])).status_code, 405) + + def test_an_invalid_flag_submission_redirects_with_a_message(self): + response = self.client.post(reverse("controlpanel:flag_create"), {"name": "", "note": "", "percent": "", "everyone": ""}, follow=True) + + self.assertRedirects(response, reverse("controlpanel:features")) + self.assertContains(response, "This field is required") + + def test_create_a_flag(self): + self.client.post(reverse("controlpanel:flag_create"), {"name": "news", "note": "News module", "percent": "", "everyone": ""}) + + self.assertTrue(Flag.objects.filter(name="news").exists()) + + def test_edit_a_flag(self): + self.client.post(reverse("controlpanel:flag_update", args=[self.flag.pk]), {"name": "shop", "note": "Webshop", "percent": "", "everyone": ""}) + + self.flag.refresh_from_db() + self.assertEqual(self.flag.note, "Webshop") + + def test_toggle_a_feature_on_and_off_for_a_club(self): + url = reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk]) + + self.client.post(url) + self.assertTrue(self.flag.clubs.filter(pk=self.club.pk).exists()) + + self.client.post(url) + self.assertFalse(self.flag.clubs.filter(pk=self.club.pk).exists()) + + def test_toggle_a_switch(self): + switch = Switch.objects.create(name="maintenance", active=False) + + self.client.post(reverse("controlpanel:switch_toggle", args=[switch.pk])) + + switch.refresh_from_db() + self.assertTrue(switch.active) + + def test_club_detail_offers_a_toggle_per_feature(self): + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, "shop") + self.assertContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk])) + + def test_an_everyone_flag_shows_a_badge_instead_of_a_club_toggle(self): + # `everyone` overrides club targeting, so offering a per-club toggle would lie. + self.flag.everyone = True + self.flag.save() + + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, "On for all clubs") + self.assertNotContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk])) + + +class NotifyTests(TestCase): + def request(self): + request = RequestFactory().get("/") + request.session = {} + storage = FallbackStorage(request) + request._messages = storage + return request, storage + + def test_splits_level_title_and_body(self): + request, storage = self.request() + + notify(request, "s|Club created|Ajax United is live.") + + [message] = list(storage) + self.assertEqual(message.level, messages.SUCCESS) + self.assertEqual(message.extra_tags, "Club created") + self.assertEqual(message.message, "Ajax United is live.") + + def test_maps_every_level_code_to_its_django_level(self): + self.assertEqual(LEVELS, {"s": messages.SUCCESS, "i": messages.INFO, "w": messages.WARNING, "e": messages.ERROR, "d": messages.DEBUG}) + + def test_a_pipe_inside_the_body_is_preserved_intact(self): + # maxsplit=2 stops after the level and the title, so a "|" a club/tier/flag name + # might contain stays part of the body rather than truncating it. + request, storage = self.request() + + notify(request, "s|Title|Before | after.") + + [message] = list(storage) + self.assertEqual(message.message, "Before | after.") + + def test_an_empty_title_falls_back_to_the_generic_one_at_render_time(self): + request, storage = self.request() + + notify(request, "s||No custom title.") + + [message] = list(storage) + self.assertEqual(as_alert(message)["title"], "Done") + + +class MessageAlertTests(TestCase): + def alert(self, level, text, extra_tags=None): + return as_alert(Message(level, text, extra_tags=extra_tags)) + + def test_each_level_gets_its_own_icon_title_and_colour(self): + self.assertEqual(self.alert(messages.SUCCESS, "Saved.")["icon"], "circle-check") + self.assertEqual(self.alert(messages.WARNING, "Careful.")["css"], "alert-warning border-warning") + self.assertEqual(self.alert(messages.ERROR, "Boom.")["title"], "Something went wrong") + self.assertEqual(self.alert(messages.INFO, "FYI.")["css"], "alert-info border-info") + + def test_extra_tags_override_the_title(self): + alert = self.alert(messages.SUCCESS, "Ajax United is live.", extra_tags="Club created") + + self.assertEqual(alert["title"], "Club created") + self.assertEqual(alert["body"], "Ajax United is live.") + self.assertEqual(alert["css"], "alert-success border-success") # a custom title must not change the level + + def test_an_unknown_level_falls_back_to_info(self): + self.assertEqual(self.alert(999, "Odd.")["css"], "alert-info border-info") + + +class MessageRenderingTests(ControlPanelTestBase): + def test_a_message_renders_as_a_soft_alert_with_icon_and_title(self): + # club_archive queues its message through `notify`, which sets a custom title — + # so the generic per-level one ("Careful") must not show. + response = self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]), follow=True) + + self.assertContains(response, "alert alert-soft border-2 alert-warning border-warning") + self.assertContains(response, '<div class="font-bold">Club archived</div>', html=False) + self.assertContains(response, "<svg") # the lucide icon + + +class FieldRenderingTests(TestCase): + def field(self, form_field, name="email", errors=False): + class Form(forms.Form): + pass + + Form.base_fields[name] = form_field + form = Form(data={} if errors else None) + if errors: + form.full_clean() + return form[name] + + def test_known_fields_get_an_icon_and_others_do_not(self): + self.assertEqual(field_icon(self.field(forms.EmailField(), "email")), "mail") + self.assertEqual(field_icon(self.field(forms.CharField(), "password")), "lock-keyhole") + self.assertEqual(field_icon(self.field(forms.CharField(), "note")), "") + + def test_the_default_rendering_styles_the_input_itself(self): + self.assertIn('class="input input-bordered w-full"', str(daisy(self.field(forms.CharField())))) + + def test_an_override_replaces_the_daisy_classes(self): + # The icon layout puts `input` on the wrapping label, so the input must not + # carry it too — that would draw a box inside a box. + rendered = str(daisy(self.field(forms.CharField()), "grow")) + + self.assertIn('class="grow"', rendered) + self.assertNotIn("input-bordered", rendered) + + def test_an_override_leaves_the_error_state_to_the_wrapper(self): + rendered = str(daisy(self.field(forms.CharField(required=True), errors=True), "grow")) + + self.assertNotIn("grow-error", rendered) + + def test_the_default_rendering_marks_errors_on_the_input(self): + rendered = str(daisy(self.field(forms.CharField(required=True), errors=True))) + + self.assertIn("input-error", rendered) + + +class LoginFormRenderingTests(TestCase): + def setUp(self): + self.response = self.client.get(reverse("account_login")) + + def test_the_fields_carry_an_icon_and_no_visible_label(self): + self.assertContains(self.response, 'class="sr-only">Email</span>') + self.assertContains(self.response, 'class="sr-only">Password</span>') + self.assertNotContains(self.response, '<span class="label-text">Email</span>') + self.assertContains(self.response, 'placeholder="Email address"') + + def test_the_checkbox_keeps_its_visible_label(self): + self.assertContains(self.response, '<span class="label-text">Remember Me</span>') + + def test_the_password_reset_link_is_spaced_and_addressable(self): + # The input's aria-describedby points here; without the id it dangles. + self.assertContains(self.response, 'id="id_password_helptext"') + self.assertContains(self.response, "mt-3") + self.assertContains(self.response, "Forgot your password?") + + +class LoginLayoutTests(TestCase): + def setUp(self): + self.response = self.client.get(reverse("account_login")) + + def test_remember_me_is_laid_out_by_the_page_not_the_fields_element(self): + # It sits on the button row, so the fields element must not also render it. + self.assertEqual(self.response.content.count(b'name="remember"'), 1) + self.assertContains(self.response, '<span class="label-text">Remember Me</span>') + + def test_the_passkey_button_sits_beside_sign_in(self): + self.assertContains(self.response, "btn btn-accent") + self.assertContains(self.response, "Sign in with a passkey") + + def test_the_passkey_button_has_a_form_to_submit(self): + # The button posts to the hidden `mfa_login` form via its `form` attribute. That + # form comes from allauth's extra_body block — without it the button is dead. + self.assertContains(self.response, 'form="mfa_login"') + self.assertContains(self.response, 'id="mfa_login"') + self.assertContains(self.response, "allauth.webauthn.forms.loginForm") + + +class ExcludedFilterTests(TestCase): + def field(self, name): + class Form(forms.Form): + pass + + Form.base_fields[name] = forms.CharField() + return Form()[name] + + def test_a_field_is_excluded_by_exact_name(self): + self.assertTrue(excluded(self.field("remember"), "remember")) + + def test_a_name_that_merely_contains_another_is_not_excluded(self): + # A substring test would drop "password" when excluding "password2". + self.assertFalse(excluded(self.field("password"), "password2,remember")) + + def test_nothing_is_excluded_without_a_list(self): + self.assertFalse(excluded(self.field("remember"), None)) + + +class PlatformAttentionTests(TestCase): + """The numbers that are supposed to be zero.""" + + def setUp(self): + self.club = Club.objects.create(name="Ajax United") + self.today = timezone.localdate() + + def season(self, club, start, end): + return Season.objects.create(club=club, start_date=start, end_date=end) + + def test_a_club_with_no_season_covering_today_is_flagged(self): + self.assertIn(self.club, clubs_without_a_season()) + + self.season(self.club, self.today - datetime.timedelta(days=10), self.today + datetime.timedelta(days=10)) + + self.assertNotIn(self.club, clubs_without_a_season()) + + def test_a_past_season_does_not_count_as_a_current_one(self): + self.season(self.club, self.today - datetime.timedelta(days=400), self.today - datetime.timedelta(days=40)) + + self.assertIn(self.club, clubs_without_a_season()) + + def test_an_archived_club_is_not_chased(self): + self.club.archive() + + self.assertNotIn(self.club, clubs_without_a_season()) + self.assertNotIn(self.club, dormant_clubs()) + + def test_a_club_with_nothing_scheduled_is_dormant(self): + self.assertIn(self.club, dormant_clubs()) + + Event.objects.create(club=self.club, title="Training", start=timezone.now() + datetime.timedelta(days=3)) + + self.assertNotIn(self.club, dormant_clubs()) + + def test_an_event_beyond_the_horizon_does_not_wake_a_club(self): + Event.objects.create(club=self.club, title="Far off", start=timezone.now() + datetime.timedelta(days=90)) + + self.assertIn(self.club, dormant_clubs()) + + def test_a_past_event_does_not_wake_a_club(self): + Event.objects.create(club=self.club, title="Gone", start=timezone.now() - datetime.timedelta(days=3)) + + self.assertIn(self.club, dormant_clubs()) + + +class MfaPendingTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United") + + def test_staff_without_a_second_factor_are_pending(self): + user = User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True) + + self.assertIn(user, admins_pending_mfa()) + + enrol_mfa(user) + + self.assertNotIn(user, admins_pending_mfa()) + + def test_a_club_admin_without_a_second_factor_is_pending(self): + # They are locked out until they enrol, so this is a support queue. + user = User.objects.create_user(email="admin@example.com", password="pw-secret-123") + member = Member.objects.create(first_name="Ada", last_name="Lovelace", user=user) + ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.ADMIN) + + self.assertIn(user, admins_pending_mfa()) + + def test_an_ordinary_member_is_not_chased(self): + user = User.objects.create_user(email="member@example.com", password="pw-secret-123") + member = Member.objects.create(first_name="Bob", last_name="Bobson", user=user) + ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER) + + self.assertNotIn(user, admins_pending_mfa()) + + def test_each_pending_admin_is_counted_once(self): + # Two elevated roles in two clubs is still one person to chase. + other = Club.objects.create(name="Feyenoord") + user = User.objects.create_user(email="admin@example.com", password="pw-secret-123", is_staff=True) + member = Member.objects.create(first_name="Ada", last_name="Lovelace", user=user) + ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.ADMIN) + ClubRole.objects.create(club=other, member=member, role=ClubRole.Roles.EDITOR) + + self.assertEqual(admins_pending_mfa().count(), 1) + + +class OnboardingFunnelTests(TestCase): + def test_the_funnel_narrows_as_clubs_stall(self): + empty = Club.objects.create(name="Empty FC") # noqa: F841 — a shell, counted only at step one + with_member = Club.objects.create(name="Members FC") + season = Season.objects.create(club=with_member, start_date=timezone.localdate(), end_date=timezone.localdate() + datetime.timedelta(days=30)) + member = Member.objects.create(first_name="Ada", last_name="Lovelace") + ClubMembership.objects.create(club=with_member, season=season, member=member) + + steps = {step["label"]: step["count"] for step in onboarding_funnel()} + + self.assertEqual(steps["Clubs"], 2) + self.assertEqual(steps["With members"], 1) + self.assertEqual(steps["With a team"], 0) + self.assertEqual(steps["With events"], 0) + + +class FlagAdoptionTests(TestCase): + def setUp(self): + cache.clear() + self.addCleanup(cache.clear) + self.club = Club.objects.create(name="Ajax United") + + def test_clubs_are_counted_per_flag(self): + # Not an exact-list assertion: migration 0018 seeds real "CEHL"/"RBIHF" + # flags for the built-in competitions, so a fresh test database is never + # actually flag-free. + flag = Flag.objects.create(name="shop") + flag.clubs.add(self.club) + + self.assertIn({"name": "shop", "clubs": 1, "everyone": None, "overridden": False}, flag_adoption()) + + def test_an_everyone_flag_reports_itself_as_overridden(self): + # `everyone` beats club targeting, so the club count would be a lie. + Flag.objects.create(name="shop", everyone=True) + + shop_entry = next(entry for entry in flag_adoption() if entry["name"] == "shop") + self.assertTrue(shop_entry["overridden"]) + + +class PlatformChartTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United") + self.season = Season.objects.create(club=self.club, start_date=timezone.localdate(), end_date=timezone.localdate() + datetime.timedelta(days=30)) + self.member = Member.objects.create(first_name="Ada", last_name="Lovelace") + + def test_the_series_is_dense(self): + # Zero-filled: a chart that skips empty months draws a smooth line over a month + # in which nothing happened. + series = platform_charts()["signups"] + + self.assertEqual(len(series), 13) + self.assertTrue(all(point["new"] == 0 and point["returning"] == 0 for point in series)) + + def test_the_series_stays_dense_on_a_late_day_of_the_month(self): + # A 30-days-per-month approximation of "12 months ago" drifts by a few days a + # year, and on the tail end of a month that drift used to fall short of a full + # calendar month, silently dropping the series to 12 points instead of 13. + late_month_day = timezone.now().replace(day=28) + + with mock.patch.object(timezone, "now", return_value=late_month_day): + series = platform_charts()["signups"] + + self.assertEqual(len(series), 13) + + def test_signups_land_in_the_month_they_happened(self): + ClubMembership.objects.create(club=self.club, season=self.season, member=self.member, signed_up_at=timezone.localdate()) + + series = platform_charts()["signups"] + + self.assertEqual(series[-1]["new"], 1) + + def test_new_is_per_club_not_per_platform(self): + # A veteran of one club joining a second is new *there*. Keying "first season" on the + # member alone would file their very first signup at the new club as a renewal. + other = Club.objects.create(name="Feyenoord") + old_season = Season.objects.create(club=other, start_date=timezone.localdate() - datetime.timedelta(days=400), end_date=timezone.localdate() - datetime.timedelta(days=40)) + ClubMembership.objects.create(club=other, season=old_season, member=self.member, signed_up_at=timezone.localdate() - datetime.timedelta(days=300)) + ClubMembership.objects.create(club=self.club, season=self.season, member=self.member, signed_up_at=timezone.localdate()) + + self.assertEqual(platform_charts()["signups"][-1]["new"], 1) + + def test_only_paid_orders_count_as_club_revenue(self): + # `club_revenue` is members paying their clubs. It is NOT platform income, which is + # why it no longer shares a chart (or a name) with our dues. + Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("50.00"), status=Order.OrderStatus.PAID) + Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("30.00"), status=Order.OrderStatus.PENDING) + + self.assertEqual(platform_charts()["club_revenue"][-1]["value"], 50.0) + self.assertEqual(platform_attention()["outstanding"], Decimal("30.00")) + + +class DashboardMetricsTests(ControlPanelTestBase): + def test_the_dashboard_renders_its_metrics_and_charts(self): + response = self.client.get(reverse("controlpanel:dashboard")) + + self.assertContains(response, "No current season") + self.assertContains(response, "MFA pending") + self.assertContains(response, "Payment pending") + self.assertContains(response, 'id="signups-chart"') + self.assertContains(response, "js/chart.js") + self.assertIn("signups", response.context["charts"]) + + +class ClubAttentionTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United") + self.today = timezone.localdate() + self.season = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=30), end_date=self.today + datetime.timedelta(days=300)) + self.member = Member.objects.create(first_name="Ada", last_name="Lovelace") + + def membership(self, member=None, season=None, **kwargs): + return ClubMembership.objects.create(club=self.club, season=season or self.season, member=member or self.member, **kwargs) + + def test_a_team_with_no_manager_is_flagged(self): + team = Team.objects.create(club=self.club, name="U15") + + self.assertIn(team, teams_without_a_manager(self.club, self.season)) + + coach = Position.objects.create(club=self.club, name="Coach", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=self.member, season=self.season, position=coach) + + self.assertNotIn(team, teams_without_a_manager(self.club, self.season)) + + def test_a_non_management_staffer_does_not_count_as_a_coach(self): + # Somebody has to be able to pick the squad; a physio cannot. + team = Team.objects.create(club=self.club, name="U15") + physio = Position.objects.create(club=self.club, name="Physio", staff_position=True, management_position=False) + StaffAssignment.objects.create(team=team, member=self.member, season=self.season, position=physio) + + self.assertIn(team, teams_without_a_manager(self.club, self.season)) + + def test_a_coach_from_a_previous_season_does_not_count(self): + old = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40)) + team = Team.objects.create(club=self.club, name="U15") + coach = Position.objects.create(club=self.club, name="Coach", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=self.member, season=old, position=coach) + + self.assertIn(team, teams_without_a_manager(self.club, self.season)) + + def test_an_active_member_on_no_team_is_unrostered(self): + self.membership(status=ClubMembership.StatusChoices.ACTIVE) + + self.assertIn(self.member, unrostered_members(self.club, self.season)) + + team = Team.objects.create(club=self.club, name="U15") + position = Position.objects.create(club=self.club, name="Forward") + TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=position, jersey_number=9) + + self.assertNotIn(self.member, unrostered_members(self.club, self.season)) + + def test_a_pending_member_is_not_counted_as_unrostered(self): + # They have not been let in yet, so having no team is expected. + self.membership(status=ClubMembership.StatusChoices.PENDING) + + self.assertNotIn(self.member, unrostered_members(self.club, self.season)) + + def test_renewal_compares_against_the_previous_season(self): + previous = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40)) + stayed = self.member + left = Member.objects.create(first_name="Bob", last_name="Bobson") + self.membership(member=stayed, season=previous, status=ClubMembership.StatusChoices.ACTIVE) + self.membership(member=left, season=previous, status=ClubMembership.StatusChoices.ACTIVE) + self.membership(member=stayed, season=self.season) + + self.assertEqual(renewal_rate(self.club, self.season), 50) + + def test_a_previous_season_with_nobody_active_yields_no_rate(self): + # There is a season to compare against but nobody to renew — dividing by that + # would blow up, and calling it 0% would be a lie. + previous = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40)) + self.membership(season=previous, status=ClubMembership.StatusChoices.LAPSED) + + self.assertIsNone(renewal_rate(self.club, self.season)) + + def test_a_first_season_club_has_no_renewal_rate(self): + # It has not failed to renew anyone; rendering that as 0% would libel it. + self.membership(status=ClubMembership.StatusChoices.ACTIVE) + + self.assertIsNone(renewal_rate(self.club, self.season)) + + def test_unpaid_orders_are_bucketed_by_age(self): + old = Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("100.00"), status=Order.OrderStatus.PENDING) + Order.objects.filter(pk=old.pk).update(created=timezone.now() - datetime.timedelta(days=90)) + Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("40.00"), status=Order.OrderStatus.PENDING) + Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("999.00"), status=Order.OrderStatus.PAID) + + buckets = {bucket["label"]: bucket["total"] for bucket in fee_aging(self.club)} + + self.assertEqual(buckets["0-30 days"], Decimal("40.00")) + self.assertEqual(buckets["60+ days"], Decimal("100.00")) # paid orders are not owed + + def test_attendance_is_turnout_of_those_who_answered(self): + event = Event.objects.create(club=self.club, season=self.season, title="Match", start=timezone.now() - datetime.timedelta(days=1)) + bob = Member.objects.create(first_name="Bob", last_name="Bobson") + carol = Member.objects.create(first_name="Carol", last_name="Carolson") + Attendance.objects.create(event=event, member=self.member, status=Attendance.AttendanceStatus.PRESENT) + Attendance.objects.create(event=event, member=bob, status=Attendance.AttendanceStatus.ABSENT) + Attendance.objects.create(event=event, member=carol, status=Attendance.AttendanceStatus.NO_RESPONSE) + + rates = attendance_rates(self.club, self.season) + + self.assertEqual(rates["turnout"], 50) # 1 present of 2 who answered — silence is not an absence + self.assertEqual(rates["no_response"], 33) # 1 of 3 never answered + + def test_a_future_event_does_not_drag_turnout_down(self): + event = Event.objects.create(club=self.club, season=self.season, title="Next week", start=timezone.now() + datetime.timedelta(days=7)) + Attendance.objects.create(event=event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE) + + self.assertIsNone(attendance_rates(self.club, self.season)["turnout"]) + + def test_a_club_with_no_season_reports_no_rates(self): + Season.objects.all().delete() + + attention = club_attention(self.club) + + self.assertTrue(attention["no_season"]) + self.assertEqual(attention["teams_without_manager"], 0) + self.assertIsNone(attention["renewal_rate"]) + + +class ClubDetailMetricsTests(ControlPanelTestBase): + def test_the_club_page_renders_its_metrics_and_charts(self): + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, "Teams without coach") + self.assertContains(response, "Unrostered") + self.assertContains(response, "Club fee status this season") + self.assertContains(response, 'id="fees-chart"') + self.assertIn("fees", response.context["charts"]) + + def test_a_club_without_a_season_is_told_it_is_inert(self): + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, "cannot take a signup") + + def test_a_club_logo_gets_a_primary_coloured_ring(self): + # The control panel never injects a page-wide --color-primary override (that would + # dress the whole panel up as the club), so the ring colour is scoped locally to + # this element instead — assert the club's own colour reaches it regardless. + self.club.logo = "clubs/ajax-united/crest.png" + self.club.primary_color = "#1e40af" + self.club.save() + + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, "ring-primary") + self.assertContains(response, "--color-primary: #1e40af") + + +class NewMemberTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United") + self.today = timezone.localdate() + self.previous = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40)) + self.season = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=30), end_date=self.today + datetime.timedelta(days=300)) + self.veteran = Member.objects.create(first_name="Ada", last_name="Lovelace") + self.rookie = Member.objects.create(first_name="Bob", last_name="Bobson") + + def membership(self, member, season, signed_up_at=None): + return ClubMembership.objects.create(club=self.club, season=season, member=member, status=ClubMembership.StatusChoices.ACTIVE, signed_up_at=signed_up_at) + + def test_only_first_timers_count_as_new(self): + self.membership(self.veteran, self.previous) + self.membership(self.veteran, self.season) + self.membership(self.rookie, self.season) + + new = new_members(self.club, self.season) + + self.assertIn(self.rookie, new) + self.assertNotIn(self.veteran, new) + + def test_a_member_returning_after_a_gap_is_not_new(self): + # They skipped a season and came back. Counting that as growth would flatter every + # recovery; they are a renewal. + self.membership(self.veteran, self.previous) + gap = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=39), end_date=self.today - datetime.timedelta(days=31)) # noqa: F841 + self.membership(self.veteran, self.season) + + self.assertNotIn(self.veteran, new_members(self.club, self.season)) + + def test_a_member_of_another_club_is_new_here(self): + # "New" is per club, not per platform. + other = Club.objects.create(name="Feyenoord") + other_season = Season.objects.create(club=other, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40)) + ClubMembership.objects.create(club=other, season=other_season, member=self.rookie) + self.membership(self.rookie, self.season) + + self.assertIn(self.rookie, new_members(self.club, self.season)) + + def test_a_club_with_no_season_has_no_new_members(self): + self.assertEqual(new_members(self.club, None).count(), 0) + + def test_signups_are_split_by_month_into_new_and_returning(self): + self.membership(self.veteran, self.previous, signed_up_at=self.today - datetime.timedelta(days=200)) + self.membership(self.veteran, self.season, signed_up_at=self.today) + self.membership(self.rookie, self.season, signed_up_at=self.today) + + this_month = signup_split(self.club)[-1] + + self.assertEqual(this_month["new"], 1) # the rookie + self.assertEqual(this_month["returning"], 1) # the veteran renewing + + def test_a_first_ever_signup_counts_as_new_in_its_own_month(self): + self.membership(self.veteran, self.previous, signed_up_at=self.today - datetime.timedelta(days=200)) + + series = signup_split(self.club) + + self.assertEqual(sum(month["new"] for month in series), 1) + self.assertEqual(sum(month["returning"] for month in series), 0) + + +class ClubHealthTableTests(TestCase): + def setUp(self): + self.today = timezone.localdate() + self.club = Club.objects.create(name="Ajax United") + self.season = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=30), end_date=self.today + datetime.timedelta(days=300)) + self.member = Member.objects.create(first_name="Ada", last_name="Lovelace") + + def health(self): + return clubs_with_health().get(pk=self.club.pk) + + def test_the_whole_table_costs_one_query(self): + Club.objects.create(name="Feyenoord") + + with self.assertNumQueries(1): + [(club.active_members, club.outstanding, club.teams_without_coach) for club in clubs_with_health()] + + def test_money_is_not_inflated_by_other_joins(self): + # The reason each aggregate is its own subquery: a Sum and a Count spanning different + # joins multiply each other's rows, and the club's debt comes back doubled for every + # membership it happens to have. + for name in ("Bob", "Carol", "Dave"): + member = Member.objects.create(first_name=name, last_name="Bobson") + ClubMembership.objects.create(club=self.club, season=self.season, member=member, status=ClubMembership.StatusChoices.ACTIVE) + Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("100.00"), status=Order.OrderStatus.PENDING) + + health = self.health() + + self.assertEqual(health.outstanding, Decimal("100.00")) # not 300.00 + self.assertEqual(health.active_members, 3) + + def test_a_club_reports_its_missing_coaches(self): + Team.objects.create(club=self.club, name="U15") + managed = Team.objects.create(club=self.club, name="U17") + coach = Position.objects.create(club=self.club, name="Coach", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=managed, member=self.member, season=self.season, position=coach) + + health = self.health() + + self.assertEqual(health.team_count, 2) + self.assertEqual(health.teams_without_coach, 1) + + def test_a_club_with_no_season_and_no_events_is_marked(self): + Season.objects.all().delete() + + health = self.health() + + self.assertFalse(health.has_season) + self.assertEqual(health.upcoming_events, 0) + + def test_upcoming_events_only_count_the_next_thirty_days(self): + Event.objects.create(club=self.club, title="Soon", start=timezone.now() + datetime.timedelta(days=3)) + Event.objects.create(club=self.club, title="Far", start=timezone.now() + datetime.timedelta(days=90)) + + self.assertEqual(self.health().upcoming_events, 1) + + def test_the_dashboard_table_shows_health_not_vanity(self): + user = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True) + enrol_mfa(user) + self.client.force_login(user) + + response = self.client.get(reverse("controlpanel:dashboard")) + + # Health, not vanity: Plan and Dues each name something to act on, next to the counts. + for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"): + self.assertContains(response, f">{column}</th>") + + +class ClubListHealthTests(ControlPanelTestBase): + def test_the_list_shows_the_same_health_columns_as_the_dashboard(self): + response = self.client.get(reverse("controlpanel:club_list")) + + for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"): + self.assertContains(response, f">{column}</th>") + self.assertTemplateUsed(response, "controlpanel/_club_health_table.html") + + def test_an_archived_club_is_badged_archived_rather_than_dormant(self): + # Its subdomain does not resolve, so "nothing scheduled" is not news. + self.club.archive() + + response = self.client.get(reverse("controlpanel:club_list"), {"archived": "1"}) + + self.assertContains(response, "Archived") + self.assertNotContains(response, "Dormant") + + def test_searching_keeps_the_health_annotations(self): + response = self.client.get(reverse("controlpanel:club_list"), {"q": "Ajax"}) + + club = response.context["clubs"][0] + + self.assertEqual(club.active_members, 0) + self.assertEqual(club.teams_without_coach, 0) + + def test_the_list_does_not_fan_out_per_club(self): + for name in ("Feyenoord", "PSV", "Twente"): + Club.objects.create(name=name) + + with self.assertNumQueries(1): + [(club.outstanding, club.upcoming_events) for club in clubs_with_health()] + + +class TemplateCommentTests(TestCase): + def test_no_template_uses_a_multiline_hash_comment(self): + """Django's {# #} is single-line only — its lexer regex is not DOTALL, so a + multi-line one is not a comment at all: it renders to the page as text.""" + templates = [path for path in pathlib.Path(settings.BASE_DIR).glob("**/templates/**/*.html") if ".venv" not in path.parts and "node_modules" not in path.parts] + offenders = [f"{path.relative_to(settings.BASE_DIR)}:{number}" for path in templates for number, line in enumerate(path.read_text().splitlines(), start=1) if "{#" in line and "#}" not in line] + + self.assertTrue(templates) # the glob must actually be finding our templates + self.assertEqual(offenders, [], "use {% comment %} for multi-line comments") + + +class PlatformDuesMetricTests(TestCase): + """What the clubs owe US — kept strictly apart from what members owe their clubs.""" + + def setUp(self): + self.today = timezone.localdate() + self.club = Club.objects.create(name="Ajax United") + self.tier = Tier.objects.create(name="Standard") + TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) + + def test_dues_owed_is_the_unpaid_balance_across_every_club(self): + subscribe(self.club, self.tier) + record_payment(self.club.dues.first(), Decimal("200.00")) + + self.assertEqual(platform_attention()["dues_owed"], Decimal("300.00")) + + def test_grace_and_overdue_are_counted_separately(self): + in_grace = Club.objects.create(name="Grace FC") + overdue = Club.objects.create(name="Overdue FC") + subscribe(in_grace, self.tier, start=self.today - datetime.timedelta(days=370)) + subscribe(overdue, self.tier, start=self.today - datetime.timedelta(days=365 + GRACE_DAYS + 10)) + + attention = platform_attention() + + self.assertEqual(attention["dues_in_grace"], 1) + self.assertEqual(attention["dues_overdue"], 1) + + def test_clubs_on_no_tier_are_flagged(self): + self.assertEqual(platform_attention()["clubs_unbilled"], 1) + + subscribe(self.club, self.tier) + + self.assertEqual(platform_attention()["clubs_unbilled"], 0) + + def test_renewals_pending_counts_clubs_about_to_lapse(self): + # ~0 in normal running; a number here means the renewal cron has stopped. + self.assertEqual(platform_attention()["renewals_pending"], 0) + + subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350)) # ends in 15 days + + self.assertEqual(platform_attention()["renewals_pending"], 1) + + def test_the_dashboard_surfaces_pending_renewals(self): + # The whole point of the KPI: a club about to go free is visible, though nothing is + # owed yet, so no other figure on the page would show it. + subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350)) + staff = User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True) + enrol_mfa(staff) + self.client.force_login(staff) + + self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "awaiting renewal") + + def test_platform_dues_and_club_shop_money_are_different_charts(self): + subscribe(self.club, self.tier) + record_payment(self.club.dues.first(), Decimal("500.00")) + + charts = platform_charts() + + self.assertEqual(charts["dues"][-1]["value"], 500.0) + self.assertEqual(charts["club_revenue"][-1]["value"], 0.0) # never ours + + def test_the_health_table_carries_the_plan_and_what_is_owed(self): + subscribe(self.club, self.tier) + + club = clubs_with_health().get(pk=self.club.pk) + + self.assertEqual(club.tier_name, "Standard") + self.assertEqual(club.dues_owed, Decimal("500.00")) + + def test_a_fully_paid_club_shows_when_its_cover_ends(self): + # The end of the current paid period is the day grace would start if nothing renews. + subscribe(self.club, self.tier) + due = self.club.dues.first() + record_payment(due, due.amount) + + club = clubs_with_health().get(pk=self.club.pk) + + self.assertEqual(club.covered_until, due.period_end) + self.assertEqual(club.covered_status, Due.Status.PAID) + + def test_a_waived_period_also_shows_its_cover_end(self): + # Waived is settled too — the club is covered for that time, so its end date shows, + # badged "waived" rather than "paid". + subscribe(self.club, self.tier) + due = self.club.dues.first() + waive(due) + + club = clubs_with_health().get(pk=self.club.pk) + + self.assertEqual(club.covered_until, due.period_end) + self.assertEqual(club.covered_status, Due.Status.WAIVED) + + def test_a_club_that_owes_has_no_cover(self): + subscribe(self.club, self.tier) # unpaid + + club = clubs_with_health().get(pk=self.club.pk) + self.assertIsNone(club.covered_until) + self.assertIsNone(club.covered_status) + + def test_the_health_table_still_costs_one_query_with_billing_on_it(self): + subscribe(self.club, self.tier) + subscribe(Club.objects.create(name="Feyenoord"), self.tier) + + with self.assertNumQueries(1): + [(club.tier_name, club.dues_owed, club.outstanding) for club in clubs_with_health()] + + +class BillingPanelTests(ControlPanelTestBase): + def setUp(self): + super().setUp() + self.today = timezone.localdate() + self.tier = Tier.objects.create(name="Standard") + TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) + + def test_the_billing_page_lists_tiers_and_what_is_owed(self): + subscribe(self.club, self.tier) + + response = self.client.get(reverse("controlpanel:billing")) + + self.assertContains(response, "Standard") + self.assertContains(response, "500.00") + + def test_a_tier_can_be_created_and_priced(self): + self.client.post(reverse("controlpanel:tier_create"), {"name": "Large", "description": "", "is_active": "on"}) + tier = Tier.objects.get(name="Large") + + self.client.post(reverse("controlpanel:tier_price_create", args=[tier.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"}) + + self.assertEqual(tier.price_on(self.today), Decimal("900.00")) + + def test_a_rate_change_does_not_rewrite_an_open_period(self): + subscribe(self.club, self.tier) + + self.client.post(reverse("controlpanel:tier_price_create", args=[self.tier.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"}) + + self.assertEqual(self.club.dues.first().amount, Decimal("500.00")) + + def test_subscribing_a_club_opens_its_first_period(self): + self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": self.tier.pk, "auto_archive": "on", "notes": ""}) + + self.assertEqual(self.club.dues.count(), 1) + self.assertEqual(self.club.subscription.tier, self.tier) + + def test_a_payment_can_be_recorded_and_settles_the_due(self): + subscribe(self.club, self.tier) + due = self.club.dues.first() + + self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "500.00", "method": "bank_transfer", "reference": "TRX-1", "paid_at": "", "note": ""}) + + due.refresh_from_db() + self.assertEqual(due.status, Due.Status.PAID) + self.assertEqual(due.payments.first().recorded_by, self.staff) + + def test_a_part_payment_leaves_a_balance(self): + subscribe(self.club, self.tier) + due = self.club.dues.first() + + self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "200.00", "method": "bank_transfer", "reference": "", "paid_at": "", "note": ""}) + + due.refresh_from_db() + self.assertEqual(due.balance, Decimal("300.00")) + + def test_a_billing_error_is_shown_rather_than_raised(self): + # A waived period cannot take a payment; the panel must say so, not 500. + subscribe(self.club, self.tier) + due = self.club.dues.first() + self.client.post(reverse("controlpanel:due_waive", args=[due.pk])) + + response = self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "50.00", "method": "cash", "reference": "", "paid_at": "", "note": ""}, follow=True) + + self.assertContains(response, "cannot take a payment") + + def test_a_period_can_be_waived(self): + subscribe(self.club, self.tier) + due = self.club.dues.first() + + self.client.post(reverse("controlpanel:due_waive", args=[due.pk])) + + due.refresh_from_db() + self.assertEqual(due.status, Due.Status.WAIVED) + + def test_opening_a_period_continues_from_the_last_one(self): + subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400)) + first = self.club.dues.first() + + self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": ""}) + + latest = self.club.dues.order_by("-period_start").first() + self.assertEqual(latest.period_start, first.period_end + datetime.timedelta(days=1)) + + def test_reactivating_an_archived_club_restores_it(self): + subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400)) + self.club.archive() + + self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": self.today.isoformat()}) + + self.club.refresh_from_db() + self.assertFalse(self.club.is_archived) + + def test_the_club_page_shows_the_plan_and_its_periods(self): + subscribe(self.club, self.tier) + + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, "Standard") + self.assertContains(response, "INV-") + + def test_an_invoice_downloads_as_a_pdf(self): + subscribe(self.club, self.tier) + due = self.club.dues.first() + + with mock.patch("controlpanel.views.invoice_pdf", return_value=b"%PDF-1.7 fake"): + response = self.client.get(reverse("controlpanel:due_invoice", args=[due.pk])) + + self.assertEqual(response["Content-Type"], "application/pdf") + self.assertIn(due.invoice.number, response["Content-Disposition"]) + + def test_a_missing_pdf_library_is_reported_rather_than_a_500(self): + # WeasyPrint needs native libs. Without them the button must explain itself. + subscribe(self.club, self.tier) + due = self.club.dues.first() + + with mock.patch("controlpanel.views.invoice_pdf", side_effect=BillingError("PDF rendering needs the native pango/cairo libraries.")): + response = self.client.get(reverse("controlpanel:due_invoice", args=[due.pk]), follow=True) + + self.assertContains(response, "pango") + + +class TrialPanelTests(ControlPanelTestBase): + """The club detail page's "Start trial" modal -- see + controlpanel.views.ClubStartTrialView / billing.services.dues.start_trial.""" + + def setUp(self): + super().setUp() + self.today = timezone.localdate() + 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")) + self.tier = Tier.objects.create(name="Standard") + TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) + + def start_trial(self, **data): + data = {"trial_tier": self.trial_tier.pk, "post_trial_tier": self.tier.pk, "trial_months": 2, "start": ""} | data + return self.client.post(reverse("controlpanel:club_trial_start", args=[self.club.pk]), data) + + def test_starting_a_trial_opens_a_short_first_period(self): + response = self.start_trial() + + self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk])) + self.assertEqual(self.club.subscription.tier, self.trial_tier) + self.assertEqual(self.club.subscription.post_trial_tier, self.tier) + due = self.club.dues.first() + self.assertTrue(due.is_trial) + self.assertLess((due.period_end - due.period_start).days, 65) + + def test_a_club_already_subscribed_cannot_be_started_on_a_trial(self): + subscribe(self.club, self.tier) + + response = self.client.post( + reverse("controlpanel:club_trial_start", args=[self.club.pk]), + {"trial_tier": self.trial_tier.pk, "post_trial_tier": self.tier.pk, "trial_months": 2, "start": ""}, + follow=True, + ) + + self.assertContains(response, "already subscribed") + self.assertEqual(self.club.dues.count(), 1) + + def test_an_invalid_trial_length_redirects_back_with_an_error(self): + response = self.client.post(reverse("controlpanel:club_trial_start", args=[self.club.pk]), {"trial_tier": self.trial_tier.pk, "post_trial_tier": self.tier.pk, "trial_months": 0, "start": ""}, follow=True) + + self.assertFalse(hasattr(self.club, "subscription")) + self.assertTrue(response.context["messages"]) + + def test_the_club_page_shows_the_active_trial(self): + self.start_trial() + + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + self.assertContains(response, "On trial") + self.assertContains(response, "Standard") + + def test_manually_changing_tier_mid_trial_clears_the_trial(self): + self.start_trial() + other = Tier.objects.create(name="Other") + TierPrice.objects.create(tier=other, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("300.00")) + + self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": other.pk, "auto_archive": "on", "notes": ""}) + + self.club.refresh_from_db() + self.assertEqual(self.club.subscription.tier, other) + self.assertIsNone(self.club.subscription.trial_ends_at) + self.assertIsNone(self.club.subscription.post_trial_tier) + + +class BillingFormRenderTests(ControlPanelTestBase): + def setUp(self): + super().setUp() + self.today = timezone.localdate() + self.tier = Tier.objects.create(name="Standard") + TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) + + def test_the_billing_forms_are_post_only(self): + # Every one of these is reachable only through a modal on the billing or club + # detail page: there is no standalone template to render on a GET. + subscribe(self.club, self.tier) + due = self.club.dues.first() + + for url in ( + reverse("controlpanel:tier_create"), + reverse("controlpanel:tier_update", args=[self.tier.pk]), + reverse("controlpanel:tier_price_create", args=[self.tier.pk]), + reverse("controlpanel:club_subscribe", args=[self.club.pk]), + reverse("controlpanel:club_open_period", args=[self.club.pk]), + reverse("controlpanel:due_pay", args=[due.pk]), + ): + self.assertEqual(self.client.get(url).status_code, 405, url) + + def test_the_billing_forms_redirect_with_a_message_on_invalid_input(self): + # Rejected input has nowhere to re-render — the modal that submitted it is on a + # page this view no longer serves — so it must bounce back with an error message + # rather than 500 or silently drop the submission. + subscribe(self.club, self.tier) + due = self.club.dues.first() + + response = self.client.post(reverse("controlpanel:tier_create"), {"name": "", "description": "", "is_active": "on"}, follow=True) + self.assertRedirects(response, reverse("controlpanel:billing")) + self.assertContains(response, "This field is required") + + response = self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "not-a-number", "method": "bank_transfer", "reference": "", "paid_at": "", "note": ""}, follow=True) + self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk])) + self.assertContains(response, "Enter a number") + + def test_the_payment_modal_defaults_to_the_outstanding_balance(self): + subscribe(self.club, self.tier) + due = self.club.dues.first() + record_payment(due, Decimal("200.00")) + due.refresh_from_db() + + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) + + rendered_due = next(rendered for rendered in response.context["dues"] if rendered.pk == due.pk) + self.assertEqual(rendered_due.payment_form.initial["amount"], Decimal("300.00")) + + def test_a_tier_can_be_renamed(self): + self.client.post(reverse("controlpanel:tier_update", args=[self.tier.pk]), {"name": "Standard plus", "description": "", "is_active": "on"}) + + self.tier.refresh_from_db() + self.assertEqual(self.tier.name, "Standard plus") + + def test_changing_tier_leaves_the_open_period_alone(self): + # The current period keeps the amount it was issued at; the new rate bites next time. + subscribe(self.club, self.tier) + premium = Tier.objects.create(name="Premium") + TierPrice.objects.create(tier=premium, active_from=self.today, amount=Decimal("900.00")) + + response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": premium.pk, "auto_archive": "on", "notes": ""}, follow=True) + + self.club.refresh_from_db() + self.assertEqual(self.club.subscription.tier, premium) + self.assertEqual(self.club.dues.first().amount, Decimal("500.00")) + self.assertContains(response, "keeps the amount it was billed at") + + def test_subscribing_to_an_unpriced_tier_reports_itself(self): + unpriced = Tier.objects.create(name="Enterprise") + + response = self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": unpriced.pk, "auto_archive": "on", "notes": ""}, follow=True) + + self.assertContains(response, "no price in force") + + def test_billing_a_period_twice_reports_itself(self): + subscribe(self.club, self.tier) + start = self.club.dues.first().period_start + + response = self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": start.isoformat()}, follow=True) + + self.assertContains(response, "already billed") + + def test_waiving_a_paid_period_reports_itself(self): + subscribe(self.club, self.tier) + due = self.club.dues.first() + record_payment(due, Decimal("500.00")) + + response = self.client.post(reverse("controlpanel:due_waive", args=[due.pk]), follow=True) + + self.assertContains(response, "remove them before waiving") + + +class MaintenancePanelTests(ControlPanelTestBase): + def setUp(self): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + + def test_closing_the_platform_records_who_did_it(self): + self.client.post(reverse("controlpanel:maintenance"), {"message": "Database upgrade."}) + + maintenance = Maintenance.current() + self.assertTrue(maintenance.is_active) + self.assertEqual(maintenance.message, "Database upgrade.") + self.assertEqual(maintenance.started_by, self.staff) + + def test_posting_again_reopens_the_platform(self): + Maintenance.start(message="x", user=self.staff) + + self.client.post(reverse("controlpanel:maintenance"), {}) + + self.assertFalse(Maintenance.is_on()) + + def test_every_panel_page_warns_while_the_platform_is_closed(self): + # Not a state to leave on by accident. + Maintenance.start(user=self.staff) + + for url in (reverse("controlpanel:dashboard"), reverse("controlpanel:club_list"), reverse("controlpanel:features")): + self.assertContains(self.client.get(url), "closed for maintenance", msg_prefix=url) + + def test_the_features_page_offers_the_switch(self): + response = self.client.get(reverse("controlpanel:features")) + + self.assertContains(response, "Maintenance mode") + self.assertContains(response, "Close the platform") diff --git a/controlpanel/urls.py b/controlpanel/urls.py new file mode 100644 index 0000000..525ba45 --- /dev/null +++ b/controlpanel/urls.py @@ -0,0 +1,42 @@ +from django.urls import path + +from . import views + +app_name = "controlpanel" + +urlpatterns = [ + path("", views.DashboardView.as_view(), name="dashboard"), + # Clubs + path("clubs/", views.ClubListView.as_view(), name="club_list"), + path("clubs/new/", views.ClubCreateView.as_view(), name="club_create"), + path("clubs/<uuid:pk>/", views.ClubDetailView.as_view(), name="club_detail"), + path("clubs/<uuid:pk>/edit/", views.ClubUpdateView.as_view(), name="club_update"), + path("clubs/<uuid:pk>/archive/", views.ClubArchiveView.as_view(), name="club_archive"), + path("clubs/<uuid:pk>/restore/", views.ClubRestoreView.as_view(), name="club_restore"), + path("clubs/<uuid:pk>/home-location/", views.ClubHomeLocationSetView.as_view(), name="club_home_location_set"), + path("clubs/<uuid:pk>/admins/add/", views.ClubAdminAddView.as_view(), name="club_admin_add"), + path("clubs/<uuid:pk>/admins/<uuid:role_pk>/remove/", views.ClubAdminRemoveView.as_view(), name="club_admin_remove"), + path("clubs/<uuid:pk>/features/<int:flag_pk>/toggle/", views.ClubFeatureToggleView.as_view(), name="club_feature_toggle"), + # Features + path("features/", views.FeatureListView.as_view(), name="features"), + path("features/maintenance/", views.MaintenanceView.as_view(), name="maintenance"), + path("features/flags/new/", views.FlagCreateView.as_view(), name="flag_create"), + path("features/flags/<int:pk>/edit/", views.FlagUpdateView.as_view(), name="flag_update"), + path("features/switches/<int:pk>/toggle/", views.SwitchToggleView.as_view(), name="switch_toggle"), + # Billing (platform charging the clubs) + path("billing/", views.BillingView.as_view(), name="billing"), + path("billing/tiers/new/", views.TierCreateView.as_view(), name="tier_create"), + path("billing/tiers/<uuid:pk>/edit/", views.TierUpdateView.as_view(), name="tier_update"), + path("billing/tiers/<uuid:pk>/prices/new/", views.TierPriceCreateView.as_view(), name="tier_price_create"), + path("billing/dues/<uuid:pk>/pay/", views.RecordPaymentView.as_view(), name="due_pay"), + path("billing/dues/<uuid:pk>/waive/", views.WaiveDueView.as_view(), name="due_waive"), + path("billing/dues/<uuid:pk>/invoice.pdf", views.InvoicePdfView.as_view(), name="due_invoice"), + path("clubs/<uuid:pk>/subscription/", views.SubscribeClubView.as_view(), name="club_subscribe"), + path("clubs/<uuid:pk>/trial/start/", views.ClubStartTrialView.as_view(), name="club_trial_start"), + path("clubs/<uuid:pk>/period/new/", views.OpenPeriodView.as_view(), name="club_open_period"), + # Platform admins (superusers only) + path("admins/", views.PlatformAdminListView.as_view(), name="admins"), + path("admins/add/", views.PlatformAdminAddView.as_view(), name="admin_add"), + path("admins/<uuid:pk>/update/", views.PlatformAdminUpdateView.as_view(), name="admin_update"), + path("admins/<uuid:pk>/revoke/", views.PlatformAdminRevokeView.as_view(), name="admin_revoke"), +] diff --git a/controlpanel/views.py b/controlpanel/views.py new file mode 100644 index 0000000..c810a7e --- /dev/null +++ b/controlpanel/views.py @@ -0,0 +1,641 @@ +from contextlib import contextmanager + +from django.contrib.auth import get_user_model +from django.db.models import Count +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils import timezone +from django.utils.formats import date_format +from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View +from waffle import get_waffle_flag_model, get_waffle_switch_model + +from billing.models import Due, Tier, TierPrice +from billing.services import BillingError +from billing.services.dues import next_period_start, open_period, reactivate, record_payment, start_trial, subscribe, waive +from billing.services.invoices import invoice_pdf, issue_invoice +from club.models import Club, ClubRole +from events.models import Location +from features.models import Maintenance + +from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, HomeLocationForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm, TrialForm +from .messages import notify +from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin +from .services.admins import grant_club_admin, revoke_club_admin +from .services.platform_admins import ( + PlatformAdminError, + grant_platform_access, + platform_admins, + revoke_platform_access, + set_platform_access, +) +from .services.statistics import club_attention, club_charts, club_statistics, clubs_with_health, flag_adoption, flags_for_club, onboarding_funnel, platform_attention, platform_charts, platform_totals + +Flag = get_waffle_flag_model() +Switch = get_waffle_switch_model() + + +@contextmanager +def suppress_billing_errors(request, title="Billing error"): + """Turn a BillingError into an error message rather than letting it propagate. + + Fits call sites that fall through to the same redirect on the happy and unhappy path + alike — the success message is set inside the block, the failure message by this + context manager, and whichever fired, the caller's next line runs unchanged. + """ + try: + yield + except BillingError as error: + notify(request, f"e|{title}|{error}") + + +class DashboardView(PlatformStaffRequiredMixin, TemplateView): + template_name = "controlpanel/dashboard.html" + + def get_context_data(self, **kwargs): + return super().get_context_data( + nav="dashboard", + totals=platform_totals(), + attention=platform_attention(), + funnel=onboarding_funnel(), + flags=flag_adoption(), + charts=platform_charts(), + clubs=clubs_with_health(), + today=timezone.localdate(), + **kwargs, + ) + + +class ClubListView(PlatformStaffRequiredMixin, ListView): + template_name = "controlpanel/club_list.html" + context_object_name = "clubs" + + @property + def show_archived(self): + return self.request.GET.get("archived") == "1" + + def get_queryset(self): + clubs = Club.objects.archived() if self.show_archived else Club.objects.active() + search = self.request.GET.get("q", "").strip() + if search: + clubs = clubs.filter(name__icontains=search) + return clubs_with_health(clubs) + + def get_context_data(self, **kwargs): + return super().get_context_data(nav="clubs", show_archived=self.show_archived, search=self.request.GET.get("q", ""), today=timezone.localdate(), **kwargs) + + +class ClubCreateView(PlatformStaffRequiredMixin, CreateView): + model = Club + form_class = ClubForm + template_name = "controlpanel/club_form.html" + + def form_valid(self, form): + response = super().form_valid(form) + notify(self.request, f"s|Club created|Club “{self.object}” created.") + return response + + def get_success_url(self): + return reverse("controlpanel:club_detail", args=[self.object.pk]) + + def get_context_data(self, **kwargs): + return super().get_context_data(nav="clubs", **kwargs) + + +class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView): + model = Club + form_class = ClubForm + template_name = "controlpanel/club_form.html" + + def form_valid(self, form): + response = super().form_valid(form) + notify(self.request, f"s|Club updated|Club “{self.object}” updated.") + return response + + def get_success_url(self): + return reverse("controlpanel:club_detail", args=[self.object.pk]) + + def get_context_data(self, **kwargs): + return super().get_context_data(nav="clubs", update_view=True, **kwargs) + + +class ClubDetailView(PlatformStaffRequiredMixin, DetailView): + model = Club + template_name = "controlpanel/club_detail.html" + context_object_name = "club" + + def get_context_data(self, **kwargs): + subscription = getattr(self.object, "subscription", None) + next_start = next_period_start(self.object) + + # Bound per-row so each due's "Add payment" modal can render its own form without + # the template calling DuePaymentForm(initial=...) itself. + dues = list(self.object.dues.select_related("tier", "invoice").prefetch_related("payments")) + for due in dues: + if due.is_owing: + due.payment_form = DuePaymentForm(initial={"amount": due.balance}) + + home_location = Location.objects.filter(club=self.object, is_home=True).first() + + return super().get_context_data( + home_location=home_location, + home_location_form=HomeLocationForm(instance=home_location), + nav="clubs", + groups=club_statistics(self.object), + attention=club_attention(self.object), + charts=club_charts(self.object), + subscription=subscription, + dues=dues, + today=timezone.localdate(), + admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"), + admin_form=ClubAdminForm(), + flags=flags_for_club(self.object), + open_period_form=OpenPeriodForm(), + open_period_blurb=f"Next period starts {date_format(next_start, 'j M Y')} unless you say otherwise. By default it continues from the end of the last one, so a lapsed year is still owed — pick a start date to forgive the gap.", + subscription_form=SubscriptionForm(instance=subscription) if subscription else SubscriptionForm(), + trial_form=TrialForm(), + **kwargs, + ) + + +class ClubArchiveView(PlatformStaffRequiredMixin, View): + """Clubs are archived, never destroyed — their data (and invoices) are kept.""" + + def post(self, request, pk): + club = get_object_or_404(Club, pk=pk) + club.archive() + notify(request, f"w|Club archived|Club “{club}” archived. Its subdomain no longer resolves.") + return redirect("controlpanel:club_detail", pk=club.pk) + + +class ClubRestoreView(PlatformStaffRequiredMixin, View): + def post(self, request, pk): + club = get_object_or_404(Club, pk=pk) + club.restore() + notify(request, f"s|Club restored|Club “{club}” restored.") + return redirect("controlpanel:club_detail", pk=club.pk) + + +class ClubAdminAddView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView): + """Reachable only via the "Add admin" modal on the club detail page — POST-only, and + there is no standalone template to render on GET or on a rejected submission.""" + + form_class = ClubAdminForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:club_detail" + + @property + def club(self): + return get_object_or_404(Club, pk=self.kwargs["pk"]) + + def get_invalid_redirect_kwargs(self): + return {"pk": self.kwargs["pk"]} + + def form_valid(self, form): + role = grant_club_admin(self.club, **form.cleaned_data) + notify(self.request, f"s|Admin added|{role.member} is now an admin of {role.club}. They must set up two-factor authentication before they can sign in.") + return redirect("controlpanel:club_detail", pk=self.kwargs["pk"]) + + +class ClubHomeLocationSetView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView): + """Create or update the club's home Location -- reachable only via the "Home + location" modal on the club detail page. Binds to the existing home Location + (if any) so submitting the form edits it in place rather than ever creating + a second one; ``unique_home_location_per_club`` backs that up at the DB level.""" + + form_class = HomeLocationForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:club_detail" + + @property + def club(self): + return get_object_or_404(Club, pk=self.kwargs["pk"]) + + def get_invalid_redirect_kwargs(self): + return {"pk": self.kwargs["pk"]} + + def get_form_kwargs(self): + return super().get_form_kwargs() | {"instance": Location.objects.filter(club=self.club, is_home=True).first()} + + def form_valid(self, form): + location = form.save(commit=False) + location.club = self.club + location.is_home = True + location.save() + notify(self.request, f"s|Home location set|{location} is now {self.club}'s home location.") + return redirect("controlpanel:club_detail", pk=self.kwargs["pk"]) + + +class ClubAdminRemoveView(PlatformStaffRequiredMixin, View): + def post(self, request, pk, role_pk): + role = get_object_or_404(ClubRole, pk=role_pk, club_id=pk, role=ClubRole.Roles.ADMIN) + member = role.member + revoke_club_admin(role) + notify(request, f"w|Admin removed|{member} is no longer an admin of this club.") + return redirect("controlpanel:club_detail", pk=pk) + + +class ClubFeatureToggleView(PlatformStaffRequiredMixin, View): + """Turn a feature on or off for one club.""" + + def post(self, request, pk, flag_pk): + club = get_object_or_404(Club, pk=pk) + flag = get_object_or_404(Flag, pk=flag_pk) + + if flag.clubs.filter(pk=club.pk).exists(): + flag.clubs.remove(club) + notify(request, f"w|Feature disabled|“{flag.name}” turned off for {club}.") + else: + flag.clubs.add(club) + notify(request, f"s|Feature enabled|“{flag.name}” turned on for {club}.") + + return redirect("controlpanel:club_detail", pk=club.pk) + + +class FeatureListView(PlatformStaffRequiredMixin, TemplateView): + template_name = "controlpanel/features.html" + + def get_context_data(self, **kwargs): + # Bound per-row so each flag's "Edit" modal can render its own form: the template + # can't call FlagForm(instance=flag) itself, so the form rides along on the flag. + flags = list(Flag.objects.prefetch_related("clubs").order_by("name")) + for flag in flags: + flag.edit_form = FlagForm(instance=flag) + + return super().get_context_data( + nav="features", + flags=flags, + flag_form=FlagForm(), + switches=Switch.objects.order_by("name"), + maintenance=Maintenance.current(), + maintenance_form=MaintenanceForm(), + **kwargs, + ) + + +class MaintenanceView(PlatformStaffRequiredMixin, View): + """Close the platform, or open it again.""" + + def post(self, request): + if Maintenance.is_on(): + Maintenance.stop() + notify(request, "s|Maintenance ended|The clubs are back.") + else: + form = MaintenanceForm(request.POST) + message = form.cleaned_data["message"] if form.is_valid() else "" + Maintenance.start(message=message, user=request.user) + notify(request, "w|Platform closed|Every club subdomain now serves a maintenance page, and the scheduled jobs stand down.") + + return redirect("controlpanel:features") + + +class FlagCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView): + """Reachable only via the "New feature" modal on the features page — POST-only, and + there is no standalone template to render on GET or on a rejected submission.""" + + model = Flag + form_class = FlagForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:features" + + def form_valid(self, form): + response = super().form_valid(form) + notify(self.request, f"s|Feature created|Feature “{self.object.name}” created.") + return response + + def get_success_url(self): + return reverse("controlpanel:features") + + +class FlagUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView): + """Reachable only via a flag's "Edit" modal on the features page — POST-only, and + there is no standalone template to render on GET or on a rejected submission.""" + + model = Flag + form_class = FlagForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:features" + + def form_valid(self, form): + response = super().form_valid(form) + notify(self.request, f"s|Feature updated|Feature “{self.object.name}” updated.") + return response + + def get_success_url(self): + return reverse("controlpanel:features") + + +class SwitchToggleView(PlatformStaffRequiredMixin, View): + """Global kill-switch: on or off for the whole platform.""" + + def post(self, request, pk): + switch = get_object_or_404(Switch, pk=pk) + switch.active = not switch.active + switch.save() + title = "Switch on" if switch.active else "Switch off" + notify(request, f"s|{title}|Switch “{switch.name}” is now {'on' if switch.active else 'off'}.") + return redirect("controlpanel:features") + + +class PlatformAdminListView(PlatformSuperuserRequiredMixin, TemplateView): + template_name = "controlpanel/admins.html" + + def get_context_data(self, **kwargs): + return super().get_context_data(nav="admins", admins=platform_admins(), admin_form=PlatformAdminForm(), **kwargs) + + +class PlatformAdminAddView(PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin, FormView): + """Reachable only via the "Grant access" modal on the admins page — POST-only, and + there is no standalone template to render on GET or on a rejected submission.""" + + form_class = PlatformAdminForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:admins" + + def form_valid(self, form): + user = grant_platform_access(form.cleaned_data["email"], is_superuser=form.cleaned_data["is_superuser"]) + notify(self.request, f"s|Platform access granted|{user.email} now has platform access. They must set up two-factor authentication before they can sign in.") + return redirect("controlpanel:admins") + + +class PlatformAdminUpdateView(PlatformSuperuserRequiredMixin, View): + def post(self, request, pk): + user = get_object_or_404(get_user_model(), pk=pk) + try: + set_platform_access( + request.user, + user, + is_staff=request.POST.get("is_staff") == "1", + is_superuser=request.POST.get("is_superuser") == "1", + ) + except PlatformAdminError as error: + notify(request, f"e|Couldn't update access|{error}") + else: + notify(request, f"s|Access updated|Updated platform access for {user.email}.") + return redirect("controlpanel:admins") + + +class PlatformAdminRevokeView(PlatformSuperuserRequiredMixin, View): + def post(self, request, pk): + user = get_object_or_404(get_user_model(), pk=pk) + try: + revoke_platform_access(request.user, user) + except PlatformAdminError as error: + notify(request, f"e|Couldn't revoke access|{error}") + else: + notify(request, f"w|Access revoked|{user.email} no longer has platform access.") + return redirect("controlpanel:admins") + + +class BillingView(PlatformStaffRequiredMixin, TemplateView): + """Tiers and their prices, plus every period we are owed money for.""" + + template_name = "controlpanel/billing.html" + + def get_context_data(self, **kwargs): + today = timezone.localdate() + + # Bound per-row so each "Edit" / "New price" modal can render its own form: the + # template can't call TierForm(instance=tier) itself, so the form rides along on + # the object it belongs to. + tiers = list(Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions"))) + for tier in tiers: + tier.edit_form = TierForm(instance=tier) + tier.price_form = TierPriceForm() + + owing = list(Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until")) + for due in owing: + due.payment_form = DuePaymentForm(initial={"amount": due.balance}) + + return super().get_context_data( + nav="billing", + tiers=tiers, + tier_form=TierForm(), + owing=owing, + today=today, + **kwargs, + ) + + +class TierCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView): + """Reachable only via the "New plan" modal on the billing page — POST-only, and there + is no standalone template to render on GET or on a rejected submission.""" + + model = Tier + form_class = TierForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:billing" + + def get_success_url(self): + notify(self.request, f"s|Plan created|Tier “{self.object}” created. Give it a price before billing anyone.") + return reverse("controlpanel:billing") + + +class TierUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView): + """Reachable only via a tier's "Edit" modal on the billing page — POST-only, and there + is no standalone template to render on GET or on a rejected submission.""" + + model = Tier + form_class = TierForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:billing" + + def get_success_url(self): + notify(self.request, f"s|Plan updated|Tier “{self.object}” updated.") + return reverse("controlpanel:billing") + + +class TierPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView): + """A rate change is a new dated price, never an edit of the old one — periods already + billed keep the amount they were billed at. + + Reachable only via a tier's "New price" modal on the billing page — POST-only, and + there is no standalone template to render on GET or on a rejected submission. + """ + + model = TierPrice + form_class = TierPriceForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:billing" + + @property + def tier(self): + return get_object_or_404(Tier, pk=self.kwargs["pk"]) + + def form_valid(self, form): + form.instance.tier = self.tier + response = super().form_valid(form) + notify(self.request, f"s|Price added|{self.tier} is €{self.object.amount} for periods opening from {self.object.active_from}.") + return response + + def get_success_url(self): + return reverse("controlpanel:billing") + + +class SubscribeClubView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView): + """Put a club on a tier, which opens its first period. + + Reachable only via the "Change plan" modal on the club detail page — POST-only, and + there is no standalone template to render on GET or on a rejected submission. + """ + + form_class = SubscriptionForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:club_detail" + + @property + def club(self): + return get_object_or_404(Club, pk=self.kwargs["pk"]) + + def get_invalid_redirect_kwargs(self): + return {"pk": self.club.pk} + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + subscription = getattr(self.club, "subscription", None) + if subscription: + kwargs["instance"] = subscription + return kwargs + + def form_valid(self, form): + club = self.club + existing = getattr(club, "subscription", None) + with suppress_billing_errors(self.request, title="Couldn't change plan"): + if existing: + # Changing tier does not re-bill: the current period keeps the amount it was + # issued at, and the new rate applies from the next one. + was_on_trial = existing.trial_ends_at is not None + subscription = form.save(commit=False) + subscription.club = club + if was_on_trial: + # A manual tier change while on a trial is a deliberate override -- + # left in place, the trial fields would silently swap the tier again + # later, onto a plan the admin didn't just choose. + subscription.trial_ends_at = None + subscription.post_trial_tier = None + subscription.save() + notify(self.request, f"s|Plan changed|{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.") + else: + subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"], auto_renew=form.cleaned_data["auto_renew"]) + notify(self.request, f"s|Billing started|{club} is on {form.cleaned_data['tier']}. Its first period is open.") + + return redirect("controlpanel:club_detail", pk=club.pk) + + +class ClubStartTrialView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView): + """Put a club with no subscription yet on a short trial -- reachable only via the + "Start trial" modal on the club detail page, shown alongside "Start billing" only + while the club has no subscription. POST-only, no standalone template.""" + + form_class = TrialForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:club_detail" + + @property + def club(self): + return get_object_or_404(Club, pk=self.kwargs["pk"]) + + def get_invalid_redirect_kwargs(self): + return {"pk": self.kwargs["pk"]} + + def form_valid(self, form): + club = self.club + with suppress_billing_errors(self.request, title="Couldn't start trial"): + start_trial( + club, + form.cleaned_data["trial_tier"], + post_trial_tier=form.cleaned_data["post_trial_tier"], + trial_months=form.cleaned_data["trial_months"], + start=form.cleaned_data.get("start"), + ) + notify(self.request, f"s|Trial started|{club} is on a {form.cleaned_data['trial_months']}-month trial of {form.cleaned_data['trial_tier']}, then switches to {form.cleaned_data['post_trial_tier']}.") + + return redirect("controlpanel:club_detail", pk=club.pk) + + +class RecordPaymentView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView): + """Reachable only via a due's "Record payment" modal on the club or billing page — + POST-only, and there is no standalone template to render on GET or on a rejected + submission.""" + + form_class = DuePaymentForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:club_detail" + + @property + def due(self): + return get_object_or_404(Due.objects.select_related("club", "tier"), pk=self.kwargs["pk"]) + + def get_invalid_redirect_kwargs(self): + return {"pk": self.due.club_id} + + def form_valid(self, form): + due = self.due + with suppress_billing_errors(self.request, title="Couldn't record payment"): + record_payment( + due, + form.cleaned_data["amount"], + method=form.cleaned_data["method"], + reference=form.cleaned_data["reference"], + paid_at=form.cleaned_data["paid_at"], + note=form.cleaned_data["note"], + user=self.request.user, + ) + due.refresh_from_db() + notify(self.request, f"s|Payment recorded|€{form.cleaned_data['amount']} recorded. {due.get_status_display().capitalize()} — €{due.balance} outstanding.") + + return redirect("controlpanel:club_detail", pk=due.club_id) + + +class WaiveDueView(PlatformStaffRequiredMixin, View): + def post(self, request, pk): + due = get_object_or_404(Due, pk=pk) + with suppress_billing_errors(request, title="Couldn't waive period"): + waive(due) + notify(request, f"w|Period waived|Period {due.period_start} to {due.period_end} waived. Nothing is owed and the club will not be archived for it.") + + return redirect("controlpanel:club_detail", pk=due.club_id) + + +class OpenPeriodView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView): + """Renew a club, or reactivate an archived one. + + Reachable only via the "Open period" modal on the club detail page — POST-only, and + there is no standalone template to render on GET or on a rejected submission. + """ + + form_class = OpenPeriodForm + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:club_detail" + + @property + def club(self): + return get_object_or_404(Club, pk=self.kwargs["pk"]) + + def get_invalid_redirect_kwargs(self): + return {"pk": self.club.pk} + + def form_valid(self, form): + club = self.club + start = form.cleaned_data.get("start") + with suppress_billing_errors(self.request, title="Couldn't open period"): + due = reactivate(club, start=start) if club.is_archived else open_period(club, start=start) + notify(self.request, f"s|Period opened|Period {due.period_start} to {due.period_end} opened for €{due.amount}. Invoice {due.invoice.number}.") + + return redirect("controlpanel:club_detail", pk=club.pk) + + +class InvoicePdfView(PlatformStaffRequiredMixin, View): + def get(self, request, pk): + due = get_object_or_404(Due.objects.select_related("club", "tier", "invoice"), pk=pk) + invoice = issue_invoice(due) + try: + pdf = invoice_pdf(invoice) + except BillingError as error: + # The native PDF libraries are missing: say so rather than 500. + notify(request, f"e|PDF unavailable|{error}") + return redirect("controlpanel:club_detail", pk=due.club_id) + + response = HttpResponse(pdf, content_type="application/pdf") + response["Content-Disposition"] = f'attachment; filename="{invoice.number}.pdf"' + return response diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 0000000..410973e --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Back up what carries state: the database, and the uploads if they are still on local disk. +# +# deploy/backup.sh /var/backups/rosterchief +# +# Runs from cron (see DEPLOYMENT.md). Exits non-zero on any failure, so cron mails you — +# a backup script that fails quietly is worse than no backup script, because you will +# believe you have backups. +set -Eeuo pipefail + +DEST="${1:-/var/backups/rosterchief}" +COMPOSE="${COMPOSE:-docker compose}" +KEEP_DAYS="${KEEP_DAYS:-14}" +STAMP="$(date +%F-%H%M)" + +mkdir -p "$DEST" + +# --- database --------------------------------------------------------------- +# Written to a temporary name and moved into place only on success: a truncated dump that +# looks like a backup is the trap this avoids. +DB_TMP="$DEST/.db-$STAMP.sql.gz.part" +DB_OUT="$DEST/db-$STAMP.sql.gz" + +$COMPOSE exec -T db pg_dump --clean --if-exists -U "${POSTGRES_USER:-rosterchief}" "${POSTGRES_DB:-rosterchief}" | gzip > "$DB_TMP" +gzip -t "$DB_TMP" # the archive is readable +[ -s "$DB_TMP" ] # ...and not empty +mv "$DB_TMP" "$DB_OUT" + +# --- uploads ---------------------------------------------------------------- +# Only while media is local. Once AWS_STORAGE_BUCKET_NAME is set the bucket's own versioning +# is the backup, and this step is skipped. +if [ -z "${AWS_STORAGE_BUCKET_NAME:-}" ]; then + MEDIA_OUT="$DEST/media-$STAMP.tar.gz" + $COMPOSE exec -T web tar -cz -C /app media | cat > "$MEDIA_OUT.part" + mv "$MEDIA_OUT.part" "$MEDIA_OUT" +fi + +# --- retention -------------------------------------------------------------- +find "$DEST" -name 'db-*.sql.gz' -mtime "+$KEEP_DAYS" -delete +find "$DEST" -name 'media-*.tar.gz' -mtime "+$KEEP_DAYS" -delete +find "$DEST" -name '*.part' -mtime +1 -delete + +echo "$(date -Iseconds) backup ok: $(basename "$DB_OUT") ($(du -h "$DB_OUT" | cut -f1))" + +# --- offsite ---------------------------------------------------------------- +# A backup on the same disk as the database is not a backup: it survives a bad migration, but +# not the server. Set BACKUP_REMOTE to an rclone remote to copy it off the box. +if [ -n "${BACKUP_REMOTE:-}" ]; then + rclone copy "$DEST" "$BACKUP_REMOTE" --max-age "${KEEP_DAYS}d" + echo "$(date -Iseconds) copied to $BACKUP_REMOTE" +fi diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..dd1c219 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,24 @@ +{ + email {$ACME_EMAIL} +} + +# The bare domain (control panel, admin, auth) and every club subdomain, on one certificate. +{$ROSTERCHIEF_BASE_DOMAIN}, *.{$ROSTERCHIEF_BASE_DOMAIN} { + tls { + dns cloudflare {$CLOUDFLARE_API_TOKEN} + } + + encode zstd gzip + + # X-Forwarded-Proto is what SECURE_PROXY_SSL_HEADER reads. Without it Django believes every + # request is plain HTTP: request.is_secure() goes false, WebAuthn disagrees with the browser + # about the origin, and the SSL redirect becomes a loop. + reverse_proxy web:8000 { + header_up X-Forwarded-Proto {scheme} + header_up X-Real-IP {remote_host} + } + + log { + output stdout + } +} diff --git a/deploy/caddy/Dockerfile b/deploy/caddy/Dockerfile new file mode 100644 index 0000000..5d1b48d --- /dev/null +++ b/deploy/caddy/Dockerfile @@ -0,0 +1,9 @@ +# Caddy with a DNS plugin. The stock image cannot solve a DNS-01 challenge, and DNS-01 is the +# only way Let's Encrypt issues the *.rosterchief.app wildcard that subdomain tenancy needs. +# +# Swap the module if your DNS lives elsewhere: caddy-dns/route53, caddy-dns/digitalocean, ... +FROM caddy:2-builder AS builder +RUN xcaddy build --with github.com/caddy-dns/cloudflare + +FROM caddy:2 +COPY --from=builder /usr/bin/caddy /usr/bin/caddy diff --git a/deploy/deploy-dev.sh b/deploy/deploy-dev.sh new file mode 100755 index 0000000..59a570c --- /dev/null +++ b/deploy/deploy-dev.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Deploy the test instance to the dev server, behind its existing Caddy. +# +# 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 +# +# Runs FROM your machine, works ON the server over one SSH session: it fetches the pushed +# branch, builds the image, runs migrations explicitly (never from the entrypoint — a +# starting gunicorn worker is a bad place to discover a failed migration), restarts web, and +# waits for /healthz. Any step failing aborts the whole thing with a non-zero exit. +set -Eeuo pipefail + +# --- config (override via env) ---------------------------------------------- +SSH_HOST="${SSH_HOST:-home.siebens.org}" +SSH_USER="${SSH_USER:-bernard}" +REMOTE_DIR="${REMOTE_DIR:-/home/bernard/RosterChief}" +BRANCH="${BRANCH:-$(git rev-parse --abbrev-ref HEAD)}" +COMPOSE_FILE="${COMPOSE_FILE:-compose.behind-proxy.yaml}" +# The published port lives in the server's .env (WEB_PORT), so it is read there, not here — +# see the remote block. This default only applies if that file omits it, matching compose's +# own `${WEB_PORT:-8001}`. +DEFAULT_WEB_PORT="${DEFAULT_WEB_PORT:-8001}" + +SSH_TARGET="${SSH_USER}@${SSH_HOST}" + +say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } +die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +# --- preflight, locally ----------------------------------------------------- +# The server deploys what is on the git remote, so unpushed commits would silently ship stale +# code. Catch that here rather than after a confusing "why isn't my change live" round trip. +git rev-parse --verify --quiet "origin/${BRANCH}" >/dev/null \ + || die "origin/${BRANCH} does not exist. Push the branch first, or pass --push." + +if [ "${1:-}" = "--push" ]; then + say "Pushing ${BRANCH} to origin" + git push origin "${BRANCH}" +elif [ -n "$(git rev-list "origin/${BRANCH}..HEAD" 2>/dev/null)" ]; then + die "Local ${BRANCH} is ahead of origin — the server would deploy stale code. Push first, or run with --push." +fi + +say "Deploying ${BRANCH} to ${SSH_TARGET}:${REMOTE_DIR}" + +# --- the work, on the server ------------------------------------------------ +# One SSH session runs the whole remote script; args are passed positionally so nothing has to +# be re-quoted inside the heredoc. +ssh -o ConnectTimeout=10 "${SSH_TARGET}" bash -s -- "${REMOTE_DIR}" "${BRANCH}" "${COMPOSE_FILE}" "${DEFAULT_WEB_PORT}" <<'REMOTE' +set -Eeuo pipefail +REMOTE_DIR="$1"; BRANCH="$2"; COMPOSE_FILE="$3"; DEFAULT_WEB_PORT="$4" + +step() { printf '\033[1;34m ->\033[0m %s\n' "$*"; } + +cd "$REMOTE_DIR" 2>/dev/null || { echo "ERROR: $REMOTE_DIR not found. Clone the repo there first."; exit 1; } +[ -d .git ] || { echo "ERROR: $REMOTE_DIR is not a git checkout."; exit 1; } + +# The env files carry secrets and are never committed, so they must already be on the server. +# Fail loudly rather than boot a half-configured stack. +[ -f .env.production ] || { echo "ERROR: .env.production missing (Django config). Copy from .env.production.example."; exit 1; } +[ -f .env ] || { echo "ERROR: .env missing (compose vars: POSTGRES_PASSWORD, ...). Copy from .env.compose.example."; exit 1; } + +dc() { docker compose -f "$COMPOSE_FILE" "$@"; } + +# The health probe must hit the port the container actually publishes, which is WEB_PORT in +# the same .env compose reads. Parse it the way compose does — last assignment wins, quotes +# and inline whitespace stripped — and fall back to the compose default when it is unset. +WEB_PORT="$(sed -n 's/^[[:space:]]*WEB_PORT[[:space:]]*=[[:space:]]*//p' .env | tail -1 | tr -d '"'"'"' \r')" +WEB_PORT="${WEB_PORT:-$DEFAULT_WEB_PORT}" +HEALTH_URL="http://127.0.0.1:${WEB_PORT}/healthz" + +# reset --hard, not pull: a deploy target only receives deploys, so make it exactly match the +# remote branch rather than risk a merge conflict from drift no one meant to leave there. +step "Fetching ${BRANCH}" +git fetch --quiet origin +git checkout --quiet "$BRANCH" +git reset --hard --quiet "origin/${BRANCH}" +echo " at $(git rev-parse --short HEAD) — $(git log -1 --pretty=%s)" + +step "Building image" +dc build + +# Bring the data services up first and wait for Postgres, so the migration below has something +# to connect to on a cold start. +step "Starting db + redis" +dc up -d db redis + +step "Running migrations" +# -T and </dev/null are load-bearing: this whole script IS ssh's stdin (a heredoc), and +# `compose run` without them attaches that stdin to the container — swallowing every command +# below it, so web never restarts and the script exits 0 having done half the job. +dc run --rm -T web python manage.py migrate --noinput </dev/null + +# Recreate only web, with the freshly built image. db and redis keep running untouched. +step "Restarting web" +dc up -d --no-deps web + +step "Waiting for /healthz on :${WEB_PORT}" +for attempt in $(seq 1 20); do + if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then + echo " healthy after ${attempt} check(s)" + exit 0 + fi + sleep 3 +done + +echo "ERROR: health check never passed. Recent web logs:" +dc logs --tail 40 web +exit 1 +REMOTE + +say "Done. https://<your-test-domain>/healthz should return ok." diff --git a/deploy/restore-check.sh b/deploy/restore-check.sh new file mode 100755 index 0000000..dbe73cb --- /dev/null +++ b/deploy/restore-check.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Restore the latest dump into a throwaway database and count the rows. +# +# deploy/restore-check.sh [/var/backups/rosterchief] +# +# The only line in the backup cron that proves the others work. A dump you have never +# restored is a hypothesis, not a backup. +set -Eeuo pipefail + +DEST="${1:-/var/backups/rosterchief}" +COMPOSE="${COMPOSE:-docker compose}" +USER_NAME="${POSTGRES_USER:-rosterchief}" +SCRATCH="restore_check_$(date +%s)" + +LATEST="$(ls -1t "$DEST"/db-*.sql.gz 2>/dev/null | head -1)" +[ -n "$LATEST" ] || { echo "no dump found in $DEST"; exit 1; } + +cleanup() { $COMPOSE exec -T db dropdb -U "$USER_NAME" --if-exists "$SCRATCH" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +$COMPOSE exec -T db createdb -U "$USER_NAME" "$SCRATCH" +gunzip -c "$LATEST" | $COMPOSE exec -T db psql -q -U "$USER_NAME" "$SCRATCH" >/dev/null + +# A restore that produces an empty schema exits 0 and tells you nothing. Ask it something. +CLUBS="$($COMPOSE exec -T db psql -tAq -U "$USER_NAME" "$SCRATCH" -c 'SELECT count(*) FROM club_club')" +USERS="$($COMPOSE exec -T db psql -tAq -U "$USER_NAME" "$SCRATCH" -c 'SELECT count(*) FROM authentication_user')" + +[ "$USERS" -gt 0 ] || { echo "restore check FAILED: $(basename "$LATEST") restored no users"; exit 1; } + +echo "$(date -Iseconds) restore ok: $(basename "$LATEST") -> $CLUBS clubs, $USERS users" diff --git a/events/__init__.py b/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/admin.py b/events/admin.py new file mode 100644 index 0000000..048db51 --- /dev/null +++ b/events/admin.py @@ -0,0 +1,104 @@ +from django import forms +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import Attendance, Competition, Event, EventSeries, Location, Opponent + + +@admin.register(Opponent) +class OpponentAdmin(admin.ModelAdmin): + list_display = ["name", "club"] + list_filter = ["club"] + search_fields = ["name"] + + +@admin.register(Location) +class LocationAdmin(admin.ModelAdmin): + list_display = ["name", "city", "club"] + list_filter = ["club"] + search_fields = ["name", "city"] + + +class AttendanceInline(admin.TabularInline): + model = Attendance + extra = 0 + raw_id_fields = ["member"] + + +@admin.register(EventSeries) +class EventSeriesAdmin(admin.ModelAdmin): + list_display = ["title", "kind", "rrule", "dtstart", "until", "generated_until", "club"] + list_filter = ["kind", "club"] + search_fields = ["title"] + autocomplete_fields = ["location", "opponent", "teams", "invited_members", "excluded_members"] + fieldsets = [ + [None, {"fields": ["title", "kind"]}], + [_("Recurrence"), {"fields": ["rrule", "dtstart", "until", "excluded_dates", "generated_until"]}], + [_("Timing"), {"fields": ["duration", "gathering_offset", "deadline_offset"]}], + [_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}], + [_("Where"), {"fields": ["location", "opponent"]}], + ] + + +class EventAdminForm(forms.ModelForm): + """The `competition` field is a plain CharField on Event (it just stores a + name), but the admin should only ever offer a competition this club is + actually allowed to use -- one whose feature flag is active for it, set + from the control panel's Features page. A competition with no flag never + appears at all.""" + + class Meta: + model = Event + fields = [ + "title", "kind", "season", "series", "detached", "cancelled", "teams", "invited_members", "excluded_members", + "start", "end", "gathering", "deadline", "location", "opponent", + "competition", "external_game_id", "score_for", "score_against", "is_live", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + club = self.instance.club if self.instance.club_id else None + competitions = Competition.objects.filter(flag__isnull=False).select_related("flag") + if club is not None: + competitions = [competition for competition in competitions if competition.flag.is_active_for_club(club)] + self.fields["competition"] = forms.ChoiceField( + choices=[("", "---------"), *[(competition.name, competition.name) for competition in competitions]], + required=False, + label=self.fields["competition"].label, + help_text=_("Only competitions whose feature flag is active for this club are offered here."), + ) + + +@admin.register(Event) +class EventAdmin(admin.ModelAdmin): + form = EventAdminForm + list_display = ["title", "kind", "start", "season", "series", "detached", "club"] + list_filter = ["kind", "club", "teams", "detached", "cancelled"] + search_fields = ["title"] + date_hierarchy = "start" + autocomplete_fields = ["season", "series", "location", "opponent", "teams", "invited_members", "excluded_members"] + inlines = [AttendanceInline] + fieldsets = [ + [None, {"fields": ["title", "kind", "season"]}], + [_("Series"), {"fields": ["series", "detached", "cancelled"]}], + [_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}], + [_("When"), {"fields": ["start", "end", "gathering", "deadline"]}], + [_("Where"), {"fields": ["location", "opponent"]}], + [_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live"]}], + ] + + +@admin.register(Competition) +class CompetitionAdmin(admin.ModelAdmin): + list_display = ["name", "sport_type", "module", "flag"] + list_filter = ["sport_type"] + search_fields = ["name", "module"] + raw_id_fields = ["flag"] + + +@admin.register(Attendance) +class AttendanceAdmin(admin.ModelAdmin): + list_display = ["event", "member", "status", "showed_up"] + list_filter = ["status", "showed_up", "event__kind"] + search_fields = ["event__title", "member__first_name", "member__last_name"] + raw_id_fields = ["event", "member"] diff --git a/events/api.py b/events/api.py new file mode 100644 index 0000000..5df94f9 --- /dev/null +++ b/events/api.py @@ -0,0 +1,139 @@ +"""Public read-only game endpoints -- see api/urls.py for how this is +mounted. Also owns GET /teams/{team_id}/games/: it's an Event query through +and through, so the query logic and GameOut schema live here rather than +being duplicated in teams/api.py. +""" + +import uuid +from datetime import datetime + +from django.db.models import Q +from django.utils import timezone +from ninja import Router, Schema +from ninja.errors import HttpError + +from api.errors import require_club +from club.services.access import current_season +from teams.models import Team + +from .models import Event + +router = Router(tags=["games"]) + +DEFAULT_UPCOMING_COUNT = 10 +MAX_UPCOMING_COUNT = 50 + +#: /games/upcoming/ covers anything worth putting on a public fixture list -- +#: not just Game, but Tournament too. The other game endpoints (live, +#: per-team) stay Game-only: is_live/score_for/score_against are genuinely +#: game-specific (events/models.py), so a tournament wouldn't have anything +#: meaningful to show there anyway. +UPCOMING_KINDS = [Event.EventKind.GAME, Event.EventKind.TOURNAMENT] + + +class LocationOut(Schema): + name: str + address: str + city: str + zip_code: str + country: str + is_home: bool + + +class GameOut(Schema): + id: uuid.UUID + start: datetime + location: LocationOut | None + home_team: str | None + away_team: str | None + competition: str + is_live: bool + status: str # "upcoming" | "live" | "finished" + home_score: int | None + away_score: int | None + + +def _to_game_out(event, team=None) -> GameOut: + if team is None: + # .first() would re-query even with teams prefetched; go through the + # prefetch cache instead. + related_teams = list(event.teams.all()) + team = related_teams[0] if related_teams else None + + team_name = team.name if team is not None else None + opponent_name = event.opponent.name if event.opponent_id else None + + if event.is_home_game: + home_team, away_team = team_name, opponent_name + home_score, away_score = event.score_for, event.score_against + else: + home_team, away_team = opponent_name, team_name + home_score, away_score = event.score_against, event.score_for + + if event.is_live: + status = "live" + elif event.start > timezone.now(): + status = "upcoming" + else: + status = "finished" + + location = None + if event.location_id: + location = LocationOut(name=event.location.name, address=event.location.address, city=event.location.city, zip_code=event.location.zip_code, country=str(event.location.country), is_home=event.location.is_home) + + return GameOut( + id=event.pk, + start=event.start, + location=location, + home_team=home_team, + away_team=away_team, + competition=event.competition, + is_live=event.is_live, + status=status, + home_score=home_score, + away_score=away_score, + ) + + +@router.get("/games/upcoming/", response=list[GameOut], summary="Upcoming games") +def list_upcoming_games(request, count: int = DEFAULT_UPCOMING_COUNT): + """The next `count` non-cancelled upcoming games and tournaments, club-wide.""" + club = require_club(request) + count = max(1, min(count, MAX_UPCOMING_COUNT)) + + events = Event.objects.filter(club=club, kind__in=UPCOMING_KINDS, cancelled=False, start__gte=timezone.now()).select_related("opponent", "location").prefetch_related("teams").order_by("start")[:count] + + return [_to_game_out(event) for event in events] + + +@router.get("/games/live/", response=list[GameOut], summary="Live games") +def list_live_games(request): + club = require_club(request) + + events = Event.objects.filter(club=club, kind=Event.EventKind.GAME, cancelled=False, is_live=True).select_related("opponent", "location").prefetch_related("teams").order_by("start") + + return [_to_game_out(event) for event in events] + + +@router.get("/teams/{team_id}/games/", response=list[GameOut], summary="A team's current-season games") +def list_team_games(request, team_id: uuid.UUID): + """All of this team's current-season games, past and upcoming -- past ones + include both teams' scores. Same "explicit season, else derived from + start date" scoping management.views.EventListView already applies.""" + club = require_club(request) + team = Team.objects.filter(club=club, pk=team_id).first() + if team is None: + raise HttpError(404, "No such team.") + + season = current_season(club) + if season is None: + return [] + + events = ( + Event.objects.filter(club=club, teams=team, kind=Event.EventKind.GAME, cancelled=False) + .filter(Q(season=season) | Q(season__isnull=True, start__date__gte=season.start_date, start__date__lte=season.end_date)) + .select_related("opponent", "location") + .order_by("start") + ) + + return [_to_game_out(event, team=team) for event in events] diff --git a/events/apps.py b/events/apps.py new file mode 100644 index 0000000..01eac98 --- /dev/null +++ b/events/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class EventsConfig(AppConfig): + name = "events" + + def ready(self): + from . import signals # noqa: F401 diff --git a/events/competition/__init__.py b/events/competition/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/competition/base.py b/events/competition/base.py new file mode 100644 index 0000000..c435eed --- /dev/null +++ b/events/competition/base.py @@ -0,0 +1,19 @@ +from typing import TypedDict + +from ..models import Event + + +class GameInformation(TypedDict): + live: bool + scoreA: int + scoreB: int + + +class CompetitionBaseClass: + url: str + + def __init__(self): + self.url = "http://localhost" + + def update_game_information(self, event: Event) -> None: + raise NotImplementedError diff --git a/events/competition/hockey.py b/events/competition/hockey.py new file mode 100644 index 0000000..88d1b9e --- /dev/null +++ b/events/competition/hockey.py @@ -0,0 +1,90 @@ +from urllib.parse import urljoin + +import requests + +from ..models import Event +from .base import CompetitionBaseClass + + +class RBIHF(CompetitionBaseClass): + def __init__(self): + super().__init__() + self.url = "https://rbihf.be/modules/league/ajax/time.php" + + def update_game_information(self, event: Event) -> None: + season = "{start}{end}".format(start=event.season.start_date.strftime("%y"), end=event.season.end_date.strftime("%y")) + + payload = {"gameNr": event.external_game_id, "season": season} + headers = { + "Cookie": "language=en", + "Postman-Token": "rosterchief", + "Host": "www.rbihf.be", + "User-Agent": "PostmanRuntime/7.37.0", + "Accept": "application/json", + "Accept-Encoding": "gzip,deflate,br", + "Connection": "keep-alive", + "Referer": f"https://rbihf.be/game/{event.external_game_id}", + "X-Requested-With": "XMLHttpRequest", + } + + req = requests.get(self.url, params=payload, headers=headers) + + if req.status_code == 200: + game_data = req.json() + + event.is_live = game_data["live"] + + if event.is_home_game: + event.score_for = game_data["scoreA"] + event.score_against = game_data["scoreB"] + else: + event.score_for = game_data["scoreB"] + event.score_against = game_data["scoreA"] + + event.save(update_fields=["is_live", "score_for", "score_against"]) + + +class CEHL(CompetitionBaseClass): + def __init__(self): + super().__init__() + self.url = "https://www.cehl.eu/ajax/" + + def update_game_information(self, event: Event) -> None: + season = "{start}{end}".format(start=event.season.start_date.strftime("%y"), end=event.season.end_date.strftime("%y")) + + referer_url = urljoin("https://www.cehl.eu", f"game/{season}/{event.external_game_id}") + timeline_url = urljoin(self.url, "timeline.php") + score_url = urljoin(self.url, "score.php") + + payload = {"nr": event.external_game_id, "season": season} + headers = { + "Cookie": "language=en", + "Postman-Token": "rosterchief", + "Host": "www.cehl.eu", + "User-Agent": "PostmanRuntime/7.37.0", + "Accept": "*/*", + "Accept-Encoding": "gzip,deflate,br", + "Connection": "close", + "Referer": referer_url, + "X-Requested-With": "XMLHttpRequest", + } + + timeline_req = requests.get(timeline_url, params=payload, headers=headers) + score_req = requests.get(score_url, params=payload, headers=headers) + + if timeline_req.status_code == 200: + event.is_live = timeline_req.json()["live"] == 1 + + if score_req.status_code == 200: + game_data = score_req.json() + + if event.is_home_game: + event.score_for = game_data["scoreA"] + event.score_against = game_data["scoreB"] + + else: + event.score_for = game_data["scoreB"] + event.score_against = game_data["scoreA"] + + if timeline_req.status_code == 200 or score_req.status_code == 200: + event.save(update_fields=["is_live", "score_for", "score_against"]) diff --git a/events/management/__init__.py b/events/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/management/commands/__init__.py b/events/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/management/commands/extend_event_series.py b/events/management/commands/extend_event_series.py new file mode 100644 index 0000000..f7fdeb7 --- /dev/null +++ b/events/management/commands/extend_event_series.py @@ -0,0 +1,19 @@ +from events.models import EventSeries +from events.services import generate_occurrences, horizon +from features.commands import MaintenanceAwareCommand + + +class Command(MaintenanceAwareCommand): + help = "Materialise recurring event occurrences up to the rolling horizon." + + def handle(self, *args, **options): + until = horizon() + total = 0 + + for series in EventSeries.objects.all(): + created = generate_occurrences(series, until) + total += len(created) + if created: + self.stdout.write(f"{series}: generated {len(created)} occurrence(s).") + + self.stdout.write(self.style.SUCCESS(f"Done. Generated {total} occurrence(s) across {EventSeries.objects.count()} series.")) diff --git a/events/migrations/0001_initial.py b/events/migrations/0001_initial.py new file mode 100644 index 0000000..5cf0a2f --- /dev/null +++ b/events/migrations/0001_initial.py @@ -0,0 +1,87 @@ +# Generated by Django 6.0.6 on 2026-07-12 15:22 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('club', '0008_alter_clubmembership_unique_together_and_more'), + ('members', '0002_alter_familymembership_unique_together_and_more'), + ('teams', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Location', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('address', models.CharField(max_length=255, verbose_name='address')), + ('city', models.CharField(max_length=255, verbose_name='city')), + ('zip_code', models.CharField(max_length=255, verbose_name='zip code')), + ('country', models.CharField(max_length=255, verbose_name='country')), + ('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')), + ], + options={ + 'verbose_name': 'location', + 'verbose_name_plural': 'locations', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Opponent', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('logo', models.ImageField(blank=True, upload_to='opponents', verbose_name='logo')), + ('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')), + ], + options={ + 'verbose_name': 'opponent', + 'verbose_name_plural': 'opponents', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('kind', models.CharField(choices=[('training', 'training'), ('match', 'match'), ('tournament', 'tournament'), ('meeting', 'meeting'), ('social', 'social'), ('other', 'other')], default='other', max_length=10, verbose_name='kind')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('start', models.DateTimeField(verbose_name='start')), + ('end', models.DateTimeField(blank=True, null=True, verbose_name='end')), + ('gathering', models.DateTimeField(blank=True, null=True, verbose_name='gathering')), + ('deadline', models.DateTimeField(blank=True, null=True, verbose_name='deadline')), + ('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')), + ('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='teams.team', verbose_name='team')), + ('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='events.location', verbose_name='location')), + ('opponent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='events.opponent', verbose_name='opponent')), + ], + options={ + 'verbose_name': 'event', + 'verbose_name_plural': 'events', + 'ordering': ['-start'], + }, + ), + migrations.CreateModel( + name='Attendance', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('present', 'present'), ('absent', 'absent'), ('excused', 'excused'), ('selected', 'selected'), ('not_selected', 'not selected'), ('maybe', 'maybe'), ('no_response', 'no response')], default='no_response', max_length=20, verbose_name='status')), + ('note', models.TextField(blank=True, verbose_name='note')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendances', to='members.member', verbose_name='member')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendances', to='events.event', verbose_name='event')), + ], + options={ + 'verbose_name': 'attendance', + 'verbose_name_plural': 'attendances', + 'ordering': ['event', 'member__last_name', 'member__first_name'], + 'constraints': [models.UniqueConstraint(fields=('event', 'member'), name='unique_attendance_per_event_per_member')], + }, + ), + ] diff --git a/events/migrations/0002_add_event_audience.py b/events/migrations/0002_add_event_audience.py new file mode 100644 index 0000000..af3f9f4 --- /dev/null +++ b/events/migrations/0002_add_event_audience.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.6 on 2026-07-12 15:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('club', '0008_alter_clubmembership_unique_together_and_more'), + ('events', '0001_initial'), + ('members', '0002_alter_familymembership_unique_together_and_more'), + ('teams', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='excluded_members', + field=models.ManyToManyField(blank=True, related_name='excluded_from_events', to='members.member', verbose_name='excluded members'), + ), + migrations.AddField( + model_name='event', + name='invited_members', + field=models.ManyToManyField(blank=True, related_name='invited_to_events', to='members.member', verbose_name='invited members'), + ), + migrations.AddField( + model_name='event', + name='season', + field=models.ForeignKey(blank=True, help_text='Season whose team rosters define the audience; derived from the start date when left blank.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='club.season', verbose_name='season'), + ), + migrations.AddField( + model_name='event', + name='teams', + field=models.ManyToManyField(blank=True, related_name='scheduled_events', to='teams.team', verbose_name='teams'), + ), + ] diff --git a/events/migrations/0003_copy_team_to_teams.py b/events/migrations/0003_copy_team_to_teams.py new file mode 100644 index 0000000..0513bec --- /dev/null +++ b/events/migrations/0003_copy_team_to_teams.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.6 on 2026-07-12 15:38 + +from django.db import migrations + + +def copy_team_to_teams(apps, schema_editor): + Event = apps.get_model("events", "Event") + for event in Event.objects.exclude(team__isnull=True).iterator(): + event.teams.add(event.team_id) + + +def copy_teams_to_team(apps, schema_editor): + Event = apps.get_model("events", "Event") + for event in Event.objects.iterator(): + first_team = event.teams.first() + if first_team is not None: + event.team = first_team + event.save(update_fields=["team"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0002_add_event_audience'), + ] + + operations = [ + migrations.RunPython(copy_team_to_teams, copy_teams_to_team), + ] diff --git a/events/migrations/0004_remove_event_team.py b/events/migrations/0004_remove_event_team.py new file mode 100644 index 0000000..402fa3a --- /dev/null +++ b/events/migrations/0004_remove_event_team.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.6 on 2026-07-12 15:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0003_copy_team_to_teams'), + ] + + operations = [ + migrations.RemoveField( + model_name='event', + name='team', + ), + ] diff --git a/events/migrations/0005_event_series.py b/events/migrations/0005_event_series.py new file mode 100644 index 0000000..5671fe7 --- /dev/null +++ b/events/migrations/0005_event_series.py @@ -0,0 +1,57 @@ +# Generated by Django 6.0.6 on 2026-07-12 15:47 + +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'), + ('events', '0004_remove_event_team'), + ('members', '0002_alter_familymembership_unique_together_and_more'), + ('teams', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='cancelled', + field=models.BooleanField(default=False, verbose_name='cancelled'), + ), + migrations.AddField( + model_name='event', + name='detached', + field=models.BooleanField(default=False, help_text='Edited independently; excluded from series-wide updates and regeneration.', verbose_name='detached'), + ), + migrations.CreateModel( + name='EventSeries', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('rrule', models.CharField(help_text='RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE.', max_length=255, verbose_name='recurrence rule')), + ('dtstart', models.DateTimeField(verbose_name='first occurrence')), + ('duration', models.DurationField(blank=True, help_text="Length of each occurrence; sets each event's end.", null=True, verbose_name='duration')), + ('excluded_dates', models.JSONField(blank=True, default=list, help_text='ISO start datetimes of occurrences removed from the series (EXDATEs).', verbose_name='excluded dates')), + ('generated_until', models.DateTimeField(blank=True, help_text='Occurrences have been materialised up to this point.', null=True, verbose_name='generated until')), + ('kind', models.CharField(choices=[('training', 'training'), ('match', 'match'), ('tournament', 'tournament'), ('meeting', 'meeting'), ('social', 'social'), ('other', 'other')], default='other', max_length=10, verbose_name='kind')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')), + ('excluded_members', models.ManyToManyField(blank=True, related_name='excluded_from_event_series', to='members.member', verbose_name='excluded members')), + ('invited_members', models.ManyToManyField(blank=True, related_name='invited_to_event_series', to='members.member', verbose_name='invited members')), + ('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_series', to='events.location', verbose_name='location')), + ('opponent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_series', to='events.opponent', verbose_name='opponent')), + ('teams', models.ManyToManyField(blank=True, related_name='event_series', to='teams.team', verbose_name='teams')), + ], + options={ + 'verbose_name': 'event series', + 'verbose_name_plural': 'event series', + 'ordering': ['title'], + }, + ), + migrations.AddField( + model_name='event', + name='series', + field=models.ForeignKey(blank=True, help_text='The recurring series this occurrence belongs to; blank for one-off events.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='occurrences', to='events.eventseries', verbose_name='series'), + ), + ] diff --git a/events/migrations/0006_event_series_offsets.py b/events/migrations/0006_event_series_offsets.py new file mode 100644 index 0000000..3f3c10f --- /dev/null +++ b/events/migrations/0006_event_series_offsets.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-07-12 16:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0005_event_series'), + ] + + operations = [ + migrations.AddField( + model_name='eventseries', + name='deadline_offset', + field=models.DurationField(blank=True, help_text="How long before the start each occurrence's sign-up deadline is.", null=True, verbose_name='deadline offset'), + ), + migrations.AddField( + model_name='eventseries', + name='gathering_offset', + field=models.DurationField(blank=True, help_text="How long before the start each occurrence's gathering time is.", null=True, verbose_name='gathering offset'), + ), + ] diff --git a/events/migrations/0007_event_series_until.py b/events/migrations/0007_event_series_until.py new file mode 100644 index 0000000..299b554 --- /dev/null +++ b/events/migrations/0007_event_series_until.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-07-12 17:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0006_event_series_offsets'), + ] + + operations = [ + migrations.AddField( + model_name='eventseries', + name='until', + field=models.DateTimeField(blank=True, help_text="Series end: no occurrences are generated after this. Leave blank for open-ended (bounded by the rule's own COUNT/UNTIL, if any).", null=True, verbose_name='until'), + ), + ] diff --git a/events/migrations/0008_event_created_by.py b/events/migrations/0008_event_created_by.py new file mode 100644 index 0000000..a71a4d7 --- /dev/null +++ b/events/migrations/0008_event_created_by.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.6 on 2026-07-12 21:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0007_event_series_until'), + ('members', '0002_alter_familymembership_unique_together_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_events', to='members.member', verbose_name='created by'), + ), + ] diff --git a/events/migrations/0009_attendance_created_attendance_modified_event_created_and_more.py b/events/migrations/0009_attendance_created_attendance_modified_event_created_and_more.py new file mode 100644 index 0000000..4a3a4d3 --- /dev/null +++ b/events/migrations/0009_attendance_created_attendance_modified_event_created_and_more.py @@ -0,0 +1,69 @@ +# 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 = [ + ('events', '0008_event_created_by'), + ] + + operations = [ + migrations.AddField( + model_name='attendance', + name='created', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 565980, tzinfo=datetime.timezone.utc), verbose_name='created'), + preserve_default=False, + ), + migrations.AddField( + model_name='attendance', + name='modified', + field=models.DateTimeField(auto_now=True, verbose_name='modified'), + ), + migrations.AddField( + model_name='event', + name='created', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 565998, tzinfo=datetime.timezone.utc), verbose_name='created'), + preserve_default=False, + ), + migrations.AddField( + model_name='event', + name='modified', + field=models.DateTimeField(auto_now=True, verbose_name='modified'), + ), + migrations.AddField( + model_name='eventseries', + name='created', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 566017, tzinfo=datetime.timezone.utc), verbose_name='created'), + preserve_default=False, + ), + migrations.AddField( + model_name='eventseries', + name='modified', + field=models.DateTimeField(auto_now=True, verbose_name='modified'), + ), + migrations.AddField( + model_name='location', + name='created', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 566033, tzinfo=datetime.timezone.utc), verbose_name='created'), + preserve_default=False, + ), + migrations.AddField( + model_name='location', + name='modified', + field=models.DateTimeField(auto_now=True, verbose_name='modified'), + ), + migrations.AddField( + model_name='opponent', + name='created', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 566050, tzinfo=datetime.timezone.utc), verbose_name='created'), + preserve_default=False, + ), + migrations.AddField( + model_name='opponent', + name='modified', + field=models.DateTimeField(auto_now=True, verbose_name='modified'), + ), + ] diff --git a/events/migrations/0010_attendance_showed_up.py b/events/migrations/0010_attendance_showed_up.py new file mode 100644 index 0000000..8ca25f2 --- /dev/null +++ b/events/migrations/0010_attendance_showed_up.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-08-03 20:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0009_attendance_created_attendance_modified_event_created_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='attendance', + name='showed_up', + field=models.BooleanField(blank=True, default=None, help_text='Recorded by a check-in, separate from the RSVP status above. Blank means no check-in has been recorded yet.', null=True, verbose_name='showed up'), + ), + ] diff --git a/events/migrations/0011_location_is_home_and_more.py b/events/migrations/0011_location_is_home_and_more.py new file mode 100644 index 0000000..4bf2339 --- /dev/null +++ b/events/migrations/0011_location_is_home_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-08-04 09:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('club', '0017_club_season_duration_months_club_season_start'), + ('events', '0010_attendance_showed_up'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='is_home', + field=models.BooleanField(default=False, help_text="The club's own ground, set from the control panel -- lets an event's location tell a home game from an away one.", verbose_name='home location'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(condition=models.Q(('is_home', True)), fields=('club',), name='unique_home_location_per_club'), + ), + ] diff --git a/events/migrations/0012_alter_location_country.py b/events/migrations/0012_alter_location_country.py new file mode 100644 index 0000000..30aa30b --- /dev/null +++ b/events/migrations/0012_alter_location_country.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.6 on 2026-08-04 09:45 + +import django_countries.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0011_location_is_home_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='location', + name='country', + field=django_countries.fields.CountryField(max_length=2, verbose_name='country'), + ), + ] diff --git a/events/migrations/0013_alter_event_kind_alter_eventseries_kind.py b/events/migrations/0013_alter_event_kind_alter_eventseries_kind.py new file mode 100644 index 0000000..12744a2 --- /dev/null +++ b/events/migrations/0013_alter_event_kind_alter_eventseries_kind.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-08-04 11:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0012_alter_location_country'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='kind', + field=models.CharField(choices=[('training', 'Training'), ('match', 'Match'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'), + ), + migrations.AlterField( + model_name='eventseries', + name='kind', + field=models.CharField(choices=[('training', 'Training'), ('match', 'Match'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'), + ), + ] diff --git a/events/migrations/0014_event_competition_event_external_game_id_and_more.py b/events/migrations/0014_event_competition_event_external_game_id_and_more.py new file mode 100644 index 0000000..ce2b36d --- /dev/null +++ b/events/migrations/0014_event_competition_event_external_game_id_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 6.0.6 on 2026-08-04 11:28 + +from django.db import migrations, models + + +def rename_match_to_game(apps, schema_editor): + Event = apps.get_model('events', 'Event') + EventSeries = apps.get_model('events', 'EventSeries') + Event.objects.filter(kind='match').update(kind='game') + EventSeries.objects.filter(kind='match').update(kind='game') + + +def rename_game_to_match(apps, schema_editor): + Event = apps.get_model('events', 'Event') + EventSeries = apps.get_model('events', 'EventSeries') + Event.objects.filter(kind='game').update(kind='match') + EventSeries.objects.filter(kind='game').update(kind='match') + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0013_alter_event_kind_alter_eventseries_kind'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='competition', + field=models.CharField(blank=True, help_text='The league, cup or competition this game is part of.', max_length=255, verbose_name='competition'), + ), + migrations.AddField( + model_name='event', + name='external_game_id', + field=models.CharField(blank=True, help_text="This game's id in an external competition data source, for automatic score fetching later.", max_length=255, verbose_name='external game ID'), + ), + migrations.AddField( + model_name='event', + name='is_live', + field=models.BooleanField(default=False, help_text='The game is currently in progress.', verbose_name='live'), + ), + migrations.AddField( + model_name='event', + name='score_against', + field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='score (opponent)'), + ), + migrations.AddField( + model_name='event', + name='score_for', + field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='score (us)'), + ), + migrations.AlterField( + model_name='event', + name='deadline', + field=models.DateTimeField(blank=True, null=True, verbose_name='registration deadline'), + ), + migrations.AlterField( + model_name='event', + name='kind', + field=models.CharField(choices=[('training', 'Training'), ('game', 'Game'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'), + ), + migrations.AlterField( + model_name='eventseries', + name='deadline_offset', + field=models.DurationField(blank=True, help_text="How long before the start each occurrence's registration deadline is.", null=True, verbose_name='registration deadline offset'), + ), + migrations.AlterField( + model_name='eventseries', + name='kind', + field=models.CharField(choices=[('training', 'Training'), ('game', 'Game'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'), + ), + migrations.RunPython(rename_match_to_game, rename_game_to_match), + ] diff --git a/events/migrations/0015_alter_attendance_status.py b/events/migrations/0015_alter_attendance_status.py new file mode 100644 index 0000000..9806fc1 --- /dev/null +++ b/events/migrations/0015_alter_attendance_status.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-08-04 13:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0014_event_competition_event_external_game_id_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='attendance', + name='status', + field=models.CharField(choices=[('present', 'Present'), ('absent', 'Absent'), ('excused', 'Excused'), ('selected', 'Selected'), ('not_selected', 'Not selected'), ('maybe', 'Maybe'), ('no_response', 'No response')], default='no_response', max_length=20, verbose_name='status'), + ), + ] diff --git a/events/migrations/0016_competition.py b/events/migrations/0016_competition.py new file mode 100644 index 0000000..de87485 --- /dev/null +++ b/events/migrations/0016_competition.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.6 on 2026-08-04 21:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0015_alter_attendance_status'), + ] + + operations = [ + migrations.CreateModel( + name='Competition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=250)), + ('module', models.CharField(max_length=250)), + ], + ), + ] diff --git a/events/migrations/0017_auto_20260804_2322.py b/events/migrations/0017_auto_20260804_2322.py new file mode 100644 index 0000000..3a00f3f --- /dev/null +++ b/events/migrations/0017_auto_20260804_2322.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.6 on 2026-08-04 21:22 + +from django.db import migrations + + +def create_competiton_group(apps, schema_editor): + Competition = apps.get_model("events", "Competition") + + try: + Competition.objects.create(name="RBIHF", module="events.competition.hockey") + Competition.objects.create(name="CEHL", module="events.competition.hockey") + + except: + pass + + +def remove_competiton_group(apps, schema_editor): + Competition = apps.get_model("events", "Competition") + + try: + Competition.objects.get(name="RBIHF", module="events.competition.hockey").delete() + Competition.objects.get(name="CEHL", module="events.competition.hockey") + + except: + pass + + +class Migration(migrations.Migration): + dependencies = [ + ('events', '0016_competition'), + ] + + operations = [migrations.RunPython(create_competiton_group, remove_competiton_group)] diff --git a/events/migrations/0018_alter_competition_options_competition_flag.py b/events/migrations/0018_alter_competition_options_competition_flag.py new file mode 100644 index 0000000..77a4c2c --- /dev/null +++ b/events/migrations/0018_alter_competition_options_competition_flag.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0.6 on 2026-08-04 21:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def create_flags_for_seeded_competitions(apps, schema_editor): + """RBIHF/CEHL (0017's data migration) predate the flag field -- give them one + each so they're actually selectable (unflagged competitions never show up + in the Event admin's dropdown) instead of silently invisible.""" + Competition = apps.get_model("events", "Competition") + Flag = apps.get_model(*settings.WAFFLE_FLAG_MODEL.split(".")) + + for competition in Competition.objects.filter(flag__isnull=True): + flag, _created = Flag.objects.get_or_create(name=competition.name) + competition.flag = flag + competition.save(update_fields=["flag"]) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0017_auto_20260804_2322'), + migrations.swappable_dependency(settings.WAFFLE_FLAG_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='competition', + options={'ordering': ['name'], 'verbose_name': 'competition', 'verbose_name_plural': 'competitions'}, + ), + migrations.AddField( + model_name='competition', + name='flag', + field=models.ForeignKey(blank=True, help_text="Which clubs this competition is offered to -- set (or leave blank to hide it everywhere) from the control panel's Features page. A competition with no flag never shows up on the Event admin's competition dropdown.", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='competitions', to=settings.WAFFLE_FLAG_MODEL, verbose_name='feature flag'), + ), + migrations.RunPython(create_flags_for_seeded_competitions, noop), + ] diff --git a/events/migrations/0019_competition_sport_type.py b/events/migrations/0019_competition_sport_type.py new file mode 100644 index 0000000..6b366dc --- /dev/null +++ b/events/migrations/0019_competition_sport_type.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.6 on 2026-08-04 22:09 + +from django.db import migrations, models + + +def mark_seeded_hockey_competitions(apps, schema_editor): + """RBIHF/CEHL (0017's data migration) predate sport_type -- they're both ice + hockey leagues (see events/competition/hockey.py), so backfill them rather + than leaving them at the generic "other" default.""" + Competition = apps.get_model("events", "Competition") + Competition.objects.filter(name__in=["RBIHF", "CEHL"]).update(sport_type="ice_hockey") + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0018_alter_competition_options_competition_flag'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='sport_type', + field=models.CharField(choices=[('ice_hockey', 'Ice hockey'), ('other', 'Other')], default='other', help_text='Which sport this competition is for.', max_length=20, verbose_name='sport'), + ), + migrations.RunPython(mark_seeded_hockey_competitions, noop), + ] diff --git a/events/migrations/__init__.py b/events/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/models.py b/events/models.py new file mode 100644 index 0000000..a8f0204 --- /dev/null +++ b/events/models.py @@ -0,0 +1,203 @@ +from django.conf import settings +from django.db import models +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ +from django_countries.fields import CountryField + +from club.models import Club, Season +from members.models import Member +from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope +from teams.models import Team + + +class Opponent(ClubScopedModel): + name = models.CharField(_("name"), max_length=255) + logo = models.ImageField(_("logo"), upload_to="opponents", blank=True) + + class Meta: + verbose_name = _("opponent") + verbose_name_plural = _("opponents") + ordering = ["name"] + + def __str__(self): + return self.name + + +class Location(ClubScopedModel): + name = models.CharField(_("name"), max_length=255) + address = models.CharField(_("address"), max_length=255) + city = models.CharField(_("city"), max_length=255) + zip_code = models.CharField(_("zip code"), max_length=255) + country = CountryField(_("country")) + is_home = models.BooleanField( + _("home location"), + default=False, + help_text=_("The club's own ground, set from the control panel -- lets an event's location tell a home game from an away one."), + ) + + class Meta: + verbose_name = _("location") + verbose_name_plural = _("locations") + ordering = ["name"] + constraints = [ + models.UniqueConstraint(fields=["club"], condition=Q(is_home=True), name="unique_home_location_per_club"), + ] + + def __str__(self): + return self.name + + +class Event(ClubScopedModel): + class EventKind(models.TextChoices): + TRAINING = "training", _("Training") + GAME = "game", _("Game") + TOURNAMENT = "tournament", _("Tournament") + MEETING = "meeting", _("Meeting") + SOCIAL = "social", _("Social") + OTHER = "other", _("Other") + + series = models.ForeignKey("EventSeries", on_delete=models.CASCADE, related_name="occurrences", null=True, blank=True, verbose_name=_("series"), help_text=_("The recurring series this occurrence belongs to; blank for one-off events.")) + detached = models.BooleanField(_("detached"), default=False, help_text=_("Edited independently; excluded from series-wide updates and regeneration.")) + cancelled = models.BooleanField(_("cancelled"), default=False) + + teams = models.ManyToManyField(Team, related_name="scheduled_events", blank=True, verbose_name=_("teams")) + invited_members = models.ManyToManyField(Member, related_name="invited_to_events", blank=True, verbose_name=_("invited members")) + excluded_members = models.ManyToManyField(Member, related_name="excluded_from_events", blank=True, verbose_name=_("excluded members")) + season = models.ForeignKey(Season, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("season"), help_text=_("Season whose team rosters define the audience; derived from the start date when left blank.")) + + kind = models.CharField(_("kind"), max_length=10, choices=EventKind.choices, default=EventKind.OTHER) + title = models.CharField(_("title"), max_length=255) + + start = models.DateTimeField(_("start")) + end = models.DateTimeField(_("end"), blank=True, null=True) + gathering = models.DateTimeField(_("gathering"), blank=True, null=True) + deadline = models.DateTimeField(_("registration deadline"), blank=True, null=True) + + location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("location")) + opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("opponent")) + created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, related_name="created_events", null=True, blank=True, verbose_name=_("created by")) + + # Game-specific -- meaningless for other kinds, so all optional. external_game_id + # is this game's id in an external competition/fixture data source, for a later + # automatic score-fetcher to key off; nothing populates it yet. + competition = models.CharField(_("competition"), max_length=255, blank=True, help_text=_("The league, cup or competition this game is part of.")) + external_game_id = models.CharField(_("external game ID"), max_length=255, blank=True, help_text=_("This game's id in an external competition data source, for automatic score fetching later.")) + score_for = models.PositiveSmallIntegerField(_("score (us)"), null=True, blank=True) + score_against = models.PositiveSmallIntegerField(_("score (opponent)"), null=True, blank=True) + is_live = models.BooleanField(_("live"), default=False, help_text=_("The game is currently in progress.")) + + class Meta: + verbose_name = _("event") + verbose_name_plural = _("events") + ordering = ["-start"] + + def __str__(self): + return self.title + + def clean(self): + validate_club_scope(self, self.club_id, same_club_fields=("season", "location", "opponent")) + + @property + def is_home_game(self) -> bool: + """Whether this game is being played at the club's own ground + (Location.is_home) -- False for anything that isn't a game, or a game + with no location set, or one at an away/neutral location.""" + return self.kind == self.EventKind.GAME and self.location_id is not None and self.location.is_home + + +class EventSeries(ClubScopedModel): + """A recurring event definition that materialises concrete Event rows.""" + + rrule = models.CharField(_("recurrence rule"), max_length=255, help_text=_("RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE.")) + dtstart = models.DateTimeField(_("first occurrence")) + until = models.DateTimeField(_("until"), null=True, blank=True, help_text=_("Series end: no occurrences are generated after this. Leave blank for open-ended (bounded by the rule's own COUNT/UNTIL, if any).")) + duration = models.DurationField(_("duration"), null=True, blank=True, help_text=_("Length of each occurrence; sets each event's end.")) + gathering_offset = models.DurationField(_("gathering offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's gathering time is.")) + deadline_offset = models.DurationField(_("registration deadline offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's registration deadline is.")) + excluded_dates = models.JSONField(_("excluded dates"), default=list, blank=True, help_text=_("ISO start datetimes of occurrences removed from the series (EXDATEs).")) + generated_until = models.DateTimeField(_("generated until"), null=True, blank=True, help_text=_("Occurrences have been materialised up to this point.")) + + # Template copied onto each generated occurrence. + kind = models.CharField(_("kind"), max_length=10, choices=Event.EventKind.choices, default=Event.EventKind.OTHER) + title = models.CharField(_("title"), max_length=255) + location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("location")) + opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("opponent")) + teams = models.ManyToManyField(Team, related_name="event_series", blank=True, verbose_name=_("teams")) + invited_members = models.ManyToManyField(Member, related_name="invited_to_event_series", blank=True, verbose_name=_("invited members")) + excluded_members = models.ManyToManyField(Member, related_name="excluded_from_event_series", blank=True, verbose_name=_("excluded members")) + + class Meta: + verbose_name = _("event series") + verbose_name_plural = _("event series") + ordering = ["title"] + + def __str__(self): + return self.title + + def clean(self): + validate_club_scope(self, self.club_id, same_club_fields=("location", "opponent")) + + +class Attendance(UUIDModel): + class AttendanceStatus(models.TextChoices): + PRESENT = "present", _("Present") + ABSENT = "absent", _("Absent") + EXCUSED = "excused", _("Excused") + SELECTED = "selected", _("Selected") + NOT_SELECTED = "not_selected", _("Not selected") + MAYBE = "maybe", _("Maybe") + NO_RESPONSE = "no_response", _("No response") + + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("event")) + member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("member")) + status = models.CharField(_("status"), max_length=20, choices=AttendanceStatus.choices, default=AttendanceStatus.NO_RESPONSE) + showed_up = models.BooleanField( + _("showed up"), + null=True, + blank=True, + default=None, + help_text=_("Recorded by a check-in, separate from the RSVP status above. Blank means no check-in has been recorded yet."), + ) + note = models.TextField(_("note"), blank=True) + + class Meta: + verbose_name = _("attendance") + verbose_name_plural = _("attendances") + ordering = ["event", "member__last_name", "member__first_name"] + constraints = [ + models.UniqueConstraint(fields=["event", "member"], name="unique_attendance_per_event_per_member"), + ] + + def __str__(self): + return f"{self.event} - {self.member}" + + +class Competition(models.Model): + """A competition has a name with a specific URL to fetch data from. These are managed centrally.""" + + name = models.CharField(max_length=250) + module = models.CharField(max_length=250) + sport_type = models.CharField( + _("sport"), + max_length=20, + choices=Club.SportType.choices, + default=Club.SportType.OTHER, + help_text=_("Which sport this competition is for."), + ) + flag = models.ForeignKey( + settings.WAFFLE_FLAG_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="competitions", + verbose_name=_("feature flag"), + help_text=_("Which clubs this competition is offered to -- set (or leave blank to hide it everywhere) from the control panel's Features page. A competition with no flag never shows up on the Event admin's competition dropdown."), + ) + + class Meta: + verbose_name = _("competition") + verbose_name_plural = _("competitions") + ordering = ["name"] + + def __str__(self): + return self.name diff --git a/events/services/__init__.py b/events/services/__init__.py new file mode 100644 index 0000000..38c1135 --- /dev/null +++ b/events/services/__init__.py @@ -0,0 +1,35 @@ +from .attendance import ( + effective_members, + player_attendance_rankings, + players_who_missed_recent_practices, + record_check_in, + sync_event_attendances, + team_attendance_rate, + team_no_shows, +) +from .recurrence import ( + apply_template, + cancel_occurrence, + detach_occurrence, + generate_occurrences, + horizon, + occurrence_datetimes, + propagate_series, +) + +__all__ = [ + "apply_template", + "cancel_occurrence", + "detach_occurrence", + "effective_members", + "generate_occurrences", + "horizon", + "occurrence_datetimes", + "player_attendance_rankings", + "players_who_missed_recent_practices", + "propagate_series", + "record_check_in", + "sync_event_attendances", + "team_attendance_rate", + "team_no_shows", +] diff --git a/events/services/attendance.py b/events/services/attendance.py new file mode 100644 index 0000000..03383a8 --- /dev/null +++ b/events/services/attendance.py @@ -0,0 +1,142 @@ +"""Keep an event's attendance rows in sync with its effective audience. + +The audience of an event is the union of the current rosters of its ``teams`` +(for the event's season) plus any individually ``invited_members``, minus any +``excluded_members``. Attendance rows are reconciled against that set, but only +for events that are still in the future — history is never rewritten. +""" + +from django.db.models import Count, Q +from django.utils import timezone + +from club.models import Season +from events.models import Attendance, Event +from members.models import Member +from teams.models import TeamMembership + + +def resolve_season(event): + """The season whose rosters define the event's audience.""" + if event.season_id is not None: + return event.season + return Season.covering(event.club, event.start.date()) + + +def effective_members(event): + """Return the ``Member`` queryset invited to ``event``.""" + member_ids: set = set() + + season = resolve_season(event) + if season is not None: + team_ids = list(event.teams.values_list("id", flat=True)) + if team_ids: + member_ids.update(TeamMembership.objects.filter(team_id__in=team_ids, season=season).values_list("member_id", flat=True)) + + member_ids.update(event.invited_members.values_list("id", flat=True)) + member_ids.difference_update(event.excluded_members.values_list("id", flat=True)) + + return Member.objects.filter(id__in=member_ids) + + +def sync_event_attendances(event): + """Reconcile ``event``'s attendance rows with its effective audience. + + No-op for events that have already started. New members get a + ``NO_RESPONSE`` row; members no longer invited have their row removed + outright (hard reconcile). + """ + if event.start < timezone.now(): + return + + desired_ids = set(effective_members(event).values_list("id", flat=True)) + existing_ids = set(event.attendances.values_list("member_id", flat=True)) + + to_add = desired_ids - existing_ids + if to_add: + Attendance.objects.bulk_create([Attendance(event=event, member_id=member_id) for member_id in to_add]) + + to_remove = existing_ids - desired_ids + if to_remove: + event.attendances.filter(member_id__in=to_remove).delete() + + +def record_check_in(attendance, *, showed_up): + """Record whether ``attendance``'s member actually showed up, separate from + their RSVP status -- the hook a future check-in UI (the coaches app) writes + through. Nothing in this codebase calls this yet; it exists so a no-show can + be recorded the moment something does.""" + attendance.showed_up = showed_up + attendance.save(update_fields=["showed_up"]) + + +def team_attendance_rate(team, season): + """Turnout for ``team`` in ``season``: present / (present + absent) among + past events -- same definition as + controlpanel.services.statistics.attendance_rates, just scoped to one team + instead of the whole club.""" + counts = Attendance.objects.filter(event__teams=team, event__season=season, event__start__lt=timezone.now()).aggregate( + present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)), + absent=Count("id", filter=Q(status=Attendance.AttendanceStatus.ABSENT)), + ) + answered = counts["present"] + counts["absent"] + return round(100 * counts["present"] / answered) if answered else None + + +def player_attendance_rankings(team, season, *, minimum_responses=3): + """Each player's turnout this season, best first: + ``[{"member": Member, "rate": int, "responses": int}, ...]``. Excludes + anyone with fewer than ``minimum_responses`` present/absent replies -- one + absence out of one invite would otherwise read as "0%, worst on the team." + """ + rows = ( + Attendance.objects.filter( + event__teams=team, + event__season=season, + event__start__lt=timezone.now(), + status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT], + ) + .values("member") + .annotate(present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)), responses=Count("id")) + .filter(responses__gte=minimum_responses) + ) + + members_by_id = Member.objects.in_bulk([row["member"] for row in rows]) + rankings = [{"member": members_by_id[row["member"]], "rate": round(100 * row["present"] / row["responses"]), "responses": row["responses"]} for row in rows] + rankings.sort(key=lambda entry: entry["rate"], reverse=True) + return rankings + + +def players_who_missed_recent_practices(team, season, *, count=2): + """Members absent or silent (not excused) on *every one* of the team's + ``count`` most recent past training-kind events this season. Empty if the + team has fewer than ``count`` past practices logged yet -- not enough + history to call anyone out.""" + practices = list(Event.objects.filter(teams=team, season=season, kind=Event.EventKind.TRAINING, start__lt=timezone.now()).order_by("-start")[:count]) + if len(practices) < count: + return Member.objects.none() + + flagged = (Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.NO_RESPONSE) + missed_by_member = None + for practice in practices: + missed_here = set(Attendance.objects.filter(event=practice, status__in=flagged).values_list("member_id", flat=True)) + missed_by_member = missed_here if missed_by_member is None else missed_by_member & missed_here + + return Member.objects.filter(pk__in=missed_by_member).order_by("last_name", "first_name") + + +def team_no_shows(team, season): + """Attendance rows where the member RSVPed present/selected but was + checked in as showed_up=False -- most recent first. Each entry carries + both the member and the event: a no-show is about a specific missed + occasion, not a season-long rate. Never inferred from a missing check-in -- + with nothing checking anyone in yet, that would flag every "present" RSVP.""" + return ( + Attendance.objects.filter( + event__teams=team, + event__season=season, + status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.SELECTED], + showed_up=False, + ) + .select_related("member", "event") + .order_by("-event__start") + ) diff --git a/events/services/competitions.py b/events/services/competitions.py new file mode 100644 index 0000000..0efc982 --- /dev/null +++ b/events/services/competitions.py @@ -0,0 +1,46 @@ +"""Refreshing a game's score/status from its competition's own data source. + +Event.competition (free text) and Event.external_game_id (that source's id for this +fixture) exist so a real integration has somewhere to key off later -- nothing +fetches anything yet. This is that integration's landing spot: the button and view +that call it already exist (management/views.py::EventFetchGameInfoView), so wiring +up a real data source later is a matter of replacing fetch_game_info's body, not +building the UI around it. +""" + +import importlib + +from events.models import Competition + + +class CompetitionFetchError(Exception): + """A game's info could not be fetched from its competition's data source.""" + + +def fetch_game_info(event) -> bool: + """Refresh `event`'s score/live status from its competition's data source. + + Gated on that competition's own feature flag being active for the event's + club: the management form's competition dropdown deliberately shows every + competition regardless of flag (see management.forms.EventForm), so this is + where per-club access actually gets enforced. No flag, an inactive one, or + a competition name that matches nothing -- there's no data source this club + is allowed to use, so this quietly does nothing and returns False rather + than erroring: missing access isn't a failure to report. + + Returns whether a fetch was actually attempted. + """ + competition = Competition.objects.filter(name=event.competition).select_related("flag").first() + if competition is None or competition.flag is None or not competition.flag.is_active_for_club(event.club): + return False + + try: + module = importlib.import_module(competition.module) + competition = getattr(module, competition.name) + + competition().update_game_information(event=event) + + return True + + except Exception as error: + raise CompetitionFetchError("No competition data source is configured yet -- there's nothing to fetch from.") from error diff --git a/events/services/rbihf_import.py b/events/services/rbihf_import.py new file mode 100644 index 0000000..a043d24 --- /dev/null +++ b/events/services/rbihf_import.py @@ -0,0 +1,297 @@ +"""Importing a team's fixture list from RBIHF's own website. + +RBIHF publishes a team's schedule at ``https://www.rbihf.be/league/team/<id>`` -- +an HTML page, not an API, scraped with BeautifulSoup. This is the create/update/ +delete counterpart to ``events.services.competitions.fetch_game_info``, which +only ever refreshes a single *existing* game's score: this is how those `Event` +rows get created in the first place. + +Two-step flow, mirroring ``management.bulk_import`` (the member Excel import): +``build_plan`` is pure with respect to the outside world once it has the raw +HTML (no further network calls), so a view can render a preview from it and +then re-run it unchanged at confirm time -- see management/views.py. +""" + +import re +from dataclasses import dataclass, field +from datetime import datetime + +import requests +from bs4 import BeautifulSoup +from django.db import transaction +from django.db.models import Q +from django.utils import timezone + +from events.models import Event, Location, Opponent + +TEAM_URL_RE = re.compile(r"^https://(?:www\.)?rbihf\.be/league/team/(?P<team_id>\d+)/?$") + +REQUEST_HEADERS = { + "Cookie": "language=en", + "User-Agent": "Mozilla/5.0 (compatible; RosterChief/1.0)", + "Accept": "text/html", +} +REQUEST_TIMEOUT_SECONDS = 15 + + +class RBIHFImportError(Exception): + """The page couldn't be fetched, or didn't look like an RBIHF team schedule.""" + + +def extract_team_id(url: str) -> str: + match = TEAM_URL_RE.match(url.strip()) + if not match: + raise RBIHFImportError("That doesn't look like an RBIHF team page -- expected something like https://www.rbihf.be/league/team/4460.") + return match.group("team_id") + + +def fetch_html(url: str) -> str: + try: + response = requests.get(url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT_SECONDS) + except requests.RequestException as error: + raise RBIHFImportError(f"Could not reach {url}: {error}") from error + + if response.status_code != 200: + raise RBIHFImportError(f"{url} returned HTTP {response.status_code}.") + + return response.text + + +@dataclass +class ScrapedFixture: + external_game_id: str + start: datetime + is_home: bool + opponent_name: str + venue_text: str + + +def _team_id_from_href(href: str) -> str | None: + match = re.search(r"/league/team/(\d+)", href or "") + return match.group(1) if match else None + + +def parse_fixtures(html: str, team_id: str) -> tuple[str, list[ScrapedFixture]]: + """Returns (the scraped team's own display name, its upcoming fixtures).""" + soup = BeautifulSoup(html, "html.parser") + + team_heading = soup.find("h2") + if team_heading is None: + raise RBIHFImportError("Could not find a team name on this page -- is this really an RBIHF team page?") + team_name = team_heading.get_text(strip=True) + + games_heading = soup.find(id="games-upcoming") + if games_heading is None: + raise RBIHFImportError("Could not find an “Upcoming games” table on this page.") + table = games_heading.find_next("table") + if table is None: + raise RBIHFImportError("Could not find an “Upcoming games” table on this page.") + + fixtures = [] + for row in table.find_all("tr"): + cells = row.find_all("td") + if len(cells) < 6: + continue # the header row (<th>s) and any stray rows + + game_link = cells[0].find("a") + game_id = game_link.get_text(strip=True) if game_link else cells[0].get_text(strip=True) + if not game_id: + continue + + date_text = cells[1].get_text(strip=True) + hour_text = cells[2].get_text(strip=True) + try: + naive_start = datetime.strptime(f"{date_text} {hour_text}", "%Y-%m-%d %H:%M") + except ValueError: + continue # a row that doesn't match the expected shape -- skip rather than blow up the whole import + start = timezone.make_aware(naive_start) + + venue_text = cells[3].get_text(strip=True) + + home_link, visit_link = cells[4].find("a"), cells[5].find("a") + if home_link is None or visit_link is None: + continue + + home_id = _team_id_from_href(home_link.get("href", "")) + visit_id = _team_id_from_href(visit_link.get("href", "")) + + if home_id == team_id: + is_home, opponent_name = True, visit_link.get("title") or visit_link.get_text(strip=True) + elif visit_id == team_id: + is_home, opponent_name = False, home_link.get("title") or home_link.get_text(strip=True) + else: + continue # neither side is the team we asked about -- inconsistent row, skip it + + fixtures.append(ScrapedFixture(external_game_id=game_id, start=start, is_home=is_home, opponent_name=opponent_name, venue_text=venue_text)) + + return team_name, fixtures + + +def suggested_location(club, fixture: ScrapedFixture): + if fixture.is_home: + return Location.objects.filter(club=club, is_home=True).first() + + venue = fixture.venue_text.strip() + if not venue: + return None + return Location.objects.filter(club=club).filter(Q(city__iexact=venue) | Q(name__iexact=venue)).first() + + +def suggested_opponent(club, fixture: ScrapedFixture): + """A case-insensitive name match among the club's existing Opponents, if + any -- RBIHF's own spelling doesn't always match punctuation/casing + already on file (e.g. "Rivals FC" vs "Rivals F.C."), and get_or_create's + exact-string match alone would just create a near-duplicate instead of + reusing it.""" + name = fixture.opponent_name.strip() + if not name: + return None + return Opponent.objects.filter(club=club, name__iexact=name).first() + + +@dataclass +class PlannedCreate: + fixture: ScrapedFixture + suggested_location: Location | None + suggested_opponent: Opponent | None = None + + +@dataclass +class PlannedUpdate: + fixture: ScrapedFixture + event: Event + changes: dict = field(default_factory=dict) # field name -> (old, new), display-ready strings + suggested_location: Location | None = None + suggested_opponent: Opponent | None = None + + +@dataclass +class ImportPlan: + club: object + team: object + scraped_team_name: str + location_choices: object # QuerySet[Location], evaluated once for the template + opponent_choices: object = None # QuerySet[Opponent], evaluated once for the template + to_create: list[PlannedCreate] = field(default_factory=list) + to_update: list[PlannedUpdate] = field(default_factory=list) + to_delete: list[Event] = field(default_factory=list) + unchanged_count: int = 0 + + +def _describe_changes(event, fixture: ScrapedFixture) -> dict: + changes = {} + if event.start != fixture.start: + changes["start"] = (event.start, fixture.start) + existing_opponent = event.opponent.name if event.opponent_id else "" + if existing_opponent != fixture.opponent_name: + changes["opponent"] = (existing_opponent, fixture.opponent_name) + return changes + + +def build_plan(club, team, rbihf_team_id: str, html: str) -> ImportPlan: + """Parses ``html`` (already fetched -- see ``fetch_html``) and diffs it + against this club/team's existing RBIHF events. Pure with respect to the + network: safe to call twice against the same stashed HTML (preview, then + confirm) and get an identical result modulo whatever changed in the DB + between the two calls.""" + scraped_team_name, fixtures = parse_fixtures(html, rbihf_team_id) + location_choices = list(Location.objects.filter(club=club).order_by("name")) + opponent_choices = list(Opponent.objects.filter(club=club).order_by("name")) + + existing_by_game_id = {event.external_game_id: event for event in Event.objects.filter(club=club, teams=team, competition="RBIHF").exclude(external_game_id="").select_related("opponent", "location")} + + plan = ImportPlan(club=club, team=team, scraped_team_name=scraped_team_name, location_choices=location_choices, opponent_choices=opponent_choices) + + seen_game_ids = set() + for fixture in fixtures: + seen_game_ids.add(fixture.external_game_id) + existing = existing_by_game_id.get(fixture.external_game_id) + + if existing is None: + plan.to_create.append(PlannedCreate(fixture=fixture, suggested_location=suggested_location(club, fixture), suggested_opponent=suggested_opponent(club, fixture))) + continue + + changes = _describe_changes(existing, fixture) + if changes: + # Prefer whatever location/opponent is already on the event -- a + # previous run's manual pick -- over re-guessing, so a re-import + # doesn't silently discard it. Only fall back to a fresh guess + # when nothing's set yet. + default_location = existing.location if existing.location_id else suggested_location(club, fixture) + default_opponent = existing.opponent if existing.opponent_id else suggested_opponent(club, fixture) + plan.to_update.append(PlannedUpdate(fixture=fixture, event=existing, changes=changes, suggested_location=default_location, suggested_opponent=default_opponent)) + else: + plan.unchanged_count += 1 + + now = timezone.now() + for game_id, event in existing_by_game_id.items(): + if game_id not in seen_game_ids and event.start >= now: + plan.to_delete.append(event) + + return plan + + +def apply_plan(plan: ImportPlan, locations_by_game_id: dict, opponents_by_game_id: dict | None = None) -> dict: + """Applies everything in ``plan``, resolving each create/update row's + location from ``locations_by_game_id`` and opponent from + ``opponents_by_game_id`` (both external_game_id -> pk as a plain string, + or None/""). A pk that doesn't belong to ``plan.club`` -- or isn't a real + pk at all -- is silently ignored rather than trusted: this is user input + straight from the confirm POST, not derived data. Compared as strings + since the input is whatever a <select> submitted, not already a UUID. + + Blank/missing means different things for the two: no location is a valid, + final state (an away game with no venue on file yet), but a game always + needs *an* opponent -- so blank there falls back to the same + find-or-create-by-scraped-name this always did, rather than leaving it + unset.""" + opponents_by_game_id = opponents_by_game_id or {} + locations_by_id = {str(location.pk): location for location in plan.location_choices} + opponents_by_id = {str(opponent.pk): opponent for opponent in plan.opponent_choices} + + def resolve_location(game_id): + location_id = locations_by_game_id.get(game_id) + if not location_id: + return None + return locations_by_id.get(str(location_id)) + + def resolve_opponent(fixture): + opponent_id = opponents_by_game_id.get(fixture.external_game_id) + if opponent_id: + opponent = opponents_by_id.get(str(opponent_id)) + if opponent is not None: + return opponent + opponent, _created = Opponent.objects.get_or_create(club=plan.club, name=fixture.opponent_name) + return opponent + + created = updated = deleted = 0 + + with transaction.atomic(): + for planned in plan.to_create: + opponent = resolve_opponent(planned.fixture) + event = Event.objects.create( + club=plan.club, + title=f"vs {opponent.name}", + kind=Event.EventKind.GAME, + start=planned.fixture.start, + competition="RBIHF", + external_game_id=planned.fixture.external_game_id, + opponent=opponent, + location=resolve_location(planned.fixture.external_game_id), + ) + event.teams.add(plan.team) + created += 1 + + for planned in plan.to_update: + event = planned.event + event.start = planned.fixture.start + event.opponent = resolve_opponent(planned.fixture) + event.location = resolve_location(planned.fixture.external_game_id) + event.save(update_fields=["start", "opponent", "location"]) + updated += 1 + + for event in plan.to_delete: + event.delete() + deleted += 1 + + return {"created": created, "updated": updated, "deleted": deleted} diff --git a/events/services/recurrence.py b/events/services/recurrence.py new file mode 100644 index 0000000..e54973e --- /dev/null +++ b/events/services/recurrence.py @@ -0,0 +1,112 @@ +"""Materialise concrete Event rows from a recurring EventSeries. + +Occurrences are real Event rows (so attendance attaches directly). The series +holds the RRULE, an anchor ``dtstart``, and a set of ``excluded_dates`` +(EXDATEs). Rows are generated up to a rolling horizon; a single occurrence can +be cancelled (adds an EXDATE + removes the row) or detached (edited +independently, so series-wide updates skip it). +""" + +from datetime import timedelta + +from dateutil.rrule import rrulestr +from django.utils import timezone + +from events.models import Event + +HORIZON_DAYS = 90 + + +def horizon(): + return timezone.now() + timedelta(days=HORIZON_DAYS) + + +def occurrence_datetimes(series, until): + """Expand the series' RRULE from its anchor up to ``until``, minus EXDATEs. + + ``until`` is the generation horizon; the series' own ``until`` (its formal + end) further caps it, whichever comes first. + """ + if series.until is not None and series.until < until: + until = series.until + + rule = rrulestr(series.rrule, dtstart=series.dtstart) + excluded = set(series.excluded_dates) + + result = [] + for occurrence in rule: + if occurrence > until: + break + if occurrence.isoformat() in excluded: + continue + result.append(occurrence) + return result + + +def apply_template(series, event): + """Copy the series template (and audience) onto ``event`` and save it. + + Setting the audience M2M fires the sync signal, so attendance follows. + """ + event.kind = series.kind + event.title = series.title + event.location = series.location + event.opponent = series.opponent + event.end = event.start + series.duration if series.duration is not None else None + event.gathering = event.start - series.gathering_offset if series.gathering_offset is not None else None + event.deadline = event.start - series.deadline_offset if series.deadline_offset is not None else None + event.save() + + event.teams.set(series.teams.all()) + event.invited_members.set(series.invited_members.all()) + event.excluded_members.set(series.excluded_members.all()) + + +def generate_occurrences(series, until=None): + """Materialise any missing occurrences up to ``until`` (default: horizon).""" + until = until or horizon() + existing = set(series.occurrences.values_list("start", flat=True)) + + created = [] + for start in occurrence_datetimes(series, until): + if start in existing: + continue + event = Event(club=series.club, series=series, start=start) + apply_template(series, event) + created.append(event) + + series.generated_until = until + series.save(update_fields=["generated_until"]) + return created + + +def cancel_occurrence(event, *, hard_delete=True): + """Drop a single occurrence and record an EXDATE so it isn't regenerated.""" + series = event.series + if series is not None: + iso = event.start.isoformat() + if iso not in series.excluded_dates: + series.excluded_dates = [*series.excluded_dates, iso] + series.save(update_fields=["excluded_dates"]) + + if hard_delete: + event.delete() + else: + event.cancelled = True + event.save(update_fields=["cancelled"]) + + +def detach_occurrence(event): + """Mark an occurrence as edited independently of the series.""" + event.detached = True + event.save(update_fields=["detached"]) + + +def propagate_series(series): + """Re-apply the series template to its non-detached future occurrences.""" + now = timezone.now() + updated = [] + for event in series.occurrences.filter(detached=False, start__gte=now): + apply_template(series, event) + updated.append(event) + return updated diff --git a/events/signals.py b/events/signals.py new file mode 100644 index 0000000..ab3b015 --- /dev/null +++ b/events/signals.py @@ -0,0 +1,60 @@ +"""Signal wiring that keeps attendance in sync with the event audience. + +Registered from ``EventsConfig.ready``. Two triggers: + +* editing an event (its ``start``, or its ``teams`` / ``invited_members`` / + ``excluded_members``) re-syncs that event; +* adding or removing a member from a team roster re-syncs that team's future + events. +""" + +from django.core.exceptions import ValidationError +from django.db.models.signals import m2m_changed, post_delete, post_save +from django.dispatch import receiver +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from events.models import Event, EventSeries +from events.services import sync_event_attendances +from teams.models import Team, TeamMembership + +M2M_SYNC_ACTIONS = {"post_add", "post_remove", "post_clear"} + + +@receiver(m2m_changed, sender=Event.teams.through) +@receiver(m2m_changed, sender=EventSeries.teams.through) +def validate_teams_same_club(sender, instance, action, reverse, pk_set, **kwargs): + # Reject teams from another club before they're attached (forward adds only; + # the reverse direction — team.scheduled_events.add(...) — is not a used path). + if action != "pre_add" or reverse: + return + if Team.objects.filter(pk__in=pk_set).exclude(club_id=instance.club_id).exists(): + raise ValidationError(_("Teams must belong to the same club as the event.")) + + +@receiver(post_save, sender=Event) +def sync_on_event_save(sender, instance, **kwargs): + sync_event_attendances(instance) + + +@receiver(m2m_changed, sender=Event.teams.through) +@receiver(m2m_changed, sender=Event.invited_members.through) +@receiver(m2m_changed, sender=Event.excluded_members.through) +def sync_on_audience_change(sender, instance, action, reverse, model, pk_set, **kwargs): + if action not in M2M_SYNC_ACTIONS: + return + + if isinstance(instance, Event): + sync_event_attendances(instance) + else: + # Reverse relation edited (e.g. member.invited_to_events.add(event)). + for event in Event.objects.filter(pk__in=pk_set or []): + sync_event_attendances(event) + + +@receiver(post_save, sender=TeamMembership) +@receiver(post_delete, sender=TeamMembership) +def sync_on_roster_change(sender, instance, **kwargs): + now = timezone.now() + for event in Event.objects.filter(teams=instance.team_id, start__gte=now).distinct(): + sync_event_attendances(event) diff --git a/events/tests.py b/events/tests.py new file mode 100644 index 0000000..420c088 --- /dev/null +++ b/events/tests.py @@ -0,0 +1,895 @@ +from datetime import timedelta +from io import StringIO + +from django.core.exceptions import ValidationError +from django.core.management import call_command +from django.db import IntegrityError +from django.test import TestCase +from django.utils import timezone +from waffle import get_waffle_flag_model + +from club.models import Club, Season +from members.models import Member +from teams.models import Position, Team, TeamMembership + +from .admin import EventAdminForm +from .models import Attendance, Competition, Event, EventSeries, Location, Opponent +from .services import ( + cancel_occurrence, + detach_occurrence, + effective_members, + generate_occurrences, + occurrence_datetimes, + player_attendance_rankings, + players_who_missed_recent_practices, + propagate_series, + record_check_in, + team_attendance_rate, + team_no_shows, +) +from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent + + +class EventsTestBase(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 - timedelta(days=30), + end_date=today + timedelta(days=300), + ) + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.position = Position.objects.create(club=self.club, name="Forward", short_name="FW") + self.alice = Member.objects.create(first_name="Alice", last_name="Ash") + self.bob = Member.objects.create(first_name="Bob", last_name="Birch") + TeamMembership.objects.create(team=self.team, member=self.alice, season=self.season, position=self.position) + TeamMembership.objects.create(team=self.team, member=self.bob, season=self.season, position=self.position) + self.future = timezone.now() + timedelta(days=7) + + def make_event(self, **kwargs): + kwargs.setdefault("club", self.club) + kwargs.setdefault("title", "Training") + kwargs.setdefault("start", self.future) + if "season" not in kwargs: + kwargs["season"] = self.season + return Event.objects.create(**kwargs) + + def attendee_ids(self, event): + return set(event.attendances.values_list("member_id", flat=True)) + + +class EventModelTests(EventsTestBase): + def test_str_methods(self): + opponent = Opponent.objects.create(club=self.club, name="Rivals FC") + location = Location.objects.create(club=self.club, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE") + event = self.make_event(title="Big Match") + attendance = Attendance.objects.create(event=event, member=self.alice) + + self.assertEqual(str(opponent), "Rivals FC") + self.assertEqual(str(location), "Arena") + self.assertEqual(str(event), "Big Match") + self.assertEqual(str(attendance), "Big Match - Alice Ash") + + def test_attendance_is_unique_per_event_and_member(self): + event = self.make_event() + Attendance.objects.create(event=event, member=self.alice) + + with self.assertRaises(IntegrityError): + Attendance.objects.create(event=event, member=self.alice) + + def test_is_home_game(self): + home_ground = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True) + away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE") + + home_game = self.make_event(kind=Event.EventKind.GAME, location=home_ground) + away_game = self.make_event(kind=Event.EventKind.GAME, location=away_ground) + game_with_no_location = self.make_event(kind=Event.EventKind.GAME) + training_at_home_ground = self.make_event(kind=Event.EventKind.TRAINING, location=home_ground) + + self.assertTrue(home_game.is_home_game) + self.assertFalse(away_game.is_home_game) + self.assertFalse(game_with_no_location.is_home_game) + self.assertFalse(training_at_home_ground.is_home_game) + + +class CompetitionModelTests(EventsTestBase): + def test_sport_type_defaults_to_other(self): + competition = Competition.objects.create(name="Local League", module="events.competition.other") + + self.assertEqual(competition.sport_type, Club.SportType.OTHER) + + def test_the_seeded_hockey_competitions_are_backfilled_as_ice_hockey(self): + # Migration 0019's data migration backfills the two competitions seeded + # by 0017 (both real ice hockey leagues) rather than leaving them at the + # generic "other" default. + for name in ["RBIHF", "CEHL"]: + with self.subTest(name=name): + self.assertEqual(Competition.objects.get(name=name).sport_type, Club.SportType.ICE_HOCKEY) + + +class EventAdminFormCompetitionTests(EventsTestBase): + """`competition` is a plain CharField, but the admin should only ever offer + competitions this club is actually allowed to use -- see events/admin.py.""" + + def setUp(self): + super().setUp() + Flag = get_waffle_flag_model() + self.active_flag = Flag.objects.create(name="active-competition") + self.active_flag.clubs.add(self.club) + self.inactive_flag = Flag.objects.create(name="inactive-competition") + self.active_competition = Competition.objects.create(name="Active Cup", module="events.competition.active", flag=self.active_flag) + self.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=self.inactive_flag) + self.flagless_competition = Competition.objects.create(name="Flagless Cup", module="events.competition.flagless") + + def test_a_flagless_competition_never_appears(self): + form = EventAdminForm(instance=Event()) + choices = dict(form.fields["competition"].choices) + + self.assertNotIn("Flagless Cup", choices) + + def test_a_brand_new_event_offers_every_flagged_competition_unfiltered(self): + # The club is unknown until the event is actually saved (see + # EventClubScopeTests for the same "club_id is None pre-save" timing issue + # elsewhere), so a new event can't be filtered by club yet -- it falls back + # to showing everything with a flag rather than crashing or showing nothing. + form = EventAdminForm(instance=Event()) + choices = dict(form.fields["competition"].choices) + + self.assertIn("Active Cup", choices) + self.assertIn("Inactive Cup", choices) + + def test_an_existing_event_only_offers_competitions_active_for_its_club(self): + event = self.make_event(kind=Event.EventKind.GAME) + form = EventAdminForm(instance=event) + choices = dict(form.fields["competition"].choices) + + self.assertIn("Active Cup", choices) + self.assertNotIn("Inactive Cup", choices) + self.assertNotIn("Flagless Cup", choices) + + +class EffectiveMembersTests(EventsTestBase): + def test_union_of_team_invited_minus_excluded(self): + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + event = self.make_event() + event.teams.set([self.team]) + event.invited_members.set([carol]) + event.excluded_members.set([self.bob]) + + self.assertEqual( + set(effective_members(event).values_list("id", flat=True)), + {self.alice.id, carol.id}, + ) + + def test_season_is_derived_from_start_date(self): + event = self.make_event(season=None) + event.teams.set([self.team]) + + self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id}) + + def test_no_covering_season_yields_no_team_members(self): + event = self.make_event(season=None, start=timezone.now() + timedelta(days=5000)) + event.teams.set([self.team]) + + self.assertEqual(event.attendances.count(), 0) + + +class AttendanceSyncTests(EventsTestBase): + def test_setting_teams_creates_attendance_for_roster(self): + event = self.make_event() + event.teams.set([self.team]) + + self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id}) + + def test_invited_member_gets_attendance(self): + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + event = self.make_event() + event.invited_members.set([carol]) + + self.assertEqual(self.attendee_ids(event), {carol.id}) + + def test_reverse_invited_relation_syncs(self): + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + event = self.make_event() + carol.invited_to_events.add(event) + + self.assertIn(carol.id, self.attendee_ids(event)) + + def test_excluding_member_removes_attendance(self): + event = self.make_event() + event.teams.set([self.team]) + event.excluded_members.set([self.alice]) + + self.assertEqual(self.attendee_ids(event), {self.bob.id}) + + def test_hard_reconcile_removes_even_responded_rows(self): + event = self.make_event() + event.teams.set([self.team]) + attendance = event.attendances.get(member=self.alice) + attendance.status = Attendance.AttendanceStatus.PRESENT + attendance.save() + + event.excluded_members.set([self.alice]) + + self.assertFalse(event.attendances.filter(member=self.alice).exists()) + + def test_past_event_is_not_synced(self): + event = self.make_event(start=timezone.now() - timedelta(days=1)) + event.teams.set([self.team]) + + self.assertEqual(event.attendances.count(), 0) + + +class RosterChangeSyncTests(EventsTestBase): + def test_adding_roster_member_syncs_future_events(self): + event = self.make_event() + event.teams.set([self.team]) + dave = Member.objects.create(first_name="Dave", last_name="Dogwood") + + TeamMembership.objects.create(team=self.team, member=dave, season=self.season, position=self.position) + + self.assertIn(dave.id, self.attendee_ids(event)) + + def test_removing_roster_member_syncs_future_events(self): + event = self.make_event() + event.teams.set([self.team]) + + TeamMembership.objects.get(team=self.team, member=self.alice).delete() + + self.assertNotIn(self.alice.id, self.attendee_ids(event)) + + def test_roster_change_leaves_past_events_untouched(self): + past = self.make_event(start=timezone.now() - timedelta(days=1)) + past.teams.add(self.team) + Attendance.objects.create(event=past, member=self.alice) + + TeamMembership.objects.get(team=self.team, member=self.alice).delete() + + self.assertTrue(past.attendances.filter(member=self.alice).exists()) + + +class RecurrenceTestBase(EventsTestBase): + def setUp(self): + super().setUp() + self.anchor = (timezone.now() + timedelta(days=1)).replace(microsecond=0) + + def make_series(self, **kwargs): + kwargs.setdefault("club", self.club) + kwargs.setdefault("title", "Weekly Training") + kwargs.setdefault("kind", Event.EventKind.TRAINING) + kwargs.setdefault("rrule", "FREQ=WEEKLY;COUNT=4") + kwargs.setdefault("dtstart", self.anchor) + kwargs.setdefault("duration", timedelta(hours=2)) + series = EventSeries.objects.create(**kwargs) + series.teams.set([self.team]) + return series + + +class OccurrenceExpansionTests(RecurrenceTestBase): + def test_str(self): + self.assertEqual(str(self.make_series()), "Weekly Training") + + def test_weekly_expansion(self): + series = self.make_series() + + dts = occurrence_datetimes(series, self.anchor + timedelta(days=30)) + + self.assertEqual(dts[0], self.anchor) + self.assertEqual(dts[1], self.anchor + timedelta(weeks=1)) + self.assertEqual(len(dts), 4) + + def test_until_bounds_expansion(self): + series = self.make_series() + + dts = occurrence_datetimes(series, self.anchor + timedelta(days=10)) + + self.assertEqual(len(dts), 2) + + def test_excluded_dates_are_skipped(self): + series = self.make_series() + skipped = self.anchor + timedelta(weeks=1) + series.excluded_dates = [skipped.isoformat()] + series.save() + + dts = occurrence_datetimes(series, self.anchor + timedelta(days=30)) + + self.assertNotIn(skipped, dts) + self.assertEqual(len(dts), 3) + + def test_series_until_caps_expansion(self): + series = self.make_series(rrule="FREQ=WEEKLY", until=self.anchor + timedelta(days=10)) + + dts = occurrence_datetimes(series, self.anchor + timedelta(days=90)) + + # Only the anchor and the first weekly repeat fall on/before `until`. + self.assertEqual(dts, [self.anchor, self.anchor + timedelta(weeks=1)]) + self.assertTrue(all(dt <= series.until for dt in dts)) + + +class GenerateOccurrencesTests(RecurrenceTestBase): + def test_materialises_occurrences_with_template_and_attendance(self): + series = self.make_series() + + created = generate_occurrences(series, self.anchor + timedelta(days=30)) + + self.assertEqual(len(created), 4) + first = series.occurrences.order_by("start").first() + self.assertEqual(first.start, self.anchor) + self.assertEqual(first.end, self.anchor + timedelta(hours=2)) + self.assertEqual(first.title, "Weekly Training") + self.assertEqual(first.kind, Event.EventKind.TRAINING) + # Audience copied from the series, so attendance follows the roster. + self.assertEqual(set(first.attendances.values_list("member_id", flat=True)), {self.alice.id, self.bob.id}) + series.refresh_from_db() + self.assertIsNotNone(series.generated_until) + + def test_generation_is_idempotent(self): + series = self.make_series() + until = self.anchor + timedelta(days=30) + + generate_occurrences(series, until) + generate_occurrences(series, until) + + self.assertEqual(series.occurrences.count(), 4) + + def test_generation_stops_at_series_until(self): + series = self.make_series(rrule="FREQ=WEEKLY", until=self.anchor + timedelta(days=10)) + + generate_occurrences(series) + + self.assertEqual(series.occurrences.count(), 2) + + def test_gathering_and_deadline_come_from_offsets(self): + series = self.make_series(gathering_offset=timedelta(minutes=30), deadline_offset=timedelta(days=1)) + + generate_occurrences(series, self.anchor + timedelta(days=30)) + + first = series.occurrences.order_by("start").first() + self.assertEqual(first.gathering, self.anchor - timedelta(minutes=30)) + self.assertEqual(first.deadline, self.anchor - timedelta(days=1)) + + def test_without_offsets_gathering_and_deadline_are_blank(self): + series = self.make_series() + + generate_occurrences(series, self.anchor + timedelta(days=30)) + + first = series.occurrences.order_by("start").first() + self.assertIsNone(first.gathering) + self.assertIsNone(first.deadline) + + +class SingleOccurrenceTests(RecurrenceTestBase): + def test_cancel_deletes_and_prevents_regeneration(self): + series = self.make_series() + until = self.anchor + timedelta(days=30) + generate_occurrences(series, until) + target = series.occurrences.order_by("start")[1] + target_start = target.start + + cancel_occurrence(target) + self.assertFalse(series.occurrences.filter(start=target_start).exists()) + + generate_occurrences(series, until) + self.assertFalse(series.occurrences.filter(start=target_start).exists()) + self.assertEqual(series.occurrences.count(), 3) + + def test_cancel_soft_marks_cancelled(self): + series = self.make_series() + generate_occurrences(series, self.anchor + timedelta(days=30)) + target = series.occurrences.order_by("start").first() + + cancel_occurrence(target, hard_delete=False) + + target.refresh_from_db() + self.assertTrue(target.cancelled) + self.assertIn(target.start.isoformat(), series.excluded_dates) + + def test_detached_occurrence_is_left_untouched_by_propagation(self): + series = self.make_series() + generate_occurrences(series, self.anchor + timedelta(days=30)) + detached = series.occurrences.order_by("start").first() + detach_occurrence(detached) + + series.title = "Renamed" + series.save() + propagate_series(series) + + detached.refresh_from_db() + self.assertEqual(detached.title, "Weekly Training") + other = series.occurrences.exclude(pk=detached.pk).order_by("start").first() + self.assertEqual(other.title, "Renamed") + + def test_propagation_updates_audience_and_attendance(self): + series = self.make_series() + generate_occurrences(series, self.anchor + timedelta(days=30)) + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + series.invited_members.set([carol]) + + propagate_series(series) + + event = series.occurrences.order_by("start").first() + self.assertIn(carol.id, set(event.attendances.values_list("member_id", flat=True))) + + def test_propagation_updates_timing_offsets(self): + series = self.make_series() + generate_occurrences(series, self.anchor + timedelta(days=30)) + series.gathering_offset = timedelta(minutes=45) + series.save() + + propagate_series(series) + + first = series.occurrences.order_by("start").first() + self.assertEqual(first.gathering, self.anchor - timedelta(minutes=45)) + + +class ExtendSeriesCommandTests(RecurrenceTestBase): + def test_command_generates_occurrences(self): + series = self.make_series() + out = StringIO() + + call_command("extend_event_series", stdout=out) + + self.assertEqual(series.occurrences.count(), 4) + self.assertIn("Done.", out.getvalue()) + + +class EventClubScopeTests(EventsTestBase): + def setUp(self): + super().setUp() + self.other = Club.objects.create(name="Rival FC", slug="rival-fc") + today = timezone.localdate() + self.other_season = Season.objects.create(club=self.other, start_date=today - timedelta(days=30), end_date=today + timedelta(days=300)) + self.other_location = Location.objects.create(club=self.other, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE") + self.other_opponent = Opponent.objects.create(club=self.other, name="Rivals") + self.other_team = Team.objects.create(club=self.other, name="First", short_name="1") + + def test_event_rejects_cross_club_season(self): + event = Event(club=self.club, title="Match", start=self.future, season=self.other_season) + with self.assertRaises(ValidationError) as ctx: + event.full_clean() + self.assertIn("season", ctx.exception.error_dict) + + def test_event_rejects_cross_club_location(self): + event = Event(club=self.club, title="Match", start=self.future, location=self.other_location) + with self.assertRaises(ValidationError) as ctx: + event.full_clean() + self.assertIn("location", ctx.exception.error_dict) + + def test_event_accepts_same_club_fields(self): + Event(club=self.club, title="Match", start=self.future, season=self.season).full_clean() + + def test_event_rejects_cross_club_team(self): + event = Event.objects.create(club=self.club, title="Match", start=self.future, season=self.season) + with self.assertRaises(ValidationError): + event.teams.add(self.other_team) + + def test_event_accepts_same_club_team(self): + event = Event.objects.create(club=self.club, title="Match", start=self.future, season=self.season) + event.teams.add(self.team) + self.assertIn(self.team, event.teams.all()) + + def test_series_rejects_cross_club_opponent(self): + series = EventSeries(club=self.club, title="Weekly", rrule="FREQ=WEEKLY", dtstart=self.future, opponent=self.other_opponent) + with self.assertRaises(ValidationError) as ctx: + series.full_clean() + self.assertIn("opponent", ctx.exception.error_dict) + + def test_series_rejects_cross_club_team(self): + series = EventSeries.objects.create(club=self.club, title="Weekly", rrule="FREQ=WEEKLY", dtstart=self.future) + with self.assertRaises(ValidationError): + series.teams.add(self.other_team) + + +class TeamAttendanceStatsTests(EventsTestBase): + """events.services.attendance's team+season-scoped stats -- the queries + behind management.views.TeamDetailView's attendance panel.""" + + def make_past_training(self, days_ago, **kwargs): + # Past-start events are never auto-synced (see test_past_event_is_not_synced + # above), so attendance rows have to be created by hand here. + kwargs.setdefault("kind", Event.EventKind.TRAINING) + event = self.make_event(start=timezone.now() - timedelta(days=days_ago), **kwargs) + event.teams.add(self.team) + return event + + def set_status(self, event, member, status): + attendance, _created = Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status}) + return attendance + + def test_record_check_in_sets_showed_up(self): + event = self.make_past_training(1) + attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + + record_check_in(attendance, showed_up=False) + + attendance.refresh_from_db() + self.assertFalse(attendance.showed_up) + + def test_team_attendance_rate_excludes_excused_and_no_response(self): + event = self.make_past_training(1) + self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + self.set_status(event, self.bob, Attendance.AttendanceStatus.ABSENT) + + self.assertEqual(team_attendance_rate(self.team, self.season), 50) + + def test_team_attendance_rate_is_none_with_no_past_events(self): + self.assertIsNone(team_attendance_rate(self.team, self.season)) + + def test_rankings_exclude_players_below_the_response_minimum(self): + event = self.make_past_training(1) + self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + self.set_status(event, self.bob, Attendance.AttendanceStatus.ABSENT) + + rankings = player_attendance_rankings(self.team, self.season, minimum_responses=2) + + self.assertEqual(rankings, []) + + def test_rankings_rank_best_first(self): + e1 = self.make_past_training(10) + e2 = self.make_past_training(3) + self.set_status(e1, self.alice, Attendance.AttendanceStatus.PRESENT) + self.set_status(e2, self.alice, Attendance.AttendanceStatus.PRESENT) + self.set_status(e1, self.bob, Attendance.AttendanceStatus.PRESENT) + self.set_status(e2, self.bob, Attendance.AttendanceStatus.ABSENT) + + rankings = player_attendance_rankings(self.team, self.season, minimum_responses=2) + + self.assertEqual([entry["member"] for entry in rankings], [self.alice, self.bob]) + self.assertEqual(rankings[0]["rate"], 100) + self.assertEqual(rankings[1]["rate"], 50) + + def test_missed_recent_practices_needs_full_history(self): + self.make_past_training(3) # only one practice logged so far + + self.assertFalse(players_who_missed_recent_practices(self.team, self.season, count=2).exists()) + + def test_missed_recent_practices_flags_absence_on_both(self): + e1 = self.make_past_training(10) + e2 = self.make_past_training(3) + self.set_status(e1, self.alice, Attendance.AttendanceStatus.ABSENT) + self.set_status(e2, self.alice, Attendance.AttendanceStatus.NO_RESPONSE) + self.set_status(e1, self.bob, Attendance.AttendanceStatus.PRESENT) + self.set_status(e2, self.bob, Attendance.AttendanceStatus.ABSENT) + + missed = players_who_missed_recent_practices(self.team, self.season, count=2) + + self.assertEqual(list(missed), [self.alice]) + + def test_missed_recent_practices_excludes_an_excused_absence(self): + e1 = self.make_past_training(10) + e2 = self.make_past_training(3) + self.set_status(e1, self.alice, Attendance.AttendanceStatus.EXCUSED) + self.set_status(e2, self.alice, Attendance.AttendanceStatus.ABSENT) + + missed = players_who_missed_recent_practices(self.team, self.season, count=2) + + self.assertNotIn(self.alice, missed) + + def test_no_shows_requires_an_explicit_check_in(self): + event = self.make_past_training(1) + self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + + # Nobody has been checked in at all -- must not read as a no-show. + self.assertEqual(list(team_no_shows(self.team, self.season)), []) + + def test_no_shows_flags_a_present_rsvp_checked_in_as_absent(self): + event = self.make_past_training(1) + attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + record_check_in(attendance, showed_up=False) + + no_shows = team_no_shows(self.team, self.season) + + self.assertEqual(len(no_shows), 1) + self.assertEqual(no_shows[0].member, self.alice) + self.assertEqual(no_shows[0].event, event) + + def test_a_confirmed_check_in_is_not_a_no_show(self): + event = self.make_past_training(1) + attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + record_check_in(attendance, showed_up=True) + + self.assertEqual(list(team_no_shows(self.team, self.season)), []) + + +RBIHF_TEAM_ID = "4460" +RBIHF_TEAM_NAME = "Sportoase Antwerp Phantoms" + + +def rbihf_sample_html(rows): + """A trimmed stand-in for an RBIHF team page (https://www.rbihf.be/league/team/<id>) + -- structure confirmed against the real page while designing this feature. + ``rows`` is a list of dicts: game_id, date ("YYYY-MM-DD"), hour ("HH:MM"), + venue, home_id, home_name, visit_id, visit_name.""" + row_html = "".join( + f'<tr><td class="game-nr"><a href="/game/{r["game_id"]}" title="Game {r["game_id"]}">{r["game_id"]}</a></td>' + f'<td class="date">{r["date"]}</td><td class="hour">{r["hour"]}</td><td>{r["venue"]}</td>' + f'<td><a href="/league/team/{r["home_id"]}" title="{r["home_name"]}">{r["home_name"]}</a></td>' + f'<td><a href="/league/team/{r["visit_id"]}" title="{r["visit_name"]}">{r["visit_name"]}</a></td></tr>' + for r in rows + ) + return f"""<html><body> + <div class="block"><div class="block-header"><h2>{RBIHF_TEAM_NAME}</h2></div></div> + <div class="block"><div class="block-header"><h2 id="games-upcoming">Upcoming games</h2></div> + <div class="block-content"><table> + <tr><th class="game-nr">#</th><th class="date">Date</th><th class="hour">Hour</th><th>Location</th><th>Home</th><th>Visit</th></tr> + {row_html} + </table></div></div> + </body></html>""" + + +class RBIHFParseFixturesTests(TestCase): + """Pure parsing, no network, no DB -- events.services.rbihf_import.parse_fixtures.""" + + def test_parses_team_name_and_fixtures(self): + html = rbihf_sample_html( + [ + {"game_id": "5002", "date": "2026-09-12", "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"}, + {"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Antwerp Ice Rink", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}, + ] + ) + + team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID) + + self.assertEqual(team_name, RBIHF_TEAM_NAME) + self.assertEqual(len(fixtures), 2) + + def test_resolves_home_fixture_correctly(self): + html = rbihf_sample_html([{"game_id": "5002", "date": "2026-09-12", "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"}]) + + _team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID) + + self.assertTrue(fixtures[0].is_home) + self.assertEqual(fixtures[0].opponent_name, "Amsterdam Tigers") + self.assertEqual(fixtures[0].venue_text, "Deurne") + self.assertEqual(fixtures[0].external_game_id, "5002") + + def test_resolves_away_fixture_correctly(self): + html = rbihf_sample_html([{"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Antwerp Ice Rink", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}]) + + _team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID) + + self.assertFalse(fixtures[0].is_home) + self.assertEqual(fixtures[0].opponent_name, "Brussels Bears") + + def test_a_row_where_neither_side_matches_is_skipped(self): + html = rbihf_sample_html([{"game_id": "9999", "date": "2026-09-20", "hour": "18:30", "venue": "Elsewhere", "home_id": "1", "home_name": "Team One", "visit_id": "2", "visit_name": "Team Two"}]) + + _team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID) + + self.assertEqual(fixtures, []) + + def test_missing_games_table_raises(self): + with self.assertRaises(RBIHFImportError): + parse_fixtures("<html><body><h2>Some Team</h2></body></html>", RBIHF_TEAM_ID) + + +class RBIHFExtractTeamIdTests(TestCase): + def test_accepts_the_documented_shape(self): + self.assertEqual(extract_team_id("https://www.rbihf.be/league/team/4460"), "4460") + + def test_accepts_without_www(self): + self.assertEqual(extract_team_id("https://rbihf.be/league/team/4460"), "4460") + + def test_rejects_a_different_host(self): + with self.assertRaises(RBIHFImportError): + extract_team_id("https://evil.example.com/league/team/4460") + + def test_rejects_a_different_path(self): + with self.assertRaises(RBIHFImportError): + extract_team_id("https://www.rbihf.be/leagues") + + +class RBIHFImportPlanTests(EventsTestBase): + def setUp(self): + super().setUp() + self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Antwerp", zip_code="1000", country="BE", is_home=True) + self.away_location = Location.objects.create(club=self.club, name="Deurne Ice Hall", address="2 St", city="Deurne", zip_code="2100", country="BE") + + def home_fixture_row(self, game_id="5002", date="2026-09-12"): + return {"game_id": game_id, "date": date, "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"} + + # -- suggested_location -------------------------------------------------- + + def test_suggested_location_for_a_home_fixture_is_the_clubs_home_location(self): + _team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID) + + self.assertEqual(suggested_location(self.club, fixtures[0]), self.home_location) + + def test_suggested_location_for_an_away_fixture_matches_by_city(self): + row = {"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Deurne", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME} + _team_name, fixtures = parse_fixtures(rbihf_sample_html([row]), RBIHF_TEAM_ID) + + self.assertEqual(suggested_location(self.club, fixtures[0]), self.away_location) + + def test_suggested_location_is_none_when_nothing_matches(self): + row = {"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Nowhere Familiar", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME} + _team_name, fixtures = parse_fixtures(rbihf_sample_html([row]), RBIHF_TEAM_ID) + + self.assertIsNone(suggested_location(self.club, fixtures[0])) + + # -- suggested_opponent --------------------------------------------------- + + def test_suggested_opponent_matches_case_insensitively(self): + opponent = Opponent.objects.create(club=self.club, name="AMSTERDAM TIGERS") + _team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID) + + self.assertEqual(suggested_opponent(self.club, fixtures[0]), opponent) + + def test_suggested_opponent_is_none_when_nothing_matches(self): + _team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID) + + self.assertIsNone(suggested_opponent(self.club, fixtures[0])) + + # -- build_plan ------------------------------------------------------------ + + def test_a_new_fixture_is_planned_as_a_create(self): + html = rbihf_sample_html([self.home_fixture_row()]) + + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + + self.assertEqual(len(plan.to_create), 1) + self.assertEqual(plan.to_create[0].fixture.external_game_id, "5002") + self.assertEqual(plan.to_create[0].suggested_location, self.home_location) + + def test_an_identical_existing_fixture_is_unchanged(self): + html = rbihf_sample_html([self.home_fixture_row()]) + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + apply_plan(plan, {}) + + plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + + self.assertEqual(plan_again.to_create, []) + self.assertEqual(plan_again.to_update, []) + self.assertEqual(plan_again.unchanged_count, 1) + + def test_a_changed_fixture_is_planned_as_an_update_with_a_diff(self): + html = rbihf_sample_html([self.home_fixture_row()]) + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + apply_plan(plan, {}) + + changed_row = self.home_fixture_row() + changed_row["hour"] = "20:00" # same game id, different time + changed_html = rbihf_sample_html([changed_row]) + + plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, changed_html) + + self.assertEqual(len(plan_again.to_update), 1) + self.assertIn("start", plan_again.to_update[0].changes) + + def test_a_future_fixture_no_longer_listed_is_planned_for_deletion(self): + html = rbihf_sample_html([self.home_fixture_row()]) + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + apply_plan(plan, {}) + + plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([])) + + self.assertEqual(len(plan_again.to_delete), 1) + self.assertEqual(plan_again.to_delete[0].external_game_id, "5002") + + def test_a_past_fixture_no_longer_listed_is_not_deleted(self): + # "Upcoming games" naturally stops listing a game once it's happened -- + # that's not a signal it was cancelled. + past_row = self.home_fixture_row(date="2020-01-01") + html = rbihf_sample_html([past_row]) + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + apply_plan(plan, {}) + + plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([])) + + self.assertEqual(plan_again.to_delete, []) + + def test_a_fixture_for_a_different_team_is_not_touched(self): + other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + html = rbihf_sample_html([self.home_fixture_row()]) + plan = build_plan(self.club, other_team, RBIHF_TEAM_ID, html) + apply_plan(plan, {}) + + # Re-running for *our* team should see no existing RBIHF events at all + # for it -- the one that exists belongs to other_team. + plan_for_our_team = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + + self.assertEqual(len(plan_for_our_team.to_create), 1) + + # -- apply_plan -------------------------------------------------------- + + def test_apply_plan_creates_events_with_the_expected_fields(self): + html = rbihf_sample_html([self.home_fixture_row()]) + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + + result = apply_plan(plan, {"5002": str(self.home_location.pk)}) + + self.assertEqual(result, {"created": 1, "updated": 0, "deleted": 0}) + event = Event.objects.get(club=self.club, external_game_id="5002") + self.assertEqual(event.kind, Event.EventKind.GAME) + self.assertEqual(event.competition, "RBIHF") + self.assertEqual(event.opponent.name, "Amsterdam Tigers") + self.assertEqual(event.location, self.home_location) + self.assertIn(self.team, event.teams.all()) + self.assertTrue(event.is_home_game) + + def test_apply_plan_ignores_a_location_id_from_another_club(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + foreign_location = Location.objects.create(club=other_club, name="Not ours", address="x", city="x", zip_code="x", country="BE") + html = rbihf_sample_html([self.home_fixture_row()]) + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + + apply_plan(plan, {"5002": str(foreign_location.pk)}) + + event = Event.objects.get(club=self.club, external_game_id="5002") + self.assertIsNone(event.location) + + def test_apply_plan_uses_an_explicitly_chosen_opponent_instead_of_the_scraped_name(self): + renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC") + html = rbihf_sample_html([self.home_fixture_row()]) + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + + apply_plan(plan, {}, {"5002": str(renamed.pk)}) + + event = Event.objects.get(club=self.club, external_game_id="5002") + self.assertEqual(event.opponent, renamed) + self.assertEqual(Opponent.objects.filter(club=self.club, name="Amsterdam Tigers").count(), 0) + + def test_apply_plan_ignores_an_opponent_id_from_another_club(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + foreign_opponent = Opponent.objects.create(club=other_club, name="Not ours") + html = rbihf_sample_html([self.home_fixture_row()]) + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + + apply_plan(plan, {}, {"5002": str(foreign_opponent.pk)}) + + event = Event.objects.get(club=self.club, external_game_id="5002") + # Falls back to find-or-create by the scraped name, not the foreign row. + self.assertEqual(event.opponent.name, "Amsterdam Tigers") + + def test_apply_plan_preserves_a_previously_chosen_opponent_on_update(self): + renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC") + html = rbihf_sample_html([self.home_fixture_row()]) + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + apply_plan(plan, {}, {"5002": str(renamed.pk)}) + + # Re-run with a changed start time (something else triggers the update). + # build_plan should suggest (pre-select) the event's existing opponent + # rather than re-guessing from the scraped name -- a real <select> + # always resubmits whichever option is pre-selected, so that's what's + # passed to apply_plan here too, not a blank dict. + changed_row = self.home_fixture_row() + changed_row["hour"] = "20:00" + plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([changed_row])) + self.assertEqual(plan_again.to_update[0].suggested_opponent, renamed) + apply_plan(plan_again, {}, {"5002": str(renamed.pk)}) + + event = Event.objects.get(club=self.club, external_game_id="5002") + self.assertEqual(event.opponent, renamed) + + def test_apply_plan_updates_only_start_opponent_and_location(self): + html = rbihf_sample_html([self.home_fixture_row()]) + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + apply_plan(plan, {}) + event = Event.objects.get(club=self.club, external_game_id="5002") + event.end = self.future + event.gathering = self.future + event.score_for = 3 + event.save() + + changed_row = self.home_fixture_row() + changed_row["hour"] = "20:00" + plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([changed_row])) + apply_plan(plan_again, {}) + + event.refresh_from_db() + self.assertEqual(timezone.localtime(event.start).strftime("%H:%M"), "20:00") + self.assertIsNotNone(event.end) + self.assertIsNotNone(event.gathering) + self.assertEqual(event.score_for, 3) + + def test_apply_plan_deletes_only_whats_in_to_delete(self): + html = rbihf_sample_html([self.home_fixture_row()]) + plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html) + apply_plan(plan, {}) + + plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([])) + result = apply_plan(plan_again, {}) + + self.assertEqual(result, {"created": 0, "updated": 0, "deleted": 1}) + self.assertFalse(Event.objects.filter(club=self.club, external_game_id="5002").exists()) diff --git a/events/views.py b/events/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/events/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/features/__init__.py b/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/features/admin.py b/features/admin.py new file mode 100644 index 0000000..8fc6ab2 --- /dev/null +++ b/features/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from .models import Flag, Maintenance + + +@admin.register(Flag) +class FlagAdmin(admin.ModelAdmin): + list_display = ["name", "note", "everyone", "percent", "superusers", "staff"] + list_filter = ["everyone", "superusers", "staff"] + search_fields = ["name", "note"] + filter_horizontal = ["clubs"] + + +@admin.register(Maintenance) +class MaintenanceAdmin(admin.ModelAdmin): + list_display = ["__str__", "started_at", "started_by"] + readonly_fields = ["started_at", "started_by"] diff --git a/features/apps.py b/features/apps.py new file mode 100644 index 0000000..1060681 --- /dev/null +++ b/features/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class FeaturesConfig(AppConfig): + name = "features" + + def ready(self): + from . import signals # noqa: F401 diff --git a/features/commands.py b/features/commands.py new file mode 100644 index 0000000..5f06cb4 --- /dev/null +++ b/features/commands.py @@ -0,0 +1,31 @@ +"""Scheduled work refuses to run while the platform is locked down. + +Deliberately opt-in, per command, rather than a blanket guard on BaseCommand: maintenance is +usually declared IN ORDER to run `migrate` or `collectstatic`, and a guard that blocked those +would make the mode useless — you would have to turn it off to do the work you turned it on +for. Only the domain jobs (which write club data, archive clubs, or import members) stand +down. +""" + +from django.core.management.base import BaseCommand, CommandError + +from features.models import Maintenance + + +class MaintenanceAwareCommand(BaseCommand): + """A command that must not run while the platform is closed.""" + + def execute(self, *args, **options): + if Maintenance.is_on() and not options.get("ignore_maintenance"): + raise CommandError("The platform is in maintenance mode; this command stands down. Pass --ignore-maintenance to override.") + + return super().execute(*args, **options) + + def create_parser(self, prog_name, subcommand, **kwargs): + parser = super().create_parser(prog_name, subcommand, **kwargs) + parser.add_argument( + "--ignore-maintenance", + action="store_true", + help="Run even though the platform is in maintenance mode.", + ) + return parser diff --git a/features/context_processors.py b/features/context_processors.py new file mode 100644 index 0000000..2875c19 --- /dev/null +++ b/features/context_processors.py @@ -0,0 +1,7 @@ +from features.models import Maintenance + + +def maintenance(request): + """So no control-panel page can forget the platform is closed. Cached, so it costs no + query.""" + return {"maintenance_on": Maintenance.is_on()} diff --git a/features/middleware.py b/features/middleware.py new file mode 100644 index 0000000..7bca397 --- /dev/null +++ b/features/middleware.py @@ -0,0 +1,77 @@ +"""Platform lock-down. + +While maintenance is on, every club subdomain is closed and the base domain keeps only what +is needed to *end* the maintenance: the control panel, the auth screens that get you into it, +the static files those pages need, and the health check. + +The exemptions are the whole design. Close /accounts/ as well and you cannot sign in to turn +maintenance off — a lock-down with no key, fixable only from a shell. Close /healthz and the +load balancer concludes the node is dead and stops routing to it, which takes the control +panel down with everything else. +""" + +from django.http import JsonResponse +from django.shortcuts import render +from django.utils.translation import gettext_lazy as _ + +from features.models import Maintenance + +#: Reachable on the base domain while the platform is locked down. +OPEN_PREFIXES = ( + "/controlpanel/", # the point of the exercise + "/accounts/", # ...which you cannot reach without signing in + "/admin/", + "/static/", + "/__reload__/", # dev only; absent outside DEBUG +) + +#: Reachable on every host, always. The health check must answer or the load balancer will +#: take the node out of rotation and the control panel with it. /media/ has to stay open too: +#: the club maintenance page is rendered through the tenant's own skin specifically so it can +#: show the club's logo, and that logo is itself a /media/ file — closing it outright would +#: serve the maintenance page over the top of its own image. +ALWAYS_OPEN = ("/healthz", "/media/") + +RETRY_AFTER_SECONDS = 3600 + + +class MaintenanceMiddleware: + """Runs after ClubTenantMiddleware: whether a request is a club's or the platform's is + decided by ``request.club``, which the tenant middleware has just resolved.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not self.is_closed(request): + return self.get_response(request) + + maintenance = Maintenance.current() + response = self.render(request, maintenance) + response["Retry-After"] = RETRY_AFTER_SECONDS + + return response + + def is_closed(self, request) -> bool: + if request.path.startswith(ALWAYS_OPEN): + return False + + if not Maintenance.is_on(): + return False + + # A club subdomain is closed outright — no login, no shop, nothing (besides the + # /media/ exemption above, which its own maintenance page needs to render). + if getattr(request, "club", None) is not None: + return True + + # The base domain keeps the way back in. + return not request.path.startswith(OPEN_PREFIXES) + + def render(self, request, maintenance): + message = maintenance.message or _("RosterChief is down for maintenance. It will be back shortly.") + + # An API-ish caller gets JSON rather than a page of HTML it cannot read. + if request.headers.get("accept", "").startswith("application/json"): + return JsonResponse({"status": "maintenance", "detail": str(message)}, status=503) + + return render(request, "maintenance.html", {"message": message, "maintenance": maintenance}, status=503) diff --git a/features/migrations/0001_initial.py b/features/migrations/0001_initial.py new file mode 100644 index 0000000..c7344f4 --- /dev/null +++ b/features/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.6 on 2026-07-13 14:29 + +import django.utils.timezone +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'), + ('club', '0011_alter_club_slug'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Flag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='The human/computer readable name.', max_length=100, unique=True, verbose_name='Name')), + ('everyone', models.BooleanField(blank=True, help_text='Flip this flag on (Yes) or off (No) for everyone, overriding all other settings. Leave as Unknown to use normally.', null=True, verbose_name='Everyone')), + ('percent', models.DecimalField(blank=True, decimal_places=1, help_text='A number between 0.0 and 99.9 to indicate a percentage of users for whom this flag will be active.', max_digits=3, null=True, verbose_name='Percent')), + ('testing', models.BooleanField(default=False, help_text='Allow this flag to be set for a session for user testing', verbose_name='Testing')), + ('superusers', models.BooleanField(default=True, help_text='Flag always active for superusers?', verbose_name='Superusers')), + ('staff', models.BooleanField(default=False, help_text='Flag always active for staff?', verbose_name='Staff')), + ('authenticated', models.BooleanField(default=False, help_text='Flag always active for authenticated users?', verbose_name='Authenticated')), + ('languages', models.TextField(blank=True, default='', help_text='Activate this flag for users with one of these languages (comma-separated list)', verbose_name='Languages')), + ('rollout', models.BooleanField(default=False, help_text='Activate roll-out mode?', verbose_name='Rollout')), + ('note', models.TextField(blank=True, help_text='Note where this Flag is used.', verbose_name='Note')), + ('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now, help_text='Date when this Flag was created.', verbose_name='Created')), + ('modified', models.DateTimeField(default=django.utils.timezone.now, help_text='Date when this Flag was last modified.', verbose_name='Modified')), + ('clubs', models.ManyToManyField(blank=True, help_text='Activate this flag for these clubs.', related_name='flags', to='club.club', verbose_name='Clubs')), + ('groups', models.ManyToManyField(blank=True, help_text='Activate this flag for these user groups.', to='auth.group', verbose_name='Groups')), + ('users', models.ManyToManyField(blank=True, help_text='Activate this flag for these users.', to=settings.AUTH_USER_MODEL, verbose_name='Users')), + ], + options={ + 'verbose_name': 'Flag', + 'verbose_name_plural': 'Flags', + 'abstract': False, + }, + ), + ] diff --git a/features/migrations/0002_maintenance.py b/features/migrations/0002_maintenance.py new file mode 100644 index 0000000..3048687 --- /dev/null +++ b/features/migrations/0002_maintenance.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.6 on 2026-07-14 08:00 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('features', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Maintenance', + 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)), + ('is_active', models.BooleanField(default=False, verbose_name='active')), + ('message', models.TextField(blank=True, help_text='Shown to clubs while the platform is locked down.', verbose_name='message')), + ('started_at', models.DateTimeField(blank=True, null=True, verbose_name='started at')), + ('started_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='maintenance_windows', to=settings.AUTH_USER_MODEL, verbose_name='started by')), + ], + options={ + 'verbose_name': 'maintenance', + 'verbose_name_plural': 'maintenance', + }, + ), + ] diff --git a/features/migrations/__init__.py b/features/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/features/models.py b/features/models.py new file mode 100644 index 0000000..45d5d62 --- /dev/null +++ b/features/models.py @@ -0,0 +1,153 @@ +"""Club-scoped feature flags. + +waffle's Flag model is swappable (``WAFFLE_FLAG_MODEL``, like ``AUTH_USER_MODEL``), +so we subclass it to add the one dimension this platform actually needs: which +*clubs* a feature is on for. The tenant middleware already puts ``request.club`` +on every request, so a flag resolves with a plain ``flag_is_active(request, "shop")`` +— no call site has to know about clubs. + +Everything waffle already offers (``everyone`` / ``percent`` / ``staff`` / +``superusers`` / per-user / per-group) keeps working untouched. +""" + +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from waffle.models import CACHE_EMPTY, AbstractUserFlag +from waffle.utils import get_cache, keyfmt + +from rosterchief.base import UUIDModel + +#: Cache key template for a flag's club ids, mirroring waffle's own +#: FLAG_USERS_CACHE_KEY / FLAG_GROUPS_CACHE_KEY. +FLAG_CLUBS_CACHE_KEY = "flag:%s:clubs" + + +class Flag(AbstractUserFlag): + clubs = models.ManyToManyField( + "club.Club", + blank=True, + related_name="flags", + help_text=_("Activate this flag for these clubs."), + verbose_name=_("Clubs"), + ) + + def get_flush_keys(self, flush_keys=None): + flush_keys = super().get_flush_keys(flush_keys) + flush_keys.append(keyfmt(FLAG_CLUBS_CACHE_KEY, self.name)) + return flush_keys + + def _get_club_ids(self) -> set: + """Club ids this flag is on for, cached the way waffle caches its own M2Ms.""" + if self.pk is None: + # waffle's own BaseModel.get() falls back to a transient, unsaved + # Flag(name=...) instance for a name nothing in the DB matches yet + # (see waffle/models.py) -- an M2M lookup can't run against that, and + # there's genuinely nothing for it to be on for anyway. + return set() + + cache = get_cache() + cache_key = keyfmt(FLAG_CLUBS_CACHE_KEY, self.name) + + cached = cache.get(cache_key) + if cached == CACHE_EMPTY: + return set() + if cached: + return cached + + club_ids = set(self.clubs.values_list("pk", flat=True)) + if not club_ids: + cache.add(cache_key, CACHE_EMPTY) + return set() + + cache.add(cache_key, club_ids) + return club_ids + + def is_active(self, request, read_only=False): + # waffle's contract: `everyone` overrides *all* other settings. So a flag + # explicitly switched off for everyone stays off even for a targeted club, + # and club targeting only applies while `everyone` is left Unknown (None). + if self.everyone is None: + club = getattr(request, "club", None) + if club is not None and club.pk in self._get_club_ids(): + return True + + return super().is_active(request, read_only=read_only) + + def is_active_for_club(self, club) -> bool: + """Explicit check for code that holds a club but no request.""" + if self.everyone is not None: + return self.everyone + return club.pk in self._get_club_ids() + + +class Maintenance(UUIDModel): + """Platform lock-down. One row, read on every request. + + Cached rather than queried per request, and the cache is the same shared Redis the flags + use — so turning maintenance on in the control panel takes effect on every worker and + every server at once. A per-process cache would leave some workers still serving clubs. + """ + + CACHE_KEY = "maintenance:current" + + #: Cached, but not for ever. Write-through makes the flip instant for the process that + #: made it and — on the shared Redis of a real deployment — for every other one too. The + #: TTL is the belt to that braces: on a per-process cache (a dev box with no Redis, or a + #: misconfigured deploy) a lock-down that only reached one gunicorn worker would be worse + #: than useless, so the others notice within ten seconds regardless. + CACHE_SECONDS = 10 + + is_active = models.BooleanField(_("active"), default=False) + message = models.TextField(_("message"), blank=True, help_text=_("Shown to clubs while the platform is locked down.")) + started_at = models.DateTimeField(_("started at"), null=True, blank=True) + started_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="maintenance_windows", verbose_name=_("started by")) + + class Meta: + verbose_name = _("maintenance") + verbose_name_plural = _("maintenance") + + def __str__(self): + return "Maintenance on" if self.is_active else "Maintenance off" + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + get_cache().set(self.CACHE_KEY, self, self.CACHE_SECONDS) + + @classmethod + def current(cls) -> Maintenance: + """The one row, created on first read. Cached until it changes.""" + cached = get_cache().get(cls.CACHE_KEY) + if cached is not None: + return cached + + maintenance = cls.objects.first() or cls.objects.create() + get_cache().set(cls.CACHE_KEY, maintenance, cls.CACHE_SECONDS) + + return maintenance + + @classmethod + def is_on(cls) -> bool: + return cls.current().is_active + + @classmethod + def start(cls, *, message: str = "", user=None) -> Maintenance: + maintenance = cls.current() + maintenance.is_active = True + maintenance.message = message + maintenance.started_at = timezone.now() + maintenance.started_by = user + maintenance.save() + + return maintenance + + @classmethod + def stop(cls) -> Maintenance: + maintenance = cls.current() + maintenance.is_active = False + maintenance.started_at = None + maintenance.started_by = None + maintenance.save() + + return maintenance diff --git a/features/signals.py b/features/signals.py new file mode 100644 index 0000000..d1ce6e7 --- /dev/null +++ b/features/signals.py @@ -0,0 +1,26 @@ +"""Keep waffle's flag cache honest when club targeting changes. + +waffle caches a flag's M2M ids and only flushes on ``save()``. Editing an M2M +does not call ``save()``, so adding or removing a club would otherwise leave a +stale cached set and the flag would keep answering with the old value. +""" + +from django.db.models.signals import m2m_changed +from django.dispatch import receiver + +from .models import Flag + +FLUSH_ACTIONS = {"post_add", "post_remove", "post_clear"} + + +@receiver(m2m_changed, sender=Flag.clubs.through) +def flush_flag_club_cache(sender, instance, action, reverse, pk_set, **kwargs): + if action not in FLUSH_ACTIONS: + return + + if isinstance(instance, Flag): + instance.flush() + else: + # Reverse edit (club.flags.add(flag)): flush each flag touched. + for flag in Flag.objects.filter(pk__in=pk_set or []): + flag.flush() diff --git a/features/tests.py b/features/tests.py new file mode 100644 index 0000000..b6aea13 --- /dev/null +++ b/features/tests.py @@ -0,0 +1,252 @@ +from io import StringIO + +from allauth.mfa.models import Authenticator +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import RequestFactory, TestCase, override_settings +from waffle import flag_is_active, get_waffle_flag_model + +from club.models import Club + +from .models import Maintenance + +Flag = get_waffle_flag_model() +User = get_user_model() + + +class ClubScopedFlagTests(TestCase): + def setUp(self): + # waffle caches flags by name, and its cache is NOT rolled back with the + # test transaction -- a flag row recreated under the same name in the next + # test would otherwise be shadowed by the previous test's cached object. + cache.clear() + self.addCleanup(cache.clear) + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.other = Club.objects.create(name="Rival FC", slug="rival-fc") + self.flag = Flag.objects.create(name="shop") + + def request_for(self, club): + request = RequestFactory().get("/") + request.club = club + request.user = None + return request + + def active_for(self, club): + return flag_is_active(self.request_for(club), "shop") + + def test_off_for_every_club_by_default(self): + self.assertFalse(self.active_for(self.club)) + self.assertFalse(self.active_for(self.other)) + + def test_on_only_for_the_targeted_club(self): + self.flag.clubs.add(self.club) + + self.assertTrue(self.active_for(self.club)) + self.assertFalse(self.active_for(self.other)) + + def test_removing_a_club_turns_it_off_again(self): + self.flag.clubs.add(self.club) + self.flag.clubs.remove(self.club) + + self.assertFalse(self.active_for(self.club)) + + def test_everyone_true_overrides_club_targeting(self): + self.flag.everyone = True + self.flag.save() + + self.assertTrue(self.active_for(self.other)) # not targeted, still on + + def test_everyone_false_beats_club_targeting(self): + # waffle's contract: `everyone` overrides ALL other settings, so a flag + # switched off for everyone must stay off even for a targeted club. + self.flag.clubs.add(self.club) + self.flag.everyone = False + self.flag.save() + + self.assertFalse(self.active_for(self.club)) + + def test_no_club_on_the_request_is_not_active(self): + # e.g. the base domain / control panel, where there is no tenant. + self.flag.clubs.add(self.club) + + self.assertFalse(flag_is_active(self.request_for(None), "shop")) + + def test_a_flag_name_with_no_matching_row_is_off_not_an_error(self): + # Regression: waffle's own BaseModel.get() falls back to a transient, + # unsaved Flag(name=...) instance when nothing in the DB matches the + # name -- an M2M lookup (self.clubs) against that unsaved instance used + # to raise ValueError instead of just resolving to "off". This is the + # normal state for any flag nobody has created in the control panel yet. + self.assertFalse(flag_is_active(self.request_for(self.club), "no-such-flag")) + + def test_is_active_for_club_without_a_request(self): + self.flag.clubs.add(self.club) + + self.assertTrue(self.flag.is_active_for_club(self.club)) + self.assertFalse(self.flag.is_active_for_club(self.other)) + + def test_is_active_for_club_respects_everyone(self): + self.flag.everyone = False + self.flag.save() + self.assertFalse(self.flag.is_active_for_club(self.club)) + + self.flag.everyone = True + self.flag.save() + self.assertTrue(self.flag.is_active_for_club(self.club)) + + def test_cache_is_flushed_when_club_targeting_changes(self): + # The M2M does not call save(), so without the flush signal waffle would + # keep answering from a stale cached set. + self.assertFalse(self.active_for(self.club)) # primes the cache + + self.flag.clubs.add(self.club) + + self.assertTrue(self.active_for(self.club)) + + def test_cache_is_flushed_on_reverse_edit(self): + self.assertFalse(self.active_for(self.club)) + + self.club.flags.add(self.flag) + + self.assertTrue(self.active_for(self.club)) + + +@override_settings( + ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", + ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], +) +class MaintenanceModeTests(TestCase): + def setUp(self): + cache.clear() + self.addCleanup(cache.clear) + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.user = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True) + Authenticator.objects.create(user=self.user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) + + def club_get(self, path="/"): + return self.client.get(path, HTTP_HOST="ajax-united.rosterchief.app") + + def platform_get(self, path): + return self.client.get(path, HTTP_HOST="rosterchief.app") + + def test_a_club_subdomain_is_closed(self): + Maintenance.start(message="Upgrading the database.") + + response = self.club_get("/accounts/login/") + + self.assertEqual(response.status_code, 503) + self.assertContains(response, "Upgrading the database", status_code=503) + self.assertEqual(response["Retry-After"], "3600") + + def test_a_club_is_open_again_when_maintenance_ends(self): + Maintenance.start() + Maintenance.stop() + + self.assertEqual(self.club_get("/accounts/login/").status_code, 200) + + def test_the_control_panel_stays_reachable(self): + Maintenance.start() + self.client.force_login(self.user) + + self.assertEqual(self.platform_get("/controlpanel/").status_code, 200) + + def test_signing_in_stays_possible(self): + # Close /accounts/ as well and you cannot sign in to turn maintenance off: a + # lock-down with no key, fixable only from a shell. + Maintenance.start() + + self.assertEqual(self.platform_get("/accounts/login/").status_code, 200) + + def test_the_club_logo_still_loads_on_the_maintenance_page(self): + # The maintenance page is rendered through the club's own skin specifically to show + # its logo -- closing /media/ on the club subdomain too would serve the maintenance + # page itself in place of that logo, making it look like the logo vanished. + Maintenance.start() + + self.assertEqual(self.club_get("/media/clubs/ajax-united/crest.png").status_code, 404) + + def test_the_health_check_still_answers(self): + # Close it and the load balancer decides the node is dead and stops routing to it — + # taking the control panel down with everything else. + Maintenance.start() + + self.assertEqual(self.platform_get("/healthz").status_code, 200) + self.assertEqual(self.client.get("/healthz", HTTP_HOST="ajax-united.rosterchief.app").status_code, 200) + + def test_the_rest_of_the_base_domain_is_closed(self): + Maintenance.start() + + self.assertEqual(self.platform_get("/").status_code, 503) + + def test_a_json_caller_gets_json(self): + Maintenance.start(message="Back soon.") + + response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app", headers={"accept": "application/json"}) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.json()["status"], "maintenance") + + def test_the_message_is_optional(self): + Maintenance.start() + + self.assertContains(self.club_get("/"), "down for maintenance", status_code=503) + + def test_it_says_which_state_it_is_in(self): + self.assertEqual(str(Maintenance.start()), "Maintenance on") + self.assertEqual(str(Maintenance.stop()), "Maintenance off") + + def test_it_records_who_closed_the_platform_and_when(self): + maintenance = Maintenance.start(message="db upgrade", user=self.user) + + self.assertTrue(maintenance.is_active) + self.assertEqual(maintenance.started_by, self.user) + self.assertIsNotNone(maintenance.started_at) + + def test_the_state_is_shared_rather_than_per_process(self): + # The cache is the shared one the flags use, so closing the platform reaches every + # worker and every server. A per-process cache would leave some workers serving clubs. + Maintenance.start() + + self.assertTrue(cache.get(Maintenance.CACHE_KEY).is_active) + self.assertTrue(Maintenance.is_on()) + + Maintenance.stop() + + self.assertFalse(cache.get(Maintenance.CACHE_KEY).is_active) + + +class MaintenanceCommandTests(TestCase): + def setUp(self): + cache.clear() + self.addCleanup(cache.clear) + + def run_command(self, name, *args): + out = StringIO() + call_command(name, *args, stdout=out, stderr=out) + return out.getvalue() + + def test_a_scheduled_job_stands_down(self): + Maintenance.start() + + with self.assertRaises(CommandError): + self.run_command("archive_overdue_clubs") + + def test_it_runs_again_once_the_platform_reopens(self): + Maintenance.start() + Maintenance.stop() + + self.assertIn("Nothing overdue", self.run_command("archive_overdue_clubs")) + + def test_an_override_exists_for_when_you_mean_it(self): + Maintenance.start() + + self.assertIn("Nothing overdue", self.run_command("archive_overdue_clubs", "--ignore-maintenance")) + + def test_migrate_is_not_blocked(self): + # Maintenance is usually declared IN ORDER to migrate. A guard on every command would + # mean turning the mode off to do the work you turned it on for. + Maintenance.start() + + self.run_command("migrate", "--check") # raises SystemExit only if migrations are pending diff --git a/formbuilder/__init__.py b/formbuilder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/formbuilder/admin.py b/formbuilder/admin.py new file mode 100644 index 0000000..94c6188 --- /dev/null +++ b/formbuilder/admin.py @@ -0,0 +1,50 @@ +from django.contrib import admin + +from .models import Answer, Field, Form, Submission + + +class FieldInline(admin.TabularInline): + model = Field + extra = 0 + fields = ["order", "key", "label", "field_type", "required", "is_active"] + ordering = ["order"] + + +@admin.register(Form) +class FormAdmin(admin.ModelAdmin): + list_display = ["title", "slug", "club", "is_active", "login_required", "opens_at", "closes_at"] + list_filter = ["club", "is_active", "login_required"] + search_fields = ["title", "slug"] + prepopulated_fields = {"slug": ["title"]} + inlines = [FieldInline] + + +@admin.register(Field) +class FieldAdmin(admin.ModelAdmin): + list_display = ["label", "key", "form", "field_type", "order", "required", "is_active"] + list_filter = ["field_type", "required", "is_active"] + search_fields = ["label", "key", "form__title"] + raw_id_fields = ["form"] + + +class AnswerInline(admin.TabularInline): + model = Answer + extra = 0 + raw_id_fields = ["field"] + + +@admin.register(Submission) +class SubmissionAdmin(admin.ModelAdmin): + list_display = ["form", "member", "submitted_at"] + list_filter = ["form"] + search_fields = ["form__title", "member__first_name", "member__last_name"] + raw_id_fields = ["form", "member"] + readonly_fields = ["submitted_at"] + inlines = [AnswerInline] + + +@admin.register(Answer) +class AnswerAdmin(admin.ModelAdmin): + list_display = ["submission", "field", "value"] + search_fields = ["field__label", "field__key"] + raw_id_fields = ["submission", "field"] diff --git a/formbuilder/apps.py b/formbuilder/apps.py new file mode 100644 index 0000000..28e84de --- /dev/null +++ b/formbuilder/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FormbuilderConfig(AppConfig): + name = "formbuilder" diff --git a/formbuilder/migrations/0001_initial.py b/formbuilder/migrations/0001_initial.py new file mode 100644 index 0000000..ec03e17 --- /dev/null +++ b/formbuilder/migrations/0001_initial.py @@ -0,0 +1,90 @@ +# Generated by Django 6.0.6 on 2026-07-12 19:02 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('club', '0008_alter_clubmembership_unique_together_and_more'), + ('members', '0002_alter_familymembership_unique_together_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Form', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('slug', models.SlugField(unique=True, verbose_name='slug')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('is_active', models.BooleanField(default=True, verbose_name='is active?')), + ('login_required', models.BooleanField(default=False, verbose_name='login required?')), + ('opens_at', models.DateTimeField(blank=True, null=True, verbose_name='opens at')), + ('closes_at', models.DateTimeField(blank=True, null=True, verbose_name='closes at')), + ('max_submissions_per_user', models.PositiveIntegerField(blank=True, null=True, verbose_name='max submissions per user')), + ('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')), + ], + options={ + 'verbose_name': 'form', + 'verbose_name_plural': 'forms', + 'ordering': ['title'], + }, + ), + migrations.CreateModel( + name='Field', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('key', models.SlugField(unique=True, verbose_name='key')), + ('label', models.CharField(max_length=255, verbose_name='label')), + ('field_type', models.CharField(choices=[('text', 'text'), ('textarea', 'textarea'), ('number', 'number'), ('email', 'email'), ('date', 'date'), ('choice', 'choice'), ('multichoice', 'multichoice'), ('checkbox', 'checkbox'), ('file', 'file')], default='text', max_length=255, verbose_name='field type')), + ('required', models.BooleanField(default=True, verbose_name='required?')), + ('help_text', models.TextField(blank=True, verbose_name='help text')), + ('order', models.PositiveIntegerField(default=0, verbose_name='order')), + ('is_active', models.BooleanField(default=True, verbose_name='is active?')), + ('options', models.JSONField(blank=True, null=True, verbose_name='options')), + ('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='formbuilder.form', verbose_name='form')), + ], + options={ + 'verbose_name': 'field', + 'verbose_name_plural': 'fields', + 'ordering': ['form', 'order'], + }, + ), + migrations.CreateModel( + name='Submission', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('submitted_at', models.DateTimeField(auto_now_add=True, verbose_name='submitted at')), + ('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='formbuilder.form', verbose_name='form')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='members.member', verbose_name='member')), + ], + options={ + 'verbose_name': 'submission', + 'verbose_name_plural': 'submissions', + 'ordering': ['-submitted_at'], + }, + ), + migrations.CreateModel( + name='Answer', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('value', models.JSONField(blank=True, null=True, verbose_name='value')), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='formbuilder.field', verbose_name='field')), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='formbuilder.submission', verbose_name='submission')), + ], + options={ + 'verbose_name': 'answer', + 'verbose_name_plural': 'answers', + 'ordering': ['submission', 'field'], + }, + ), + migrations.AddConstraint( + model_name='form', + constraint=models.UniqueConstraint(fields=('club', 'slug'), name='unique_form_slug_per_club'), + ), + ] diff --git a/formbuilder/migrations/0002_alter_answer_field_alter_submission_member.py b/formbuilder/migrations/0002_alter_answer_field_alter_submission_member.py new file mode 100644 index 0000000..017078c --- /dev/null +++ b/formbuilder/migrations/0002_alter_answer_field_alter_submission_member.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.6 on 2026-07-12 19:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('formbuilder', '0001_initial'), + ('members', '0002_alter_familymembership_unique_together_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='answer', + name='field', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='answers', to='formbuilder.field', verbose_name='field'), + ), + migrations.AlterField( + model_name='submission', + name='member', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submissions', to='members.member', verbose_name='member'), + ), + ] diff --git a/formbuilder/migrations/0003_fix_tenant_uniqueness.py b/formbuilder/migrations/0003_fix_tenant_uniqueness.py new file mode 100644 index 0000000..c3749f0 --- /dev/null +++ b/formbuilder/migrations/0003_fix_tenant_uniqueness.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.6 on 2026-07-12 19:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('formbuilder', '0002_alter_answer_field_alter_submission_member'), + ] + + operations = [ + migrations.AlterField( + model_name='field', + name='key', + field=models.SlugField(verbose_name='key'), + ), + migrations.AlterField( + model_name='form', + name='slug', + field=models.SlugField(verbose_name='slug'), + ), + migrations.AddConstraint( + model_name='answer', + constraint=models.UniqueConstraint(fields=('submission', 'field'), name='unique_answer_per_field_per_submission'), + ), + migrations.AddConstraint( + model_name='field', + constraint=models.UniqueConstraint(fields=('form', 'key'), name='unique_field_key_per_form'), + ), + ] diff --git a/formbuilder/migrations/0004_alter_field_key_alter_form_slug.py b/formbuilder/migrations/0004_alter_field_key_alter_form_slug.py new file mode 100644 index 0000000..2066841 --- /dev/null +++ b/formbuilder/migrations/0004_alter_field_key_alter_form_slug.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-07-12 19:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('formbuilder', '0003_fix_tenant_uniqueness'), + ] + + operations = [ + migrations.AlterField( + model_name='field', + name='key', + field=models.SlugField(blank=True, verbose_name='key'), + ), + migrations.AlterField( + model_name='form', + name='slug', + field=models.SlugField(blank=True, verbose_name='slug'), + ), + ] diff --git a/formbuilder/migrations/0005_answer_created_answer_modified_field_created_and_more.py b/formbuilder/migrations/0005_answer_created_answer_modified_field_created_and_more.py new file mode 100644 index 0000000..c068112 --- /dev/null +++ b/formbuilder/migrations/0005_answer_created_answer_modified_field_created_and_more.py @@ -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 = [ + ('formbuilder', '0004_alter_field_key_alter_form_slug'), + ] + + operations = [ + migrations.AddField( + model_name='answer', + name='created', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 566066, tzinfo=datetime.timezone.utc), verbose_name='created'), + preserve_default=False, + ), + migrations.AddField( + model_name='answer', + name='modified', + field=models.DateTimeField(auto_now=True, verbose_name='modified'), + ), + migrations.AddField( + model_name='field', + name='created', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 566082, tzinfo=datetime.timezone.utc), verbose_name='created'), + preserve_default=False, + ), + migrations.AddField( + model_name='field', + name='modified', + field=models.DateTimeField(auto_now=True, verbose_name='modified'), + ), + migrations.AddField( + model_name='form', + name='created', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 566098, tzinfo=datetime.timezone.utc), verbose_name='created'), + preserve_default=False, + ), + migrations.AddField( + model_name='form', + name='modified', + field=models.DateTimeField(auto_now=True, verbose_name='modified'), + ), + migrations.AddField( + model_name='submission', + name='created', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 566113, tzinfo=datetime.timezone.utc), verbose_name='created'), + preserve_default=False, + ), + migrations.AddField( + model_name='submission', + name='modified', + field=models.DateTimeField(auto_now=True, verbose_name='modified'), + ), + ] diff --git a/formbuilder/migrations/__init__.py b/formbuilder/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/formbuilder/models.py b/formbuilder/models.py new file mode 100644 index 0000000..7eb2c5a --- /dev/null +++ b/formbuilder/models.py @@ -0,0 +1,107 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import UniqueConstraint +from django.utils.translation import gettext_lazy as _ + +from members.models import Member +from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify + + +class Form(ClubScopedModel): + title = models.CharField(_("title"), max_length=255) + slug = models.SlugField(_("slug"), blank=True) + description = models.TextField(_("description"), blank=True) + + slug_source = "title" + + is_active = models.BooleanField(_("is active?"), default=True) + login_required = models.BooleanField(_("login required?"), default=False) + + opens_at = models.DateTimeField(_("opens at"), blank=True, null=True) + closes_at = models.DateTimeField(_("closes at"), blank=True, null=True) + max_submissions_per_user = models.PositiveIntegerField(_("max submissions per user"), blank=True, null=True) + + class Meta: + verbose_name = _("form") + verbose_name_plural = _("forms") + ordering = ["title"] + constraints = [ + UniqueConstraint(fields=["club", "slug"], name="unique_form_slug_per_club"), + ] + + def __str__(self): + return self.title + + +class Field(UUIDModel): + class FieldType(models.TextChoices): + TEXT = "text", _("text") + TEXTAREA = "textarea", _("textarea") + NUMBER = "number", _("number") + EMAIL = "email", _("email") + DATE = "date", _("date") + CHOICE = "choice", _("choice") + MULTICHOICE = "multichoice", _("multichoice") + CHECKBOX = "checkbox", _("checkbox") + FILE = "file", _("file") + + form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name="fields", verbose_name=_("form")) + key = models.SlugField(_("key"), blank=True) + label = models.CharField(_("label"), max_length=255) + field_type = models.CharField(_("field type"), max_length=255, choices=FieldType.choices, default=FieldType.TEXT) + required = models.BooleanField(_("required?"), default=True) + help_text = models.TextField(_("help text"), blank=True) + order = models.PositiveIntegerField(_("order"), default=0) + is_active = models.BooleanField(_("is active?"), default=True) + options = models.JSONField(_("options"), blank=True, null=True) + + class Meta: + verbose_name = _("field") + verbose_name_plural = _("fields") + ordering = ["form", "order"] + constraints = [ + UniqueConstraint(fields=["form", "key"], name="unique_field_key_per_form"), + ] + + def save(self, *args, **kwargs): + if not self.key: + self.key = unique_slugify(self, self.label, slug_field="key", scope={"form": self.form}) + super().save(*args, **kwargs) + + def __str__(self): + return self.label + + +class Submission(UUIDModel): + form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name="submissions", verbose_name=_("form")) + member = models.ForeignKey(Member, on_delete=models.SET_NULL, related_name="submissions", verbose_name=_("member"), blank=True, null=True) + submitted_at = models.DateTimeField(_("submitted at"), auto_now_add=True) + + class Meta: + verbose_name = _("submission") + verbose_name_plural = _("submissions") + ordering = ["-submitted_at"] + + def __str__(self): + return f"{self.form} - {self.member}" + + +class Answer(UUIDModel): + submission = models.ForeignKey(Submission, on_delete=models.CASCADE, related_name="answers", verbose_name=_("submission")) + field = models.ForeignKey(Field, on_delete=models.PROTECT, related_name="answers", verbose_name=_("field")) + value = models.JSONField(_("value"), blank=True, null=True) + + class Meta: + verbose_name = _("answer") + verbose_name_plural = _("answers") + ordering = ["submission", "field"] + constraints = [ + UniqueConstraint(fields=["submission", "field"], name="unique_answer_per_field_per_submission"), + ] + + def __str__(self): + return f"{self.submission} - {self.field}" + + def clean(self): + if self.field_id and self.submission_id and self.field.form_id != self.submission.form_id: + raise ValidationError({"field": _("Must belong to the same form as the submission.")}) diff --git a/formbuilder/services/__init__.py b/formbuilder/services/__init__.py new file mode 100644 index 0000000..0a5c57e --- /dev/null +++ b/formbuilder/services/__init__.py @@ -0,0 +1,16 @@ +from .form_factory import build_form, build_form_class +from .options import allowed_values, field_choices +from .reporting import FormReport, ReportRow, form_report +from .submission import FormSubmissionError, submit_form + +__all__ = [ + "FormReport", + "FormSubmissionError", + "ReportRow", + "allowed_values", + "build_form", + "build_form_class", + "field_choices", + "form_report", + "submit_form", +] diff --git a/formbuilder/services/form_factory.py b/formbuilder/services/form_factory.py new file mode 100644 index 0000000..13b388e --- /dev/null +++ b/formbuilder/services/form_factory.py @@ -0,0 +1,35 @@ +"""Build a live ``django.forms.Form`` class from a Form instance's fields.""" + +from django import forms + +from formbuilder.models import Field + +from .options import field_choices + +FIELD_BUILDERS = { + Field.FieldType.TEXT: lambda field, kw: forms.CharField(**kw), + Field.FieldType.TEXTAREA: lambda field, kw: forms.CharField(widget=forms.Textarea, **kw), + Field.FieldType.NUMBER: lambda field, kw: forms.DecimalField(**kw), + Field.FieldType.EMAIL: lambda field, kw: forms.EmailField(**kw), + Field.FieldType.DATE: lambda field, kw: forms.DateField(**kw), + Field.FieldType.CHOICE: lambda field, kw: forms.ChoiceField(choices=field_choices(field), **kw), + Field.FieldType.MULTICHOICE: lambda field, kw: forms.MultipleChoiceField(choices=field_choices(field), **kw), + Field.FieldType.CHECKBOX: lambda field, kw: forms.BooleanField(**kw), + Field.FieldType.FILE: lambda field, kw: forms.FileField(**kw), +} + + +def _build_field(field): + kwargs = {"required": field.required, "label": field.label, "help_text": field.help_text} + return FIELD_BUILDERS[field.field_type](field, kwargs) + + +def build_form_class(form): + """Return a ``forms.Form`` subclass with a field per active Field, keyed by ``key``.""" + fields = {field.key: _build_field(field) for field in form.fields.filter(is_active=True).order_by("order")} + return type("DynamicForm", (forms.Form,), fields) + + +def build_form(form, data=None, files=None): + """Instantiate the dynamic form, bound to ``data``/``files`` when provided.""" + return build_form_class(form)(data=data, files=files) diff --git a/formbuilder/services/options.py b/formbuilder/services/options.py new file mode 100644 index 0000000..fea825e --- /dev/null +++ b/formbuilder/services/options.py @@ -0,0 +1,23 @@ +"""Helpers for interpreting a Field's ``options`` (choice definitions). + +``options`` is a JSON list, either of plain strings (``["a", "b"]``) or of +``{"value": ..., "label": ...}`` dicts. +""" + + +def field_choices(field): + """Return ``[(value, label), ...]`` for a choice-type field.""" + choices = [] + for option in field.options or []: + if isinstance(option, dict): + value = option.get("value") + label = option.get("label", value) + else: + value = label = option + choices.append((value, label)) + return choices + + +def allowed_values(field): + """Return the set of accepted values for a choice-type field.""" + return {value for value, _ in field_choices(field)} diff --git a/formbuilder/services/reporting.py b/formbuilder/services/reporting.py new file mode 100644 index 0000000..15dc2d0 --- /dev/null +++ b/formbuilder/services/reporting.py @@ -0,0 +1,46 @@ +"""Build a tabular overview of all answers for a Form.""" + +from dataclasses import dataclass + +from formbuilder.models import Field + +CHOICE_TYPES = {Field.FieldType.CHOICE, Field.FieldType.MULTICHOICE, Field.FieldType.CHECKBOX} + + +@dataclass +class ReportRow: + submission: object + values: dict # field id -> answer value + + +@dataclass +class FormReport: + form: object + columns: list # Field instances, in display order + rows: list # ReportRow, newest submission first + summaries: dict # field id -> {stringified value: count} for choice-like fields + count: int + + +def form_report(form): + """Return a FormReport: one column per field, one row per submission.""" + columns = list(form.fields.order_by("order")) + summaries = {column.id: {} for column in columns if column.field_type in CHOICE_TYPES} + + rows = [] + submissions = form.submissions.select_related("member").prefetch_related("answers") + for submission in submissions: + values = {answer.field_id: answer.value for answer in submission.answers.all()} + rows.append(ReportRow(submission=submission, values=values)) + for field_id, bucket in summaries.items(): + if field_id in values: + _tally(bucket, values[field_id]) + + return FormReport(form=form, columns=columns, rows=rows, summaries=summaries, count=len(rows)) + + +def _tally(bucket, value): + items = value if isinstance(value, list) else [value] + for item in items: + key = str(item) + bucket[key] = bucket.get(key, 0) + 1 diff --git a/formbuilder/services/submission.py b/formbuilder/services/submission.py new file mode 100644 index 0000000..48aedd8 --- /dev/null +++ b/formbuilder/services/submission.py @@ -0,0 +1,65 @@ +"""Validate and persist a submission against a Form's fields and rules.""" + +from django.db import transaction +from django.utils import timezone + +from formbuilder.models import Answer, Submission + +from .form_factory import build_form + + +class FormSubmissionError(Exception): + """Raised when a submission is rejected. ``errors`` maps field key -> messages.""" + + def __init__(self, message, *, errors=None): + super().__init__(message) + self.errors = errors or {} + + +def _is_empty(value): + return value is None or value == "" or value == [] + + +@transaction.atomic +def submit_form(form, member, data, *, files=None, when=None): + """Create a Submission (with Answers) for ``data``/``files`` or raise FormSubmissionError. + + Validation goes through ``build_form`` — the same dynamic Django Form the UI would + render — so a NUMBER field is actually checked as a decimal, an EMAIL as an email, a + CHOICE against its real options, and so on, rather than a hand-rolled subset of that. + """ + when = when or timezone.now() + + _check_open(form, member, when) + cleaned = _clean_answers(form, data, files) + + submission = Submission.objects.create(form=form, member=member) + Answer.objects.bulk_create([Answer(submission=submission, field=field, value=value) for field, value in cleaned]) + return submission + + +def _check_open(form, member, when): + if not form.is_active: + raise FormSubmissionError("This form is not accepting submissions.") + if form.login_required and member is None: + raise FormSubmissionError("You must be signed in to submit this form.") + if form.opens_at is not None and when < form.opens_at: + raise FormSubmissionError("This form is not open yet.") + if form.closes_at is not None and when > form.closes_at: + raise FormSubmissionError("This form has closed.") + if form.max_submissions_per_user is not None and member is not None: + used = form.submissions.filter(member=member).count() + if used >= form.max_submissions_per_user: + raise FormSubmissionError("You have reached the maximum number of submissions for this form.") + + +def _clean_answers(form, data, files): + bound_form = build_form(form, data=data, files=files or {}) + if not bound_form.is_valid(): + errors = {key: list(messages) for key, messages in bound_form.errors.items()} + raise FormSubmissionError("The submission has errors.", errors=errors) + + # Blank optional answers are validated (they may legitimately be empty) but not + # stored — an Answer row exists only where the submitter actually said something. + fields_by_key = {field.key: field for field in form.fields.filter(is_active=True)} + return [(fields_by_key[key], value) for key, value in bound_form.cleaned_data.items() if not _is_empty(value)] diff --git a/formbuilder/tests.py b/formbuilder/tests.py new file mode 100644 index 0000000..0305e3c --- /dev/null +++ b/formbuilder/tests.py @@ -0,0 +1,367 @@ +from datetime import timedelta + +from django import forms +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.db.models import ProtectedError +from django.test import TestCase +from django.utils import timezone + +from club.models import Club +from members.models import Member + +from .models import Answer, Field, Form, Submission +from .services import ( + FormSubmissionError, + build_form, + build_form_class, + field_choices, + form_report, + submit_form, +) + + +class FormbuilderTestBase(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.form = Form.objects.create(club=self.club, title="Sign-up", slug="sign-up") + self.name = Field.objects.create(form=self.form, key="name", label="Name", field_type=Field.FieldType.TEXT, required=True, order=1) + self.size = Field.objects.create(form=self.form, key="size", label="Shirt size", field_type=Field.FieldType.CHOICE, required=False, order=2, options=["S", "M", "L"]) + self.member = Member.objects.create(first_name="Jane", last_name="Doe") + + +class ModelTests(FormbuilderTestBase): + def test_str_methods(self): + submission = Submission.objects.create(form=self.form, member=self.member) + answer = Answer.objects.create(submission=submission, field=self.name, value="Jane") + + self.assertEqual(str(self.form), "Sign-up") + self.assertEqual(str(self.name), "Name") + self.assertEqual(str(submission), "Sign-up - Jane Doe") + self.assertEqual(str(answer), "Sign-up - Jane Doe - Name") + + def test_slug_is_unique_per_club_not_globally(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + Form.objects.create(club=other_club, title="Sign-up", slug="sign-up") + + with self.assertRaises(IntegrityError): + Form.objects.create(club=self.club, title="Another", slug="sign-up") + + def test_key_is_unique_per_form_not_globally(self): + other_form = Form.objects.create(club=self.club, title="Other", slug="other") + Field.objects.create(form=other_form, key="name", label="Name", order=1) + + with self.assertRaises(IntegrityError): + Field.objects.create(form=self.form, key="name", label="Duplicate", order=9) + + def test_answer_is_unique_per_field_per_submission(self): + submission = Submission.objects.create(form=self.form, member=self.member) + Answer.objects.create(submission=submission, field=self.name, value="Jane") + + with self.assertRaises(IntegrityError): + Answer.objects.create(submission=submission, field=self.name, value="Again") + + def test_deleting_form_cascades_to_fields_and_submissions(self): + Submission.objects.create(form=self.form, member=self.member) + + self.form.delete() + + self.assertFalse(Field.objects.exists()) + self.assertFalse(Submission.objects.exists()) + + def test_field_referenced_by_answer_is_protected(self): + submission = Submission.objects.create(form=self.form, member=self.member) + Answer.objects.create(submission=submission, field=self.name, value="Jane") + + with self.assertRaises(ProtectedError): + self.name.delete() + + +class FormSlugTests(FormbuilderTestBase): + def test_slug_auto_populated_from_title(self): + form = Form.objects.create(club=self.club, title="Registration Form") + + self.assertEqual(form.slug, "registration-form") + + def test_explicit_slug_is_preserved(self): + form = Form.objects.create(club=self.club, title="Registration", slug="reg") + + self.assertEqual(form.slug, "reg") + + def test_slug_is_unique_per_club_with_suffix(self): + first = Form.objects.create(club=self.club, title="Registration") + second = Form.objects.create(club=self.club, title="Registration") + + self.assertEqual(first.slug, "registration") + self.assertEqual(second.slug, "registration-2") + + +class FieldKeyTests(FormbuilderTestBase): + def test_key_auto_populated_from_label(self): + field = Field.objects.create(form=self.form, label="First Name", order=5) + + self.assertEqual(field.key, "first-name") + + def test_explicit_key_is_preserved(self): + field = Field.objects.create(form=self.form, key="fn", label="First Name", order=5) + + self.assertEqual(field.key, "fn") + + def test_key_is_unique_per_form_with_suffix(self): + first = Field.objects.create(form=self.form, label="First Name", order=5) + second = Field.objects.create(form=self.form, label="First Name", order=6) + + self.assertEqual(first.key, "first-name") + self.assertEqual(second.key, "first-name-2") + + def test_same_key_allowed_in_a_different_form(self): + other_form = Form.objects.create(club=self.club, title="Other", slug="other") + here = Field.objects.create(form=self.form, label="First Name", order=5) + there = Field.objects.create(form=other_form, label="First Name", order=1) + + self.assertEqual(here.key, there.key) + + +class FieldChoicesTests(FormbuilderTestBase): + def test_string_options(self): + self.assertEqual(field_choices(self.size), [("S", "S"), ("M", "M"), ("L", "L")]) + + def test_dict_options(self): + field = Field.objects.create(form=self.form, key="tier", label="Tier", field_type=Field.FieldType.CHOICE, order=3, options=[{"value": "a", "label": "Gold"}, {"value": "b", "label": "Silver"}]) + + self.assertEqual(field_choices(field), [("a", "Gold"), ("b", "Silver")]) + + def test_no_options(self): + self.assertEqual(field_choices(self.name), []) + + +class SubmitFormTests(FormbuilderTestBase): + def test_successful_submission_creates_submission_and_answers(self): + submission = submit_form(self.form, self.member, {"name": "Jane", "size": "M"}) + + self.assertEqual(submission.form, self.form) + self.assertEqual(submission.member, self.member) + self.assertEqual(submission.answers.count(), 2) + self.assertEqual(submission.answers.get(field=self.name).value, "Jane") + + def test_optional_field_can_be_omitted(self): + submission = submit_form(self.form, self.member, {"name": "Jane"}) + + self.assertEqual(submission.answers.count(), 1) + + def test_missing_required_field_is_rejected(self): + with self.assertRaises(FormSubmissionError) as ctx: + submit_form(self.form, self.member, {"size": "M"}) + + self.assertIn("name", ctx.exception.errors) + self.assertFalse(Submission.objects.exists()) + + def test_inactive_form_is_rejected(self): + self.form.is_active = False + self.form.save() + + with self.assertRaises(FormSubmissionError): + submit_form(self.form, self.member, {"name": "Jane"}) + + def test_login_required_without_member_is_rejected(self): + self.form.login_required = True + self.form.save() + + with self.assertRaises(FormSubmissionError): + submit_form(self.form, None, {"name": "Jane"}) + + def test_anonymous_submission_allowed_when_login_not_required(self): + submission = submit_form(self.form, None, {"name": "Jane"}) + + self.assertIsNone(submission.member) + + def test_not_open_yet_is_rejected(self): + self.form.opens_at = timezone.now() + timedelta(days=1) + self.form.save() + + with self.assertRaises(FormSubmissionError): + submit_form(self.form, self.member, {"name": "Jane"}) + + def test_closed_form_is_rejected(self): + self.form.closes_at = timezone.now() - timedelta(days=1) + self.form.save() + + with self.assertRaises(FormSubmissionError): + submit_form(self.form, self.member, {"name": "Jane"}) + + def test_open_window_respects_when_argument(self): + self.form.opens_at = timezone.now() + timedelta(days=1) + self.form.save() + + submission = submit_form(self.form, self.member, {"name": "Jane"}, when=timezone.now() + timedelta(days=2)) + + self.assertTrue(Submission.objects.filter(pk=submission.pk).exists()) + + def test_max_submissions_per_user_is_enforced(self): + self.form.max_submissions_per_user = 1 + self.form.save() + submit_form(self.form, self.member, {"name": "Jane"}) + + with self.assertRaises(FormSubmissionError): + submit_form(self.form, self.member, {"name": "Jane"}) + + def test_invalid_choice_is_rejected(self): + with self.assertRaises(FormSubmissionError) as ctx: + submit_form(self.form, self.member, {"name": "Jane", "size": "XL"}) + + self.assertIn("size", ctx.exception.errors) + + def test_number_field_rejects_non_numeric_input(self): + # Validation goes through the same dynamic Django Form the UI renders, so a + # NUMBER field is checked as a decimal — not merely "present". + Field.objects.create(form=self.form, key="age", label="Age", field_type=Field.FieldType.NUMBER, required=True, order=3) + + with self.assertRaises(FormSubmissionError) as ctx: + submit_form(self.form, self.member, {"name": "Jane", "age": "not-a-number"}) + + self.assertIn("age", ctx.exception.errors) + + def test_email_field_rejects_an_invalid_address(self): + Field.objects.create(form=self.form, key="contact", label="Contact", field_type=Field.FieldType.EMAIL, required=True, order=3) + + with self.assertRaises(FormSubmissionError) as ctx: + submit_form(self.form, self.member, {"name": "Jane", "contact": "not-an-email"}) + + self.assertIn("contact", ctx.exception.errors) + + def test_multichoice_validation(self): + field = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue", "wed"]) + + submit_form(self.form, self.member, {"name": "Jane", "days": ["mon", "wed"]}) + with self.assertRaises(FormSubmissionError): + submit_form(self.form, self.member, {"name": "Joe", "days": ["mon", "sun"]}) + self.assertEqual(Answer.objects.filter(field=field).count(), 1) + + def test_inactive_field_is_ignored(self): + Field.objects.create(form=self.form, key="secret", label="Secret", field_type=Field.FieldType.TEXT, required=True, is_active=False, order=4) + + submission = submit_form(self.form, self.member, {"name": "Jane"}) + + self.assertFalse(submission.answers.filter(field__key="secret").exists()) + + def test_unknown_keys_are_ignored(self): + submission = submit_form(self.form, self.member, {"name": "Jane", "bogus": "x"}) + + self.assertEqual(submission.answers.count(), 1) + + +class BuildFormTests(FormbuilderTestBase): + def make_all_field_types_form(self): + form = Form.objects.create(club=self.club, title="All", slug="all") + specs = [ + ("f_text", Field.FieldType.TEXT, forms.CharField), + ("f_area", Field.FieldType.TEXTAREA, forms.CharField), + ("f_num", Field.FieldType.NUMBER, forms.DecimalField), + ("f_email", Field.FieldType.EMAIL, forms.EmailField), + ("f_date", Field.FieldType.DATE, forms.DateField), + ("f_choice", Field.FieldType.CHOICE, forms.ChoiceField), + ("f_multi", Field.FieldType.MULTICHOICE, forms.MultipleChoiceField), + ("f_check", Field.FieldType.CHECKBOX, forms.BooleanField), + ("f_file", Field.FieldType.FILE, forms.FileField), + ] + for i, (key, field_type, _) in enumerate(specs): + Field.objects.create(form=form, key=key, label=key, field_type=field_type, required=False, order=i, options=["a", "b"]) + return form, specs + + def test_field_classes_map_from_field_type(self): + form, specs = self.make_all_field_types_form() + + instance = build_form_class(form)() + + for key, _, expected in specs: + with self.subTest(key=key): + self.assertIsInstance(instance.fields[key], expected) + + def test_required_and_labels_propagate(self): + instance = build_form_class(self.form)() + + self.assertTrue(instance.fields["name"].required) + self.assertFalse(instance.fields["size"].required) + self.assertEqual(instance.fields["name"].label, "Name") + + def test_choice_field_gets_choices(self): + instance = build_form(self.form) + + self.assertEqual(list(instance.fields["size"].choices), [("S", "S"), ("M", "M"), ("L", "L")]) + + def test_bound_form_validates(self): + bound = build_form(self.form, data={"name": "Jane", "size": "M"}) + + self.assertTrue(bound.is_valid()) + self.assertEqual(bound.cleaned_data["name"], "Jane") + + def test_bound_form_reports_required_error(self): + bound = build_form(self.form, data={"size": "M"}) + + self.assertFalse(bound.is_valid()) + self.assertIn("name", bound.errors) + + def test_inactive_fields_are_excluded(self): + Field.objects.create(form=self.form, key="hidden", label="Hidden", is_active=False, order=9) + + instance = build_form(self.form) + + self.assertNotIn("hidden", instance.fields) + + +class FormReportTests(FormbuilderTestBase): + def test_columns_are_fields_in_order(self): + report = form_report(self.form) + + self.assertEqual(report.columns, [self.name, self.size]) + + def test_rows_carry_answer_values(self): + submit_form(self.form, self.member, {"name": "Jane", "size": "M"}) + + report = form_report(self.form) + + self.assertEqual(report.count, 1) + row = report.rows[0] + self.assertEqual(row.values[self.name.id], "Jane") + self.assertEqual(row.values[self.size.id], "M") + + def test_choice_field_is_summarised(self): + submit_form(self.form, self.member, {"name": "Jane", "size": "M"}) + submit_form(self.form, Member.objects.create(first_name="Joe", last_name="Roe"), {"name": "Joe", "size": "M"}) + submit_form(self.form, Member.objects.create(first_name="Kim", last_name="Ash"), {"name": "Kim", "size": "L"}) + + report = form_report(self.form) + + self.assertEqual(report.summaries[self.size.id], {"M": 2, "L": 1}) + self.assertNotIn(self.name.id, report.summaries) + + def test_multichoice_values_are_tallied_per_option(self): + days = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue"]) + submit_form(self.form, self.member, {"name": "Jane", "days": ["mon", "tue"]}) + + report = form_report(self.form) + + self.assertEqual(report.summaries[days.id], {"mon": 1, "tue": 1}) + + def test_empty_form_reports_no_rows(self): + report = form_report(self.form) + + self.assertEqual(report.count, 0) + self.assertEqual(report.rows, []) + + +class AnswerCleanTests(FormbuilderTestBase): + def test_rejects_field_from_another_form(self): + other_form = Form.objects.create(club=self.club, title="Other", slug="other") + other_field = Field.objects.create(form=other_form, key="x", label="X", order=1) + submission = Submission.objects.create(form=self.form, member=self.member) + answer = Answer(submission=submission, field=other_field, value="v") + + with self.assertRaises(ValidationError) as ctx: + answer.full_clean() + self.assertIn("field", ctx.exception.error_dict) + + def test_accepts_field_from_the_submissions_form(self): + submission = Submission.objects.create(form=self.form, member=self.member) + + Answer(submission=submission, field=self.name, value="v").full_clean() diff --git a/formbuilder/views.py b/formbuilder/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/formbuilder/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/manage.py b/manage.py index e3be89a..86cc41a 100755 --- a/manage.py +++ b/manage.py @@ -1,22 +1,19 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'clubmanager.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rosterchief.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc + raise ImportError("Couldn't import Django. Are you sure it's installed and available on your PYTHONPATH environment variable? Did you forget to activate a virtual environment?") from exc execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/management/__init__.py b/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/management/apps.py b/management/apps.py new file mode 100644 index 0000000..f4ad1a6 --- /dev/null +++ b/management/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ManagementConfig(AppConfig): + name = "management" diff --git a/management/bulk_import.py b/management/bulk_import.py new file mode 100644 index 0000000..4715219 --- /dev/null +++ b/management/bulk_import.py @@ -0,0 +1,163 @@ +"""Mass-uploading members from an Excel template: extracting a workbook into plain +row data, and validating that data into what will be created. + +Kept as two separate functions so binary parsing happens exactly once (at upload +time) while validation -- the part that must behave identically whether it's +building the preview or actually creating records -- runs against plain data both +times (see MemberImportView / MemberImportConfirmView in management/views.py). +""" + +from datetime import date, datetime + +import openpyxl +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ +from openpyxl.worksheet.datavalidation import DataValidation + +from club.models import ClubMembership +from members.models import Member + +from .forms import MemberForm + +TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status"] +REQUIRED_HEADER_COLUMNS = {"first_name", "last_name"} + +TEMPLATE_EXAMPLE_ROW = ["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID] + + +def build_member_import_template(): + """The downloadable .xlsx: header row, one example row, and a dropdown on the + status/fee_status columns so a cell can't be typo'd into an invalid value.""" + workbook = openpyxl.Workbook() + sheet = workbook.active + sheet.title = "Members" + + sheet.append(TEMPLATE_COLUMNS) + for cell in sheet[1]: + cell.font = openpyxl.styles.Font(bold=True) + sheet.freeze_panes = "A2" + + sheet.append(TEMPLATE_EXAMPLE_ROW) + + for column_name, choices in (("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus)): + column_index = TEMPLATE_COLUMNS.index(column_name) + 1 + column_letter = sheet.cell(row=1, column=column_index).column_letter + options = ",".join(choices.values) + validation = DataValidation(type="list", formula1=f'"{options}"', allow_blank=True) + sheet.add_data_validation(validation) + validation.add(f"{column_letter}2:{column_letter}1000") + + for column_index, column_name in enumerate(TEMPLATE_COLUMNS, start=1): + sheet.column_dimensions[sheet.cell(row=1, column=column_index).column_letter].width = max(12, len(column_name) + 2) + + return workbook + + +def read_member_import_workbook(file): + """Uploaded .xlsx -> list[dict], one dict per row keyed by TEMPLATE_COLUMNS. + + Normalizes every cell to a plain str/None here: openpyxl returns typed cells + (a date-formatted cell comes back as a datetime.date, a phone number typed as + digits-only can come back as a number), so this is the one place that has to + deal with that -- everything downstream, including the session, only ever + sees plain strings. + """ + workbook = openpyxl.load_workbook(file, data_only=True) + sheet = workbook.active + + header = [str(cell.value).strip() if cell.value is not None else "" for cell in next(sheet.iter_rows(min_row=1, max_row=1))] + if not REQUIRED_HEADER_COLUMNS.issubset(header): + missing = REQUIRED_HEADER_COLUMNS - set(header) + raise ValueError(_("This doesn't look like the template — missing column(s): %(columns)s.") % {"columns": ", ".join(sorted(missing))}) + + rows = [] + for excel_row in sheet.iter_rows(min_row=2): + values = {} + for column_name, cell in zip(header, excel_row, strict=False): + if column_name not in TEMPLATE_COLUMNS: + continue + values[column_name] = _cell_to_str(cell.value) + + if not any(values.values()): + continue # a fully blank row (trailing spreadsheet padding) isn't a row to import + rows.append(values) + + return rows + + +def _cell_to_str(value): + if value is None: + return "" + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value).strip() + + +def parse_member_import_rows(rows, club): + """list[dict] (as returned by read_member_import_workbook) -> one result per + row: {line_number, raw, member, membership_kwargs, errors}. `member` is an + unsaved Member instance (ready to .save()) when the row is valid, else None.""" + results = [] + seen_emails = set() + + for line_number, raw in enumerate(rows, start=2): + errors = [] + member_fields = {key: raw.get(key, "") for key in ("first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone")} + form = MemberForm(data=member_fields) + + member = None + if form.is_valid(): + member = form.save(commit=False) + else: + for field_errors in form.errors.values(): + errors.extend(field_errors) + + email = member_fields["email"].strip() + if email: + if email.lower() in seen_emails: + errors.append(_("Duplicate email in this file.")) + seen_emails.add(email.lower()) + already_in_club = Member.objects.filter(member_of__club=club).filter(Q(email__iexact=email) | Q(user__email__iexact=email)).exists() + if already_in_club: + errors.append(_("Already a member of this club.")) + + membership_kwargs, status_fee_errors = _parse_membership_fields(raw) + errors.extend(status_fee_errors) + + results.append({"line_number": line_number, "raw": raw, "member": member if not errors else None, "membership_kwargs": membership_kwargs, "errors": errors}) + + return results + + +def _parse_membership_fields(raw): + errors = [] + license_number = raw.get("license", "").strip() + + status = raw.get("status", "").strip() + if status: + status_value = _match_choice(status, ClubMembership.StatusChoices) + if status_value is None: + errors.append(_("Invalid status '%(value)s'.") % {"value": status}) + else: + status_value = ClubMembership.StatusChoices.ACTIVE + + fee_status = raw.get("fee_status", "").strip() + if fee_status: + fee_status_value = _match_choice(fee_status, ClubMembership.FeeStatus) + if fee_status_value is None: + errors.append(_("Invalid fee status '%(value)s'.") % {"value": fee_status}) + else: + fee_status_value = ClubMembership.FeeStatus.UNPAID + + return {"license": license_number, "status": status_value, "fee_status": fee_status_value}, errors + + +def _match_choice(value, choices): + for choice_value in choices.values: + if choice_value.lower() == value.lower(): + return choice_value + return None diff --git a/management/context_processors.py b/management/context_processors.py new file mode 100644 index 0000000..9275e30 --- /dev/null +++ b/management/context_processors.py @@ -0,0 +1,172 @@ +"""Whether the signed-in user is a club ADMIN, for the management nav to hide +admin-only sections (seasons, positions, roles, shop, forms) from plain staff. + +The underlying views are gated regardless (``ClubAdminRequiredMixin``) -- this is +purely so the nav doesn't show a link a coach or manager can't actually follow. +Same reasoning for ``news_permissions`` below, gating just the "New news item" +action rather than the whole section (``NewsAuthorRequiredMixin``/``can_add_news``). +""" + +from waffle import flag_is_active + +from club.services.access import can_add_news, has_management_access, is_club_admin, is_coach_manager + +#: Every management URL name, mapped to the nav item it should light up -- +#: management/templates/management/_nav_items.html compares against this. +#: One dict here beats threading `nav=` through every view in views.py, and +#: unlike that, a new page can't silently be forgotten (it just renders with +#: no active item until added below, rather than needing every view touched). +_NAV_SECTIONS = { + "home": "home", + "member_list": "member_list", + "member_create": "member_list", + "member_import_template": "member_list", + "member_import": "member_list", + "member_import_confirm": "member_list", + "member_detail": "member_list", + "member_update": "member_list", + "member_delete": "member_list", + "member_attach_family": "member_list", + "member_grant_login": "member_list", + "member_detach_family": "member_list", + "family_create": "member_list", + "family_detail": "member_list", + "family_add_child": "member_list", + "family_add_parent": "member_list", + "family_membership_role_update": "member_list", + "membership_list": "membership_list", + "membership_mark_paid": "membership_list", + "membership_export_pdf": "membership_list", + "membership_mark_fully_paid": "membership_list", + "membership_record_payment": "membership_list", + "position_list": "position_list", + "position_create": "position_list", + "position_update": "position_list", + "role_list": "role_list", + "role_create": "role_list", + "role_revoke": "role_list", + "team_list": "team_list", + "team_create": "team_list", + "team_update": "team_list", + "team_delete": "team_list", + "team_detail": "team_list", + "team_roster_add": "team_list", + "team_roster_update": "team_list", + "team_roster_remove": "team_list", + "team_staff_add": "team_list", + "team_staff_update": "team_list", + "team_staff_remove": "team_list", + "team_photo_set": "team_list", + "team_photo_delete": "team_list", + "news_list": "news_list", + "news_create": "news_list", + "news_detail": "news_list", + "news_update": "news_list", + "news_delete": "news_list", + "news_publish": "news_list", + "news_unpublish": "news_list", + "news_photo_upload": "news_list", + "news_photo_set_main": "news_list", + "news_photo_delete": "news_list", + "event_list": "event_list", + "event_create": "event_list", + "event_detail": "event_list", + "event_update": "event_list", + "event_delete": "event_list", + "event_detach": "event_list", + "event_fetch_game_info": "event_list", + "rbihf_import": "event_list", + "rbihf_import_confirm": "event_list", + "event_series_create": "event_list", + "event_series_detail": "event_list", + "event_series_update": "event_list", + "event_series_delete": "event_list", + "event_series_stop": "event_list", + "event_series_generate": "event_list", + "location_list": "location_list", + "location_create": "location_list", + "location_update": "location_list", + "location_delete": "location_list", + "opponent_list": "opponent_list", + "opponent_create": "opponent_list", + "opponent_update": "opponent_list", + "opponent_delete": "opponent_list", + "sponsor_list": "sponsor_list", + "sponsor_create": "sponsor_list", + "sponsor_update": "sponsor_list", + "sponsor_delete": "sponsor_list", + "product_list": "product_list", + "order_list": "order_list", + "discount_list": "discount_list", + "invoice_list": "invoice_list", + "form_list": "form_list", + "submission_list": "form_list", +} + + +def active_nav_section(request): + """Which management nav item is currently active, derived from the resolved + URL name. Guarded on the "management" namespace so a same-named url_name in + some other app can never leak into this.""" + match = request.resolver_match + if match is None or match.namespace != "management": + return {"nav": None} + + return {"nav": _NAV_SECTIONS.get(match.url_name)} + + +def is_admin(request): + club = getattr(request, "club", None) + if club is None or not request.user.is_authenticated: + return {"is_club_admin": False} + + return {"is_club_admin": is_club_admin(request.user, club)} + + +def management_position(request): + """Whether the signed-in user holds a management position (or is ADMIN) -- + gates the nav's Locations/Opponents links, which ``ManagementPositionRequiredMixin`` + restricts to exactly this group (unlike most staff-visible sections).""" + club = getattr(request, "club", None) + if club is None or not request.user.is_authenticated: + return {"has_management_position": False} + + return {"has_management_position": is_club_admin(request.user, club) or is_coach_manager(request.user, club)} + + +def management_link(request): + """Whether to show a "Management" link in the global navbar (templates/_base.html), + next to the Django admin one -- only on a club subdomain, and only for someone with + real authority there (see has_management_access).""" + club = getattr(request, "club", None) + if club is None or not request.user.is_authenticated: + return {"has_management_access": False} + + return {"has_management_access": has_management_access(request.user, club)} + + +def feature_sections(request): + """Whether the nav's Shop/Forms sections -- and the Events page's "Import + from RBIHF" button -- should show at all. Each is gated behind its own + waffle Flag (see club.mixins.FeatureRequiredMixin, which gates the + underlying views regardless), on top of the existing is_club_admin check + those all already require.""" + club = getattr(request, "club", None) + if club is None or not request.user.is_authenticated: + return {"shop_enabled": False, "forms_enabled": False, "rbihf_enabled": False} + + return { + "shop_enabled": flag_is_active(request, "shop"), + "forms_enabled": flag_is_active(request, "formbuilder"), + "rbihf_enabled": flag_is_active(request, "RBIHF"), + } + + +def news_permissions(request): + """Whether the "New news item" action should show -- viewing the News + section itself is open to any staff, same as Roster/Staff.""" + club = getattr(request, "club", None) + if club is None or not request.user.is_authenticated: + return {"can_add_news": False} + + return {"can_add_news": can_add_news(request.user, club)} diff --git a/management/forms.py b/management/forms.py new file mode 100644 index 0000000..cebe039 --- /dev/null +++ b/management/forms.py @@ -0,0 +1,512 @@ +import datetime +from decimal import Decimal + +from django import forms +from django.contrib.auth import get_user_model +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from club.models import ClubMembership, ClubRole, FeePayment, Season, Sponsor +from club.services.access import is_club_admin, teams_managed_by +from events.models import Competition, Event, EventSeries, Location, Opponent +from events.services.rbihf_import import RBIHFImportError, extract_team_id +from members.models import Family, FamilyMembership, Member +from members.services.family import find_member_by_email +from news.models import News +from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto + +from .recurrence_ui import FREQUENCY_CHOICES, WEEKDAY_CHOICES, build_rrule, parse_rrule + +User = get_user_model() + + +class MemberForm(forms.ModelForm): + class Meta: + model = Member + fields = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone"] + widgets = {"date_of_birth": forms.DateInput(attrs={"type": "date"})} + + +class TeamForm(forms.ModelForm): + class Meta: + model = Team + fields = ["name", "short_name"] + + +class TeamPhotoForm(forms.ModelForm): + """One photo per (team, season) -- bound to the existing TeamPhoto (if + any) by the view's get_form_kwargs, same "create or update the one row + for this scope" pattern as controlpanel.forms.HomeLocationForm.""" + + class Meta: + model = TeamPhoto + fields = ["image"] + + +class TeamMembershipForm(forms.ModelForm): + """Add/edit one roster entry -- team and season come from the view (the URL + already identifies both), never from the form itself.""" + + class Meta: + model = TeamMembership + fields = ["member", "position", "jersey_number", "is_captain", "is_alternate_captain"] + widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})} + + def __init__(self, *args, club=None, team=None, season=None, **kwargs): + super().__init__(*args, **kwargs) + self.team = team + self.season = season + # Eligible to be added regardless of which season's roster is being edited + # (the team detail page's season switcher can be pointed at an older + # season): active this season or the next one, not lapsed/pending/cancelled + # or active only in some other season. + today = timezone.localdate() + eligible_seasons = [s for s in (Season.covering(club, today), Season.next_after(club, today)) if s is not None] + members = Member.objects.filter(member_of__club=club, member_of__season__in=eligible_seasons, member_of__status=ClubMembership.StatusChoices.ACTIVE).distinct() + if team is not None and season is not None: + # Already on this team's roster this season -- offering them again + # would just fail the unique_member_per_team_per_season constraint. + taken = TeamMembership.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True) + members = members.exclude(pk__in=taken) + self.fields["member"].queryset = members + self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=False) + + def clean(self): + cleaned = super().clean() + # team/season aren't form fields (the view sets them from the URL, not user + # input), so Django's automatic validate_unique() excludes both of them -- + # and with them, the whole unique_jersey_number_per_team_per_season check. + # Without this, a clashing jersey number reaches the database unrejected + # and surfaces as a raw IntegrityError instead of a form error. + jersey_number = cleaned.get("jersey_number") + if jersey_number is not None and self.team is not None and self.season is not None: + clash = TeamMembership.objects.filter(team=self.team, season=self.season, jersey_number=jersey_number).exclude(pk=self.instance.pk).exists() + if clash: + self.add_error("jersey_number", _("Another player on this team already has this jersey number this season.")) + return cleaned + + +class StaffAssignmentForm(forms.ModelForm): + """Assign/edit one staff assignment -- team and season come from the view, + same reasoning as TeamMembershipForm.""" + + class Meta: + model = StaffAssignment + fields = ["member", "position"] + widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})} + + def __init__(self, *args, club=None, team=None, season=None, **kwargs): + super().__init__(*args, **kwargs) + # See TeamMembershipForm for why current-or-next-season: eligible to be + # assigned regardless of which season's staff list is being edited. + today = timezone.localdate() + eligible_seasons = [s for s in (Season.covering(club, today), Season.next_after(club, today)) if s is not None] + members = Member.objects.filter(member_of__club=club, member_of__season__in=eligible_seasons, member_of__status=ClubMembership.StatusChoices.ACTIVE).distinct() + if team is not None and season is not None: + taken = StaffAssignment.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True) + members = members.exclude(pk__in=taken) + self.fields["member"].queryset = members + self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=True) + + +class PositionForm(forms.ModelForm): + class Meta: + model = Position + fields = ["name", "short_name", "ordering", "staff_position", "management_position"] + + def clean(self): + cleaned = super().clean() + # Mirrors Position's management_position_implies_staff_position check + # constraint -- caught here so it reads as a form error, not a 500. + if cleaned.get("management_position") and not cleaned.get("staff_position"): + self.add_error("management_position", _("A management position must also be a staff position.")) + return cleaned + + +class LocationForm(forms.ModelForm): + class Meta: + model = Location + fields = ["name", "address", "city", "zip_code", "country"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # CountryField's own widget (a lazily-translated Select) must stay the widget + # class -- only the searchable-select JS hooks are added on top of it, the + # same progressive enhancement TeamMembershipForm uses for its member field. + self.fields["country"].widget.attrs.update({"data-searchable": "true", "data-search-placeholder": _("Type a country to search...")}) + + +class OpponentForm(forms.ModelForm): + class Meta: + model = Opponent + fields = ["name", "logo"] + + +class SponsorForm(forms.ModelForm): + class Meta: + model = Sponsor + fields = ["name", "logo", "url", "start_date", "end_date"] + widgets = { + "start_date": forms.DateInput(attrs={"type": "date"}), + "end_date": forms.DateInput(attrs={"type": "date"}), + } + + def clean(self): + cleaned = super().clean() + # Mirrors Sponsor.clean() -- caught here too so it reads as a form + # error tied to the end_date field, not a raw ValidationError. + start_date, end_date = cleaned.get("start_date"), cleaned.get("end_date") + if start_date and end_date and end_date < start_date: + self.add_error("end_date", _("End date can't be before the start date.")) + return cleaned + + +class EventAudienceFormMixin: + """Shared club/user-scoped audience fields for EventForm and EventSeriesForm: + teams restricted to the ones the requester manages (all of them for an + admin), and a non-admin must pick at least one -- a team-less/club-wide + event (e.g. an AGM) has no team-manager claim to anchor it to, so that's + admin-only.""" + + def scope_audience_fields(self, club, user): + self.club = club + self.user = user + self.fields["teams"].queryset = Team.objects.filter(club=club) if is_club_admin(user, club) else teams_managed_by(user, club) + self.fields["location"].queryset = Location.objects.filter(club=club) + self.fields["opponent"].queryset = Opponent.objects.filter(club=club) + members = Member.objects.filter(member_of__club=club).distinct() + self.fields["invited_members"].queryset = members + self.fields["excluded_members"].queryset = members + + def clean_teams_requires_one_for_non_admins(self, cleaned): + teams = cleaned.get("teams") + if teams is not None and not teams.exists() and not is_club_admin(self.user, self.club): + self.add_error("teams", _("Select at least one of your teams, or ask an admin to create a club-wide event.")) + + +_AUDIENCE_WIDGETS = { + "teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")}), + "invited_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}), + "excluded_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}), +} + + +class EventForm(EventAudienceFormMixin, forms.ModelForm): + class Meta: + model = Event + fields = ["title", "kind", "teams", "invited_members", "excluded_members", "location", "opponent", "start", "end", "gathering", "deadline", "competition", "external_game_id", "score_for", "score_against", "is_live"] + widgets = { + "start": forms.DateTimeInput(attrs={"type": "datetime-local"}), + "end": forms.DateTimeInput(attrs={"type": "datetime-local"}), + "gathering": forms.DateTimeInput(attrs={"type": "datetime-local"}), + "deadline": forms.DateTimeInput(attrs={"type": "datetime-local"}), + "score_for": forms.NumberInput(attrs={"min": 0}), + "score_against": forms.NumberInput(attrs={"min": 0}), + **_AUDIENCE_WIDGETS, + } + + def __init__(self, *args, club=None, user=None, editing=False, **kwargs): + super().__init__(*args, **kwargs) + self.scope_audience_fields(club, user) + # Unlike the Django-admin form (events.admin.EventAdminForm), this dropdown + # isn't filtered by which competitions have their feature flag on for the + # club -- a manager should be able to pick any competition when scheduling a + # game; it's fetch_game_info (events.services.competitions) that gates the + # actual per-club fetch once a competition is set. + self.fields["competition"] = forms.ChoiceField( + choices=[("", "---------"), *[(competition.name, competition.name) for competition in Competition.objects.all()]], + required=False, + label=self.fields["competition"].label, + help_text=self.fields["competition"].help_text, + ) + if not editing: + # Score/live status don't exist yet for a game that's only just being + # scheduled -- offering them on the add form is just noise. Editing an + # existing game is the only time there's anything to record here. + del self.fields["score_for"] + del self.fields["score_against"] + del self.fields["is_live"] + + def clean(self): + cleaned = super().clean() + self.clean_teams_requires_one_for_non_admins(cleaned) + return cleaned + + +class RBIHFImportForm(forms.Form): + """Step 1 of importing a team's fixtures from RBIHF's own website -- see + events.services.rbihf_import. Only the URL and which of the club's own + teams it applies to; everything else is scraped.""" + + url = forms.CharField( + label=_("RBIHF team page URL"), + help_text=_("E.g. https://www.rbihf.be/league/team/4460"), + widget=forms.URLInput(attrs={"placeholder": "https://www.rbihf.be/league/team/4460"}), + ) + team = forms.ModelChoiceField(queryset=Team.objects.none(), label=_("Team"), help_text=_("Which of your teams this fixture list is for.")) + + def __init__(self, *args, club=None, **kwargs): + super().__init__(*args, **kwargs) + self.fields["team"].queryset = Team.objects.filter(club=club) + + def clean_url(self): + url = self.cleaned_data["url"].strip() + try: + extract_team_id(url) + except RBIHFImportError as error: + raise forms.ValidationError(str(error)) from error + return url + + +class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm): + """A friendly weekly/monthly recurrence picker on top of EventSeries' raw + RRULE (see management.recurrence_ui) -- covers the common case (every N + weeks/months) with an advanced raw-RRULE field as an escape hatch for + anything else.""" + + frequency = forms.ChoiceField(choices=FREQUENCY_CHOICES, initial="weekly", label=_("Repeats")) + interval = forms.IntegerField(min_value=1, initial=1, label=_("Every"), help_text=_("E.g. 2 for every other week/month.")) + # A plain multi-select, not CheckboxSelectMultiple: its widget_type ("checkboxselectmultiple") + # isn't one form_field's ui.py recognises, which would silently render a broken + # plain text input instead -- see the "lazyselect" fix for the same class of bug. + weekdays = forms.MultipleChoiceField(choices=WEEKDAY_CHOICES, required=False, label=_("On"), widget=forms.SelectMultiple(attrs={"data-searchable": "true"})) + duration_hours = forms.IntegerField(min_value=0, required=False, label=_("Duration (hours)")) + duration_minutes = forms.IntegerField(min_value=0, max_value=59, required=False, label=_("Duration (minutes)")) + gathering_minutes_before = forms.IntegerField(min_value=0, required=False, label=_("Gather (minutes before)")) + deadline_minutes_before = forms.IntegerField(min_value=0, required=False, label=_("Registration deadline (minutes before)")) + advanced_rrule = forms.CharField(required=False, label=_("Advanced: raw recurrence rule"), help_text=_("Overrides the fields above if filled in. RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE.")) + + class Meta: + model = EventSeries + fields = ["title", "kind", "dtstart", "until", "teams", "invited_members", "excluded_members", "location", "opponent"] + widgets = { + "dtstart": forms.DateTimeInput(attrs={"type": "datetime-local"}), + "until": forms.DateTimeInput(attrs={"type": "datetime-local"}), + **_AUDIENCE_WIDGETS, + } + + def __init__(self, *args, club=None, user=None, **kwargs): + super().__init__(*args, **kwargs) + self.scope_audience_fields(club, user) + + if self.instance.pk: + parsed = parse_rrule(self.instance.rrule) + if parsed is not None: + self.fields["frequency"].initial = parsed["frequency"] + self.fields["interval"].initial = parsed["interval"] + self.fields["weekdays"].initial = parsed["weekdays"] + else: + # Not one of our two shapes (hand-written/imported) -- show it + # verbatim in the advanced field rather than guessing at it. + self.fields["advanced_rrule"].initial = self.instance.rrule + + if self.instance.duration is not None: + total_minutes = int(self.instance.duration.total_seconds() // 60) + self.fields["duration_hours"].initial = total_minutes // 60 + self.fields["duration_minutes"].initial = total_minutes % 60 + if self.instance.gathering_offset is not None: + self.fields["gathering_minutes_before"].initial = int(self.instance.gathering_offset.total_seconds() // 60) + if self.instance.deadline_offset is not None: + self.fields["deadline_minutes_before"].initial = int(self.instance.deadline_offset.total_seconds() // 60) + + def clean(self): + cleaned = super().clean() + self.clean_teams_requires_one_for_non_admins(cleaned) + + if cleaned.get("advanced_rrule"): + return cleaned # the advanced field wins outright; nothing else to check + + if cleaned.get("frequency") == "weekly" and not cleaned.get("weekdays"): + self.add_error("weekdays", _("Pick at least one day of the week.")) + return cleaned + + def save(self, commit=True): + instance = super().save(commit=False) + cleaned = self.cleaned_data + + advanced = (cleaned.get("advanced_rrule") or "").strip() + instance.rrule = advanced or build_rrule(cleaned["frequency"], cleaned["interval"], cleaned.get("weekdays")) + + hours, minutes = cleaned.get("duration_hours") or 0, cleaned.get("duration_minutes") or 0 + instance.duration = datetime.timedelta(hours=hours, minutes=minutes) if (hours or minutes) else None + + gathering_minutes = cleaned.get("gathering_minutes_before") + instance.gathering_offset = datetime.timedelta(minutes=gathering_minutes) if gathering_minutes else None + + deadline_minutes = cleaned.get("deadline_minutes_before") + instance.deadline_offset = datetime.timedelta(minutes=deadline_minutes) if deadline_minutes else None + + if commit: + instance.save() + self.save_m2m() + return instance + + +class ClubRoleAssignForm(forms.ModelForm): + """Grant a club-wide role to a member already affiliated with this club.""" + + class Meta: + model = ClubRole + fields = ["member", "role"] + widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})} + + def __init__(self, *args, club=None, **kwargs): + super().__init__(*args, **kwargs) + # Never list members of other clubs -- this isn't a platform-wide picker. + self.fields["member"].queryset = Member.objects.filter(member_of__club=club).distinct() + + +class FamilyCreateForm(forms.Form): + """One new family in one go: a parent (who gets a login) and a child (who + doesn't). See members.services.family.register_family.""" + + parent_first_name = forms.CharField(label=_("Parent first name")) + parent_last_name = forms.CharField(label=_("Parent last name")) + parent_email = forms.EmailField(label=_("Parent email"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link.")) + + child_first_name = forms.CharField(label=_("Child first name")) + child_last_name = forms.CharField(label=_("Child last name")) + child_date_of_birth = forms.DateField(label=_("Child date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"})) + + +class AddChildForm(forms.Form): + """A family that needs one more child registered -- see + members.services.family.add_child_to_family.""" + + first_name = forms.CharField(label=_("First name")) + last_name = forms.CharField(label=_("Last name")) + date_of_birth = forms.DateField(label=_("Date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"})) + + +class AddParentForm(forms.Form): + """A family that needs one more parent/guardian registered -- see + members.services.family.add_parent_to_family.""" + + email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link.")) + first_name = forms.CharField(label=_("First name"), required=False) + last_name = forms.CharField(label=_("Last name"), required=False) + + def clean(self): + cleaned = super().clean() + email = cleaned.get("email") + + # Only a brand-new person needs a name; an existing member already has one. + if email and find_member_by_email(email) is None: + for field in ("first_name", "last_name"): + if not cleaned.get(field): + self.add_error(field, _("Required: this email has no account yet.")) + + return cleaned + + +class AttachToFamilyForm(forms.Form): + """Link a standalone member into a family -- a new one, or an existing one they + turn out to belong to. See members.services.family.attach_to_family.""" + + role = forms.ChoiceField(label=_("Role"), choices=FamilyMembership.FamilyRole.choices) + family = forms.ModelChoiceField(label=_("Family"), queryset=Family.objects.none(), required=False, empty_label=_("— start a new family —")) + + def __init__(self, *args, club=None, member=None, **kwargs): + super().__init__(*args, **kwargs) + # Same scoping query as management.views.families_of_club -- inlined rather + # than imported, since that function lives in views.py, which imports this + # module (a module-level import back here would be circular). + queryset = Family.objects.filter(memberships__member__member_of__club=club).distinct() + if member is not None: + # Already a member of it -- offering it again would be a no-op re-add. + queryset = queryset.exclude(memberships__member=member) + self.fields["family"].queryset = queryset + + +class MemberImportUploadForm(forms.Form): + """The mass-upload entry point -- one .xlsx file, built from the downloadable + template. See management.bulk_import.read_member_import_workbook.""" + + file = forms.FileField(label=_("Excel file"), help_text=_("Use the downloaded template — one row per member.")) + + +class GrantLoginForm(forms.Form): + """A login-less family member (a child, typically) getting their own account -- + see members.services.family.grant_login. Pre-filled from the member's contact + email where one is already on file; still editable, and required either way.""" + + email = forms.EmailField(label=_("Email"), help_text=_("They'll set a password via the reset link the first time they sign in.")) + + def clean_email(self): + email = self.cleaned_data["email"] + if User.objects.filter(email__iexact=email).exists(): + raise forms.ValidationError(_("This email is already in use.")) + return email + + +class ClubMembershipForm(forms.ModelForm): + """This season's standing -- shown and edited right on the member's own page, + since a Member has no club of its own without one. fee_amount is the only + money field here -- amount_paid is exclusively written by club.services.fees, + never hand-edited.""" + + class Meta: + model = ClubMembership + fields = ["license", "status", "fee_status", "fee_amount"] + + +class NewsForm(forms.ModelForm): + """Title/teams/visibility/body only -- status and published_at are never + directly editable, only through the publish/unpublish actions.""" + + class Meta: + model = News + fields = ["title", "teams", "visibility", "body"] + widgets = { + "teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type to filter teams...")}), + "body": forms.Textarea(attrs={"rows": 8}), + } + + def __init__(self, *args, club=None, **kwargs): + super().__init__(*args, **kwargs) + self.fields["teams"].queryset = Team.objects.filter(club=club) + + +class MultipleFileInput(forms.ClearableFileInput): + allow_multiple_selected = True + + +class MultipleFileField(forms.FileField): + """Django's own documented recipe for a multi-file upload field: the plain + FileField only ever picks up one of several selected files, so clean() has + to iterate the list itself instead.""" + + def __init__(self, *args, **kwargs): + kwargs.setdefault("widget", MultipleFileInput(attrs={"multiple": True})) + super().__init__(*args, **kwargs) + + def clean(self, data, initial=None): + single_file_clean = super().clean + if isinstance(data, (list, tuple)): + return [single_file_clean(item, initial) for item in data] + return [single_file_clean(data, initial)] if data else [] + + +class NewsPhotoUploadForm(forms.Form): + images = MultipleFileField(label=_("Photos")) + + +class NewsPublishForm(forms.Form): + published_at = forms.DateTimeField( + label=_("Publish date"), + initial=timezone.now, + widget=forms.DateTimeInput(attrs={"type": "datetime-local"}), + help_text=_("Leave as now to publish immediately, or pick a future date/time to schedule it."), + ) + + +class RecordFeePaymentForm(forms.Form): + """Money received against one membership's fee -- see club.services.fees.record_payment. + Reusable for any amount, partial or the exact remaining balance; "Mark fully + paid" (management.views.MembershipMarkFullyPaidView) skips this form entirely + and settles the balance directly in one click.""" + + amount = forms.DecimalField(label=_("Amount"), max_digits=10, decimal_places=2, min_value=Decimal("0.01")) + method = forms.ChoiceField(label=_("Method"), choices=FeePayment.Method.choices) + reference = forms.CharField(label=_("Reference"), required=False, help_text=_("Bank reference, transaction id — whatever lets you find this again.")) + note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2})) diff --git a/management/pdf.py b/management/pdf.py new file mode 100644 index 0000000..32624c4 --- /dev/null +++ b/management/pdf.py @@ -0,0 +1,30 @@ +"""HTML-to-PDF rendering for exports (currently just the memberships list). + +Same lazy-import shape as billing.services.invoices.render_pdf -- WeasyPrint 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, so the import happens here, not at +module scope, and only fails when someone actually asks for a PDF. Kept separate +from billing's version rather than shared: the two exports have nothing else in +common, and sharing would make one app depend on the other's error type for no +real benefit. +""" + +from django.template.loader import render_to_string + + +class PDFExportError(Exception): + """Raised when WeasyPrint's native libraries aren't available.""" + + +def render_pdf(html: str) -> bytes: + try: + from weasyprint import HTML + except (ImportError, OSError) as error: + raise PDFExportError("PDF rendering needs the native pango/cairo libraries (on macOS: brew install pango).") from error + + return HTML(string=html).write_pdf() + + +def membership_list_pdf(context: dict) -> bytes: + html = render_to_string("management/membership_list_pdf.html", context) + return render_pdf(html) diff --git a/management/recurrence_ui.py b/management/recurrence_ui.py new file mode 100644 index 0000000..b73723a --- /dev/null +++ b/management/recurrence_ui.py @@ -0,0 +1,84 @@ +"""A friendly weekly/monthly recurrence picker on top of EventSeries.rrule (a raw +RFC 5545 string, e.g. "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE") -- covers the common +club-scheduling case (every N weeks on these weekdays, or every N months on the +same day of month) without building a full RRULE UI. + +dateutil's rrule recurs on dtstart's own day-of-month for FREQ=MONTHLY with no +BYMONTHDAY needed, which is what keeps monthly this cheap to support alongside +weekly. + +Anything that isn't exactly one of the two shapes build_rrule() produces -- +hand-written, imported, or from a future frequency this module doesn't cover -- +is left alone: parse_rrule() returns None, and EventSeriesForm falls back to +showing the raw string in an "Advanced" field instead of the friendly picker. +""" + +import re + +from django.utils.translation import gettext_lazy as _ + +WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] + +WEEKDAY_LABELS = { + "MO": _("Mon"), + "TU": _("Tue"), + "WE": _("Wed"), + "TH": _("Thu"), + "FR": _("Fri"), + "SA": _("Sat"), + "SU": _("Sun"), +} + +WEEKDAY_CHOICES = [(code, WEEKDAY_LABELS[code]) for code in WEEKDAY_CODES] + +FREQUENCY_CHOICES = [ + ("weekly", _("Weekly")), + ("monthly", _("Monthly")), +] + +_WEEKLY_RE = re.compile(r"^FREQ=WEEKLY;INTERVAL=(\d+);BYDAY=([A-Z,]+)$") +_MONTHLY_RE = re.compile(r"^FREQ=MONTHLY;INTERVAL=(\d+)$") + + +def build_rrule(frequency, interval, weekdays=None): + """Compose an RRULE string from the friendly fields. ``weekdays`` is a list + of two-letter codes (e.g. ["MO", "WE"]), required for "weekly", ignored for + "monthly".""" + interval = interval or 1 + if frequency == "weekly": + # Preserve WEEKDAY_CODES order regardless of the order the caller passed + # them in, so the same pattern always serializes to the same string. + ordered = [code for code in WEEKDAY_CODES if code in (weekdays or [])] + return f"FREQ=WEEKLY;INTERVAL={interval};BYDAY={','.join(ordered)}" + if frequency == "monthly": + return f"FREQ=MONTHLY;INTERVAL={interval}" + raise ValueError(f"Unknown frequency: {frequency!r}") + + +def parse_rrule(rrule): + """The friendly fields that produced ``rrule``, or None if it doesn't match + one of build_rrule()'s two shapes.""" + if match := _WEEKLY_RE.match(rrule): + interval, days = match.groups() + return {"frequency": "weekly", "interval": int(interval), "weekdays": days.split(",")} + if match := _MONTHLY_RE.match(rrule): + return {"frequency": "monthly", "interval": int(match.group(1)), "weekdays": []} + return None + + +def describe_rrule(rrule): + """A human-readable summary for list/detail pages, e.g. "Every week on Mon, + Wed" or "Every 2 months" -- or the raw string verbatim if it isn't one of + ours (an imported/hand-written RRULE).""" + parsed = parse_rrule(rrule) + if parsed is None: + return rrule + + interval = parsed["interval"] + if parsed["frequency"] == "weekly": + days = ", ".join(str(WEEKDAY_LABELS[code]) for code in parsed["weekdays"] if code in WEEKDAY_LABELS) + if not days: + return _("Every week") if interval == 1 else _("Every %(n)d weeks") % {"n": interval} + return _("Every week on %(days)s") % {"days": days} if interval == 1 else _("Every %(n)d weeks on %(days)s") % {"n": interval, "days": days} + + return _("Every month") if interval == 1 else _("Every %(n)d months") % {"n": interval} diff --git a/management/templates/management/_family_members_table.html b/management/templates/management/_family_members_table.html new file mode 100644 index 0000000..efc7f7e --- /dev/null +++ b/management/templates/management/_family_members_table.html @@ -0,0 +1,92 @@ +{% load lucide ui i18n %} + +{% comment %} +Shared by family_detail.html and member_detail.html's own Family panel -- one +table design for "everyone in this family", included with `group` (a dict from +management.views.group_by_family: family/guardians/children/others/all) and +`family_role_choices` (FamilyMembership.FamilyRole.choices, for the role dropdown). +`next_url`, if passed, is where a role change redirects back to -- member_detail.html +passes its own URL so admins land back on the member they were viewing; family_detail.html +leaves it unset, since staying on the family page is already the right place there. +{% endcomment %} +<div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>{% trans "Last name" %}</th> + <th>{% trans "First name" %}</th> + <th>{% trans "Email" %}</th> + <th>{% trans "Type" %}</th> + <th></th> + </tr> + </thead> + <tbody> + {% for person in group.all %} + <tr> + <td><a class="link link-hover" href="{% url 'management:member_detail' person.pk %}">{{ person.last_name }}</a></td> + <td>{{ person.first_name }}</td> + <td>{{ person.contact_email|default:"-" }}</td> + <td> + {% if is_club_admin %} + <form method="post" action="{% url 'management:family_membership_role_update' group.family.pk person.pk %}"> + {% csrf_token %} + {% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %} + <select name="role" class="select select-bordered select-sm" onchange="this.form.requestSubmit()" aria-label="{% trans 'Role in family' %}"> + {% for value, label in family_role_choices %} + <option value="{{ value }}" {% if value == person.role_in_family %}selected{% endif %}>{{ label|capfirst }}</option> + {% endfor %} + </select> + </form> + {% else %} + <span class="badge badge-sm badge-ghost">{{ person.role_in_family_display|capfirst }}</span> + {% endif %} + </td> + <td class="text-right"> + {% if is_club_admin %} + <div class="flex justify-end gap-1"> + {% if person.grant_login_form %} + <button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('grant_login_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Grant login' %}"> + {% lucide "key-round" size=14 %} {% trans "Grant login" %} + </button> + {% endif %} + <a class="btn btn-outline btn-sm" href="{% url 'management:member_detail' person.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a> + <button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('remove_family_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Remove from family' %}"> + {% lucide "user-x" size=14 %} {% trans "Remove" %} + </button> + <button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('member_delete_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Delete' %}"> + {% lucide "trash-2" size=14 %} {% trans "Delete" %} + </button> + </div> + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> +</div> + +{% if is_club_admin %} +{% trans "Delete member" as delete_member_title %} +{% trans "Delete" as delete_label %} +{% trans "Remove from family" as remove_from_family_title %} +{% trans "Remove" as remove_label %} +{% trans "Grant login" as grant_login_title %} +{% trans "Grant" as grant_login_submit_label %} +{% trans "They'll be able to sign in with this email once you confirm." as grant_login_blurb %} +{% with family_pk_str=group.family.pk|stringformat:"s" %} +{% for person in group.all %} +{% with person_pk_str=person.pk|stringformat:"s" %} +{% url 'management:member_delete' person.pk as member_delete_url %} +{% url 'management:member_detach_family' person.pk group.family.pk as detach_family_url %} +{% url 'management:member_grant_login' person.pk as grant_login_url %} +{% blocktrans with full_name=person.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %} +{% blocktrans with full_name=person.get_full_name family_name=group.family asvar detach_family_body %}Remove {{ full_name }} from family {{ family_name }}? They become a standalone member - nothing else about them changes.{% endblocktrans %} +{% include "controlpanel/_confirm_modal.html" with modal_id="member_delete_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %} +{% include "controlpanel/_confirm_modal.html" with modal_id="remove_family_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=remove_from_family_title body=detach_family_body action_url=detach_family_url submit_label=remove_label submit_icon="user-x" %} +{% if person.grant_login_form %} +{% include "controlpanel/_modal_form.html" with modal_id="grant_login_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=grant_login_title form=person.grant_login_form action_url=grant_login_url submit_label=grant_login_submit_label submit_icon="key-round" blurb=grant_login_blurb %} +{% endif %} +{% endwith %} +{% endfor %} +{% endwith %} +{% endif %} diff --git a/management/templates/management/_generic_list.html b/management/templates/management/_generic_list.html new file mode 100644 index 0000000..044a665 --- /dev/null +++ b/management/templates/management/_generic_list.html @@ -0,0 +1,33 @@ +{% extends "management/base.html" %} +{% load i18n %} + +{% comment %} + Shared placeholder for every entity that doesn't have its own list template yet + (see management.views.StubListMixin) -- a plain one-column table of __str__, just + enough to prove the query scoping and permission gate work. Not meant to stay this + bare once each section gets its own page. +{% endcomment %} + +{% block heading %}{{ page_title }}{% endblock heading %} + +{% block panel %} + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <div class="overflow-x-auto"> + <table class="table"> + <tbody> + {% for object in object_list %} + <tr> + <td>{{ object }}</td> + </tr> + {% empty %} + <tr> + <td class="text-center opacity-60">{% trans "Nothing here yet." %}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> +{% endblock panel %} diff --git a/management/templates/management/_nav_items.html b/management/templates/management/_nav_items.html new file mode 100644 index 0000000..c720998 --- /dev/null +++ b/management/templates/management/_nav_items.html @@ -0,0 +1,54 @@ +{% load i18n lucide %} + +{% comment %} + The management nav, in one place: the sidebar renders it on a wide screen and the + collapsed menu renders it on a narrow one. Admin-only sections are hidden here for + plain staff -- the views are gated regardless (ClubAdminRequiredMixin), this is + just so the nav never shows a link they can't follow. + + `nav` (management.context_processors.active_nav_section) is the current page's + section, derived from the resolved URL name -- `menu-active` is daisyUI's active + state, same convention as controlpanel/templates/controlpanel/_nav_items.html. +{% endcomment %} +<li> + <a class="{% if nav == 'home' %}menu-active{% endif %}" href="{% url 'management:home' %}">{% lucide "layout-dashboard" size=16 %} {% trans "Dashboard" %}</a> +</li> + +<li class="menu-title">{% trans "People" %}</li> +<li><a class="{% if nav == 'member_list' %}menu-active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=16 %} {% trans "Members" %}</a></li> +{% if is_club_admin %} + <li><a class="{% if nav == 'membership_list' %}menu-active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=16 %} {% trans "Memberships" %}</a></li> + <li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li> +{% endif %} + +<li class="menu-title">{% trans "Teams" %}</li> +<li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li> +<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li> + +<li class="menu-title">{% trans "News" %}</li> +<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li> + +<li class="menu-title">{% trans "Calendar" %}</li> +<li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li> +{% if has_management_position %} + <li><a class="{% if nav == 'location_list' %}menu-active{% endif %}" href="{% url 'management:location_list' %}">{% lucide "map-pin" size=16 %} {% trans "Locations" %}</a></li> + <li><a class="{% if nav == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</a></li> +{% endif %} + +{% if is_club_admin %} + <li class="menu-title">{% trans "Sponsors" %}</li> + <li><a class="{% if nav == 'sponsor_list' %}menu-active{% endif %}" href="{% url 'management:sponsor_list' %}">{% lucide "handshake" size=16 %} {% trans "Sponsors" %}</a></li> +{% endif %} + +{% if is_club_admin and shop_enabled %} + <li class="menu-title">{% trans "Shop" %}</li> + <li><a class="{% if nav == 'product_list' %}menu-active{% endif %}" href="{% url 'management:product_list' %}">{% lucide "package" size=16 %} {% trans "Products" %}</a></li> + <li><a class="{% if nav == 'order_list' %}menu-active{% endif %}" href="{% url 'management:order_list' %}">{% lucide "shopping-cart" size=16 %} {% trans "Orders" %}</a></li> + <li><a class="{% if nav == 'discount_list' %}menu-active{% endif %}" href="{% url 'management:discount_list' %}">{% lucide "percent" size=16 %} {% trans "Discounts" %}</a></li> + <li><a class="{% if nav == 'invoice_list' %}menu-active{% endif %}" href="{% url 'management:invoice_list' %}">{% lucide "receipt" size=16 %} {% trans "Invoices" %}</a></li> +{% endif %} + +{% if is_club_admin and forms_enabled %} + <li class="menu-title">{% trans "Forms" %}</li> + <li><a class="{% if nav == 'form_list' %}menu-active{% endif %}" href="{% url 'management:form_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Forms" %}</a></li> +{% endif %} diff --git a/management/templates/management/base.html b/management/templates/management/base.html new file mode 100644 index 0000000..9560dd1 --- /dev/null +++ b/management/templates/management/base.html @@ -0,0 +1,42 @@ +{% extends "_club_base.html" %} +{% load i18n lucide %} + +{% comment %} + The club-staff shell: team managers, coaches and admins only (never parents or + players -- see club.mixins.ClubStaffRequiredMixin). Mirrors controlpanel/base.html's + block structure, extending the club's own skin instead of the platform's. +{% endcomment %} + +{% block head_title %} + {% block section_title %}{% trans "Management" %}{% endblock section_title %} +{% endblock head_title %} + +{% block menu %} + <aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block"> + <ul class="menu w-full gap-1 p-3 mt-4"> + {% include "management/_nav_items.html" %} + </ul> + </aside> +{% endblock menu %} + +{% block main %} + <div class="mb-6 flex flex-wrap flex-row items-center justify-between gap-3"> + <div class="flex flex-col gap-2 grow"> + <h1 class="text-3xl font-bold"> + {% block heading %}{% trans "Management" %}{% endblock heading %} + </h1> + <span class="text-sm text-base-content/50">{% block subheading %}{% endblock subheading %}</span> + </div> + + <div class="flex gap-2"> + {% block actions %}{% endblock actions %} + </div> + </div> + + {# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #} + <ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden"> + {% include "management/_nav_items.html" %} + </ul> + + {% block panel %}{% endblock panel %} +{% endblock main %} diff --git a/management/templates/management/event_detail.html b/management/templates/management/event_detail.html new file mode 100644 index 0000000..d1fa466 --- /dev/null +++ b/management/templates/management/event_detail.html @@ -0,0 +1,215 @@ +{% extends "management/base.html" %} +{% load i18n lucide %} + +{% block heading %}{{ event.title }}{% endblock heading %} +{% block subheading %}{{ event.get_kind_display }}{% endblock subheading %} + +{% block actions %} + {% if can_manage %} + <a class="btn btn-outline gap-2" href="{% url 'management:event_update' event.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a> + {% if event.series_id and not event.detached %} + <form method="post" action="{% url 'management:event_detach' event.pk %}"> + {% csrf_token %} + <button class="btn btn-outline gap-2" type="submit">{% lucide "unlink" size=16 %} {% trans "Detach from series" %}</button> + </form> + {% endif %} + <button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('event_delete_modal').showModal()"> + {% lucide "trash-2" size=16 %} + {% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete" %}{% endif %} + </button> + {% endif %} +{% endblock actions %} + +{% block panel %} + {% if event.series_id %} + <div class="alert alert-info mb-6"> + {% lucide "repeat" size=20 %} + <span> + {% blocktrans with series=event.series %}Part of the recurring series “{{ series }}”.{% endblocktrans %} + <a class="link" href="{% url 'management:event_series_detail' event.series_id %}">{% trans "View series" %}</a> + {% if event.detached %}<span class="badge badge-sm badge-neutral ml-2">{% trans "Detached" %}</span>{% endif %} + </span> + </div> + {% endif %} + + <div class="mb-6 grid gap-4 lg:grid-cols-2"> + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Details" %}</h2> + <dl class="divide-y divide-base-200"> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Start" %}</dt> + <dd>{{ event.start|date:"j M Y H:i" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "End" %}</dt> + <dd>{{ event.end|date:"j M Y H:i"|default:"—" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Gathering" %}</dt> + <dd>{{ event.gathering|date:"j M Y H:i"|default:"—" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Registration deadline" %}</dt> + <dd>{{ event.deadline|date:"j M Y H:i"|default:"—" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Location" %}</dt> + <dd>{{ event.location|default:"—" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Opponent" %}</dt> + <dd>{{ event.opponent|default:"—" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Teams" %}</dt> + <dd>{% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd> + </div> + </dl> + </div> + </div> + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <div class="flex items-center justify-between"> + <h2 class="card-title text-base">{% lucide "user-check" size=18 %} {% trans "RSVPs" %}</h2> + {% if has_attendance_rows %} + <button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('rsvp_modal').showModal()"> + {% lucide "list" size=14 %} {% trans "View responses" %} + </button> + {% endif %} + </div> + <dl class="divide-y divide-base-200"> + {% for group in attendance_groups %} + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{{ group.label }}</dt> + <dd class="font-semibold tabular-nums font-mono">{{ group.rows|length }}</dd> + </div> + {% endfor %} + </dl> + {% if not has_attendance_rows %} + <p class="text-sm opacity-60">{% trans "No one invited yet." %}</p> + {% endif %} + </div> + </div> + </div> + + {% if event.kind == "game" %} + <div class="mb-6 card bg-base-100 shadow"> + <div class="card-body"> + <div class="flex items-center justify-between"> + <h2 class="card-title text-base"> + {% lucide "trophy" size=18 %} {% trans "Game" %} + {% if event.is_live %}<span class="badge badge-error gap-1 animate-pulse">{% lucide "circle" size=10 %} {% trans "Live" %}</span>{% endif %} + </h2> + {% if can_manage and event.competition %} + <form method="post" action="{% url 'management:event_fetch_game_info' event.pk %}"> + {% csrf_token %} + <button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "refresh-cw" size=14 %} {% trans "Fetch new game info" %}</button> + </form> + {% endif %} + </div> + <div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> + <div> + <div class="text-sm opacity-70">{% trans "Score" %}</div> + <div class="text-sm font-semibold tabular-nums font-mono"> + {% if event.score_for is not None and event.score_against is not None %}{{ event.score_for }} - {{ event.score_against }}{% else %}—{% endif %} + </div> + </div> + <div> + <div class="text-sm opacity-70">{% trans "Competition" %}</div> + <div class="text-sm">{{ event.competition|default:"—" }}</div> + </div> + <div> + <div class="text-sm opacity-70">{% trans "External game ID" %}</div> + <div class="font-mono text-sm">{{ event.external_game_id|default:"—" }}</div> + </div> + </div> + </div> + </div> + {% endif %} +{% endblock panel %} + +{% block extra_body %} + {% if can_manage %} + <dialog id="event_delete_modal" class="modal"> + <div class="modal-box"> + <h3 class="text-lg font-bold"> + {% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete event" %}{% endif %} + </h3> + <form method="post" action="{% url 'management:event_delete' event.pk %}" id="event_delete_form"> + {% csrf_token %} + {% if event.series_id %} + <p class="py-2 text-sm opacity-70">{% trans "Removes this occurrence from the schedule. It won't be regenerated." %}</p> + <label class="label cursor-pointer justify-start gap-2"> + <input type="checkbox" name="keep_record" class="checkbox"> + <span class="label-text">{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span> + </label> + {% else %} + <p class="py-2 text-sm opacity-70">{% trans "This cannot be undone." %}</p> + {% endif %} + </form> + <div class="modal-action"> + <form method="dialog"> + <button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button> + </form> + <button class="btn btn-error gap-2" type="submit" form="event_delete_form">{% lucide "trash-2" size=16 %} {% trans "Confirm" %}</button> + </div> + </div> + <form method="dialog" class="modal-backdrop"> + <button>close</button> + </form> + </dialog> + {% endif %} + + {% if has_attendance_rows %} + <dialog id="rsvp_modal" class="modal"> + <div class="modal-box max-w-2xl"> + <h3 class="text-lg font-bold">{% trans "Who responded" %}</h3> + <div class="mt-2 space-y-2"> + {% for group in attendance_groups %} + {% if group.rows %} + <details class="collapse collapse-arrow border border-base-300 bg-base-100"> + <summary class="collapse-title text-sm font-medium"> + <span class="badge badge-sm + {% if group.value == "present" %}badge-success + {% elif group.value == "absent" %}badge-error + {% elif group.value == "excused" %}badge-warning + {% elif group.value == "selected" %}badge-info + {% elif group.value == "maybe" %}badge-warning badge-outline + {% elif group.value == "not_selected" %}badge-neutral + {% else %}badge-outline{% endif %}"> + {{ group.label }} + </span> + <span class="opacity-60">({{ group.rows|length }})</span> + </summary> + <div class="collapse-content"> + <div class="overflow-x-auto"> + <table class="table table-sm"> + <tbody> + {% for row in group.rows %} + <tr> + <td>{{ row.member }}</td> + <td class="text-sm opacity-70">{{ row.note|default:"—" }}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </details> + {% endif %} + {% endfor %} + </div> + <div class="modal-action"> + <form method="dialog"> + <button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Close" %}</button> + </form> + </div> + </div> + <form method="dialog" class="modal-backdrop"> + <button>close</button> + </form> + </dialog> + {% endif %} +{% endblock extra_body %} diff --git a/management/templates/management/event_form.html b/management/templates/management/event_form.html new file mode 100644 index 0000000..063303a --- /dev/null +++ b/management/templates/management/event_form.html @@ -0,0 +1,96 @@ +{% extends "management/base.html" %} +{% load i18n lucide static ui %} + +{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New event" %}{% endif %}{% endblock heading %} + +{% block panel %} + <div class="card w-full bg-base-100 shadow"> + <div class="card-body"> + <form method="post"> + {% csrf_token %} + + {% for error in form.non_field_errors %} + <div class="alert alert-error my-2"> + <span>{{ error }}</span> + </div> + {% endfor %} + + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + {% form_field form.title %} + {% form_field form.kind %} + </div> + + <div class="divider"></div> + <h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3> + <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> + {% form_field form.teams %} + {% form_field form.invited_members %} + {% form_field form.excluded_members %} + </div> + + <div class="divider"></div> + <h3 class="text-lg font-semibold mb-3">{% trans "Where" %}</h3> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + {% form_field form.location %} + <div class="game-only"> + {% form_field form.opponent %} + </div> + </div> + + <div class="divider"></div> + <h3 class="text-lg font-semibold mb-3">{% trans "When" %}</h3> + <div class="grid grid-cols-1 md:grid-cols-4 gap-4"> + {% form_field form.start %} + {% form_field form.end %} + {% form_field form.gathering %} + {% form_field form.deadline %} + </div> + + <div id="game-details-section" class="game-only"> + <div class="divider"></div> + <h3 class="text-lg font-semibold mb-3">{% trans "Game" %}</h3> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + {% form_field form.competition %} + {% form_field form.external_game_id %} + </div> + {% if update_view %} + {% comment %} + Score/live status only exist to record once a game has + actually been created -- the add form leaves them out + entirely (see EventForm's editing=False path). + {% endcomment %} + <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> + {% form_field form.score_for %} + {% form_field form.score_against %} + {% form_field form.is_live %} + </div> + {% endif %} + </div> + + <div class="card-actions justify-start pt-2 mt-2"> + <a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:event_detail" object.pk %}{% else %}{% url "management:event_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a> + <button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button> + </div> + </form> + </div> + </div> +{% endblock panel %} + +{% block extra_body %} + <script src="{% static 'js/searchable-select.js' %}"></script> + <script> + (() => { + // Progressive enhancement only -- the fields stay in the form and post + // normally either way, this just hides them when they don't apply so a + // training/social/etc. event isn't cluttered with opponent/score/competition + // inputs. Anything gated on "only makes sense for a game" carries this class. + const kindField = document.getElementById("id_kind"); + const gameOnlyElements = document.querySelectorAll(".game-only"); + if (!kindField || !gameOnlyElements.length) return; + + const sync = () => gameOnlyElements.forEach((element) => element.classList.toggle("hidden", kindField.value !== "game")); + kindField.addEventListener("change", sync); + sync(); + })(); + </script> +{% endblock extra_body %} diff --git a/management/templates/management/event_list.html b/management/templates/management/event_list.html new file mode 100644 index 0000000..9a338f3 --- /dev/null +++ b/management/templates/management/event_list.html @@ -0,0 +1,118 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% trans "Events" %}{% endblock heading %} + +{% block actions %} + {% if is_club_admin and rbihf_enabled %} + <a class="btn btn-outline gap-2" href="{% url 'management:rbihf_import' %}">{% lucide "download" size=16 %} {% trans "Import from RBIHF" %}</a> + {% endif %} + {% if can_create %} + <a class="btn btn-outline gap-2" href="{% url 'management:event_series_create' %}">{% lucide "repeat" size=16 %} {% trans "New series" %}</a> + <a class="btn btn-outline gap-2" href="{% url 'management:event_create' %}">{% lucide "plus" size=16 %} {% trans "New event" %}</a> + {% endif %} +{% endblock actions %} + +{% block panel %} + <form method="get" class="mb-4"> + <div class="flex flex-row flex-wrap items-center gap-2"> + <select name="season" class="select select-bordered"> + {% for season in seasons %} + <option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option> + {% endfor %} + </select> + <select name="kind" class="select select-bordered"> + <option value="">{% trans "All kinds" %}</option> + {% for value, label in event_kinds %} + <option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option> + {% endfor %} + </select> + <label class="label cursor-pointer gap-2"> + <input type="checkbox" name="show_past" value="1" class="checkbox" {% if show_past %}checked{% endif %} onchange="this.form.submit()"> + <span class="label-text">{% trans "Show past events" %}</span> + </label> + <button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "View" %}</button> + </div> + </form> + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>{% trans "Title" %}</th> + <th>{% trans "Kind" %}</th> + <th>{% trans "When" %}</th> + <th>{% trans "Teams" %}</th> + <th>{% trans "Location" %}</th> + <th></th> + </tr> + </thead> + <tbody> + {% for event in events %} + <tr> + <td class="flex flex-row items-center gap-2"> + <a class="link link-hover" href="{% url 'management:event_detail' event.pk %}">{{ event.title }}</a> + {% if event.series_id %}<span class="tooltip" data-tip="{% trans 'Part of a series' %}">{% lucide "repeat" size=12 %}</span>{% endif %} + </td> + <td>{{ event.get_kind_display }}</td> + <td>{{ event.start|date:"j M Y H:i" }}</td> + <td>{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td> + <td>{{ event.location.name|default:"—" }}</td> + <td class="text-right"> + {% if event.can_manage %} + <div class="flex justify-end gap-1"> + <a class="btn btn-outline btn-sm" href="{% url 'management:event_detail' event.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a> + <button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ event.pk|dom_id:"event_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button> + </div> + {% endif %} + </td> + </tr> + {% empty %} + <tr> + <td colspan="6" class="text-center opacity-60">{% trans "No events." %}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> + + {% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %} + {% for event in events %} + {% if event.can_manage %} + <dialog id="{{ event.pk|dom_id:"event_delete_modal" }}" class="modal"> + <div class="modal-box"> + <h3 class="text-lg font-bold"> + {% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete event" %}{% endif %} + </h3> + <form method="post" action="{% url 'management:event_delete' event.pk %}" id="{{ event.pk|dom_id:"event_delete_form" }}"> + {% csrf_token %} + {% if event.series_id %} + {% blocktrans with name=event.title asvar delete_body %}Removes “{{ name }}” from the schedule. It won't be regenerated.{% endblocktrans %} + <p class="py-2 text-sm opacity-70">{{ delete_body }}</p> + <label class="label cursor-pointer justify-start gap-2"> + <input type="checkbox" name="keep_record" class="checkbox"> + <span class="label-text">{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span> + </label> + {% else %} + {% blocktrans with name=event.title asvar delete_body %}Delete “{{ name }}”? This cannot be undone.{% endblocktrans %} + <p class="py-2 text-sm opacity-70">{{ delete_body }}</p> + {% endif %} + </form> + <div class="modal-action"> + <form method="dialog"> + <button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button> + </form> + <button class="btn btn-error gap-2" type="submit" form="{{ event.pk|dom_id:"event_delete_form" }}">{% lucide "trash-2" size=16 %} {% trans "Confirm" %}</button> + </div> + </div> + <form method="dialog" class="modal-backdrop"> + <button>close</button> + </form> + </dialog> + {% endif %} + {% endfor %} +{% endblock panel %} diff --git a/management/templates/management/event_series_detail.html b/management/templates/management/event_series_detail.html new file mode 100644 index 0000000..1e048c6 --- /dev/null +++ b/management/templates/management/event_series_detail.html @@ -0,0 +1,117 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{{ series.title }}{% endblock heading %} +{% block subheading %}{{ series.get_kind_display }} · {{ recurrence_summary }}{% endblock subheading %} + +{% block actions %} + {% if can_manage %} + <a class="btn btn-outline gap-2" href="{% url 'management:event_series_update' series.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a> + <form method="post" action="{% url 'management:event_series_generate' series.pk %}"> + {% csrf_token %} + <button class="btn btn-outline gap-2" type="submit">{% lucide "refresh-cw" size=16 %} {% trans "Regenerate occurrences" %}</button> + </form> + <button class="btn btn-outline btn-warning gap-2" type="button" onclick="document.getElementById('series_stop_modal').showModal()">{% lucide "octagon-pause" size=16 %} {% trans "Stop repeating" %}</button> + <button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('series_delete_modal').showModal()">{% lucide "trash-2" size=16 %} {% trans "Delete series" %}</button> + {% endif %} +{% endblock actions %} + +{% block panel %} + <div class="mb-6 grid gap-4 lg:grid-cols-2"> + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "repeat" size=18 %} {% trans "Recurrence" %}</h2> + <dl class="divide-y divide-base-200"> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Pattern" %}</dt> + <dd>{{ recurrence_summary }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "First occurrence" %}</dt> + <dd>{{ series.dtstart|date:"j M Y H:i" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Repeats until" %}</dt> + <dd>{{ series.until|date:"j M Y H:i"|default:"—" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Generated up to" %}</dt> + <dd>{{ series.generated_until|date:"j M Y"|default:"—" }}</dd> + </div> + </dl> + </div> + </div> + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Template" %}</h2> + <dl class="divide-y divide-base-200"> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Location" %}</dt> + <dd>{{ series.location|default:"—" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Opponent" %}</dt> + <dd>{{ series.opponent|default:"—" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Teams" %}</dt> + <dd>{% for team in series.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd> + </div> + </dl> + </div> + </div> + </div> + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Occurrences" %}</h2> + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>{% trans "When" %}</th> + <th>{% trans "Status" %}</th> + </tr> + </thead> + <tbody> + {% for occurrence in occurrences %} + <tr> + <td><a class="link link-hover" href="{% url 'management:event_detail' occurrence.pk %}">{{ occurrence.start|date:"j M Y H:i" }}</a></td> + <td> + {% if occurrence.cancelled %} + <span class="badge badge-outline gap-1">{% lucide "ban" size=12 %} {% trans "Cancelled" %}</span> + {% elif occurrence.detached %} + <span class="badge badge-neutral gap-1">{% lucide "unlink" size=12 %} {% trans "Detached" %}</span> + {% elif occurrence.is_past %} + <span class="badge badge-outline">{% trans "Past" %}</span> + {% else %} + <span class="badge badge-success">{% trans "Upcoming" %}</span> + {% endif %} + </td> + </tr> + {% empty %} + <tr> + <td colspan="2" class="text-center opacity-60">{% trans "No occurrences generated yet." %}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> +{% endblock panel %} + +{% block extra_body %} + {% if can_manage %} + {% trans "Stop repeating" as stop_title %} + {% blocktrans with name=series.title asvar stop_body %}Stop “{{ name }}” from generating new occurrences? Existing ones (past and future) are left exactly as they are — this only affects what happens from here on.{% endblocktrans %} + {% url 'management:event_series_stop' series.pk as stop_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id="series_stop_modal" title=stop_title body=stop_body action_url=stop_url submit_label=stop_title submit_icon="octagon-pause" %} + + {% trans "Delete series" as delete_title %} + {% blocktrans with name=series.title asvar delete_body %}Delete “{{ name }}”? This also deletes every occurrence it has ever generated, past and future, and their attendance records. This cannot be undone — use “Stop repeating” instead if you just want it to stop.{% endblocktrans %} + {% url 'management:event_series_delete' series.pk as delete_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id="series_delete_modal" title=delete_title body=delete_body action_url=delete_url submit_label=delete_title %} + {% endif %} +{% endblock extra_body %} diff --git a/management/templates/management/event_series_form.html b/management/templates/management/event_series_form.html new file mode 100644 index 0000000..60e9234 --- /dev/null +++ b/management/templates/management/event_series_form.html @@ -0,0 +1,75 @@ +{% extends "management/base.html" %} +{% load i18n lucide static ui %} + +{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New series" %}{% endif %}{% endblock heading %} + +{% block panel %} + <div class="card w-full bg-base-100 shadow"> + <div class="card-body"> + <form method="post"> + {% csrf_token %} + + {% for error in form.non_field_errors %} + <div class="alert alert-error my-2"> + <span>{{ error }}</span> + </div> + {% endfor %} + + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + {% form_field form.title %} + {% form_field form.kind %} + </div> + + <div class="divider"></div> + <h3 class="text-lg font-semibold mb-3">{% trans "Repeats" %}</h3> + <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> + {% form_field form.frequency %} + {% form_field form.interval %} + {% form_field form.dtstart %} + {% form_field form.until %} + </div> + {% trans "Only used for a weekly pattern -- a monthly one repeats on the same day of the month as the first occurrence above." as weekdays_help %} + {% form_field form.weekdays help_text=weekdays_help %} + <details class="collapse collapse-arrow bg-base-200 mt-2"> + <summary class="collapse-title text-sm font-medium">{% trans "Advanced: raw recurrence rule" %}</summary> + <div class="collapse-content"> + {% form_field form.advanced_rrule %} + </div> + </details> + + <div class="divider"></div> + <h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3> + <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> + {% form_field form.teams %} + {% form_field form.invited_members %} + {% form_field form.excluded_members %} + </div> + + <div class="divider"></div> + <h3 class="text-lg font-semibold mb-3">{% trans "Where" %}</h3> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + {% form_field form.location %} + {% form_field form.opponent %} + </div> + + <div class="divider"></div> + <h3 class="text-lg font-semibold mb-3">{% trans "Timing" %}</h3> + <div class="grid grid-cols-1 md:grid-cols-4 gap-4"> + {% form_field form.duration_hours %} + {% form_field form.duration_minutes %} + {% form_field form.gathering_minutes_before %} + {% form_field form.deadline_minutes_before %} + </div> + + <div class="card-actions justify-start pt-2 mt-2"> + <a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:event_series_detail" object.pk %}{% else %}{% url "management:event_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a> + <button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button> + </div> + </form> + </div> + </div> +{% endblock panel %} + +{% block extra_body %} + <script src="{% static 'js/searchable-select.js' %}"></script> +{% endblock extra_body %} diff --git a/management/templates/management/family_detail.html b/management/templates/management/family_detail.html new file mode 100644 index 0000000..ff3bec1 --- /dev/null +++ b/management/templates/management/family_detail.html @@ -0,0 +1,31 @@ +{% extends "management/base.html" %} +{% load lucide i18n %} + +{% block heading %}{% blocktrans with name=family %}{{ name }} Family{% endblocktrans %}{% endblock heading %} + +{% block actions %} +{% if is_club_admin %} +{% trans "Add parent" as add_parent_label %} +{% trans "Add child" as add_child_label %} +<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('add_parent_modal').showModal()">{% lucide "user-plus" size=16 %} {{ add_parent_label }}</button> +<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('add_child_modal').showModal()">{% lucide "baby" size=16 %} {{ add_child_label }}</button> +{% endif %} +{% endblock actions %} + +{% block panel %} +<div class="card bg-base-100 shadow"> + <div class="card-body"> + {% include "management/_family_members_table.html" with group=group %} + </div> +</div> + +{% if is_club_admin %} +{% url 'management:family_add_parent' family.pk as add_parent_url %} +{% url 'management:family_add_child' family.pk as add_child_url %} +{% trans "Add parent" as add_parent_title %} +{% trans "Add child" as add_child_title %} +{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %} +{% include "controlpanel/_modal_form.html" with modal_id="add_parent_modal" title=add_parent_title form=add_parent_form action_url=add_parent_url submit_label=add_parent_title submit_icon="user-plus" blurb=add_parent_blurb %} +{% include "controlpanel/_modal_form.html" with modal_id="add_child_modal" title=add_child_title form=add_child_form action_url=add_child_url submit_label=add_child_title submit_icon="baby" %} +{% endif %} +{% endblock panel %} diff --git a/management/templates/management/family_form.html b/management/templates/management/family_form.html new file mode 100644 index 0000000..c6eb451 --- /dev/null +++ b/management/templates/management/family_form.html @@ -0,0 +1,43 @@ +{% extends "management/base.html" %} +{% load lucide ui i18n %} + +{% block heading %}{% trans "Add family" %}{% endblock heading %} + +{% block panel %} + <div class="card w-full bg-base-100 shadow"> + <div class="card-body"> + <form method="post"> + {% csrf_token %} + + {% for error in form.non_field_errors %} + <div class="alert alert-error my-2"> + <span>{{ error }}</span> + </div> + {% endfor %} + + <h2 class="card-title text-base">{% lucide "user" size=18 %} {% trans "Parent / guardian" %}</h2> + <p class="text-sm opacity-70">{% trans "Gets a login -- they set a password via the reset link if this is a new email." %}</p> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + {% form_field form.parent_first_name %} + {% form_field form.parent_last_name %} + {% form_field form.parent_email %} + </div> + + <div class="divider"></div> + + <h2 class="card-title text-base">{% lucide "baby" size=18 %} {% trans "Child" %}</h2> + <p class="text-sm opacity-70">{% trans "No login of their own -- the parent above manages things for them." %}</p> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + {% form_field form.child_first_name %} + {% form_field form.child_last_name %} + {% form_field form.child_date_of_birth %} + </div> + + <div class="card-actions justify-start pt-2 mt-2"> + <a class="btn btn-outline gap-2" href="{% url "management:member_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a> + <button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button> + </div> + </form> + </div> + </div> +{% endblock panel %} diff --git a/management/templates/management/home.html b/management/templates/management/home.html new file mode 100644 index 0000000..4e249bc --- /dev/null +++ b/management/templates/management/home.html @@ -0,0 +1,308 @@ +{% extends "management/base.html" %} +{% load i18n static lucide %} + +{% block heading %}{{ club.name }}{% endblock heading %} +{% block subheading %}{% trans "Management" %}{% endblock subheading %} + +{% block panel %} + {% if attention.no_season %} + <div class="alert alert-warning mb-6"> + {% lucide "calendar-x" size=20 %} + <span> + {% trans "No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert." %} + </span> + </div> + {% endif %} + + {% if is_club_admin and billing_ends_at %} + <div class="alert alert-warning mb-6"> + {% lucide "calendar-clock" size=20 %} + <span> + {% if billing_auto_renews %} + {% blocktrans with date=billing_ends_at|date:"j M Y" %}Your current billing period ends {{ date }} and will renew automatically.{% endblocktrans %} + {% else %} + {% blocktrans with date=billing_ends_at|date:"j M Y" %}Your current billing period ends {{ date }} and is not set to renew automatically — billing is about to stop. Contact us to keep access.{% endblocktrans %} + {% endif %} + </span> + </div> + {% endif %} + + {% comment %} + The club's own numbers that should be zero -- same attention/chart/stat-group + data controlpanel/club_detail.html shows a platform admin drilling into this + club from outside; club_attention/club_charts/club_statistics are already + club-scoped, so this is that same data for the club's own staff. Nothing + money-shaped renders below for non-admins, same line the nav already draws + around the Shop section. + {% endcomment %} + <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-5"> + <div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} {% trans "Teams without coach" %}</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.teams_without_manager }}</div> + <div class="text-xs opacity-60">{% trans "Teams nobody can pick a squad for" %}</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 {% if attention.unrostered %}border-warning{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} {% trans "Unrostered members" %}</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div> + <div class="text-xs opacity-60">{% trans "Active members on no team" %}</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}border-warning{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} {% trans "Pending" %}</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div> + <div class="text-xs opacity-60">{% trans "Memberships awaiting approval" %}</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 border-info"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} {% trans "New members" %}</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div> + <div class="text-xs opacity-60">{% trans "First season at this club" %}</div> + </div> + </div> + + <div class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} {% trans "Attendance rate" %}</div> + <div class="text-4xl font-bold tabular-nums font-mono"> + {% if attention.attendance.turnout is None %} + {% trans "N/A" %} + {% else %} + {{ attention.attendance.turnout }}% + {% endif %} + </div> + <div class="text-xs opacity-60"> + {% if attention.attendance.turnout is None %} + {% trans "No events this season" %} + {% else %} + <progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}" + value="{{ attention.attendance.turnout }}" max="100"></progress> + {% endif %} + </div> + </div> + </div> + </div> + + <div class="mb-6 grid gap-4 lg:grid-cols-2"> + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <div class="flex items-center justify-between"> + <h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Upcoming events" %}</h2> + <a class="btn btn-outline btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a> + </div> + <div class="overflow-x-auto"> + <table class="table"> + <tbody> + {% for event in upcoming_events %} + <tr> + <td class="whitespace-nowrap">{{ event.start|date:"D j M, H:i" }}</td> + <td>{{ event.title }}</td> + <td>{{ event.get_kind_display }}</td> + <td>{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td> + </tr> + {% empty %} + <tr> + <td class="text-center opacity-60">{% trans "Nothing scheduled." %}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <div class="flex items-center justify-between"> + <h2 class="card-title text-base">{% lucide "newspaper" size=18 %} {% trans "News" %}</h2> + <a class="btn btn-outline btn-sm gap-2" href="{% url 'management:news_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a> + </div> + <div class="overflow-x-auto"> + <table class="table"> + <tbody> + {% for news_item in published_news %} + <tr> + <td class="whitespace-nowrap">{{ news_item.published_at|date:"D j M" }}</td> + <td><a class="link link-hover" href="{% url 'management:news_detail' news_item.pk %}">{{ news_item.title }}</a></td> + </tr> + {% empty %} + <tr> + <td class="text-center opacity-60">{% trans "Nothing published yet." %}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> + </div> + + {% if is_club_admin %} + <div class="mb-6 grid gap-4 lg:grid-cols-3"> + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "user-plus" size=18 %} {% trans "Signups per month" %}</h2> + <p class="text-sm opacity-70">{% trans "New members against returning ones." %}</p> + <div class="h-56"> + <canvas id="signups-chart"></canvas> + </div> + </div> + </div> + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "wallet" size=18 %} {% trans "Club fee status this season" %}</h2> + <div class="h-56"> + <canvas id="fees-chart"></canvas> + </div> + </div> + </div> + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "repeat" size=18 %} {% trans "Renewal rate" %}</h2> + {% if attention.renewal_rate is None %} + <p class="text-sm opacity-70">{% trans "No previous season to compare." %}</p> + {% else %} + <div class="h-56"> + <canvas id="renewal-chart"></canvas> + </div> + {% endif %} + </div> + </div> + </div> + {% endif %} + + <div class="grid gap-4 {% if is_club_admin %}md:grid-cols-4{% else %}md:grid-cols-3{% endif %}"> + {% for group in groups %} + {% if group.title != "Shop" or is_club_admin %} + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2> + <dl class="divide-y divide-base-200"> + {% for label, value in group.stats %} + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{{ label }}</dt> + <dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd> + </div> + {% endfor %} + </dl> + </div> + </div> + {% endif %} + {% endfor %} + </div> +{% endblock panel %} + +{% block extra_body %} + {% trans "New" as new_label %} + {% trans "Returning" as returning_label %} + {% trans "Renewed" as renewed_label %} + {% trans "Not renewed" as not_renewed_label %} + {{ charts|json_script:"chart-data" }} + {{ attention.renewal_rate|json_script:"renewal-rate-data" }} + <script src="{% static 'js/chart.js' %}"></script> + <script> + (() => { + const data = JSON.parse(document.getElementById("chart-data").textContent); + const renewalRate = JSON.parse(document.getElementById("renewal-rate-data").textContent); + const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; + const signupsCanvas = document.getElementById("signups-chart"); + const feesCanvas = document.getElementById("fees-chart"); + const renewalCanvas = document.getElementById("renewal-chart"); + + const render = () => { + const ink = css("--color-base-content", "#333"); + const grid = "color-mix(in oklab, " + ink + " 15%, transparent)"; + const charts = []; + + // Both canvases are admin-only -- absent entirely for a manager/coach, + // same gate as the Shop nav section and the rest of this row. + if (signupsCanvas) { + // Stacked: the bar height stays "signups this month" while the split shows + // where they came from. Side-by-side bars would answer a different question. + charts.push(new Chart(signupsCanvas, { + type: "bar", + data: { + labels: data.signups.map((point) => point.month), + datasets: [ + {label: "{{ new_label|escapejs }}", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")}, + {label: "{{ returning_label|escapejs }}", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")}, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: {legend: {position: "bottom", labels: {color: ink}}}, + scales: { + x: {stacked: true, ticks: {color: ink}, grid: {color: grid}}, + y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}}, + }, + }, + })); + } + + if (feesCanvas) { + // Colour carries the meaning here — unpaid must read as a problem, waived + // must not — so the slices are pinned to the semantic theme colours, in order. + charts.push(new Chart(feesCanvas, { + type: "pie", + data: { + labels: data.fees.map((slice) => slice.label), + datasets: [ + { + data: data.fees.map((slice) => slice.value), + backgroundColor: [css("--color-success", "#16a34a"), css("--color-warning", "#f59e0b"), css("--color-error", "#dc2626"), css("--color-neutral", "#6b7280")], + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: {legend: {position: "right", labels: {color: ink}}}, + }, + })); + } + + if (renewalCanvas && renewalRate !== null) { + // Same "colour carries the meaning" reasoning as the fee chart: a low + // renewal rate reads as a problem, so it's pinned to error, not neutral. + charts.push(new Chart(renewalCanvas, { + type: "pie", + data: { + labels: ["{{ renewed_label|escapejs }}", "{{ not_renewed_label|escapejs }}"], + datasets: [ + { + data: [renewalRate, 100 - renewalRate], + backgroundColor: [css("--color-success", "#16a34a"), css("--color-error", "#dc2626")], + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: {legend: {position: "right", labels: {color: ink}}}, + }, + })); + } + + return charts; + }; + + let charts = render(); + + // "auto" removes data-theme entirely, so watch the attribute rather than a click. + new MutationObserver(() => { + charts.forEach((chart) => chart.destroy()); + charts = render(); + }).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]}); + })(); + </script> +{% endblock extra_body %} diff --git a/management/templates/management/location_form.html b/management/templates/management/location_form.html new file mode 100644 index 0000000..9398291 --- /dev/null +++ b/management/templates/management/location_form.html @@ -0,0 +1,35 @@ +{% extends "management/base.html" %} +{% load i18n lucide static ui %} + +{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New location" %}{% endif %}{% endblock heading %} + +{% block panel %} + <div class="card w-full bg-base-100 shadow"> + <div class="card-body"> + <form method="post"> + {% csrf_token %} + + {% for error in form.non_field_errors %} + <div class="alert alert-error my-2"> + <span>{{ error }}</span> + </div> + {% endfor %} + + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + {% for field in form %} + {% form_field field %} + {% endfor %} + </div> + + <div class="card-actions justify-start pt-2 mt-2"> + <a class="btn btn-outline gap-2" href="{% url "management:location_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a> + <button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button> + </div> + </form> + </div> + </div> +{% endblock panel %} + +{% block extra_body %} + <script src="{% static 'js/searchable-select.js' %}"></script> +{% endblock extra_body %} diff --git a/management/templates/management/location_list.html b/management/templates/management/location_list.html new file mode 100644 index 0000000..b768f7d --- /dev/null +++ b/management/templates/management/location_list.html @@ -0,0 +1,59 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% trans "Locations" %}{% endblock heading %} + +{% block actions %} + <a class="btn btn-primary gap-2" href="{% url 'management:location_create' %}">{% lucide "plus" size=16 %} {% trans "New location" %}</a> +{% endblock actions %} + +{% block panel %} + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>{% trans "Name" %}</th> + <th>{% trans "Address" %}</th> + <th>{% trans "City" %}</th> + <th>{% trans "Country" %}</th> + <th></th> + </tr> + </thead> + <tbody> + {% for location in locations %} + <tr> + <td> + {{ location.name }} + {% if location.is_home %}<span class="badge badge-sm badge-primary ml-1">{% trans "Home" %}</span>{% endif %} + </td> + <td>{{ location.address }}</td> + <td>{{ location.city }}</td> + <td>{{ location.country }}</td> + <td class="text-right"> + <div class="flex justify-end gap-1"> + <a class="btn btn-outline btn-sm" href="{% url 'management:location_update' location.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a> + <button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ location.pk|dom_id:"location_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button> + </div> + </td> + </tr> + {% empty %} + <tr> + <td colspan="5" class="text-center opacity-60">{% trans "No locations yet." %}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> + + {% trans "Delete location" as delete_location_title %} + {% trans "Delete" as delete_label %} + {% for location in locations %} + {% url 'management:location_delete' location.pk as location_delete_url %} + {% blocktrans with name=location.name asvar delete_location_body %}Delete “{{ name }}”? Any events at this location keep their history but lose the link. This cannot be undone.{% endblocktrans %} + {% include "controlpanel/_confirm_modal.html" with modal_id=location.pk|dom_id:"location_delete_modal" title=delete_location_title body=delete_location_body action_url=location_delete_url submit_label=delete_label %} + {% endfor %} +{% endblock panel %} diff --git a/management/templates/management/member_detail.html b/management/templates/management/member_detail.html new file mode 100644 index 0000000..5f0dee0 --- /dev/null +++ b/management/templates/management/member_detail.html @@ -0,0 +1,157 @@ +{% extends "management/base.html" %} +{% load lucide ui i18n %} + +{% block heading %}{{ member.get_full_name }}{% endblock heading %} +{% block subheading %}{{ member.contact_email }}{% endblock subheading %} + +{% block actions %} + {% if is_club_admin %} + <a class="btn btn-outline gap-2" href="{% url 'management:member_update' member.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a> + {% endif %} +{% endblock actions %} + +{% block panel %} + <div class="grid gap-4 lg:grid-cols-2"> + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "user" size=18 %} {% trans "Personal information" %}</h2> + <dl class="divide-y divide-base-200"> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Date of birth" %}</dt> + <dd class="font-semibold">{{ member.date_of_birth|default:"-" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Phone" %}</dt> + <dd class="font-semibold">{{ member.phone.as_international|default:"-" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Emergency phone" %}</dt> + <dd class="font-semibold">{{ member.emergency_phone.as_international|default:"-" }}</dd> + </div> + </dl> + + <div class="flex flex-row gap-2 items-center mt-2"> + {% if member.phone %} + <a class="btn btn-sm btn-outline btn-primary grow" href="tel:{{ member.phone.as_international }}">{% lucide "phone" size=14 %} {% trans "Call" %}</a> + {% endif %} + {% if member.emergency_phone %} + <a class="btn btn-sm btn-outline btn-error grow" href="tel:{{ member.emergency_phone.as_international }}">{% lucide "shield-alert" size=14 %} {% trans "Emergency call" %}</a> + {% endif %} + </div> + </div> + </div> + + {% comment %} + A Member has no club of its own -- ClubMembership is the only thing that + actually ties this person to *this* club, so it gets its own panel: the + current season's standing, plus every season they've been part of. + {% endcomment %} + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "Club membership" %}</h2> + {% if current_membership %} + <dl class="divide-y divide-base-200"> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "License" %}</dt> + <dd class="font-semibold">{{ current_membership.license|default:"-" }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Status" %}</dt> + <dd class="font-semibold">{{ current_membership.get_status_display }}</dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-sm opacity-70">{% trans "Fee status" %}</dt> + <dd class="font-semibold">{{ current_membership.get_fee_status_display }}</dd> + </div> + </dl> + {% else %} + <p class="text-sm opacity-60">{% trans "Not rostered for the current season." %}</p> + {% endif %} + + {% if membership_history %} + <div class="divider"></div> + <h3 class="text-sm font-semibold opacity-70">{% trans "Season history" %}</h3> + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>{% trans "Season" %}</th> + <th>{% trans "Status" %}</th> + <th>{% trans "Fee status" %}</th> + <th>{% trans "License" %}</th> + </tr> + </thead> + <tbody> + {% for membership in membership_history %} + <tr> + <td>{{ membership.season.start_date|date:"Y" }} - {{ membership.season.end_date|date:"Y" }}</td> + <td>{{ membership.get_status_display }}</td> + <td>{{ membership.get_fee_status_display }}</td> + <td>{{ membership.license|default:"-" }}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + {% endif %} + </div> + </div> + </div> + + {% trans "Add parent" as add_parent_label %} + {% trans "Add child" as add_child_label %} + {% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %} + {% blocktrans with short_name=member.get_short_name asvar remove_from_family_label %}Remove {{ short_name }} from family{% endblocktrans %} + {% trans "Remove from family" as remove_from_family_title %} + {% trans "Remove" as remove_label %} + {% for family_group in family_groups %} + <div class="card bg-base-100 shadow mt-4"> + <div class="card-body"> + <div class="flex items-center justify-between"> + <h2 class="card-title text-base"> + {% lucide "users" size=18 %} + <a class="link link-hover" href="{% url 'management:family_detail' family_group.family.pk %}">{{ family_group.family }}</a> + </h2> + {% if is_club_admin %} + <div class="flex gap-2"> + <button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_parent_modal" }}').showModal()">{% lucide "user-plus" size=14 %} {{ add_parent_label }}</button> + <button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_child_modal" }}').showModal()">{% lucide "baby" size=14 %} {{ add_child_label }}</button> + <button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"detach_family_modal" }}').showModal()">{% lucide "user-x" size=14 %} {{ remove_from_family_label }}</button> + </div> + {% endif %} + </div> + {% include "management/_family_members_table.html" with group=family_group next_url=request.path %} + </div> + </div> + + {% if is_club_admin %} + {% url 'management:family_add_parent' family_group.family.pk as add_parent_url %} + {% url 'management:family_add_child' family_group.family.pk as add_child_url %} + {% url 'management:member_detach_family' member.pk family_group.family.pk as detach_family_url %} + {% blocktrans with full_name=member.get_full_name family_name=family_group.family asvar detach_family_body %}Remove {{ full_name }} from {{ family_name }}? They become a standalone member -- nothing else about them changes.{% endblocktrans %} + {% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_parent_modal" title=add_parent_label form=add_parent_form action_url=add_parent_url submit_label=add_parent_label submit_icon="user-plus" blurb=add_parent_blurb %} + {% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_child_modal" title=add_child_label form=add_child_form action_url=add_child_url submit_label=add_child_label submit_icon="baby" %} + {% include "controlpanel/_confirm_modal.html" with modal_id=family_group.family.pk|dom_id:"detach_family_modal" title=remove_from_family_title body=detach_family_body action_url=detach_family_url submit_label=remove_label submit_icon="user-x" %} + {% endif %} + {% endfor %} + + {% if is_club_admin %} + <div class="card bg-base-100 shadow mt-4"> + <div class="card-body"> + <div class="flex items-center justify-between"> + <h2 class="card-title text-base">{% lucide "users" size=18 %} {% trans "Family" %}</h2> + <button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('attach_family_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add to family" %}</button> + </div> + {% if not family_groups %} + <p class="text-sm opacity-60">{% trans "Not part of a family." %}</p> + {% endif %} + </div> + </div> + + {% url 'management:member_attach_family' member.pk as attach_family_url %} + {% trans "Add to family" as add_to_family_title %} + {% trans "Add" as add_label %} + {% trans "Pick an existing family, or leave it blank to start a new one." as attach_family_blurb %} + {% include "controlpanel/_modal_form.html" with modal_id="attach_family_modal" title=add_to_family_title form=attach_to_family_form action_url=attach_family_url submit_label=add_label submit_icon="user-plus" blurb=attach_family_blurb %} + {% endif %} +{% endblock panel %} diff --git a/management/templates/management/member_form.html b/management/templates/management/member_form.html new file mode 100644 index 0000000..4a6f484 --- /dev/null +++ b/management/templates/management/member_form.html @@ -0,0 +1,62 @@ +{% extends "management/base.html" %} +{% load lucide ui i18n %} + +{% block heading %}{% if update_view %}{% blocktrans with name=object %}Edit {{ name }}{% endblocktrans %}{% else %}{% trans "New member" %}{% endif %}{% endblock heading %} + +{% block actions %} + {% if update_view %} + <button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('member_delete_modal').showModal()">{% lucide "trash-2" size=16 %} {% trans "Delete" %}</button> + {% endif %} +{% endblock actions %} + +{% block panel %} + <div class="card w-full bg-base-100 shadow"> + <div class="card-body"> + <form method="post"> + {% csrf_token %} + + {% for error in form.non_field_errors %} + <div class="alert alert-error my-2"> + <span>{{ error }}</span> + </div> + {% endfor %} + + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + {% for field in form %} + {% form_field field %} + {% endfor %} + </div> + + {% if membership_form %} + <div class="divider"></div> + <h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "This season" %}</h2> + + {% for error in membership_form.non_field_errors %} + <div class="alert alert-error my-2"> + <span>{{ error }}</span> + </div> + {% endfor %} + + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + {% for field in membership_form %} + {% form_field field %} + {% endfor %} + </div> + {% endif %} + + <div class="card-actions justify-start pt-2 mt-2"> + <a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:member_detail" object.pk %}{% else %}{% url "management:member_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a> + <button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button> + </div> + </form> + </div> + </div> + + {% if update_view %} + {% url 'management:member_delete' object.pk as member_delete_url %} + {% trans "Delete member" as delete_member_title %} + {% trans "Delete" as delete_label %} + {% blocktrans with full_name=object.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %} + {% include "controlpanel/_confirm_modal.html" with modal_id="member_delete_modal" title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %} + {% endif %} +{% endblock panel %} diff --git a/management/templates/management/member_import.html b/management/templates/management/member_import.html new file mode 100644 index 0000000..ab92361 --- /dev/null +++ b/management/templates/management/member_import.html @@ -0,0 +1,39 @@ +{% extends "management/base.html" %} +{% load lucide ui i18n %} + +{% block heading %}{% trans "Mass upload members" %}{% endblock heading %} + +{% block actions %} + <a class="btn btn-outline gap-2" href="{% url 'management:member_import_template' %}">{% lucide "download" size=16 %} {% trans "Download template" %}</a> +{% endblock actions %} + +{% block panel %} + <div class="card w-full bg-base-100 shadow"> + <div class="card-body"> + <p class="text-sm opacity-70"> + {% blocktrans %}Fill in the downloaded template — one row per member — then upload it + here. Nothing is created yet: you'll see exactly what will be added + before anything is saved.{% endblocktrans %} + </p> + + <form method="post" enctype="multipart/form-data"> + {% csrf_token %} + + {% for error in form.non_field_errors %} + <div class="alert alert-error my-2"> + <span>{{ error }}</span> + </div> + {% endfor %} + + {% for field in form %} + {% form_field field %} + {% endfor %} + + <div class="card-actions justify-start pt-2 mt-2"> + <a class="btn btn-outline gap-2" href="{% url 'management:member_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a> + <button class="btn btn-primary gap-2" type="submit">{% lucide "upload" size=16 %} {% trans "Upload and preview" %}</button> + </div> + </form> + </div> + </div> +{% endblock panel %} diff --git a/management/templates/management/member_import_preview.html b/management/templates/management/member_import_preview.html new file mode 100644 index 0000000..5875072 --- /dev/null +++ b/management/templates/management/member_import_preview.html @@ -0,0 +1,68 @@ +{% extends "management/base.html" %} +{% load lucide i18n %} + +{% block heading %}{% trans "Review import" %}{% endblock heading %} + +{% block panel %} + {% if not season %} + <div class="alert alert-warning mb-4"> + {% lucide "triangle-alert" size=16 %} + <span>{% trans "No active season — members will still be created, but won't be rostered for one until it exists." %}</span> + </div> + {% endif %} + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <p class="text-sm opacity-70"> + {% blocktrans count counter=valid_count %}{{ counter }} member will be created.{% plural %}{{ counter }} members will be created.{% endblocktrans %} + {% if skipped_count %}{% blocktrans count counter=skipped_count %}{{ counter }} row will be skipped.{% plural %}{{ counter }} rows will be skipped.{% endblocktrans %}{% endif %} + </p> + + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>{% trans "Row" %}</th> + <th>{% trans "Last name" %}</th> + <th>{% trans "First name" %}</th> + <th>{% trans "Email" %}</th> + <th>{% trans "Outcome" %}</th> + </tr> + </thead> + <tbody> + {% for result in results %} + <tr> + <td>{{ result.line_number }}</td> + <td>{{ result.raw.last_name }}</td> + <td>{{ result.raw.first_name }}</td> + <td>{{ result.raw.email }}</td> + <td> + {% if result.member %} + <span class="badge badge-success badge-sm">{% trans "Will create" %}</span> + {% else %} + <span class="badge badge-error badge-sm">{% trans "Skipped" %}</span> + <div class="text-xs opacity-70 mt-1">{{ result.errors|join:"; " }}</div> + {% endif %} + </td> + </tr> + {% empty %} + <tr> + <td colspan="5" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + + <div class="card-actions justify-start pt-2 mt-2"> + <a class="btn btn-outline gap-2" href="{% url 'management:member_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a> + {% if valid_count %} + <form method="post" action="{% url 'management:member_import_confirm' %}"> + {% csrf_token %} + <button class="btn btn-primary gap-2" type="submit">{% lucide "check" size=16 %} {% blocktrans %}Confirm import ({{ valid_count }}){% endblocktrans %}</button> + </form> + {% endif %} + </div> + </div> + </div> +{% endblock panel %} diff --git a/management/templates/management/member_list.html b/management/templates/management/member_list.html new file mode 100644 index 0000000..cd1e9f4 --- /dev/null +++ b/management/templates/management/member_list.html @@ -0,0 +1,102 @@ +{% extends "management/base.html" %} +{% load lucide ui i18n %} + +{% block heading %}{% trans "Members" %}{% endblock heading %} + +{% block actions %} +<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import_template' %}" xmlns:management="http://www.w3.org/1999/xhtml">{% lucide "download" size=16 %} {% trans "Download upload template" %}</a> + {% if is_club_admin %} + <a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import' %}">{% lucide "upload" size=16 %} {% trans "Mass upload" %}</a> + <a class="btn btn-outline gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a> + <a class="btn btn-outline gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=16 %} {% trans "Add member" %}</a> + {% endif %} +{% endblock actions %} + +{% block panel %} + <form method="get" class="mb-4 flex gap-2"> + <label class="input"> + <span class="opacity-50">{% lucide "search" size=16 %}</span> + <input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered w-full max-w-xs"> + </label> + <button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=16 %} {% trans "Search" %}</button> + {% if search %} + <a class="btn gap-2" href="{% url "management:member_list" %}">{% lucide "x" size=16 %} {% trans "Clear filter" %}</a> + {% endif %} + </form> + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th>{% trans "Last name" %}</th> + <th>{% trans "First name" %}</th> + <th>{% trans "Email" %}</th> + <th>{% trans "Family" %}</th> + <th>{% trans "Status" %}</th> + <th></th> + </tr> + </thead> + <tbody> + {% for member in members %} + <tr> + <td><a class="link link-hover" href="{% url 'management:member_detail' member.pk %}">{{ member.last_name }}</a></td> + <td>{{ member.first_name }}</td> + <td>{{ member.contact_email|default:"-" }}</td> + <td> + {% if member.family_memberships_display %} + <div class="flex flex-col gap-1"> + {% for fm in member.family_memberships_display %} + <div> + <a class="btn btn-outline btn-sm" href="{% url 'management:family_detail' fm.family.pk %}">{% lucide "users" size=14 %} {{ fm.family }} {% lucide "arrow-right" size=14 %}</a> + </div> + {% endfor %} + </div> + {% else %} + <span class="opacity-40">-</span> + {% endif %} + </td> + <td> + {% if member.current_membership %} + <span class="badge badge-sm + {% if member.current_membership.status == "active" %}badge-success + {% elif member.current_membership.status == "pending" %}badge-warning + {% elif member.current_membership.status == "cancelled" %}badge-error + {% else %}badge-neutral{% endif %}"> + {{ member.current_membership.get_status_display }} + </span> + {% else %} + <span class="badge badge-sm badge-neutral">{% trans "unknown" %}</span> + {% endif %} + </td> + <td class="text-right"> + {% if is_club_admin %} + <div class="flex justify-end gap-1"> + <a class="btn btn-outline btn-sm" href="{% url 'management:member_detail' member.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a> + <button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ member.pk|dom_id:"member_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button> + </div> + {% endif %} + </td> + </tr> + {% empty %} + <tr> + <td colspan="6" class="text-center opacity-60">{% trans "No members yet." %}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> + + {% if is_club_admin %} + {% trans "Delete member" as delete_member_title %} + {% trans "Delete" as delete_label %} + {% for member in members %} + {% url 'management:member_delete' member.pk as member_delete_url %} + {% blocktrans with full_name=member.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %} + {% include "controlpanel/_confirm_modal.html" with modal_id=member.pk|dom_id:"member_delete_modal" title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %} + {% endfor %} + {% endif %} +{% endblock panel %} diff --git a/management/templates/management/membership_list.html b/management/templates/management/membership_list.html new file mode 100644 index 0000000..9a39a88 --- /dev/null +++ b/management/templates/management/membership_list.html @@ -0,0 +1,226 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% trans "Memberships" %}{% endblock heading %} +{% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %} + +{% block actions %} + <a class="btn btn-outline gap-2" href="{% url 'management:membership_export_pdf' %}?{{ request.GET.urlencode }}">{% lucide "file-down" size=16 %} {% trans "Export to PDF" %}</a> +{% endblock actions %} + +{% block panel %} + {% if not current_season %} + <div class="alert alert-warning mb-6"> + {% lucide "calendar-x" size=20 %} + <span>{% trans "No season covers today, so there's nothing to show fee status for yet." %}</span> + </div> + {% else %} + <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6"> + <div class="card bg-base-100 shadow border-l-4 border-info"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "users" size=16 %} {% trans "Registered" %}</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_total }}</div> + <div class="text-xs opacity-60">{% trans "This season" %}</div> + </div> + </div> + <div class="card bg-base-100 shadow border-l-4 border-success"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Paid" %}</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_paid }}</div> + </div> + </div> + <div class="card bg-base-100 shadow border-l-4 {% if kpi_partial %}border-warning{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-dashed" size=16 %} {% trans "Partially paid" %}</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_partial }}</div> + </div> + </div> + <div class="card bg-base-100 shadow border-l-4 {% if kpi_unpaid %}border-error{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-x" size=16 %} {% trans "Unpaid" %}</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_unpaid }}</div> + </div> + </div> + <div class="card bg-base-100 shadow border-l-4 border-neutral"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Waived" %}</div> + <div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_waived }}</div> + </div> + </div> + <div class="card bg-base-100 shadow border-l-4 {% if kpi_paid_rate is None %}border-info{% elif kpi_paid_rate < 50 %}border-error{% elif kpi_paid_rate < 85 %}border-warning{% else %}border-success{% endif %}"> + <div class="card-body p-4"> + <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "percent" size=16 %} {% trans "Paid rate" %}</div> + <div class="text-4xl font-bold tabular-nums font-mono"> + {% if kpi_paid_rate is None %}{% trans "N/A" %}{% else %}{{ kpi_paid_rate }}%{% endif %} + </div> + </div> + </div> + </div> + {% endif %} + + <form method="get" class="mb-4"> + <div class="flex flex-row items-center gap-2"> + <label class="input"> + <span class="opacity-50">{% lucide "search" size=16 %}</span> + <input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered"> + </label> + + <select name="season" class="select select-bordered"> + {% for season in seasons %} + <option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option> + {% endfor %} + </select> + + <select name="fee_status" class="select select-bordered"> + <option value="not_paid" {% if selected_fee_status == "not_paid" %}selected{% endif %}>{% trans "Not paid (unpaid or partially)" %}</option> + {% for value, label in fee_status_choices %} + <option value="{{ value }}" {% if value == selected_fee_status %}selected{% endif %}>{{ label|capfirst }}</option> + {% endfor %} + <option value="all" {% if selected_fee_status == "all" %}selected{% endif %}>{% trans "All" %}</option> + </select> + + <select name="status" class="select select-bordered"> + <option value="all" {% if selected_status == "all" %}selected{% endif %}>{% trans "All statuses" %}</option> + {% for value, label in status_choices %} + <option value="{{ value }}" {% if value == selected_status %}selected{% endif %}>{{ label|capfirst }}</option> + {% endfor %} + </select> + + <select name="team" class="select select-bordered"> + <option value="" {% if not selected_team %}selected{% endif %}>{% trans "All teams" %}</option> + {% for team in teams %} + <option value="{{ team.pk }}" {% if team.pk|stringformat:"s" == selected_team %}selected{% endif %}>{{ team }}</option> + {% endfor %} + </select> + + <button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button> + <a class="btn gap-2" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a> + </div> + </form> + + <form method="post" action="{% url 'management:membership_mark_paid' %}" id="membership-form"> + {% csrf_token %} + <input type="hidden" name="next" value="{{ request.get_full_path }}"> + + <div class="card bg-base-100 shadow"> + <div class="card-body"> + <div class="flex items-center justify-between mb-2"> + <span class="text-sm opacity-70 font-semibold"> + {% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %} + </span> + <button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button> + </div> + + <div class="overflow-x-auto"> + <table class="table"> + <thead> + <tr> + <th><input type="checkbox" class="checkbox" id="select-all-memberships"></th> + <th>{% trans "Last name" %}</th> + <th>{% trans "First name" %}</th> + <th>{% trans "Email" %}</th> + <th>{% trans "Family" %}</th> + <th>{% trans "Status" %}</th> + <th>{% trans "Fee status" %}</th> + <th>{% trans "Owed" %}</th> + <th>{% trans "Paid" %}</th> + <th>{% trans "License" %}</th> + <th></th> + </tr> + </thead> + <tbody> + {% for membership in memberships %} + <tr> + <td><input type="checkbox" class="checkbox membership-row-checkbox" name="membership_ids" value="{{ membership.pk }}"></td> + <td><a class="link link-hover" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member.last_name }}</a></td> + <td>{{ membership.member.first_name }}</td> + <td>{{ membership.member.contact_email|default:"-" }}</td> + <td> + {% if membership.member.family_memberships_display %} + {% for fm in membership.member.family_memberships_display %} + {{ fm.family }} {% if not forloop.last %},{% endif %} + {% endfor %} + {% else %} + <span class="opacity-40">-</span> + {% endif %} + </td> + <td> + <span class="badge badge-sm + {% if membership.status == "active" %}badge-success + {% elif membership.status == "pending" %}badge-warning + {% elif membership.status == "cancelled" %}badge-error + {% else %}badge-neutral{% endif %}"> + {{ membership.get_status_display }} + </span> + </td> + <td> + <span class="badge badge-sm + {% if membership.fee_status == "paid" %}badge-success + {% elif membership.fee_status == "partially_paid" %}badge-warning + {% elif membership.fee_status == "unpaid" %}badge-error + {% else %}badge-neutral{% endif %}"> + {{ membership.get_fee_status_display }} + </span> + </td> + <td class="tabular-nums">€ {{ membership.fee_amount }}</td> + <td class="tabular-nums"> + € {{ membership.amount_paid }} + {% if membership.record_payment_form and membership.fee_amount %} + <div class="text-xs opacity-60">{% blocktrans with remaining=membership.remaining_balance_display %}{{ remaining }} left{% endblocktrans %}</div> + {% endif %} + </td> + <td>{{ membership.license|default:"-" }}</td> + <td class="text-right"> + {% if membership.record_payment_form %} + <div class="flex justify-end gap-1"> + <button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"record_payment_modal" }}').showModal()"> + {% lucide "receipt" size=12 %} {% trans "Record payment" %} + </button> + <button class="btn btn-sm btn-outline btn-success" type="submit" form="{{ membership.pk|dom_id:"mark_fully_paid_form" }}"> + {% lucide "circle-check" size=12 %} {% trans "Mark fully paid" %} + </button> + </div> + {% endif %} + </td> + </tr> + {% empty %} + <tr> + <td colspan="11" class="text-center opacity-60">{% trans "Nobody matches these filters." %}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> + </form> + + {% trans "Record payment" as record_payment_title %} + {% trans "Record" as record_payment_submit_label %} + {% for membership in memberships %} + {% if membership.record_payment_form %} + {# A standalone form, not nested inside the bulk form above -- its submit button lives in the table row and links back here via the form="..." attribute. #} + <form method="post" action="{% url 'management:membership_mark_fully_paid' membership.pk %}" id="{{ membership.pk|dom_id:"mark_fully_paid_form" }}"> + {% csrf_token %} + <input type="hidden" name="next" value="{{ request.get_full_path }}"> + </form> + {% url 'management:membership_record_payment' membership.pk as record_payment_url %} + {% blocktrans with name=membership.member asvar record_payment_blurb %}For {{ name }}. Any amount is fine -- partial payments accumulate until the fee is settled.{% endblocktrans %} + {% include "controlpanel/_modal_form.html" with modal_id=membership.pk|dom_id:"record_payment_modal" title=record_payment_title form=membership.record_payment_form action_url=record_payment_url submit_label=record_payment_submit_label submit_icon="receipt" blurb=record_payment_blurb %} + {% endif %} + {% endfor %} +{% endblock panel %} + +{% block extra_body %} + <script> + (() => { + const selectAll = document.getElementById("select-all-memberships"); + const rowCheckboxes = document.querySelectorAll(".membership-row-checkbox"); + if (!selectAll) return; + + selectAll.addEventListener("change", () => { + rowCheckboxes.forEach((checkbox) => { checkbox.checked = selectAll.checked; }); + }); + })(); + </script> +{% endblock extra_body %} diff --git a/management/templates/management/membership_list_pdf.html b/management/templates/management/membership_list_pdf.html new file mode 100644 index 0000000..c746a6c --- /dev/null +++ b/management/templates/management/membership_list_pdf.html @@ -0,0 +1,99 @@ +{% load i18n %} + +{% comment %} + Rendered by WeasyPrint, not a browser: a standalone document with its own print + stylesheet, same convention as billing/templates/billing/invoice.html -- it + deliberately doesn't pull in app.css (daisyUI's dark theme/flex layouts mean + nothing on paper). +{% endcomment %} +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <title>{% trans "Memberships" %} + + + +
+
+

{{ club.name }}

+
{% trans "Membership fee status" %}{% if selected_season %} — {{ selected_season }}{% endif %}
+
+
+ {% blocktrans with generated=generated_at|date:"j F Y, H:i" %}Generated {{ generated }}{% endblocktrans %}
+ {% blocktrans count counter=memberships|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %} +
+
+ + + + + + + + + + + + + + + + + {% for membership in memberships %} + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Last name" %}{% trans "First name" %}{% trans "Email" %}{% trans "Family" %}{% trans "Status" %}{% trans "Fee status" %}{% trans "Owed" %}{% trans "Paid" %}{% trans "License" %}
{{ membership.member.last_name }}{{ membership.member.first_name }}{{ membership.member.contact_email|default:"—" }} + {% for fm in membership.member.family_memberships_display %}{{ fm.family }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %} + + {{ membership.get_status_display }} + + {{ membership.get_fee_status_display }} + {{ membership.fee_amount }}{{ membership.amount_paid }}{{ membership.license|default:"—" }}
{% trans "Nobody matches these filters." %}
+ + diff --git a/management/templates/management/news_detail.html b/management/templates/management/news_detail.html new file mode 100644 index 0000000..704b1e1 --- /dev/null +++ b/management/templates/management/news_detail.html @@ -0,0 +1,109 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{{ news_item.title }}{% endblock heading %} +{% block subheading %} + {% if news_item.status == "draft" %} + {% trans "Draft" %} + {% elif news_item.is_scheduled %} + {% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %} + {% else %} + {% blocktrans with date=news_item.published_at %}Published {{ date }}{% endblocktrans %} + {% endif %} + · {{ news_item.get_visibility_display }} +{% endblock subheading %} + +{% block actions %} + {% if can_edit %} + {% lucide "pencil" size=16 %} {% trans "Edit" %} + + {% endif %} + {% if can_publish %} + {% if news_item.status == "draft" %} + + {% else %} + + {% endif %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
+
+ {% if news_item.teams.all %} +
+ {% for team in news_item.teams.all %} + {{ team.short_name }} + {% endfor %} +
+ {% endif %} +

{{ news_item.body }}

+
+
+ +
+
+
+

{% trans "Photos" %}

+ {% if can_edit %} + + {% endif %} +
+ +
+ {% for photo in news_item.photos.all %} +
+ + {% if photo.is_main %} + {% trans "Main" %} + {% endif %} + {% if can_edit %} +
+ {% if not photo.is_main %} +
+ {% csrf_token %} + +
+ {% endif %} + +
+ {% url 'management:news_photo_delete' news_item.pk photo.pk as delete_photo_url %} + {% trans "Delete photo" as delete_photo_title %} + {% trans "This photo will be permanently removed." as delete_photo_body %} + {% trans "Delete" as delete_label %} + {% include "controlpanel/_confirm_modal.html" with modal_id=photo.pk|dom_id:"delete_photo_modal" title=delete_photo_title body=delete_photo_body action_url=delete_photo_url submit_label=delete_label submit_icon="trash-2" %} + {% endif %} +
+ {% empty %} +

{% trans "No photos yet." %}

+ {% endfor %} +
+
+
+ + {% if can_edit %} + {% trans "Add photos" as add_photos_label %} + {% url 'management:news_photo_upload' news_item.pk as add_photos_url %} + {% include "controlpanel/_modal_form.html" with modal_id="add_photos_modal" title=add_photos_label form=photo_upload_form action_url=add_photos_url submit_label=add_photos_label submit_icon="image-plus" %} + + {% trans "Delete news item" as delete_news_title %} + {% blocktrans asvar delete_news_body %}Delete “{{ news_item }}”? Its photos go with it. This cannot be undone.{% endblocktrans %} + {% trans "Delete" as delete_label %} + {% url 'management:news_delete' news_item.pk as delete_news_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id="delete_news_modal" title=delete_news_title body=delete_news_body action_url=delete_news_url submit_label=delete_label %} + {% endif %} + + {% if can_publish %} + {% if news_item.status == "draft" %} + {% trans "Publish" as publish_label %} + {% url 'management:news_publish' news_item.pk as publish_url %} + {% trans "Leave as now to publish immediately, or pick a future date/time to schedule it." as publish_blurb %} + {% include "controlpanel/_modal_form.html" with modal_id="publish_modal" title=publish_label form=publish_form action_url=publish_url submit_label=publish_label submit_icon="upload" blurb=publish_blurb %} + {% else %} + {% trans "Unpublish" as unpublish_label %} + {% blocktrans asvar unpublish_body %}This pulls “{{ news_item }}” back to a draft. It won't be visible anywhere until it's published again.{% endblocktrans %} + {% url 'management:news_unpublish' news_item.pk as unpublish_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id="unpublish_modal" title=unpublish_label body=unpublish_body action_url=unpublish_url submit_label=unpublish_label submit_icon="eye-off" %} + {% endif %} + {% endif %} +{% endblock panel %} diff --git a/management/templates/management/news_form.html b/management/templates/management/news_form.html new file mode 100644 index 0000000..9065068 --- /dev/null +++ b/management/templates/management/news_form.html @@ -0,0 +1,39 @@ +{% extends "management/base.html" %} +{% load i18n lucide static ui %} + +{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New news item" %}{% endif %}{% endblock heading %} + +{% block panel %} +
+
+
+ {% csrf_token %} + + {% for error in form.non_field_errors %} +
+ {{ error }} +
+ {% endfor %} + + {% if not update_view %} +

{% trans "Photos can be added once the news item is created." %}

+ {% endif %} + +
+ {% for field in form %} + {% form_field field %} + {% endfor %} +
+ +
+ {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
+
+
+
+{% endblock panel %} + +{% block extra_body %} + +{% endblock extra_body %} diff --git a/management/templates/management/news_list.html b/management/templates/management/news_list.html new file mode 100644 index 0000000..75e8a81 --- /dev/null +++ b/management/templates/management/news_list.html @@ -0,0 +1,76 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% trans "News" %}{% endblock heading %} + +{% block actions %} + {% if can_add_news %} + {% lucide "plus" size=16 %} {% trans "New news item" %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
+
+
+ + + + + + + + + + + + {% for news_item in news_items %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Title" %}{% trans "Teams" %}{% trans "Visibility" %}{% trans "Status" %}
{{ news_item.title }} + {% for team in news_item.teams.all %} + {{ team.short_name }} + {% empty %} + {% trans "Club-wide" %} + {% endfor %} + {{ news_item.get_visibility_display }} + {% if news_item.status == "draft" %} + {% trans "Draft" %} + {% elif news_item.is_scheduled %} + {% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %} + {% else %} + {% trans "Published" %} + {% endif %} + + {% if news_item.can_edit %} +
+ {% lucide "pencil" size=14 %} {% trans "Edit" %} + +
+ {% endif %} +
{% trans "No news items yet." %}
+
+
+
+ + {% trans "Delete news item" as delete_news_title %} + {% trans "Delete" as delete_label %} + {% for news_item in news_items %} + {% if news_item.can_edit %} + {% url 'management:news_delete' news_item.pk as news_delete_url %} + {% blocktrans with title=news_item.title asvar delete_news_body %}Delete “{{ title }}”? Its photos go with it. This cannot be undone.{% endblocktrans %} + {% include "controlpanel/_confirm_modal.html" with modal_id=news_item.pk|dom_id:"news_delete_modal" title=delete_news_title body=delete_news_body action_url=news_delete_url submit_label=delete_label %} + {% endif %} + {% endfor %} +{% endblock panel %} diff --git a/management/templates/management/opponent_form.html b/management/templates/management/opponent_form.html new file mode 100644 index 0000000..215f36a --- /dev/null +++ b/management/templates/management/opponent_form.html @@ -0,0 +1,31 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New opponent" %}{% endif %}{% endblock heading %} + +{% block panel %} +
+
+
+ {% csrf_token %} + + {% for error in form.non_field_errors %} +
+ {{ error }} +
+ {% endfor %} + +
+ {% for field in form %} + {% form_field field %} + {% endfor %} +
+ +
+ {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
+
+
+
+{% endblock panel %} diff --git a/management/templates/management/opponent_list.html b/management/templates/management/opponent_list.html new file mode 100644 index 0000000..e1480d2 --- /dev/null +++ b/management/templates/management/opponent_list.html @@ -0,0 +1,56 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% trans "Opponents" %}{% endblock heading %} + +{% block actions %} + {% lucide "plus" size=16 %} {% trans "New opponent" %} +{% endblock actions %} + +{% block panel %} +
+
+
+ + + + + + + + + + {% for opponent in opponents %} + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Name" %}
+ {% if opponent.logo %} + + {% endif %} + {{ opponent.name }} +
+ {% lucide "pencil" size=14 %} {% trans "Edit" %} + +
+
{% trans "No opponents yet." %}
+
+
+
+ + {% trans "Delete opponent" as delete_opponent_title %} + {% trans "Delete" as delete_label %} + {% for opponent in opponents %} + {% url 'management:opponent_delete' opponent.pk as opponent_delete_url %} + {% blocktrans with name=opponent.name asvar delete_opponent_body %}Delete “{{ name }}”? Any events against this opponent keep their history but lose the link. This cannot be undone.{% endblocktrans %} + {% include "controlpanel/_confirm_modal.html" with modal_id=opponent.pk|dom_id:"opponent_delete_modal" title=delete_opponent_title body=delete_opponent_body action_url=opponent_delete_url submit_label=delete_label %} + {% endfor %} +{% endblock panel %} diff --git a/management/templates/management/position_form.html b/management/templates/management/position_form.html new file mode 100644 index 0000000..709c405 --- /dev/null +++ b/management/templates/management/position_form.html @@ -0,0 +1,38 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New position" %}{% endif %}{% endblock heading %} + +{% block panel %} +
+
+
+ {% csrf_token %} + + {% for error in form.non_field_errors %} +
+ {{ error }} +
+ {% endfor %} + +
+ {% form_field form.name %} + {% form_field form.short_name %} + {% form_field form.ordering %} +
+ +
+ +
+ {% form_field form.staff_position %} + {% form_field form.management_position %} +
+ +
+ {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
+
+
+
+{% endblock panel %} diff --git a/management/templates/management/position_list.html b/management/templates/management/position_list.html new file mode 100644 index 0000000..99ec28e --- /dev/null +++ b/management/templates/management/position_list.html @@ -0,0 +1,59 @@ +{% extends "management/base.html" %} +{% load i18n lucide %} + +{% block heading %}{% trans "Positions" %}{% endblock heading %} + +{% block actions %} + {% if is_club_admin %} + {% lucide "plus" size=16 %} {% trans "New position" %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
+
+
+ + + + + + + + + + + + + {% for position in positions %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Ordering" %}{% trans "Name" %}{% trans "Short name" %}{% trans "Staff position" %}{% trans "Management position" %}
{{ position.ordering }}{{ position.name }}{{ position.short_name }} + + {% if position.staff_position %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %} + + + + {% if position.management_position %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %} + + + {% if is_club_admin %} + {% lucide "pencil" size=14 %} {% trans "Edit" %} + {% endif %} +
{% trans "No positions yet." %}
+
+
+
+{% endblock panel %} diff --git a/management/templates/management/rbihf_import_form.html b/management/templates/management/rbihf_import_form.html new file mode 100644 index 0000000..df39c57 --- /dev/null +++ b/management/templates/management/rbihf_import_form.html @@ -0,0 +1,35 @@ +{% extends "management/base.html" %} +{% load lucide ui i18n %} + +{% block heading %}{% trans "Import from RBIHF" %}{% endblock heading %} + +{% block panel %} +
+
+

+ {% blocktrans %}Paste the URL of a team's page on the RBIHF website and pick which of + your teams it's for. Nothing is created yet: you'll see exactly what will + be added, changed, or removed before anything is saved.{% endblocktrans %} +

+ +
+ {% csrf_token %} + + {% for error in form.non_field_errors %} +
+ {{ error }} +
+ {% endfor %} + + {% for field in form %} + {% form_field field %} + {% endfor %} + +
+ {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
+
+
+
+{% endblock panel %} diff --git a/management/templates/management/rbihf_import_preview.html b/management/templates/management/rbihf_import_preview.html new file mode 100644 index 0000000..f6c7cf4 --- /dev/null +++ b/management/templates/management/rbihf_import_preview.html @@ -0,0 +1,152 @@ +{% extends "management/base.html" %} +{% load lucide i18n %} + +{% block heading %}{% trans "Review import" %}{% endblock heading %} + +{% block panel %} +
+ {% lucide "info" size=16 %} + {% blocktrans with scraped=plan.scraped_team_name team=plan.team %}Importing fixtures for “{{ scraped }}” onto your team “{{ team }}”. Double check that's the right match before confirming.{% endblocktrans %} +
+ +
+ {% csrf_token %} + +
+
+

{% lucide "plus" size=18 %} {% blocktrans count counter=plan.to_create|length %}{{ counter }} game to create{% plural %}{{ counter }} games to create{% endblocktrans %}

+ + {% if plan.to_create %} +
+ + + + + + + + + + + {% for planned in plan.to_create %} + + + + + + + {% endfor %} + +
{% trans "#" %}{% trans "Start" %}{% trans "Opponent" %}{% trans "Location" %}
{{ planned.fixture.external_game_id }}{{ planned.fixture.start|date:"Y-m-d H:i" }} +
+ + {% blocktrans with opponent=planned.fixture.opponent_name %}RBIHF: {{ opponent }}{% endblocktrans %} +
+
+
+ + {% blocktrans with venue=planned.fixture.venue_text %}RBIHF: {{ venue }}{% endblocktrans %} +
+
+
+ {% endif %} +
+
+ +
+
+

{% lucide "refresh-cw" size=18 %} {% blocktrans count counter=plan.to_update|length %}{{ counter }} game to update{% plural %}{{ counter }} games to update{% endblocktrans %}

+ + {% if plan.to_update %} +
+ + + + + + + + + + + {% for planned in plan.to_update %} + + + + + + + {% endfor %} + +
{% trans "#" %}{% trans "Changes" %}{% trans "Opponent" %}{% trans "Location" %}
{{ planned.fixture.external_game_id }} + {% for field, change in planned.changes.items %} +
+ {{ field }}: + {{ change.0|default:"—" }} + → + {{ change.1 }} +
+ {% endfor %} +
+
+ + {% blocktrans with opponent=planned.fixture.opponent_name %}RBIHF: {{ opponent }}{% endblocktrans %} +
+
+
+ + {% blocktrans with venue=planned.fixture.venue_text %}RBIHF: {{ venue }}{% endblocktrans %} +
+
+
+ {% endif %} +
+
+ +
+
+

{% lucide "trash-2" size=18 %} {% blocktrans count counter=plan.to_delete|length %}{{ counter }} game to delete{% plural %}{{ counter }} games to delete{% endblocktrans %}

+

{% trans "No longer listed on the RBIHF page — only upcoming games are ever removed this way, past results are left alone." %}

+ + {% if plan.to_delete %} +
    + {% for event in plan.to_delete %} +
  • {{ event.start|date:"Y-m-d H:i" }} — {{ event }}
  • + {% endfor %} +
+ {% endif %} +
+
+ + {% if plan.unchanged_count %} +

{% blocktrans count counter=plan.unchanged_count %}{{ counter }} game is already up to date.{% plural %}{{ counter }} games are already up to date.{% endblocktrans %}

+ {% endif %} + +
+ {% lucide "arrow-left" size=16 %} {% trans "Back" %} + {% if plan.to_create or plan.to_update or plan.to_delete %} + + {% endif %} +
+
+{% endblock panel %} diff --git a/management/templates/management/role_list.html b/management/templates/management/role_list.html new file mode 100644 index 0000000..88092e3 --- /dev/null +++ b/management/templates/management/role_list.html @@ -0,0 +1,66 @@ +{% extends "management/base.html" %} +{% load i18n lucide static %} + +{% comment %} + One section per non-MEMBER role (management.views.ClubRoleListView) -- the + plain MEMBER role every active club member holds automatically is excluded + entirely, since listing it here would just be noise. "Grant role" opens a + modal (controlpanel/_modal_form.html) rather than a separate page -- role_create + is POST-only, see ClubRoleCreateView. +{% endcomment %} + +{% block heading %}{% trans "Roles" %}{% endblock heading %} + +{% block actions %} + +{% endblock actions %} + +{% block panel %} +
+ {% for value, label, description, roles_for_section in sections %} + {% with role_label=label|capfirst %} +
+
+

{{ role_label }}

+ {% if description %}

{{ description }}

{% endif %} +
+ + + + + + + + + {% for role in roles_for_section %} + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Member" %}
{{ role.member }} +
+ {% csrf_token %} + +
+
{% blocktrans %}No one has the {{ role_label }} role yet.{% endblocktrans %}
+
+
+
+ {% endwith %} + {% endfor %} +
+ + {% trans "Grant role" as grant_role_label %} + {% url 'management:role_create' as role_create_url %} + {% include "controlpanel/_modal_form.html" with modal_id="grant_role_modal" title=grant_role_label form=role_form action_url=role_create_url submit_label=grant_role_label submit_icon="shield-check" %} +{% endblock panel %} + +{% block extra_body %} + +{% endblock extra_body %} diff --git a/management/templates/management/sponsor_form.html b/management/templates/management/sponsor_form.html new file mode 100644 index 0000000..cb1d4e7 --- /dev/null +++ b/management/templates/management/sponsor_form.html @@ -0,0 +1,31 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New sponsor" %}{% endif %}{% endblock heading %} + +{% block panel %} +
+
+
+ {% csrf_token %} + + {% for error in form.non_field_errors %} +
+ {{ error }} +
+ {% endfor %} + +
+ {% for field in form %} + {% form_field field %} + {% endfor %} +
+ +
+ {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
+
+
+
+{% endblock panel %} diff --git a/management/templates/management/sponsor_list.html b/management/templates/management/sponsor_list.html new file mode 100644 index 0000000..9940fc3 --- /dev/null +++ b/management/templates/management/sponsor_list.html @@ -0,0 +1,68 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% trans "Sponsors" %}{% endblock heading %} + +{% block actions %} + {% lucide "plus" size=16 %} {% trans "New sponsor" %} +{% endblock actions %} + +{% block panel %} +
+
+
+ + + + + + + + + + + + + {% for sponsor in sponsors %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Name" %}{% trans "URL" %}{% trans "Start date" %}{% trans "End date" %}
+ {% if sponsor.logo %} + + {% endif %} + {{ sponsor.name }} + {% if sponsor.url %} + {{ sponsor.url }} + {% else %} + + {% endif %} + {{ sponsor.start_date|date:"Y-m-d" }}{{ sponsor.end_date|date:"Y-m-d"|default:"—" }} +
+ {% lucide "pencil" size=14 %} {% trans "Edit" %} + +
+
{% trans "No sponsors yet." %}
+
+
+
+ + {% trans "Delete sponsor" as delete_sponsor_title %} + {% trans "Delete" as delete_label %} + {% for sponsor in sponsors %} + {% url 'management:sponsor_delete' sponsor.pk as sponsor_delete_url %} + {% blocktrans with name=sponsor.name asvar delete_sponsor_body %}Delete “{{ name }}”? This cannot be undone.{% endblocktrans %} + {% include "controlpanel/_confirm_modal.html" with modal_id=sponsor.pk|dom_id:"sponsor_delete_modal" title=delete_sponsor_title body=delete_sponsor_body action_url=sponsor_delete_url submit_label=delete_label %} + {% endfor %} +{% endblock panel %} diff --git a/management/templates/management/team_detail.html b/management/templates/management/team_detail.html new file mode 100644 index 0000000..496dd6e --- /dev/null +++ b/management/templates/management/team_detail.html @@ -0,0 +1,306 @@ +{% extends "management/base.html" %} +{% load i18n lucide static ui %} + +{% block heading %}{{ team.name }}{% endblock heading %} +{% block subheading %} + + {{ team.short_name }} + · + {% lucide "bookmark" size=14 %} {{ team.pk }} + +{% endblock subheading %} + +{% block actions %} + {% if is_club_admin %} + {% lucide "pencil" size=16 %} {% trans "Edit" %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
+
+ + +
+
+ + {% if not selected_season %} +
+ {% lucide "calendar-x" size=20 %} + {% trans "This club has no seasons yet, so there's no roster or staff to show." %} +
+ {% else %} + {% if can_manage %} + {% if team_photo %} + {% trans "Replace photo" as photo_modal_title %} + {% else %} + {% trans "Upload photo" as photo_modal_title %} + {% endif %} + {% url 'management:team_photo_set' team.pk selected_season.pk as team_photo_set_url %} + {% include "controlpanel/_modal_form.html" with modal_id="team_photo_modal" title=photo_modal_title form=team_photo_form action_url=team_photo_set_url submit_label="Save" submit_icon="save" %} + + {% if team_photo %} + {% trans "Remove team photo?" as remove_photo_body %} + {% trans "Remove photo" as remove_photo_title %} + {% url 'management:team_photo_delete' team.pk selected_season.pk as team_photo_delete_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id="team_photo_delete_modal" title=remove_photo_title body=remove_photo_body action_url=team_photo_delete_url submit_label="Remove" submit_icon="trash-2" %} + {% endif %} + {% endif %} + +
+
+
+
{% lucide "user-check" size=16 %} {% trans "Attendance rate" %}
+
+ {% if attendance_rate is None %} + {% trans "N/A" %} + {% else %} + {{ attendance_rate }}% + {% endif %} +
+
+ {% if attendance_rate is None %} + {% trans "No past events this season" %} + {% else %} + + {% endif %} +
+
+
+ +
+
+

{% lucide "trending-up" size=18 %} {% trans "Top attenders" %}

+
    + {% for entry in top_attenders %} +
  • + {{ entry.member }} + {{ entry.rate }}% +
  • + {% empty %} +
  • {% trans "Not enough data yet." %}
  • + {% endfor %} +
+
+
+ +
+
+

{% lucide "trending-down" size=18 %} {% trans "Needs attention" %}

+
    + {% for entry in bottom_attenders %} +
  • + {{ entry.member }} + {{ entry.rate }}% +
  • + {% empty %} +
  • {% trans "Not enough data yet." %}
  • + {% endfor %} +
+
+
+
+ +
+
+
+

{% lucide "alert-triangle" size=18 %} {% trans "Missed the last 2 practices" %}

+
    + {% for member in missed_practices %} +
  • {{ member }}
  • + {% empty %} +
  • {% trans "No one -- or not enough practice history yet." %}
  • + {% endfor %} +
+
+
+ +
+
+

{% lucide "user-x" size=18 %} {% trans "No-shows" %}

+ +
+ + + {% for entry in no_shows %} + + + + + + {% empty %} + + + + {% endfor %} + +
{{ entry.member }}{{ entry.event.title }}{{ entry.event.start|date:"j M" }}
{% trans "None recorded." %}
+
+
+
+
+ +
+
+
+

{% trans "Roster" %}

+ {% if can_manage %} + + {% endif %} +
+
+ + + + + + + + + + + + {% for membership in roster %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Member" %}{% trans "Position" %}{% trans "Jersey #" %}
{{ membership.member }}{{ membership.position }}{{ membership.jersey_number|default:"—" }} + {% if membership.is_captain %}{% trans "Captain" %}{% endif %} + {% if membership.is_alternate_captain %}{% trans "Alternate captain" %}{% endif %} + + {% if can_manage %} +
+ + +
+ {% endif %} +
{% trans "No one on the roster for this season yet." %}
+
+
+
+ +
+
+
+

{% trans "Staff" %}

+ {% if can_manage %} + + {% endif %} +
+
+ + + + + + + + + + {% for assignment in staff %} + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Member" %}{% trans "Position" %}
{{ assignment.member }}{{ assignment.position }} + {% if can_manage %} +
+ + +
+ {% endif %} +
{% trans "No staff assigned for this season yet." %}
+
+
+
+ +
+
+
+

{% trans "Team photo" %}

+ {% if can_manage %} +
+ {% trans "Upload photo" as upload_photo_label %} + {% trans "Replace photo" as replace_photo_label %} + + {% if team_photo %} + + {% endif %} +
+ {% endif %} +
+ + {% if team_photo %} + {{ team }} + {% else %} +

{% trans "No photo uploaded for this season yet." %}

+ {% endif %} +
+
+ + {% if can_manage %} + {% trans "Add player" as add_player_label %} + {% url 'management:team_roster_add' team.pk selected_season.pk as add_player_url %} + {% include "controlpanel/_modal_form.html" with modal_id="add_player_modal" title=add_player_label form=roster_form action_url=add_player_url submit_label=add_player_label submit_icon="user-plus" %} + + {% trans "Assign staff" as add_staff_label %} + {% url 'management:team_staff_add' team.pk selected_season.pk as add_staff_url %} + {% include "controlpanel/_modal_form.html" with modal_id="add_staff_modal" title=add_staff_label form=staff_form action_url=add_staff_url submit_label=add_staff_label submit_icon="user-plus" %} + + {% trans "Edit player" as edit_player_label %} + {% trans "Save" as save_label %} + {% for membership in roster %} + {% url 'management:team_roster_update' team.pk membership.pk as edit_player_url %} + {% include "controlpanel/_modal_form.html" with modal_id=membership.pk|dom_id:"edit_player_modal" title=edit_player_label form=membership.edit_form action_url=edit_player_url submit_label=save_label submit_icon="save" %} + + {% blocktrans asvar remove_player_body with member=membership.member %}Remove {{ member }} from the roster?{% endblocktrans %} + {% trans "Remove player" as remove_player_title %} + {% trans "Remove" as remove_label %} + {% url 'management:team_roster_remove' team.pk membership.pk as remove_player_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id=membership.pk|dom_id:"remove_player_modal" title=remove_player_title body=remove_player_body action_url=remove_player_url submit_label=remove_label submit_icon="user-minus" %} + {% endfor %} + + {% trans "Edit staff assignment" as edit_staff_label %} + {% for assignment in staff %} + {% url 'management:team_staff_update' team.pk assignment.pk as edit_staff_url %} + {% include "controlpanel/_modal_form.html" with modal_id=assignment.pk|dom_id:"edit_staff_modal" title=edit_staff_label form=assignment.edit_form action_url=edit_staff_url submit_label=save_label submit_icon="save" %} + + {% blocktrans asvar remove_staff_body with member=assignment.member %}Remove {{ member }} from staff?{% endblocktrans %} + {% trans "Remove staff" as remove_staff_title %} + {% url 'management:team_staff_remove' team.pk assignment.pk as remove_staff_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id=assignment.pk|dom_id:"remove_staff_modal" title=remove_staff_title body=remove_staff_body action_url=remove_staff_url submit_label=remove_label submit_icon="user-minus" %} + {% endfor %} + {% endif %} + {% endif %} +{% endblock panel %} + +{% block extra_body %} + +{% endblock extra_body %} diff --git a/management/templates/management/team_form.html b/management/templates/management/team_form.html new file mode 100644 index 0000000..c25cc3c --- /dev/null +++ b/management/templates/management/team_form.html @@ -0,0 +1,31 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New team" %}{% endif %}{% endblock heading %} + +{% block panel %} +
+
+
+ {% csrf_token %} + + {% for error in form.non_field_errors %} +
+ {{ error }} +
+ {% endfor %} + +
+ {% for field in form %} + {% form_field field %} + {% endfor %} +
+ +
+ {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
+
+
+
+{% endblock panel %} diff --git a/management/templates/management/team_list.html b/management/templates/management/team_list.html new file mode 100644 index 0000000..703d49e --- /dev/null +++ b/management/templates/management/team_list.html @@ -0,0 +1,62 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% trans "Teams" %}{% endblock heading %} + +{% block actions %} + {% if is_club_admin %} + {% lucide "plus" size=16 %} {% trans "New team" %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
+
+
+ + + + + + + + + + + + {% for team in teams %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Short name" %}{% trans "Players" %}{% trans "Staff" %}
{{ team.name }}{{ team.short_name }}{{ team.player_count }}{{ team.staff_count }} + {% if is_club_admin %} +
+ {% lucide "pencil" size=14 %} {% trans "Edit" %} + +
+ {% endif %} +
{% trans "No teams yet." %}
+
+
+
+ + {% if is_club_admin %} + {% trans "Delete team" as delete_team_title %} + {% trans "Delete" as delete_label %} + {% for team in teams %} + {% url 'management:team_delete' team.pk as team_delete_url %} + {% blocktrans with name=team.name asvar delete_team_body %}Delete “{{ name }}”? This also removes its roster and staff assignments. This cannot be undone.{% endblocktrans %} + {% include "controlpanel/_confirm_modal.html" with modal_id=team.pk|dom_id:"team_delete_modal" title=delete_team_title body=delete_team_body action_url=team_delete_url submit_label=delete_label %} + {% endfor %} + {% endif %} +{% endblock panel %} diff --git a/management/tests.py b/management/tests.py new file mode 100644 index 0000000..493cbc1 --- /dev/null +++ b/management/tests.py @@ -0,0 +1,3601 @@ +import datetime +import os +import sys +from decimal import Decimal +from io import BytesIO +from unittest import mock + +import openpyxl +from allauth.mfa.models import Authenticator +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.urls import NoReverseMatch, reverse +from django.utils import timezone +from waffle import get_waffle_flag_model + +from billing.models import Tier, TierPrice +from billing.services.dues import subscribe +from club.models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor +from events.models import Attendance, Competition, Event, EventSeries, Location, Opponent +from events.services.rbihf_import import RBIHFImportError +from events.services.recurrence import detach_occurrence, generate_occurrences +from management.bulk_import import TEMPLATE_COLUMNS +from management.pdf import PDFExportError, render_pdf +from management.recurrence_ui import build_rrule, describe_rrule, parse_rrule +from members.models import Family, FamilyMembership, Member +from news.models import News, NewsPhoto +from shop.models import Order +from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto + +User = get_user_model() + +XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + + +def make_import_workbook(rows): + """An in-memory .xlsx upload -- header matching the real template, plus + whatever data rows a test wants to exercise.""" + workbook = openpyxl.Workbook() + sheet = workbook.active + sheet.append(TEMPLATE_COLUMNS) + for row in rows: + sheet.append(row) + + buffer = BytesIO() + workbook.save(buffer) + buffer.seek(0) + return SimpleUploadedFile("members.xlsx", buffer.read(), content_type=XLSX_CONTENT_TYPE) + + +def enrol_mfa(user): + return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) + + +def make_season(club): + # Must genuinely cover *today*: current_season()/teams_staffed_by() key off + # Season.covering(club, timezone.localdate()), not just any season row. + today = timezone.localdate() + return Season.objects.create(club=club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300)) + + +@override_settings( + ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", + ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"], +) +class ManagementTestBase(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.season = make_season(self.club) + + self.admin_user = User.objects.create_user(email="admin@example.com", password="pw-secret-123") + self.admin_member = Member.objects.create(user=self.admin_user, first_name="Ada", last_name="Admin") + ClubMembership.objects.create(club=self.club, member=self.admin_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + ClubRole.objects.filter(club=self.club, member=self.admin_member).update(role=ClubRole.Roles.ADMIN) + enrol_mfa(self.admin_user) + + def club_get(self, name, *args): + return self.client.get(reverse(f"management:{name}", args=args), HTTP_HOST="ajax-united.rosterchief.app") + + def club_post(self, name, data, *args): + return self.client.post(reverse(f"management:{name}", args=args), data, HTTP_HOST="ajax-united.rosterchief.app") + + +class AccessTests(ManagementTestBase): + def test_anonymous_is_sent_to_login(self): + response = self.club_get("home") + + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("account_login"), response.url) + + def test_a_club_member_with_no_role_or_staff_assignment_gets_403(self): + plain_user = User.objects.create_user(email="plain@example.com", password="pw-secret-123") + self.client.force_login(plain_user) + + self.assertEqual(self.club_get("home").status_code, 403) + + def test_a_plain_active_club_member_gets_403(self): + # An active ClubMembership auto-grants a MEMBER ClubRole (club/signals.py) -- + # every signed-up player has one. That alone must not be enough to get in, or + # players would reach the staff UI they're explicitly excluded from. + player_user = User.objects.create_user(email="player@example.com", password="pw-secret-123") + player_member = Member.objects.create(user=player_user, first_name="Paul", last_name="Player") + ClubMembership.objects.create(club=self.club, member=player_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(player_user) + + self.assertEqual(self.club_get("home").status_code, 403) + + def test_a_club_admin_can_reach_it(self): + self.client.force_login(self.admin_user) + + self.assertEqual(self.club_get("home").status_code, 200) + + def test_it_does_not_exist_on_the_base_domain(self): + # The management UI manages one club; the mirror image of controlpanel + # refusing to exist on a club subdomain. + self.client.force_login(self.admin_user) + + response = self.client.get(reverse("management:home"), HTTP_HOST="rosterchief.app") + + self.assertEqual(response.status_code, 404) + + def test_staff_with_only_a_staff_assignment_can_reach_staff_pages(self): + # No ClubRole at all -- authority comes purely from a current-season + # StaffAssignment, per club.services.access. + coach_user = User.objects.create_user(email="coach@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U12", short_name="U12") + position = Position.objects.create(club=self.club, name="Coach", short_name="C", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + self.assertEqual(self.club_get("member_list").status_code, 200) + + def test_staff_without_admin_role_cannot_reach_admin_only_pages(self): + coach_user = User.objects.create_user(email="coach2@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cody", last_name="Coach") + team = Team.objects.create(club=self.club, name="U13", short_name="U13") + position = Position.objects.create(club=self.club, name="Coach2", short_name="C2", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + # position_list itself is open to any staff (see TeamAndPositionAccessTests) + # -- creating and editing positions stays admin-only. + self.assertEqual(self.club_get("position_create").status_code, 403) + self.assertEqual(self.club_post("member_create", {"first_name": "X", "last_name": "Y"}).status_code, 403) + + +class NavLinkTests(ManagementTestBase): + """The global navbar's "Management" link, next to Django admin -- see + management.context_processors.management_link and templates/_base.html.""" + + def test_a_club_admin_sees_the_link_on_the_club_subdomain(self): + self.client.force_login(self.admin_user) + + self.assertContains(self.club_get("home"), reverse("management:home")) + + def test_a_plain_active_club_member_does_not_see_the_link(self): + player_user = User.objects.create_user(email="player2@example.com", password="pw-secret-123") + player_member = Member.objects.create(user=player_user, first_name="Pia", last_name="Player") + ClubMembership.objects.create(club=self.club, member=player_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.login(email="player2@example.com", password="pw-secret-123") + + # 403 on /manage/ itself, but the link must not appear on pages this user *can* + # reach either -- assert against a page outside the gate: the account view. + response = self.client.get(reverse("mfa_index"), HTTP_HOST="ajax-united.rosterchief.app") + + self.assertNotContains(response, reverse("management:home")) + + def test_the_link_is_absent_on_the_base_domain_even_for_a_club_admin(self): + # has_management_access requires a resolved club; the control panel/base domain + # has none, so the link -- which points at a single club's management app -- + # correctly never appears there regardless of who's signed in. + self.client.force_login(self.admin_user) + + response = self.client.get(reverse("mfa_index"), HTTP_HOST="rosterchief.app") + + self.assertNotContains(response, reverse("management:home")) + + +class ActiveNavHighlightTests(ManagementTestBase): + """The sidebar/mobile nav highlights whichever section the current page + belongs to -- see management.context_processors.active_nav_section.""" + + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_the_members_list_page_highlights_members(self): + response = self.club_get("member_list") + + self.assertContains(response, f'class="menu-active" href="{reverse("management:member_list")}"') + self.assertNotContains(response, f'class="menu-active" href="{reverse("management:home")}"') + + def test_a_member_detail_sub_page_still_highlights_members(self): + # member_detail has no nav entry of its own -- it belongs to the Members + # section, same as member_list, member_update, family_detail, etc. + member = Member.objects.create(first_name="Sub", last_name="Page") + ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + response = self.club_get("member_detail", member.pk) + + self.assertContains(response, f'class="menu-active" href="{reverse("management:member_list")}"') + + def test_the_dashboard_highlights_dashboard_only(self): + response = self.club_get("home") + + self.assertContains(response, f'class="menu-active" href="{reverse("management:home")}"') + self.assertNotContains(response, f'class="menu-active" href="{reverse("management:member_list")}"') + + +class MemberManagementTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_member_list_is_scoped_to_the_club(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_season = make_season(other_club) + other_member = Member.objects.create(first_name="Other", last_name="Person") + ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE) + + response = self.club_get("member_list") + + self.assertNotContains(response, "Other Person") + + def test_creating_a_member_also_signs_them_up_for_the_current_season(self): + response = self.club_post("member_create", {"first_name": "New", "last_name": "Player", "email": "new@example.com"}) + + member = Member.objects.get(first_name="New", last_name="Player") + self.assertRedirects(response, reverse("management:member_detail", args=[member.pk])) + self.assertTrue(ClubMembership.objects.filter(club=self.club, member=member, season=self.season).exists()) + + def test_updating_a_member(self): + member = Member.objects.create(first_name="Old", last_name="Name") + ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + # This member has a current-season membership, so its section renders too -- + # one combined submit, so its (required) fields must come along. + self.club_post( + "member_update", + {"first_name": "New", "last_name": "Name", "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.UNPAID}, + member.pk, + ) + + member.refresh_from_db() + self.assertEqual(member.first_name, "New") + + +class TeamManagementTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_team_list_is_scoped_to_the_club(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + Team.objects.create(club=other_club, name="Rival Team", short_name="RT") + + response = self.club_get("team_list") + + self.assertNotContains(response, "Rival Team") + + def test_creating_a_team(self): + response = self.club_post("team_create", {"name": "U15", "short_name": "U15"}) + + team = Team.objects.get(club=self.club, name="U15") + self.assertRedirects(response, reverse("management:team_detail", args=[team.pk])) + + def test_deleting_a_team(self): + team = Team.objects.create(club=self.club, name="U16", short_name="U16") + + response = self.club_post("team_delete", {}, team.pk) + + self.assertRedirects(response, reverse("management:team_list")) + self.assertFalse(Team.objects.filter(pk=team.pk).exists()) + + def test_deleting_a_team_cascades_its_roster_and_staff(self): + team = Team.objects.create(club=self.club, name="U17", short_name="U17") + position = Position.objects.create(club=self.club, name="Coach17", short_name="C17", staff_position=True) + member = Member.objects.create(first_name="Sam", last_name="Staffer") + StaffAssignment.objects.create(team=team, member=member, season=self.season, position=position) + + self.club_post("team_delete", {}, team.pk) + + self.assertFalse(StaffAssignment.objects.filter(team=team).exists()) + + def test_a_non_admin_cannot_delete_a_team(self): + team = Team.objects.create(club=self.club, name="U18", short_name="U18") + coach_user = User.objects.create_user(email="coach-team-delete@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + position = Position.objects.create(club=self.club, name="CoachTeamDelete", short_name="CTD", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("team_delete", {}, team.pk) + + self.assertEqual(response.status_code, 403) + self.assertTrue(Team.objects.filter(pk=team.pk).exists()) + + +class TeamRosterStaffTests(ManagementTestBase): + """Roster/staff management folded into the team page -- see + management.views.TeamDetailView and the TeamRoster*/TeamStaff* views.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + self.player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW", staff_position=False) + self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + + self.player = Member.objects.create(first_name="Peter", last_name="Player") + ClubMembership.objects.create(club=self.club, member=self.player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def make_team_coach(self, team, email="coach-roster@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=self.coach_position) + return coach_user + + def test_nav_no_longer_lists_roster_or_staff(self): + # Not a bare "Roster"/"Staff" substring check -- "RosterChief" branding is + # on every page regardless. The nav's old icons are a safe, specific proxy. + self.client.force_login(self.admin_user) + + response = self.club_get("team_list") + + self.assertNotContains(response, "clipboard-list") + self.assertNotContains(response, "hard-hat") + + def test_roster_and_staff_urls_no_longer_resolve(self): + with self.assertRaises(NoReverseMatch): + reverse("management:roster_list") + with self.assertRaises(NoReverseMatch): + reverse("management:staff_list") + + def test_season_switcher_defaults_to_the_current_season(self): + self.client.force_login(self.admin_user) + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "Peter Player") + + def test_season_switcher_honours_the_query_param(self): + other_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) + TeamMembership.objects.create(team=self.team, season=other_season, member=self.player, position=self.player_position) + self.client.force_login(self.admin_user) + + default_response = self.club_get("team_detail", self.team.pk) + other_response = self.client.get(f"{reverse('management:team_detail', args=[self.team.pk])}?season={other_season.pk}", HTTP_HOST="ajax-united.rosterchief.app") + + # Not assertNotContains("Peter Player") on the default response -- he's + # still a valid pick in the "Add player" combobox even when he isn't on + # *this* season's roster, so his name legitimately appears there too. + self.assertContains(default_response, "No one on the roster for this season yet.") + self.assertContains(other_response, "Peter Player") + + def test_a_teams_own_coach_can_add_a_player(self): + self.client.force_login(self.make_team_coach(self.team)) + + response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk), "jersey_number": "9"}, self.team.pk, self.season.pk) + + self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") + membership = TeamMembership.objects.get(team=self.team, season=self.season, member=self.player) + self.assertEqual(membership.jersey_number, 9) + + def test_a_different_teams_coach_cannot_add_a_player(self): + self.client.force_login(self.make_team_coach(self.other_team)) + + response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk) + + self.assertEqual(response.status_code, 403) + self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season).exists()) + + def test_a_different_teams_coach_can_still_view_the_team(self): + self.client.force_login(self.make_team_coach(self.other_team)) + + response = self.club_get("team_detail", self.team.pk) + + self.assertEqual(response.status_code, 200) + + def test_admin_can_add_a_player_to_any_team(self): + self.client.force_login(self.admin_user) + + self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk) + + self.assertTrue(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).exists()) + + def test_the_add_player_dropdown_excludes_a_member_with_no_active_membership(self): + lapsed_player = Member.objects.create(first_name="Lex", last_name="Lapsed") + ClubMembership.objects.create(club=self.club, member=lapsed_player, season=self.season, status=ClubMembership.StatusChoices.LAPSED) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertNotContains(response, "Lex Lapsed") + + def test_the_add_player_dropdown_includes_a_member_active_only_next_season(self): + next_season = Season.objects.create(club=self.club, start_date=self.season.end_date + datetime.timedelta(days=1), end_date=self.season.end_date + datetime.timedelta(days=300)) + upcoming_player = Member.objects.create(first_name="Uma", last_name="Upcoming") + ClubMembership.objects.create(club=self.club, member=upcoming_player, season=next_season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "Uma Upcoming") + + def test_the_add_player_dropdown_excludes_a_member_active_only_in_a_past_season(self): + past_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) + past_player = Member.objects.create(first_name="Pip", last_name="Past") + ClubMembership.objects.create(club=self.club, member=past_player, season=past_season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertNotContains(response, "Pip Past") + + def test_adding_the_same_member_twice_fails_with_a_form_error_not_a_500(self): + self.client.force_login(self.admin_user) + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) + + response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk) + + self.assertEqual(response.status_code, 302) + self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 1) + + def test_a_duplicate_jersey_number_fails_with_a_form_error_not_a_500(self): + # team/season aren't TeamMembershipForm fields, so Django's own + # validate_unique() can't see unique_jersey_number_per_team_per_season -- + # this constraint only gets checked because the form does it by hand. + other_player = Member.objects.create(first_name="Olly", last_name="Other") + ClubMembership.objects.create(club=self.club, member=other_player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7) + self.client.force_login(self.admin_user) + + response = self.club_post("team_roster_add", {"member": str(other_player.pk), "position": str(self.player_position.pk), "jersey_number": "7"}, self.team.pk, self.season.pk) + + self.assertEqual(response.status_code, 302) + self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=other_player).exists()) + + def test_editing_a_roster_entry_to_a_clashing_jersey_number_fails_gracefully(self): + other_player = Member.objects.create(first_name="Olly", last_name="Other") + ClubMembership.objects.create(club=self.club, member=other_player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7) + other_membership = TeamMembership.objects.create(team=self.team, season=self.season, member=other_player, position=self.player_position, jersey_number=8) + self.client.force_login(self.admin_user) + + response = self.club_post( + "team_roster_update", + {"member": str(other_player.pk), "position": str(self.player_position.pk), "jersey_number": "7"}, + self.team.pk, + other_membership.pk, + ) + + self.assertEqual(response.status_code, 302) + other_membership.refresh_from_db() + self.assertEqual(other_membership.jersey_number, 8) + + def test_editing_a_roster_entry_updates_it(self): + membership = TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=9) + self.client.force_login(self.admin_user) + + self.club_post( + "team_roster_update", + {"member": str(self.player.pk), "position": str(self.player_position.pk), "jersey_number": "10", "is_captain": "on"}, + self.team.pk, + membership.pk, + ) + + membership.refresh_from_db() + self.assertEqual(membership.jersey_number, 10) + self.assertTrue(membership.is_captain) + + def test_removing_a_roster_entry_deletes_it(self): + membership = TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) + self.client.force_login(self.admin_user) + + self.club_post("team_roster_remove", {}, self.team.pk, membership.pk) + + self.assertFalse(TeamMembership.objects.filter(pk=membership.pk).exists()) + + def test_a_teams_own_coach_can_assign_staff(self): + physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True) + physio = Member.objects.create(first_name="Pat", last_name="Physio") + ClubMembership.objects.create(club=self.club, member=physio, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(self.make_team_coach(self.team)) + + self.club_post("team_staff_add", {"member": str(physio.pk), "position": str(physio_position.pk)}, self.team.pk, self.season.pk) + + self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=physio).exists()) + + def test_the_assign_staff_dropdown_excludes_a_member_active_only_in_a_past_season(self): + # Same eligibility rule as the "Add player" dropdown -- see + # test_the_add_player_dropdown_excludes_a_member_active_only_in_a_past_season. + past_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) + past_member = Member.objects.create(first_name="Sam", last_name="Stale") + ClubMembership.objects.create(club=self.club, member=past_member, season=past_season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertNotContains(response, "Sam Stale") + + def test_a_different_teams_coach_cannot_assign_staff(self): + physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True) + physio = Member.objects.create(first_name="Pat", last_name="Physio") + self.client.force_login(self.make_team_coach(self.other_team)) + + response = self.club_post("team_staff_add", {"member": str(physio.pk), "position": str(physio_position.pk)}, self.team.pk, self.season.pk) + + self.assertEqual(response.status_code, 403) + + def test_removing_a_staff_assignment_deletes_it(self): + assignment = StaffAssignment.objects.create(team=self.team, season=self.season, member=self.player, position=self.coach_position) + self.client.force_login(self.admin_user) + + self.club_post("team_staff_remove", {}, self.team.pk, assignment.pk) + + self.assertFalse(StaffAssignment.objects.filter(pk=assignment.pk).exists()) + + +ONE_PIXEL_PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" + + +def make_image_file(name="photo.png"): + return SimpleUploadedFile(name, ONE_PIXEL_PNG, content_type="image/png") + + +class TeamPhotoTests(ManagementTestBase): + """One photo per (team, season), uploaded from the team page -- see + management.views.TeamPhotoSetView/TeamPhotoDeleteView.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + + def make_team_coach(self, team, email="coach-photo@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=self.coach_position) + return coach_user + + def make_plain_staff(self, email="physio-photo@example.com"): + staff_user = User.objects.create_user(email=email, password="pw-secret-123") + staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio") + position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False) + StaffAssignment.objects.create(team=self.team, member=staff_member, season=self.season, position=position) + return staff_user + + def test_uploading_creates_a_photo(self): + self.client.force_login(self.admin_user) + + response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk) + + self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") + self.assertEqual(TeamPhoto.objects.filter(team=self.team, season=self.season).count(), 1) + + def test_uploading_again_replaces_it_in_place(self): + self.client.force_login(self.admin_user) + self.club_post("team_photo_set", {"image": make_image_file("first.png")}, self.team.pk, self.season.pk) + first = TeamPhoto.objects.get(team=self.team, season=self.season) + + self.club_post("team_photo_set", {"image": make_image_file("second.png")}, self.team.pk, self.season.pk) + + self.assertEqual(TeamPhoto.objects.filter(team=self.team, season=self.season).count(), 1) + second = TeamPhoto.objects.get(team=self.team, season=self.season) + self.assertEqual(first.pk, second.pk) + self.assertIn("second", second.image.name) + + def test_a_different_teams_coach_cannot_upload(self): + self.client.force_login(self.make_team_coach(self.other_team)) + + response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk) + + self.assertEqual(response.status_code, 403) + self.assertFalse(TeamPhoto.objects.filter(team=self.team).exists()) + + def test_this_teams_coach_can_upload(self): + self.client.force_login(self.make_team_coach(self.team)) + + response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk) + + self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") + self.assertTrue(TeamPhoto.objects.filter(team=self.team, season=self.season).exists()) + + def test_deleting_removes_the_photo(self): + TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file()) + self.client.force_login(self.admin_user) + + response = self.club_post("team_photo_delete", {}, self.team.pk, self.season.pk) + + self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") + self.assertFalse(TeamPhoto.objects.filter(team=self.team, season=self.season).exists()) + + def test_the_team_page_shows_the_photo_when_set(self): + TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file()) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "Replace photo") + self.assertNotContains(response, "No photo uploaded") + + def test_the_team_page_shows_a_placeholder_when_not_set(self): + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "No photo uploaded") + self.assertContains(response, "Upload photo") + + def test_upload_ui_is_hidden_from_a_plain_staff_member(self): + TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file()) + self.client.force_login(self.make_plain_staff()) + + response = self.club_get("team_detail", self.team.pk) + + self.assertNotContains(response, "Replace photo") + self.assertNotContains(response, "Upload photo") + + +class PositionManagementTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_position_list_is_scoped_to_the_club(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + Position.objects.create(club=other_club, name="Rival Coach", short_name="RC", staff_position=True) + + response = self.club_get("position_list") + + self.assertNotContains(response, "Rival Coach") + + def test_creating_a_position(self): + response = self.club_post("position_create", {"name": "Physio", "short_name": "PH", "ordering": 0, "staff_position": "on", "management_position": ""}) + + position = Position.objects.get(club=self.club, name="Physio") + self.assertRedirects(response, reverse("management:position_list")) + self.assertTrue(position.staff_position) + self.assertFalse(position.management_position) + + def test_updating_a_position(self): + position = Position.objects.create(club=self.club, name="Old name", short_name="ON") + + self.club_post("position_update", {"name": "New name", "short_name": "NN", "ordering": 0}, position.pk) + + position.refresh_from_db() + self.assertEqual(position.name, "New name") + + def test_a_management_position_must_also_be_a_staff_position(self): + response = self.club_post("position_create", {"name": "Bad", "short_name": "B", "ordering": 0, "management_position": "on"}) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Position.objects.filter(club=self.club, name="Bad").exists()) + self.assertFormError(response.context["form"], "management_position", "A management position must also be a staff position.") + + +class ClubRoleManagementTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Future", last_name="Editor") + # An active membership already grants an implicit MEMBER role (club/signals.py) -- + # granting EDITOR must promote that row, not insert a second one. + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_granting_a_role_promotes_the_existing_membership_role(self): + self.club_post("role_create", {"member": str(self.member.pk), "role": ClubRole.Roles.EDITOR}) + + role = ClubRole.objects.get(club=self.club, member=self.member) + self.assertEqual(role.role, ClubRole.Roles.EDITOR) + self.assertEqual(ClubRole.objects.filter(club=self.club, member=self.member).count(), 1) + + def test_revoking_a_role(self): + role = ClubRole.objects.get(club=self.club, member=self.member) + + self.club_post("role_revoke", {}, role.pk) + + self.assertFalse(ClubRole.objects.filter(pk=role.pk).exists()) + + def test_the_plain_member_role_never_appears_on_the_list(self): + # self.admin_member and self.member both hold an implicit MEMBER role -- + # noise this page must never show. (self.member's name still legitimately + # appears once, in the "Grant role" modal's member picker.) + response = self.club_get("role_list") + + self.assertContains(response, "No one has the Editor role yet.") + + def test_a_granted_role_appears_under_its_own_section(self): + self.club_post("role_create", {"member": str(self.member.pk), "role": ClubRole.Roles.EDITOR}) + + response = self.club_get("role_list") + + self.assertContains(response, "Future Editor") + + def test_grant_role_is_a_modal_on_the_list_page(self): + response = self.club_get("role_list") + + self.assertContains(response, 'id="grant_role_modal"') + + def test_each_section_explains_what_the_role_grants(self): + response = self.club_get("role_list") + + self.assertContains(response, "Full control over the club") + self.assertContains(response, "Can create and edit events") + + def test_an_invalid_submission_redirects_back_to_the_list_instead_of_a_page(self): + response = self.club_post("role_create", {"member": "", "role": ClubRole.Roles.EDITOR}) + + self.assertRedirects(response, reverse("management:role_list")) + + +class FamilyManagementTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_registering_a_family_creates_a_login_parent_and_a_login_less_child(self): + response = self.club_post( + "family_create", + { + "parent_first_name": "Pat", + "parent_last_name": "Parent", + "parent_email": "pat.parent@example.com", + "child_first_name": "Cody", + "child_last_name": "Child", + "child_date_of_birth": "2015-04-01", + }, + ) + + family = Family.objects.get(memberships__member__first_name="Cody") + self.assertRedirects(response, reverse("management:family_detail", args=[family.pk])) + + parent = Member.objects.get(first_name="Pat", last_name="Parent") + child = Member.objects.get(first_name="Cody", last_name="Child") + self.assertEqual(parent.user.email, "pat.parent@example.com") + self.assertFalse(parent.user.has_usable_password(), "should set a password via the reset link, not be given one") + self.assertIsNone(child.user) + + self.assertEqual(FamilyMembership.objects.get(family=family, member=parent).role, FamilyMembership.FamilyRole.PARENT) + self.assertEqual(FamilyMembership.objects.get(family=family, member=child).role, FamilyMembership.FamilyRole.CHILD) + + # Both get signed up for the current season, same as a plain MemberCreateView. + self.assertTrue(ClubMembership.objects.filter(club=self.club, member=parent, season=self.season).exists()) + self.assertTrue(ClubMembership.objects.filter(club=self.club, member=child, season=self.season).exists()) + + def test_reusing_an_existing_login_by_email(self): + # A parent who's already a Member elsewhere (an existing login) must be + # reused, not duplicated, when registered onto a second family. + existing_user = User.objects.create_user(email="existing@example.com", password="pw-secret-123") + existing_member = Member.objects.create(user=existing_user, first_name="Existing", last_name="Parent") + + self.club_post( + "family_create", + { + "parent_first_name": "Ignored", + "parent_last_name": "Ignored", + "parent_email": "existing@example.com", + "child_first_name": "New", + "child_last_name": "Kid", + }, + ) + + self.assertEqual(Member.objects.filter(user=existing_user).count(), 1) + family = Family.objects.get(memberships__member__first_name="New") + self.assertIn(existing_member, family.guardians) + + def make_existing_family(self): + # A family only counts as "of this club" once at least one of its members + # has actually signed up (families_of_club, management/views.py) -- exactly + # what registering the first child through this app already does. + family = Family.objects.create() + first_kid = Member.objects.create(first_name="First", last_name="Kid") + FamilyMembership.objects.create(family=family, member=first_kid, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=first_kid, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + return family + + def test_adding_a_child_to_an_existing_family(self): + family = self.make_existing_family() + + response = self.club_post("family_add_child", {"first_name": "Second", "last_name": "Kid", "date_of_birth": "2018-01-01"}, family.pk) + + self.assertRedirects(response, reverse("management:family_detail", args=[family.pk])) + self.assertEqual(family.children.count(), 2) + self.assertTrue(ClubMembership.objects.filter(club=self.club, member__first_name="Second", season=self.season).exists()) + + def test_adding_a_parent_to_an_existing_family_is_idempotent(self): + family = self.make_existing_family() + + self.club_post("family_add_parent", {"email": "new.parent@example.com", "first_name": "New", "last_name": "Parent"}, family.pk) + self.club_post("family_add_parent", {"email": "new.parent@example.com", "first_name": "", "last_name": ""}, family.pk) + + self.assertEqual(family.guardians.count(), 1) + + +class MemberListFamilyColumnTests(ManagementTestBase): + """The member list is one flat table -- family is a column (each member's family/ + role attached in Python, management.views.MemberListView), not a grouping.""" + + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def make_family(self, parent_name, child_name): + family = Family.objects.create() + parent = Member.objects.create(first_name=parent_name, last_name="Guardian") + child = Member.objects.create(first_name=child_name, last_name="Kid") + FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT) + FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=parent, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + ClubMembership.objects.create(club=self.club, member=child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + return family, parent, child + + def test_a_family_renders_as_one_group_on_the_member_list(self): + family, parent, child = self.make_family("Pat", "Cody") + + response = self.club_get("member_list") + + self.assertContains(response, str(family)) + self.assertContains(response, str(parent)) + self.assertContains(response, str(child)) + + def test_a_member_with_no_family_has_an_empty_family_column(self): + loner = Member.objects.create(first_name="Lone", last_name="Member") + ClubMembership.objects.create(club=self.club, member=loner, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + response = self.club_get("member_list") + + members_by_pk = {member.pk: member for member in response.context["members"]} + self.assertEqual(members_by_pk[loner.pk].family_memberships_display, []) + + def test_a_family_members_column_shows_the_family_and_role(self): + family, parent, child = self.make_family("Pat", "Cody") + + response = self.club_get("member_list") + + members_by_pk = {member.pk: member for member in response.context["members"]} + parent_fms = members_by_pk[parent.pk].family_memberships_display + child_fms = members_by_pk[child.pk].family_memberships_display + self.assertEqual([fm.family for fm in parent_fms], [family]) + self.assertEqual(parent_fms[0].role, "parent") + self.assertEqual(child_fms[0].role, "child") + + def test_a_member_in_two_families_shows_both_on_the_member_list(self): + family, parent, _child = self.make_family("Pat", "Cody") + other_family = Family.objects.create() + FamilyMembership.objects.create(family=other_family, member=parent, role=FamilyMembership.FamilyRole.OTHER) + + response = self.club_get("member_list") + + members_by_pk = {member.pk: member for member in response.context["members"]} + parent_fms = members_by_pk[parent.pk].family_memberships_display + self.assertEqual({fm.family for fm in parent_fms}, {family, other_family}) + + def test_families_of_other_clubs_do_not_appear(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_season = make_season(other_club) + other_member = Member.objects.create(first_name="Other", last_name="Kid") + ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE) + other_family = Family.objects.create() + FamilyMembership.objects.create(family=other_family, member=other_member, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_get("member_list") + + self.assertNotContains(response, str(other_family)) + self.assertNotContains(response, "Other Kid") + + def test_a_non_admin_never_sees_a_family_mate_outside_their_own_visibility(self): + # group_by_family must bucket the already-scoped members_visible_to() result, + # never Family.guardians/.children directly -- those ignore scoping entirely + # and would leak a family-mate a non-admin has no other reason to see. + _family, parent, child = self.make_family("Pat", "Cody") + + coach_user = User.objects.create_user(email="coach3@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U14", short_name="U14") + position = Position.objects.create(club=self.club, name="Coach3", short_name="C3", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_get("member_list") + + self.assertNotContains(response, str(parent)) + self.assertNotContains(response, str(child)) + + +class MemberListStatusColumnTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_current_season_status_is_shown(self): + member = Member.objects.create(first_name="Sam", last_name="Pending") + ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.PENDING) + + response = self.club_get("member_list") + + members_by_pk = {m.pk: m for m in response.context["members"]} + self.assertEqual(members_by_pk[member.pk].current_membership.status, ClubMembership.StatusChoices.PENDING) + self.assertContains(response, "Pending") + + def test_a_member_with_no_current_season_membership_shows_a_dash(self): + ClubMembership.objects.filter(club=self.club, member=self.admin_member).delete() + + response = self.club_get("member_list") + + members_by_pk = {m.pk: m for m in response.context["members"]} + self.assertIsNone(members_by_pk[self.admin_member.pk].current_membership) + + def test_a_lapsed_season_membership_does_not_count_as_current(self): + lapsed_season = Season.objects.create(club=self.club, start_date=self.season.start_date - datetime.timedelta(days=400), end_date=self.season.start_date - datetime.timedelta(days=40)) + member = Member.objects.create(first_name="Old", last_name="Season") + ClubMembership.objects.create(club=self.club, member=member, season=lapsed_season, status=ClubMembership.StatusChoices.ACTIVE) + + response = self.club_get("member_list") + + members_by_pk = {m.pk: m for m in response.context["members"]} + self.assertIsNone(members_by_pk[member.pk].current_membership) + + +class MemberGrantLoginTests(ManagementTestBase): + """A login-less child getting their own account -- see + members.services.family.grant_login and management.forms.GrantLoginForm.""" + + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.family = Family.objects.create() + self.child = Member.objects.create(first_name="Cody", last_name="Kid") + FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=self.child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_family_page_offers_the_button_for_a_login_less_child(self): + response = self.club_get("family_detail", self.family.pk) + + self.assertContains(response, reverse("management:member_grant_login", args=[self.child.pk])) + + def test_no_button_once_the_child_already_has_a_login(self): + self.child.user = User.objects.create_user(email="already@example.com", password="pw-secret-123") + self.child.save() + + response = self.club_get("family_detail", self.family.pk) + + self.assertNotContains(response, reverse("management:member_grant_login", args=[self.child.pk])) + + def test_no_button_for_a_parent(self): + parent = Member.objects.create(first_name="Pat", last_name="Parent") + FamilyMembership.objects.create(family=self.family, member=parent, role=FamilyMembership.FamilyRole.PARENT) + ClubMembership.objects.create(club=self.club, member=parent, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + response = self.club_get("family_detail", self.family.pk) + + self.assertNotContains(response, reverse("management:member_grant_login", args=[parent.pk])) + + def test_the_form_is_prefilled_with_the_childs_contact_email_if_set(self): + self.child.email = "cody.kid@example.com" + self.child.save() + + response = self.club_get("family_detail", self.family.pk) + + self.assertContains(response, "cody.kid@example.com") + + def test_granting_a_login_creates_a_usable_account(self): + response = self.club_post("member_grant_login", {"email": "cody.kid@example.com"}, self.child.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.child.pk])) + self.child.refresh_from_db() + self.assertIsNotNone(self.child.user) + self.assertEqual(self.child.user.email, "cody.kid@example.com") + self.assertFalse(self.child.user.has_usable_password()) + + def test_an_email_already_in_use_is_rejected(self): + User.objects.create_user(email="taken@example.com", password="pw-secret-123") + + response = self.club_post("member_grant_login", {"email": "taken@example.com"}, self.child.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.child.pk])) + self.child.refresh_from_db() + self.assertIsNone(self.child.user) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-grant@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U18", short_name="U18") + position = Position.objects.create(club=self.club, name="Coach7", short_name="C7", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("member_grant_login", {"email": "cody.kid@example.com"}, self.child.pk) + + self.assertEqual(response.status_code, 403) + self.child.refresh_from_db() + self.assertIsNone(self.child.user) + + +class MemberFamilyAttachDetachTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.standalone = Member.objects.create(first_name="Stan", last_name="Alone") + ClubMembership.objects.create(club=self.club, member=self.standalone, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_attaching_to_a_new_family(self): + response = self.club_post("member_attach_family", {"role": FamilyMembership.FamilyRole.PARENT, "family": ""}, self.standalone.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.standalone.pk])) + family = Family.objects.get(memberships__member=self.standalone) + self.assertIn(self.standalone, family.guardians) + + def test_attaching_to_an_existing_family(self): + family = Family.objects.create() + kid = Member.objects.create(first_name="Existing", last_name="Kid") + ClubMembership.objects.create(club=self.club, member=kid, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + FamilyMembership.objects.create(family=family, member=kid, role=FamilyMembership.FamilyRole.CHILD) + + self.club_post("member_attach_family", {"role": FamilyMembership.FamilyRole.PARENT, "family": str(family.pk)}, self.standalone.pk) + + self.assertIn(self.standalone, family.guardians) + self.assertEqual(Family.objects.filter(memberships__member=self.standalone).count(), 1) + + def test_detaching_from_family_removes_it_when_left_empty(self): + family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_post("member_detach_family", {}, self.standalone.pk, family.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.standalone.pk])) + self.assertFalse(FamilyMembership.objects.filter(member=self.standalone).exists()) + self.assertFalse(Family.objects.filter(pk=family.pk).exists()) + + def test_detaching_from_family_keeps_it_when_others_remain(self): + family = Family.objects.create() + sibling = Member.objects.create(first_name="Sibling", last_name="Kid") + ClubMembership.objects.create(club=self.club, member=sibling, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + FamilyMembership.objects.create(family=family, member=sibling, role=FamilyMembership.FamilyRole.CHILD) + + self.club_post("member_detach_family", {}, self.standalone.pk, family.pk) + + self.assertTrue(Family.objects.filter(pk=family.pk).exists()) + self.assertIn(sibling, family.children) + + def test_detaching_from_one_family_keeps_membership_in_another(self): + family = Family.objects.create() + other_family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + FamilyMembership.objects.create(family=other_family, member=self.standalone, role=FamilyMembership.FamilyRole.OTHER) + + self.club_post("member_detach_family", {}, self.standalone.pk, family.pk) + + self.assertFalse(FamilyMembership.objects.filter(member=self.standalone, family=family).exists()) + self.assertTrue(FamilyMembership.objects.filter(member=self.standalone, family=other_family).exists()) + + def test_a_member_can_join_a_second_family(self): + family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_post("member_attach_family", {"role": FamilyMembership.FamilyRole.OTHER, "family": ""}, self.standalone.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.standalone.pk])) + self.assertEqual(FamilyMembership.objects.filter(member=self.standalone).count(), 2) + self.assertTrue(FamilyMembership.objects.filter(member=self.standalone, family=family).exists()) + + def test_member_detail_shows_a_card_per_family(self): + family = Family.objects.create() + other_family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + FamilyMembership.objects.create(family=other_family, member=self.standalone, role=FamilyMembership.FamilyRole.OTHER) + + response = self.club_get("member_detail", self.standalone.pk) + + self.assertEqual(len(response.context["family_groups"]), 2) + self.assertContains(response, str(family)) + self.assertContains(response, str(other_family)) + # "Add to family" stays available even though the member is already in two. + self.assertContains(response, "Add to family") + + def test_attach_form_excludes_families_already_joined(self): + family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_get("member_detail", self.standalone.pk) + + self.assertNotIn(family, response.context["attach_to_family_form"].fields["family"].queryset) + + +class MemberDeleteTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Doomed", last_name="Member") + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_deleting_a_member(self): + response = self.club_post("member_delete", {}, self.member.pk) + + self.assertRedirects(response, reverse("management:member_list")) + self.assertFalse(Member.objects.filter(pk=self.member.pk).exists()) + + def test_deleting_the_last_member_of_a_family_cleans_it_up(self): + family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.CHILD) + + self.club_post("member_delete", {}, self.member.pk) + + self.assertFalse(Family.objects.filter(pk=family.pk).exists()) + + def test_a_member_referenced_by_an_order_cannot_be_deleted(self): + # shop.Order.purchaser is PROTECT -- deleting must fail gracefully, not 500. + Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("10.00")) + + response = self.club_post("member_delete", {}, self.member.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk])) + self.assertTrue(Member.objects.filter(pk=self.member.pk).exists()) + + +class MemberClubMembershipFormTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Fee", last_name="Payer") + self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_editing_a_member_also_updates_their_current_membership(self): + response = self.club_post( + "member_update", + { + "first_name": "Fee", + "last_name": "Payer", + "license": "BE-9999", + "status": ClubMembership.StatusChoices.ACTIVE, + "fee_status": ClubMembership.FeeStatus.PAID, + }, + self.member.pk, + ) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk])) + self.membership.refresh_from_db() + self.assertEqual(self.membership.license, "BE-9999") + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) + + def test_membership_section_is_fillable_when_not_yet_rostered_this_season(self): + member = Member.objects.create(first_name="Unrostered", last_name="Member") + # Visible (has *a* membership in this club), but not for the current season -- + # this is exactly the case that should now offer a fillable section to sign + # them up, rather than hiding it because nothing exists yet. + old_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 8, 1), end_date=datetime.date(2021, 5, 31)) + ClubMembership.objects.create(club=self.club, member=member, season=old_season, status=ClubMembership.StatusChoices.LAPSED) + + response = self.club_get("member_update", member.pk) + + self.assertContains(response, "This season") + self.assertFalse(ClubMembership.objects.filter(club=self.club, member=member, season=self.season).exists()) + + def test_saving_the_membership_section_signs_up_a_previously_unrostered_member(self): + member = Member.objects.create(first_name="Unrostered", last_name="Member") + # A ClubRole, not a ClubMembership -- gives the admin visibility into this + # member without a season-bound row already existing, which is exactly the + # "not signed up for anything yet" case this test means to exercise. + ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER) + + response = self.club_post( + "member_update", + {"first_name": "Unrostered", "last_name": "Member", "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.PAID}, + member.pk, + ) + + self.assertRedirects(response, reverse("management:member_detail", args=[member.pk])) + membership = ClubMembership.objects.get(club=self.club, member=member, season=self.season) + self.assertEqual(membership.status, ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(membership.fee_status, ClubMembership.FeeStatus.PAID) + + def test_membership_section_absent_with_no_season_at_all(self): + # A fresh club, never given a season -- there's nothing sensible to sign up + # for, so unlike the "not rostered yet" case above, this stays hidden. + empty_club = Club.objects.create(name="No Season FC", slug="no-season-fc") + admin_member = Member.objects.create(first_name="Empty", last_name="Admin") + ClubRole.objects.create(club=empty_club, member=admin_member, role=ClubRole.Roles.ADMIN) + admin_user = User.objects.create_user(email="noseasonadmin@example.com", password="pw-secret-123") + admin_member.user = admin_user + admin_member.save() + enrol_mfa(admin_user) + self.client.force_login(admin_user) + member = Member.objects.create(first_name="No", last_name="Season") + ClubRole.objects.create(club=empty_club, member=member, role=ClubRole.Roles.MEMBER) + + with override_settings(ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "no-season-fc.rosterchief.app", "testserver"]): + response = self.client.get(reverse("management:member_update", args=[member.pk]), HTTP_HOST="no-season-fc.rosterchief.app") + + self.assertNotContains(response, "This season") + + def test_detail_page_shows_season_history(self): + old_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 8, 1), end_date=datetime.date(2021, 5, 31)) + ClubMembership.objects.create(club=self.club, member=self.member, season=old_season, status=ClubMembership.StatusChoices.LAPSED, fee_status=ClubMembership.FeeStatus.UNPAID) + + response = self.club_get("member_detail", self.member.pk) + + self.assertContains(response, f"{self.season.start_date:%Y} - {self.season.end_date:%Y}") + self.assertContains(response, f"{old_season.start_date:%Y} - {old_season.end_date:%Y}") + self.assertContains(response, "lapsed") + + def test_formatted_phone_number_is_shown(self): + self.member.phone = "+32476123456" + self.member.save() + + response = self.club_get("member_detail", self.member.pk) + + self.assertContains(response, "+32 476 12 34 56") + + +class FamilyDetailViewTests(ManagementTestBase): + """The family overview page, reached by clicking a family's name -- there's no + standalone "Families" nav entry or list.""" + + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.family = Family.objects.create() + self.parent = Member.objects.create(first_name="Pat", last_name="Guardian") + self.child = Member.objects.create(first_name="Cody", last_name="Kid") + FamilyMembership.objects.create(family=self.family, member=self.parent, role=FamilyMembership.FamilyRole.PARENT) + FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=self.parent, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + ClubMembership.objects.create(club=self.club, member=self.child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_shows_every_member_of_the_family(self): + response = self.club_get("family_detail", self.family.pk) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Guardian") + self.assertContains(response, "Kid") + + def test_a_family_from_another_club_404s(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_family = Family.objects.create() + other_member = Member.objects.create(first_name="Other", last_name="Kid") + other_season = make_season(other_club) + ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE) + FamilyMembership.objects.create(family=other_family, member=other_member, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_get("family_detail", other_family.pk) + + self.assertEqual(response.status_code, 404) + + def test_only_admins_see_edit_and_delete_actions(self): + # Give the coach visibility into this family's child (rostered on their team), + # so the assertion below actually exercises the admin-only button gating -- + # not just "the coach can't see this row at all". + coach_user = User.objects.create_user(email="coach4@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U15", short_name="U15") + staff_position = Position.objects.create(club=self.club, name="Coach4", short_name="C4", staff_position=True, management_position=True) + player_position = Position.objects.create(club=self.club, name="Player4", short_name="P4", staff_position=False) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=staff_position) + TeamMembership.objects.create(team=team, member=self.child, season=self.season, position=player_position) + self.client.force_login(coach_user) + + response = self.club_get("family_detail", self.family.pk) + + self.assertContains(response, "Kid") + # The edit icon links straight to member_detail, same as the row's own name + # link -- not admin-exclusive, so member_delete is the actual gating signal. + self.assertNotContains(response, reverse("management:member_delete", args=[self.child.pk])) + + def test_admin_sees_a_remove_from_family_action_per_row(self): + response = self.club_get("family_detail", self.family.pk) + + self.assertContains(response, reverse("management:member_detach_family", args=[self.parent.pk, self.family.pk])) + self.assertContains(response, reverse("management:member_detach_family", args=[self.child.pk, self.family.pk])) + + def test_non_admin_does_not_see_the_remove_from_family_action(self): + coach_user = User.objects.create_user(email="coach6@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U17", short_name="U17") + staff_position = Position.objects.create(club=self.club, name="Coach6", short_name="C6", staff_position=True, management_position=True) + player_position = Position.objects.create(club=self.club, name="Player6", short_name="P6", staff_position=False) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=staff_position) + TeamMembership.objects.create(team=team, member=self.child, season=self.season, position=player_position) + self.client.force_login(coach_user) + + response = self.club_get("family_detail", self.family.pk) + + self.assertNotContains(response, reverse("management:member_detach_family", args=[self.child.pk, self.family.pk])) + + def test_removing_a_family_member_from_the_row_button_detaches_only_them(self): + response = self.club_post("member_detach_family", {}, self.child.pk, self.family.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.child.pk])) + self.assertFalse(FamilyMembership.objects.filter(family=self.family, member=self.child).exists()) + self.assertTrue(FamilyMembership.objects.filter(family=self.family, member=self.parent).exists()) + + +class FamilyMembershipRoleUpdateTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.family = Family.objects.create() + self.member = Member.objects.create(first_name="Cody", last_name="Kid") + self.membership = FamilyMembership.objects.create(family=self.family, member=self.member, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_admin_can_change_the_role(self): + response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.GUARDIAN}, self.family.pk, self.member.pk) + + self.assertRedirects(response, reverse("management:family_detail", args=[self.family.pk])) + self.membership.refresh_from_db() + self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.GUARDIAN) + + def test_only_changes_the_role_in_the_targeted_family(self): + other_family = Family.objects.create() + other_membership = FamilyMembership.objects.create(family=other_family, member=self.member, role=FamilyMembership.FamilyRole.OTHER) + + self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.PARENT}, self.family.pk, self.member.pk) + + self.membership.refresh_from_db() + other_membership.refresh_from_db() + self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.PARENT) + self.assertEqual(other_membership.role, FamilyMembership.FamilyRole.OTHER) + + def test_an_invalid_role_is_rejected(self): + response = self.club_post("family_membership_role_update", {"role": "not-a-role"}, self.family.pk, self.member.pk) + + self.assertRedirects(response, reverse("management:family_detail", args=[self.family.pk])) + self.membership.refresh_from_db() + self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.CHILD) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-role@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U16", short_name="U16") + position = Position.objects.create(club=self.club, name="Coach5", short_name="C5", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.PARENT}, self.family.pk, self.member.pk) + + self.assertEqual(response.status_code, 403) + self.membership.refresh_from_db() + self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.CHILD) + + def test_a_family_from_another_club_404s(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_season = make_season(other_club) + other_family = Family.objects.create() + other_member = Member.objects.create(first_name="Other", last_name="Kid") + ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE) + FamilyMembership.objects.create(family=other_family, member=other_member, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.PARENT}, other_family.pk, other_member.pk) + + self.assertEqual(response.status_code, 404) + + def test_the_dropdown_reflects_the_role_specific_to_that_family(self): + # Same member, a different role in a second family -- the "others" bucket + # used to read person.family_memberships.first(), which could silently show + # a role from the wrong family. This is exactly that scenario. + other_family = Family.objects.create() + FamilyMembership.objects.create(family=other_family, member=self.member, role=FamilyMembership.FamilyRole.OTHER) + + this_family_response = self.club_get("family_detail", self.family.pk) + other_family_response = self.club_get("family_detail", other_family.pk) + + self.assertContains(this_family_response, 'value="child" selected') + self.assertContains(other_family_response, 'value="other" selected') + + def test_redirects_to_next_when_changed_from_the_member_page(self): + next_url = reverse("management:member_detail", args=[self.member.pk]) + + response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.GUARDIAN, "next": next_url}, self.family.pk, self.member.pk) + + self.assertRedirects(response, next_url) + + def test_ignores_an_unsafe_next_url(self): + response = self.club_post( + "family_membership_role_update", {"role": FamilyMembership.FamilyRole.GUARDIAN, "next": "https://evil.example.com/steal"}, self.family.pk, self.member.pk + ) + + self.assertRedirects(response, reverse("management:family_detail", args=[self.family.pk])) + + def test_member_detail_page_sends_its_own_url_as_next(self): + response = self.club_get("member_detail", self.member.pk) + + self.assertContains(response, f'name="next" value="{reverse("management:member_detail", args=[self.member.pk])}"') + + def test_family_detail_page_sends_no_next(self): + response = self.club_get("family_detail", self.family.pk) + + self.assertNotContains(response, 'name="next"') + + +class MembershipListViewTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + # The base class's own admin ClubMembership (fee_status defaults to UNPAID) + # would otherwise pollute every count below -- ClubRole (not ClubMembership) + # is what actually makes them an admin, so this is safe to drop. + ClubMembership.objects.filter(club=self.club, member=self.admin_member).delete() + + def make_membership(self, first_name, last_name, *, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID, season=None, license=""): + member = Member.objects.create(first_name=first_name, last_name=last_name) + return ClubMembership.objects.create(club=self.club, member=member, season=season or self.season, status=status, fee_status=fee_status, license=license) + + def test_kpi_counts_for_the_current_season(self): + self.make_membership("Paid", "One", fee_status=ClubMembership.FeeStatus.PAID) + self.make_membership("Partial", "One", fee_status=ClubMembership.FeeStatus.PARTIALLY_PAID) + self.make_membership("Unpaid", "One", fee_status=ClubMembership.FeeStatus.UNPAID) + self.make_membership("Unpaid", "Two", fee_status=ClubMembership.FeeStatus.UNPAID) + self.make_membership("Waived", "One", fee_status=ClubMembership.FeeStatus.WAIVED) + + response = self.club_get("membership_list") + + self.assertEqual(response.context["kpi_total"], 5) + self.assertEqual(response.context["kpi_paid"], 1) + self.assertEqual(response.context["kpi_partial"], 1) + self.assertEqual(response.context["kpi_unpaid"], 2) + self.assertEqual(response.context["kpi_waived"], 1) + self.assertEqual(response.context["kpi_paid_rate"], 20) + + def test_default_list_excludes_paid_but_includes_waived(self): + paid = self.make_membership("Paid", "One", fee_status=ClubMembership.FeeStatus.PAID) + unpaid = self.make_membership("Unpaid", "One", fee_status=ClubMembership.FeeStatus.UNPAID) + waived = self.make_membership("Waived", "One", fee_status=ClubMembership.FeeStatus.WAIVED) + + response = self.club_get("membership_list") + + ids = {m.pk for m in response.context["memberships"]} + self.assertNotIn(paid.pk, ids) + self.assertIn(unpaid.pk, ids) + self.assertIn(waived.pk, ids) + + def test_fee_status_filter_narrows_to_one_value(self): + unpaid = self.make_membership("Unpaid", "One", fee_status=ClubMembership.FeeStatus.UNPAID) + waived = self.make_membership("Waived", "One", fee_status=ClubMembership.FeeStatus.WAIVED) + + response = self.client.get(reverse("management:membership_list") + "?fee_status=waived", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertNotIn(unpaid.pk, ids) + self.assertIn(waived.pk, ids) + + def test_fee_status_all_includes_paid(self): + paid = self.make_membership("Paid", "One", fee_status=ClubMembership.FeeStatus.PAID) + + response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(paid.pk, ids) + + def test_status_filter(self): + active = self.make_membership("Active", "One", status=ClubMembership.StatusChoices.ACTIVE) + pending = self.make_membership("Pending", "One", status=ClubMembership.StatusChoices.PENDING) + + response = self.client.get(reverse("management:membership_list") + "?status=pending", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertNotIn(active.pk, ids) + self.assertIn(pending.pk, ids) + + def test_team_filter(self): + on_team = self.make_membership("OnTeam", "Kid") + off_team = self.make_membership("OffTeam", "Kid") + team = Team.objects.create(club=self.club, name="U10", short_name="U10") + position = Position.objects.create(club=self.club, name="Player", short_name="P") + TeamMembership.objects.create(team=team, member=on_team.member, season=self.season, position=position) + + response = self.client.get(reverse("management:membership_list") + f"?team={team.pk}", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(on_team.pk, ids) + self.assertNotIn(off_team.pk, ids) + + def test_search_filter(self): + match = self.make_membership("Findme", "Person") + other = self.make_membership("Other", "Person") + + response = self.client.get(reverse("management:membership_list") + "?q=Findme", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(match.pk, ids) + self.assertNotIn(other.pk, ids) + + def test_search_matches_by_family_surname(self): + # Searching "Smith" should find a family member even when their own name + # isn't Smith -- e.g. a parent with a different surname than their kid. + family = Family.objects.create() + smith_kid = self.make_membership("Junior", "Smith") + FamilyMembership.objects.create(family=family, member=smith_kid.member, role=FamilyMembership.FamilyRole.CHILD) + other_parent = self.make_membership("Alex", "Jones") + FamilyMembership.objects.create(family=family, member=other_parent.member, role=FamilyMembership.FamilyRole.PARENT) + unrelated = self.make_membership("Nobody", "Related") + + response = self.client.get(reverse("management:membership_list") + "?q=Smith", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(smith_kid.pk, ids) + self.assertIn(other_parent.pk, ids) + self.assertNotIn(unrelated.pk, ids) + + def test_search_matches_an_explicit_family_name(self): + family = Family.objects.create(name="The Andersons") + membership = self.make_membership("Pat", "Vandermeer") + FamilyMembership.objects.create(family=family, member=membership.member, role=FamilyMembership.FamilyRole.PARENT) + unrelated = self.make_membership("Nobody", "Related") + + response = self.client.get(reverse("management:membership_list") + "?q=Anderson", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(membership.pk, ids) + self.assertNotIn(unrelated.pk, ids) + + def test_family_column_shows_every_family_a_member_belongs_to(self): + family = Family.objects.create() + other_family = Family.objects.create() + membership = self.make_membership("Multi", "Family") + FamilyMembership.objects.create(family=family, member=membership.member, role=FamilyMembership.FamilyRole.CHILD) + FamilyMembership.objects.create(family=other_family, member=membership.member, role=FamilyMembership.FamilyRole.OTHER) + + response = self.club_get("membership_list") + + by_pk = {m.pk: m for m in response.context["memberships"]} + family_ids = {fm.family_id for fm in by_pk[membership.pk].member.family_memberships_display} + self.assertEqual(family_ids, {family.pk, other_family.pk}) + + def test_season_filter_switches_away_from_current(self): + old_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 8, 1), end_date=datetime.date(2021, 5, 31)) + current_membership = self.make_membership("Current", "Season") + old_membership = self.make_membership("Old", "Season", season=old_season, fee_status=ClubMembership.FeeStatus.UNPAID) + + response = self.client.get(reverse("management:membership_list") + f"?season={old_season.pk}", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(old_membership.pk, ids) + self.assertNotIn(current_membership.pk, ids) + # KPIs stay pinned to the *current* season regardless of the list's own filter. + self.assertEqual(response.context["current_season"], self.season) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-membership@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U19", short_name="U19") + position = Position.objects.create(club=self.club, name="Coach8", short_name="C8", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_get("membership_list") + + self.assertEqual(response.status_code, 403) + + def test_nav_entry_is_admin_only(self): + admin_response = self.club_get("home") + self.assertContains(admin_response, reverse("management:membership_list")) + + +class MembershipMarkPaidTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Owed", last_name="Fee") + self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID) + + def test_marking_paid_sets_active_and_paid(self): + response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]}) + + self.assertRedirects(response, reverse("management:membership_list")) + self.membership.refresh_from_db() + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) + self.assertEqual(self.membership.activated_at, timezone.localdate()) + + def test_marking_paid_grants_the_member_role(self): + # The whole reason this loops and calls .save() instead of .update() -- + # club/signals.py grants MEMBER via a post_save signal. + self.assertFalse(ClubRole.objects.filter(club=self.club, member=self.member, role=ClubRole.Roles.MEMBER).exists()) + + self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]}) + + self.assertTrue(ClubRole.objects.filter(club=self.club, member=self.member, role=ClubRole.Roles.MEMBER).exists()) + + def test_does_not_overwrite_an_existing_activated_at(self): + earlier = datetime.date(2026, 1, 1) + self.membership.activated_at = earlier + self.membership.save() + + self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]}) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.activated_at, earlier) + + def test_does_not_touch_unselected_rows(self): + other = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Not", last_name="Selected"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID) + + self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]}) + + other.refresh_from_db() + self.assertEqual(other.status, ClubMembership.StatusChoices.PENDING) + self.assertEqual(other.fee_status, ClubMembership.FeeStatus.UNPAID) + + def test_a_membership_from_another_club_is_ignored(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_season = make_season(other_club) + other_member = Member.objects.create(first_name="Other", last_name="Club") + other_membership = ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID) + + self.club_post("membership_mark_paid", {"membership_ids": [str(other_membership.pk)]}) + + other_membership.refresh_from_db() + self.assertEqual(other_membership.status, ClubMembership.StatusChoices.PENDING) + + def test_redirects_to_a_safe_next(self): + next_url = reverse("management:membership_list") + "?status=pending" + + response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)], "next": next_url}) + + self.assertRedirects(response, next_url) + + def test_ignores_an_unsafe_next(self): + response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)], "next": "https://evil.example.com/"}) + + self.assertRedirects(response, reverse("management:membership_list")) + + def test_nothing_selected_is_a_harmless_no_op(self): + response = self.club_post("membership_mark_paid", {}) + + self.assertRedirects(response, reverse("management:membership_list")) + self.membership.refresh_from_db() + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-markpaid@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U20", short_name="U20") + position = Position.objects.create(club=self.club, name="Coach9", short_name="C9", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]}) + + self.assertEqual(response.status_code, 403) + self.membership.refresh_from_db() + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING) + + +class MembershipRecordPaymentTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Owed", last_name="Fee") + self.membership = ClubMembership.objects.create( + club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00") + ) + + def test_recording_a_partial_payment(self): + response = self.club_post("membership_record_payment", {"amount": "50.00", "method": FeePayment.Method.CASH, "reference": "R1"}, self.membership.pk) + + self.assertRedirects(response, reverse("management:membership_list")) + self.membership.refresh_from_db() + self.assertEqual(self.membership.amount_paid, Decimal("50.00")) + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID) + payment = FeePayment.objects.get(membership=self.membership) + self.assertEqual(payment.reference, "R1") + self.assertEqual(payment.recorded_by, self.admin_user) + + def test_recording_the_full_remaining_amount_settles_it(self): + self.club_post("membership_record_payment", {"amount": "150.00", "method": FeePayment.Method.BANK_TRANSFER}, self.membership.pk) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE) + + def test_an_invalid_amount_is_rejected_without_recording_anything(self): + response = self.club_post("membership_record_payment", {"amount": "0", "method": FeePayment.Method.CASH}, self.membership.pk) + + self.assertRedirects(response, reverse("management:membership_list")) + self.assertFalse(FeePayment.objects.filter(membership=self.membership).exists()) + self.membership.refresh_from_db() + self.assertEqual(self.membership.amount_paid, Decimal("0.00")) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-recordpay@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U22", short_name="U22") + position = Position.objects.create(club=self.club, name="Coach11", short_name="C11", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("membership_record_payment", {"amount": "50.00", "method": FeePayment.Method.CASH}, self.membership.pk) + + self.assertEqual(response.status_code, 403) + self.assertFalse(FeePayment.objects.filter(membership=self.membership).exists()) + + +class MembershipMarkFullyPaidTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Owed", last_name="Fee") + self.membership = ClubMembership.objects.create( + club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00") + ) + + def test_settles_the_remaining_balance_in_one_click(self): + response = self.club_post("membership_mark_fully_paid", {}, self.membership.pk) + + self.assertRedirects(response, reverse("management:membership_list")) + self.membership.refresh_from_db() + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(self.membership.amount_paid, Decimal("150.00")) + + def test_a_membership_from_another_club_404s(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_season = make_season(other_club) + other_member = Member.objects.create(first_name="Other", last_name="Club") + other_membership = ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, fee_amount=Decimal("100.00")) + + response = self.club_post("membership_mark_fully_paid", {}, other_membership.pk) + + self.assertEqual(response.status_code, 404) + + def test_redirects_to_a_safe_next(self): + next_url = reverse("management:membership_list") + "?status=pending" + + response = self.club_post("membership_mark_fully_paid", {"next": next_url}, self.membership.pk) + + self.assertRedirects(response, next_url) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-fullypaid@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U23", short_name="U23") + position = Position.objects.create(club=self.club, name="Coach12", short_name="C12", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("membership_mark_fully_paid", {}, self.membership.pk) + + self.assertEqual(response.status_code, 403) + self.membership.refresh_from_db() + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING) + + +class MembershipListButtonVisibilityTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def make_membership(self, fee_status): + member = Member.objects.create(first_name=fee_status, last_name="Row") + return ClubMembership.objects.create(club=self.club, member=member, season=self.season, fee_status=fee_status, fee_amount=Decimal("100.00")) + + def test_buttons_hidden_for_a_paid_row(self): + membership = self.make_membership(ClubMembership.FeeStatus.PAID) + + response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertNotContains(response, reverse("management:membership_mark_fully_paid", args=[membership.pk])) + + def test_buttons_hidden_for_a_waived_row(self): + membership = self.make_membership(ClubMembership.FeeStatus.WAIVED) + + response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertNotContains(response, reverse("management:membership_mark_fully_paid", args=[membership.pk])) + + def test_buttons_shown_for_an_unpaid_row(self): + membership = self.make_membership(ClubMembership.FeeStatus.UNPAID) + + response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertContains(response, reverse("management:membership_mark_fully_paid", args=[membership.pk])) + + +class RenderPdfTests(TestCase): + """management.pdf.render_pdf itself -- see billing/tests.py's equivalent + coverage of billing.services.invoices.render_pdf, same lazy-import shape.""" + + def test_the_pdf_library_is_only_needed_when_a_pdf_is_asked_for(self): + 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("

hi

"), 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(PDFExportError): + render_pdf("

hi

") + + +class MembershipExportPdfTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Print", last_name="Me") + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID) + + def test_downloads_as_a_pdf(self): + with mock.patch("management.views.membership_list_pdf", return_value=b"%PDF-fake") as renderer: + response = self.club_get("membership_export_pdf") + + self.assertEqual(response["Content-Type"], "application/pdf") + self.assertIn(".pdf", response["Content-Disposition"]) + self.assertEqual(response.content, b"%PDF-fake") + renderer.assert_called_once() + + def test_export_uses_the_same_filters_as_the_page(self): + # Same fixture as the "on-team" filter test for the page itself -- the PDF + # must reflect whatever's filtered, not the whole club. + other = Member.objects.create(first_name="Other", last_name="Person") + ClubMembership.objects.create(club=self.club, member=other, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID) + + with mock.patch("management.views.membership_list_pdf", return_value=b"%PDF-fake") as renderer: + self.client.get(reverse("management:membership_export_pdf") + "?q=Print", HTTP_HOST="ajax-united.rosterchief.app") + + context = renderer.call_args[0][0] + names = {m.member.last_name for m in context["memberships"]} + self.assertEqual(names, {"Me"}) + + def test_a_missing_pdf_library_is_reported_rather_than_a_500(self): + # WeasyPrint needs native libs. Without them the button must explain itself. + with mock.patch("management.views.membership_list_pdf", side_effect=PDFExportError("PDF rendering needs the native pango/cairo libraries.")): + response = self.club_get("membership_export_pdf") + response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app") + + self.assertContains(response, "pango") + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-export@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U21", short_name="U21") + position = Position.objects.create(club=self.club, name="Coach10", short_name="C10", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_get("membership_export_pdf") + + self.assertEqual(response.status_code, 403) + + +class MemberListRowActionsTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.member = Member.objects.create(first_name="Row", last_name="Actions") + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_admin_sees_edit_and_delete_buttons(self): + self.client.force_login(self.admin_user) + + response = self.club_get("member_list") + + self.assertContains(response, reverse("management:member_delete", args=[self.member.pk])) + + def test_non_admin_does_not_see_edit_and_delete_buttons(self): + # Roster self.member on the coach's team so this actually tests the + # admin-only button gating, not just "the coach can't see this row at all". + coach_user = User.objects.create_user(email="coach5@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U16", short_name="U16") + staff_position = Position.objects.create(club=self.club, name="Coach5", short_name="C5", staff_position=True, management_position=True) + player_position = Position.objects.create(club=self.club, name="Player5", short_name="P5", staff_position=False) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=staff_position) + TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=player_position) + self.client.force_login(coach_user) + + response = self.club_get("member_list") + + self.assertContains(response, "Row") + self.assertNotContains(response, reverse("management:member_delete", args=[self.member.pk])) + + +class HomeViewTests(ManagementTestBase): + """The dashboard reuses controlpanel.services.statistics' club_attention/ + club_charts/club_statistics -- already club-scoped, so directly usable for this + club's own staff. Financial pieces (fee chart, Shop stat group) are admin-only, + same line the nav already draws around the Shop section.""" + + def make_coach(self, email): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name=f"Team-{email}", short_name="T") + position = Position.objects.create(club=self.club, name=f"Coach-{email}", short_name="C", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + return coach_user + + def test_admin_sees_the_financial_sections(self): + self.client.force_login(self.admin_user) + + response = self.club_get("home") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="fees-chart"') + self.assertContains(response, 'id="signups-chart"') + self.assertContains(response, "Renewal rate") + self.assertContains(response, "Open carts") + self.assertContains(response, "md:grid-cols-4") + + def test_non_admin_staff_does_not_see_the_financial_sections(self): + self.client.force_login(self.make_coach("coach6@example.com")) + + response = self.club_get("home") + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'id="fees-chart"') + self.assertNotContains(response, 'id="signups-chart"') + self.assertNotContains(response, "Renewal rate") + self.assertNotContains(response, "Open carts") + self.assertContains(response, "md:grid-cols-3") + + def test_published_news_is_shown_to_everyone(self): + item = News.objects.create(club=self.club, title="Season kickoff", body="Body.") + item.publish() + self.client.force_login(self.make_coach("coach-news-home@example.com")) + + response = self.club_get("home") + + self.assertContains(response, "Season kickoff") + + def test_a_draft_or_scheduled_news_item_is_not_shown_on_the_home_page(self): + draft = News.objects.create(club=self.club, title="Still a draft", body="Body.") + scheduled = News.objects.create(club=self.club, title="Scheduled for later", body="Body.") + scheduled.publish(at=timezone.now() + datetime.timedelta(days=7)) + self.client.force_login(self.admin_user) + + response = self.club_get("home") + + self.assertNotContains(response, draft.title) + self.assertNotContains(response, scheduled.title) + + def test_upcoming_events_are_listed_in_order_and_future_only(self): + now = timezone.now() + past = Event.objects.create(club=self.club, kind=Event.EventKind.TRAINING, title="Past training", start=now - datetime.timedelta(days=1)) + soon = Event.objects.create(club=self.club, kind=Event.EventKind.TRAINING, title="Sooner training", start=now + datetime.timedelta(days=1)) + later = Event.objects.create(club=self.club, kind=Event.EventKind.GAME, title="Later game", start=now + datetime.timedelta(days=5)) + self.client.force_login(self.admin_user) + + response = self.club_get("home") + + body = response.content.decode() + self.assertNotIn(past.title, body) + self.assertLess(body.index(soon.title), body.index(later.title)) + + def test_loads_fine_with_no_season_and_no_events(self): + # A fresh club, never given a season -- ClubMembership.season is PROTECT, so + # this is a new club rather than deleting self.season out from under setUp's + # own ClubMembership. + empty_club = Club.objects.create(name="Empty FC", slug="empty-fc") + admin_member = Member.objects.create(first_name="Empty", last_name="Admin") + ClubRole.objects.create(club=empty_club, member=admin_member, role=ClubRole.Roles.ADMIN) + admin_user = User.objects.create_user(email="emptyadmin@example.com", password="pw-secret-123") + admin_member.user = admin_user + admin_member.save() + enrol_mfa(admin_user) + self.client.force_login(admin_user) + + with override_settings(ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "empty-fc.rosterchief.app", "testserver"]): + response = self.client.get(reverse("management:home"), HTTP_HOST="empty-fc.rosterchief.app") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "cannot take a signup") + self.assertContains(response, "Nothing scheduled.") + + +class MemberBulkImportTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def make_non_admin_staff(self): + coach_user = User.objects.create_user(email="coach-import@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U14", short_name="U14") + position = Position.objects.create(club=self.club, name="CoachImport", short_name="CI", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + return coach_user + + def test_template_download_has_expected_headers_and_dropdowns(self): + response = self.club_get("member_import_template") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], XLSX_CONTENT_TYPE) + + workbook = openpyxl.load_workbook(BytesIO(response.content)) + sheet = workbook.active + header = [cell.value for cell in sheet[1]] + self.assertEqual(header, TEMPLATE_COLUMNS) + self.assertTrue(sheet.data_validations.dataValidation) + + def test_non_admin_can_download_template_but_not_upload(self): + coach_user = self.make_non_admin_staff() + self.client.force_login(coach_user) + + template_response = self.club_get("member_import_template") + upload_response = self.club_post("member_import", {"file": make_import_workbook([["Jamie", "Kid", "", "jamie@example.com", "", "", "", "", ""]])}) + + self.assertEqual(template_response.status_code, 200) + self.assertEqual(upload_response.status_code, 403) + + def test_anonymous_is_redirected_to_login_for_both(self): + self.client.logout() + + self.assertEqual(self.club_get("member_import_template").status_code, 302) + self.assertEqual(self.club_get("member_import").status_code, 302) + + def test_a_clean_row_previews_as_will_create(self): + upload = make_import_workbook([["Jamie", "Kid", "2010-01-01", "jamie.kid@example.com", "+32470111111", "", "", "", ""]]) + + response = self.club_post("member_import", {"file": upload}) + + self.assertEqual(response.context["valid_count"], 1) + self.assertEqual(response.context["skipped_count"], 0) + result = response.context["results"][0] + self.assertIsNotNone(result["member"]) + self.assertEqual(result["membership_kwargs"]["status"], ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(result["membership_kwargs"]["fee_status"], ClubMembership.FeeStatus.UNPAID) + + def test_a_row_missing_a_required_name_is_skipped(self): + upload = make_import_workbook([["", "Noname", "", "", "", "", "", "", ""]]) + + response = self.club_post("member_import", {"file": upload}) + + self.assertEqual(response.context["valid_count"], 0) + result = response.context["results"][0] + self.assertIsNone(result["member"]) + self.assertTrue(result["errors"]) + + def test_an_invalid_email_is_skipped(self): + upload = make_import_workbook([["Bad", "Email", "", "not-an-email", "", "", "", "", ""]]) + + response = self.club_post("member_import", {"file": upload}) + + result = response.context["results"][0] + self.assertIsNone(result["member"]) + + def test_an_invalid_status_value_is_skipped(self): + upload = make_import_workbook([["Bad", "Status", "", "bad.status@example.com", "", "", "", "not-a-status", ""]]) + + response = self.club_post("member_import", {"file": upload}) + + result = response.context["results"][0] + self.assertIsNone(result["member"]) + self.assertTrue(any("status" in error.lower() for error in result["errors"])) + + def test_a_duplicate_email_within_the_file_is_flagged_on_the_second_row(self): + upload = make_import_workbook( + [ + ["First", "Dup", "", "dup@example.com", "", "", "", "", ""], + ["Second", "Dup", "", "dup@example.com", "", "", "", "", ""], + ] + ) + + response = self.club_post("member_import", {"file": upload}) + + results = response.context["results"] + self.assertIsNotNone(results[0]["member"]) + self.assertIsNone(results[1]["member"]) + self.assertIn("Duplicate email in this file.", results[1]["errors"]) + + def test_an_email_already_in_the_club_is_skipped(self): + upload = make_import_workbook([["Ada", "Admin", "", self.admin_user.email, "", "", "", "", ""]]) + + response = self.club_post("member_import", {"file": upload}) + + result = response.context["results"][0] + self.assertIsNone(result["member"]) + self.assertIn("Already a member of this club.", result["errors"]) + + def test_confirm_creates_only_the_valid_rows_with_defaults(self): + upload = make_import_workbook( + [ + ["Jamie", "Kid", "2010-01-01", "jamie.kid@example.com", "", "", "LIC-1", "", ""], + ["", "Noname", "", "", "", "", "", "", ""], + ] + ) + self.club_post("member_import", {"file": upload}) + + response = self.club_post("member_import_confirm", {}) + + self.assertRedirects(response, reverse("management:member_list")) + member = Member.objects.get(email="jamie.kid@example.com") + membership = ClubMembership.objects.get(club=self.club, member=member) + self.assertEqual(membership.status, ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(membership.fee_status, ClubMembership.FeeStatus.UNPAID) + self.assertEqual(membership.license, "LIC-1") + self.assertFalse(Member.objects.filter(last_name="Noname").exists()) + + def test_confirm_without_a_prior_upload_creates_nothing(self): + response = self.club_post("member_import_confirm", {}) + + self.assertRedirects(response, reverse("management:member_import")) + self.assertEqual(Member.objects.filter(last_name="Kid").count(), 0) + + def test_confirm_is_admin_only(self): + coach_user = self.make_non_admin_staff() + upload = make_import_workbook([["Jamie", "Kid", "", "jamie.kid2@example.com", "", "", "", "", ""]]) + self.club_post("member_import", {"file": upload}) + + self.client.force_login(coach_user) + response = self.club_post("member_import_confirm", {}) + + self.assertEqual(response.status_code, 403) + + +class NewsManagementTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + + def make_coach_manager(self, email="coach-news@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position) + return coach_user + + def make_plain_staff(self, email="physio-news@example.com"): + staff_user = User.objects.create_user(email=email, password="pw-secret-123") + staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio") + position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False) + StaffAssignment.objects.create(team=self.team, member=staff_member, season=self.season, position=position) + return staff_user + + def make_editor(self, email="editor-news@example.com"): + editor_user = User.objects.create_user(email=email, password="pw-secret-123") + editor_member = Member.objects.create(user=editor_user, first_name="Eve", last_name="Editor") + ClubMembership.objects.create(club=self.club, member=editor_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + ClubRole.objects.filter(club=self.club, member=editor_member).update(role=ClubRole.Roles.EDITOR) + enrol_mfa(editor_user) # ClubRole ADMIN/EDITOR requires a second factor; StaffAssignment-only doesn't. + return editor_user + + def test_list_is_scoped_to_the_club(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + News.objects.create(club=other_club, title="Rival news", body="Body.") + self.client.force_login(self.admin_user) + + response = self.club_get("news_list") + + self.assertNotContains(response, "Rival news") + + def test_a_coach_manager_can_create_a_draft(self): + self.client.force_login(self.make_coach_manager()) + + response = self.club_post("news_create", {"title": "Season Kickoff", "body": "Big news.", "visibility": News.Visibility.INTERNAL, "teams": [str(self.team.pk)]}) + + item = News.objects.get(club=self.club, title="Season Kickoff") + self.assertRedirects(response, reverse("management:news_detail", args=[item.pk])) + self.assertEqual(item.status, News.Status.DRAFT) + + def test_plain_staff_cannot_create_news(self): + self.client.force_login(self.make_plain_staff()) + + response = self.club_post("news_create", {"title": "Not allowed", "body": "Body.", "visibility": News.Visibility.INTERNAL}) + + self.assertEqual(response.status_code, 403) + self.assertFalse(News.objects.filter(club=self.club, title="Not allowed").exists()) + + def test_coach_manager_cannot_publish(self): + item = News.objects.create(club=self.club, title="Draft item", body="Body.") + self.client.force_login(self.make_coach_manager()) + + response = self.club_post("news_publish", {"published_at": "2026-08-10T10:00"}, item.pk) + + self.assertEqual(response.status_code, 403) + item.refresh_from_db() + self.assertEqual(item.status, News.Status.DRAFT) + + def test_editor_can_publish(self): + item = News.objects.create(club=self.club, title="Draft item", body="Body.") + self.client.force_login(self.make_editor()) + + self.club_post("news_publish", {"published_at": "2026-08-10T10:00"}, item.pk) + + item.refresh_from_db() + self.assertEqual(item.status, News.Status.PUBLISHED) + + def test_publishing_with_a_future_date_leaves_it_scheduled(self): + item = News.objects.create(club=self.club, title="Draft item", body="Body.") + self.client.force_login(self.make_editor()) + future = timezone.now() + datetime.timedelta(days=7) + + self.club_post("news_publish", {"published_at": future.strftime("%Y-%m-%dT%H:%M")}, item.pk) + + item.refresh_from_db() + self.assertTrue(item.is_scheduled) + + def test_publishing_with_now_makes_it_live_immediately(self): + item = News.objects.create(club=self.club, title="Draft item", body="Body.") + self.client.force_login(self.make_editor()) + + self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M")}, item.pk) + + item.refresh_from_db() + self.assertFalse(item.is_scheduled) + + def test_unpublishing_reverts_to_draft(self): + item = News.objects.create(club=self.club, title="Live item", body="Body.") + item.publish() + self.client.force_login(self.make_editor()) + + self.club_post("news_unpublish", {}, item.pk) + + item.refresh_from_db() + self.assertEqual(item.status, News.Status.DRAFT) + self.assertIsNone(item.published_at) + + def test_a_coach_manager_can_edit_someone_elses_draft(self): + item = News.objects.create(club=self.club, title="Old title", body="Body.") + self.client.force_login(self.make_coach_manager()) + + self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk) + + item.refresh_from_db() + self.assertEqual(item.title, "New title") + + def test_a_coach_manager_cannot_edit_once_published(self): + item = News.objects.create(club=self.club, title="Old title", body="Body.") + item.publish() + self.client.force_login(self.make_coach_manager()) + + response = self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk) + + self.assertEqual(response.status_code, 403) + + def test_an_editor_can_still_edit_once_published(self): + item = News.objects.create(club=self.club, title="Old title", body="Body.") + item.publish() + self.client.force_login(self.make_editor()) + + self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk) + + item.refresh_from_db() + self.assertEqual(item.title, "New title") + + def test_uploading_multiple_photos_creates_one_per_file_and_marks_the_first_main(self): + item = News.objects.create(club=self.club, title="Match report", body="Body.") + self.client.force_login(self.make_coach_manager()) + images = [ + SimpleUploadedFile("one.jpg", b"fake-bytes-one", content_type="image/jpeg"), + SimpleUploadedFile("two.jpg", b"fake-bytes-two", content_type="image/jpeg"), + ] + + self.club_post("news_photo_upload", {"images": images}, item.pk) + + self.assertEqual(item.photos.count(), 2) + self.assertEqual(item.photos.filter(is_main=True).count(), 1) + + def test_set_main_moves_the_main_flag(self): + item = News.objects.create(club=self.club, title="Match report", body="Body.") + first = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"), is_main=True) + second = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("two.jpg", b"two", content_type="image/jpeg"), is_main=False) + self.client.force_login(self.make_coach_manager()) + + self.club_post("news_photo_set_main", {}, item.pk, second.pk) + + first.refresh_from_db() + second.refresh_from_db() + self.assertFalse(first.is_main) + self.assertTrue(second.is_main) + + def test_deleting_a_photo_removes_it(self): + item = News.objects.create(club=self.club, title="Match report", body="Body.") + photo = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg")) + photo_path = photo.image.path + self.client.force_login(self.make_coach_manager()) + + self.club_post("news_photo_delete", {}, item.pk, photo.pk) + + self.assertFalse(NewsPhoto.objects.filter(pk=photo.pk).exists()) + self.assertFalse(os.path.exists(photo_path)) + + def test_deleting_the_main_photo_promotes_another_one(self): + item = News.objects.create(club=self.club, title="Match report", body="Body.") + main = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"), is_main=True) + other = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("two.jpg", b"two", content_type="image/jpeg"), is_main=False) + self.client.force_login(self.make_coach_manager()) + + self.club_post("news_photo_delete", {}, item.pk, main.pk) + + other.refresh_from_db() + self.assertTrue(other.is_main) + + def test_deleting_the_only_photo_leaves_nothing_to_promote(self): + item = News.objects.create(club=self.club, title="Match report", body="Body.") + photo = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"), is_main=True) + self.client.force_login(self.make_coach_manager()) + + response = self.club_post("news_photo_delete", {}, item.pk, photo.pk) + + self.assertEqual(response.status_code, 302) + self.assertEqual(item.photos.count(), 0) + + def test_a_coach_manager_can_delete_a_draft(self): + item = News.objects.create(club=self.club, title="Draft item", body="Body.") + self.client.force_login(self.make_coach_manager()) + + response = self.club_post("news_delete", {}, item.pk) + + self.assertRedirects(response, reverse("management:news_list")) + self.assertFalse(News.objects.filter(pk=item.pk).exists()) + + def test_a_coach_manager_cannot_delete_once_published(self): + item = News.objects.create(club=self.club, title="Live item", body="Body.") + item.publish() + self.client.force_login(self.make_coach_manager()) + + response = self.club_post("news_delete", {}, item.pk) + + self.assertEqual(response.status_code, 403) + self.assertTrue(News.objects.filter(pk=item.pk).exists()) + + def test_an_editor_can_delete_once_published(self): + item = News.objects.create(club=self.club, title="Live item", body="Body.") + item.publish() + self.client.force_login(self.make_editor()) + + self.club_post("news_delete", {}, item.pk) + + self.assertFalse(News.objects.filter(pk=item.pk).exists()) + + def test_deleting_a_news_item_removes_its_photos(self): + item = News.objects.create(club=self.club, title="Match report", body="Body.") + photo = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg")) + photo_path = photo.image.path + self.client.force_login(self.make_coach_manager()) + + self.club_post("news_delete", {}, item.pk) + + self.assertFalse(NewsPhoto.objects.filter(pk=photo.pk).exists()) + self.assertFalse(os.path.exists(photo_path)) + + def test_the_edit_and_delete_buttons_are_hidden_once_published_for_a_coach_manager(self): + item = News.objects.create(club=self.club, title="Live item", body="Body.") + item.publish() + self.client.force_login(self.make_coach_manager()) + + response = self.club_get("news_list") + + self.assertNotContains(response, reverse("management:news_update", args=[item.pk])) + + +class TeamAttendancePanelTests(ManagementTestBase): + """The attendance KPI panel on the team page -- see + management.views.TeamDetailView and events.services.attendance.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.position = Position.objects.create(club=self.club, name="Forward", short_name="FW", staff_position=False) + self.player = Member.objects.create(first_name="Peter", last_name="Player") + ClubMembership.objects.create(club=self.club, member=self.player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.position) + + def make_past_training(self, days_ago=1): + event = Event.objects.create(club=self.club, title="Practice", kind=Event.EventKind.TRAINING, season=self.season, start=timezone.now() - datetime.timedelta(days=days_ago)) + event.teams.add(self.team) + return event + + def test_attendance_panel_shows_the_rate_and_rankings(self): + event = self.make_past_training() + Attendance.objects.create(event=event, member=self.player, status=Attendance.AttendanceStatus.PRESENT) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "Attendance rate") + self.assertContains(response, "Peter Player") + + def test_a_present_rsvp_without_a_check_in_is_never_a_no_show(self): + event = self.make_past_training() + Attendance.objects.create(event=event, member=self.player, status=Attendance.AttendanceStatus.PRESENT) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "None recorded.") + + def test_a_checked_in_no_show_appears_in_the_panel(self): + event = self.make_past_training() + attendance = Attendance.objects.create(event=event, member=self.player, status=Attendance.AttendanceStatus.PRESENT, showed_up=False) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "Peter Player") + self.assertContains(response, attendance.event.title) + self.assertNotContains(response, "None recorded.") + + +class TeamAndPositionAccessTests(ManagementTestBase): + """Non-admin coaches/managers: scoped to their own teams, read-only on + positions -- see club.mixins.TeamManagerRequiredMixin and + management.views.TeamListView/PositionListView.""" + + def setUp(self): + super().setUp() + self.own_team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + self.manager_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + + self.coach_user = User.objects.create_user(email="coach3@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=self.coach_user, first_name="Cara", last_name="Coach") + StaffAssignment.objects.create(team=self.own_team, member=coach_member, season=self.season, position=self.manager_position) + + def test_a_coach_only_sees_their_own_team_in_the_list(self): + self.client.force_login(self.coach_user) + + response = self.club_get("team_list") + + self.assertContains(response, "First Team") + self.assertNotContains(response, "Second Team") + + def test_an_admin_sees_every_team_in_the_list(self): + self.client.force_login(self.admin_user) + + response = self.club_get("team_list") + + self.assertContains(response, "First Team") + self.assertContains(response, "Second Team") + + def test_a_coach_does_not_see_the_new_team_button(self): + self.client.force_login(self.coach_user) + + response = self.club_get("team_list") + + self.assertNotContains(response, reverse("management:team_create")) + + def test_a_coach_does_not_see_the_edit_button_on_their_team_page(self): + self.client.force_login(self.coach_user) + + response = self.club_get("team_detail", self.own_team.pk) + + self.assertNotContains(response, reverse("management:team_update", args=[self.own_team.pk])) + + def test_a_coach_can_view_positions_but_not_edit_them(self): + self.client.force_login(self.coach_user) + + response = self.club_get("position_list") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Head Coach") + self.assertNotContains(response, reverse("management:position_create")) + self.assertNotContains(response, reverse("management:position_update", args=[self.manager_position.pk])) + + def test_a_coach_cannot_create_or_edit_a_position(self): + self.client.force_login(self.coach_user) + + self.assertEqual(self.club_get("position_create").status_code, 403) + self.assertEqual(self.club_get("position_update", self.manager_position.pk).status_code, 403) + + +class TeamListCountsTests(ManagementTestBase): + """Player/staff counts on the team list -- see TeamListView.get_queryset.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW", staff_position=False) + self.coach_position = Position.objects.create(club=self.club, name="Coach", short_name="C", staff_position=True, management_position=True) + self.client.force_login(self.admin_user) + + def test_counts_reflect_the_current_seasons_roster_and_staff(self): + peter = Member.objects.create(first_name="Peter", last_name="Player") + paula = Member.objects.create(first_name="Paula", last_name="Player") + cara = Member.objects.create(first_name="Cara", last_name="Coach") + TeamMembership.objects.create(team=self.team, season=self.season, member=peter, position=self.player_position) + TeamMembership.objects.create(team=self.team, season=self.season, member=paula, position=self.player_position) + StaffAssignment.objects.create(team=self.team, season=self.season, member=cara, position=self.coach_position) + + response = self.club_get("team_list") + + team = response.context["teams"].get(pk=self.team.pk) + self.assertEqual(team.player_count, 2) + self.assertEqual(team.staff_count, 1) + + def test_counts_exclude_a_different_season(self): + other_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) + peter = Member.objects.create(first_name="Peter", last_name="Player") + TeamMembership.objects.create(team=self.team, season=other_season, member=peter, position=self.player_position) + + response = self.club_get("team_list") + + team = response.context["teams"].get(pk=self.team.pk) + self.assertEqual(team.player_count, 0) + + +class LocationOpponentManagementTests(ManagementTestBase): + """Full CRUD for Location/Opponent -- restricted to ADMIN and anyone with a + current-season management position, see club.mixins.ManagementPositionRequiredMixin + and management.views.LocationListView/OpponentListView (and their Create/Update/Delete + siblings).""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + + def make_coach_manager(self, email="coach-loc@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position) + return coach_user + + def make_plain_staff(self, email="physio-loc@example.com"): + staff_user = User.objects.create_user(email=email, password="pw-secret-123") + staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio") + position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False) + StaffAssignment.objects.create(team=self.team, member=staff_member, season=self.season, position=position) + return staff_user + + # --- Locations --------------------------------------------------------- + + def test_a_management_position_can_view_the_location_list(self): + Location.objects.create(club=self.club, name="Main Field", address="1 St", city="Town", zip_code="1000", country="BE") + self.client.force_login(self.make_coach_manager()) + + response = self.club_get("location_list") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Main Field") + + def test_the_location_form_renders_country_as_a_dropdown(self): + # Regression: CountryField's widget reports widget_type "lazyselect", which + # the form_field templatetag didn't recognise -- it fell through to the + # "input" case and rendered a plain (i.e. a + # broken text box), not a (single or + * `multiple`): a small typeahead combobox, a select2-alike without the + * dependency. Type to filter, click or Enter to pick; the hidden + {% else %} + + {% endif %} + + {% if slots.help_text %} + {% slot help_text %}{% endslot %} + {% endif %} + diff --git a/templates/allauth/elements/fields.html b/templates/allauth/elements/fields.html new file mode 100644 index 0000000..6481eae --- /dev/null +++ b/templates/allauth/elements/fields.html @@ -0,0 +1,102 @@ +{% load lucide ui %} + +{% comment %} + allauth passes `unlabeled=True` on the entrance forms (login, signup, reset) and + already sets a placeholder on each of their fields ("Email address", "Password"), + so dropping the visible label loses nothing on screen. The label is still emitted + sr-only inside the wrapper: a placeholder is not a label, and it disappears the + moment you start typing. + + Fields with an icon use daisyUI's icon-in-field layout, where the `input` class + goes on the *wrapping label* and the input itself carries only `grow`. Putting + `input` on both draws a box inside a box. +{% endcomment %} +{% for field in attrs.form.hidden_fields %}{{ field }}{% endfor %} +{% for error in attrs.form.non_field_errors %} +
+ {{ error }} +
+{% endfor %} +{% comment %} + `exclude` lets a page lay a field out itself — the login page puts "remember" on the + button row. It is pinned to a real variable first, and that is not cosmetic: passing + `attrs.exclude` straight into a filter on a page that never set it raises + VariableDoesNotExist, which Django catches for the *main* variable of an expression + but not for a filter *argument*. {% if %} then swallows it and reads the condition as + false — silently dropping every field on every form that doesn't pass `exclude`. +{% endcomment %} +{% with exclude=attrs.exclude|default:"" %} + {% for field in attrs.form.visible_fields %} + {% if not field|excluded:exclude %} +
+ {% if field.field.widget.input_type == "checkbox" %} + + {% else %} + {% if not attrs.unlabeled and not field|is_otp %} + + {% endif %} + {% with icon=field|field_icon %} + {% if field|is_otp %} + {% comment %} + daisyUI's otp is one input overlaid on the boxes. Child order is load-bearing: + it places each box with `nth-child`, which counts *every* child, not just the + spans — so the input must come LAST. Put it first and all six boxes shift one + stride right, the container matches :has(>span:nth-child(7)) and grows to + seven strides, and the ::after active-box marker (invisible until focus) shows + up in the gap as a phantom seventh box. + + For the same reason the sr-only label lives outside the container: another + child would throw the count off again. + + The overlaid input carries daisyUI's own `pointer-events: none` (it exists so + clicks land on the boxes, not a naked input) — which also means a tap/click on + the boxes never reaches the input directly. A
+ {% endif %} + {% endfor %} +{% endwith %} diff --git a/templates/allauth/elements/form.html b/templates/allauth/elements/form.html new file mode 100644 index 0000000..3bbba1a --- /dev/null +++ b/templates/allauth/elements/form.html @@ -0,0 +1,23 @@ +{% load allauth %} + +{% comment %} + `id` is not decorative: allauth renders out-of-band forms (webauthn, logout-from-stage) + and points buttons at them with the HTML `form` attribute. Drop the id and those + buttons submit nothing. + + The action bar is drawn when the actions slot has something in it — NOT keyed on + `no_visible_fields`, which means the form has no visible *fields* (logout is a bare + csrf token, and so is TOTP deactivate) and says nothing about its buttons. Keying the + bar on it hides the Sign Out button on the very page whose only purpose is that button. +{% endcomment %} +
+ {% slot body %}{% endslot %} + {% if slots.actions %} +
+ {% slot actions %}{% endslot %} +
+ {% endif %} +
diff --git a/templates/allauth/elements/h1.html b/templates/allauth/elements/h1.html new file mode 100644 index 0000000..92a6edb --- /dev/null +++ b/templates/allauth/elements/h1.html @@ -0,0 +1 @@ +{% comment %} djlint:off {% endcomment %}{% load allauth %}

{% slot %}{% endslot %}

diff --git a/templates/allauth/elements/h2.html b/templates/allauth/elements/h2.html new file mode 100644 index 0000000..0018ab0 --- /dev/null +++ b/templates/allauth/elements/h2.html @@ -0,0 +1 @@ +{% comment %} djlint:off {% endcomment %}{% load allauth %}

{% slot %}{% endslot %}

diff --git a/templates/allauth/elements/hr.html b/templates/allauth/elements/hr.html new file mode 100644 index 0000000..31684e3 --- /dev/null +++ b/templates/allauth/elements/hr.html @@ -0,0 +1 @@ +{% comment %} djlint:off {% endcomment %}{% load allauth %}
diff --git a/templates/allauth/elements/img.html b/templates/allauth/elements/img.html new file mode 100644 index 0000000..c7a607d --- /dev/null +++ b/templates/allauth/elements/img.html @@ -0,0 +1,10 @@ +{% load allauth %} + +{% comment %} + Only used for the TOTP QR code. The white plate is not decoration: the QR is dark + modules on a transparent ground, so on the dark theme it would be dark-on-dark and + phones could not scan it. +{% endcomment %} +
+ +
diff --git a/templates/allauth/elements/p.html b/templates/allauth/elements/p.html new file mode 100644 index 0000000..04cd76c --- /dev/null +++ b/templates/allauth/elements/p.html @@ -0,0 +1 @@ +{% comment %} djlint:off {% endcomment %}{% load allauth %}

{% slot %}{% endslot %}

diff --git a/templates/allauth/elements/panel.html b/templates/allauth/elements/panel.html new file mode 100644 index 0000000..44c3931 --- /dev/null +++ b/templates/allauth/elements/panel.html @@ -0,0 +1,9 @@ +{% load allauth %} +
+
+ {% if slots.title %}

{% slot title %}{% endslot %}

{% endif %} +
{% slot body %}{% endslot %}
+ {% if slots.actions %} +
{% slot actions %}{% endslot %}
{% endif %} +
+
diff --git a/templates/allauth/elements/table.html b/templates/allauth/elements/table.html new file mode 100644 index 0000000..bf01d82 --- /dev/null +++ b/templates/allauth/elements/table.html @@ -0,0 +1,6 @@ +{% load allauth %} +
+ + {% slot %}{% endslot %} +
+
diff --git a/templates/allauth/layouts/base.html b/templates/allauth/layouts/base.html new file mode 100644 index 0000000..ac71a41 --- /dev/null +++ b/templates/allauth/layouts/base.html @@ -0,0 +1,20 @@ +{% extends base_template %} + +{% comment %} + Not a fixed parent: `base_template` is resolved per request by the branding context + processor, so every auth screen allauth ships -- login, password reset, MFA, passkeys, + and whatever it adds next -- picks up the club's skin on a club subdomain and the + RosterChief one on the base domain, with no template of its own knowing clubs exist. + + The title is left to the chosen base, which suffixes the club or the platform name. +{% endcomment %} + +{% block main %} +
+
+
+ {% block content %}{% endblock content %} +
+
+
+{% endblock main %} diff --git a/templates/maintenance.html b/templates/maintenance.html new file mode 100644 index 0000000..5b6df86 --- /dev/null +++ b/templates/maintenance.html @@ -0,0 +1,24 @@ +{% extends base_template %} +{% load lucide %} + +{% comment %} + Rendered through the tenant's own skin, so a club sees its own logo and colours rather + than a bare error page — this is the platform being unavailable, not the club being gone. +{% endcomment %} + +{% block head_title %}Maintenance{% endblock head_title %} + +{% block main %} +
+
+
+
{% lucide "wrench" size=48 %}
+

Down for maintenance

+

{{ message }}

+ {% if maintenance.started_at %} +

Since {{ maintenance.started_at|date:"j M Y, H:i" }}

+ {% endif %} +
+
+
+{% endblock main %} diff --git a/templates/mfa/authenticate.html b/templates/mfa/authenticate.html new file mode 100644 index 0000000..925302c --- /dev/null +++ b/templates/mfa/authenticate.html @@ -0,0 +1,73 @@ +{% extends "mfa/base_entrance.html" %} +{% load allauth i18n lucide %} + +{% comment %} + Overridden for layout only: Cancel sits beside Sign In, and "Use a security key" is an + accent button under Alternative options. The code field itself is styled by the `fields` + element (it renders a `code` field as a daisyUI otp), so the reauthenticate and TOTP + activate pages get the same input without a template of their own. + + Cancel and the security key submit *other* forms (`logout-from-stage`, `webauthn_form`) + through the HTML `form` attribute — forms cannot nest, so those sit at the end of the + page and the buttons point at them by id. +{% endcomment %} + +{% block head_title %} + {% trans "Sign In" %} +{% endblock head_title %} + +{% block content %} + {% element h1 %} + {% trans "Two-Factor Authentication" %} + {% endelement %} + {% element p %} + {% blocktranslate %}Your account is protected by two-factor authentication. Please enter an authenticator code:{% endblocktranslate %} + {% endelement %} + +
+ {% csrf_token %} + +
+ {% element fields form=form unlabeled=True %} + {% endelement %} +
+ +
+ + +
+
+ + {% if "webauthn" in MFA_SUPPORTED_TYPES %} + {% element hr %} + {% endelement %} + {% element h2 %} + {% translate "Alternative options" %} + {% endelement %} + + + +
+ {% csrf_token %} + {{ webauthn_form.credential }} +
+ {{ js_data|json_script:"js_data" }} + {% include "mfa/webauthn/snippets/scripts.html" %} + + {% endif %} + +
+ + {% csrf_token %} +
+{% endblock content %} diff --git a/templates/mfa/index.html b/templates/mfa/index.html new file mode 100644 index 0000000..1281248 --- /dev/null +++ b/templates/mfa/index.html @@ -0,0 +1,129 @@ +{% extends "mfa/base_manage.html" %} +{% load allauth i18n %} + +{% comment %} + allauth's own index, with an icon on every button and the recovery-code actions given + the ranking they deserve: View is the primary action, Download and Generate are outline + (Generate silently invalidates the codes you already have, so it must not look like the + obvious thing to click). +{% endcomment %} + +{% block head_title %} + {% trans "Two-Factor Authentication" %} +{% endblock head_title %} + +{% block content %} + {% element h1 tags="mfa,index" %} + {% trans "Two-Factor Authentication" %} + {% endelement %} + + {% if "totp" in MFA_SUPPORTED_TYPES %} + {% element panel %} + {% slot title %} + {% translate "Authenticator App" %} + {% endslot %} + {% slot body %} + {% if authenticators.totp %} + {% element p %} + {% translate "Authentication using an authenticator app is active." %} + {% endelement %} + {% else %} + {% element p %} + {% translate "An authenticator app is not active." %} + {% endelement %} + {% endif %} + {% endslot %} + {% slot actions %} + {% url 'mfa_deactivate_totp' as deactivate_url %} + {% url 'mfa_activate_totp' as activate_url %} + {% if authenticators.totp %} + {% element button href=deactivate_url icon="power" tags="danger,delete,panel" %} + {% translate "Deactivate" %} + {% endelement %} + {% else %} + {% element button href=activate_url icon="qr-code" tags="panel" %} + {% translate "Activate" %} + {% endelement %} + {% endif %} + {% endslot %} + {% endelement %} + {% endif %} + + {% if "webauthn" in MFA_SUPPORTED_TYPES %} + {% element panel %} + {% slot title %} + {% translate "Security Keys" %} + {% endslot %} + {% slot body %} + {% if authenticators.webauthn|length %} + {% element p %} + {% blocktranslate count count=authenticators.webauthn|length %}You have added {{ count }} security key.{% plural %}You have added {{ count }} security keys.{% endblocktranslate %} + {% endelement %} + {% else %} + {% element p %} + {% translate "No security keys have been added." %} + {% endelement %} + {% endif %} + {% endslot %} + {% slot actions %} + {% if authenticators.webauthn|length %} + {% url 'mfa_list_webauthn' as webauthn_list_url %} + {% element button href=webauthn_list_url icon="settings" %} + {% translate "Manage" %} + {% endelement %} + {% else %} + {% url 'mfa_add_webauthn' as webauthn_add_url %} + {% element button href=webauthn_add_url icon="user-key" %} + {% translate "Add" %} + {% endelement %} + {% endif %} + {% endslot %} + {% endelement %} + {% endif %} + + {% if "recovery_codes" in MFA_SUPPORTED_TYPES %} + {% with total_count=authenticators.recovery_codes.generate_codes|length unused_count=authenticators.recovery_codes.get_unused_codes|length %} + {% element panel %} + {% slot title %} + {% translate "Recovery Codes" %} + {% endslot %} + {% slot body %} + {% if authenticators.recovery_codes %} + {% element p %} + {% blocktranslate count unused_count=unused_count %}There is {{ unused_count }} out of {{ total_count }} recovery codes available.{% plural %}There are {{ unused_count }} out of {{ total_count }} recovery codes available. + {% endblocktranslate %} + {% endelement %} + {% else %} + {% element p %} + {% translate "No recovery codes set up." %} + {% endelement %} + {% endif %} + {% endslot %} + {% if is_mfa_enabled %} + {% if authenticators.recovery_codes %} + {% if unused_count > 0 and not MFA_RECOVERY_CODES_SHOW_ONCE %} + {% slot actions %} + {% url 'mfa_view_recovery_codes' as view_url %} + {% element button href=view_url icon="eye" tags="panel" %} + {% translate "View" %} + {% endelement %} + {% endslot %} + {% slot actions %} + {% url 'mfa_download_recovery_codes' as download_url %} + {% element button href=download_url icon="download" tags="outline,panel" %} + {% translate "Download" %} + {% endelement %} + {% endslot %} + {% endif %} + {% endif %} + {% slot actions %} + {% url 'mfa_generate_recovery_codes' as generate_url %} + {% element button href=generate_url icon="refresh-cw" tags="outline,panel" %} + {% translate "Generate" %} + {% endelement %} + {% endslot %} + {% endif %} + {% endelement %} + {% endwith %} + {% endif %} +{% endblock content %} diff --git a/templates/mfa/recovery_codes/generate.html b/templates/mfa/recovery_codes/generate.html new file mode 100644 index 0000000..25c8dfd --- /dev/null +++ b/templates/mfa/recovery_codes/generate.html @@ -0,0 +1,35 @@ +{% extends "mfa/recovery_codes/base.html" %} +{% load i18n allauth %} + +{% block content %} + {% element h1 %} + {% translate "Recovery Codes" %} + {% endelement %} + {% element p %} + {% blocktranslate %}You are about to generate a new set of recovery codes for your account.{% endblocktranslate %} + {% if unused_code_count %} + {% blocktranslate %}This action will invalidate your existing codes.{% endblocktranslate %} + {% endif %} + {% blocktranslate %}Are you sure?{% endblocktranslate %} + {% endelement %} + + {% url 'mfa_generate_recovery_codes' as action_url %} + {% element form method="post" action=action_url no_visible_fields=True %} + {% slot body %} + {% csrf_token %} + {{ form.as_p }} + {% endslot %} + {% slot actions %} + {# Destructive when codes already exist: generating throws the old ones away. #} + {% setvar tags %} + {% if unused_code_count %} + danger + {% else %} + {% endif %} + {% endsetvar %} + {% element button type="submit" icon="refresh-cw" tags=tags %} + {% trans "Generate" %} + {% endelement %} + {% endslot %} + {% endelement %} +{% endblock content %} diff --git a/templates/mfa/recovery_codes/index.html b/templates/mfa/recovery_codes/index.html new file mode 100644 index 0000000..f17d8c3 --- /dev/null +++ b/templates/mfa/recovery_codes/index.html @@ -0,0 +1,65 @@ +{% extends "mfa/recovery_codes/base.html" %} +{% load i18n allauth static %} + +{% comment %} + Overridden for layout: Download and Generate sit side by side under the codes rather + than stacking. Generate is outline-only — it silently invalidates the codes on screen, + so it must not be the button your eye lands on. +{% endcomment %} + +{% block content %} + {% element h1 %} + {% translate "Recovery Codes" %} + {% endelement %} + {% element p %} + {% blocktranslate count unused_count=unused_codes|length %}There is {{ unused_count }} out of {{ total_count }} recovery codes available.{% plural %}There are {{ unused_count }} out of {{ total_count }} recovery codes available.{% endblocktranslate %} + {% endelement %} + + {% if can_view_codes %} + {% element field id="recovery_codes" type="textarea" rows=unused_codes|length readonly=True %} + {% slot label %} + {% translate "Unused codes" %} + {% endslot %} + {# djlint:off #} +{% slot value %}{% for code in unused_codes %}{% if forloop.counter0 %} +{% endif %}{{ code }}{% endfor %}{% endslot %} + {# djlint:on #} + {% endelement %} + {% endif %} + +
+ {% if can_download_codes %} + {% url 'mfa_download_recovery_codes' as download_url %} + {% element button href=download_url icon="download" %} + {% translate "Download codes" %} + {% endelement %} + {% endif %} + {% if can_generate_codes %} + {% url 'mfa_generate_recovery_codes' as generate_url %} + {% element button href=generate_url icon="refresh-cw" tags="outline" %} + {% translate "Generate new codes" %} + {% endelement %} + {% endif %} +
+ + {% if MFA_RECOVERY_CODES_SHOW_ONCE and can_view_codes %} + {% element field type="checkbox" id="codes_saved" %} + {% slot label %} + {% translate "I have saved my recovery codes" %} + {% endslot %} + {% endelement %} + {% endif %} +{% endblock content %} + +{% block extra_body %} + {{ block.super }} + + + +{% endblock extra_body %} diff --git a/templates/mfa/totp/activate_form.html b/templates/mfa/totp/activate_form.html new file mode 100644 index 0000000..c81c39a --- /dev/null +++ b/templates/mfa/totp/activate_form.html @@ -0,0 +1,45 @@ +{% extends "mfa/totp/base.html" %} +{% load allauth i18n %} + +{% comment %} + The code field is boxed and label-less (the `fields` element does that for any `code` + field), and the secret sits in a mono field with room around it — it is copied by hand, + character by character. +{% endcomment %} + +{% block head_title %} + {% translate "Activate Authenticator App" %} +{% endblock head_title %} + +{% block content %} + {% element h1 %} + {% translate "Activate Authenticator App" %} + {% endelement %} + {% element p %} + {% blocktranslate %}To protect your account with two-factor authentication, scan the QR code below with your authenticator app. Then, input the verification code generated by the app below.{% endblocktranslate %} + {% endelement %} + + {% url 'mfa_activate_totp' as action_url %} + {% element form form=form method="post" action=action_url %} + {% slot body %} + {% element img src=totp_svg_data_uri alt=form.secret tags="mfa,totp,qr" %} + {% endelement %} + {% csrf_token %} + {% element field id="authenticator_secret" type="text" value=form.secret disabled=True %} + {% slot label %} + {% translate "Authenticator secret" %} + {% endslot %} + {% slot help_text %} + {% translate "You can store this secret and use it to reinstall your authenticator app at a later time." %} + {% endslot %} + {% endelement %} + {% element fields form=form %} + {% endelement %} + {% endslot %} + {% slot actions %} + {% element button type="submit" icon="check" %} + {% trans "Activate" %} + {% endelement %} + {% endslot %} + {% endelement %} +{% endblock content %} diff --git a/templates/mfa/totp/deactivate_form.html b/templates/mfa/totp/deactivate_form.html new file mode 100644 index 0000000..36736bb --- /dev/null +++ b/templates/mfa/totp/deactivate_form.html @@ -0,0 +1,30 @@ +{% extends "mfa/totp/base.html" %} +{% load allauth i18n %} + +{% block head_title %} + {% trans "Deactivate Authenticator App" %} +{% endblock head_title %} + +{% block content %} + {% element h1 %} + {% trans "Deactivate Authenticator App" %} + {% endelement %} + {% element p %} + {% blocktranslate %}You are about to deactivate authenticator app based authentication. Are you sure?{% endblocktranslate %} + {% endelement %} + + {% url 'mfa_deactivate_totp' as action_url %} + {% element form form=form method="post" action=action_url no_visible_fields=True %} + {% slot body %} + {% csrf_token %} + {% element fields form=form %} + {{ form.as_p }} + {% endelement %} + {% endslot %} + {% slot actions %} + {% element button type="submit" icon="power" tags="danger,delete" %} + {% trans "Deactivate" %} + {% endelement %} + {% endslot %} + {% endelement %} +{% endblock content %} diff --git a/templates/mfa/webauthn/add_form.html b/templates/mfa/webauthn/add_form.html new file mode 100644 index 0000000..27a17bc --- /dev/null +++ b/templates/mfa/webauthn/add_form.html @@ -0,0 +1,39 @@ +{% extends "mfa/webauthn/base.html" %} +{% load allauth i18n static %} + +{% block head_title %} + {% trans "Add Security Key" %} +{% endblock head_title %} + +{% block content %} + {% element h1 %} + {% trans "Add Security Key" %} + {% endelement %} + + {% url 'mfa_add_webauthn' as action_url %} + {% element form form=form method="post" action=action_url %} + {% slot body %} + {% csrf_token %} + {% element fields form=form %} + {% endelement %} + {% endslot %} + {% slot actions %} + {# type="button": the webauthn script takes over the click and posts the form itself. #} + {% element button id="mfa_webauthn_add" type="button" icon="user-key" %} + {% trans "Add" %} + {% endelement %} + {% endslot %} + {% endelement %} + + {% include "mfa/webauthn/snippets/scripts.html" %} + {{ js_data|json_script:"js_data" }} + +{% endblock content %} diff --git a/templates/mfa/webauthn/authenticator_confirm_delete.html b/templates/mfa/webauthn/authenticator_confirm_delete.html new file mode 100644 index 0000000..82e9945 --- /dev/null +++ b/templates/mfa/webauthn/authenticator_confirm_delete.html @@ -0,0 +1,25 @@ +{% extends "mfa/webauthn/base.html" %} +{% load allauth i18n %} + +{% block head_title %} + {% trans "Remove Security Key" %} +{% endblock head_title %} + +{% block content %} + {% element h1 %} + {% trans "Remove Security Key" %} + {% endelement %} + {% element p %} + {% blocktranslate %}Are you sure you want to remove this security key?{% endblocktranslate %} + {% endelement %} + + {% url 'mfa_remove_webauthn' pk=authenticator.pk as action_url %} + {% element form method="post" action=action_url no_visible_fields=True %} + {% slot actions %} + {% csrf_token %} + {% element button type="submit" icon="trash-2" tags="danger" %} + {% translate "Remove" %} + {% endelement %} + {% endslot %} + {% endelement %} +{% endblock content %} diff --git a/templates/mfa/webauthn/authenticator_list.html b/templates/mfa/webauthn/authenticator_list.html new file mode 100644 index 0000000..2ed3050 --- /dev/null +++ b/templates/mfa/webauthn/authenticator_list.html @@ -0,0 +1,72 @@ +{% extends "mfa/webauthn/base.html" %} +{% load i18n %} +{% load static %} +{% load allauth %} +{% load humanize %} +{% block content %} + {% element h1 %} + {% trans "Security Keys" %} + {% endelement %} + {% if authenticators|length == 0 %} + {% element p %} + {% blocktranslate %}No security keys have been added.{% endblocktranslate %} + {% endelement %} + {% else %} + {% element table %} + {% element thead %} + {% element th %} + {% translate "Key" %} + {% endelement %} + {% element th %} + {% translate "Usage" %} + {% endelement %} + {% element th %} + {% endelement %} + {% endelement %} + {% element tbody %} + {% for authenticator in authenticators %} + {% element tr %} + {% element td %} + {{ authenticator }} + {% if authenticator.wrap.is_passwordless is True %} + {% element badge tags="mfa,key,primary" %} + {% translate "Passkey" %} + {% endelement %} + {% elif authenticator.wrap.is_passwordless is False %} + {% element badge tags="mfa,key,secondary" %} + {% translate "Security key" %} + {% endelement %} + {% else %} + {% element badge title=_("This key does not indicate whether it is a passkey.") tags="mfa,key,warning" %} + {% translate "Unspecified" %} + {% endelement %} + {% endif %} + {% endelement %} + {% element td %} + {% blocktranslate with created_at=authenticator.created_at|date:"SHORT_DATE_FORMAT" %}Added on {{ created_at }}{% endblocktranslate %}. + {% if authenticator.last_used_at %} + {% blocktranslate with last_used=authenticator.last_used_at|naturaltime %}Last used {{ last_used }}{% endblocktranslate %} + {% else %} + Not used. + {% endif %} + {% endelement %} + {% element td align="right" %} + {% url 'mfa_edit_webauthn' pk=authenticator.pk as edit_url %} + {% element button href=edit_url icon="pencil" tags="mfa,authenticator,edit,tool" %} + {% translate "Edit" %} + {% endelement %} + {% url 'mfa_remove_webauthn' pk=authenticator.pk as remove_url %} + {% element button href=remove_url icon="trash-2" tags="mfa,authenticator,danger,delete,tool" %} + {% translate "Remove" %} + {% endelement %} + {% endelement %} + {% endelement %} + {% endfor %} + {% endelement %} + {% endelement %} + {% endif %} + {% url 'mfa_add_webauthn' as add_url %} + {% element button href=add_url icon="user-key" %} + {% translate "Add" %} + {% endelement %} +{% endblock %} diff --git a/uv.lock b/uv.lock index 8a55f2c..087f760 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "asgiref" version = "3.11.1" @@ -12,24 +21,311 @@ wheels = [ ] [[package]] -name = "clubmanager" -version = "0.1.0" -source = { virtual = "." } +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "boto3" +version = "1.43.47" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/76/8c2b062b9a9f27db8d78c3183213efd2cb31493b9a81853025da5254652e/boto3-1.43.47.tar.gz", hash = "sha256:09a8ce4abab4391bf08315e2dcc449b4b33e97ce7654ee9398d9fe4ef73a33da", size = 112681, upload-time = "2026-07-13T19:32:13.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/86/4aaa23e1433a6f4021258b1d8e4ec129bf691e419339454806807595a93d/boto3-1.43.47-py3-none-any.whl", hash = "sha256:aef0cccd5bc4a443fb3e64fada023f7b5b9da36864cb4255b68cf93067476676", size = 140031, upload-time = "2026-07-13T19:32:11.072Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.47" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/2c/279bf51f68e85a12323996aa4a7f2a163da84dad949ee751caa318928ce1/botocore-1.43.47.tar.gz", hash = "sha256:9e04d8da7f9cff8a911b14284829f78b74e1ce785444833199837decb5ecc17a", size = 15696311, upload-time = "2026-07-13T19:32:01.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/c0/6a8f824d613c5e5b8755ce99e3cf83fd1db135f5df02da7ed5bf106abdfd/botocore-1.43.47-py3-none-any.whl", hash = "sha256:4d16559a3a1866fa7b9e651dd1422c1a033497e069f8a73cae5eebafe70ff39c", size = 15382537, upload-time = "2026-07-13T19:31:56.213Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + +[[package]] +name = "cssselect2" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tinycss2" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, +] + +[[package]] +name = "dj-database-url" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] - -[package.dev-dependencies] -dev = [ - { name = "ruff" }, +sdist = { url = "https://files.pythonhosted.org/packages/03/f6/00b625e9d371b980aa261011d0dc906a16444cb688f94215e0dc86996eb5/dj_database_url-3.1.2.tar.gz", hash = "sha256:63c20e4bbaa51690dfd4c8d189521f6bf6bc9da9fcdb23d95d2ee8ee87f9ec62", size = 11490, upload-time = "2026-02-19T15:30:23.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/a9/57c66006373381f1d3e5bd94216f1d371228a89f443d3030e010f73dd198/dj_database_url-3.1.2-py3-none-any.whl", hash = "sha256:544e015fee3efa5127a1eb1cca465f4ace578265b3671fe61d0ed7dbafb5ec8a", size = 8953, upload-time = "2026-02-19T15:30:39.37Z" }, ] -[package.metadata] -requires-dist = [{ name = "django", specifier = ">=6.0.6" }] - -[package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.15.17" }] - [[package]] name = "django" version = "6.0.6" @@ -44,6 +340,531 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" }, ] +[[package]] +name = "django-allauth" +version = "65.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/10/2d33c5bc3c0f9d7db1276a1babcaadd147e73eb416ccb1bd9523c6ac925f/django_allauth-65.18.0.tar.gz", hash = "sha256:afb82e2c545b9a5539370ad120468faaed84715e095a7b6e76972dacdc6376fe", size = 2246988, upload-time = "2026-05-29T13:01:26.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/75/5a4c2bf050e38ab16549395a7cd751ddcf3a18305bff4852cfd6c0d94998/django_allauth-65.18.0-py3-none-any.whl", hash = "sha256:e508640c83b94eaf1a9b4e9ac5332dc310dcdc83fca40d58a3f670e15bad1e84", size = 2061080, upload-time = "2026-05-29T13:01:16.532Z" }, +] + +[package.optional-dependencies] +mfa = [ + { name = "fido2" }, + { name = "qrcode" }, +] + +[[package]] +name = "django-browser-reload" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/ef/ab407c1a2f14e13c75a6419a2d124954aa0364f62580ad95a3d31dc6c73d/django_browser_reload-1.21.0.tar.gz", hash = "sha256:3335ad3d107eb657f623d1a8e680dfbcab8a83ae1f94df1895e069dddf5604ba", size = 15800, upload-time = "2025-09-22T17:00:35.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/61/1b4a8c589652859995bcab87682286443eb9fdf2d7fd584975b9ffc1db33/django_browser_reload-1.21.0-py3-none-any.whl", hash = "sha256:0b2a86ab460774fa9bb142a121c70e75a72f18109f51a4f6de409cd633d3a70d", size = 12852, upload-time = "2025-09-22T17:00:33.479Z" }, +] + +[[package]] +name = "django-countries" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/1e8feb818164c3f23465c08cc3bc1cd7da7101506268a138a1b92ea40339/django_countries-9.0.0.tar.gz", hash = "sha256:a993416af08a8a4e6e866d56b71c7ce92351c81a2543da213cd7899917567a42", size = 614144, upload-time = "2026-06-10T00:39:28.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/bb/5a17b339852f6486d02b4dd0033132082976c71f049ee909ccb42917be52/django_countries-9.0.0-py3-none-any.whl", hash = "sha256:21fce461733c856355c487d1f24b71338599482505004a0c2e521fd574a59fb1", size = 931759, upload-time = "2026-06-10T00:39:26.851Z" }, +] + +[[package]] +name = "django-lucide" +version = "1.3.1" +source = { git = "https://github.com/bsiebens/lucide#d2cdc609c30ad09701c9d044427adef5cec87500" } + +[[package]] +name = "django-ninja" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/7c/3307e17b872f545c88314b2737a22f965785dfb5a120d739b0131d0492c3/django_ninja-1.6.2.tar.gz", hash = "sha256:d56ae5aa4791068ef4ac9a66cfdf2fc11f507413ded35abb79c51d0d52ad6412", size = 3685599, upload-time = "2026-03-18T20:06:47.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" }, +] + +[[package]] +name = "django-phonenumber-field" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/bf/8aa60c9834773b955dff1ddea842e361e8daaf6b0945d5bfc29fc66d53ab/django_phonenumber_field-8.4.0.tar.gz", hash = "sha256:2b83e843dac35eec6a69880a166487235b737a71a1e38c9a52e5ad67d6996083", size = 45512, upload-time = "2025-11-24T15:09:51.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/a3/f6b85a9246e22cf719752ad83f5e95aa9ba12419b2f9eff70f20d30e55df/django_phonenumber_field-8.4.0-py3-none-any.whl", hash = "sha256:7a1cb3a6456edb54d879f11ffa0acb227ded08c93b587035d0f28093f0e46511", size = 69528, upload-time = "2025-11-24T15:09:45.479Z" }, +] + +[package.optional-dependencies] +phonenumbers = [ + { name = "phonenumbers" }, +] + +[[package]] +name = "django-redis" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "redis" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/78/203a0cdc0f1c083a5407d01f77b58099a120bb8a5a04562f56a9bb341314/django_redis-7.0.0.tar.gz", hash = "sha256:e48491c862f4350b0747ceb1016700686fb93c4f4e0fb9c490fe6c6658ffd933", size = 64601, upload-time = "2026-06-02T14:17:48.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/9f/09cdb9a1eebe8533b02a7694ca787acfc1e4d93b5b6175ff99366d4e6d64/django_redis-7.0.0-py3-none-any.whl", hash = "sha256:4b23aa6e0cd0937bb1242e9a463809e6004de3ca2150f34e986306bb6220d688", size = 38932, upload-time = "2026-06-02T14:17:47.281Z" }, +] + +[[package]] +name = "django-storages" +version = "1.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" }, +] + +[package.optional-dependencies] +s3 = [ + { name = "boto3" }, +] + +[[package]] +name = "django-waffle" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/e1/6f533da0d4ac89f427dfd9410e39bfc14ae3a23335ecd549d76be4b2a834/django_waffle-5.0.0.tar.gz", hash = "sha256:62f9d00eedf68dafb82657beab56e601bddedc1ea1ccfef91d83df8658708509", size = 37761, upload-time = "2025-06-12T07:38:54.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/d2/6f0d664bd35a3fdd0403655c7c32ec290704923f11541ef356b180cd8fbf/django_waffle-5.0.0-py3-none-any.whl", hash = "sha256:3312851d9d926b76b9e90712355781700a383b82b5bf2b61e1f1be97532c0f3d", size = 48137, upload-time = "2025-06-12T07:38:53.698Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "fido2" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/ea/6f08c354b7aeb8019249d46a86c2153f8218499cced4d21bf16b6d49fc16/fido2-2.2.1.tar.gz", hash = "sha256:85787428a94c3f8eaf72f0ff30afba983b559a1b1b795c93318c81b4ad4062c4", size = 327147, upload-time = "2026-06-29T17:41:11.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/84/198d99c3312557ef6121cf78c38281efe9b3bc88cba0e2c05446f38a024d/fido2-2.2.1-py3-none-any.whl", hash = "sha256:ed397da981b9ab133da6ead7309e41f924b566b749956129efe286fae097749f", size = 238354, upload-time = "2026-06-29T17:41:09.921Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[package.optional-dependencies] +woff = [ + { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, + { name = "zopfli" }, +] + +[[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "phonenumbers" +version = "9.0.33" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/37/dfc4cf24169f1a7169ebaedaf896c818f0add8603409d1e748e3085ccdc0/phonenumbers-9.0.33.tar.gz", hash = "sha256:9ab8a02b940b90c64f3866c0b25a30e567ddf7bb9836a3e11efdb0478f65fc1c", size = 2306756, upload-time = "2026-06-22T10:23:33.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/29/f7e30e3dbd3c7e3d9c4a55006112c04ee62b4765a31f21bcc28c253ac3f1/phonenumbers-9.0.33-py2.py3-none-any.whl", hash = "sha256:ba1d0da52711d5fdda6b2b673b2621fe80774fc5d1b2e5a6ef783396b0343186", size = 2595422, upload-time = "2026-06-22T10:23:29.925Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pydyf" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716, upload-time = "2025-12-02T14:52:14.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028, upload-time = "2025-12-02T14:52:12.938Z" }, +] + +[[package]] +name = "pyphen" +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-decouple" +version = "3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/97/373dcd5844ec0ea5893e13c39a2c67e7537987ad8de3842fe078db4582fa/python-decouple-3.8.tar.gz", hash = "sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f", size = 9612, upload-time = "2023-03-01T19:38:38.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/d4/9193206c4563ec771faf2ccf54815ca7918529fe81f6adb22ee6d0e06622/python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66", size = 9947, upload-time = "2023-03-01T19:38:36.015Z" }, +] + +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[[package]] +name = "redis" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rosterchief" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "dj-database-url" }, + { name = "django" }, + { name = "django-allauth", extra = ["mfa"] }, + { name = "django-countries" }, + { name = "django-lucide" }, + { name = "django-ninja" }, + { name = "django-phonenumber-field", extra = ["phonenumbers"] }, + { name = "django-redis" }, + { name = "django-storages", extra = ["s3"] }, + { name = "django-waffle" }, + { name = "gunicorn" }, + { name = "openpyxl" }, + { name = "pillow" }, + { name = "psycopg", extra = ["binary"] }, + { name = "python-dateutil" }, + { name = "python-decouple" }, + { name = "requests" }, + { name = "weasyprint" }, + { name = "whitenoise" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage" }, + { name = "django-browser-reload" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.15.0" }, + { name = "dj-database-url", specifier = ">=3.1.2" }, + { name = "django", specifier = ">=6.0.6" }, + { name = "django-allauth", extras = ["mfa"], specifier = ">=65.18.0" }, + { name = "django-countries", specifier = ">=9.0.0" }, + { name = "django-lucide", git = "https://github.com/bsiebens/lucide" }, + { name = "django-ninja", specifier = ">=1.6.2" }, + { name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" }, + { name = "django-redis", specifier = ">=7.0.0" }, + { name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" }, + { name = "django-waffle", specifier = ">=5.0.0" }, + { name = "gunicorn", specifier = ">=26.0.0" }, + { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "pillow", specifier = ">=12.3.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, + { name = "python-dateutil", specifier = ">=2.9.0.post0" }, + { name = "python-decouple", specifier = ">=3.8" }, + { name = "requests", specifier = ">=2.34.2" }, + { name = "weasyprint", specifier = ">=69.0" }, + { name = "whitenoise", specifier = ">=6.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", specifier = ">=7.15.0" }, + { name = "django-browser-reload", specifier = ">=1.21.0" }, + { name = "ruff", specifier = ">=0.15.17" }, +] + [[package]] name = "ruff" version = "0.15.17" @@ -69,6 +890,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] +[[package]] +name = "s3transfer" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/da/4bef7ce7bb989b222aa4785a413896dbec53306dfc59c6ce7d16a7ffbd6a/s3transfer-0.19.1.tar.gz", hash = "sha256:d3d6371dc3f1e5c5427b2b457bcf13bcf87bec334c95aed18642eae61f6926f3", size = 165354, upload-time = "2026-07-10T19:32:04.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/23/e84c64ad0e8bc59cd1b2ef98def848deff0ef3456c542afe74d51e9e8c85/s3transfer-0.19.1-py3-none-any.whl", hash = "sha256:d5fd7005ee39307455ad5f310b5ea67f4b1960d7fed5b3671ee50c249de675de", size = 90072, upload-time = "2026-07-10T19:32:03.673Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" }, +] + [[package]] name = "sqlparse" version = "0.5.5" @@ -78,6 +929,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + +[[package]] +name = "tinyhtml5" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/1f/cfe2f6b30557c92b3f31d41707e09cef5c1efbd87392bc6c0430c46b0e4d/tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67", size = 179242, upload-time = "2026-03-05T17:06:30.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2026.2" @@ -86,3 +982,65 @@ sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35c wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "weasyprint" +version = "69.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "cssselect2" }, + { name = "fonttools", extra = ["woff"] }, + { name = "pillow" }, + { name = "pydyf" }, + { name = "pyphen" }, + { name = "tinycss2" }, + { name = "tinyhtml5" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834, upload-time = "2026-06-02T14:42:17.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/cb/208525c6bd5033d7b2589b55e07bec23d9c61bb00703cbaf20ef52c3811f/weasyprint-69.0-py3-none-any.whl", hash = "sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6", size = 322872, upload-time = "2026-06-02T14:42:15.871Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, +] + +[[package]] +name = "zopfli" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" }, + { url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" }, + { url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" }, +]