Compare commits

31 Commits

Author SHA1 Message Date
40255805c3 Modularize confirmation modal for destructive POST actions and add notify helper for concise message handling across the UI. 2026-07-16 18:34:03 +02:00
127d0e338e Modularize and streamline billing templates; replace _billing_form.html with reusable modals and shared partials, and update styles and interactions for consistency and clarity. billing, feature management, and forms
Refactored club detail templates to modularize common UI components. Standardized layout, interactions, and styles across admin, billing, and feature cards for consistency and reusability.
2026-07-16 16:44:48 +02:00
83caa233d7 Replace doughnut chart with pie chart in fees visualization and clean up unused CSS styles. 2026-07-16 08:38:17 +02:00
fc7a349f8f Add "Open" button to club detail for direct access to club subdomain 2026-07-16 00:12:29 +02:00
f403128f57 Improve club detail layout: enhance logo display, adjust badge styles, refine card layouts, and optimize spacing 2026-07-16 00:06:54 +02:00
91270b0cf8 Add "Plan end" column to health table and simplify badge styles
The health table now includes a "Plan end" column showing the coverage end date ("-" if none). Simplified several badge styles for consistency, replacing "n/a" with "-" and updating waived badges to use a ghost-outline class.
2026-07-15 18:28:02 +02:00
19108407c6 Show the cover-end date for waived periods too, not just paid
The "until <date>" only appeared for a PAID due, because the annotation filtered
status=PAID. A WAIVED period is settled just the same — the club is covered for
that time, and its end is still when grace would start — so it belongs there too.

The annotation is now `covered_until` (furthest-out period end where status is PAID
or WAIVED) plus `covered_status`, read from the same ordered row so the table can
badge "paid" vs "waived" and still show the date for both. Rides the same single
query — assertNumQueries(1) still holds.

Full matrix now: no plan -> n/a; paid -> "paid, until X"; waived -> "waived, until
X"; unpaid/partial -> the amount owed (no date); a paid-then-owing club shows the
amount, not the stale cover date. A subscribed club with no covering period at all
(only cancelled dues) shows a dash rather than a bare "paid".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:16:56 +02:00
10b113f244 Read the health-check port from the server's WEB_PORT
The probe now reads WEB_PORT from the server's .env — the same file compose reads —
and builds the health URL from it, falling back to 8001 (compose's own default) when
it is unset. No more passing HEALTH_URL by hand when the published port changes.

Parsed the way compose parses it: last assignment wins, surrounding quotes and
inline whitespace stripped, commented lines ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:25:39 +02:00
5b51f2c945 Point HOME at /app so gunicorn stops erroring on boot
gunicorn 26's control server creates a socket in $HOME. The app user has no home
directory, so every boot logged "Permission denied: /home/rosterchief" — harmless
but noisy. /app is the workdir and already owned by the app user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:18:10 +02:00
4d74f3fbde Always allow the loopback in ALLOWED_HOSTS, for health checks
/healthz is hit over 127.0.0.1 (the deploy probe) and localhost (the container's
own healthcheck) before any proxy has supplied a real Host header. With only the
public domain in ALLOWED_HOSTS, Django 400s both, the container is marked unhealthy
forever, and the deploy never goes green — which is how the first real deploy
failed.

The loopback is now appended unconditionally. It widens nothing: gunicorn binds to
the loopback only and the proxy owns the public domains, so nothing external can
present these hosts. Tested with a non-loopback ALLOWED_HOSTS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:15:08 +02:00
a1266378fc Fix deploy script: compose run was eating the rest of the heredoc
The whole remote script is fed to `ssh bash -s` as stdin (a heredoc). `docker
compose run` without -T attaches that stdin to the container, so it consumed every
line after the migrate — web was never restarted and no health check ran, yet the
script exited 0 and printed "Done". A deploy that half-ran and reported success.

`-T` plus `</dev/null` on the migrate command stops it reading the heredoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:10:09 +02:00
a2bcb2f0c8 Add a one-command SSH deploy for the dev server
deploy/deploy-dev.sh deploys the test instance to home.siebens.org behind its
existing Caddy: from your machine, over one SSH session, it fetches the pushed
branch, builds, migrates explicitly, restarts web, and waits for /healthz.

- A hard reset to origin/<branch>, not a pull: a deploy target only receives
  deploys, so it should match the branch exactly rather than risk a merge conflict
  from drift no one meant to leave on the server.
- Refuses to deploy a branch with unpushed local commits — the server pulls from
  git, so that would ship stale code without saying so.
- Migrations run explicitly (dc run --rm web migrate), never from the entrypoint,
  and only `web` is recreated so db/redis keep running.
- Fails loudly if .env.production or .env is missing rather than booting a
  half-configured stack, and dumps recent web logs if the health check never passes.

Host/user/dir/branch all override via env vars. Documented in DEPLOYMENT.md with
the first-time server setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:02:11 +02:00
9bc5377cc5 Drop the sidebar's "Platform" title, add top margin
Cosmetic: the section label was redundant with the panel it lives in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:58:24 +02:00
7c874aa87b Stack the paid-until date under the paid badge
The "until <date>" cover-end now sits on its own row beneath the paid badge,
right-aligned, rather than to its left on one line. Only the paid-with-plan branch
changed; overdue and n/a are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:48:20 +02:00
5283262e6b Show a paid club's cover end date in the health table
For a club on a plan whose dues are settled, the Dues cell now reads "until
<date> · paid" — the end of the current paid period, which is the day the grace
period would start if nothing renews. It is exactly the "when does this lapse?"
question the paid badge alone could not answer.

Driven by a new paid_until annotation: the furthest-out PAID period end, null when
the club owes or was never billed (so a fully-paid free tier shows just "paid",
and an owing club shows the amount, unchanged). It rides the SAME single query —
the assertNumQueries(1) test still holds — and the date is whitespace-nowrap so it
does not wrap in the narrow cell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:41:20 +02:00
65a2f741f6 Format: collapse a chained queryset to one line
ruff format, no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:35:19 +02:00
975426a17f Match tests to the reworked dashboard, surface the renewals KPI
The club table was rebuilt (logo, status badges, Plan/Dues columns, an Edit
action) and the dashboard dropped the second chart, so three render tests were
asserting columns and a canvas that no longer exist. Updated to the current
layout — the service-level tests were already correct, since the annotations they
check still exist even where the template stopped rendering them.

Worked the renewals KPI into the billing card: "N awaiting renewal", shown only
when non-zero. It should sit at 0 in normal running — the cron renews clubs 30
days out and they fall past the horizon — so a number here means the job has
stopped and a club is about to use the platform free, which nothing else on the
page reveals because nothing has been billed yet.

Fixed two things in the WIP table while here: a debug line that printed the raw
grace/period/owed values into the Dues cell, and a missing {% empty %} clause
(so an empty list showed a headed table with no "no clubs" row, and empty_message
was dead). Removed the stale commented-out copy of the old table.

Answers "auto-renewed but unpaid?": it is not a special case. Renewal opens an
ordinary unpaid Due, which flows unpaid -> grace -> overdue -> archive like any
period — so the safety net that the never-billed club slipped past now fires,
because there is a due to be overdue. Tested both ways.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:34:49 +02:00
9a616c20e4 Auto-renew subscriptions before they lapse
Answers "does a plan renew itself?": until now, no — and that was a silent revenue
leak, not merely a missing convenience. A club whose period ended with its last due
PAID owes nothing, so dues_overdue() is empty, so archive_overdue_clubs never fires.
The club kept using the platform for free and no dashboard number went red, because
nothing was ever billed. The safety net only caught clubs you remembered to invoice.

`renew_subscriptions` (cron) issues the next period 30 days before the current one
ends, so the invoice lands before the period lapses and grace only matters for
genuine non-payers. It is idempotent by construction: a just-renewed club has a
latest period a year out, past the horizon, so a second run is a no-op.

It ACTS by default and previews with --dry-run — the opposite asymmetry to
archiving, and deliberately so. Archiving switches off a customer, so not-acting is
safe there; here, not-acting is the expensive failure, because an unbilled club is
also an unchased one. An unpriced tier fails that one club loudly (non-zero exit, so
cron mails you) without stopping the rest.

Opt-out per club via Subscription.auto_renew, mirroring auto_archive: off means you
invoice that club by hand. The dashboard gains a "renewals pending" count that
should sit at ~0 — a number here means cron has died and a club is about to go free,
which no other metric would reveal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 07:26:41 +02:00
2d43b0b903 Make the control panel a real app shell with a fixed sidebar
The navbar and the menu stay put; only the content scrolls.

That is a property of the layout, not a `sticky` class: the body is a flex column
pinned to the viewport with overflow hidden, and <main> is the single scrolling
region. The sidebar is a SIBLING of <main>, not inside it, so it sits outside that
region and cannot drift by construction. A sticky sidebar in a scrollable body
still slides on a long page, which is the failure this avoids.

The tabs become a daisyUI menu with an active indicator (`menu-active`) and the
hover/focus states `.menu` already provides. Sidebar below `lg` would be a menu
nobody can reach, so the same items render as a horizontal menu there -- from ONE
partial, because two copies of a link list is how a new section ends up reachable on
a desktop and invisible on a phone.

Auth pages leave the menu block empty and <main> simply takes the full width.

Verified in a browser: content scrolled 800px, sidebar and navbar moved 0px, and
the body is not scrollable at all. Below `lg` the sidebar hides and all five links
are still there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:06:31 +02:00
c42963c447 Do not let DEBUG=True take down a container that has no dev deps
The image installs with --no-dev, so django_browser_reload is absent. Settings and
urls both assumed DEBUG implied it was installed, so DJANGO_DEBUG=True in a
deployed container did not merely turn on debugging: the app refused to start, with
a ModuleNotFoundError that says nothing about the actual mistake.

Both now guard on the module being importable. Reproduced the failure locally by
hiding the package with DEBUG on, and confirmed the urlconf loads afterwards.

DEPLOYMENT.md says the obvious thing out loud: a test server is still a deployment
-- real TLS, real domain, real passkeys -- so DEBUG stays off there. The crash is
fixed; the reason to keep it off was never the crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:14:53 +02:00
5d42691a10 Fix the image build: fetch the git dependency in its own stage
The build died at `uv sync`: django-lucide is our fork, declared as a git source
and pinned by the lock to a commit, so uv shells out to `git` to fetch it — and
python:3.14-slim has no git.

Installing git in the runtime image would have fixed it and left a build tool, plus
its dependency tree, in production for the sake of one package that is already
vendored into the venv by then. So the virtualenv is now built in a stage that has
git, and the finished .venv is copied into a runtime stage that does not. Same base
image, so the compiled wheels inside it stay ABI compatible.

Also drops the second `uv sync`, which installed the project itself: there is no
[build-system] and rosterchief is not a package — gunicorn imports it from the
working directory, exactly as it does locally.

Unverified end to end: still no container runtime on this machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:07:54 +02:00
534d01d6fe Size the server, and cost an AWS three-node layout
For 1-5 clubs / 1000 members / 10 events per club per week: 2 vCPU, 4 GB, 40 GB.

The data does not size this box. Computed from the real schema, attendance
dominates (every event invites a squad, so one event is ~20 rows) and the whole
thing comes to ~40 MB/year -- 0.2 GB after five years. Invoices are rendered on
demand and never stored.

What sizes it is the processes, measured rather than guessed: gunicorn master plus
three workers is ~270 MB (~54 MB each), and the whole stack idles around 1.0-1.2 GB.
2 GB would run it; 4 GB is the recommendation because `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. Rendering an invoice adds ~50-100 MB to one worker
the first time, since WeasyPrint is imported lazily.

Also adds the AWS three-node layout for fun, with a cost table. Two things worth
knowing there: ACM issues the wildcard certificate free with Route 53 validation, so
the entire DNS-01 dance disappears; and a NAT Gateway would cost more than the
compute (~$32/month per AZ) if the tasks sit in private subnets.

The honest line at the end: ~$110-130/month on AWS against ~EUR 5 on a VPS, for a
database that is 200 MB after five years. The money buys resilience, not capacity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:25:40 +02:00
98e5f22873 Document running RosterChief beside another domain on one Caddy
The "behind an existing Caddy" section assumed the DNS plugin was being set up
from scratch. The realistic case is a box whose Caddy already does Cloudflare
DNS-01 for another domain, so it now covers that: set acme_dns once globally and
every site inherits it, or scope a token per zone with a snippet.

Leads with the failure that will actually happen -- a Cloudflare token is scoped to
named zones, so the existing one grants DNS:Edit on the domain it was made for and
nothing else, and the new site fails its challenge on a permissions error whose
text does not say so.

Also spells out that *.test.rosterchief.app does NOT match test.rosterchief.app: a
wildcard covers exactly one label, so leaving the bare host off the site line gives
the club subdomains a certificate and the control panel none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:22:37 +02:00
d30b163122 Add maintenance mode: lock the platform down from the control panel
Closes every club subdomain with a 503 in that club's own colours, stands the
scheduled jobs down, and keeps open exactly what is needed to end it again.

The exemptions ARE the feature:

- /accounts/ stays open on the base domain. Close it too and you cannot sign in to
  turn maintenance off -- a lock-down with no key, fixable only from a shell.
- /healthz answers on every host. Close it and the load balancer decides the node
  is dead, stops routing to it, and takes the control panel down with everything
  else.
- migrate and collectstatic are NOT blocked. Maintenance is usually declared in
  order to run them; a blanket guard on BaseCommand would mean turning the mode off
  to do the work you turned it on for. Only the domain jobs (archive_overdue_clubs,
  extend_event_series, import_members_csv) refuse, and they exit non-zero so cron
  mails you -- a scheduled job that silently skips itself is how a month of billing
  goes missing.

The state is cached with a 10-second TTL, not for ever. Write-through makes the
flip instant for the shared Redis of a real deployment, and 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 reached only one gunicorn worker would be worse than
useless. Live-verified: a club subdomain, its login page and the base domain all
503 while the control panel and the sign-in screens stay up.

Also adds the two deployment pieces asked for: compose.behind-proxy.yaml for a
dev/test box that already runs Caddy on :80 (app on the loopback, host Caddy proxies
to it -- and the host's Caddy still needs the DNS plugin, because the wildcard is
still a wildcard), and deploy/backup.sh + restore-check.sh with a cron schedule. The
backup writes to a .part file and only lands it once gzip -t says it is readable: a
truncated dump that looks like a backup is the failure you find on the day you need
it. The weekly restore rehearsal is the only line in that cron that proves the rest
work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:11:15 +02:00
c0a44093d9 Add a health check and the deployment runbook
/healthz checks the database and does a cache ROUND TRIP, not a ping. Both matter:
a node that cannot reach Postgres serves nothing, and a cache that accepts writes
and returns nothing would have waffle read every feature flag as unset -- so
"healthy" has to mean more than "the process is listening", or the load balancer
will keep feeding traffic to a node that only looks alive.

No auth and no tenant on it: the proxy, and later a load balancer, must reach it on
any host.

DEPLOYMENT.md is the runbook, and leads with the five things that make this app not
a generic Django deploy: the wildcard cert forces DNS-01 (Let's Encrypt will not
issue a wildcard over HTTP-01); Redis is required on one server, not two, because
of the per-process flag cache; SECURE_PROXY_SSL_HEADER plus Caddy's
X-Forwarded-Proto or WebAuthn and the SSL redirect both break; uploads must reach
object storage BEFORE the second app server, not during; and invoices need native
pango.

Also documents why the archive job ships with --commit off, why migrations are run
explicitly rather than from the entrypoint, and how to test the restore before the
day you need it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:45:21 +02:00
35d1ec45a7 Containerise: Dockerfile, Compose stack and wildcard TLS
One server now, the same image and env vars for many later: point
DJANGO_DATABASE_URL / DJANGO_REDIS_URL at central services, set a bucket, drop the
db and redis services, run several web containers behind a load balancer. No code
changes.

The wildcard certificate is what shapes this. Subdomain tenancy needs
*.rosterchief.app, and Let's Encrypt will not issue a wildcard over HTTP-01 -- only
DNS-01 -- so Caddy is built with a DNS provider plugin and needs an API token. That
single constraint is why the proxy is Caddy rather than the usual nginx+certbot.

The image apt-installs libpango and friends, which is what WeasyPrint binds to. The
PDF invoices that cannot render on a Mac without Homebrew work in the container by
construction.

Migrations are NOT run by the entrypoint: with more than one web container they
would race, and a starting gunicorn worker is a bad place to discover a failed
migration. Deploy runs them once, explicitly.

Two things the local build check caught, either of which would have failed the
image build at collectstatic (manifest storage treats a missing referenced file as
fatal):

- chart.js ended with a sourceMappingURL pointing at a .map we never vendored.
  Stripped, with an npm script so re-vendoring cannot bring it back.
- The Tailwind INPUT file lived at static/src/app.css, inside the served static
  tree, so collectstatic collected it and then choked on its @import "tailwindcss".
  It belongs outside: it is a build input, not an asset. Now assets/app.css.

Verified locally under gunicorn + WhiteNoise + manifest storage: pages serve and
the CSS comes back hashed. The image itself is unverified -- there is no container
runtime on this machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:41:51 +02:00
e5a93194bf Make the app deployable: Postgres, shared cache, S3, HTTPS
Three things would have broken a deploy, all invisible until it happened:

- psycopg was missing. dj-database-url parses postgres:// happily, so the app
  would have started and died on its first query.
- No CACHES, so Django used LocMemCache -- private to one process. waffle caches
  each flag's targeting there, so under several gunicorn workers a toggle flipped
  in the control panel flushes ONE worker and the others keep serving the stale
  flag. That is a feature that "sometimes doesn't turn on", and it makes Redis a
  requirement of the first multi-worker deploy, not of the second server.
- Uploads (club logos) sat on local disk. Fine on one box; on two, a logo
  uploaded to node A 404s on node B. Storage now switches to S3 the moment a
  bucket is configured, so adding a server stays a config change.

HTTPS behind a proxy: SECURE_PROXY_SSL_HEADER is not optional once Caddy
terminates TLS -- without it Django thinks every request is plain HTTP,
request.is_secure() is false, WebAuthn disagrees with the browser about the
origin, and SECURE_SSL_REDIRECT turns into a loop. HSTS covers subdomains,
because every club is one.

The SSL/cookie flags default to off and are switched on by the production
environment on purpose: defaulting them to `not DEBUG` would redirect every test
request to https and break the suite wherever DEBUG is unset. `check --deploy` is
what catches a deploy that forgot them.

Static files are served by WhiteNoise from the app itself, so a second app server
needs no shared volume or CDN. Manifest storage is production-only: it demands a
collectstatic manifest that no test run has.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:37:36 +02:00
29d61eeea8 Manage billing from the control panel
A Billing tab (tiers, their dated prices, and everything we are owed), a billing
panel on each club (plan, periods, payment history, invoice), and the dues on the
dashboard and the club tables.

Every state change goes through the billing service, and a BillingError surfaces
as a message rather than a 500 -- so "that period is waived", "no price in force",
"already billed for that period" and a missing PDF library all explain themselves
instead of crashing.

The dashboard now separates the two pots of money that were previously one word.
"Revenue per month" was CLUB SHOP revenue -- members paying their clubs, which is
never ours -- sitting on our dashboard under a label that implied it was income.
It is now "Platform dues per month" (what clubs paid us) with the club-shop series
renamed club_revenue, and the club tables carry a Plan column and what each club
owes us, annotated in the same single query.

Rate changes are add-only in the UI as well as the model: the price form creates a
dated row and never edits the last one, and a test asserts that raising the rate
leaves an already-open period at the amount it was billed at.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:52:43 +02:00
60bfac9881 Add platform billing: tiers, dues, payments and invoices
RosterChief charging the clubs, which is a different domain from `shop` (a club
charging its members). Nothing here is club-scoped: these rows reference a Club,
they are not owned by one, and no club user ever sees them.

- Tier + TierPrice. Prices are dated, not keyed by year: a rate change is one row
  with a future active_from, and price_on(day) answers "what was in force then".
  A tier with no price yet returns None, which callers must treat as "cannot
  bill" -- never as free.
- Due: one rolling-year period per club, with a 45-day grace tail. The tier and
  the amount are SNAPSHOTS taken when the period opens. Raise the price and last
  year's period must still say what was actually charged; reading it back through
  the tier would silently rewrite financial history.
- DuePayment: partial payments accumulate. amount_paid is re-summed from the
  payments on every change, never incremented -- an increment drifts the moment a
  payment is deleted, and the drift still looks like money.
- Invoice: PDF via WeasyPrint, rendered on demand from the frozen snapshot. Only
  the number is stored, in one platform-wide series (unlike the shop's per-club
  order numbers), and re-issuing returns the existing one rather than burning a
  number -- a gap in an invoice series is a question you don't want to answer.
  WeasyPrint is imported lazily: it binds to native pango/cairo, and the app, the
  tests and every other page must still run on a machine without them.
- archive_overdue_clubs reports by default and archives only with --commit. That
  asymmetry is deliberate: this switches off paying customers, so a bad clock or a
  cron misconfiguration should cost an email, not a morning of angry clubs. A club
  with auto_archive off is spared entirely.

Renewal continues from the last period end, not from the payment date: a club that
pays two months late has still used those two months.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:45:15 +02:00
6899e203f6 Show club health on the clubs list too
The list page carried the same vanity counts the dashboard just lost. It now shows
active members, unpaid members, money owed, teams (flagging those nobody can pick a
squad for), upcoming events and admins, with the same No season / Dormant badges --
and the annotations survive search and the archived filter.

The table lives in one partial, included by both pages, so they cannot drift apart.
Archived clubs are badged "Archived" rather than "Dormant": their subdomain does not
resolve, so of course nothing is scheduled, and flagging that as a problem would be
noise on the one page where every row has it.

assertNumQueries(1) covers the list as well now -- health is annotated per club, so
the page must not fan out as clubs are added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:24:31 +02:00
3a6dc00e05 Guard against multi-line {# #} template comments
Django's {# #} is single-line only -- its lexer regex is not DOTALL -- so a
multi-line one is not a comment at all and renders to the page as text. It shipped
into the clubs list, where the archived row read:

    Probe Retired probe-retired {# An archived club's subdomain does not... #}

A test now walks every template and fails on a {# without a closing #} on the same
line, since this is an easy habit to fall back into and the failure is invisible
until someone looks at the rendered page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:24:19 +02:00
83 changed files with 5837 additions and 859 deletions

13
.dockerignore Normal file
View File

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

10
.env.compose.example Normal file
View File

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

38
.env.production.example Normal file
View File

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

1
.gitignore vendored
View File

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

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

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

594
DEPLOYMENT.md Normal file
View File

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

94
Dockerfile Normal file
View File

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

View File

@@ -2,8 +2,9 @@
/* Scan Django templates for utility classes (Tailwind's auto-detection doesn't /* Scan Django templates for utility classes (Tailwind's auto-detection doesn't
know about our template dirs). */ know about our template dirs). */
@source "../../templates"; @source "../templates";
@source "../../controlpanel"; @source "../controlpanel";
@source "../billing";
/* daisyUI: light is the default, dark applies automatically when the OS asks /* 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. */ for it. An explicit data-theme on <html> (set by the toggle) overrides both. */

0
billing/__init__.py Normal file
View File

53
billing/admin.py Normal file
View File

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

7
billing/apps.py Normal file
View File

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

View File

View File

View File

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

View File

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

View File

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

View File

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

View File

253
billing/models.py Normal file
View File

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

View File

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

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

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

View File

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

View File

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

490
billing/tests.py Normal file
View File

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

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

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

72
compose.yaml Normal file
View File

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

View File

@@ -1,7 +1,10 @@
from decimal import Decimal
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from waffle import get_waffle_flag_model from waffle import get_waffle_flag_model
from billing.models import DuePayment, Subscription, Tier, TierPrice
from club.models import Club from club.models import Club
from .services.admins import find_member_by_email from .services.admins import find_member_by_email
@@ -56,3 +59,59 @@ class FlagForm(forms.ModelForm):
help_texts = { help_texts = {
"everyone": _("Yes = on for all clubs, No = off everywhere (overrides club targeting). Leave unknown to target clubs."), "everyone": _("Yes = on for all clubs, No = off everywhere (overrides club targeting). Leave unknown to target clubs."),
} }
class TierForm(forms.ModelForm):
class Meta:
model = Tier
fields = ["name", "description", "is_active"]
class TierPriceForm(forms.ModelForm):
class Meta:
model = TierPrice
fields = ["active_from", "amount"]
widgets = {"active_from": forms.DateInput(attrs={"type": "date"})}
help_texts = {"active_from": _("Periods opening on or after this date are billed at this amount. Existing periods keep the amount they were billed at.")}
class SubscriptionForm(forms.ModelForm):
"""Put a club on a tier. The first period opens when the subscription is created."""
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("First period starts"), help_text=_("Left blank, the period starts today."))
class Meta:
model = Subscription
fields = ["tier", "auto_renew", "auto_archive", "notes"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# An inactive tier still bills its existing subscriptions, but must not be picked up
# by a new one — which is the whole point of retiring a tier.
self.fields["tier"].queryset = Tier.objects.filter(is_active=True)
class DuePaymentForm(forms.Form):
amount = forms.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal("0.01"), label=_("Amount"))
method = forms.ChoiceField(choices=DuePayment.Method.choices, initial=DuePayment.Method.BANK_TRANSFER, label=_("Method"))
reference = forms.CharField(required=False, label=_("Reference"), help_text=_("Bank reference, transaction id — whatever lets you find this again."))
paid_at = forms.DateTimeField(required=False, widget=forms.DateTimeInput(attrs={"type": "datetime-local"}), label=_("Received"), help_text=_("Left blank, now."))
note = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 2}), label=_("Note"))
class OpenPeriodForm(forms.Form):
"""Renew, or reactivate an archived club."""
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("Period starts"), help_text=_("Left blank, it continues from the end of the last period — so a lapsed year is still owed."))
class MaintenanceForm(forms.Form):
"""Closing the platform is a deliberate act, so it takes a sentence explaining itself —
that message is the only thing a club will see."""
message = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 2}),
label=_("Message"),
help_text=_("Shown to every club while the platform is closed. Left blank, they get a generic notice."),
)

35
controlpanel/messages.py Normal file
View File

@@ -0,0 +1,35 @@
"""A compact way to queue a Django message that carries its own title.
Django's messages framework has no title field — a call site that wants one passes it
as ``extra_tags`` (``messages.success(request, body, extra_tags="Club created")``), which
reads fine written out but is easy to forget, so in practice every message ends up on
the generic per-level heading (`as_alert`'s "Done" / "Careful" / "Something went wrong").
``notify`` folds level, title and body into one string instead: ``"<level>|<title>|<body>"``.
One call, title included, nothing to forget. `as_alert` (controlpanel/templatetags/ui.py)
reads the title back off ``extra_tags`` at render time — unchanged from before.
"""
from django.contrib import messages
#: One letter per Django message level. `notify` picks the level from the spec string;
#: `as_alert` picks the icon/colour/fallback-title from the level the message actually
#: carries (via ``level_tag``), so the two stay in step by construction.
LEVELS = {
"s": messages.SUCCESS,
"i": messages.INFO,
"w": messages.WARNING,
"e": messages.ERROR,
"d": messages.DEBUG,
}
def notify(request, spec: str, **kwargs) -> None:
"""Queue a message from a ``"<level>|<title>|<body>"`` spec.
``level`` is one of ``s`` (success), ``i`` (info), ``w`` (warning), ``e`` (error),
``d`` (debug). An empty title (``"s||Body text"``) falls back to the generic
per-level heading, same as never passing ``extra_tags`` at all.
"""
level_code, title, body = spec.split("|", 2)
messages.add_message(request, LEVELS[level_code], body, extra_tags=title, **kwargs)

View File

@@ -1,5 +1,8 @@
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import UserPassesTestMixin
from django.http import Http404 from django.http import Http404
from django.shortcuts import redirect
from .messages import notify
class PlatformStaffRequiredMixin(UserPassesTestMixin): class PlatformStaffRequiredMixin(UserPassesTestMixin):
@@ -38,3 +41,21 @@ class PlatformSuperuserRequiredMixin(PlatformStaffRequiredMixin):
def test_func(self): def test_func(self):
return self.request.user.is_superuser 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())

View File

@@ -11,12 +11,14 @@ from decimal import Decimal
from allauth.mfa.models import Authenticator from allauth.mfa.models import Authenticator
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db.models import Count, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value 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.db.models.functions import Coalesce, TruncMonth
from django.utils import timezone from django.utils import timezone
from waffle import get_waffle_flag_model from waffle import get_waffle_flag_model
from authentication.middleware import ELEVATED_ROLES 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 club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Event from events.models import Attendance, Event
from members.models import Member from members.models import Member
@@ -62,6 +64,8 @@ def clubs_with_health(queryset=None, today=None, now=None):
clubs = Club.objects.active() if queryset is None else queryset clubs = Club.objects.active() if queryset is None else queryset
in_season = Q(season__start_date__lte=today, season__end_date__gte=today) 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( managed_this_season = Q(
staff_assignments__season__start_date__lte=today, staff_assignments__season__start_date__lte=today,
staff_assignments__season__end_date__gte=today, staff_assignments__season__end_date__gte=today,
@@ -78,6 +82,17 @@ def clubs_with_health(queryset=None, today=None, now=None):
team_count=_subquery(Team.objects.all(), 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()), 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()), 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")) .annotate(teams_without_coach=F("team_count") - F("teams_managed"))
.order_by("name") .order_by("name")
@@ -135,11 +150,25 @@ def onboarding_funnel():
return [ return [
{"label": "Clubs", "count": total, "icon": "building-2"}, {"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 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": "shield"}, {"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"}, {"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(): def flag_adoption():
"""Clubs per feature flag. `everyone` overrides club targeting, so a flag set that """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`.""" way is on (or off) everywhere and its club count says nothing — hence `overridden`."""
@@ -160,9 +189,26 @@ def platform_attention():
"outstanding": _money(Order.objects.filter(status__in=OWED_STATUSES)), "outstanding": _money(Order.objects.filter(status__in=OWED_STATUSES)),
"members_without_login": Member.objects.filter(user__isnull=True).count(), "members_without_login": Member.objects.filter(user__isnull=True).count(),
"members": members, "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): def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY):
"""A dense month-by-month series — zero-filled, because a chart that silently skips """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.""" empty months draws a smooth line over a month where nothing happened."""
@@ -183,7 +229,11 @@ def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY):
def platform_charts(): def platform_charts():
return { return {
"signups": signup_split(), "signups": signup_split(),
"revenue": _monthly(Order.objects.filter(status__in=PAID_STATUSES), "created", Sum("total")), # 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")),
} }

View File

@@ -0,0 +1,49 @@
{% load lucide ui %}
{% comment %}
Club-scoped admins, and the modals to add one / confirm removing one. Included with
`club`, `admins`, `admin_form` already in context.
{% endcomment %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "shield-user" size=18 %} Club admins</h2>
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('club_admin_add_modal').showModal()">{% lucide "user-plus" size=16 %} Add admin</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th></th>
</tr>
</thead>
<tbody>
{% for role in admins %}
<tr>
<td>{{ role.member }}</td>
<td>{{ role.member.user.email|default:"—" }}</td>
<td class="text-right">
<button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ role.pk|dom_id:"admin_remove_modal" }}').showModal()">{% lucide "trash-2" size=14 %} Remove</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">No admins yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% url 'controlpanel:club_admin_add' club.pk as club_admin_add_url %}
{% include "controlpanel/_modal_form.html" with modal_id="club_admin_add_modal" title="Add admin" form=admin_form action_url=club_admin_add_url submit_label="Grant admin" submit_icon="user-plus" blurb="A club admin can manage everything in this club. They will be required to set up two-factor authentication before they can sign in." %}
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for role in admins %}
{% url 'controlpanel:club_admin_remove' club.pk role.pk as admin_remove_url %}
{% include "controlpanel/_confirm_modal.html" with modal_id=role.pk|dom_id:"admin_remove_modal" title="Remove admin" body="Remove "|add:role.member.get_full_name|add:" as an admin of this club? They keep their membership — only admin rights are revoked." action_url=admin_remove_url submit_label="Remove" %}
{% endfor %}

View File

@@ -0,0 +1,124 @@
{% load lucide ui %}
{% comment %}
What the platform bills this club: plan, periods, and the modals for changing plan,
opening a period, and recording a payment. Included with `club`, `subscription`,
`dues`, `today`, `subscription_form`, `open_period_form`, `open_period_blurb` already
in context.
{% endcomment %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
<div class="flex flex-wrap gap-2">
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
</button>
{% if subscription %}
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('open_period_modal').showModal()">
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
</button>
{% endif %}
</div>
</div>
{% if not subscription %}
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a tier to start.</p>
{% else %}
<p class="text-sm opacity-70">
On plan <strong>{{ subscription.tier.name }}</strong>.
{% if subscription.auto_renew %}
Renews automatically 30 days before the period ends.
{% else %}
<span class="badge badge-warning badge-sm">Auto-renew off</span> — you must open each period by hand, or this club uses the platform for free.
{% endif %}
{% if subscription.auto_archive %}
Archived automatically when a period goes unpaid past its grace period.
{% else %}
<span class="badge badge-warning badge-sm">Auto-archive off</span> — it will never be archived for non-payment.
{% endif %}
</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Period</th>
<th class="text-right">Billed</th>
<th class="text-right">Paid</th>
<th class="text-right">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for due in dues %}
<tr>
<td>
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
<div class="text-xs opacity-60">{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div>
</td>
<td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td>
<td class="text-right tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td>
<td class="text-right">
{% if due.status == "paid" %}
<span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span>
{% elif due.status == "waived" %}
<span class="badge badge-outline gap-1">{% lucide "check" size=12 %} Waived</span>
{% elif due.grace_until < today %}
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
{% elif due.period_end < today %}
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
{% else %}
<span class="badge badge-outline">{{ due.get_status_display }}</span>
{% endif %}
</td>
<td class="text-right flex flex-row gap-2 justify-end">
{% if due.is_owing %}
<button class="btn btn-primary btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Add payment</button>
{% if not due.payments.all %}
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
{% csrf_token %}
<button class="btn btn-outline btn-sm gap-1" type="submit">{% lucide "ban" size=14 %} Waive payment</button>
</form>
{% endif %}
{% endif %}
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
</td>
</tr>
{% for payment in due.payments.all %}
<tr class="text-xs opacity-70">
<td colspan="2" class="pl-8">
{% lucide "corner-down-right" size=12 %}
{{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %}
</td>
<td class="text-right tabular-nums">€{{ payment.amount|floatformat:2 }}</td>
<td colspan="2"></td>
</tr>
{% endfor %}
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">No periods billed yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for due in dues %}
{% if due.is_owing %}
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment" form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
{% url 'controlpanel:club_subscribe' club.pk as subscribe_url %}
{% include "controlpanel/_modal_form.html" with modal_id="subscription_modal" title=subscription|yesno:"Change plan,Start billing" form=subscription_form action_url=subscribe_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %}
{% if subscription %}
{% url 'controlpanel:club_open_period' club.pk as open_period_url %}
{% include "controlpanel/_modal_form.html" with modal_id="open_period_modal" title=club.is_archived|yesno:"Reactivate,Open period" form=open_period_form action_url=open_period_url submit_label="Open period" submit_icon="calendar-plus" blurb=open_period_blurb %}
{% endif %}

View File

@@ -0,0 +1,45 @@
{% load lucide %}
{% comment %}
Which feature flags apply to this club. Included with `club`, `flags` (from
`flags_for_club`) already in context.
{% endcomment %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for entry in flags %}
<tr>
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
<td class="text-right">
{% if entry.overridden %}
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
</span>
{% else %}
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
{% csrf_token %}
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No features defined yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,135 @@
{% load lucide %}
{% comment %}
The club table, shared by the dashboard and the clubs list so the two cannot drift apart.
Health, not vanity: a member total says nothing you can act on, while "no coach",
"nothing scheduled", "€ owed" and "no admins" each name something somebody has to go and
fix. Every column is annotated by clubs_with_health() in a single query.
Expects: clubs (from clubs_with_health), and optionally empty_message.
{% endcomment %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Club</th>
<th></th>
<th class="text-right">Members</th>
<th class="text-right">Admins</th>
<th class="text-right">Teams</th>
<th class="text-right">Events</th>
<th class="text-right">Plan</th>
<th class="text-right">Dues</th>
<th class="text-right">Plan end</th>
<th></th>
</tr>
</thead>
<tbody>
{% for club in clubs %}
<tr>
<td>
<div class="flex flex-row items-center gap-4">
<div>
{% if club.logo %}
<img class="h-12 w-12 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
{% else %}
<div class="avatar avatar-placeholder">
<div class="w-12 rounded-full bg-neutral text-neutral-content">
<span>{{ club.initials }}</span>
</div>
</div>
{% endif %}
</div>
<div class="flex flex-col gap-1">
<a class="link link-hover font-semibold tracking-wide" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
<div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app</div>
</div>
</div>
</td>
<td>
<div class="flex flex-row gap-2">
{% if club.is_archived %}
<span class="badge badge-warning">{% lucide "archive" size=14 %} archived</span>
{% else %}
{% if not club.has_season %}
<span class="badge badge-warning">{% lucide "calendar-x" size=14 %} no seasons</span>
{% endif %}
{% if not club.upcoming_events %}
<span class="badge badge-ghost badge-outline">{% lucide "moon-star" size=14 %}dormant</span>
{% endif %}
{% endif %}
</div>
</td>
<td class="text-right tabular-nums">{{ club.active_members }}</td>
<td class="text-right tabular-nums">
<div class="flex flex-row gap-2 items-center justify-end">
{% if not club.admin_count %}
<span class="text-error">{% lucide "triangle-alert" size=16 %}</span>
{% endif %}
<span class="{% if not club.admin_count %}font-bold text-error{% endif %}">{{ club.admin_count }}</span>
</div>
</td>
<td class="text-right tabular-nums">{{ club.team_count }}</td>
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
<td class="text-right">
{% if club.tier_name %}
<span class="badge badge-accent">{{ club.tier_name|lower }}</span>
{% else %}
-
{% endif %}
</td>
<td class="text-right">
<div class="flex flex-row gap-2 items-center justify-end">
{% if not club.dues_owed %}
{% if club.tier_name %}
{% comment %}
Not owing and on a plan. covered_until is the settled period's end — the day
grace would start if nothing renews — shown on its own row under the badge,
for a paid period AND a waived one (both cover the club, they just differ in
how). No covered period at all (only cancelled dues, say) shows a dash.
{% endcomment %}
<div class="flex flex-col items-end gap-1">
{% if club.covered_status == "waived" %}
<span class="badge badge-ghost badge-outline">waived</span>
{% elif club.covered_until %}
<span class="badge badge-success">paid</span>
{% else %}
-
{% endif %}
</div>
{% else %}
-
{% endif %}
{% else %}
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
{% if club.dues_grace_until < today %}
<span class="badge badge-error">overdue</span>
{% elif club.dues_period_end < today %}
<span class="badge badge-warning">grace</span>
{% endif %}
{% endif %}
</div>
</td>
<td class="text-right">
<span class="whitespace-nowrap">{{ club.covered_until|date:"j M Y"|default:"-" }}</span>
</td>
<td>
<a class="btn btn-sm btn-outline gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="9" class="text-center opacity-60">{{ empty_message|default:"No clubs yet." }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,28 @@
{% load lucide %}
{% comment %}
A daisyUI native <dialog> confirmation modal for a destructive POST action with no
fields of its own. Included with `modal_id`, `title`, `body`, `action_url`, and
optional `submit_label` (default "Confirm"), `submit_icon` (default "trash-2"). The
submit button sits outside the form tag (linked via the `form` attribute), same as
`_modal_form.html`, so it can share the `modal-action` row with the dialog-closing
Cancel button without nesting one <form> inside another.
{% endcomment %}
<dialog id="{{ modal_id }}" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">{{ title }}</h3>
<p class="py-2 text-sm opacity-70">{{ body }}</p>
<form method="post" action="{{ action_url }}" id="{{ modal_id }}-form">
{% csrf_token %}
</form>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} Cancel</button>
</form>
<button class="btn btn-error gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"trash-2" size=16 %} {{ submit_label|default:"Confirm" }}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

View File

@@ -0,0 +1,29 @@
{% load ui %}
{% comment %}
The field loop every card-form and modal-form wrapper shares: label, daisyUI-styled
widget, help text, errors — with checkboxes laid out label-beside-input instead of
label-above. Included with `form`.
{% endcomment %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
{% if field.field.widget.input_type == "checkbox" %}
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
{{ field|daisy }}
<span class="label-text">{{ field.label }}</span>
</label>
{% else %}
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% endif %}
{% if field.help_text %}<span class="label-text-alt mt-1 text-xs block text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt text-xs mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}

View File

@@ -0,0 +1,28 @@
{% load lucide %}
{% comment %}
A daisyUI native <dialog> modal wrapping a Django form that posts straight to
`action_url`. Included with `modal_id`, `title`, `form`, `action_url`, `submit_label`
and `submit_icon`, plus an optional `blurb`. The submit button sits outside the form
tag (linked via the `form` attribute) so it can share the `modal-action` row with the
dialog-closing Cancel button without nesting one <form> inside another.
{% endcomment %}
<dialog id="{{ modal_id }}" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">{{ title }}</h3>
{% if blurb %}<p class="py-2 text-sm opacity-70">{{ blurb }}</p>{% endif %}
<form method="post" action="{{ action_url }}" id="{{ modal_id }}-form">
{% csrf_token %}
{% include "controlpanel/_form_fields.html" %}
</form>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} Cancel</button>
</form>
<button class="btn btn-primary gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:"Save" }}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

View File

@@ -0,0 +1,37 @@
{% load lucide %}
{% comment %}
The panel's navigation, in one place: the sidebar renders it on a wide screen and the
collapsed menu renders it on a narrow one. Two copies of a link list is how a new section
ends up reachable on a desktop and invisible on a phone.
`menu-active` is daisyUI 5's active state; hover and focus come with `.menu` itself.
{% endcomment %}
<li>
<a class="{% if nav == 'dashboard' %}menu-active{% endif %}" href="{% url 'controlpanel:dashboard' %}">
{% lucide "layout-dashboard" size=16 %} Dashboard
</a>
</li>
<li>
<a class="{% if nav == 'clubs' %}menu-active{% endif %}" href="{% url 'controlpanel:club_list' %}">
{% lucide "building-2" size=16 %} Clubs
</a>
</li>
<li>
<a class="{% if nav == 'billing' %}menu-active{% endif %}" href="{% url 'controlpanel:billing' %}">
{% lucide "receipt-euro" size=16 %} Billing
</a>
</li>
<li>
<a class="{% if nav == 'features' %}menu-active{% endif %}" href="{% url 'controlpanel:features' %}">
{% lucide "toggle-right" size=16 %} Features
</a>
</li>
{% if user.is_superuser %}
{# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #}
<li>
<a class="{% if nav == 'admins' %}menu-active{% endif %}" href="{% url 'controlpanel:admins' %}">
{% lucide "user-cog" size=16 %} Platform admins
</a>
</li>
{% endif %}

View File

@@ -1,39 +0,0 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% block heading %}Grant platform access{% endblock heading %}
{% block panel %}
<div class="card max-w-xl bg-base-100 shadow">
<div class="card-body">
<div class="alert alert-info">
<span>Platform admins can manage every club. They must set up two-factor authentication before they can sign in.</span>
</div>
<form method="post">
{% 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 %}
<div class="form-control my-3 w-full">
{% if field.field.widget.input_type == "checkbox" %}
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
{{ field|daisy }}
<span class="label-text">{{ field.label }}</span>
</label>
{% else %}
<label class="label" for="{{ field.id_for_label }}"><span class="label-text">{{ field.label }}</span></label>
{{ field|daisy }}
{% endif %}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:admins' %}">{% lucide "arrow-left" size=16 %} Cancel</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "user-plus" size=16 %} Grant access</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load lucide %} {% load lucide ui %}
{% block heading %}Platform admins{% endblock heading %} {% block heading %}Platform admins{% endblock heading %}
@@ -8,10 +8,13 @@
{% endblock subheading %} {% endblock subheading %}
{% block actions %} {% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:admin_add' %}">{% lucide "user-plus" size=16 %} Grant access</a> <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 %} {% endblock actions %}
{% block panel %} {% 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 bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -30,15 +33,16 @@
<tr> <tr>
<td> <td>
<div class="font-medium">{{ admin.email }}</div> <div class="font-medium">{{ admin.email }}</div>
{% if admin.pk == user.pk %}<div class="text-xs opacity-60">That's you</div>{% endif %} {% if admin.pk == user.pk %}
<div class="text-xs opacity-60">That's you</div>{% endif %}
</td> </td>
<td> <td>
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}"> <form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}0{% else %}1{% endif %}"> <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 %}"> <input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}1{% else %}0{% endif %}">
<button class="btn btn-xs gap-1 {% if admin.is_staff %}btn-success{% else %}btn-ghost{% endif %}" type="submit"> <button class="btn btn-sm gap-1 {% if admin.is_staff %}btn-success{% else %}btn-outline{% endif %}" type="submit">
{% if admin.is_staff %}{% lucide "check" size=14 %} Yes{% else %}No{% endif %} {% if admin.is_staff %}{% lucide "user" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
</button> </button>
</form> </form>
</td> </td>
@@ -47,17 +51,14 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}1{% else %}0{% endif %}"> <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 %}"> <input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}0{% else %}1{% endif %}">
<button class="btn btn-xs gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-ghost{% endif %}" type="submit"> <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 %}No{% endif %} {% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
</button> </button>
</form> </form>
</td> </td>
<td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td> <td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td>
<td class="text-right"> <td class="text-right">
<form method="post" action="{% url 'controlpanel:admin_revoke' admin.pk %}"> <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>
{% csrf_token %}
<button class="btn btn-ghost btn-xs gap-1 text-error" type="submit">{% lucide "user-minus" size=14 %} Revoke</button>
</form>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
@@ -68,6 +69,12 @@
</tbody> </tbody>
</table> </table>
</div> </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>
</div> </div>
{% endblock panel %} {% endblock panel %}

View File

@@ -5,9 +5,33 @@
{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief {% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
{% endblock title %} {% 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 %} {% block main %}
<div class="mb-6 flex flex-wrap items-center justify-between gap-3"> {% if maintenance_on %}
<div class="flex flex-col gap-2"> <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"> <h1 class="text-3xl font-bold">
{% block heading %}Control panel{% endblock heading %} {% block heading %}Control panel{% endblock heading %}
</h1> </h1>
@@ -19,13 +43,10 @@
</div> </div>
</div> </div>
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit"> {# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #}
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab gap-2 {% if nav == 'dashboard' %}tab-active{% endif %}">{% lucide "layout-dashboard" size=16 %} Dashboard</a> <ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden">
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab gap-2 {% if nav == 'clubs' %}tab-active{% endif %}">{% lucide "building-2" size=16 %} Clubs</a> {% include "controlpanel/_nav_items.html" %}
<a role="tab" href="{% url 'controlpanel:features' %}" class="tab gap-2 {% if nav == 'features' %}tab-active{% endif %}">{% lucide "toggle-right" size=16 %} Features</a> </ul>
{% if user.is_superuser %}
<a role="tab" href="{% url 'controlpanel:admins' %}" class="tab gap-2 {% if nav == 'admins' %}tab-active{% endif %}">{% lucide "user-cog" size=16 %} Admins</a>
{% endif %}
</div>
{% block panel %}{% endblock panel %} {% block panel %}{% endblock panel %}
{% endblock main %} {% endblock main %}

View File

@@ -0,0 +1,135 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% block heading %}Billing{% endblock heading %}
{% block actions %}
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('tier_create_modal').showModal()">{% lucide "plus" size=16 %} New plan</button>
{% endblock actions %}
{% block panel %}
{% url 'controlpanel:tier_create' as tier_create_url %}
{% include "controlpanel/_modal_form.html" with modal_id="tier_create_modal" title="New plan" form=tier_form action_url=tier_create_url submit_label="Create plan" submit_icon="plus" %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Plans</h2>
{% comment %}
Prices are dated, not edited. A rate change is a new row with a future
active_from; every period already opened keeps the amount it was billed at,
so raising the price cannot rewrite an invoice you have already sent.
{% endcomment %}
<p class="text-sm opacity-70">A rate change only takes effect as of a certain date. Periods already billed keep the amount they were issued at.</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Plan</th>
<th class="text-right">Clubs</th>
<th>Prices</th>
<th></th>
</tr>
</thead>
<tbody>
{% for tier in tiers %}
<tr>
<td>
<div class="font-medium">{{ tier.name }}</div>
{% if not tier.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %}
{% if tier.description %}
<div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %}
</td>
<td class="text-right tabular-nums">{{ tier.club_count }}</td>
<td>
{% for price in tier.prices.all %}
<div class="text-sm tabular-nums">
€{{ price.amount|floatformat:2 }}
<span class="opacity-60">from {{ price.active_from|date:"j M Y" }}</span>
{% if price.active_from > today %}<span class="badge badge-info badge-xs">Scheduled</span>{% endif %}
</div>
{% empty %}
<span class="badge badge-error badge-sm">No price — cannot be billed</span>
{% endfor %}
</td>
<td class="flex flex-row gap-2 justify-end">
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
<button class="btn btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center opacity-60">No plans yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for tier in tiers %}
{% url 'controlpanel:tier_price_create' tier.pk as tier_price_url %}
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_price_modal" title="New price — "|add:tier.name form=tier.price_form action_url=tier_price_url submit_label="Add price" submit_icon="euro" %}
{% url 'controlpanel:tier_update' tier.pk as tier_update_url %}
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_edit_modal" title="Edit "|add:tier.name form=tier.edit_form action_url=tier_update_url submit_label="Save" submit_icon="check" %}
{% endfor %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Club</th>
<th>Period</th>
<th class="text-right">Owed</th>
<th class="text-right">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for due in owing %}
<tr>
<td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' due.club.pk %}">{{ due.club.name }}</a>
<div class="text-xs opacity-60">{{ due.tier.name }}</div>
</td>
<td class="text-sm">
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
<div class="text-xs opacity-60">Grace to {{ due.grace_until|date:"j M Y" }}</div>
</td>
<td class="text-right font-semibold tabular-nums">€{{ due.balance|floatformat:2 }}</td>
<td class="text-right">
{% if due.grace_until < today %}
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
{% elif due.period_end < today %}
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
{% else %}
<span class="badge badge-outline">{{ due.get_status_display }}</span>
{% endif %}
</td>
<td class="text-right flex flex-row gap-2 justify-end">
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Record payment</button>
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">Nothing outstanding.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for due in owing %}
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment — "|add:due.club.name form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
{% endfor %}
{% endblock panel %}

View File

@@ -1,36 +0,0 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% block heading %}Add an admin to {{ club }}{% endblock heading %}
{% block panel %}
<div class="card max-w-xl bg-base-100 shadow">
<div class="card-body">
<div class="alert alert-info">
<span>A club admin can manage everything in this club. They will be required to set up two-factor authentication before they can sign in.</span>
</div>
<form method="post">
{% 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 %}
<div class="form-control my-3 w-full">
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_detail' club.pk %}">{% lucide "arrow-left" size=16 %} Cancel</a>
<button class="btn btn-primary" type="submit">Grant admin</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -1,19 +1,30 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load static lucide %} {% load static lucide %}
{% block logo %}
{% if club.logo %}
<img class="h-16 w-16 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
{% else %}
<div class="avatar avatar-placeholder">
<div class="w-16 text-xl rounded-full bg-neutral text-neutral-content">
<span>{{ club.initials }}</span>
</div>
</div>
{% endif %}
{% endblock logo %}
{% block heading %}{{ club.name }}{% endblock heading %} {% block heading %}{{ club.name }}{% endblock heading %}
{% block subheading %} {% block subheading %}
<p class="text-sm opacity-70"> {{ club.slug }}.rosterchief.app
{{ club.slug }} {% if club.is_archived %}
{% if club.is_archived %} <span class="badge badge-warning badge-sm ml-2">Archived</span>
<span class="badge badge-warning badge-sm ml-2">Archived</span> {% endif %}
{% endif %}
</p>
{% endblock subheading %} {% endblock subheading %}
{% block actions %} {% block actions %}
<a class="btn btn-ghost gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a> <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 %} {% if club.is_archived %}
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}"> <form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
{% csrf_token %} {% csrf_token %}
@@ -30,6 +41,7 @@
{% block panel %} {% block panel %}
{% if club.is_archived %} {% if club.is_archived %}
<div class="alert alert-warning mb-6"> <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> <span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
</div> </div>
{% endif %} {% endif %}
@@ -47,96 +59,91 @@
the club's setup, not a statistic: with nobody in a management position the access 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. service grants no authority over that team, so nobody can pick the squad.
{% endcomment %} {% endcomment %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}"> {% 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="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div> <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-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 class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
</div> </div>
</div> </div>{% endcomment %}
<div class="card bg-base-100 shadow {% if attention.teams_without_manager %}border-l-4 border-error{% endif %}"> <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="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-x" size=16 %} No coach</div> <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-3xl font-bold tabular-nums">{{ attention.teams_without_manager }}</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 class="text-xs opacity-60">Teams nobody can pick a squad for</div>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow {% if attention.unrostered %}border-l-4 border-warning{% endif %}">
<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="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-minus" size=16 %} Unrostered</div> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} Unrostered members</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.unrostered }}</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 class="text-xs opacity-60">Active members on no team</div>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow {% if attention.pending_approvals %}border-l-4 border-warning{% endif %}">
<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="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "clock" size=16 %} Pending</div> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} Pending</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.pending_approvals }}</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 class="text-xs opacity-60">Memberships awaiting approval</div>
</div> </div>
</div> </div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-4"> <div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card bg-base-100 shadow"> <div class="card-body p-4">
<div class="card-body"> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} New members</div>
<h2 class="card-title text-base">{% lucide "sparkles" size=18 %} New members</h2> <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
<div class="text-4xl font-bold tabular-nums">{{ attention.new_members }}</div>
{# First season at this club — someone returning after a year away is a renewal. #} {# First season at this club — someone returning after a year away is a renewal. #}
<p class="text-sm opacity-70">first season at this club</p> <div class="text-xs opacity-60">First season at this club</div>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow"> <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"> <div class="card-body p-4">
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} Renewal</h2> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} Renewal rate</div>
{% if attention.renewal_rate is None %} <div class="text-4xl font-bold tabular-nums font-mono">
{# No prior season to compare against: a first-season club has not failed to renew anyone. #} {% if attention.renewal_rate is None %}
<p class="text-sm opacity-60">No previous season to compare against yet.</p> N/A
{% else %} {% else %}
<div class="text-4xl font-bold tabular-nums">{{ attention.renewal_rate }}%</div> {{ attention.renewal_rate }}%
<p class="text-sm opacity-70">of last season's active members signed up again</p> {% endif %}
<progress class="progress progress-primary w-full" value="{{ attention.renewal_rate }}" max="100"></progress> </div>
{% endif %} <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> </div>
<div class="card bg-base-100 shadow"> <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"> <div class="card-body p-4">
<h2 class="card-title text-base">{% lucide "user-check" size=18 %} Attendance</h2> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} Attendance rate</div>
{% if attention.attendance.turnout is None %} <div class="text-4xl font-bold tabular-nums font-mono">
<p class="text-sm opacity-60">No past events with responses this season.</p> {% if attention.attendance.turnout is None %}
{% else %} N/A
<div class="text-4xl font-bold tabular-nums">{{ attention.attendance.turnout }}%</div> {% else %}
<p class="text-sm opacity-70">turnout of those who answered</p> {{ attention.attendance.turnout }}%
<p class="mt-2 text-sm"> {% endif %}
{# The leading indicator: it measures whether members use the app at all. #} </div>
<span class="font-semibold {% if attention.attendance.no_response > 30 %}text-warning{% endif %}">{{ attention.attendance.no_response }}%</span> <div class="text-xs opacity-60">
<span class="opacity-70">never responded</span> {% if attention.attendance.turnout is None %}
</p> No events this season
{% endif %} {% else %}
</div> <progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}"
</div> value="{{ attention.attendance.turnout }}" max="100"></progress>
{% endif %}
<div class="card bg-base-100 shadow"> </div>
<div class="card-body">
<h2 class="card-title text-base">{% lucide "hourglass" size=18 %} Unpaid, by age</h2>
<table class="table table-sm">
<tbody>
{% for bucket in attention.aging %}
<tr>
<td class="{% if bucket.overdue and bucket.total %}font-semibold text-error{% endif %}">{{ bucket.label }}</td>
<td class="text-right tabular-nums">€{{ bucket.total|floatformat:2 }}</td>
<td class="text-right opacity-60">{{ bucket.count }} order{{ bucket.count|pluralize }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-6 grid gap-4 lg:grid-cols-2"> <div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
@@ -149,7 +156,7 @@
</div> </div>
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} Fee status this season</h2> <h2 class="card-title text-base">{% lucide "wallet" size=18 %} Club fee status this season</h2>
<div class="h-56"> <div class="h-56">
<canvas id="fees-chart"></canvas> <canvas id="fees-chart"></canvas>
</div> </div>
@@ -157,7 +164,7 @@
</div> </div>
</div> </div>
<div class="mb-6 grid gap-4 md:grid-cols-2"> <div class="mb-6 grid gap-4 md:grid-cols-4">
{% for group in groups %} {% for group in groups %}
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
@@ -166,7 +173,7 @@
{% for label, value in group.stats %} {% for label, value in group.stats %}
<div class="flex items-center justify-between py-2"> <div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{{ label }}</dt> <dt class="text-sm opacity-70">{{ label }}</dt>
<dd class="font-semibold tabular-nums">{{ value }}</dd> <dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}&euro;{% endif %}{{ value }}</dd>
</div> </div>
{% endfor %} {% endfor %}
</dl> </dl>
@@ -174,82 +181,9 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="card mb-6 bg-base-100 shadow"> {% include "controlpanel/_club_features_card.html" %}
<div class="card-body"> {% include "controlpanel/_club_billing_card.html" %}
<div class="flex items-center justify-between"> {% include "controlpanel/_club_admins_card.html" %}
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
<a class="btn btn-ghost btn-xs" href="{% url 'controlpanel:features' %}">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>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">Club admins</h2>
<a class="btn btn-primary btn-sm gap-2" href="{% url 'controlpanel:club_admin_add' club.pk %}">{% lucide "user-plus" size=16 %} Add admin</a>
</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">
<form method="post" action="{% url 'controlpanel:club_admin_remove' club.pk role.pk %}">
{% csrf_token %}
<button class="btn btn-ghost btn-xs gap-1 text-error" type="submit">{% lucide "trash-2" size=14 %} Remove</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">No admins yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %} {% endblock panel %}
{% block extra_body %} {% block extra_body %}
@@ -271,17 +205,17 @@
data: { data: {
labels: data.signups.map((point) => point.month), labels: data.signups.map((point) => point.month),
datasets: [ datasets: [
{ label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5") }, {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") }, {label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
], ],
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { legend: { position: "bottom", labels: { color: ink } } }, plugins: {legend: {position: "bottom", labels: {color: ink}}},
scales: { scales: {
x: { stacked: true, ticks: { color: ink }, grid: { color: grid } }, x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
y: { stacked: true, beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } }, y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
}, },
}, },
}); });
@@ -289,7 +223,7 @@
// Colour carries the meaning here — unpaid must read as a problem, waived must // 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. // not — so the slices are pinned to the semantic theme colours, in order.
const fees = new Chart(document.getElementById("fees-chart"), { const fees = new Chart(document.getElementById("fees-chart"), {
type: "doughnut", type: "pie",
data: { data: {
labels: data.fees.map((slice) => slice.label), labels: data.fees.map((slice) => slice.label),
datasets: [ datasets: [
@@ -302,7 +236,7 @@
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { legend: { position: "right", labels: { color: ink } } }, plugins: {legend: {position: "right", labels: {color: ink}}},
}, },
}); });
@@ -315,7 +249,7 @@
new MutationObserver(() => { new MutationObserver(() => {
charts.forEach((chart) => chart.destroy()); charts.forEach((chart) => chart.destroy());
charts = render(); charts = render();
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); }).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
})(); })();
</script> </script>
{% endblock extra_body %} {% endblock extra_body %}

View File

@@ -4,28 +4,33 @@
{% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %} {% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %}
{% block panel %} {% block panel %}
<div class="card max-w-xl bg-base-100 shadow"> <div class="card w-full bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{% for error in form.non_field_errors %} {% for error in form.non_field_errors %}
<div class="alert alert-error my-2"> <div class="alert alert-error my-2">
<span>{{ error }}</span> <span>{{ error }}</span>
</div> </div>
{% endfor %} {% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="label" for="{{ field.id_for_label }}"> {% for field in form %}
<span class="label-text">{{ field.label }}</span> <div class="my-3 w-full">
</label> <label class="label" for="{{ field.id_for_label }}">
{{ field|daisy }} <span class="label-text">{{ field.label }}</span>
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %} </label>
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %} {{ field|daisy }}
</div> {% if field.help_text and not field.errors %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% endfor %} {% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
<div class="card-actions justify-end pt-2"> </div>
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_list' %}">{% lucide "arrow-left" size=16 %} Cancel</a> {% endfor %}
<button class="btn btn-primary" type="submit">Save</button> </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> </div>
</form> </form>
</div> </div>

View File

@@ -5,9 +5,9 @@
{% block actions %} {% block actions %}
{% if show_archived %} {% if show_archived %}
<a class="btn btn-ghost" href="{% url 'controlpanel:club_list' %}">Active clubs</a> <a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}">{% lucide "archive-x" size=16 %} Hide archived clubs</a>
{% else %} {% else %}
<a class="btn btn-ghost" href="{% url 'controlpanel:club_list' %}?archived=1">Archived</a> <a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}?archived=1">{% lucide "archive" size=16 %} Show archived clubs</a>
{% endif %} {% endif %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a> <a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a>
{% endblock actions %} {% endblock actions %}
@@ -15,48 +15,27 @@
{% block panel %} {% block panel %}
<form method="get" class="mb-4 flex gap-2"> <form method="get" class="mb-4 flex gap-2">
{% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %} {% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %}
<input type="search" <label class="input">
name="q" <span class="opacity-50">{% lucide "search" size=16 %}</span>
value="{{ search }}" <input type="search"
placeholder="Search clubs…" name="q"
class="input input-bordered w-full max-w-xs"> value="{{ search }}"
<button class="btn gap-2" type="submit">{% lucide "search" size=16 %} Search</button> 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> </form>
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<div class="overflow-x-auto"> {% if show_archived %}
<table class="table"> {% include "controlpanel/_club_health_table.html" with empty_message="No archived clubs." %}
<thead> {% else %}
<tr> {% include "controlpanel/_club_health_table.html" %}
<th>Club</th> {% endif %}
<th>Members</th>
<th>Teams</th>
<th>Events</th>
<th>Admins</th>
</tr>
</thead>
<tbody>
{% for club in clubs %}
<tr>
<td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<div class="text-xs opacity-60">{{ club.slug }}</div>
</td>
<td>{{ club.member_count }}</td>
<td>{{ club.team_count }}</td>
<td>{{ club.event_count }}</td>
<td>{{ club.admin_count }}</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">
{% if show_archived %}No archived clubs.{% else %}No clubs yet.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
{% endblock panel %} {% endblock panel %}

View File

@@ -2,46 +2,71 @@
{% load static lucide %} {% load static lucide %}
{% block heading %}RosterChief Platform Dashboard{% endblock heading %} {% block heading %}RosterChief Platform Dashboard{% endblock heading %}
{% block subheading %}Welcome back {{ user.member.first_name }}!{% endblock subheading %} {% block subheading %}Welcome back {{ user.member.first_name }} &middot; {% now "d b Y" %}{% endblock subheading %}
{% block actions %} {% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a> <a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a>
{% endblock actions %} {% endblock actions %}
{% block panel %} {% block panel %}
{% comment %} <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
Needs attention first: these are the numbers that are supposed to be zero. A club with <div class="card bg-base-100 shadow border-l-4 border-info">
no current season cannot take a signup or schedule a match — and it fails silently,
nothing errors — while an admin without a second factor is locked out of their own
club. Both are work queues, not statistics. The vanity totals sit further down.
{% endcomment %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="card bg-base-100 shadow {% if attention.clubs_without_season %}border-l-4 border-warning{% endif %}">
<div class="card-body p-4"> <div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "calendar-x" size=16 %} No current season</div> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "building-2" size=16 %} Clubs</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.clubs_without_season }}</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 class="text-xs opacity-60">Clubs that cannot take signups</div>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow {% if attention.dormant_clubs %}border-l-4 border-warning{% endif %}">
<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="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "moon-star" size=16 %} Dormant</div> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "moon-star" size=16 %} Dormant clubs</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.dormant_clubs }}</div> <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.dormant_clubs }}</div>
<div class="text-xs opacity-60">Nothing scheduled in 30 days</div> <div class="text-xs opacity-60">No events scheduled next 30 days</div>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow {% if attention.admins_pending_mfa %}border-l-4 border-error{% endif %}">
<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="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "shield-alert" size=16 %} MFA pending</div> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "shield-alert" size=16 %} MFA pending</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.admins_pending_mfa }}</div> <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.admins_pending_mfa }}</div>
<div class="text-xs opacity-60">Admins locked out until they enrol</div> <div class="text-xs opacity-60">Admins without MFA configured</div>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
<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="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "receipt-euro" size=16 %} Payment pending</div>
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div> <div class="text-4xl font-bold tabular-nums font-mono">&euro;{{ attention.dues_owed|floatformat:2 }}</div>
<div class="text-xs opacity-60">Unpaid across every club</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>
</div> </div>
@@ -56,142 +81,31 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "euro" size=18 %} Revenue per month</h2>
<div class="h-56">
<canvas id="revenue-chart"></canvas>
</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 bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2> <h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2>
<p class="text-sm opacity-70">Where clubs stall. One with no team or no events is a shell.</p> <p class="text-sm opacity-70">Tracking club onboarding to ensure a smooth start</p>
<div class="mt-2 space-y-3"> <div class="mt-2 space-y-3">
{% for step in funnel %} {% for step in funnel %}
<div> <div>
<div class="mb-1 flex items-center justify-between text-sm"> <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="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
<span class="font-semibold tabular-nums">{{ step.count }}</span> <span class="font-semibold tabular-nums font-mono">{{ step.count }}</span>
</div> </div>
<progress class="progress progress-primary w-full" value="{{ step.count }}" max="{{ funnel.0.count }}"></progress> <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> </div>
{% endfor %} {% endfor %}
</div> </div>
</div> </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 "toggle-right" size=18 %} Feature adoption</h2>
<a class="btn btn-ghost btn-xs" href="{% url 'controlpanel:features' %}">Manage</a>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<tbody>
{% for flag in flags %}
<tr>
<td class="font-mono font-medium">{{ flag.name }}</td>
<td class="text-right">
{% if flag.overridden %}
{# `everyone` overrides club targeting, so the club count says nothing here. #}
<span class="badge badge-sm {% if flag.everyone %}badge-success{% else %}badge-error{% endif %}">
{% if flag.everyone %}On for all{% else %}Off everywhere{% endif %}
</span>
{% else %}
<span class="tabular-nums">{{ flag.clubs }} / {{ totals.clubs }} clubs</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No features yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="stats mb-6 w-full bg-base-100 shadow">
<div class="stat">
<div class="stat-title">Active clubs</div>
<div class="stat-value">{{ totals.clubs }}</div>
</div>
<div class="stat">
<div class="stat-title">Archived</div>
<div class="stat-value">{{ totals.archived_clubs }}</div>
</div>
<div class="stat">
<div class="stat-title">Members</div>
<div class="stat-value">{{ totals.members }}</div>
<div class="stat-desc">{{ attention.members_without_login }} without a login</div>
</div>
<div class="stat">
<div class="stat-title">Club admins</div>
<div class="stat-value">{{ totals.admins }}</div>
</div>
</div> </div>
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<h2 class="card-title">Clubs</h2> <h2 class="card-title">{% lucide "building-2" size=18 %} Clubs</h2>
{% comment %} {% include "controlpanel/_club_health_table.html" %}
Health, not vanity: a club's member count says nothing you can act on, while
"no coach", "nothing scheduled" and "€ owed" each name a thing somebody has to
go and fix. Every column here is annotated in the same single query.
{% endcomment %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Club</th>
<th class="text-right">Members</th>
<th class="text-right">Unpaid</th>
<th class="text-right">Owed</th>
<th class="text-right">Teams</th>
<th class="text-right">Upcoming</th>
<th class="text-right">Admins</th>
</tr>
</thead>
<tbody>
{% for club in clubs %}
<tr>
<td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<div class="mt-1 flex flex-wrap items-center gap-1">
<span class="text-xs opacity-60">{{ club.slug }}</span>
{% if not club.has_season %}<span class="badge badge-warning badge-xs gap-1">{% lucide "calendar-x" size=10 %} No season</span>{% endif %}
{% if not club.upcoming_events %}<span class="badge badge-ghost badge-xs gap-1">{% lucide "moon-star" size=10 %} Dormant</span>{% endif %}
</div>
</td>
<td class="text-right tabular-nums">{{ club.active_members }}</td>
<td class="text-right tabular-nums {% if club.unpaid_members %}text-warning{% endif %}">{{ club.unpaid_members }}</td>
<td class="text-right tabular-nums {% if club.outstanding %}font-semibold text-error{% endif %}">€{{ club.outstanding|floatformat:2 }}</td>
<td class="text-right tabular-nums">
{{ club.team_count }}
{% if club.teams_without_coach %}
<span class="badge badge-error badge-xs ml-1" title="Teams with nobody able to pick the squad">{{ club.teams_without_coach }} no coach</span>
{% endif %}
</td>
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
<td class="text-right tabular-nums {% if not club.admin_count %}text-error{% endif %}">{{ club.admin_count }}</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center opacity-60">No clubs yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
{% endblock panel %} {% endblock panel %}
@@ -254,22 +168,22 @@
data: { data: {
labels: data.signups.map((point) => point.month), labels: data.signups.map((point) => point.month),
datasets: [ datasets: [
{ label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5") }, {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") }, {label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
], ],
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { legend: { position: "bottom", labels: { color: ink } } }, plugins: {legend: {position: "bottom", labels: {color: ink}}},
scales: { scales: {
x: { stacked: true, ticks: { color: ink }, grid: { color: grid } }, x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
y: { stacked: true, beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } }, y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
}, },
}, },
}); });
return [signups, build("revenue-chart", "Revenue", data.revenue, css("--color-accent", "#0ea5e9"), "line", true)]; return [signups]; // build("revenue-chart", "Dues", data.dues, css("--color-accent", "#0ea5e9"), "bar", true)];
}; };
let charts = render(); let charts = render();

View File

@@ -1,13 +1,65 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load lucide %} {% load lucide ui %}
{% block heading %}Features{% endblock heading %} {% block heading %}Features{% endblock heading %}
{% block actions %} {% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:flag_create' %}">{% lucide "plus" size=16 %} New feature</a> <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 %} {% endblock actions %}
{% block panel %} {% 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>&middot;</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 mb-6 bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-base">{% lucide "flag" size=18 %} Flags</h2> <h2 class="card-title text-base">{% lucide "flag" size=18 %} Flags</h2>
@@ -35,13 +87,13 @@
{% elif flag.everyone is False %} {% elif flag.everyone is False %}
<span class="badge badge-error">Off everywhere</span> <span class="badge badge-error">Off everywhere</span>
{% else %} {% else %}
<span class="badge badge-ghost">Per club</span> <span class="badge badge-info">Per club</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ flag.clubs.count }}</td> <td>{{ flag.clubs.count }}</td>
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"—" }}</td> <td class="max-w-xs truncate opacity-70">{{ flag.note|default:"-" }}</td>
<td class="text-right"> <td class="text-right">
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:flag_update' flag.pk %}">{% lucide "pencil" size=14 %} Edit</a> <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> </td>
</tr> </tr>
{% empty %} {% empty %}
@@ -52,6 +104,12 @@
</tbody> </tbody>
</table> </table>
</div> </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> </div>
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
@@ -64,7 +122,7 @@
{% for switch in switches %} {% for switch in switches %}
<tr> <tr>
<td class="font-mono font-medium">{{ switch.name }}</td> <td class="font-mono font-medium">{{ switch.name }}</td>
<td class="opacity-70">{{ switch.note|default:"—" }}</td> <td class="opacity-70">{{ switch.note|default:"-" }}</td>
<td class="text-right"> <td class="text-right">
<form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}"> <form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}">
{% csrf_token %} {% csrf_token %}

View File

@@ -1,33 +0,0 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% block heading %}{% if object %}Edit {{ object.name }}{% else %}New feature{% endif %}{% endblock heading %}
{% block panel %}
<div class="card max-w-xl 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 %}
{% for field in form %}
<div class="form-control my-3 w-full">
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:features' %}">{% lucide "arrow-left" size=16 %} Cancel</a>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -18,11 +18,11 @@ DEFAULT_WIDGET_CLASS = "input input-bordered w-full"
#: Icon, default heading and daisyUI colour per message level. #: Icon, default heading and daisyUI colour per message level.
MESSAGE_ALERTS = { MESSAGE_ALERTS = {
"debug": ("bug", "Debug", "alert-info"), "debug": ("bug", "Debug", "alert-info border-info"),
"info": ("info", "Heads up", "alert-info"), "info": ("info", "Heads up", "alert-info border-info"),
"success": ("circle-check", "Done", "alert-success"), "success": ("circle-check", "Done", "alert-success border-success"),
"warning": ("triangle-alert", "Careful", "alert-warning"), "warning": ("triangle-alert", "Careful", "alert-warning border-warning"),
"error": ("circle-x", "Something went wrong", "alert-error"), "error": ("circle-x", "Something went wrong", "alert-error border-error"),
} }
DEFAULT_MESSAGE_ALERT = MESSAGE_ALERTS["info"] DEFAULT_MESSAGE_ALERT = MESSAGE_ALERTS["info"]
@@ -32,10 +32,11 @@ def as_alert(message):
"""Presentation for one Django message: icon, bold title, body, colour. """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 Django messages carry a level and a string — there is no title field — so the
title comes from the level, and a call site that wants a specific one passes it title comes from the level, unless the message carries one as ``extra_tags``.
as ``extra_tags``:: Call sites queue messages with ``notify`` (controlpanel/messages.py), which sets
exactly that from a compact ``"<level>|<title>|<body>"`` spec::
messages.success(request, f"{club} is live.", extra_tags="Club created") notify(request, f"s|Club created|{club} is live.")
Keyed on ``level_tag``, never ``tags``: ``tags`` is extra_tags and level_tag 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 joined, so a message carrying a custom title would stop matching its own level
@@ -99,6 +100,17 @@ def excluded(field, names):
return field.name in (names or "").split(",") 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 @register.filter
def daisy(field, css=None): def daisy(field, css=None):
"""Render a bound form field with the right daisyUI classes. """Render a bound form field with the right daisyUI classes.

View File

@@ -1,23 +1,32 @@
import datetime import datetime
import pathlib
from decimal import Decimal from decimal import Decimal
from unittest import mock
from allauth.mfa.models import Authenticator from allauth.mfa.models import Authenticator
from django import forms from django import forms
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.messages.storage.base import Message from django.contrib.messages.storage.base import Message
from django.contrib.messages.storage.fallback import FallbackStorage
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase, override_settings from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from waffle import get_waffle_flag_model, get_waffle_switch_model 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 club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Event from events.models import Attendance, Event
from features.models import Maintenance
from members.models import Member from members.models import Member
from shop.models import Order from shop.models import Order
from teams.models import Position, StaffAssignment, Team, TeamMembership from teams.models import Position, StaffAssignment, Team, TeamMembership
from .messages import LEVELS, notify
from .services.admins import grant_club_admin from .services.admins import grant_club_admin
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
from .services.statistics import ( from .services.statistics import (
@@ -168,11 +177,18 @@ class ClubAdminManagementTests(ControlPanelTestBase):
self.assertEqual(ClubRole.objects.get(club=self.club, member__user=user).role, ClubRole.Roles.ADMIN) 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): def test_a_new_email_must_come_with_a_name(self):
response = self.add_admin(email="nameless@example.com", first_name="", last_name="") # 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.assertEqual(response.status_code, 200) self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertFalse(ClubRole.objects.exists()) self.assertFalse(ClubRole.objects.exists())
self.assertFormError(response.context["form"], "first_name", "Required: this email has no account yet.") 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): def test_an_existing_member_is_promoted_rather_than_duplicated(self):
user = User.objects.create_user(email="existing@example.com", password="pw-secret-123") user = User.objects.create_user(email="existing@example.com", password="pw-secret-123")
@@ -308,8 +324,10 @@ class PlatformAdminTests(TestCase):
def test_superuser_sees_the_admins_section(self): def test_superuser_sees_the_admins_section(self):
self.assertEqual(self.client.get(reverse("controlpanel:admins")).status_code, 200) self.assertEqual(self.client.get(reverse("controlpanel:admins")).status_code, 200)
def test_the_grant_form_renders(self): def test_the_grant_form_is_post_only(self):
self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 200) # 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): 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"}) self.client.post(reverse("controlpanel:admin_add"), {"email": "New.Admin@Example.com"})
@@ -400,9 +418,17 @@ class FeatureViewTests(ControlPanelTestBase):
self.assertContains(response, "shop") self.assertContains(response, "shop")
self.assertContains(response, "maintenance") self.assertContains(response, "maintenance")
def test_the_flag_forms_render(self): def test_the_flag_forms_are_post_only(self):
self.assertEqual(self.client.get(reverse("controlpanel:flag_create")).status_code, 200) # Reachable only through a modal on the features page: there is no standalone
self.assertEqual(self.client.get(reverse("controlpanel:flag_update", args=[self.flag.pk])).status_code, 200) # 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): def test_create_a_flag(self):
self.client.post(reverse("controlpanel:flag_create"), {"name": "news", "note": "News module", "percent": "", "everyone": ""}) self.client.post(reverse("controlpanel:flag_create"), {"name": "news", "note": "News module", "percent": "", "everyone": ""})
@@ -449,33 +475,75 @@ class FeatureViewTests(ControlPanelTestBase):
self.assertNotContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk])) 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): class MessageAlertTests(TestCase):
def alert(self, level, text, extra_tags=None): def alert(self, level, text, extra_tags=None):
return as_alert(Message(level, text, extra_tags=extra_tags)) return as_alert(Message(level, text, extra_tags=extra_tags))
def test_each_level_gets_its_own_icon_title_and_colour(self): 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.SUCCESS, "Saved.")["icon"], "circle-check")
self.assertEqual(self.alert(messages.WARNING, "Careful.")["css"], "alert-warning") 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.ERROR, "Boom.")["title"], "Something went wrong")
self.assertEqual(self.alert(messages.INFO, "FYI.")["css"], "alert-info") self.assertEqual(self.alert(messages.INFO, "FYI.")["css"], "alert-info border-info")
def test_extra_tags_override_the_title(self): def test_extra_tags_override_the_title(self):
alert = self.alert(messages.SUCCESS, "Ajax United is live.", extra_tags="Club created") alert = self.alert(messages.SUCCESS, "Ajax United is live.", extra_tags="Club created")
self.assertEqual(alert["title"], "Club created") self.assertEqual(alert["title"], "Club created")
self.assertEqual(alert["body"], "Ajax United is live.") self.assertEqual(alert["body"], "Ajax United is live.")
self.assertEqual(alert["css"], "alert-success") # a custom title must not change the level 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): def test_an_unknown_level_falls_back_to_info(self):
self.assertEqual(self.alert(999, "Odd.")["css"], "alert-info") self.assertEqual(self.alert(999, "Odd.")["css"], "alert-info border-info")
class MessageRenderingTests(ControlPanelTestBase): class MessageRenderingTests(ControlPanelTestBase):
def test_a_message_renders_as_a_soft_alert_with_icon_and_title(self): 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) response = self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]), follow=True)
self.assertContains(response, "alert alert-soft alert-warning") self.assertContains(response, "alert alert-soft border-2 alert-warning border-warning")
self.assertContains(response, '<div class="font-bold">Careful</div>', html=False) self.assertContains(response, '<div class="font-bold">Club archived</div>', html=False)
self.assertContains(response, "<svg") # the lucide icon self.assertContains(response, "<svg") # the lucide icon
@@ -728,11 +796,13 @@ class PlatformChartTests(TestCase):
self.assertEqual(platform_charts()["signups"][-1]["new"], 1) self.assertEqual(platform_charts()["signups"][-1]["new"], 1)
def test_only_paid_orders_count_as_revenue(self): 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("50.00"), status=Order.OrderStatus.PAID)
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("30.00"), status=Order.OrderStatus.PENDING) Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("30.00"), status=Order.OrderStatus.PENDING)
self.assertEqual(platform_charts()["revenue"][-1]["value"], 50.0) self.assertEqual(platform_charts()["club_revenue"][-1]["value"], 50.0)
self.assertEqual(platform_attention()["outstanding"], Decimal("30.00")) self.assertEqual(platform_attention()["outstanding"], Decimal("30.00"))
@@ -742,8 +812,8 @@ class DashboardMetricsTests(ControlPanelTestBase):
self.assertContains(response, "No current season") self.assertContains(response, "No current season")
self.assertContains(response, "MFA pending") self.assertContains(response, "MFA pending")
self.assertContains(response, "Payment pending")
self.assertContains(response, 'id="signups-chart"') self.assertContains(response, 'id="signups-chart"')
self.assertContains(response, 'id="revenue-chart"')
self.assertContains(response, "js/chart.js") self.assertContains(response, "js/chart.js")
self.assertIn("signups", response.context["charts"]) self.assertIn("signups", response.context["charts"])
@@ -1005,6 +1075,405 @@ class ClubHealthTableTests(TestCase):
response = self.client.get(reverse("controlpanel:dashboard")) response = self.client.get(reverse("controlpanel:dashboard"))
self.assertContains(response, "Owed") # Health, not vanity: Plan and Dues each name something to act on, next to the counts.
self.assertContains(response, "Upcoming") for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
self.assertContains(response, "Unpaid") 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 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")

View File

@@ -18,9 +18,20 @@ urlpatterns = [
path("clubs/<uuid:pk>/features/<int:flag_pk>/toggle/", views.ClubFeatureToggleView.as_view(), name="club_feature_toggle"), path("clubs/<uuid:pk>/features/<int:flag_pk>/toggle/", views.ClubFeatureToggleView.as_view(), name="club_feature_toggle"),
# Features # Features
path("features/", views.FeatureListView.as_view(), name="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/new/", views.FlagCreateView.as_view(), name="flag_create"),
path("features/flags/<int:pk>/edit/", views.FlagUpdateView.as_view(), name="flag_update"), 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"), 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>/period/new/", views.OpenPeriodView.as_view(), name="club_open_period"),
# Platform admins (superusers only) # Platform admins (superusers only)
path("admins/", views.PlatformAdminListView.as_view(), name="admins"), path("admins/", views.PlatformAdminListView.as_view(), name="admins"),
path("admins/add/", views.PlatformAdminAddView.as_view(), name="admin_add"), path("admins/add/", views.PlatformAdminAddView.as_view(), name="admin_add"),

View File

@@ -1,14 +1,25 @@
from django.contrib import messages from contextlib import contextmanager
from django.contrib.auth import get_user_model 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.shortcuts import get_object_or_404, redirect
from django.urls import reverse 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 django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
from waffle import get_waffle_flag_model, get_waffle_switch_model 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, subscribe, waive
from billing.services.invoices import invoice_pdf, issue_invoice
from club.models import Club, ClubRole from club.models import Club, ClubRole
from features.models import Maintenance
from .forms import ClubAdminForm, ClubForm, FlagForm, PlatformAdminForm from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin from .messages import notify
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
from .services.admins import grant_club_admin, revoke_club_admin from .services.admins import grant_club_admin, revoke_club_admin
from .services.platform_admins import ( from .services.platform_admins import (
PlatformAdminError, PlatformAdminError,
@@ -17,12 +28,26 @@ from .services.platform_admins import (
revoke_platform_access, revoke_platform_access,
set_platform_access, set_platform_access,
) )
from .services.statistics import club_attention, club_charts, club_statistics, clubs_with_health, clubs_with_totals, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals 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() Flag = get_waffle_flag_model()
Switch = get_waffle_switch_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): class DashboardView(PlatformStaffRequiredMixin, TemplateView):
template_name = "controlpanel/dashboard.html" template_name = "controlpanel/dashboard.html"
@@ -35,6 +60,7 @@ class DashboardView(PlatformStaffRequiredMixin, TemplateView):
flags=flag_adoption(), flags=flag_adoption(),
charts=platform_charts(), charts=platform_charts(),
clubs=clubs_with_health(), clubs=clubs_with_health(),
today=timezone.localdate(),
**kwargs, **kwargs,
) )
@@ -52,10 +78,10 @@ class ClubListView(PlatformStaffRequiredMixin, ListView):
search = self.request.GET.get("q", "").strip() search = self.request.GET.get("q", "").strip()
if search: if search:
clubs = clubs.filter(name__icontains=search) clubs = clubs.filter(name__icontains=search)
return clubs_with_totals(clubs) return clubs_with_health(clubs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(nav="clubs", show_archived=self.show_archived, search=self.request.GET.get("q", ""), **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): class ClubCreateView(PlatformStaffRequiredMixin, CreateView):
@@ -65,12 +91,15 @@ class ClubCreateView(PlatformStaffRequiredMixin, CreateView):
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(form) response = super().form_valid(form)
messages.success(self.request, f"Club “{self.object}” created.") notify(self.request, f"s|Club created|Club “{self.object}” created.")
return response return response
def get_success_url(self): def get_success_url(self):
return reverse("controlpanel:club_detail", args=[self.object.pk]) 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): class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView):
model = Club model = Club
@@ -79,12 +108,15 @@ class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView):
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(form) response = super().form_valid(form)
messages.success(self.request, f"Club “{self.object}” updated.") notify(self.request, f"s|Club updated|Club “{self.object}” updated.")
return response return response
def get_success_url(self): def get_success_url(self):
return reverse("controlpanel:club_detail", args=[self.object.pk]) 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): class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
model = Club model = Club
@@ -92,13 +124,30 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
context_object_name = "club" context_object_name = "club"
def get_context_data(self, **kwargs): 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})
return super().get_context_data( return super().get_context_data(
nav="clubs", nav="clubs",
groups=club_statistics(self.object), groups=club_statistics(self.object),
attention=club_attention(self.object), attention=club_attention(self.object),
charts=club_charts(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"), 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), 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(),
**kwargs, **kwargs,
) )
@@ -109,7 +158,7 @@ class ClubArchiveView(PlatformStaffRequiredMixin, View):
def post(self, request, pk): def post(self, request, pk):
club = get_object_or_404(Club, pk=pk) club = get_object_or_404(Club, pk=pk)
club.archive() club.archive()
messages.warning(request, f"Club “{club}” archived. Its subdomain no longer resolves.") notify(request, f"w|Club archived|Club “{club}” archived. Its subdomain no longer resolves.")
return redirect("controlpanel:club_detail", pk=club.pk) return redirect("controlpanel:club_detail", pk=club.pk)
@@ -117,24 +166,28 @@ class ClubRestoreView(PlatformStaffRequiredMixin, View):
def post(self, request, pk): def post(self, request, pk):
club = get_object_or_404(Club, pk=pk) club = get_object_or_404(Club, pk=pk)
club.restore() club.restore()
messages.success(request, f"Club “{club}” restored.") notify(request, f"s|Club restored|Club “{club}” restored.")
return redirect("controlpanel:club_detail", pk=club.pk) return redirect("controlpanel:club_detail", pk=club.pk)
class ClubAdminAddView(PlatformStaffRequiredMixin, FormView): 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 form_class = ClubAdminForm
template_name = "controlpanel/club_admin_form.html" http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:club_detail"
@property @property
def club(self): def club(self):
return get_object_or_404(Club, pk=self.kwargs["pk"]) return get_object_or_404(Club, pk=self.kwargs["pk"])
def get_context_data(self, **kwargs): def get_invalid_redirect_kwargs(self):
return super().get_context_data(nav="clubs", club=self.club, **kwargs) return {"pk": self.kwargs["pk"]}
def form_valid(self, form): def form_valid(self, form):
role = grant_club_admin(self.club, **form.cleaned_data) role = grant_club_admin(self.club, **form.cleaned_data)
messages.success(self.request, f"{role.member} is now an admin of {role.club}. They must set up two-factor authentication before they can sign in.") 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"]) return redirect("controlpanel:club_detail", pk=self.kwargs["pk"])
@@ -143,24 +196,10 @@ class ClubAdminRemoveView(PlatformStaffRequiredMixin, View):
role = get_object_or_404(ClubRole, pk=role_pk, club_id=pk, role=ClubRole.Roles.ADMIN) role = get_object_or_404(ClubRole, pk=role_pk, club_id=pk, role=ClubRole.Roles.ADMIN)
member = role.member member = role.member
revoke_club_admin(role) revoke_club_admin(role)
messages.warning(request, f"{member} is no longer an admin of this club.") notify(request, f"w|Admin removed|{member} is no longer an admin of this club.")
return redirect("controlpanel:club_detail", pk=pk) return redirect("controlpanel:club_detail", pk=pk)
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 Flag.objects.order_by("name")
]
class ClubFeatureToggleView(PlatformStaffRequiredMixin, View): class ClubFeatureToggleView(PlatformStaffRequiredMixin, View):
"""Turn a feature on or off for one club.""" """Turn a feature on or off for one club."""
@@ -170,10 +209,10 @@ class ClubFeatureToggleView(PlatformStaffRequiredMixin, View):
if flag.clubs.filter(pk=club.pk).exists(): if flag.clubs.filter(pk=club.pk).exists():
flag.clubs.remove(club) flag.clubs.remove(club)
messages.warning(request, f"“{flag.name}” turned off for {club}.") notify(request, f"w|Feature disabled|“{flag.name}” turned off for {club}.")
else: else:
flag.clubs.add(club) flag.clubs.add(club)
messages.success(request, f"“{flag.name}” turned on for {club}.") notify(request, f"s|Feature enabled|“{flag.name}” turned on for {club}.")
return redirect("controlpanel:club_detail", pk=club.pk) return redirect("controlpanel:club_detail", pk=club.pk)
@@ -182,43 +221,69 @@ class FeatureListView(PlatformStaffRequiredMixin, TemplateView):
template_name = "controlpanel/features.html" template_name = "controlpanel/features.html"
def get_context_data(self, **kwargs): 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( return super().get_context_data(
nav="features", nav="features",
flags=Flag.objects.prefetch_related("clubs").order_by("name"), flags=flags,
flag_form=FlagForm(),
switches=Switch.objects.order_by("name"), switches=Switch.objects.order_by("name"),
maintenance=Maintenance.current(),
maintenance_form=MaintenanceForm(),
**kwargs, **kwargs,
) )
class FlagCreateView(PlatformStaffRequiredMixin, CreateView): 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 model = Flag
form_class = FlagForm form_class = FlagForm
template_name = "controlpanel/flag_form.html" http_method_names = ["post"]
success_url = None invalid_redirect_url_name = "controlpanel:features"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="features", **kwargs)
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(form) response = super().form_valid(form)
messages.success(self.request, f"Feature “{self.object.name}” created.") notify(self.request, f"s|Feature created|Feature “{self.object.name}” created.")
return response return response
def get_success_url(self): def get_success_url(self):
return reverse("controlpanel:features") return reverse("controlpanel:features")
class FlagUpdateView(PlatformStaffRequiredMixin, UpdateView): 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 model = Flag
form_class = FlagForm form_class = FlagForm
template_name = "controlpanel/flag_form.html" http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:features"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="features", **kwargs)
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(form) response = super().form_valid(form)
messages.success(self.request, f"Feature “{self.object.name}” updated.") notify(self.request, f"s|Feature updated|Feature “{self.object.name}” updated.")
return response return response
def get_success_url(self): def get_success_url(self):
@@ -232,7 +297,8 @@ class SwitchToggleView(PlatformStaffRequiredMixin, View):
switch = get_object_or_404(Switch, pk=pk) switch = get_object_or_404(Switch, pk=pk)
switch.active = not switch.active switch.active = not switch.active
switch.save() switch.save()
messages.success(request, f"Switch “{switch.name}” is now {'on' if switch.active else 'off'}.") 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") return redirect("controlpanel:features")
@@ -240,19 +306,20 @@ class PlatformAdminListView(PlatformSuperuserRequiredMixin, TemplateView):
template_name = "controlpanel/admins.html" template_name = "controlpanel/admins.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(nav="admins", admins=platform_admins(), **kwargs) return super().get_context_data(nav="admins", admins=platform_admins(), admin_form=PlatformAdminForm(), **kwargs)
class PlatformAdminAddView(PlatformSuperuserRequiredMixin, FormView): 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 form_class = PlatformAdminForm
template_name = "controlpanel/admin_form.html" http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:admins"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="admins", **kwargs)
def form_valid(self, form): def form_valid(self, form):
user = grant_platform_access(form.cleaned_data["email"], is_superuser=form.cleaned_data["is_superuser"]) user = grant_platform_access(form.cleaned_data["email"], is_superuser=form.cleaned_data["is_superuser"])
messages.success(self.request, f"{user.email} now has platform access. They must set up two-factor authentication before they can sign in.") 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") return redirect("controlpanel:admins")
@@ -267,9 +334,9 @@ class PlatformAdminUpdateView(PlatformSuperuserRequiredMixin, View):
is_superuser=request.POST.get("is_superuser") == "1", is_superuser=request.POST.get("is_superuser") == "1",
) )
except PlatformAdminError as error: except PlatformAdminError as error:
messages.error(request, str(error)) notify(request, f"e|Couldn't update access|{error}")
else: else:
messages.success(request, f"Updated platform access for {user.email}.") notify(request, f"s|Access updated|Updated platform access for {user.email}.")
return redirect("controlpanel:admins") return redirect("controlpanel:admins")
@@ -279,7 +346,223 @@ class PlatformAdminRevokeView(PlatformSuperuserRequiredMixin, View):
try: try:
revoke_platform_access(request.user, user) revoke_platform_access(request.user, user)
except PlatformAdminError as error: except PlatformAdminError as error:
messages.error(request, str(error)) notify(request, f"e|Couldn't revoke access|{error}")
else: else:
messages.warning(request, f"{user.email} no longer has platform access.") notify(request, f"w|Access revoked|{user.email} no longer has platform access.")
return redirect("controlpanel:admins") 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.
subscription = form.save(commit=False)
subscription.club = club
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 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

51
deploy/backup.sh Executable file
View File

@@ -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

24
deploy/caddy/Caddyfile Normal file
View File

@@ -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
}
}

9
deploy/caddy/Dockerfile Normal file
View File

@@ -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

111
deploy/deploy-dev.sh Executable file
View File

@@ -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."

30
deploy/restore-check.sh Executable file
View File

@@ -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"

View File

@@ -1,10 +1,9 @@
from django.core.management.base import BaseCommand
from events.models import EventSeries from events.models import EventSeries
from events.services import generate_occurrences, horizon from events.services import generate_occurrences, horizon
from features.commands import MaintenanceAwareCommand
class Command(BaseCommand): class Command(MaintenanceAwareCommand):
help = "Materialise recurring event occurrences up to the rolling horizon." help = "Materialise recurring event occurrences up to the rolling horizon."
def handle(self, *args, **options): def handle(self, *args, **options):

31
features/commands.py Normal file
View File

@@ -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

View File

@@ -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()}

74
features/middleware.py Normal file
View File

@@ -0,0 +1,74 @@
"""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/",
"/media/",
"/__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.
ALWAYS_OPEN = ("/healthz",)
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.
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)

View File

@@ -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',
},
),
]

View File

@@ -10,11 +10,15 @@ Everything waffle already offers (``everyone`` / ``percent`` / ``staff`` /
``superusers`` / per-user / per-group) keeps working untouched. ``superusers`` / per-user / per-group) keeps working untouched.
""" """
from django.conf import settings
from django.db import models from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from waffle.models import CACHE_EMPTY, AbstractUserFlag from waffle.models import CACHE_EMPTY, AbstractUserFlag
from waffle.utils import get_cache, keyfmt 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 #: Cache key template for a flag's club ids, mirroring waffle's own
#: FLAG_USERS_CACHE_KEY / FLAG_GROUPS_CACHE_KEY. #: FLAG_USERS_CACHE_KEY / FLAG_GROUPS_CACHE_KEY.
FLAG_CLUBS_CACHE_KEY = "flag:%s:clubs" FLAG_CLUBS_CACHE_KEY = "flag:%s:clubs"
@@ -69,3 +73,74 @@ class Flag(AbstractUserFlag):
if self.everyone is not None: if self.everyone is not None:
return self.everyone return self.everyone
return club.pk in self._get_club_ids() 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

View File

@@ -1,10 +1,19 @@
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.cache import cache
from django.test import RequestFactory, TestCase 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 waffle import flag_is_active, get_waffle_flag_model
from club.models import Club from club.models import Club
from .models import Maintenance
Flag = get_waffle_flag_model() Flag = get_waffle_flag_model()
User = get_user_model()
class ClubScopedFlagTests(TestCase): class ClubScopedFlagTests(TestCase):
@@ -94,3 +103,134 @@ class ClubScopedFlagTests(TestCase):
self.club.flags.add(self.flag) self.club.flags.add(self.flag)
self.assertTrue(self.active_for(self.club)) 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_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

View File

@@ -3,13 +3,13 @@
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
from formbuilder.models import Answer, Field, Submission from formbuilder.models import Answer, Submission
from .options import allowed_values from .form_factory import build_form
class FormSubmissionError(Exception): class FormSubmissionError(Exception):
"""Raised when a submission is rejected. ``errors`` maps field key -> message.""" """Raised when a submission is rejected. ``errors`` maps field key -> messages."""
def __init__(self, message, *, errors=None): def __init__(self, message, *, errors=None):
super().__init__(message) super().__init__(message)
@@ -21,12 +21,17 @@ def _is_empty(value):
@transaction.atomic @transaction.atomic
def submit_form(form, member, data, *, when=None): def submit_form(form, member, data, *, files=None, when=None):
"""Create a Submission (with Answers) for ``data`` or raise FormSubmissionError.""" """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() when = when or timezone.now()
_check_open(form, member, when) _check_open(form, member, when)
cleaned = _clean_answers(form, data) cleaned = _clean_answers(form, data, files)
submission = Submission.objects.create(form=form, member=member) submission = Submission.objects.create(form=form, member=member)
Answer.objects.bulk_create([Answer(submission=submission, field=field, value=value) for field, value in cleaned]) Answer.objects.bulk_create([Answer(submission=submission, field=field, value=value) for field, value in cleaned])
@@ -48,37 +53,13 @@ def _check_open(form, member, when):
raise FormSubmissionError("You have reached the maximum number of submissions for this form.") raise FormSubmissionError("You have reached the maximum number of submissions for this form.")
def _clean_answers(form, data): def _clean_answers(form, data, files):
errors = {} bound_form = build_form(form, data=data, files=files or {})
cleaned = [] if not bound_form.is_valid():
errors = {key: list(messages) for key, messages in bound_form.errors.items()}
for field in form.fields.filter(is_active=True):
raw = data.get(field.key)
if _is_empty(raw):
if field.required:
errors[field.key] = "This field is required."
continue
message = _validate_choice(field, raw)
if message is not None:
errors[field.key] = message
continue
cleaned.append((field, raw))
if errors:
raise FormSubmissionError("The submission has errors.", errors=errors) raise FormSubmissionError("The submission has errors.", errors=errors)
return cleaned
# Blank optional answers are validated (they may legitimately be empty) but not
def _validate_choice(field, raw): # stored — an Answer row exists only where the submitter actually said something.
if field.field_type == Field.FieldType.CHOICE: fields_by_key = {field.key: field for field in form.fields.filter(is_active=True)}
allowed = allowed_values(field) return [(fields_by_key[key], value) for key, value in bound_form.cleaned_data.items() if not _is_empty(value)]
if allowed and raw not in allowed:
return "Select a valid choice."
elif field.field_type == Field.FieldType.MULTICHOICE:
allowed = allowed_values(field)
values = raw if isinstance(raw, list) else [raw]
if allowed and not set(values) <= allowed:
return "Select valid choices."
return None

View File

@@ -211,6 +211,24 @@ class SubmitFormTests(FormbuilderTestBase):
self.assertIn("size", ctx.exception.errors) 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): 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"]) field = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue", "wed"])

View File

@@ -1,9 +1,8 @@
from django.core.management.base import BaseCommand, CommandError from features.commands import CommandError, MaintenanceAwareCommand
from members.services import MemberCsvImporter from members.services import MemberCsvImporter
class Command(BaseCommand): class Command(MaintenanceAwareCommand):
help = "Import club members from a CSV file." help = "Import club members from a CSV file."
def add_arguments(self, parser) -> None: def add_arguments(self, parser) -> None:

View File

@@ -123,7 +123,7 @@ class MemberCsvImporter:
}, },
) )
club, _ = Club.objects.get_or_create(name=club_name) club = self.get_club(club_name)
season = self.get_current_season(club) season = self.get_current_season(club)
_, membership_created = ClubMembership.objects.update_or_create( _, membership_created = ClubMembership.objects.update_or_create(
@@ -141,6 +141,19 @@ class MemberCsvImporter:
membership_created=membership_created, membership_created=membership_created,
) )
def get_club(self, club_name) -> Club:
# Never get_or_create: Club.name isn't unique, so a typo'd or differently-cased
# value would otherwise either spin up a duplicate club or raise
# MultipleObjectsReturned against one that already exists. Matching
# case-insensitively absorbs the harmless variety (a CSV export's casing rarely
# matches the platform's own); an unknown club is a data problem the importer
# must not paper over by inventing one.
club = Club.objects.filter(name__iexact=club_name).first()
if club is None:
raise ValueError(f"Unknown club '{club_name}'.")
return club
def get_current_season(self, club) -> Season: def get_current_season(self, club) -> Season:
# Season.get_current() is tenant-scoped, so bind the row's club as the # Season.get_current() is tenant-scoped, so bind the row's club as the
# active tenant for the lookup. # active tenant for the lookup.

View File

@@ -554,8 +554,9 @@ class ImportMembersCsvCommandTests(TestCase):
self.assertFalse(Member.objects.filter(email="nofirst@example.com").exists()) self.assertFalse(Member.objects.filter(email="nofirst@example.com").exists())
def test_import_skips_row_when_club_has_no_current_season(self): def test_import_skips_row_when_club_has_no_current_season(self):
# "New Club" has no season, so the row is skipped and — because the row # The club exists but has no season, so the row is skipped and — because
# is atomic — the member and club creation roll back too. # the row is atomic — the member creation rolls back too.
Club.objects.create(name="New Club")
csv_path = self.write_csv( csv_path = self.write_csv(
"\n".join( "\n".join(
[ [
@@ -571,7 +572,45 @@ class ImportMembersCsvCommandTests(TestCase):
self.assertIn("No current season for club 'New Club'.", stderr) self.assertIn("No current season for club 'New Club'.", stderr)
self.assertIn("Rows skipped: 1.", stdout) self.assertIn("Rows skipped: 1.", stdout)
self.assertFalse(Member.objects.filter(email="jane@example.com").exists()) self.assertFalse(Member.objects.filter(email="jane@example.com").exists())
self.assertFalse(Club.objects.filter(name="New Club").exists())
def test_import_skips_row_for_an_unknown_club(self):
# The importer must not silently spin up a club for a typo'd or unknown
# name — that is a data problem, not something to paper over.
csv_path = self.write_csv(
"\n".join(
[
"first_name,last_name,email,date_of_birth,create_account,club_name,license_number",
"Jane,Doe,jane@example.com,2010-04-12,false,Nonexistent Club,LIC-001",
]
)
)
stdout, stderr = self.call_import_command(csv_path)
self.assertIn("Row 2 skipped:", stderr)
self.assertIn("Unknown club 'Nonexistent Club'.", stderr)
self.assertIn("Rows skipped: 1.", stdout)
self.assertFalse(Member.objects.filter(email="jane@example.com").exists())
self.assertFalse(Club.objects.filter(name="Nonexistent Club").exists())
def test_import_matches_a_club_name_case_insensitively(self):
# A CSV export's casing rarely matches the platform's own; that is
# harmless variation, not a different club.
csv_path = self.write_csv(
"\n".join(
[
"first_name,last_name,email,date_of_birth,create_account,club_name,license_number",
"Jane,Doe,jane@example.com,2010-04-12,false,city swim club,LIC-001",
]
)
)
stdout, stderr = self.call_import_command(csv_path)
self.assertEqual(stderr, "")
self.assertIn("Rows skipped: 0.", stdout)
membership = ClubMembership.objects.get(club=self.club, member__email="jane@example.com")
self.assertEqual(membership.season, self.season)
class MemberImportResultTests(TestCase): class MemberImportResultTests(TestCase):

View File

@@ -3,8 +3,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tailwindcss -i ./static/src/app.css -o ./static/css/app.css --minify", "build": "tailwindcss -i ./assets/app.css -o ./static/css/app.css --minify",
"watch": "tailwindcss -i ./static/src/app.css -o ./static/css/app.css --watch" "watch": "tailwindcss -i ./assets/app.css -o ./static/css/app.css --watch",
"vendor:chart": "node -e \"const fs=require('fs');const s=fs.readFileSync('node_modules/chart.js/dist/chart.umd.js','utf8').split('\\n').filter(l=>!l.startsWith('//# sourceMappingURL=')).join('\\n');fs.writeFileSync('static/js/chart.js',s)\""
}, },
"devDependencies": { "devDependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",

View File

@@ -8,10 +8,16 @@ dependencies = [
"django-allauth[mfa]>=65.18.0", "django-allauth[mfa]>=65.18.0",
"django-lucide", "django-lucide",
"django-phonenumber-field[phonenumbers]>=8.4.0", "django-phonenumber-field[phonenumbers]>=8.4.0",
"django-redis>=7.0.0",
"django-storages[s3]>=1.14.6",
"django-waffle>=5.0.0", "django-waffle>=5.0.0",
"gunicorn>=26.0.0",
"pillow>=12.3.0", "pillow>=12.3.0",
"psycopg[binary]>=3.3.4",
"python-dateutil>=2.9.0.post0", "python-dateutil>=2.9.0.post0",
"python-decouple>=3.8", "python-decouple>=3.8",
"weasyprint>=69.0",
"whitenoise>=6.12.0",
] ]
[dependency-groups] [dependency-groups]
@@ -49,7 +55,8 @@ ignore = [
"rosterchief/settings/*" = ["F403", "F405", "E501"] "rosterchief/settings/*" = ["F403", "F405", "E501"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-first-party = ["authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "news", "pages", "home", "search", "rosterchief"] known-first-party = [
"billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "news", "pages", "home", "search", "rosterchief"]
[tool.uv.sources] [tool.uv.sources]
django-lucide = { git = "https://github.com/bsiebens/lucide" } django-lucide = { git = "https://github.com/bsiebens/lucide" }

44
rosterchief/health.py Normal file
View File

@@ -0,0 +1,44 @@
"""Liveness for the proxy today, for a load balancer later.
Checks the two dependencies whose absence makes the app lie rather than fail: without the
database it cannot serve anything, and without a shared cache the feature flags drift apart
between workers. A health check that only proves the process is listening would call that
healthy.
"""
from django.core.cache import cache
from django.db import connection
from django.http import JsonResponse
from django.views.decorators.cache import never_cache
PROBE_KEY = "healthz"
@never_cache
def healthz(request):
checks = {"database": _database(), "cache": _cache()}
healthy = all(checks.values())
return JsonResponse({"status": "ok" if healthy else "degraded", "checks": checks}, status=200 if healthy else 503)
def _database() -> bool:
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
except Exception:
return False
return True
def _cache() -> bool:
"""A round trip, not a ping: a cache that accepts writes and returns nothing is worse
than one that is plainly down, because waffle would read every flag as unset."""
try:
cache.set(PROBE_KEY, "ok", 10)
return cache.get(PROBE_KEY) == "ok"
except Exception:
return False

View File

@@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/ https://docs.djangoproject.com/en/6.0/ref/settings/
""" """
from importlib.util import find_spec
from pathlib import Path from pathlib import Path
from decouple import Csv, config from decouple import Csv, config
@@ -29,6 +30,12 @@ SECRET_KEY = config("DJANGO_SECRET_KEY")
DEBUG = config("DJANGO_DEBUG", default=False, cast=bool) DEBUG = config("DJANGO_DEBUG", default=False, cast=bool)
ALLOWED_HOSTS = config("DJANGO_ALLOWED_HOSTS", cast=Csv(), default="") ALLOWED_HOSTS = config("DJANGO_ALLOWED_HOSTS", cast=Csv(), default="")
# The loopback is always allowed, and it must be: the container's own healthcheck and the
# deploy probe hit /healthz over 127.0.0.1/localhost, before any proxy has supplied a real
# Host header. Without this they get a 400 and the container is marked unhealthy forever.
# It widens nothing — gunicorn binds to the loopback only; the proxy owns the public domains.
ALLOWED_HOSTS += [host for host in ("localhost", "127.0.0.1") if host not in ALLOWED_HOSTS]
INTERNAL_IPS = config("DJANGO_INTERNAL_IPS", cast=Csv(), default="127.0.0.1") INTERNAL_IPS = config("DJANGO_INTERNAL_IPS", cast=Csv(), default="127.0.0.1")
CSRF_TRUSTED_ORIGINS = config("DJANGO_CSRF_TRUSTED_ORIGINS", cast=Csv(), default="") CSRF_TRUSTED_ORIGINS = config("DJANGO_CSRF_TRUSTED_ORIGINS", cast=Csv(), default="")
@@ -61,6 +68,8 @@ INSTALLED_APPS = [
"events.apps.EventsConfig", "events.apps.EventsConfig",
"formbuilder.apps.FormbuilderConfig", "formbuilder.apps.FormbuilderConfig",
"shop.apps.ShopConfig", "shop.apps.ShopConfig",
# Platform billing: RosterChief charging the clubs. Not tenant data — see billing/models.py.
"billing.apps.BillingConfig",
"waffle", "waffle",
"features.apps.FeaturesConfig", "features.apps.FeaturesConfig",
"controlpanel.apps.ControlpanelConfig", "controlpanel.apps.ControlpanelConfig",
@@ -73,6 +82,10 @@ WAFFLE_FLAG_MODEL = "features.Flag"
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
# Directly after SecurityMiddleware, per WhiteNoise's contract. It serves the collected
# static files from the app itself, so no shared volume or CDN is needed to add a second
# app server.
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@@ -80,14 +93,23 @@ MIDDLEWARE = [
"allauth.account.middleware.AccountMiddleware", "allauth.account.middleware.AccountMiddleware",
"authentication.middleware.RequireMFAMiddleware", "authentication.middleware.RequireMFAMiddleware",
"club.tenancy.ClubTenantMiddleware", "club.tenancy.ClubTenantMiddleware",
# After tenancy: it decides club-vs-platform from request.club, which was just resolved.
"features.middleware.MaintenanceMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
# Reload the browser when templates, static files or Python change. Dev only: it # Reload the browser when templates, static files or Python change. Dev only: it injects a
# injects a script tag into every HTML response and serves an open event stream, # script tag into every HTML response and serves an open event stream, neither of which
# neither of which belongs in production. # belongs in production.
if DEBUG: #
# Guarded on the module being *importable*, not just on DEBUG: it is a dev dependency, and the
# production image installs with --no-dev. Without the guard, DEBUG=True in a deployed
# container does not merely turn on debugging — it stops the app from starting at all, with a
# ModuleNotFoundError that says nothing about the actual mistake.
BROWSER_RELOAD_AVAILABLE = find_spec("django_browser_reload") is not None
if DEBUG and BROWSER_RELOAD_AVAILABLE:
INSTALLED_APPS += ["django_browser_reload"] INSTALLED_APPS += ["django_browser_reload"]
MIDDLEWARE += ["django_browser_reload.middleware.BrowserReloadMiddleware"] MIDDLEWARE += ["django_browser_reload.middleware.BrowserReloadMiddleware"]
@@ -160,6 +182,7 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"club.context_processors.branding", "club.context_processors.branding",
"features.context_processors.maintenance",
], ],
}, },
}, },
@@ -207,7 +230,20 @@ USE_I18N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images) # Cache
#
# Redis in production, and not merely for speed: waffle caches each flag's targeting in the
# Django cache, and LocMemCache is private to one process. Under several gunicorn workers a
# toggle flipped 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, so Redis is required from the first multi-worker deploy, not from the second server.
REDIS_URL = config("DJANGO_REDIS_URL", default="")
CACHES = {"default": {"BACKEND": "django_redis.cache.RedisCache", "LOCATION": REDIS_URL, "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}} if REDIS_URL else {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
# Static files and uploads
# https://docs.djangoproject.com/en/6.0/howto/static-files/ # https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
@@ -217,6 +253,44 @@ STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "media/" MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = BASE_DIR / "media"
# Uploads (club logos) go to S3-compatible storage as soon as a bucket is configured. On one
# server the local disk works; on two, a logo uploaded to node A 404s on node B — so this is
# the switch that decides whether "add a server" is an afternoon or a migration.
AWS_STORAGE_BUCKET_NAME = config("AWS_STORAGE_BUCKET_NAME", default="")
AWS_S3_ENDPOINT_URL = config("AWS_S3_ENDPOINT_URL", default="") # set for Hetzner/Scaleway/Backblaze
AWS_S3_REGION_NAME = config("AWS_S3_REGION_NAME", default="")
AWS_ACCESS_KEY_ID = config("AWS_ACCESS_KEY_ID", default="")
AWS_SECRET_ACCESS_KEY = config("AWS_SECRET_ACCESS_KEY", default="")
AWS_S3_FILE_OVERWRITE = False
AWS_QUERYSTRING_AUTH = False # logos are public; signed URLs would break browser caching
STORAGES = {
"default": {"BACKEND": "storages.backends.s3.S3Storage"} if AWS_STORAGE_BUCKET_NAME else {"BACKEND": "django.core.files.storage.FileSystemStorage"},
# Manifest storage only in production: it demands a collectstatic manifest, and every
# {% static %} in a test would blow up without one.
"staticfiles": {"BACKEND": config("DJANGO_STATICFILES_BACKEND", default="django.contrib.staticfiles.storage.StaticFilesStorage")},
}
# HTTPS, behind a reverse proxy
#
# SECURE_PROXY_SSL_HEADER is not optional here: Caddy terminates TLS, so without it Django
# believes every request is plain HTTP. request.is_secure() goes false, allauth and WebAuthn
# disagree with the browser about the origin, and SECURE_SSL_REDIRECT becomes a loop.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Off by default and switched on by the production env, deliberately: defaulting these to
# `not DEBUG` would redirect every test request to https and break the suite anywhere DEBUG
# is unset. `manage.py check --deploy` is what catches a deploy that forgot them.
SECURE_SSL_REDIRECT = config("DJANGO_SECURE_SSL_REDIRECT", default=False, cast=bool)
SESSION_COOKIE_SECURE = config("DJANGO_SESSION_COOKIE_SECURE", default=False, cast=bool)
CSRF_COOKIE_SECURE = config("DJANGO_CSRF_COOKIE_SECURE", default=False, cast=bool)
SECURE_HSTS_SECONDS = config("DJANGO_SECURE_HSTS_SECONDS", default=0, cast=int)
# Every club is a subdomain, so HSTS must cover them all or it protects only the bare domain.
SECURE_HSTS_INCLUDE_SUBDOMAINS = config("DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True, cast=bool)
SECURE_HSTS_PRELOAD = config("DJANGO_SECURE_HSTS_PRELOAD", default=False, cast=bool)
# Phone numbers (django-phonenumber-field) # Phone numbers (django-phonenumber-field)

View File

@@ -1,7 +1,9 @@
import importlib import importlib
from unittest import mock
from django.db.utils import OperationalError
from django.test import SimpleTestCase, override_settings from django.test import SimpleTestCase, override_settings
from django.urls import Resolver404, clear_url_caches, resolve from django.urls import Resolver404, clear_url_caches, resolve, reverse
from . import urls from . import urls
@@ -28,3 +30,59 @@ class BrowserReloadUrlTests(SimpleTestCase):
with self.assertRaises(Resolver404): with self.assertRaises(Resolver404):
resolve("/__reload__/events/") resolve("/__reload__/events/")
class HealthCheckTests(SimpleTestCase):
databases = {"default"}
def test_it_reports_ok_when_the_database_and_cache_answer(self):
response = self.client.get(reverse("healthz"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok", "checks": {"database": True, "cache": True}})
def test_a_dead_database_is_a_503(self):
# 200 while the database is unreachable is worse than no health check at all: the load
# balancer would keep sending traffic to a node that cannot serve a single page.
with mock.patch("rosterchief.health.connection") as db:
db.cursor.side_effect = OperationalError("connection refused")
response = self.client.get(reverse("healthz"))
self.assertEqual(response.status_code, 503)
self.assertFalse(response.json()["checks"]["database"])
def test_a_cache_that_swallows_writes_is_unhealthy(self):
# Not a ping: a cache that accepts writes and returns nothing would have waffle read
# every feature flag as unset.
with mock.patch("rosterchief.health.cache") as broken:
broken.get.return_value = None
response = self.client.get(reverse("healthz"))
self.assertEqual(response.status_code, 503)
self.assertFalse(response.json()["checks"]["cache"])
def test_an_unreachable_cache_is_a_503(self):
# Distinct from the cache that answers wrongly above: here Redis is simply down, and
# the check must fail rather than raise its way to a 500.
with mock.patch("rosterchief.health.cache") as down:
down.set.side_effect = ConnectionError("redis is down")
response = self.client.get(reverse("healthz"))
self.assertEqual(response.status_code, 503)
self.assertFalse(response.json()["checks"]["cache"])
def test_it_is_never_cached(self):
response = self.client.get(reverse("healthz"))
self.assertIn("no-cache", response["Cache-Control"])
@override_settings(ALLOWED_HOSTS=["example.com", "localhost", "127.0.0.1"])
def test_it_answers_over_the_loopback(self):
# The container healthcheck and the deploy probe hit it as 127.0.0.1/localhost, before
# a proxy supplies a real Host. If ALLOWED_HOSTS rejects those, /healthz 400s and the
# container is unhealthy forever — which is exactly how the first deploy failed.
for host in ("127.0.0.1", "localhost"):
self.assertEqual(self.client.get(reverse("healthz"), HTTP_HOST=host).status_code, 200, host)

View File

@@ -15,7 +15,11 @@ from django.views.generic import RedirectView
from club.views import root from club.views import root
from .health import healthz
urlpatterns = [ urlpatterns = [
# No auth and no tenant: the proxy and the load balancer must reach it on any host.
path("healthz", healthz, name="healthz"),
path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"), path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
@@ -26,6 +30,11 @@ urlpatterns = [
] ]
if settings.DEBUG: if settings.DEBUG:
urlpatterns += [path("__reload__/", include("django_browser_reload.urls"))] # Only when the app is actually installed. It is a dev dependency, and the production
# image installs with --no-dev, so DEBUG=True in a container must not take the whole
# site down over a package that is only there to refresh a browser tab.
if settings.BROWSER_RELOAD_AVAILABLE:
urlpatterns += [path("__reload__/", include("django_browser_reload.urls"))]
# Club logos are uploads: runserver has to serve MEDIA_ROOT itself. # Club logos are uploads: runserver has to serve MEDIA_ROOT itself.
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -11,6 +11,14 @@ from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Position, Team from teams.models import Position, Team
class DiscountType(models.TextChoices):
"""Shared by ``Product`` (early-bird), ``Discount`` and ``AppliedDiscount`` (its
snapshot on an order) — one discount vocabulary, not three copies of it."""
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
def next_scoped_number(instance, code): def next_scoped_number(instance, code):
"""Next per-club sequential number for the current year: ``<code>-<year>-<seq>``.""" """Next per-club sequential number for the current year: ``<code>-<year>-<seq>``."""
prefix = f"{code}-{timezone.now().year}-" prefix = f"{code}-{timezone.now().year}-"
@@ -46,10 +54,6 @@ class Product(ClubScopedModel):
MERCHANDISE = "merchandise", _("Merchandise") MERCHANDISE = "merchandise", _("Merchandise")
DONATION = "donation", _("Donation") DONATION = "donation", _("Donation")
class DiscountType(models.TextChoices):
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
name = models.CharField(_("name"), max_length=255) name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, blank=True) slug = models.SlugField(_("slug"), max_length=255, blank=True)
@@ -191,10 +195,6 @@ class OrderLine(UUIDModel):
class Discount(ClubScopedModel): class Discount(ClubScopedModel):
class DiscountType(models.TextChoices):
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
name = models.CharField(_("name"), max_length=255) name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, blank=True) slug = models.SlugField(_("slug"), max_length=255, blank=True)
description = models.TextField(_("description"), blank=True) description = models.TextField(_("description"), blank=True)
@@ -219,10 +219,6 @@ class Discount(ClubScopedModel):
class AppliedDiscount(UUIDModel): class AppliedDiscount(UUIDModel):
class DiscountType(models.TextChoices):
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="applied_discounts", verbose_name=_("order")) order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="applied_discounts", verbose_name=_("order"))
discount = models.ForeignKey(Discount, on_delete=models.PROTECT, related_name="applied_discounts", verbose_name=_("discount")) discount = models.ForeignKey(Discount, on_delete=models.PROTECT, related_name="applied_discounts", verbose_name=_("discount"))
@@ -240,7 +236,7 @@ class AppliedDiscount(UUIDModel):
] ]
def __str__(self): def __str__(self):
suffix = "%" if self.discount_type == self.DiscountType.PERCENTAGE else "" suffix = "%" if self.discount_type == DiscountType.PERCENTAGE else ""
return f"{self.discount} - {self.discount_amount}{suffix}" return f"{self.discount} - {self.discount_amount}{suffix}"
def clean(self): def clean(self):

View File

@@ -21,6 +21,7 @@ from .models import (
Cart, Cart,
CartItem, CartItem,
Discount, Discount,
DiscountType,
Invoice, Invoice,
Order, Order,
OrderLine, OrderLine,
@@ -251,12 +252,12 @@ class AppliedDiscountTests(ShopEntitiesTestBase):
return AppliedDiscount.objects.create(**kwargs) return AppliedDiscount.objects.create(**kwargs)
def test_str_percentage_shows_percent(self): def test_str_percentage_shows_percent(self):
applied = self.apply(discount_type=AppliedDiscount.DiscountType.PERCENTAGE) applied = self.apply(discount_type=DiscountType.PERCENTAGE)
self.assertEqual(str(applied), "Sibling - 10.00%") self.assertEqual(str(applied), "Sibling - 10.00%")
def test_str_fixed_amount_has_no_percent(self): def test_str_fixed_amount_has_no_percent(self):
applied = self.apply(discount_type=AppliedDiscount.DiscountType.FIXED_AMOUNT) applied = self.apply(discount_type=DiscountType.FIXED_AMOUNT)
self.assertEqual(str(applied), "Sibling - 10.00") self.assertEqual(str(applied), "Sibling - 10.00")

View File

@@ -5,7 +5,9 @@
:root, :host { :root, :host {
--font-sans: "Ubuntu", ui-sans-serif, system-ui, sans-serif; --font-sans: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--color-red-500: oklch(63.7% 0.237 25.331);
--color-sky-500: oklch(68.5% 0.169 237.323); --color-sky-500: oklch(68.5% 0.169 237.323);
--color-blue-500: oklch(62.3% 0.214 259.815);
--color-black: #000; --color-black: #000;
--color-white: #fff; --color-white: #fff;
--spacing: 0.25rem; --spacing: 0.25rem;
@@ -30,7 +32,12 @@
--font-weight-medium: 500; --font-weight-medium: 500;
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-weight-bold: 700; --font-weight-bold: 700;
--font-weight-extrabold: 800;
--tracking-wide: 0.025em;
--tracking-wider: 0.05em; --tracking-wider: 0.05em;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--default-transition-duration: 150ms; --default-transition-duration: 150ms;
@@ -189,6 +196,69 @@
} }
} }
@layer utilities { @layer utilities {
.modal {
@layer daisyui.l1.l2.l3 {
pointer-events: none;
visibility: hidden;
position: fixed;
inset: 0;
margin: 0;
display: grid;
height: 100%;
max-height: none;
width: 100%;
max-width: none;
align-items: center;
justify-items: center;
background-color: transparent;
padding: 0;
color: inherit;
transition: overlay 0.3s allow-discrete, visibility 0.3s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out;
overflow: clip;
overscroll-behavior: contain;
z-index: 999;
scrollbar-gutter: auto;
&::backdrop {
display: none;
}
&[popover] {
inset: 0;
margin: 0;
border: 0;
padding: 0;
background: transparent;
color: inherit;
max-width: none;
max-height: none;
&::backdrop {
background-color: oklch(0% 0 0/ 0.4);
transition: background-color 0.3s ease-out;
}
}
}
@layer daisyui.l1.l2 {
&.modal-open, &[open], &:popover-open, &:target, .modal-toggle:checked + & {
pointer-events: auto;
visibility: visible;
opacity: 100%;
transition: visibility 0s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out;
background-color: oklch(0% 0 0/ 0.4);
.modal-box {
translate: 0 0;
scale: 1;
opacity: 1;
}
:root:has(&) {
--page-scroll-lock: ;
}
}
@starting-style {
&.modal-open, &[open], &:popover-open, &:target, .modal-toggle:checked + & {
opacity: 0%;
}
}
}
}
.tooltip { .tooltip {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
position: relative; position: relative;
@@ -258,92 +328,6 @@
} }
} }
} }
.tab {
@layer daisyui.l1.l2.l3 {
&:is(.tabs > .tab) {
position: relative;
display: inline-flex;
cursor: pointer;
appearance: none;
flex-wrap: wrap;
align-items: center;
justify-content: center;
text-align: center;
webkit-user-select: none;
user-select: none;
&:hover {
@media (hover: hover) {
color: var(--color-base-content);
}
}
--tab-p: 0.75rem;
--tab-bg: var(--color-base-100);
--tab-border-color: var(--color-base-300);
--tab-radius-ss: 0;
--tab-radius-se: 0;
--tab-radius-es: 0;
--tab-radius-ee: 0;
--tab-order: 0;
--tab-radius-min: calc(0.75rem - var(--border));
--tab-radius-limit: min(var(--radius-field), var(--tab-radius-min));
--tab-radius-grad: #0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + 0.25px),
var(--tab-border-color) 69%, var(--tab-bg) calc(69% + 0.25px);
border-color: #0000;
order: var(--tab-order);
height: var(--tab-height);
font-size: 0.875rem;
padding-inline: var(--tab-p);
&:is(input[type="radio"]) {
min-width: fit-content;
&:after {
--tw-content: attr(aria-label);
content: var(--tw-content);
}
}
&:is(label) {
position: relative;
input {
position: absolute;
inset: 0;
cursor: pointer;
appearance: none;
opacity: 0%;
}
}
&:checked, &:is(label:has(:checked)), &:is(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"]) {
& + .tab-content {
display: block;
}
}
&:not(:checked, label:has(:checked), :hover, .tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"] ) {
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
}
}
&:not(input):empty {
flex-grow: 1;
cursor: default;
}
&:focus {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
&:focus-visible, &:is(label:has(:checked:focus-visible)) {
outline: 2px solid currentColor;
outline-offset: -5px;
}
&[disabled] {
pointer-events: none;
opacity: 40%;
}
}
}
}
.menu { .menu {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: flex; display: flex;
@@ -1197,6 +1181,9 @@
.collapse { .collapse {
visibility: collapse; visibility: collapse;
} }
.invisible {
visibility: hidden;
}
.visible { .visible {
visibility: visible; visibility: visible;
} }
@@ -1997,6 +1984,49 @@
white-space: nowrap; white-space: nowrap;
border-width: 0; border-width: 0;
} }
.menu-horizontal {
@layer daisyui.l1.l2 {
display: inline-flex;
flex-direction: row;
align-items: center;
& > li:not(.menu-title) > details {
& > :is(ul, menu) {
position: absolute;
margin-inline-start: 0;
margin-top: calc(0.25rem * 4);
transform-origin: top;
border-radius: var(--radius-box);
background-color: var(--color-base-100);
padding-block: calc(0.25rem * 2);
padding-inline-end: calc(0.25rem * 2);
opacity: 0%;
scale: 95%;
box-shadow: 0 1px 3px 0 oklch(0% 0 0/0.1), 0 1px 2px -1px oklch(0% 0 0/0.1);
@media (prefers-reduced-motion: no-preference) {
@starting-style {
scale: 95%;
opacity: 0;
}
animation: menu 0.2s;
transition-property: opacity, scale, display;
transition-behavior: allow-discrete;
transition-duration: 0.2s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
}
&[open] > :is(ul, menu) {
opacity: 100%;
scale: 100%;
}
}
& > li > details > :is(ul, menu) {
&:before {
--tw-content: none;
content: var(--tw-content);
}
}
}
}
.avatar { .avatar {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
position: relative; position: relative;
@@ -2336,6 +2366,9 @@
.static { .static {
position: static; position: static;
} }
.sticky {
position: sticky;
}
.dropdown-right { .dropdown-right {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
--anchor-h: right; --anchor-h: right;
@@ -2741,29 +2774,23 @@
} }
} }
} }
.modal-backdrop {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
grid-row-start: 1;
display: grid;
align-self: stretch;
justify-self: stretch;
color: transparent;
z-index: -1;
button {
cursor: pointer;
}
}
}
.z-10 { .z-10 {
z-index: 10; z-index: 10;
} }
.tab-content {
@layer daisyui.l1.l2.l3 {
order: var(--tabcontent-order);
display: none;
border-color: transparent;
--tabcontent-radius-ss: var(--radius-box);
--tabcontent-radius-se: var(--radius-box);
--tabcontent-radius-es: var(--radius-box);
--tabcontent-radius-ee: var(--radius-box);
--tabcontent-order: 1;
width: 100%;
height: calc(100% - var(--tab-height) + var(--border));
margin: var(--tabcontent-margin);
border-width: var(--border);
border-start-start-radius: var(--tabcontent-radius-ss);
border-start-end-radius: var(--tabcontent-radius-se);
border-end-start-radius: var(--tabcontent-radius-es);
border-end-end-radius: var(--tabcontent-radius-ee);
}
}
.hero { .hero {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: grid; display: grid;
@@ -2777,34 +2804,25 @@
} }
} }
} }
.stat-value { .modal-box {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
grid-column-start: 1; grid-column-start: 1;
white-space: nowrap; grid-row-start: 1;
font-size: 2rem; max-height: 100vh;
font-weight: 800; width: calc(11 / 12 * 100%);
} max-width: 32rem;
} background-color: var(--color-base-100);
.stat-desc { padding: calc(0.25rem * 6);
@layer daisyui.l1.l2.l3 { transition: translate 0.3s ease-out, scale 0.3s ease-out, opacity 0.2s ease-out 0.05s, box-shadow 0.3s ease-out;
grid-column-start: 1; border-top-left-radius: var(--modal-tl, var(--radius-box));
white-space: nowrap; border-top-right-radius: var(--modal-tr, var(--radius-box));
color: var(--color-base-content); border-bottom-left-radius: var(--modal-bl, var(--radius-box));
@supports (color: color-mix(in lab, red, red)) { border-bottom-right-radius: var(--modal-br, var(--radius-box));
color: color-mix(in oklab, var(--color-base-content) 60%, transparent); scale: 95%;
} opacity: 0;
font-size: 0.75rem; box-shadow: oklch(0% 0 0/ 0.25) 0px 25px 50px -12px;
} overflow-y: auto;
} overscroll-behavior: contain;
.stat-title {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
white-space: nowrap;
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
}
font-size: 0.75rem;
} }
} }
.container { .container {
@@ -2909,6 +2927,14 @@
} }
} }
} }
.modal-action {
@layer daisyui.l1.l2.l3 {
margin-top: calc(0.25rem * 6);
display: flex;
justify-content: flex-end;
gap: calc(0.25rem * 2);
}
}
.mt-1 { .mt-1 {
margin-top: var(--spacing); margin-top: var(--spacing);
} }
@@ -2936,6 +2962,9 @@
.mb-2 { .mb-2 {
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
} }
.mb-3 {
margin-bottom: calc(var(--spacing) * 3);
}
.mb-4 { .mb-4 {
margin-bottom: calc(var(--spacing) * 4); margin-bottom: calc(var(--spacing) * 4);
} }
@@ -2945,9 +2974,6 @@
.mb-10 { .mb-10 {
margin-bottom: calc(var(--spacing) * 10); margin-bottom: calc(var(--spacing) * 10);
} }
.ml-1 {
margin-left: var(--spacing);
}
.ml-2 { .ml-2 {
margin-left: calc(var(--spacing) * 2); margin-left: calc(var(--spacing) * 2);
} }
@@ -2998,17 +3024,6 @@
padding-inline: calc(var(--size) / 2 - var(--border)); padding-inline: calc(var(--size) / 2 - var(--border));
} }
} }
.tabs {
@layer daisyui.l1.l2.l3 {
display: flex;
flex-wrap: wrap;
--tabs-height: auto;
--tabs-direction: row;
--tab-height: calc(var(--size-field, 0.25rem) * 10);
height: var(--tabs-height);
flex-direction: var(--tabs-direction);
}
}
.footer { .footer {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: grid; display: grid;
@@ -3192,6 +3207,9 @@
.hidden { .hidden {
display: none; display: none;
} }
.inline {
display: inline;
}
.inline-block { .inline-block {
display: inline-block; display: inline-block;
} }
@@ -3204,14 +3222,20 @@
.table { .table {
display: table; display: table;
} }
.h-12 {
height: calc(var(--spacing) * 12);
}
.h-16 { .h-16 {
height: calc(var(--spacing) * 16); height: calc(var(--spacing) * 16);
} }
.h-56 { .h-56 {
height: calc(var(--spacing) * 56); height: calc(var(--spacing) * 56);
} }
.min-h-screen { .h-screen {
min-height: 100vh; height: 100vh;
}
.w-12 {
width: calc(var(--spacing) * 12);
} }
.w-16 { .w-16 {
width: calc(var(--spacing) * 16); width: calc(var(--spacing) * 16);
@@ -3222,8 +3246,8 @@
.w-60 { .w-60 {
width: calc(var(--spacing) * 60); width: calc(var(--spacing) * 60);
} }
.w-fit { .w-64 {
width: fit-content; width: calc(var(--spacing) * 64);
} }
.w-full { .w-full {
width: 100%; width: 100%;
@@ -3243,6 +3267,9 @@
.flex-shrink { .flex-shrink {
flex-shrink: 1; flex-shrink: 1;
} }
.shrink-0 {
flex-shrink: 0;
}
.flex-grow { .flex-grow {
flex-grow: 1; flex-grow: 1;
} }
@@ -3307,6 +3334,9 @@
.resize { .resize {
resize: both; resize: both;
} }
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@@ -3319,6 +3349,12 @@
.items-center { .items-center {
align-items: center; align-items: center;
} }
.items-end {
align-items: flex-end;
}
.items-start {
align-items: flex-start;
}
.justify-between { .justify-between {
justify-content: space-between; justify-content: space-between;
} }
@@ -3376,9 +3412,15 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.overflow-hidden {
overflow: hidden;
}
.overflow-x-auto { .overflow-x-auto {
overflow-x: auto; overflow-x: auto;
} }
.overflow-y-auto {
overflow-y: auto;
}
.rounded { .rounded {
border-radius: 0.25rem; border-radius: 0.25rem;
} }
@@ -3399,6 +3441,10 @@
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 2px; border-width: 2px;
} }
.border-r {
border-right-style: var(--tw-border-style);
border-right-width: 1px;
}
.border-b { .border-b {
border-bottom-style: var(--tw-border-style); border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px; border-bottom-width: 1px;
@@ -3415,6 +3461,14 @@
background-image: none; background-image: none;
} }
} }
.badge-outline {
@layer daisyui.l1.l2 {
color: var(--badge-color);
--badge-bg: #0000;
background-image: none;
border-color: currentColor;
}
}
.border-base-300 { .border-base-300 {
border-color: var(--color-base-300); border-color: var(--color-base-300);
} }
@@ -3427,20 +3481,33 @@
border-color: color-mix(in oklab, var(--color-base-content) 20%, transparent); border-color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
} }
} }
.border-base-content\/70 {
border-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
}
}
.border-error { .border-error {
border-color: var(--color-error); border-color: var(--color-error);
} }
.border-info {
border-color: var(--color-info);
}
.border-success {
border-color: var(--color-success);
}
.border-warning { .border-warning {
border-color: var(--color-warning); border-color: var(--color-warning);
} }
.border-white { .menu-active {
border-color: var(--color-white); :where(:not(ul, menu, details, .menu-title, .btn))& {
@layer daisyui.l1.l2 {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
color: var(--menu-active-fg);
background-color: var(--menu-active-bg);
background-size: auto, calc(var(--noise) * 100%);
background-image: none, var(--fx-noise);
}
}
} }
.bg-base-100 { .bg-base-100 {
background-color: var(--color-base-100); background-color: var(--color-base-100);
@@ -3448,6 +3515,12 @@
.bg-base-200 { .bg-base-200 {
background-color: var(--color-base-200); background-color: var(--color-base-200);
} }
.bg-base-300 {
background-color: var(--color-base-300);
}
.bg-neutral {
background-color: var(--color-neutral);
}
.bg-primary { .bg-primary {
background-color: var(--color-primary); background-color: var(--color-primary);
} }
@@ -3495,6 +3568,31 @@
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0); --btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
} }
} }
.btn-soft {
@layer daisyui.l1.l2.l3 {
--btn-bg: var(--btn-color, var(--color-base-content));
@supports (color: color-mix(in lab, red, red)) {
--btn-bg: color-mix(
in oklab,
var(--btn-color, var(--color-base-content)) 8%,
var(--btn-soft-bg, var(--color-base-100))
);
}
color: var(--btn-rest-fg, var(--btn-color, var(--color-base-content)));
--btn-border: var(--btn-color, var(--color-base-content));
@supports (color: color-mix(in lab, red, red)) {
--btn-border: color-mix(
in oklab,
var(--btn-color, var(--color-base-content)) 10%,
var(--btn-soft-bg, var(--color-base-100))
);
}
--btn-border-style: solid;
background-image: none;
--btn-inset: 0 0 0 0 oklch(0% 0 0/0);
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
}
}
.btn-ghost { .btn-ghost {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
--btn-bg: #0000; --btn-bg: #0000;
@@ -3520,17 +3618,6 @@
.p-4 { .p-4 {
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
} }
.table-sm {
@layer daisyui.l1.l2 {
:not(thead, tfoot) tr {
font-size: 0.75rem;
}
:where(th, td) {
padding-inline: calc(0.25rem * 3);
padding-block: calc(0.25rem * 2);
}
}
}
.px-4 { .px-4 {
padding-inline: calc(var(--spacing) * 4); padding-inline: calc(var(--spacing) * 4);
} }
@@ -3543,8 +3630,8 @@
.py-2 { .py-2 {
padding-block: calc(var(--spacing) * 2); padding-block: calc(var(--spacing) * 2);
} }
.py-4 { .py-6 {
padding-block: calc(var(--spacing) * 4); padding-block: calc(var(--spacing) * 6);
} }
.pt-2 { .pt-2 {
padding-top: calc(var(--spacing) * 2); padding-top: calc(var(--spacing) * 2);
@@ -3555,6 +3642,9 @@
.pb-2 { .pb-2 {
padding-bottom: calc(var(--spacing) * 2); padding-bottom: calc(var(--spacing) * 2);
} }
.pl-8 {
padding-left: calc(var(--spacing) * 8);
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
@@ -3629,10 +3719,17 @@
--tw-font-weight: var(--font-weight-semibold); --tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
} }
.tracking-wide {
--tw-tracking: var(--tracking-wide);
letter-spacing: var(--tracking-wide);
}
.tracking-wider { .tracking-wider {
--tw-tracking: var(--tracking-wider); --tw-tracking: var(--tracking-wider);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
} }
.whitespace-nowrap {
white-space: nowrap;
}
.alert-error { .alert-error {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
color: var(--color-error-content); color: var(--color-error-content);
@@ -3661,9 +3758,19 @@
--alert-color: var(--color-warning); --alert-color: var(--color-warning);
} }
} }
.progress-primary { .progress-error {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
color: var(--color-primary); color: var(--color-error);
}
}
.progress-success {
@layer daisyui.l1.l2 {
color: var(--color-success);
}
}
.progress-warning {
@layer daisyui.l1.l2 {
color: var(--color-warning);
} }
} }
.text-base-content { .text-base-content {
@@ -3690,6 +3797,9 @@
.text-error { .text-error {
color: var(--color-error); color: var(--color-error);
} }
.text-neutral-content {
color: var(--color-neutral-content);
}
.text-primary-content { .text-primary-content {
color: var(--color-primary-content); color: var(--color-primary-content);
} }
@@ -3730,6 +3840,9 @@
.opacity-70 { .opacity-70 {
opacity: 70%; opacity: 70%;
} }
.opacity-80 {
opacity: 80%;
}
.shadow { .shadow {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -3830,12 +3943,24 @@
--size: calc(var(--size-field, 0.25rem) * 6); --size: calc(var(--size-field, 0.25rem) * 6);
} }
} }
.badge-accent {
@layer daisyui.l1.l2 {
--badge-color: var(--color-accent);
--badge-fg: var(--color-accent-content);
}
}
.badge-error { .badge-error {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
--badge-color: var(--color-error); --badge-color: var(--color-error);
--badge-fg: var(--color-error-content); --badge-fg: var(--color-error-content);
} }
} }
.badge-info {
@layer daisyui.l1.l2 {
--badge-color: var(--color-info);
--badge-fg: var(--color-info-content);
}
}
.badge-neutral { .badge-neutral {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
--badge-color: var(--color-neutral); --badge-color: var(--color-neutral);
@@ -3871,19 +3996,29 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
.md\:grid-cols-4 {
@media (width >= 48rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.lg\:block {
@media (width >= 64rem) {
display: block;
}
}
.lg\:hidden {
@media (width >= 64rem) {
display: none;
}
}
.lg\:grid-cols-2 { .lg\:grid-cols-2 {
@media (width >= 64rem) { @media (width >= 64rem) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
.lg\:grid-cols-3 { .lg\:grid-cols-6 {
@media (width >= 64rem) { @media (width >= 64rem) {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
} }
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -33,8 +33,14 @@
{% block extra %}{% endblock extra %} {% block extra %}{% endblock extra %}
</head> </head>
<body class="min-h-screen bg-base-200"> {% comment %}
<div class="navbar mb-4 border-b border-base-300 bg-base-100 px-6 shadow-sm"> An app shell: the viewport is the frame, and exactly one region scrolls. The body is a
flex column pinned to the screen height with overflow hidden, so the navbar and the
sidebar cannot be scrolled away — only <main> moves. Leave the body scrollable instead
and a "sticky" sidebar still drifts on a long page, which is the thing this is for.
{% endcomment %}
<body class="flex h-screen flex-col overflow-hidden bg-base-200">
<div class="navbar shrink-0 border-b border-base-300 bg-base-100 px-6 shadow-sm">
<div class="my-4 flex-1"> <div class="my-4 flex-1">
{% block brand %}{% endblock brand %} {% block brand %}{% endblock brand %}
</div> </div>
@@ -65,25 +71,36 @@
{% endif %} {% endif %}
</div> </div>
{% if messages %} <div class="flex flex-1 overflow-hidden">
<div class="mx-auto mt-4 w-full space-y-2 px-4"> {% comment %}
{% for message in messages %} The sidebar is a sibling of <main>, not inside it, so it sits outside the one
{% with alert=message|as_alert %} scrolling region and stays put by construction. Pages with no menu (the auth
<div class="alert alert-soft {{ alert.css }}" role="alert"> screens) leave the block empty and <main> simply takes the full width.
{% lucide alert.icon size=20 %} {% endcomment %}
<div> {% block menu %}{% endblock menu %}
<div class="font-bold">{{ alert.title }}</div>
<div class="text-sm">{{ alert.body }}</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endif %}
<main class="mx-auto w-full py-4 px-8"> <main class="flex-1 overflow-y-auto">
{% block main %}{% endblock main %} <div class="mx-auto w-full px-8 py-6">
</main> {% if messages %}
<div class="mb-6 w-full space-y-2">
{% for message in messages %}
{% with alert=message|as_alert %}
<div class="alert alert-soft border-2 {{ alert.css }}" role="alert">
{% lucide alert.icon size=20 %}
<div>
<div class="font-bold">{{ alert.title }}</div>
<div class="text-sm">{{ alert.body }}</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endif %}
{% block main %}{% endblock main %}
</div>
</main>
</div>
<script> <script>
// The button cycles light -> dark -> auto. "auto" removes the attribute and the // The button cycles light -> dark -> auto. "auto" removes the attribute and the

View File

@@ -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 %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body items-center text-center">
<div class="text-warning">{% lucide "wrench" size=48 %}</div>
<h1 class="card-title mt-2">Down for maintenance</h1>
<p class="opacity-80">{{ message }}</p>
{% if maintenance.started_at %}
<p class="text-xs opacity-60">Since {{ maintenance.started_at|date:"j M Y, H:i" }}</p>
{% endif %}
</div>
</div>
</div>
{% endblock main %}

344
uv.lock generated
View File

@@ -11,6 +11,73 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
] ]
[[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]] [[package]]
name = "cffi" name = "cffi"
version = "2.1.0" version = "2.1.0"
@@ -168,6 +235,19 @@ wheels = [
{ 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" }, { 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]] [[package]]
name = "dj-database-url" name = "dj-database-url"
version = "3.1.2" version = "3.1.2"
@@ -248,6 +328,36 @@ phonenumbers = [
{ name = "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]] [[package]]
name = "django-waffle" name = "django-waffle"
version = "5.0.0" version = "5.0.0"
@@ -272,6 +382,68 @@ 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" }, { 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 = "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 = "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]] [[package]]
name = "phonenumbers" name = "phonenumbers"
version = "9.0.33" version = "9.0.33"
@@ -331,6 +503,41 @@ wheels = [
{ 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" }, { 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]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" version = "3.0"
@@ -340,6 +547,24 @@ 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" }, { 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 = "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]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@@ -373,6 +598,15 @@ 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" }, { 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]] [[package]]
name = "rosterchief" name = "rosterchief"
version = "0.1.0" version = "0.1.0"
@@ -383,10 +617,16 @@ dependencies = [
{ name = "django-allauth", extra = ["mfa"] }, { name = "django-allauth", extra = ["mfa"] },
{ name = "django-lucide" }, { name = "django-lucide" },
{ name = "django-phonenumber-field", extra = ["phonenumbers"] }, { name = "django-phonenumber-field", extra = ["phonenumbers"] },
{ name = "django-redis" },
{ name = "django-storages", extra = ["s3"] },
{ name = "django-waffle" }, { name = "django-waffle" },
{ name = "gunicorn" },
{ name = "pillow" }, { name = "pillow" },
{ name = "psycopg", extra = ["binary"] },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "python-decouple" }, { name = "python-decouple" },
{ name = "weasyprint" },
{ name = "whitenoise" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -403,10 +643,16 @@ requires-dist = [
{ name = "django-allauth", extras = ["mfa"], specifier = ">=65.18.0" }, { name = "django-allauth", extras = ["mfa"], specifier = ">=65.18.0" },
{ name = "django-lucide", git = "https://github.com/bsiebens/lucide" }, { name = "django-lucide", git = "https://github.com/bsiebens/lucide" },
{ name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" }, { 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 = "django-waffle", specifier = ">=5.0.0" },
{ name = "gunicorn", specifier = ">=26.0.0" },
{ name = "pillow", specifier = ">=12.3.0" }, { name = "pillow", specifier = ">=12.3.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" },
{ name = "python-decouple", specifier = ">=3.8" }, { name = "python-decouple", specifier = ">=3.8" },
{ name = "weasyprint", specifier = ">=69.0" },
{ name = "whitenoise", specifier = ">=6.12.0" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -441,6 +687,18 @@ 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" }, { 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]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
@@ -459,6 +717,30 @@ 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" }, { 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]] [[package]]
name = "tzdata" name = "tzdata"
version = "2026.2" version = "2026.2"
@@ -467,3 +749,65 @@ sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35c
wheels = [ 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" }, { 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" },
]