Compare commits
31 Commits
192fe5ad0e
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 40255805c3 | |||
| 127d0e338e | |||
| 83caa233d7 | |||
| fc7a349f8f | |||
| f403128f57 | |||
| 91270b0cf8 | |||
| 19108407c6 | |||
| 10b113f244 | |||
| 5b51f2c945 | |||
| 4d74f3fbde | |||
| a1266378fc | |||
| a2bcb2f0c8 | |||
| 9bc5377cc5 | |||
| 7c874aa87b | |||
| 5283262e6b | |||
| 65a2f741f6 | |||
| 975426a17f | |||
| 9a616c20e4 | |||
| 2d43b0b903 | |||
| c42963c447 | |||
| 5d42691a10 | |||
| 534d01d6fe | |||
| 98e5f22873 | |||
| d30b163122 | |||
| c0a44093d9 | |||
| 35d1ec45a7 | |||
| e5a93194bf | |||
| 29d61eeea8 | |||
| 60bfac9881 | |||
| 6899e203f6 | |||
| 3a6dc00e05 |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.venv
|
||||
node_modules
|
||||
staticfiles
|
||||
media
|
||||
db.sqlite3
|
||||
.env
|
||||
*.pyc
|
||||
__pycache__
|
||||
.coverage
|
||||
.idea
|
||||
.ruff_cache
|
||||
ARCHITECTURE.pdf
|
||||
10
.env.compose.example
Normal file
10
.env.compose.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Copy to .env — read by docker compose itself (not by Django).
|
||||
ROSTERCHIEF_BASE_DOMAIN=rosterchief.app
|
||||
ACME_EMAIL=you@example.com
|
||||
|
||||
# DNS-01 is the only way to get the *.rosterchief.app wildcard. Token needs DNS:Edit on the zone.
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
|
||||
POSTGRES_DB=rosterchief
|
||||
POSTGRES_USER=rosterchief
|
||||
POSTGRES_PASSWORD=
|
||||
38
.env.production.example
Normal file
38
.env.production.example
Normal file
@@ -0,0 +1,38 @@
|
||||
# Copy to .env.production and fill in. Everything here is read by python-decouple.
|
||||
|
||||
# --- Django ---
|
||||
DJANGO_SECRET_KEY= # python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
DJANGO_DEBUG=False
|
||||
# The leading dot matches every club subdomain.
|
||||
DJANGO_ALLOWED_HOSTS=.rosterchief.app
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://rosterchief.app,https://*.rosterchief.app
|
||||
DJANGO_TIME_ZONE=Europe/Brussels
|
||||
|
||||
# --- Tenancy ---
|
||||
# Drives subdomain resolution, the shared session cookie, and the WebAuthn RP ID (one passkey
|
||||
# across every club).
|
||||
ROSTERCHIEF_BASE_DOMAIN=rosterchief.app
|
||||
ROSTERCHIEF_RP_NAME=RosterChief
|
||||
|
||||
# --- Services ---
|
||||
DJANGO_DATABASE_URL=postgres://rosterchief:CHANGEME@db:5432/rosterchief
|
||||
DJANGO_REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# --- HTTPS (off by default in code; the deploy is what turns them on) ---
|
||||
DJANGO_SECURE_SSL_REDIRECT=True
|
||||
DJANGO_SESSION_COOKIE_SECURE=True
|
||||
DJANGO_CSRF_COOKIE_SECURE=True
|
||||
DJANGO_SECURE_HSTS_SECONDS=31536000
|
||||
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=True
|
||||
# Preload is a one-way door — turn it on only once the wildcard cert has proven itself.
|
||||
DJANGO_SECURE_HSTS_PRELOAD=False
|
||||
|
||||
# --- Static ---
|
||||
DJANGO_STATICFILES_BACKEND=whitenoise.storage.CompressedManifestStaticFilesStorage
|
||||
|
||||
# --- Uploads: set these and club logos move off local disk (required for >1 app server) ---
|
||||
# AWS_STORAGE_BUCKET_NAME=rosterchief-media
|
||||
# AWS_S3_ENDPOINT_URL=https://fsn1.your-objectstorage.com
|
||||
# AWS_S3_REGION_NAME=fsn1
|
||||
# AWS_ACCESS_KEY_ID=
|
||||
# AWS_SECRET_ACCESS_KEY=
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -379,3 +379,4 @@ pyrightconfig.json
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,django%
|
||||
# Node
|
||||
node_modules/
|
||||
staticfiles/
|
||||
|
||||
10
.idea/pySourceRootDetection.xml
generated
Normal file
10
.idea/pySourceRootDetection.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PySourceRootDetectionService">
|
||||
<option name="sourcePathsSet">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
594
DEPLOYMENT.md
Normal file
594
DEPLOYMENT.md
Normal file
@@ -0,0 +1,594 @@
|
||||
# Deploying RosterChief
|
||||
|
||||
One server today, several later, with no code changes in between — only environment
|
||||
variables. This document is the runbook and, more usefully, the list of things that are
|
||||
specific to *this* app and will bite you if you treat it as a generic Django deploy.
|
||||
|
||||
## The five things that make this deployment unusual
|
||||
|
||||
**1. You need a wildcard TLS certificate, and that forces DNS-01.**
|
||||
Tenancy is subdomain-based (`ajax.rosterchief.app`), so the certificate must cover
|
||||
`*.rosterchief.app`. Let's Encrypt **will not issue a wildcard over HTTP-01** — only over
|
||||
DNS-01, which means the TLS terminator needs API access to your DNS zone. That is why
|
||||
`deploy/caddy/Dockerfile` builds Caddy *with* a DNS provider plugin, and why
|
||||
`CLOUDFLARE_API_TOKEN` is a required variable rather than a nicety. Swap the plugin
|
||||
(`caddy-dns/route53`, `caddy-dns/digitalocean`, …) if your DNS lives elsewhere.
|
||||
|
||||
DNS needs two records, both pointing at the server:
|
||||
|
||||
```
|
||||
A rosterchief.app -> <server ip>
|
||||
A *.rosterchief.app -> <server ip>
|
||||
```
|
||||
|
||||
**2. Redis is not optional, even on one server.**
|
||||
`waffle` caches each feature flag's targeting in the Django cache, and `LocMemCache` is
|
||||
private to a single process. Under several gunicorn workers, toggling a feature in the
|
||||
control panel flushes **one** worker's cache while the others keep serving the stale flag —
|
||||
a feature that "sometimes doesn't turn on". A shared cache is the fix.
|
||||
|
||||
**3. `SECURE_PROXY_SSL_HEADER` must be set, and Caddy must send the header.**
|
||||
Caddy terminates TLS, so without it Django believes every request is plain HTTP:
|
||||
`request.is_secure()` goes false, WebAuthn disagrees with the browser about the origin, and
|
||||
`SECURE_SSL_REDIRECT` becomes a redirect loop. Both halves are already wired (settings +
|
||||
`header_up X-Forwarded-Proto`); don't remove either.
|
||||
|
||||
**4. Uploads must move to object storage before the second app server.**
|
||||
Club logos go to `MEDIA_ROOT` on local disk. On one box that is fine. On two, a logo
|
||||
uploaded to node A is a 404 on node B. Setting `AWS_STORAGE_BUCKET_NAME` switches the
|
||||
default storage to S3 — do it *before* you scale, not during.
|
||||
|
||||
**5. PDF invoices need native libraries.**
|
||||
WeasyPrint binds to pango/cairo. The image installs them; a bare-metal deploy would need
|
||||
them too, and a Mac needs Homebrew. This is the main reason to run the container even in
|
||||
development if you touch invoicing.
|
||||
|
||||
## First deploy
|
||||
|
||||
```bash
|
||||
# 1. Configure
|
||||
cp .env.compose.example .env # read by docker compose
|
||||
cp .env.production.example .env.production # read by Django
|
||||
python -c "import secrets; print(secrets.token_urlsafe(64))" # -> DJANGO_SECRET_KEY
|
||||
|
||||
# 2. Build and start
|
||||
docker compose build
|
||||
docker compose up -d db redis
|
||||
docker compose run --rm web python manage.py migrate
|
||||
docker compose run --rm web python manage.py createsuperuser
|
||||
docker compose up -d
|
||||
|
||||
# 3. Verify
|
||||
curl -fsS https://rosterchief.app/healthz # {"status": "ok", ...}
|
||||
docker compose run --rm web python manage.py check --deploy
|
||||
```
|
||||
|
||||
`check --deploy` is what catches an env file that forgot the HTTPS flags: they default to
|
||||
**off** in code, because defaulting them to `not DEBUG` would redirect every test request to
|
||||
https and break the suite anywhere `DEBUG` is unset.
|
||||
|
||||
### Keep DJANGO_DEBUG=False, even on the test server
|
||||
|
||||
A test box is still a deployment: it is behind TLS, on a real domain, with real passkeys.
|
||||
`DEBUG=True` there leaks tracebacks and settings to anyone who can reach a 500, and turns off
|
||||
several of the protections in this document. Use it locally, not on a server.
|
||||
|
||||
The app no longer *crashes* if you set it — `django_browser_reload` is a dev dependency that
|
||||
the image installs with `--no-dev`, so settings guard on the module being importable rather
|
||||
than assuming DEBUG implies it is there — but the reason to keep it off is not the crash.
|
||||
|
||||
### One dependency comes from git
|
||||
|
||||
`django-lucide` is our fork (`[tool.uv.sources]` in `pyproject.toml`, pinned by `uv.lock` to a
|
||||
commit), so **uv shells out to `git`** to fetch it. `python:*-slim` has no git, which is why
|
||||
the image builds the virtualenv in a **separate stage** that installs git, and copies the
|
||||
finished `.venv` into a runtime stage that does not have it — a build tool has no business in
|
||||
a production image.
|
||||
|
||||
Two consequences worth knowing:
|
||||
|
||||
- The build needs **network access to GitHub**, and the fork must stay reachable. If that ever
|
||||
becomes awkward (a private runner, an air-gapped build), publish the fork to a private index
|
||||
or vendor the wheel, and the git stage disappears.
|
||||
- `uv.lock` pins the exact commit, so the build is reproducible even though the source is a
|
||||
branch. Don't build with `--no-frozen`.
|
||||
|
||||
The first `docker compose up` will take a minute or two: Caddy is provisioning the wildcard
|
||||
certificate over DNS-01, and DNS propagation is not instant. Watch it with
|
||||
`docker compose logs -f caddy`.
|
||||
|
||||
## Migrations
|
||||
|
||||
Deliberately **not** run by the container's entrypoint. With more than one web container they
|
||||
would race, and a starting gunicorn worker is a bad place to discover a failed migration.
|
||||
Run them once, explicitly, as part of the deploy:
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose run --rm web python manage.py migrate
|
||||
docker compose up -d --no-deps web
|
||||
```
|
||||
|
||||
## Scheduled jobs
|
||||
|
||||
Two commands need to run on a schedule. Put them on the **host**, not in a container, and on
|
||||
**exactly one node** when you have several — three nodes archiving the same club is three
|
||||
emails to the same club.
|
||||
|
||||
```cron
|
||||
# Bill: archive clubs unpaid past their grace period.
|
||||
# Run it WITHOUT --commit for the first week and read the output. The flag exists because
|
||||
# this switches off paying customers: a bad clock or a bad cron should cost you an email,
|
||||
# not a morning of angry clubs.
|
||||
0 6 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py archive_overdue_clubs --commit
|
||||
|
||||
# Events: extend recurring series so the calendar never runs dry.
|
||||
0 3 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py extend_event_series
|
||||
```
|
||||
|
||||
## Maintenance mode
|
||||
|
||||
Control panel → **Features → Maintenance mode**. While it is on:
|
||||
|
||||
- every **club subdomain** serves a 503 maintenance page, in that club's own colours;
|
||||
- the **control panel and the sign-in screens stay open**, because closing them would leave
|
||||
you with no way to turn it back off;
|
||||
- `/healthz` keeps answering on every host, or the load balancer would take the node out of
|
||||
rotation and the control panel with it;
|
||||
- the **scheduled jobs stand down** — `archive_overdue_clubs`, `extend_event_series` and
|
||||
`import_members_csv` refuse to run.
|
||||
|
||||
`migrate` and `collectstatic` are deliberately **not** blocked. Maintenance is usually
|
||||
declared *in order* to run them, and a guard that stopped them would mean turning the mode
|
||||
off to do the work you turned it on for.
|
||||
|
||||
The scheduled jobs exit **non-zero** while the platform is closed, so cron will mail you.
|
||||
That is intended: a job that silently skips itself is how a month of billing goes missing. If
|
||||
you genuinely mean to run one during a window, pass `--ignore-maintenance`.
|
||||
|
||||
So a migration-heavy deploy looks like:
|
||||
|
||||
```bash
|
||||
# 1. Close the platform in the control panel (or from a shell):
|
||||
docker compose run --rm web python manage.py shell -c \
|
||||
"from features.models import Maintenance; Maintenance.start(message='Upgrading. Back by 21:00.')"
|
||||
|
||||
# 2. Do the work — migrate is not blocked.
|
||||
docker compose build
|
||||
docker compose run --rm web python manage.py migrate
|
||||
docker compose up -d --no-deps web
|
||||
|
||||
# 3. Reopen from the control panel.
|
||||
```
|
||||
|
||||
The state lives in Redis as well as the database, so it takes effect on **every worker and
|
||||
every server at once** — a per-process cache would leave some workers still serving clubs.
|
||||
|
||||
## Behind an existing Caddy (dev / test server)
|
||||
|
||||
If the box already runs Caddy on :80 and :443 — a test server sharing a host with other
|
||||
sites — do **not** run ours: two Caddies cannot both hold port 80. Run the app only, publish
|
||||
it on the loopback, and add a site block to the Caddy that is already there.
|
||||
|
||||
```bash
|
||||
docker compose -f compose.behind-proxy.yaml up -d # web + db + redis, no caddy
|
||||
```
|
||||
|
||||
`web` publishes on `127.0.0.1:8001` (override with `WEB_PORT`). **Loopback, not 0.0.0.0** —
|
||||
bound to all interfaces, a test instance is reachable at `http://<server-ip>:8001` with no
|
||||
TLS, bypassing the proxy and every security header with it.
|
||||
|
||||
Then add a site block to the host's Caddyfile. Caddy serves any number of domains on the same
|
||||
ports — TLS is chosen per connection by SNI — so a second (or tenth) site is just another
|
||||
block.
|
||||
|
||||
### If that Caddy already does Cloudflare DNS-01
|
||||
|
||||
Which is the usual case: the box has a domain on Cloudflare and Caddy already has the DNS
|
||||
plugin. Then set the challenge **once, globally**, and every site inherits it — no `tls`
|
||||
block per site, and wildcards simply work:
|
||||
|
||||
```caddy
|
||||
{
|
||||
email you@example.com
|
||||
|
||||
# Applies DNS-01 to every site below.
|
||||
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
}
|
||||
|
||||
# --- whatever the box already serves --------------------------------------
|
||||
existing-thing.example.com {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
# --- RosterChief test instance --------------------------------------------
|
||||
# The bare host AND the wildcard, on one certificate.
|
||||
test.rosterchief.app, *.test.rosterchief.app {
|
||||
encode zstd gzip
|
||||
|
||||
reverse_proxy 127.0.0.1:8001 {
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### If the two domains need different tokens
|
||||
|
||||
Different Cloudflare accounts, or tokens scoped per zone. Drop `acme_dns` and give each site
|
||||
its own `tls`; a snippet keeps it short:
|
||||
|
||||
```caddy
|
||||
{
|
||||
email you@example.com
|
||||
}
|
||||
|
||||
(cf) {
|
||||
tls {
|
||||
dns cloudflare {args[0]}
|
||||
}
|
||||
}
|
||||
|
||||
existing-thing.example.com {
|
||||
import cf {env.CF_TOKEN_EXAMPLE}
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
test.rosterchief.app, *.test.rosterchief.app {
|
||||
import cf {env.CF_TOKEN_ROSTERCHIEF}
|
||||
reverse_proxy 127.0.0.1:8001 {
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### What actually goes wrong
|
||||
|
||||
1. **The token must cover the *new* zone.** A Cloudflare token is scoped to named zones, and
|
||||
an existing one almost certainly grants `Zone:DNS:Edit` on the domain it was made for and
|
||||
nothing else. The new site then fails its DNS-01 challenge on a permissions error whose
|
||||
text does not say so. Widen the token, or mint a second one and use the snippet form.
|
||||
2. **Both hostnames must be listed.** `*.test.rosterchief.app` does **not** match
|
||||
`test.rosterchief.app` — a wildcard covers exactly one label. Leave the bare host out and
|
||||
the club subdomains have a certificate while the control panel does not. Hence the comma.
|
||||
(Wildcards are also only one level deep: `ajax.test.…` yes, `a.b.test.…` no.)
|
||||
3. **Caddy must have the DNS plugin.** Stock `caddy` cannot answer a DNS-01 challenge at all.
|
||||
`caddy add-package github.com/caddy-dns/cloudflare`, or run a Caddy built like
|
||||
`deploy/caddy/Dockerfile`. (If DNS-01 already works on the box, you have it.)
|
||||
4. **The token must be in *Caddy's* environment**, not your shell's — `{env.…}` reads the
|
||||
process it runs in:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/caddy.service.d/override.conf
|
||||
[Service]
|
||||
EnvironmentFile=/etc/caddy/caddy.env # CLOUDFLARE_API_TOKEN=...
|
||||
```
|
||||
|
||||
Then `systemctl daemon-reload && systemctl restart caddy`.
|
||||
|
||||
5. **`header_up X-Forwarded-Proto` is not optional**, exactly as in the bundled Caddyfile:
|
||||
without it Django believes the request behind the proxy is plain HTTP.
|
||||
|
||||
6. **Give the test instance its own subdomain tree** and set
|
||||
`ROSTERCHIEF_BASE_DOMAIN=test.rosterchief.app`. That variable drives tenant resolution,
|
||||
the shared session cookie *and* the WebAuthn RP ID — point it at the production domain and
|
||||
test passkeys start colliding with real ones.
|
||||
|
||||
### Applying and checking it
|
||||
|
||||
```bash
|
||||
caddy validate --config /etc/caddy/Caddyfile # syntax and modules
|
||||
systemctl reload caddy # zero downtime; existing certs untouched
|
||||
journalctl -u caddy -f # watch the DNS-01 challenge
|
||||
|
||||
curl -I https://test.rosterchief.app/healthz
|
||||
curl -I https://any-club-slug.test.rosterchief.app/ # proves the WILDCARD, not just the host
|
||||
```
|
||||
|
||||
Reloading provisions only what is new, so the existing site's certificate is not reissued.
|
||||
Allow 30–60s for the DNS record to propagate before the challenge completes.
|
||||
|
||||
DNS needs both records, pointing at the test box:
|
||||
|
||||
```
|
||||
A test.rosterchief.app -> <server ip>
|
||||
A *.test.rosterchief.app -> <server ip>
|
||||
```
|
||||
|
||||
The compose project is named `rosterchief-test`, so its containers and volumes never collide
|
||||
with a production stack on the same host.
|
||||
|
||||
### Deploying with one command
|
||||
|
||||
Once the server has the repo cloned at `/home/bernard/RosterChief` and its two env files in
|
||||
place, `deploy/deploy-dev.sh` does a full deploy over SSH:
|
||||
|
||||
```bash
|
||||
deploy/deploy-dev.sh # deploy the current branch
|
||||
BRANCH=main deploy/deploy-dev.sh
|
||||
deploy/deploy-dev.sh --push # push the branch first, then deploy
|
||||
```
|
||||
|
||||
It runs from your machine and does the work on the server in one SSH session: fetch the pushed
|
||||
branch (a hard reset to `origin/<branch>`, since a deploy target only receives deploys), build
|
||||
the image, run migrations *explicitly*, restart only `web`, and wait for `/healthz`.
|
||||
|
||||
It refuses to deploy a branch whose local commits are not pushed — the server pulls from git,
|
||||
so unpushed work would ship stale code silently. Override the host, user, directory or branch
|
||||
with the `SSH_HOST` / `SSH_USER` / `REMOTE_DIR` / `BRANCH` environment variables.
|
||||
|
||||
First-time setup on the server, once:
|
||||
|
||||
```bash
|
||||
git clone git@git.siebens.org:bernard/RosterChief.git /home/bernard/RosterChief
|
||||
cd /home/bernard/RosterChief
|
||||
cp .env.compose.example .env # fill in POSTGRES_PASSWORD etc.
|
||||
cp .env.production.example .env.production
|
||||
# then add the reverse_proxy site block to the host's Caddy (see above)
|
||||
```
|
||||
|
||||
## Automated backups
|
||||
|
||||
`deploy/backup.sh` dumps the database, tars the uploads while they are still on local disk,
|
||||
prunes anything older than `KEEP_DAYS`, and — if you set `BACKUP_REMOTE` — copies the lot off
|
||||
the box with rclone.
|
||||
|
||||
```bash
|
||||
deploy/backup.sh /var/backups/rosterchief
|
||||
```
|
||||
|
||||
It writes to a `.part` file and only moves it into place once `gzip -t` says the archive is
|
||||
readable and non-empty. A truncated dump that *looks* like a backup is the failure mode worth
|
||||
engineering against, because you only discover it on the day you need it.
|
||||
|
||||
Schedule it as root on the host (single server; on several, run it on the database node):
|
||||
|
||||
```cron
|
||||
# Nightly at 02:30, before the billing and event jobs.
|
||||
30 2 * * * cd /srv/rosterchief && BACKUP_REMOTE=b2:rosterchief-backups KEEP_DAYS=14 deploy/backup.sh /var/backups/rosterchief
|
||||
|
||||
# Weekly restore rehearsal into a throwaway database. This is the only line here that proves
|
||||
# the others work.
|
||||
0 4 * * 0 cd /srv/rosterchief && deploy/restore-check.sh
|
||||
```
|
||||
|
||||
Cron mails you on non-zero exit, and the script uses `set -Eeuo pipefail` so it *does* exit
|
||||
non-zero. A backup script that fails quietly is worse than none, because you will believe you
|
||||
have backups.
|
||||
|
||||
**Offsite matters more than frequency.** A dump sitting on the same disk as the database
|
||||
survives a bad migration but not the server. `BACKUP_REMOTE` takes any rclone remote (S3,
|
||||
Backblaze, a second box).
|
||||
|
||||
**Once uploads move to S3** (`AWS_STORAGE_BUCKET_NAME`), the script skips the media tarball:
|
||||
the bucket's own versioning is the backup. Turn versioning on when you create it.
|
||||
|
||||
### Restoring
|
||||
|
||||
```bash
|
||||
gunzip -c /var/backups/rosterchief/db-2026-07-14-0230.sql.gz \
|
||||
| docker compose exec -T db psql -U rosterchief rosterchief
|
||||
```
|
||||
|
||||
The dump is taken with `--clean --if-exists`, so it drops and recreates rather than colliding
|
||||
with what is there. Rehearse it once, now, against a scratch database — not the first time you
|
||||
need it.
|
||||
|
||||
## Backups (manual)
|
||||
|
||||
Two things carry state: Postgres and the uploads.
|
||||
|
||||
```bash
|
||||
# Database
|
||||
docker compose exec -T db pg_dump -U rosterchief rosterchief | gzip > rosterchief-$(date +%F).sql.gz
|
||||
|
||||
# Uploads — until they are on S3, in which case the bucket's own versioning is the backup.
|
||||
docker compose cp web:/app/media ./media-backup
|
||||
```
|
||||
|
||||
Restore is `gunzip -c dump.sql.gz | docker compose exec -T db psql -U rosterchief rosterchief`.
|
||||
Test it once, now, rather than the first time you need it.
|
||||
|
||||
## Sizing the server
|
||||
|
||||
For **1–5 clubs, ~1000 members, ~10 events per club per week**.
|
||||
|
||||
The short answer: **2 vCPU, 4 GB RAM, 40 GB SSD** — a €4–6/month VPS (Hetzner CX22 or
|
||||
equivalent). The interesting part is *why*, because the data is not what sizes this box.
|
||||
|
||||
### The data is negligible
|
||||
|
||||
Row counts for that workload, from the actual schema (attendance dominates: every event
|
||||
invites a squad, so one event is ~20 rows):
|
||||
|
||||
| table | rows/year | MB/year |
|
||||
|---|---:|---:|
|
||||
| `events.Attendance` | 52,000 | 16 |
|
||||
| `events.Event` | 2,600 | 2 |
|
||||
| `formbuilder` answers | 10,000 | 3 |
|
||||
| `shop` orders + lines | 3,000 | 1 |
|
||||
| members, memberships, rosters | ~3,000 | 1 |
|
||||
| **total, with WAL and bloat** | | **~40 MB/year** |
|
||||
|
||||
That is **0.2 GB after five years**. Uploads are club logos — a handful of files. Invoices are
|
||||
rendered on demand and never stored. Nothing here grows into a problem.
|
||||
|
||||
So do not size for the data. Size for the **processes**.
|
||||
|
||||
### What actually consumes the box
|
||||
|
||||
Measured, running this app under gunicorn with `DEBUG=False`:
|
||||
|
||||
| | memory |
|
||||
|---|---|
|
||||
| gunicorn master + 3 workers | **~270 MB** (~54 MB per worker) |
|
||||
| PostgreSQL (default `shared_buffers`) | ~200–400 MB |
|
||||
| Redis (cache only) | < 50 MB |
|
||||
| Caddy | ~30 MB |
|
||||
| OS + Docker daemon | ~400 MB |
|
||||
| **steady state** | **~1.0–1.2 GB** |
|
||||
|
||||
2 GB would run it. 4 GB is the recommendation for three reasons, all of which are the kind of
|
||||
thing that bites at the worst moment:
|
||||
|
||||
1. **`docker compose build` is the memory spike, not serving.** npm, uv and `collectstatic`
|
||||
together will OOM a 2 GB box that is also running Postgres. Either take the 4 GB, or build
|
||||
the image elsewhere and pull it.
|
||||
2. **Rendering an invoice loads WeasyPrint.** It is imported lazily (which is why the workers
|
||||
measure 54 MB and not 150), so pango and its fonts land in whichever worker renders a PDF —
|
||||
expect that worker to grow by ~50–100 MB the first time someone downloads an invoice.
|
||||
3. **Headroom is Postgres's page cache.** With 200 MB of data and 4 GB of RAM, the entire
|
||||
database lives in cache and the disk is never touched for reads.
|
||||
|
||||
### Disk
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Docker images (app ~1 GB with pango, postgres, redis, caddy) | ~1.5 GB |
|
||||
| Build cache | 2–4 GB |
|
||||
| Database, 5 years | < 0.5 GB |
|
||||
| Backups: 14 daily compressed dumps | < 0.5 GB |
|
||||
| Logs | ~1 GB |
|
||||
| **40 GB is roomy; 20 GB works** | |
|
||||
|
||||
### CPU and concurrency
|
||||
|
||||
2 vCPU. Three workers × four threads is twelve concurrent requests, against a peak of "the
|
||||
whole club checks the Saturday line-up at 09:00" — perhaps a few hundred requests over a few
|
||||
minutes. This workload is not CPU-bound; the one CPU-heavy operation is PDF rendering, which
|
||||
happens a handful of times a month.
|
||||
|
||||
### When to grow
|
||||
|
||||
Not at "more members" — at these:
|
||||
|
||||
- **Uploads become real content** (photo galleries, documents). Media, not rows, is what makes
|
||||
storage grow, and it is also the trigger for moving to S3.
|
||||
- **Attendance passes a few million rows** (~20 clubs at this rate, i.e. several years out).
|
||||
Add an index before adding a server.
|
||||
- **You want zero-downtime deploys.** That is a second app node, not a bigger one.
|
||||
|
||||
## For fun: three nodes on AWS
|
||||
|
||||
Wildly over-engineered for 1000 members, but here is what it looks like — and what it costs.
|
||||
|
||||
### The layout
|
||||
|
||||
```
|
||||
Route 53 (rosterchief.app + *.rosterchief.app)
|
||||
|
|
||||
ACM certificate (wildcard, free)
|
||||
|
|
||||
Application Load Balancer (TLS terminates here)
|
||||
|
|
||||
+----+----+----+
|
||||
| | |
|
||||
ECS task task task 3 × Fargate, one per AZ, same image
|
||||
| | |
|
||||
+----+----+----+
|
||||
|
|
||||
+----+---------------+----------------+
|
||||
| | |
|
||||
RDS PostgreSQL ElastiCache Redis S3 (media)
|
||||
(Multi-AZ) (cache.t4g.micro) + CloudFront (optional)
|
||||
```
|
||||
|
||||
**The one genuinely nice thing AWS gives you here: ACM issues the wildcard certificate for
|
||||
free, with DNS validation in Route 53.** The whole DNS-01 dance disappears — no Caddy plugin,
|
||||
no API token, no renewal. The ALB terminates TLS and forwards to the tasks. That is the single
|
||||
biggest simplification versus the VPS.
|
||||
|
||||
### What changes in the app
|
||||
|
||||
Nothing in the code. Only environment:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `DJANGO_DATABASE_URL` | the RDS endpoint |
|
||||
| `DJANGO_REDIS_URL` | the ElastiCache endpoint |
|
||||
| `AWS_STORAGE_BUCKET_NAME` | the media bucket — **required** now, three nodes cannot share a disk |
|
||||
| `SECURE_PROXY_SSL_HEADER` | already set; the ALB sends `X-Forwarded-Proto` |
|
||||
| health check | point the target group at **`/healthz`** — that is what it is for |
|
||||
|
||||
Sessions are database-backed, so **no sticky sessions**: any task can serve any request.
|
||||
|
||||
**Scheduled jobs get better here.** EventBridge Scheduler firing a one-off ECS task solves the
|
||||
"run it on exactly one node" problem properly — no cron on three boxes racing each other:
|
||||
|
||||
```
|
||||
EventBridge (cron: 0 6 * * ? *) -> ECS RunTask -> archive_overdue_clubs --commit
|
||||
```
|
||||
|
||||
Backups become RDS automated snapshots + PITR, and `deploy/backup.sh` retires — though the
|
||||
*restore rehearsal* does not. Snapshots you have never restored are still a hypothesis.
|
||||
|
||||
### Monthly cost (eu-central-1, on-demand, indicative)
|
||||
|
||||
| | | $/month |
|
||||
|---|---|---:|
|
||||
| ALB | fixed + a little LCU | ~22 |
|
||||
| ECS Fargate | 3 × (0.5 vCPU, 1 GB) | ~54 |
|
||||
| RDS PostgreSQL | `db.t4g.micro`, 20 GB gp3, single-AZ | ~17 |
|
||||
| ElastiCache | `cache.t4g.micro` | ~12 |
|
||||
| S3 + CloudFront | a few GB, low traffic | ~2 |
|
||||
| Route 53 | hosted zone + queries | ~1 |
|
||||
| ECR, CloudWatch logs | small | ~3 |
|
||||
| | **single-AZ total** | **~110** |
|
||||
| RDS Multi-AZ | doubles the database | +17 |
|
||||
| | **highly-available total** | **~130** |
|
||||
|
||||
**Watch the NAT Gateway.** If the tasks sit in private subnets and reach the internet through
|
||||
a NAT Gateway, add **~$32/month per AZ plus data charges** — for three AZs that is more than
|
||||
the compute. Either put the tasks in public subnets with tight security groups, or use VPC
|
||||
endpoints for ECR/S3/CloudWatch. It is the single most common surprise on an AWS bill of this
|
||||
shape.
|
||||
|
||||
Prices are indicative and move; check the calculator before committing.
|
||||
|
||||
### The honest comparison
|
||||
|
||||
| | | |
|
||||
|---|---|---|
|
||||
| **Hetzner CX22** | 2 vCPU, 4 GB, 40 GB | **~€5/month** |
|
||||
| **AWS, three nodes** | as above | **~$110–130/month** |
|
||||
|
||||
Roughly **25×**, for a workload whose database is 200 MB after five years. What the money buys
|
||||
is real — managed Postgres with PITR, three AZs, no box to patch, free wildcard certificates —
|
||||
but it is bought for *resilience*, not for capacity. At 1000 members you are paying for the
|
||||
insurance, not the compute.
|
||||
|
||||
A reasonable middle: one VPS now, and move Postgres to a managed service (RDS, or a €15/month
|
||||
managed Postgres) the day the data starts to matter more than the uptime. That is the change
|
||||
that is painful to do late, and everything else in this document is already designed for it.
|
||||
|
||||
## Going multi-server
|
||||
|
||||
Nothing in the code changes. What changes is where the services live:
|
||||
|
||||
| | one server | several |
|
||||
|---|---|---|
|
||||
| Postgres | `db` container | `DJANGO_DATABASE_URL` → your central Postgres |
|
||||
| Cache / flags | `redis` container | managed Redis (or your existing one) |
|
||||
| Uploads | local disk | **S3 bucket** (`AWS_STORAGE_BUCKET_NAME`) |
|
||||
| Static files | WhiteNoise, in the image | unchanged — that is why WhiteNoise is there |
|
||||
| Cron | host crontab | one node only |
|
||||
| TLS | Caddy on the box | load balancer, or Caddy on each node |
|
||||
|
||||
Drop `db` and `redis` from `compose.yaml`, point the URLs at the central services, and run
|
||||
`web` on as many nodes as you like behind a load balancer pointed at `/healthz`.
|
||||
|
||||
The health check tests the database *and* a cache round trip, not just that the process is
|
||||
listening — a node that cannot reach Postgres, or whose cache silently swallows writes, is
|
||||
not healthy, and a load balancer must not keep feeding it traffic.
|
||||
|
||||
## Rollback
|
||||
|
||||
Images are the unit of rollback. Tag on build, keep the last few, and:
|
||||
|
||||
```bash
|
||||
docker compose up -d --no-deps web # with the previous image tag
|
||||
```
|
||||
|
||||
Migrations are the exception: they don't roll back with the image. Prefer additive migrations
|
||||
(add a column, deploy, backfill, then stop writing the old one) so that yesterday's image
|
||||
still runs against today's schema.
|
||||
94
Dockerfile
Normal file
94
Dockerfile
Normal file
@@ -0,0 +1,94 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# --- 1. the stylesheet -------------------------------------------------------
|
||||
# Tailwind is a build-time concern: the CSS it emits is committed, but building it here means
|
||||
# the image never depends on someone having remembered to run `npm run build`.
|
||||
FROM node:22-slim AS css
|
||||
|
||||
WORKDIR /build
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY assets ./assets
|
||||
COPY templates ./templates
|
||||
COPY controlpanel ./controlpanel
|
||||
COPY billing ./billing
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# --- 2. the virtualenv -------------------------------------------------------
|
||||
# Separate from the runtime for one reason: django-lucide is a *git* dependency (our lucide
|
||||
# fork), so uv shells out to git to fetch it. python:*-slim has no git, and installing it in
|
||||
# the runtime image would leave a build-time tool — plus its dependency tree — in production
|
||||
# for the sake of one package that is already vendored into the venv by then.
|
||||
FROM python:3.14-slim AS venv
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=never
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dependencies first: they change far less often than the code, so this layer caches.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
|
||||
# --- 3. the runtime ----------------------------------------------------------
|
||||
FROM python:3.14-slim AS app
|
||||
|
||||
# WeasyPrint binds to these at import: no pango, no invoices. This is also why building the
|
||||
# PDF path in a container is easier than on a Mac — apt has what Homebrew would have to.
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libharfbuzz0b \
|
||||
libffi8 \
|
||||
libjpeg62-turbo \
|
||||
libopenjp2-7 \
|
||||
shared-mime-info \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
# gunicorn 26's control server puts a socket in $HOME. The app user has no home dir, so
|
||||
# without this it logs "Permission denied: /home/rosterchief" on every boot. /app is
|
||||
# already the workdir and owned by the app user, so point HOME there.
|
||||
HOME="/app"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# The venv arrives fully built. Same base image, so the compiled wheels inside it are ABI
|
||||
# compatible; nothing is re-resolved here, and no git is needed to run what git fetched.
|
||||
COPY --from=venv /app/.venv ./.venv
|
||||
|
||||
COPY . .
|
||||
COPY --from=css /build/static/css/app.css ./static/css/app.css
|
||||
|
||||
# collectstatic needs a settings module that imports: a throwaway key, never used at runtime.
|
||||
RUN DJANGO_SECRET_KEY=build-only-not-a-secret \
|
||||
DJANGO_STATICFILES_BACKEND=whitenoise.storage.CompressedManifestStaticFilesStorage \
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
RUN useradd --system --uid 1000 rosterchief && chown -R rosterchief /app
|
||||
USER rosterchief
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Migrations are NOT run here. With more than one app container they would race, and a failed
|
||||
# migration inside a starting web process is a bad place to find out — deploy runs them once,
|
||||
# explicitly (see DEPLOYMENT.md).
|
||||
CMD ["gunicorn", "rosterchief.wsgi:application", \
|
||||
"--bind", "0.0.0.0:8000", \
|
||||
"--workers", "3", \
|
||||
"--threads", "4", \
|
||||
"--timeout", "60", \
|
||||
"--access-logfile", "-", \
|
||||
"--error-logfile", "-"]
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
/* Scan Django templates for utility classes (Tailwind's auto-detection doesn't
|
||||
know about our template dirs). */
|
||||
@source "../../templates";
|
||||
@source "../../controlpanel";
|
||||
@source "../templates";
|
||||
@source "../controlpanel";
|
||||
@source "../billing";
|
||||
|
||||
/* daisyUI: light is the default, dark applies automatically when the OS asks
|
||||
for it. An explicit data-theme on <html> (set by the toggle) overrides both. */
|
||||
0
billing/__init__.py
Normal file
0
billing/__init__.py
Normal file
53
billing/admin.py
Normal file
53
billing/admin.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Due, DuePayment, Subscription, Tier, TierPrice
|
||||
|
||||
|
||||
class TierPriceInline(admin.TabularInline):
|
||||
model = TierPrice
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Tier)
|
||||
class TierAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "is_active"]
|
||||
list_filter = ["is_active"]
|
||||
search_fields = ["name"]
|
||||
prepopulated_fields = {"slug": ["name"]}
|
||||
inlines = [TierPriceInline]
|
||||
|
||||
|
||||
@admin.register(TierPrice)
|
||||
class TierPriceAdmin(admin.ModelAdmin):
|
||||
list_display = ["tier", "amount", "active_from"]
|
||||
list_filter = ["tier"]
|
||||
|
||||
|
||||
@admin.register(Subscription)
|
||||
class SubscriptionAdmin(admin.ModelAdmin):
|
||||
list_display = ["club", "tier", "auto_renew", "auto_archive"]
|
||||
list_filter = ["tier", "auto_renew", "auto_archive"]
|
||||
search_fields = ["club__name"]
|
||||
|
||||
|
||||
class DuePaymentInline(admin.TabularInline):
|
||||
model = DuePayment
|
||||
extra = 0
|
||||
readonly_fields = ["recorded_by"]
|
||||
|
||||
|
||||
@admin.register(Due)
|
||||
class DueAdmin(admin.ModelAdmin):
|
||||
list_display = ["club", "tier", "period_start", "period_end", "amount", "amount_paid", "status"]
|
||||
list_filter = ["status", "tier"]
|
||||
search_fields = ["club__name"]
|
||||
# Money is settled by the billing service, which re-derives these from the payments.
|
||||
readonly_fields = ["amount_paid", "status", "paid_at"]
|
||||
inlines = [DuePaymentInline]
|
||||
|
||||
|
||||
@admin.register(DuePayment)
|
||||
class DuePaymentAdmin(admin.ModelAdmin):
|
||||
list_display = ["due", "amount", "method", "paid_at", "recorded_by"]
|
||||
list_filter = ["method"]
|
||||
search_fields = ["due__club__name", "reference"]
|
||||
7
billing/apps.py
Normal file
7
billing/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BillingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "billing"
|
||||
verbose_name = "Billing"
|
||||
0
billing/management/__init__.py
Normal file
0
billing/management/__init__.py
Normal file
0
billing/management/commands/__init__.py
Normal file
0
billing/management/commands/__init__.py
Normal file
38
billing/management/commands/archive_overdue_clubs.py
Normal file
38
billing/management/commands/archive_overdue_clubs.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Archive clubs whose billing period has gone unpaid past its grace period.
|
||||
|
||||
Reports by default and only acts with --commit. That asymmetry is the point: this command
|
||||
switches off paying customers, and a cron misconfiguration, a clock skew or a bad import
|
||||
should cost you a confusing email, not a morning of angry clubs.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from billing.services.dues import archivable_clubs
|
||||
from features.commands import MaintenanceAwareCommand
|
||||
|
||||
|
||||
class Command(MaintenanceAwareCommand):
|
||||
help = "Archive clubs that are unpaid past their grace period (dry run unless --commit)."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--commit", action="store_true", help="Actually archive them. Without this the command only reports.")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
today = timezone.localdate()
|
||||
overdue = list(archivable_clubs(today))
|
||||
|
||||
if not overdue:
|
||||
self.stdout.write(self.style.SUCCESS("Nothing overdue past grace."))
|
||||
return
|
||||
|
||||
for due in overdue:
|
||||
days = (today - due.grace_until).days
|
||||
self.stdout.write(f"{due.club} — {due.tier}, {due.balance} owed, grace ended {due.grace_until} ({days} day{'s'[: days != 1]} ago)")
|
||||
|
||||
if not options["commit"]:
|
||||
self.stdout.write(self.style.WARNING(f"\nDry run: {len(overdue)} club(s) would be archived. Re-run with --commit to do it."))
|
||||
return
|
||||
|
||||
for due in overdue:
|
||||
due.club.archive()
|
||||
self.stdout.write(self.style.SUCCESS(f"\nArchived {len(overdue)} club(s). Their data is kept; restoring re-opens billing."))
|
||||
57
billing/management/commands/renew_subscriptions.py
Normal file
57
billing/management/commands/renew_subscriptions.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Issue the next billing period for clubs whose current one is running out.
|
||||
|
||||
Unlike archive_overdue_clubs, this ACTS by default and only previews with --dry-run. The
|
||||
asymmetry is deliberate and runs the other way: archiving switches off a paying customer, so
|
||||
not acting is the safe failure. Here, not acting means a club keeps using the platform for
|
||||
free — and because nothing is owed, no dashboard number goes red and the archive job never
|
||||
fires either. A missed renewal is silent, and silence is the expensive failure.
|
||||
"""
|
||||
|
||||
from django.core.management.base import CommandError
|
||||
|
||||
from billing.models import RENEWAL_LEAD_DAYS
|
||||
from billing.services import BillingError
|
||||
from billing.services.dues import renew, subscriptions_due_for_renewal
|
||||
from features.commands import MaintenanceAwareCommand
|
||||
|
||||
|
||||
class Command(MaintenanceAwareCommand):
|
||||
help = "Open the next billing period for clubs whose current period ends soon."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--dry-run", action="store_true", help="Report what would be issued, and issue nothing.")
|
||||
parser.add_argument("--lead-days", type=int, default=RENEWAL_LEAD_DAYS, help=f"Issue this many days before the period ends (default {RENEWAL_LEAD_DAYS}).")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
due_for_renewal = subscriptions_due_for_renewal(lead_days=options["lead_days"])
|
||||
|
||||
if not due_for_renewal:
|
||||
self.stdout.write(self.style.SUCCESS("Nothing to renew."))
|
||||
return
|
||||
|
||||
failures = []
|
||||
for subscription in due_for_renewal:
|
||||
club = subscription.club
|
||||
|
||||
if options["dry_run"]:
|
||||
self.stdout.write(f"would renew {club} — {subscription.tier}, current period ends {subscription.latest_period_end or 'never opened'}")
|
||||
continue
|
||||
|
||||
try:
|
||||
due = renew(subscription)
|
||||
except BillingError as error:
|
||||
# One unpriced tier must not stop every other club from being billed.
|
||||
failures.append(f"{club}: {error}")
|
||||
self.stdout.write(self.style.ERROR(f"{club} — {error}"))
|
||||
continue
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"{club} — {due.period_start} to {due.period_end}, {due.amount} ({due.invoice.number})"))
|
||||
|
||||
if options["dry_run"]:
|
||||
self.stdout.write(self.style.WARNING(f"\nDry run: {len(due_for_renewal)} club(s) would be renewed."))
|
||||
return
|
||||
|
||||
if failures:
|
||||
# Non-zero, so cron mails you: a club that could not be billed is revenue quietly
|
||||
# not being collected.
|
||||
raise CommandError(f"{len(failures)} club(s) could not be renewed:\n " + "\n ".join(failures))
|
||||
138
billing/migrations/0001_initial.py
Normal file
138
billing/migrations/0001_initial.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-13 23:40
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('club', '0013_club_created_club_modified_clubmembership_created_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tier',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('slug', models.SlugField(blank=True, max_length=255, unique=True, verbose_name='slug')),
|
||||
('description', models.TextField(blank=True, verbose_name='description')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Inactive tiers keep billing existing subscriptions but cannot be chosen for new ones.', verbose_name='active')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'tier',
|
||||
'verbose_name_plural': 'tiers',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Due',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='amount')),
|
||||
('amount_paid', models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Kept in step with the payments by the billing service.', max_digits=10, verbose_name='amount paid')),
|
||||
('period_start', models.DateField(verbose_name='period start')),
|
||||
('period_end', models.DateField(blank=True, verbose_name='period end')),
|
||||
('grace_until', models.DateField(blank=True, help_text='Past this date an unpaid club is archived.', verbose_name='grace until')),
|
||||
('status', models.CharField(choices=[('unpaid', 'unpaid'), ('partial', 'partially paid'), ('paid', 'paid'), ('waived', 'waived'), ('cancelled', 'cancelled')], default='unpaid', max_length=20, verbose_name='status')),
|
||||
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='paid at')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dues', to='club.club', verbose_name='club')),
|
||||
('tier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dues', to='billing.tier', verbose_name='tier')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'due',
|
||||
'verbose_name_plural': 'dues',
|
||||
'ordering': ['-period_start', 'club__name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DuePayment',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')),
|
||||
('method', models.CharField(choices=[('bank_transfer', 'bank transfer'), ('card', 'card'), ('cash', 'cash'), ('other', 'other')], default='bank_transfer', max_length=20, verbose_name='method')),
|
||||
('reference', models.CharField(blank=True, help_text='Bank reference, transaction id — whatever lets you find this again.', max_length=255, verbose_name='reference')),
|
||||
('paid_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='paid at')),
|
||||
('note', models.TextField(blank=True, verbose_name='note')),
|
||||
('due', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='billing.due', verbose_name='due')),
|
||||
('recorded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recorded_due_payments', to=settings.AUTH_USER_MODEL, verbose_name='recorded by')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'due payment',
|
||||
'verbose_name_plural': 'due payments',
|
||||
'ordering': ['-paid_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invoice',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('number', models.CharField(blank=True, max_length=32, unique=True, verbose_name='number')),
|
||||
('issued_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='issued at')),
|
||||
('due', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='invoice', to='billing.due', verbose_name='due')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'invoice',
|
||||
'verbose_name_plural': 'invoices',
|
||||
'ordering': ['-issued_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Subscription',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('auto_archive', models.BooleanField(default=True, help_text='Archive this club when a period goes unpaid past its grace period.', verbose_name='auto archive')),
|
||||
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||
('club', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='club.club', verbose_name='club')),
|
||||
('tier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='billing.tier', verbose_name='tier')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'subscription',
|
||||
'verbose_name_plural': 'subscriptions',
|
||||
'ordering': ['club__name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TierPrice',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('active_from', models.DateField(help_text='Periods opening on or after this date are billed at this amount.', verbose_name='active from')),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='amount')),
|
||||
('tier', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='billing.tier', verbose_name='tier')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'tier price',
|
||||
'verbose_name_plural': 'tier prices',
|
||||
'ordering': ['tier__name', '-active_from'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='due',
|
||||
constraint=models.UniqueConstraint(fields=('club', 'period_start'), name='unique_due_per_club_per_period'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='tierprice',
|
||||
constraint=models.UniqueConstraint(fields=('tier', 'active_from'), name='unique_tier_price_per_start_date'),
|
||||
),
|
||||
]
|
||||
18
billing/migrations/0002_subscription_auto_renew.py
Normal file
18
billing/migrations/0002_subscription_auto_renew.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-14 22:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subscription',
|
||||
name='auto_renew',
|
||||
field=models.BooleanField(default=True, help_text='Issue the next period automatically before this one ends. Off means you invoice this club by hand.', verbose_name='auto renew'),
|
||||
),
|
||||
]
|
||||
0
billing/migrations/__init__.py
Normal file
0
billing/migrations/__init__.py
Normal file
253
billing/models.py
Normal file
253
billing/models.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""What the platform charges a club.
|
||||
|
||||
Deliberately NOT club-scoped. `shop` is a club charging its members — tenant data, owned by
|
||||
the club. This is RosterChief charging the club: platform-owned, and no club user ever sees
|
||||
it. Nothing here inherits ClubScopedModel: these rows reference a Club, they are not owned
|
||||
by one, and a tenant-scoped manager would be exactly the wrong default.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rosterchief.base import UUIDModel, unique_slugify
|
||||
|
||||
ZERO = Decimal("0.00")
|
||||
|
||||
#: A club stays live for six weeks past the end of an unpaid period before it is archived.
|
||||
GRACE_DAYS = 45
|
||||
|
||||
#: The next period is issued this long before the current one ends, so the invoice reaches the
|
||||
#: club — and can be paid — before the old period lapses. Grace then only matters for genuine
|
||||
#: non-payers, rather than for everyone who takes a fortnight to pay a bank transfer.
|
||||
RENEWAL_LEAD_DAYS = 30
|
||||
|
||||
|
||||
def add_one_year(day: date) -> date:
|
||||
"""The day one year on. 29 February has no counterpart in a common year, so it falls
|
||||
back to the 28th rather than raising."""
|
||||
try:
|
||||
return day.replace(year=day.year + 1)
|
||||
except ValueError:
|
||||
return day.replace(year=day.year + 1, day=28)
|
||||
|
||||
|
||||
class Tier(UUIDModel):
|
||||
"""A price band. The price itself lives in TierPrice, which is dated."""
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True)
|
||||
description = models.TextField(_("description"), blank=True)
|
||||
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive tiers keep billing existing subscriptions but cannot be chosen for new ones."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("tier")
|
||||
verbose_name_plural = _("tiers")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = unique_slugify(self, self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def price_on(self, day: date | None = None) -> Decimal | None:
|
||||
"""The price in force on ``day`` — the latest one that had started by then.
|
||||
|
||||
None means the tier had no price yet on that date. Callers must treat that as
|
||||
"cannot bill", never as free.
|
||||
"""
|
||||
day = day or timezone.localdate()
|
||||
price = self.prices.filter(active_from__lte=day).order_by("-active_from").first()
|
||||
|
||||
return price.amount if price else None
|
||||
|
||||
|
||||
class TierPrice(UUIDModel):
|
||||
"""A dated price for a tier.
|
||||
|
||||
Dated rather than keyed by year: a rate change is one new row with a future
|
||||
``active_from``, and every period already opened keeps the amount it was billed at.
|
||||
"""
|
||||
|
||||
tier = models.ForeignKey(Tier, on_delete=models.CASCADE, related_name="prices", verbose_name=_("tier"))
|
||||
active_from = models.DateField(_("active from"), help_text=_("Periods opening on or after this date are billed at this amount."))
|
||||
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("tier price")
|
||||
verbose_name_plural = _("tier prices")
|
||||
ordering = ["tier__name", "-active_from"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["tier", "active_from"], name="unique_tier_price_per_start_date"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tier} — {self.amount} from {self.active_from}"
|
||||
|
||||
|
||||
class Subscription(UUIDModel):
|
||||
"""A club's current plan. The periods it is billed for are Dues."""
|
||||
|
||||
club = models.OneToOneField("club.Club", on_delete=models.CASCADE, related_name="subscription", verbose_name=_("club"))
|
||||
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("tier"))
|
||||
auto_renew = models.BooleanField(_("auto renew"), default=True, help_text=_("Issue the next period automatically before this one ends. Off means you invoice this club by hand."))
|
||||
auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period."))
|
||||
notes = models.TextField(_("notes"), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("subscription")
|
||||
verbose_name_plural = _("subscriptions")
|
||||
ordering = ["club__name"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.club} — {self.tier}"
|
||||
|
||||
|
||||
class Due(UUIDModel):
|
||||
"""One billing period for one club.
|
||||
|
||||
``tier`` and ``amount`` are snapshots taken when the period opens, never read back
|
||||
through the tier at display time: raise the price and last year's period must still say
|
||||
what was actually charged. A live lookup would rewrite financial history.
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
UNPAID = "unpaid", _("unpaid")
|
||||
PARTIAL = "partial", _("partially paid")
|
||||
PAID = "paid", _("paid")
|
||||
WAIVED = "waived", _("waived")
|
||||
CANCELLED = "cancelled", _("cancelled")
|
||||
|
||||
#: Statuses that still owe money.
|
||||
OWING = (Status.UNPAID, Status.PARTIAL)
|
||||
|
||||
club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="dues", verbose_name=_("club"))
|
||||
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="dues", verbose_name=_("tier"))
|
||||
|
||||
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(ZERO)])
|
||||
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=ZERO, help_text=_("Kept in step with the payments by the billing service."))
|
||||
|
||||
period_start = models.DateField(_("period start"))
|
||||
period_end = models.DateField(_("period end"), blank=True)
|
||||
grace_until = models.DateField(_("grace until"), blank=True, help_text=_("Past this date an unpaid club is archived."))
|
||||
|
||||
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.UNPAID)
|
||||
paid_at = models.DateTimeField(_("paid at"), null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("due")
|
||||
verbose_name_plural = _("dues")
|
||||
ordering = ["-period_start", "club__name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["club", "period_start"], name="unique_due_per_club_per_period"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.club} — {self.period_start} to {self.period_end}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# A period runs a rolling year from its start and the grace hangs off its end.
|
||||
# Derived here so no caller can open a period without them.
|
||||
if not self.period_end:
|
||||
self.period_end = add_one_year(self.period_start) - timedelta(days=1)
|
||||
if not self.grace_until:
|
||||
self.grace_until = self.period_end + timedelta(days=GRACE_DAYS)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
return self.amount - self.amount_paid
|
||||
|
||||
@property
|
||||
def is_owing(self) -> bool:
|
||||
return self.status in self.OWING
|
||||
|
||||
def is_in_grace(self, today: date | None = None) -> bool:
|
||||
"""The period has ended unpaid, but the club is not archivable yet."""
|
||||
today = today or timezone.localdate()
|
||||
|
||||
return self.is_owing and self.period_end < today <= self.grace_until
|
||||
|
||||
def is_overdue(self, today: date | None = None) -> bool:
|
||||
"""Unpaid past grace — this is what makes a club archivable."""
|
||||
today = today or timezone.localdate()
|
||||
|
||||
return self.is_owing and self.grace_until < today
|
||||
|
||||
|
||||
class DuePayment(UUIDModel):
|
||||
"""Money received against a due.
|
||||
|
||||
Several may land on one due: a club that pays in two transfers must not read as unpaid,
|
||||
and the half that did arrive has to be recorded somewhere.
|
||||
"""
|
||||
|
||||
class Method(models.TextChoices):
|
||||
BANK_TRANSFER = "bank_transfer", _("bank transfer")
|
||||
CARD = "card", _("card")
|
||||
CASH = "cash", _("cash")
|
||||
OTHER = "other", _("other")
|
||||
|
||||
due = models.ForeignKey(Due, on_delete=models.CASCADE, related_name="payments", verbose_name=_("due"))
|
||||
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
|
||||
method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
|
||||
reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
|
||||
paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
|
||||
note = models.TextField(_("note"), blank=True)
|
||||
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_due_payments", verbose_name=_("recorded by"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("due payment")
|
||||
verbose_name_plural = _("due payments")
|
||||
ordering = ["-paid_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.amount} — {self.due}"
|
||||
|
||||
|
||||
class Invoice(UUIDModel):
|
||||
"""The bill for one period.
|
||||
|
||||
Only the number and the issue date are stored: the money, the tier and the dates are
|
||||
already frozen on the Due, so the PDF is rendered from those snapshots on demand. The
|
||||
number, though, must be stable and gapless — it is the thing an accountant reconciles
|
||||
against, so it is allocated once and never recomputed.
|
||||
"""
|
||||
|
||||
due = models.OneToOneField(Due, on_delete=models.CASCADE, related_name="invoice", verbose_name=_("due"))
|
||||
number = models.CharField(_("number"), max_length=32, unique=True, blank=True)
|
||||
issued_at = models.DateTimeField(_("issued at"), default=timezone.now)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("invoice")
|
||||
verbose_name_plural = _("invoices")
|
||||
ordering = ["-issued_at"]
|
||||
|
||||
def __str__(self):
|
||||
return self.number
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.number:
|
||||
self.number = self.next_number(self.issued_at.year)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def next_number(cls, year: int) -> str:
|
||||
"""INV-2026-00001, restarting each year.
|
||||
|
||||
Platform-wide, unlike the shop's order numbers, which are per club: these are OUR
|
||||
invoices, and one sequence has to cover every club we bill.
|
||||
"""
|
||||
prefix = f"INV-{year}-"
|
||||
last = cls.objects.filter(number__startswith=prefix).order_by("-number").first()
|
||||
sequence = int(last.number.removeprefix(prefix)) + 1 if last else 1
|
||||
|
||||
return f"{prefix}{sequence:05d}"
|
||||
6
billing/services/__init__.py
Normal file
6
billing/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
class BillingError(Exception):
|
||||
"""A billing action that must not silently half-happen.
|
||||
|
||||
Lives here rather than in dues.py so invoices.py can raise it without the two modules
|
||||
importing each other in a circle.
|
||||
"""
|
||||
181
billing/services/dues.py
Normal file
181
billing/services/dues.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""The billing lifecycle. Views and the archive command go through here, never through the
|
||||
models directly — a Due whose amount_paid disagrees with its payments is a wrong invoice.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import DateField, OuterRef, Subquery, Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from billing.models import RENEWAL_LEAD_DAYS, ZERO, Due, DuePayment, Subscription, Tier
|
||||
from billing.services import BillingError
|
||||
from billing.services.invoices import issue_invoice
|
||||
|
||||
|
||||
def subscribe(club, tier: Tier, *, start: date | None = None, auto_archive: bool = True, auto_renew: bool = True) -> Subscription:
|
||||
"""Put a club on a tier and open its first period."""
|
||||
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"tier": tier, "auto_archive": auto_archive, "auto_renew": auto_renew})
|
||||
open_period(club, start=start)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
def next_period_start(club, today: date | None = None) -> date:
|
||||
"""Where the club's next period begins.
|
||||
|
||||
The day after the last one ended — not today. A club that pays two months late has still
|
||||
used those two months, and restarting the clock at the payment date would quietly gift
|
||||
them away. Callers can override; that is what the start field on the renew form is for.
|
||||
"""
|
||||
today = today or timezone.localdate()
|
||||
last = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()
|
||||
|
||||
return last.period_end + timedelta(days=1) if last else today
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def open_period(club, *, start: date | None = None, tier: Tier | None = None) -> Due:
|
||||
"""Issue the next due for a club, snapshotting the tier and the price of the day."""
|
||||
subscription = getattr(club, "subscription", None)
|
||||
tier = tier or (subscription.tier if subscription else None)
|
||||
if tier is None:
|
||||
raise BillingError(f"{club} has no tier: put it on a subscription before billing it.")
|
||||
|
||||
start = start or next_period_start(club)
|
||||
|
||||
amount = tier.price_on(start)
|
||||
if amount is None:
|
||||
raise BillingError(f"{tier} has no price in force on {start:%d %b %Y}. Add one before opening the period.")
|
||||
|
||||
if club.dues.filter(period_start=start).exists():
|
||||
raise BillingError(f"{club} is already billed for a period starting {start:%d %b %Y}.")
|
||||
|
||||
due = Due.objects.create(club=club, tier=tier, amount=amount, period_start=start)
|
||||
issue_invoice(due) # every period is billable the moment it opens
|
||||
|
||||
return due
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def record_payment(due: Due, amount: Decimal, *, method=DuePayment.Method.BANK_TRANSFER, reference: str = "", paid_at=None, note: str = "", user=None) -> DuePayment:
|
||||
"""Log money against a due and re-derive its status from the payments."""
|
||||
if due.status in (Due.Status.WAIVED, Due.Status.CANCELLED):
|
||||
raise BillingError(f"This period is {due.get_status_display()}; it cannot take a payment.")
|
||||
if amount <= ZERO:
|
||||
raise BillingError("A payment must be for a positive amount.")
|
||||
|
||||
payment = DuePayment.objects.create(due=due, amount=amount, method=method, reference=reference, paid_at=paid_at or timezone.now(), note=note, recorded_by=user)
|
||||
_resettle(due)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def remove_payment(payment: DuePayment) -> None:
|
||||
"""Undo a mis-keyed payment, then re-derive the due from what is left."""
|
||||
due = payment.due
|
||||
payment.delete()
|
||||
_resettle(due)
|
||||
|
||||
|
||||
def _resettle(due: Due) -> None:
|
||||
"""Recompute amount_paid and status from the payments on record.
|
||||
|
||||
Summed from the payments rather than incremented: an increment drifts the moment a
|
||||
payment is edited or deleted, and the drift is invisible — the number still looks like
|
||||
money.
|
||||
"""
|
||||
paid = due.payments.aggregate(total=Sum("amount"))["total"] or ZERO
|
||||
|
||||
due.amount_paid = paid
|
||||
if paid >= due.amount:
|
||||
due.status = Due.Status.PAID
|
||||
due.paid_at = due.payments.order_by("-paid_at").first().paid_at
|
||||
elif paid > ZERO:
|
||||
due.status = Due.Status.PARTIAL
|
||||
due.paid_at = None
|
||||
else:
|
||||
due.status = Due.Status.UNPAID
|
||||
due.paid_at = None
|
||||
due.save(update_fields=["amount_paid", "status", "paid_at", "modified"])
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def waive(due: Due, *, note: str = "") -> Due:
|
||||
"""Write a period off. It stops owing, and stops counting towards archiving."""
|
||||
if due.payments.exists():
|
||||
raise BillingError("This period has payments against it; remove them before waiving it.")
|
||||
|
||||
due.status = Due.Status.WAIVED
|
||||
due.save(update_fields=["status", "modified"])
|
||||
|
||||
return due
|
||||
|
||||
|
||||
def owing_dues():
|
||||
return Due.objects.filter(status__in=Due.OWING)
|
||||
|
||||
|
||||
def dues_in_grace(today: date | None = None):
|
||||
"""Period over, unpaid, not yet archivable."""
|
||||
today = today or timezone.localdate()
|
||||
|
||||
return owing_dues().filter(period_end__lt=today, grace_until__gte=today)
|
||||
|
||||
|
||||
def dues_overdue(today: date | None = None):
|
||||
"""Past grace: these are the clubs the archive command would take down."""
|
||||
today = today or timezone.localdate()
|
||||
|
||||
return owing_dues().filter(grace_until__lt=today)
|
||||
|
||||
|
||||
def archivable_clubs(today: date | None = None):
|
||||
"""Clubs the archive command would act on: overdue, still live, and opted in.
|
||||
|
||||
A club with auto_archive off is deliberately spared — that flag is how you keep a club
|
||||
you are negotiating with from being switched off overnight.
|
||||
"""
|
||||
return dues_overdue(today).filter(club__archived_at__isnull=True, club__subscription__auto_archive=True).select_related("club", "tier").order_by("club__name")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def reactivate(club, *, start: date | None = None) -> Due:
|
||||
"""Bring an archived club back and bill it again.
|
||||
|
||||
The new period defaults to continuing from the last one, so a lapsed year is still owed.
|
||||
Pass ``start`` to forgive the gap and begin today instead.
|
||||
"""
|
||||
club.restore()
|
||||
|
||||
return open_period(club, start=start)
|
||||
|
||||
|
||||
def subscriptions_due_for_renewal(today: date | None = None, lead_days: int = RENEWAL_LEAD_DAYS):
|
||||
"""Clubs whose next period should be issued now.
|
||||
|
||||
Idempotent by construction: a club that has just been renewed has a latest period ending a
|
||||
year out, which is past the horizon, so it cannot be picked up twice. Running the job twice
|
||||
a day is harmless.
|
||||
|
||||
A subscription with no period at all (its only due was cancelled) counts too — a club on a
|
||||
plan and billed for nothing is the leak this whole job exists to close.
|
||||
"""
|
||||
today = today or timezone.localdate()
|
||||
horizon = today + timedelta(days=lead_days)
|
||||
|
||||
latest_period_end = Subquery(
|
||||
Due.objects.filter(club=OuterRef("club")).exclude(status=Due.Status.CANCELLED).order_by("-period_end").values("period_end")[:1],
|
||||
output_field=DateField(),
|
||||
)
|
||||
|
||||
subscriptions = Subscription.objects.filter(auto_renew=True, club__archived_at__isnull=True).select_related("club", "tier").annotate(latest_period_end=latest_period_end).order_by("club__name")
|
||||
|
||||
return [subscription for subscription in subscriptions if subscription.latest_period_end is None or subscription.latest_period_end <= horizon]
|
||||
|
||||
|
||||
def renew(subscription: Subscription) -> Due:
|
||||
"""Open the club's next period, continuing from the last one."""
|
||||
return open_period(subscription.club)
|
||||
40
billing/services/invoices.py
Normal file
40
billing/services/invoices.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Invoice PDFs.
|
||||
|
||||
The PDF is rendered on demand from the Due's frozen snapshot (tier, amount, dates), so it
|
||||
carries no state of its own beyond the number. Only the number is stored — an accountant
|
||||
reconciles against it, so it is allocated once, never recomputed.
|
||||
"""
|
||||
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from billing.models import Due, Invoice
|
||||
from billing.services import BillingError
|
||||
|
||||
|
||||
def issue_invoice(due: Due) -> Invoice:
|
||||
"""One invoice per due, allocated once. Re-issuing returns the existing one rather than
|
||||
burning a number — a gap in an invoice series is a question you do not want to answer."""
|
||||
invoice, _created = Invoice.objects.get_or_create(due=due)
|
||||
|
||||
return invoice
|
||||
|
||||
|
||||
def render_pdf(html: str) -> bytes:
|
||||
"""HTML to PDF.
|
||||
|
||||
WeasyPrint is imported here, not at module scope: it binds to native pango/cairo
|
||||
libraries, and a machine without them must still be able to run the app, the tests and
|
||||
every other page — it should only fail when someone actually asks for a PDF, and say why.
|
||||
"""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
except (ImportError, OSError) as error:
|
||||
raise BillingError("PDF rendering needs the native pango/cairo libraries (on macOS: brew install pango).") from error
|
||||
|
||||
return HTML(string=html).write_pdf()
|
||||
|
||||
|
||||
def invoice_pdf(invoice: Invoice, base_url: str | None = None) -> bytes:
|
||||
html = render_to_string("billing/invoice.html", {"invoice": invoice, "due": invoice.due, "club": invoice.due.club, "payments": invoice.due.payments.all()})
|
||||
|
||||
return render_pdf(html)
|
||||
106
billing/templates/billing/invoice.html
Normal file
106
billing/templates/billing/invoice.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{% load static %}
|
||||
|
||||
{% comment %}
|
||||
Rendered by WeasyPrint, not by a browser: this is a standalone document with its own
|
||||
print stylesheet. It deliberately does NOT pull in app.css — daisyUI is built for a
|
||||
screen, and half of it (dark theme, flex layouts) means nothing on paper.
|
||||
{% endcomment %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ invoice.number }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm;
|
||||
@bottom-center {
|
||||
content: "RosterChief — invoice {{ invoice.number }} — page " counter(page) " of " counter(pages);
|
||||
font-size: 8pt;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
body { font-family: sans-serif; font-size: 10pt; color: #111; }
|
||||
h1 { font-size: 20pt; margin: 0 0 2mm; }
|
||||
.muted { color: #666; }
|
||||
.header { display: flex; justify-content: space-between; margin-bottom: 12mm; }
|
||||
.parties { display: flex; justify-content: space-between; margin-bottom: 10mm; }
|
||||
.parties h2 { font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; margin: 0 0 2mm; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
|
||||
th { text-align: left; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; border-bottom: 1px solid #ccc; padding: 2mm 0; }
|
||||
td { padding: 2mm 0; border-bottom: 1px solid #eee; }
|
||||
.right { text-align: right; }
|
||||
.total td { font-weight: bold; border-bottom: 2px solid #111; border-top: 1px solid #111; }
|
||||
.balance { font-size: 12pt; font-weight: bold; }
|
||||
.paid { color: #15803d; }
|
||||
.owed { color: #b91c1c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>RosterChief</h1>
|
||||
<div class="muted">Club & team management</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h1>Invoice</h1>
|
||||
<div><strong>{{ invoice.number }}</strong></div>
|
||||
<div class="muted">Issued {{ invoice.issued_at|date:"j F Y" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="parties">
|
||||
<div>
|
||||
<h2>Billed to</h2>
|
||||
<div><strong>{{ club.name }}</strong></div>
|
||||
<div class="muted">{{ club.slug }}.rosterchief.app</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h2>Period</h2>
|
||||
<div>{{ due.period_start|date:"j F Y" }} — {{ due.period_end|date:"j F Y" }}</div>
|
||||
<div class="muted">Payable by {{ due.grace_until|date:"j F Y" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th class="right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ due.tier.name }}</strong> — platform subscription
|
||||
<div class="muted">{{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }}</div>
|
||||
</td>
|
||||
<td class="right">€{{ due.amount|floatformat:2 }}</td>
|
||||
</tr>
|
||||
{% for payment in payments %}
|
||||
<tr>
|
||||
<td class="muted">
|
||||
Payment received {{ payment.paid_at|date:"j M Y" }} ({{ payment.get_method_display }}{% if payment.reference %}, {{ payment.reference }}{% endif %})
|
||||
</td>
|
||||
<td class="right muted">−€{{ payment.amount|floatformat:2 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="total">
|
||||
<td>Balance due</td>
|
||||
<td class="right balance {% if due.balance > 0 %}owed{% else %}paid{% endif %}">€{{ due.balance|floatformat:2 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if due.status == "paid" %}
|
||||
<p class="paid"><strong>Paid in full.</strong> Thank you.</p>
|
||||
{% elif due.status == "waived" %}
|
||||
<p class="muted"><strong>Waived.</strong> Nothing is owed for this period.</p>
|
||||
{% else %}
|
||||
<p class="muted">
|
||||
Payable by <strong>{{ due.grace_until|date:"j F Y" }}</strong>. Unpaid past that date the club is archived: its
|
||||
subdomain stops resolving, though nothing is deleted.
|
||||
</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
490
billing/tests.py
Normal file
490
billing/tests.py
Normal file
@@ -0,0 +1,490 @@
|
||||
import datetime
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from io import StringIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Club
|
||||
|
||||
from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year
|
||||
from .services import BillingError
|
||||
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, renew, subscribe, subscriptions_due_for_renewal, waive
|
||||
from .services.invoices import invoice_pdf, issue_invoice, render_pdf
|
||||
|
||||
|
||||
class BillingTestBase(TestCase):
|
||||
def setUp(self):
|
||||
self.today = timezone.localdate()
|
||||
self.club = Club.objects.create(name="Ajax United")
|
||||
self.tier = Tier.objects.create(name="Standard")
|
||||
# Priced well back, so a backdated (lapsed) period still has a price in force —
|
||||
# opening one before any price existed is refused, and rightly so.
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
|
||||
|
||||
def bill(self, start=None, club=None):
|
||||
return open_period(club or self.club, start=start, tier=self.tier)
|
||||
|
||||
|
||||
class TierPriceTests(BillingTestBase):
|
||||
def test_the_price_in_force_is_the_latest_one_that_has_started(self):
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("600.00"))
|
||||
|
||||
self.assertEqual(self.tier.price_on(self.today - datetime.timedelta(days=1)), Decimal("500.00"))
|
||||
self.assertEqual(self.tier.price_on(self.today), Decimal("600.00"))
|
||||
|
||||
def test_a_future_price_does_not_apply_yet(self):
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
|
||||
|
||||
self.assertEqual(self.tier.price_on(self.today), Decimal("500.00"))
|
||||
|
||||
def test_a_tier_with_no_price_yet_cannot_be_billed(self):
|
||||
# None must never be read as free.
|
||||
empty = Tier.objects.create(name="Enterprise")
|
||||
|
||||
self.assertIsNone(empty.price_on(self.today))
|
||||
|
||||
with self.assertRaises(BillingError):
|
||||
open_period(self.club, tier=empty)
|
||||
|
||||
|
||||
class PeriodTests(BillingTestBase):
|
||||
def test_a_period_runs_a_rolling_year_with_a_grace_tail(self):
|
||||
due = self.bill(start=datetime.date(2026, 3, 1))
|
||||
|
||||
self.assertEqual(due.period_end, datetime.date(2027, 2, 28))
|
||||
self.assertEqual(due.grace_until, due.period_end + datetime.timedelta(days=GRACE_DAYS))
|
||||
|
||||
def test_a_leap_day_period_does_not_explode(self):
|
||||
# 29 February has no counterpart in a common year.
|
||||
self.assertEqual(add_one_year(datetime.date(2028, 2, 29)), datetime.date(2029, 2, 28))
|
||||
|
||||
def test_the_next_period_continues_from_the_last_one(self):
|
||||
# Not from today: a club that pays two months late has still used those two months,
|
||||
# and restarting the clock at the payment date would quietly gift them away.
|
||||
first = self.bill(start=self.today - datetime.timedelta(days=400))
|
||||
|
||||
self.assertEqual(next_period_start(self.club), first.period_end + datetime.timedelta(days=1))
|
||||
|
||||
def test_a_first_period_starts_today(self):
|
||||
self.assertEqual(next_period_start(self.club), self.today)
|
||||
|
||||
def test_the_amount_is_snapshotted_at_the_price_of_the_day(self):
|
||||
due = self.bill()
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=1), amount=Decimal("900.00"))
|
||||
due.refresh_from_db()
|
||||
|
||||
# Raising the rate must not rewrite what was already billed.
|
||||
self.assertEqual(due.amount, Decimal("500.00"))
|
||||
|
||||
def test_a_club_cannot_be_billed_twice_for_one_period(self):
|
||||
self.bill(start=self.today)
|
||||
|
||||
with self.assertRaises(BillingError):
|
||||
self.bill(start=self.today)
|
||||
|
||||
def test_a_club_with_no_tier_cannot_be_billed(self):
|
||||
with self.assertRaises(BillingError):
|
||||
open_period(Club.objects.create(name="Feyenoord"))
|
||||
|
||||
def test_subscribing_puts_a_club_on_a_tier_and_opens_a_period(self):
|
||||
club = Club.objects.create(name="Feyenoord")
|
||||
|
||||
subscribe(club, self.tier)
|
||||
|
||||
self.assertEqual(Subscription.objects.get(club=club).tier, self.tier)
|
||||
self.assertEqual(club.dues.count(), 1)
|
||||
|
||||
|
||||
class PaymentTests(BillingTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.due = self.bill()
|
||||
|
||||
def test_a_part_payment_leaves_the_due_partially_paid(self):
|
||||
record_payment(self.due, Decimal("200.00"))
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.due.status, Due.Status.PARTIAL)
|
||||
self.assertEqual(self.due.balance, Decimal("300.00"))
|
||||
self.assertIsNone(self.due.paid_at)
|
||||
|
||||
def test_payments_accumulate_until_the_due_is_settled(self):
|
||||
record_payment(self.due, Decimal("200.00"))
|
||||
record_payment(self.due, Decimal("300.00"))
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.due.status, Due.Status.PAID)
|
||||
self.assertEqual(self.due.balance, Decimal("0.00"))
|
||||
self.assertIsNotNone(self.due.paid_at)
|
||||
|
||||
def test_an_overpayment_still_settles_the_due(self):
|
||||
record_payment(self.due, Decimal("600.00"))
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.due.status, Due.Status.PAID)
|
||||
|
||||
def test_removing_a_payment_re_derives_the_due(self):
|
||||
# amount_paid is summed from the payments, never incremented: an increment drifts the
|
||||
# moment one is deleted, and the drift still looks like money.
|
||||
first = record_payment(self.due, Decimal("200.00"))
|
||||
record_payment(self.due, Decimal("300.00"))
|
||||
|
||||
remove_payment(first)
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.due.amount_paid, Decimal("300.00"))
|
||||
self.assertEqual(self.due.status, Due.Status.PARTIAL)
|
||||
|
||||
def test_removing_the_only_payment_puts_the_due_back_to_unpaid(self):
|
||||
payment = record_payment(self.due, Decimal("500.00"))
|
||||
|
||||
remove_payment(payment)
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.due.status, Due.Status.UNPAID)
|
||||
self.assertEqual(self.due.amount_paid, Decimal("0.00"))
|
||||
self.assertIsNone(self.due.paid_at)
|
||||
|
||||
def test_a_zero_payment_is_refused(self):
|
||||
with self.assertRaises(BillingError):
|
||||
record_payment(self.due, Decimal("0.00"))
|
||||
|
||||
def test_a_waived_period_cannot_take_a_payment(self):
|
||||
waive(self.due)
|
||||
|
||||
with self.assertRaises(BillingError):
|
||||
record_payment(self.due, Decimal("100.00"))
|
||||
|
||||
def test_a_period_with_payments_cannot_be_waived(self):
|
||||
record_payment(self.due, Decimal("100.00"))
|
||||
|
||||
with self.assertRaises(BillingError):
|
||||
waive(self.due)
|
||||
|
||||
def test_a_waived_period_owes_nothing_and_never_archives_a_club(self):
|
||||
waive(self.due)
|
||||
self.due.refresh_from_db()
|
||||
|
||||
self.assertFalse(self.due.is_owing)
|
||||
self.assertFalse(self.due.is_overdue(self.due.grace_until + datetime.timedelta(days=1)))
|
||||
|
||||
|
||||
class GraceAndArchiveTests(BillingTestBase):
|
||||
LAPSED = 365 + GRACE_DAYS + 10
|
||||
|
||||
def test_a_period_past_its_end_but_inside_grace_is_in_grace(self):
|
||||
due = self.bill(start=self.today - datetime.timedelta(days=370))
|
||||
|
||||
self.assertTrue(due.is_in_grace(self.today))
|
||||
self.assertFalse(due.is_overdue(self.today))
|
||||
self.assertIn(due, dues_in_grace(self.today))
|
||||
|
||||
def test_a_period_past_grace_is_overdue(self):
|
||||
due = self.bill(start=self.today - datetime.timedelta(days=self.LAPSED))
|
||||
|
||||
self.assertTrue(due.is_overdue(self.today))
|
||||
self.assertFalse(due.is_in_grace(self.today))
|
||||
self.assertIn(due, dues_overdue(self.today))
|
||||
|
||||
def test_a_paid_period_is_never_overdue(self):
|
||||
due = self.bill(start=self.today - datetime.timedelta(days=self.LAPSED))
|
||||
record_payment(due, Decimal("500.00"))
|
||||
due.refresh_from_db()
|
||||
|
||||
self.assertFalse(due.is_overdue(self.today))
|
||||
self.assertNotIn(due, dues_overdue(self.today))
|
||||
|
||||
def test_an_overdue_club_is_archivable(self):
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
|
||||
|
||||
self.assertEqual(archivable_clubs(self.today).count(), 1)
|
||||
|
||||
def test_a_club_that_opted_out_is_never_archived(self):
|
||||
# auto_archive off is how you stop a club you are negotiating with from being
|
||||
# switched off overnight.
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED), auto_archive=False)
|
||||
|
||||
self.assertEqual(archivable_clubs(self.today).count(), 0)
|
||||
|
||||
def test_an_already_archived_club_is_not_archived_again(self):
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
|
||||
self.club.archive()
|
||||
|
||||
self.assertEqual(archivable_clubs(self.today).count(), 0)
|
||||
|
||||
|
||||
class ArchiveCommandTests(BillingTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=365 + GRACE_DAYS + 10))
|
||||
|
||||
def run_command(self, *args):
|
||||
out = StringIO()
|
||||
call_command("archive_overdue_clubs", *args, stdout=out)
|
||||
return out.getvalue()
|
||||
|
||||
def test_it_reports_without_archiving_by_default(self):
|
||||
# The asymmetry is the point: this switches off paying customers, so a cron
|
||||
# misconfiguration or a clock skew must cost an email, not a morning of angry clubs.
|
||||
output = self.run_command()
|
||||
|
||||
self.club.refresh_from_db()
|
||||
self.assertFalse(self.club.is_archived)
|
||||
self.assertIn("Dry run", output)
|
||||
self.assertIn("Ajax United", output)
|
||||
|
||||
def test_it_archives_with_commit(self):
|
||||
self.run_command("--commit")
|
||||
|
||||
self.club.refresh_from_db()
|
||||
self.assertTrue(self.club.is_archived)
|
||||
|
||||
def test_it_says_so_when_nothing_is_overdue(self):
|
||||
record_payment(self.club.dues.first(), Decimal("500.00"))
|
||||
|
||||
self.assertIn("Nothing overdue", self.run_command())
|
||||
|
||||
|
||||
class ReactivationTests(BillingTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Through subscribe(), not open_period(): reactivating reads the club's tier off its
|
||||
# subscription, and a club billed without one cannot be re-billed later.
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
|
||||
self.first = self.club.dues.first()
|
||||
self.club.archive()
|
||||
|
||||
def test_reactivating_continues_from_the_lapsed_period_by_default(self):
|
||||
due = reactivate(self.club)
|
||||
|
||||
self.club.refresh_from_db()
|
||||
self.assertFalse(self.club.is_archived)
|
||||
self.assertEqual(due.period_start, self.first.period_end + datetime.timedelta(days=1))
|
||||
|
||||
def test_a_chosen_start_forgives_the_gap(self):
|
||||
due = reactivate(self.club, start=self.today)
|
||||
|
||||
self.assertEqual(due.period_start, self.today)
|
||||
|
||||
|
||||
class InvoiceTests(BillingTestBase):
|
||||
def test_every_period_is_invoiced_when_it_opens(self):
|
||||
due = self.bill()
|
||||
|
||||
self.assertTrue(Invoice.objects.filter(due=due).exists())
|
||||
|
||||
def test_numbers_run_in_one_platform_wide_series(self):
|
||||
# Unlike the shop's per-club order numbers: these are OUR invoices, and one sequence
|
||||
# covers every club we bill.
|
||||
first = self.bill(start=self.today).invoice
|
||||
second = open_period(Club.objects.create(name="Feyenoord"), tier=self.tier).invoice
|
||||
|
||||
year = timezone.now().year
|
||||
self.assertEqual(first.number, f"INV-{year}-00001")
|
||||
self.assertEqual(second.number, f"INV-{year}-00002")
|
||||
|
||||
def test_re_issuing_does_not_burn_a_number(self):
|
||||
# A gap in an invoice series is a question you do not want to have to answer.
|
||||
due = self.bill()
|
||||
|
||||
self.assertEqual(issue_invoice(due), due.invoice)
|
||||
self.assertEqual(Invoice.objects.count(), 1)
|
||||
|
||||
def test_the_invoice_renders_the_frozen_snapshot(self):
|
||||
due = self.bill()
|
||||
record_payment(due, Decimal("200.00"), reference="TRX-9")
|
||||
due.refresh_from_db()
|
||||
|
||||
with mock.patch("billing.services.invoices.render_pdf", return_value=b"%PDF-fake") as renderer:
|
||||
invoice_pdf(due.invoice)
|
||||
|
||||
html = renderer.call_args.args[0]
|
||||
self.assertIn("INV-", html)
|
||||
self.assertIn("Ajax United", html)
|
||||
self.assertIn("500.00", html) # billed
|
||||
self.assertIn("200.00", html) # paid
|
||||
self.assertIn("300.00", html) # balance
|
||||
|
||||
def test_the_pdf_library_is_only_needed_when_a_pdf_is_asked_for(self):
|
||||
# WeasyPrint binds to native pango/cairo. The app, the tests and every other page must
|
||||
# run without them; only this call may fail.
|
||||
with mock.patch.dict(sys.modules, {"weasyprint": mock.MagicMock()}):
|
||||
sys.modules["weasyprint"].HTML.return_value.write_pdf.return_value = b"%PDF-1.7"
|
||||
|
||||
self.assertEqual(render_pdf("<p>hi</p>"), b"%PDF-1.7")
|
||||
|
||||
def test_a_missing_pdf_library_says_what_is_missing(self):
|
||||
with mock.patch.dict(sys.modules, {"weasyprint": None}), self.assertRaises(BillingError) as caught:
|
||||
render_pdf("<p>hi</p>")
|
||||
|
||||
self.assertIn("pango", str(caught.exception))
|
||||
|
||||
|
||||
class ModelStringTests(BillingTestBase):
|
||||
def test_models_describe_themselves(self):
|
||||
due = self.bill()
|
||||
payment = record_payment(due, Decimal("10.00"))
|
||||
|
||||
self.assertEqual(str(self.tier), "Standard")
|
||||
self.assertIn("500.00", str(self.tier.prices.first()))
|
||||
self.assertIn("Ajax United", str(due))
|
||||
self.assertIn("10.00", str(payment))
|
||||
self.assertIn("INV-", str(due.invoice))
|
||||
self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.tier)))
|
||||
|
||||
|
||||
class RenewalTests(BillingTestBase):
|
||||
"""The leak this closes: a club whose period lapses with its last due PAID owes nothing,
|
||||
so dues_overdue() is empty, so archive_overdue_clubs never fires — and the club keeps
|
||||
using the platform for free while every number on the dashboard stays green."""
|
||||
|
||||
def ending_in(self, days, **kwargs):
|
||||
"""A club whose current period ends `days` from now."""
|
||||
club = Club.objects.create(name=f"Club {days}")
|
||||
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=365 - days), **kwargs)
|
||||
return club
|
||||
|
||||
def test_a_club_nearing_its_end_date_is_picked_up(self):
|
||||
club = self.ending_in(20)
|
||||
|
||||
due = [s.club for s in subscriptions_due_for_renewal()]
|
||||
|
||||
self.assertIn(club, due)
|
||||
|
||||
def test_a_club_with_a_period_beyond_the_horizon_is_left_alone(self):
|
||||
club = self.ending_in(200)
|
||||
|
||||
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||
|
||||
def test_renewing_continues_from_the_last_period(self):
|
||||
club = self.ending_in(20)
|
||||
first = club.dues.first()
|
||||
|
||||
renew(club.subscription)
|
||||
|
||||
latest = club.dues.order_by("-period_start").first()
|
||||
self.assertEqual(latest.period_start, first.period_end + datetime.timedelta(days=1))
|
||||
self.assertEqual(club.dues.count(), 2)
|
||||
|
||||
def test_running_twice_does_not_bill_twice(self):
|
||||
# Idempotent by construction: once renewed, the club's latest period ends a year out,
|
||||
# which is past the horizon.
|
||||
club = self.ending_in(20)
|
||||
|
||||
call_command("renew_subscriptions", stdout=StringIO())
|
||||
call_command("renew_subscriptions", stdout=StringIO())
|
||||
|
||||
self.assertEqual(club.dues.count(), 2)
|
||||
|
||||
def test_a_club_that_opted_out_is_not_renewed(self):
|
||||
club = self.ending_in(20, auto_renew=False)
|
||||
|
||||
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||
|
||||
def test_an_archived_club_is_not_renewed(self):
|
||||
# Reactivation is the way back, and it opens a period of its own.
|
||||
club = self.ending_in(20)
|
||||
club.archive()
|
||||
|
||||
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||
|
||||
def test_the_new_period_is_billed_at_the_price_in_force_then(self):
|
||||
club = self.ending_in(20)
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("900.00"))
|
||||
|
||||
due = renew(club.subscription)
|
||||
|
||||
self.assertEqual(due.amount, Decimal("900.00")) # the new rate
|
||||
self.assertEqual(club.dues.order_by("period_start").first().amount, Decimal("500.00")) # the old one, untouched
|
||||
|
||||
def test_the_new_period_is_invoiced(self):
|
||||
club = self.ending_in(20)
|
||||
|
||||
due = renew(club.subscription)
|
||||
|
||||
self.assertTrue(due.invoice.number.startswith("INV-"))
|
||||
|
||||
def test_a_dry_run_issues_nothing(self):
|
||||
club = self.ending_in(20)
|
||||
out = StringIO()
|
||||
|
||||
call_command("renew_subscriptions", "--dry-run", stdout=out)
|
||||
|
||||
self.assertEqual(club.dues.count(), 1)
|
||||
self.assertIn("would renew", out.getvalue())
|
||||
|
||||
def test_the_command_issues_by_default(self):
|
||||
# The opposite asymmetry to archiving: NOT acting is the expensive failure here,
|
||||
# because a club that is never billed is never chased either.
|
||||
club = self.ending_in(20)
|
||||
|
||||
call_command("renew_subscriptions", stdout=StringIO())
|
||||
|
||||
self.assertEqual(club.dues.count(), 2)
|
||||
|
||||
def test_an_unpriced_tier_fails_loudly_without_stopping_the_others(self):
|
||||
priced = self.ending_in(20)
|
||||
broken = Club.objects.create(name="Unpriced FC")
|
||||
subscribe(broken, self.tier, start=self.today - datetime.timedelta(days=350))
|
||||
# Its next period starts beyond the last price... by removing every price, it cannot bill.
|
||||
TierPrice.objects.all().delete()
|
||||
cheap = Tier.objects.create(name="Cheap")
|
||||
TierPrice.objects.create(tier=cheap, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
|
||||
priced.subscription.tier = cheap
|
||||
priced.subscription.save()
|
||||
|
||||
with self.assertRaises(CommandError):
|
||||
call_command("renew_subscriptions", stdout=StringIO(), stderr=StringIO())
|
||||
|
||||
# ...and the club that COULD be billed still was.
|
||||
self.assertEqual(priced.dues.count(), 2)
|
||||
|
||||
def test_a_subscription_with_no_period_at_all_is_renewed(self):
|
||||
club = Club.objects.create(name="Orphan FC")
|
||||
Subscription.objects.create(club=club, tier=self.tier)
|
||||
|
||||
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||
|
||||
def test_it_says_so_when_there_is_nothing_to_renew(self):
|
||||
self.assertIn("Nothing to renew", self.run_renewal())
|
||||
|
||||
def run_renewal(self, *args):
|
||||
out = StringIO()
|
||||
call_command("renew_subscriptions", *args, stdout=out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
class RenewedButUnpaidTests(BillingTestBase):
|
||||
"""A club auto-renewed that never pays the new fee flows through the ordinary
|
||||
unpaid -> grace -> overdue -> archive path. Renewal creates a normal Due; it does not
|
||||
create a special case, and the safety net that the never-billed club slipped past now
|
||||
fires, because there IS an unpaid due."""
|
||||
|
||||
def lapsed_club(self):
|
||||
"""A club on its first, PAID period — far enough back that a renewal from its end is
|
||||
itself already past grace, so only the renewal's payment state decides the outcome."""
|
||||
club = Club.objects.create(name="Renewed FC")
|
||||
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=800))
|
||||
first = club.dues.first()
|
||||
record_payment(first, first.amount) # the FIRST period is settled; only the renewal is in question
|
||||
return club
|
||||
|
||||
def test_an_unpaid_renewal_becomes_overdue_and_archivable(self):
|
||||
club = self.lapsed_club()
|
||||
renewed = renew(club.subscription) # continues from the first period's end, unpaid
|
||||
|
||||
self.assertTrue(renewed.is_overdue(self.today))
|
||||
self.assertIn(renewed, dues_overdue(self.today))
|
||||
self.assertIn(club, [d.club for d in archivable_clubs(self.today)])
|
||||
|
||||
def test_a_paid_renewal_is_not_chased(self):
|
||||
club = self.lapsed_club()
|
||||
renewed = renew(club.subscription)
|
||||
record_payment(renewed, renewed.amount)
|
||||
|
||||
self.assertNotIn(club, [d.club for d in archivable_clubs(self.today)])
|
||||
55
compose.behind-proxy.yaml
Normal file
55
compose.behind-proxy.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
# A dev/test deployment on a server that ALREADY runs Caddy on :80/:443.
|
||||
#
|
||||
# docker compose -f compose.behind-proxy.yaml up -d
|
||||
#
|
||||
# The difference from compose.yaml is only what listens on the network: no caddy service, and
|
||||
# web publishes on the loopback instead of the public interface. The host's Caddy reverse
|
||||
# proxies to it (see DEPLOYMENT.md, "Behind an existing Caddy").
|
||||
#
|
||||
# Publishing on 127.0.0.1 and not 0.0.0.0 is the point: bound to all interfaces, a test
|
||||
# instance is reachable on http://<server-ip>:8001 with no TLS, bypassing the proxy and every
|
||||
# security header with it.
|
||||
|
||||
name: rosterchief-test
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
ports:
|
||||
- "127.0.0.1:${WEB_PORT:-8001}:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-rosterchief}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-rosterchief}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a database password}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rosterchief}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
72
compose.yaml
Normal file
72
compose.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
# One server. The same image and the same environment variables run a multi-server
|
||||
# deployment: point DJANGO_DATABASE_URL / DJANGO_REDIS_URL at your central services, set a
|
||||
# bucket, drop the `db` and `redis` services, and run several `web` containers behind a load
|
||||
# balancer. Nothing in the code changes.
|
||||
|
||||
name: rosterchief
|
||||
|
||||
services:
|
||||
caddy:
|
||||
build:
|
||||
context: ./deploy/caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
environment:
|
||||
# A wildcard certificate for *.rosterchief.app cannot be issued over HTTP-01 — Let's
|
||||
# Encrypt only does wildcards via DNS-01. That is why Caddy needs a DNS API token, and
|
||||
# why this image is built with the provider's DNS plugin rather than pulled as-is.
|
||||
ROSTERCHIEF_BASE_DOMAIN: ${ROSTERCHIEF_BASE_DOMAIN:?set the base domain, e.g. rosterchief.app}
|
||||
ACME_EMAIL: ${ACME_EMAIL:?set an email for Let's Encrypt}
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:?DNS-01 needs an API token with DNS:Edit on the zone}
|
||||
volumes:
|
||||
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
web:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-rosterchief}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-rosterchief}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a database password}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rosterchief}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||
# Cache only, so nothing here needs to survive a restart. It is not optional though: it
|
||||
# is what keeps every gunicorn worker agreeing about which feature flags are on.
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -1,7 +1,10 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from waffle import get_waffle_flag_model
|
||||
|
||||
from billing.models import DuePayment, Subscription, Tier, TierPrice
|
||||
from club.models import Club
|
||||
|
||||
from .services.admins import find_member_by_email
|
||||
@@ -56,3 +59,59 @@ class FlagForm(forms.ModelForm):
|
||||
help_texts = {
|
||||
"everyone": _("Yes = on for all clubs, No = off everywhere (overrides club targeting). Leave unknown to target clubs."),
|
||||
}
|
||||
|
||||
|
||||
class TierForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Tier
|
||||
fields = ["name", "description", "is_active"]
|
||||
|
||||
|
||||
class TierPriceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TierPrice
|
||||
fields = ["active_from", "amount"]
|
||||
widgets = {"active_from": forms.DateInput(attrs={"type": "date"})}
|
||||
help_texts = {"active_from": _("Periods opening on or after this date are billed at this amount. Existing periods keep the amount they were billed at.")}
|
||||
|
||||
|
||||
class SubscriptionForm(forms.ModelForm):
|
||||
"""Put a club on a tier. The first period opens when the subscription is created."""
|
||||
|
||||
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("First period starts"), help_text=_("Left blank, the period starts today."))
|
||||
|
||||
class Meta:
|
||||
model = Subscription
|
||||
fields = ["tier", "auto_renew", "auto_archive", "notes"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# An inactive tier still bills its existing subscriptions, but must not be picked up
|
||||
# by a new one — which is the whole point of retiring a tier.
|
||||
self.fields["tier"].queryset = Tier.objects.filter(is_active=True)
|
||||
|
||||
|
||||
class DuePaymentForm(forms.Form):
|
||||
amount = forms.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal("0.01"), label=_("Amount"))
|
||||
method = forms.ChoiceField(choices=DuePayment.Method.choices, initial=DuePayment.Method.BANK_TRANSFER, label=_("Method"))
|
||||
reference = forms.CharField(required=False, label=_("Reference"), help_text=_("Bank reference, transaction id — whatever lets you find this again."))
|
||||
paid_at = forms.DateTimeField(required=False, widget=forms.DateTimeInput(attrs={"type": "datetime-local"}), label=_("Received"), help_text=_("Left blank, now."))
|
||||
note = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 2}), label=_("Note"))
|
||||
|
||||
|
||||
class OpenPeriodForm(forms.Form):
|
||||
"""Renew, or reactivate an archived club."""
|
||||
|
||||
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("Period starts"), help_text=_("Left blank, it continues from the end of the last period — so a lapsed year is still owed."))
|
||||
|
||||
|
||||
class MaintenanceForm(forms.Form):
|
||||
"""Closing the platform is a deliberate act, so it takes a sentence explaining itself —
|
||||
that message is the only thing a club will see."""
|
||||
|
||||
message = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 2}),
|
||||
label=_("Message"),
|
||||
help_text=_("Shown to every club while the platform is closed. Left blank, they get a generic notice."),
|
||||
)
|
||||
|
||||
35
controlpanel/messages.py
Normal file
35
controlpanel/messages.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""A compact way to queue a Django message that carries its own title.
|
||||
|
||||
Django's messages framework has no title field — a call site that wants one passes it
|
||||
as ``extra_tags`` (``messages.success(request, body, extra_tags="Club created")``), which
|
||||
reads fine written out but is easy to forget, so in practice every message ends up on
|
||||
the generic per-level heading (`as_alert`'s "Done" / "Careful" / "Something went wrong").
|
||||
|
||||
``notify`` folds level, title and body into one string instead: ``"<level>|<title>|<body>"``.
|
||||
One call, title included, nothing to forget. `as_alert` (controlpanel/templatetags/ui.py)
|
||||
reads the title back off ``extra_tags`` at render time — unchanged from before.
|
||||
"""
|
||||
|
||||
from django.contrib import messages
|
||||
|
||||
#: One letter per Django message level. `notify` picks the level from the spec string;
|
||||
#: `as_alert` picks the icon/colour/fallback-title from the level the message actually
|
||||
#: carries (via ``level_tag``), so the two stay in step by construction.
|
||||
LEVELS = {
|
||||
"s": messages.SUCCESS,
|
||||
"i": messages.INFO,
|
||||
"w": messages.WARNING,
|
||||
"e": messages.ERROR,
|
||||
"d": messages.DEBUG,
|
||||
}
|
||||
|
||||
|
||||
def notify(request, spec: str, **kwargs) -> None:
|
||||
"""Queue a message from a ``"<level>|<title>|<body>"`` spec.
|
||||
|
||||
``level`` is one of ``s`` (success), ``i`` (info), ``w`` (warning), ``e`` (error),
|
||||
``d`` (debug). An empty title (``"s||Body text"``) falls back to the generic
|
||||
per-level heading, same as never passing ``extra_tags`` at all.
|
||||
"""
|
||||
level_code, title, body = spec.split("|", 2)
|
||||
messages.add_message(request, LEVELS[level_code], body, extra_tags=title, **kwargs)
|
||||
@@ -1,5 +1,8 @@
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.http import Http404
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from .messages import notify
|
||||
|
||||
|
||||
class PlatformStaffRequiredMixin(UserPassesTestMixin):
|
||||
@@ -38,3 +41,21 @@ class PlatformSuperuserRequiredMixin(PlatformStaffRequiredMixin):
|
||||
|
||||
def test_func(self):
|
||||
return self.request.user.is_superuser
|
||||
|
||||
|
||||
class RedirectOnInvalidMixin:
|
||||
"""A form submitted from a modal has nowhere sensible to re-render on error: the page
|
||||
that opened it has already moved on, and the view has no standalone template of its
|
||||
own. Redirect back to ``invalid_redirect_url_name`` instead, with the errors flattened
|
||||
into messages, rather than Django's default of re-rendering ``template_name``.
|
||||
"""
|
||||
|
||||
invalid_redirect_url_name = None
|
||||
|
||||
def get_invalid_redirect_kwargs(self):
|
||||
return {}
|
||||
|
||||
def form_invalid(self, form):
|
||||
for error in form.errors.values():
|
||||
notify(self.request, f"e|Couldn't save|{' '.join(error)}")
|
||||
return redirect(self.invalid_redirect_url_name, **self.get_invalid_redirect_kwargs())
|
||||
|
||||
@@ -11,12 +11,14 @@ from decimal import Decimal
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
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.utils import timezone
|
||||
from waffle import get_waffle_flag_model
|
||||
|
||||
from authentication.middleware import ELEVATED_ROLES
|
||||
from billing.models import Due, DuePayment, Subscription
|
||||
from billing.services.dues import dues_in_grace, dues_overdue, subscriptions_due_for_renewal
|
||||
from club.models import Club, ClubMembership, ClubRole, Season
|
||||
from events.models import Attendance, Event
|
||||
from members.models import Member
|
||||
@@ -62,6 +64,8 @@ def clubs_with_health(queryset=None, today=None, now=None):
|
||||
clubs = Club.objects.active() if queryset is None else queryset
|
||||
|
||||
in_season = Q(season__start_date__lte=today, season__end_date__gte=today)
|
||||
# A period the club is covered for, most recent first — paid or waived, both settled.
|
||||
_covered = Due.objects.filter(club=OuterRef("pk"), status__in=(Due.Status.PAID, Due.Status.WAIVED)).order_by("-period_end")
|
||||
managed_this_season = Q(
|
||||
staff_assignments__season__start_date__lte=today,
|
||||
staff_assignments__season__end_date__gte=today,
|
||||
@@ -78,6 +82,17 @@ def clubs_with_health(queryset=None, today=None, now=None):
|
||||
team_count=_subquery(Team.objects.all(), Count("pk"), IntegerField()),
|
||||
teams_managed=_subquery(Team.objects.filter(managed_this_season), Count("pk", distinct=True), IntegerField()),
|
||||
admin_count=_subquery(ClubRole.objects.filter(role=ClubRole.Roles.ADMIN), Count("pk"), IntegerField()),
|
||||
tier_name=Subquery(Subscription.objects.filter(club=OuterRef("pk")).values("tier__name")[:1]),
|
||||
dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)),
|
||||
dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]),
|
||||
dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]),
|
||||
# How far the club is covered: the furthest-out period that is settled. PAID and
|
||||
# WAIVED both mean nothing is owed for that period, and its end is the day grace
|
||||
# would start if nothing renews — so both count. `covered_status` is read from the
|
||||
# same top row, so the table can badge "paid" vs "waived". Null when the club owes
|
||||
# or was never billed.
|
||||
covered_until=Subquery(_covered.values("period_end")[:1], output_field=DateField()),
|
||||
covered_status=Subquery(_covered.values("status")[:1]),
|
||||
)
|
||||
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
|
||||
.order_by("name")
|
||||
@@ -135,11 +150,25 @@ def onboarding_funnel():
|
||||
return [
|
||||
{"label": "Clubs", "count": total, "icon": "building-2"},
|
||||
{"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
|
||||
{"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "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"},
|
||||
]
|
||||
|
||||
|
||||
def flags_for_club(club):
|
||||
"""Every flag, annotated with whether it is on for this club and why."""
|
||||
enabled_ids = set(club.flags.values_list("pk", flat=True))
|
||||
return [
|
||||
{
|
||||
"flag": flag,
|
||||
"enabled": flag.pk in enabled_ids,
|
||||
# `everyone` overrides club targeting, so the per-club toggle is moot.
|
||||
"overridden": flag.everyone is not None,
|
||||
}
|
||||
for flag in get_waffle_flag_model().objects.order_by("name")
|
||||
]
|
||||
|
||||
|
||||
def flag_adoption():
|
||||
"""Clubs per feature flag. `everyone` overrides club targeting, so a flag set that
|
||||
way is on (or off) everywhere and its club count says nothing — hence `overridden`."""
|
||||
@@ -160,9 +189,26 @@ def platform_attention():
|
||||
"outstanding": _money(Order.objects.filter(status__in=OWED_STATUSES)),
|
||||
"members_without_login": Member.objects.filter(user__isnull=True).count(),
|
||||
"members": members,
|
||||
# Platform billing: what the clubs owe US. Distinct from `outstanding`, which is
|
||||
# what members owe their clubs — that money is never ours.
|
||||
"dues_owed": _dues_owed(),
|
||||
"dues_in_grace": dues_in_grace().count(),
|
||||
"dues_overdue": dues_overdue().count(),
|
||||
"clubs_unbilled": Club.objects.active().filter(subscription__isnull=True).count(),
|
||||
# Normally ~0: the renewal job keeps it there. A number that sits here means cron is
|
||||
# dead, and a club is about to use the platform for free — silently, because nothing is
|
||||
# owed, so no other number on this page would go red.
|
||||
"renewals_pending": len(subscriptions_due_for_renewal()),
|
||||
}
|
||||
|
||||
|
||||
def _dues_owed():
|
||||
"""What clubs owe the platform right now."""
|
||||
owed = Due.objects.filter(status__in=Due.OWING).aggregate(total=Sum(F("amount") - F("amount_paid")))["total"]
|
||||
|
||||
return owed or ZERO
|
||||
|
||||
|
||||
def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY):
|
||||
"""A dense month-by-month series — zero-filled, because a chart that silently skips
|
||||
empty months draws a smooth line over a month where nothing happened."""
|
||||
@@ -183,7 +229,11 @@ def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY):
|
||||
def platform_charts():
|
||||
return {
|
||||
"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")),
|
||||
}
|
||||
|
||||
|
||||
|
||||
49
controlpanel/templates/controlpanel/_club_admins_card.html
Normal file
49
controlpanel/templates/controlpanel/_club_admins_card.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% load lucide ui %}
|
||||
|
||||
{% comment %}
|
||||
Club-scoped admins, and the modals to add one / confirm removing one. Included with
|
||||
`club`, `admins`, `admin_form` already in context.
|
||||
{% endcomment %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-base">{% lucide "shield-user" size=18 %} Club admins</h2>
|
||||
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('club_admin_add_modal').showModal()">{% lucide "user-plus" size=16 %} Add admin</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for role in admins %}
|
||||
<tr>
|
||||
<td>{{ role.member }}</td>
|
||||
<td>{{ role.member.user.email|default:"—" }}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ role.pk|dom_id:"admin_remove_modal" }}').showModal()">{% lucide "trash-2" size=14 %} Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center opacity-60">No admins yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% url 'controlpanel:club_admin_add' club.pk as club_admin_add_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="club_admin_add_modal" title="Add admin" form=admin_form action_url=club_admin_add_url submit_label="Grant admin" submit_icon="user-plus" blurb="A club admin can manage everything in this club. They will be required to set up two-factor authentication before they can sign in." %}
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for role in admins %}
|
||||
{% url 'controlpanel:club_admin_remove' club.pk role.pk as admin_remove_url %}
|
||||
{% include "controlpanel/_confirm_modal.html" with modal_id=role.pk|dom_id:"admin_remove_modal" title="Remove admin" body="Remove "|add:role.member.get_full_name|add:" as an admin of this club? They keep their membership — only admin rights are revoked." action_url=admin_remove_url submit_label="Remove" %}
|
||||
{% endfor %}
|
||||
124
controlpanel/templates/controlpanel/_club_billing_card.html
Normal file
124
controlpanel/templates/controlpanel/_club_billing_card.html
Normal file
@@ -0,0 +1,124 @@
|
||||
{% load lucide ui %}
|
||||
|
||||
{% comment %}
|
||||
What the platform bills this club: plan, periods, and the modals for changing plan,
|
||||
opening a period, and recording a payment. Included with `club`, `subscription`,
|
||||
`dues`, `today`, `subscription_form`, `open_period_form`, `open_period_blurb` already
|
||||
in context.
|
||||
{% endcomment %}
|
||||
<div class="card mb-6 bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
|
||||
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
|
||||
</button>
|
||||
{% if subscription %}
|
||||
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('open_period_modal').showModal()">
|
||||
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not subscription %}
|
||||
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a tier to start.</p>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70">
|
||||
On plan <strong>{{ subscription.tier.name }}</strong>.
|
||||
{% if subscription.auto_renew %}
|
||||
Renews automatically 30 days before the period ends.
|
||||
{% else %}
|
||||
<span class="badge badge-warning badge-sm">Auto-renew off</span> — you must open each period by hand, or this club uses the platform for free.
|
||||
{% endif %}
|
||||
{% if subscription.auto_archive %}
|
||||
Archived automatically when a period goes unpaid past its grace period.
|
||||
{% else %}
|
||||
<span class="badge badge-warning badge-sm">Auto-archive off</span> — it will never be archived for non-payment.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th class="text-right">Billed</th>
|
||||
<th class="text-right">Paid</th>
|
||||
<th class="text-right">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for due in dues %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
|
||||
<div class="text-xs opacity-60">{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div>
|
||||
</td>
|
||||
<td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td>
|
||||
<td class="text-right tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td>
|
||||
<td class="text-right">
|
||||
{% if due.status == "paid" %}
|
||||
<span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span>
|
||||
{% elif due.status == "waived" %}
|
||||
<span class="badge badge-outline gap-1">{% lucide "check" size=12 %} Waived</span>
|
||||
{% elif due.grace_until < today %}
|
||||
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
|
||||
{% elif due.period_end < today %}
|
||||
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
|
||||
{% else %}
|
||||
<span class="badge badge-outline">{{ due.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flex flex-row gap-2 justify-end">
|
||||
{% if due.is_owing %}
|
||||
<button class="btn btn-primary btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Add payment</button>
|
||||
{% if not due.payments.all %}
|
||||
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-outline btn-sm gap-1" type="submit">{% lucide "ban" size=14 %} Waive payment</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% for payment in due.payments.all %}
|
||||
<tr class="text-xs opacity-70">
|
||||
<td colspan="2" class="pl-8">
|
||||
{% lucide "corner-down-right" size=12 %}
|
||||
{{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %}
|
||||
</td>
|
||||
<td class="text-right tabular-nums">€{{ payment.amount|floatformat:2 }}</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center opacity-60">No periods billed yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for due in dues %}
|
||||
{% if due.is_owing %}
|
||||
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment" form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% url 'controlpanel:club_subscribe' club.pk as subscribe_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="subscription_modal" title=subscription|yesno:"Change plan,Start billing" form=subscription_form action_url=subscribe_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %}
|
||||
|
||||
{% if subscription %}
|
||||
{% url 'controlpanel:club_open_period' club.pk as open_period_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="open_period_modal" title=club.is_archived|yesno:"Reactivate,Open period" form=open_period_form action_url=open_period_url submit_label="Open period" submit_icon="calendar-plus" blurb=open_period_blurb %}
|
||||
{% endif %}
|
||||
45
controlpanel/templates/controlpanel/_club_features_card.html
Normal file
45
controlpanel/templates/controlpanel/_club_features_card.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{% load lucide %}
|
||||
|
||||
{% comment %}
|
||||
Which feature flags apply to this club. Included with `club`, `flags` (from
|
||||
`flags_for_club`) already in context.
|
||||
{% endcomment %}
|
||||
<div class="card mb-6 bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
{% for entry in flags %}
|
||||
<tr>
|
||||
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
|
||||
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
|
||||
<td class="text-right">
|
||||
{% if entry.overridden %}
|
||||
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
|
||||
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
|
||||
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
||||
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td class="text-center opacity-60">No features defined yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
135
controlpanel/templates/controlpanel/_club_health_table.html
Normal file
135
controlpanel/templates/controlpanel/_club_health_table.html
Normal file
@@ -0,0 +1,135 @@
|
||||
{% load lucide %}
|
||||
|
||||
{% comment %}
|
||||
The club table, shared by the dashboard and the clubs list so the two cannot drift apart.
|
||||
Health, not vanity: a member total says nothing you can act on, while "no coach",
|
||||
"nothing scheduled", "€ owed" and "no admins" each name something somebody has to go and
|
||||
fix. Every column is annotated by clubs_with_health() in a single query.
|
||||
|
||||
Expects: clubs (from clubs_with_health), and optionally empty_message.
|
||||
{% endcomment %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Club</th>
|
||||
<th></th>
|
||||
<th class="text-right">Members</th>
|
||||
<th class="text-right">Admins</th>
|
||||
<th class="text-right">Teams</th>
|
||||
<th class="text-right">Events</th>
|
||||
<th class="text-right">Plan</th>
|
||||
<th class="text-right">Dues</th>
|
||||
<th class="text-right">Plan end</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for club in clubs %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<div>
|
||||
{% if club.logo %}
|
||||
<img class="h-12 w-12 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
|
||||
{% else %}
|
||||
<div class="avatar avatar-placeholder">
|
||||
<div class="w-12 rounded-full bg-neutral text-neutral-content">
|
||||
<span>{{ club.initials }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<a class="link link-hover font-semibold tracking-wide" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
|
||||
<div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="flex flex-row gap-2">
|
||||
{% if club.is_archived %}
|
||||
<span class="badge badge-warning">{% lucide "archive" size=14 %} archived</span>
|
||||
{% else %}
|
||||
{% if not club.has_season %}
|
||||
<span class="badge badge-warning">{% lucide "calendar-x" size=14 %} no seasons</span>
|
||||
{% endif %}
|
||||
|
||||
{% if not club.upcoming_events %}
|
||||
<span class="badge badge-ghost badge-outline">{% lucide "moon-star" size=14 %}dormant</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="text-right tabular-nums">{{ club.active_members }}</td>
|
||||
<td class="text-right tabular-nums">
|
||||
<div class="flex flex-row gap-2 items-center justify-end">
|
||||
{% if not club.admin_count %}
|
||||
<span class="text-error">{% lucide "triangle-alert" size=16 %}</span>
|
||||
{% endif %}
|
||||
<span class="{% if not club.admin_count %}font-bold text-error{% endif %}">{{ club.admin_count }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right tabular-nums">{{ club.team_count }}</td>
|
||||
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
|
||||
|
||||
<td class="text-right">
|
||||
{% if club.tier_name %}
|
||||
<span class="badge badge-accent">{{ club.tier_name|lower }}</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="text-right">
|
||||
<div class="flex flex-row gap-2 items-center justify-end">
|
||||
{% if not club.dues_owed %}
|
||||
{% if club.tier_name %}
|
||||
{% comment %}
|
||||
Not owing and on a plan. covered_until is the settled period's end — the day
|
||||
grace would start if nothing renews — shown on its own row under the badge,
|
||||
for a paid period AND a waived one (both cover the club, they just differ in
|
||||
how). No covered period at all (only cancelled dues, say) shows a dash.
|
||||
{% endcomment %}
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
{% if club.covered_status == "waived" %}
|
||||
<span class="badge badge-ghost badge-outline">waived</span>
|
||||
{% elif club.covered_until %}
|
||||
<span class="badge badge-success">paid</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
|
||||
{% if club.dues_grace_until < today %}
|
||||
<span class="badge badge-error">overdue</span>
|
||||
{% elif club.dues_period_end < today %}
|
||||
<span class="badge badge-warning">grace</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="text-right">
|
||||
<span class="whitespace-nowrap">{{ club.covered_until|date:"j M Y"|default:"-" }}</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="9" class="text-center opacity-60">{{ empty_message|default:"No clubs yet." }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
28
controlpanel/templates/controlpanel/_confirm_modal.html
Normal file
28
controlpanel/templates/controlpanel/_confirm_modal.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% load lucide %}
|
||||
|
||||
{% comment %}
|
||||
A daisyUI native <dialog> confirmation modal for a destructive POST action with no
|
||||
fields of its own. Included with `modal_id`, `title`, `body`, `action_url`, and
|
||||
optional `submit_label` (default "Confirm"), `submit_icon` (default "trash-2"). The
|
||||
submit button sits outside the form tag (linked via the `form` attribute), same as
|
||||
`_modal_form.html`, so it can share the `modal-action` row with the dialog-closing
|
||||
Cancel button without nesting one <form> inside another.
|
||||
{% endcomment %}
|
||||
<dialog id="{{ modal_id }}" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{{ title }}</h3>
|
||||
<p class="py-2 text-sm opacity-70">{{ body }}</p>
|
||||
<form method="post" action="{{ action_url }}" id="{{ modal_id }}-form">
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} Cancel</button>
|
||||
</form>
|
||||
<button class="btn btn-error gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"trash-2" size=16 %} {{ submit_label|default:"Confirm" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
29
controlpanel/templates/controlpanel/_form_fields.html
Normal file
29
controlpanel/templates/controlpanel/_form_fields.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% load ui %}
|
||||
|
||||
{% comment %}
|
||||
The field loop every card-form and modal-form wrapper shares: label, daisyUI-styled
|
||||
widget, help text, errors — with checkboxes laid out label-beside-input instead of
|
||||
label-above. Included with `form`.
|
||||
{% endcomment %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="alert alert-error my-2">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for field in form %}
|
||||
<div class="form-control my-3 w-full">
|
||||
{% if field.field.widget.input_type == "checkbox" %}
|
||||
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
|
||||
{{ field|daisy }}
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<label class="label" for="{{ field.id_for_label }}">
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{{ field|daisy }}
|
||||
{% endif %}
|
||||
{% if field.help_text %}<span class="label-text-alt mt-1 text-xs block text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% for error in field.errors %}<span class="label-text-alt text-xs mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
28
controlpanel/templates/controlpanel/_modal_form.html
Normal file
28
controlpanel/templates/controlpanel/_modal_form.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% load lucide %}
|
||||
|
||||
{% comment %}
|
||||
A daisyUI native <dialog> modal wrapping a Django form that posts straight to
|
||||
`action_url`. Included with `modal_id`, `title`, `form`, `action_url`, `submit_label`
|
||||
and `submit_icon`, plus an optional `blurb`. The submit button sits outside the form
|
||||
tag (linked via the `form` attribute) so it can share the `modal-action` row with the
|
||||
dialog-closing Cancel button without nesting one <form> inside another.
|
||||
{% endcomment %}
|
||||
<dialog id="{{ modal_id }}" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{{ title }}</h3>
|
||||
{% if blurb %}<p class="py-2 text-sm opacity-70">{{ blurb }}</p>{% endif %}
|
||||
<form method="post" action="{{ action_url }}" id="{{ modal_id }}-form">
|
||||
{% csrf_token %}
|
||||
{% include "controlpanel/_form_fields.html" %}
|
||||
</form>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} Cancel</button>
|
||||
</form>
|
||||
<button class="btn btn-primary gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:"Save" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
37
controlpanel/templates/controlpanel/_nav_items.html
Normal file
37
controlpanel/templates/controlpanel/_nav_items.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% load lucide %}
|
||||
|
||||
{% comment %}
|
||||
The panel's navigation, in one place: the sidebar renders it on a wide screen and the
|
||||
collapsed menu renders it on a narrow one. Two copies of a link list is how a new section
|
||||
ends up reachable on a desktop and invisible on a phone.
|
||||
|
||||
`menu-active` is daisyUI 5's active state; hover and focus come with `.menu` itself.
|
||||
{% endcomment %}
|
||||
<li>
|
||||
<a class="{% if nav == 'dashboard' %}menu-active{% endif %}" href="{% url 'controlpanel:dashboard' %}">
|
||||
{% lucide "layout-dashboard" size=16 %} Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="{% if nav == 'clubs' %}menu-active{% endif %}" href="{% url 'controlpanel:club_list' %}">
|
||||
{% lucide "building-2" size=16 %} Clubs
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="{% if nav == 'billing' %}menu-active{% endif %}" href="{% url 'controlpanel:billing' %}">
|
||||
{% lucide "receipt-euro" size=16 %} Billing
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="{% if nav == 'features' %}menu-active{% endif %}" href="{% url 'controlpanel:features' %}">
|
||||
{% lucide "toggle-right" size=16 %} Features
|
||||
</a>
|
||||
</li>
|
||||
{% if user.is_superuser %}
|
||||
{# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #}
|
||||
<li>
|
||||
<a class="{% if nav == 'admins' %}menu-active{% endif %}" href="{% url 'controlpanel:admins' %}">
|
||||
{% lucide "user-cog" size=16 %} Platform admins
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -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 %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide %}
|
||||
{% load lucide ui %}
|
||||
|
||||
{% block heading %}Platform admins{% endblock heading %}
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
{% endblock subheading %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:admin_add' as admin_add_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="admin_add_modal" title="Grant platform access" form=admin_form action_url=admin_add_url submit_label="Grant access" submit_icon="user-plus" blurb="Platform admins can manage every club. They must set up two-factor authentication before they can sign in." %}
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="overflow-x-auto">
|
||||
@@ -30,15 +33,16 @@
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-medium">{{ admin.email }}</div>
|
||||
{% if admin.pk == user.pk %}<div class="text-xs opacity-60">That's you</div>{% endif %}
|
||||
{% if admin.pk == user.pk %}
|
||||
<div class="text-xs opacity-60">That's you</div>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}0{% else %}1{% endif %}">
|
||||
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}1{% else %}0{% endif %}">
|
||||
<button class="btn btn-xs gap-1 {% if admin.is_staff %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
||||
{% if admin.is_staff %}{% lucide "check" size=14 %} Yes{% else %}No{% endif %}
|
||||
<button class="btn btn-sm gap-1 {% if admin.is_staff %}btn-success{% else %}btn-outline{% endif %}" type="submit">
|
||||
{% if admin.is_staff %}{% lucide "user" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
@@ -47,17 +51,14 @@
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}1{% else %}0{% endif %}">
|
||||
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}0{% else %}1{% endif %}">
|
||||
<button class="btn btn-xs gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-ghost{% endif %}" type="submit">
|
||||
{% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}No{% endif %}
|
||||
<button class="btn btn-sm gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-outline{% endif %}" type="submit">
|
||||
{% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td>
|
||||
<td class="text-right">
|
||||
<form method="post" action="{% url 'controlpanel:admin_revoke' admin.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-ghost btn-xs gap-1 text-error" type="submit">{% lucide "user-minus" size=14 %} Revoke</button>
|
||||
</form>
|
||||
<button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ admin.pk|dom_id:"admin_revoke_modal" }}').showModal()">{% lucide "user-minus" size=14 %} Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
@@ -68,6 +69,12 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for admin in admins %}
|
||||
{% url 'controlpanel:admin_revoke' admin.pk as admin_revoke_url %}
|
||||
{% include "controlpanel/_confirm_modal.html" with modal_id=admin.pk|dom_id:"admin_revoke_modal" title="Revoke platform access" body="Revoke platform access for "|add:admin.email|add:"? They will no longer be able to reach the control panel." action_url=admin_revoke_url submit_label="Revoke" submit_icon="user-minus" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
|
||||
@@ -5,9 +5,33 @@
|
||||
{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
|
||||
{% endblock title %}
|
||||
|
||||
{% block menu %}
|
||||
{% comment %}
|
||||
Outside <main>, so it never scrolls with the content. Its own overflow-y-auto is for
|
||||
the day the menu itself grows taller than the screen.
|
||||
{% endcomment %}
|
||||
<aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block">
|
||||
<ul class="menu w-full gap-1 p-3 mt-4">
|
||||
{% include "controlpanel/_nav_items.html" %}
|
||||
</ul>
|
||||
</aside>
|
||||
{% endblock menu %}
|
||||
|
||||
{% block main %}
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
{% if maintenance_on %}
|
||||
<div class="alert alert-error mb-6">
|
||||
{% lucide "wrench" size=20 %}
|
||||
<span>
|
||||
<strong>The platform is currently closed for maintenance.</strong>
|
||||
Clubs see a maintenance page and the scheduled jobs are standing down.
|
||||
</span>
|
||||
<a class="btn btn-sm gap-2 btn-error btn-soft" href="{% url 'controlpanel:features' %}">{% lucide "unlock" size=16 %} Reopen platform</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-6 flex flex-wrap flex-row items-center justify-between gap-3">
|
||||
{% block logo %}{% endblock logo %}
|
||||
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<h1 class="text-3xl font-bold">
|
||||
{% block heading %}Control panel{% endblock heading %}
|
||||
</h1>
|
||||
@@ -19,13 +43,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit">
|
||||
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab gap-2 {% if nav == 'dashboard' %}tab-active{% endif %}">{% lucide "layout-dashboard" size=16 %} Dashboard</a>
|
||||
<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>
|
||||
<a role="tab" href="{% url 'controlpanel:features' %}" class="tab gap-2 {% if nav == 'features' %}tab-active{% endif %}">{% lucide "toggle-right" size=16 %} Features</a>
|
||||
{% 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>
|
||||
{# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #}
|
||||
<ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden">
|
||||
{% include "controlpanel/_nav_items.html" %}
|
||||
</ul>
|
||||
|
||||
{% block panel %}{% endblock panel %}
|
||||
{% endblock main %}
|
||||
|
||||
135
controlpanel/templates/controlpanel/billing.html
Normal file
135
controlpanel/templates/controlpanel/billing.html
Normal file
@@ -0,0 +1,135 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide ui %}
|
||||
|
||||
{% block heading %}Billing{% endblock heading %}
|
||||
|
||||
{% block actions %}
|
||||
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('tier_create_modal').showModal()">{% lucide "plus" size=16 %} New plan</button>
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:tier_create' as tier_create_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="tier_create_modal" title="New plan" form=tier_form action_url=tier_create_url submit_label="Create plan" submit_icon="plus" %}
|
||||
|
||||
<div class="card mb-6 bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Plans</h2>
|
||||
{% comment %}
|
||||
Prices are dated, not edited. A rate change is a new row with a future
|
||||
active_from; every period already opened keeps the amount it was billed at,
|
||||
so raising the price cannot rewrite an invoice you have already sent.
|
||||
{% endcomment %}
|
||||
<p class="text-sm opacity-70">A rate change only takes effect as of a certain date. Periods already billed keep the amount they were issued at.</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plan</th>
|
||||
<th class="text-right">Clubs</th>
|
||||
<th>Prices</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tier in tiers %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-medium">{{ tier.name }}</div>
|
||||
{% if not tier.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %}
|
||||
{% if tier.description %}
|
||||
<div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="text-right tabular-nums">{{ tier.club_count }}</td>
|
||||
<td>
|
||||
{% for price in tier.prices.all %}
|
||||
<div class="text-sm tabular-nums">
|
||||
€{{ price.amount|floatformat:2 }}
|
||||
<span class="opacity-60">from {{ price.active_from|date:"j M Y" }}</span>
|
||||
{% if price.active_from > today %}<span class="badge badge-info badge-xs">Scheduled</span>{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<span class="badge badge-error badge-sm">No price — cannot be billed</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="flex flex-row gap-2 justify-end">
|
||||
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
|
||||
<button class="btn btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center opacity-60">No plans yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for tier in tiers %}
|
||||
{% url 'controlpanel:tier_price_create' tier.pk as tier_price_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_price_modal" title="New price — "|add:tier.name form=tier.price_form action_url=tier_price_url submit_label="Add price" submit_icon="euro" %}
|
||||
|
||||
{% url 'controlpanel:tier_update' tier.pk as tier_update_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_edit_modal" title="Edit "|add:tier.name form=tier.edit_form action_url=tier_update_url submit_label="Save" submit_icon="check" %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Club</th>
|
||||
<th>Period</th>
|
||||
<th class="text-right">Owed</th>
|
||||
<th class="text-right">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for due in owing %}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' due.club.pk %}">{{ due.club.name }}</a>
|
||||
<div class="text-xs opacity-60">{{ due.tier.name }}</div>
|
||||
</td>
|
||||
<td class="text-sm">
|
||||
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
|
||||
<div class="text-xs opacity-60">Grace to {{ due.grace_until|date:"j M Y" }}</div>
|
||||
</td>
|
||||
<td class="text-right font-semibold tabular-nums">€{{ due.balance|floatformat:2 }}</td>
|
||||
<td class="text-right">
|
||||
{% if due.grace_until < today %}
|
||||
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
|
||||
{% elif due.period_end < today %}
|
||||
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
|
||||
{% else %}
|
||||
<span class="badge badge-outline">{{ due.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flex flex-row gap-2 justify-end">
|
||||
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Record payment</button>
|
||||
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center opacity-60">Nothing outstanding.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for due in owing %}
|
||||
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment — "|add:due.club.name form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
|
||||
{% endfor %}
|
||||
{% endblock panel %}
|
||||
@@ -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 %}
|
||||
@@ -1,19 +1,30 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load static lucide %}
|
||||
|
||||
{% block logo %}
|
||||
{% if club.logo %}
|
||||
<img class="h-16 w-16 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
|
||||
{% else %}
|
||||
<div class="avatar avatar-placeholder">
|
||||
<div class="w-16 text-xl rounded-full bg-neutral text-neutral-content">
|
||||
<span>{{ club.initials }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock logo %}
|
||||
|
||||
{% block heading %}{{ club.name }}{% endblock heading %}
|
||||
|
||||
{% block subheading %}
|
||||
<p class="text-sm opacity-70">
|
||||
{{ club.slug }}
|
||||
{% if club.is_archived %}
|
||||
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{{ club.slug }}.rosterchief.app
|
||||
{% if club.is_archived %}
|
||||
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
||||
{% endif %}
|
||||
{% endblock subheading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-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 %}
|
||||
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
|
||||
{% csrf_token %}
|
||||
@@ -30,6 +41,7 @@
|
||||
{% block panel %}
|
||||
{% if club.is_archived %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
{% lucide "alert-triangle" size=20 %}
|
||||
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -47,96 +59,91 @@
|
||||
the club's setup, not a statistic: with nobody in a management position the access
|
||||
service grants no authority over that team, so nobody can pick the squad.
|
||||
{% endcomment %}
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||
{% comment %}<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
|
||||
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div>
|
||||
<div class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% if attention.teams_without_manager %}border-l-4 border-error{% endif %}">
|
||||
</div>{% endcomment %}
|
||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-x" size=16 %} No coach</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.teams_without_manager }}</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-4xl font-bold tabular-nums font-mono">{{ attention.teams_without_manager }}</div>
|
||||
<div class="text-xs opacity-60">Teams nobody can pick a squad for</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% 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="flex items-center gap-2 text-sm opacity-70">{% lucide "user-minus" size=16 %} Unrostered</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.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-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div>
|
||||
<div class="text-xs opacity-60">Active members on no team</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% 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="flex items-center gap-2 text-sm opacity-70">{% lucide "clock" size=16 %} Pending</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.pending_approvals }}</div>
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} Pending</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div>
|
||||
<div class="text-xs opacity-60">Memberships awaiting approval</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-4 lg:grid-cols-4">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "sparkles" size=18 %} New members</h2>
|
||||
<div class="text-4xl font-bold tabular-nums">{{ attention.new_members }}</div>
|
||||
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} New members</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
|
||||
{# First season at this club — someone returning after a year away is a renewal. #}
|
||||
<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 class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} Renewal</h2>
|
||||
{% if attention.renewal_rate is None %}
|
||||
{# No prior season to compare against: a first-season club has not failed to renew anyone. #}
|
||||
<p class="text-sm opacity-60">No previous season to compare against yet.</p>
|
||||
{% else %}
|
||||
<div class="text-4xl font-bold tabular-nums">{{ attention.renewal_rate }}%</div>
|
||||
<p class="text-sm opacity-70">of last season's active members signed up again</p>
|
||||
<progress class="progress progress-primary w-full" value="{{ attention.renewal_rate }}" max="100"></progress>
|
||||
{% endif %}
|
||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.renewal_rate is None %}border-info{% elif attention.renewal_rate < 30 %}border-error{% elif attention.renewal_rate < 65 %}border-warning{% else %}border-success{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} Renewal rate</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">
|
||||
{% if attention.renewal_rate is None %}
|
||||
N/A
|
||||
{% else %}
|
||||
{{ attention.renewal_rate }}%
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{% if attention.renewal_rate is None %}
|
||||
No previous season
|
||||
{% else %}
|
||||
<progress class="progress w-full {% if attention.renewal_rate < 30 %}progress-error{% elif attention.renewal_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.renewal_rate }}"
|
||||
max="100"></progress>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "user-check" size=18 %} Attendance</h2>
|
||||
{% if attention.attendance.turnout is None %}
|
||||
<p class="text-sm opacity-60">No past events with responses this season.</p>
|
||||
{% else %}
|
||||
<div class="text-4xl font-bold tabular-nums">{{ attention.attendance.turnout }}%</div>
|
||||
<p class="text-sm opacity-70">turnout of those who answered</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{# The leading indicator: it measures whether members use the app at all. #}
|
||||
<span class="font-semibold {% if attention.attendance.no_response > 30 %}text-warning{% endif %}">{{ attention.attendance.no_response }}%</span>
|
||||
<span class="opacity-70">never responded</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<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 class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} Attendance rate</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">
|
||||
{% if attention.attendance.turnout is None %}
|
||||
N/A
|
||||
{% else %}
|
||||
{{ attention.attendance.turnout }}%
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{% if attention.attendance.turnout is None %}
|
||||
No events this season
|
||||
{% else %}
|
||||
<progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}"
|
||||
value="{{ attention.attendance.turnout }}" max="100"></progress>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
@@ -149,7 +156,7 @@
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<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">
|
||||
<canvas id="fees-chart"></canvas>
|
||||
</div>
|
||||
@@ -157,7 +164,7 @@
|
||||
</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 %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
@@ -166,7 +173,7 @@
|
||||
{% for label, value in group.stats %}
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt class="text-sm opacity-70">{{ label }}</dt>
|
||||
<dd class="font-semibold tabular-nums">{{ value }}</dd>
|
||||
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
@@ -174,82 +181,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<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-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>
|
||||
{% include "controlpanel/_club_features_card.html" %}
|
||||
{% include "controlpanel/_club_billing_card.html" %}
|
||||
{% include "controlpanel/_club_admins_card.html" %}
|
||||
{% endblock panel %}
|
||||
|
||||
{% block extra_body %}
|
||||
@@ -271,17 +205,17 @@
|
||||
data: {
|
||||
labels: data.signups.map((point) => point.month),
|
||||
datasets: [
|
||||
{ label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5") },
|
||||
{ label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9") },
|
||||
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
|
||||
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "bottom", labels: { color: ink } } },
|
||||
plugins: {legend: {position: "bottom", labels: {color: ink}}},
|
||||
scales: {
|
||||
x: { stacked: true, ticks: { color: ink }, grid: { color: grid } },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } },
|
||||
x: {stacked: true, ticks: {color: ink}, 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
|
||||
// not — so the slices are pinned to the semantic theme colours, in order.
|
||||
const fees = new Chart(document.getElementById("fees-chart"), {
|
||||
type: "doughnut",
|
||||
type: "pie",
|
||||
data: {
|
||||
labels: data.fees.map((slice) => slice.label),
|
||||
datasets: [
|
||||
@@ -302,7 +236,7 @@
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "right", labels: { color: ink } } },
|
||||
plugins: {legend: {position: "right", labels: {color: ink}}},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -315,7 +249,7 @@
|
||||
new MutationObserver(() => {
|
||||
charts.forEach((chart) => chart.destroy());
|
||||
charts = render();
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
|
||||
})();
|
||||
</script>
|
||||
{% endblock extra_body %}
|
||||
|
||||
@@ -4,28 +4,33 @@
|
||||
{% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %}
|
||||
|
||||
{% 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">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="alert alert-error my-2">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for field in form %}
|
||||
<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_list' %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{% for field in form %}
|
||||
<div class="my-3 w-full">
|
||||
<label class="label" for="{{ field.id_for_label }}">
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{{ field|daisy }}
|
||||
{% if field.help_text and not field.errors %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-start pt-2 mt-2">
|
||||
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "controlpanel:club_detail" object.pk %}{% else %}{% url "controlpanel:club_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
{% block actions %}
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a>
|
||||
{% endblock actions %}
|
||||
@@ -15,48 +15,27 @@
|
||||
{% block panel %}
|
||||
<form method="get" class="mb-4 flex gap-2">
|
||||
{% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %}
|
||||
<input type="search"
|
||||
name="q"
|
||||
value="{{ search }}"
|
||||
placeholder="Search clubs…"
|
||||
class="input input-bordered w-full max-w-xs">
|
||||
<button class="btn gap-2" type="submit">{% lucide "search" size=16 %} Search</button>
|
||||
<label class="input">
|
||||
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
||||
<input type="search"
|
||||
name="q"
|
||||
value="{{ search }}"
|
||||
placeholder="Search clubs…"
|
||||
class="input input-bordered w-full max-w-xs">
|
||||
</label>
|
||||
|
||||
<button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=16 %} Search</button>
|
||||
{% if search %}
|
||||
<a class="btn btn-primary gap-2" href="{% url "controlpanel:club_list" %}">{% lucide "x" size=16 %} Clear filter</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Club</th>
|
||||
<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>
|
||||
{% if show_archived %}
|
||||
{% include "controlpanel/_club_health_table.html" with empty_message="No archived clubs." %}
|
||||
{% else %}
|
||||
{% include "controlpanel/_club_health_table.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
|
||||
@@ -2,46 +2,71 @@
|
||||
{% load static lucide %}
|
||||
|
||||
{% 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 }} · {% now "d b Y" %}{% endblock subheading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a>
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
{% comment %}
|
||||
Needs attention first: these are the numbers that are supposed to be zero. A club with
|
||||
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="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "calendar-x" size=16 %} No current season</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.clubs_without_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-4xl font-bold tabular-nums font-mono">{{ totals.clubs }}</div>
|
||||
<div class="text-xs opacity-60">Managing {{ totals.members }} member{{ totals.members|pluralize }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "archive" size=16 %} Archived clubs</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ totals.archived_clubs }}</div>
|
||||
<div class="text-xs opacity-60">Not accessible but data maintained</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.clubs_without_season %}border-warning{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "calendar-x" size=16 %} No current season</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.clubs_without_season }}</div>
|
||||
<div class="text-xs opacity-60">Clubs that cannot take signups</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% 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="flex items-center gap-2 text-sm opacity-70">{% lucide "moon-star" size=16 %} Dormant</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.dormant_clubs }}</div>
|
||||
<div class="text-xs opacity-60">Nothing scheduled in 30 days</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-4xl font-bold tabular-nums font-mono">{{ attention.dormant_clubs }}</div>
|
||||
<div class="text-xs opacity-60">No events scheduled next 30 days</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% 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="flex items-center gap-2 text-sm opacity-70">{% lucide "shield-alert" size=16 %} MFA pending</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.admins_pending_mfa }}</div>
|
||||
<div class="text-xs opacity-60">Admins locked out until they enrol</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-4xl font-bold tabular-nums font-mono">{{ attention.admins_pending_mfa }}</div>
|
||||
<div class="text-xs opacity-60">Admins without MFA configured</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% 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="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
|
||||
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div>
|
||||
<div class="text-xs opacity-60">Unpaid across every club</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-4xl font-bold tabular-nums font-mono">€{{ attention.dues_owed|floatformat:2 }}</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{ attention.dues_in_grace }} in grace ·
|
||||
<span class="{% if attention.dues_overdue %}font-semibold text-error{% endif %}">{{ attention.dues_overdue }} overdue</span>
|
||||
{% comment %}
|
||||
Renewals pending should sit at ~0: the cron job renews clubs 30 days out and
|
||||
then they fall past the horizon. A number that lingers here means the job has
|
||||
stopped and a club is about to use the platform for free — which no other
|
||||
figure on this page reveals, because nothing has been billed yet.
|
||||
{% endcomment %}
|
||||
{% if attention.renewals_pending %}
|
||||
· <span class="font-semibold text-warning">{{ attention.renewals_pending }} awaiting renewal</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,142 +81,31 @@
|
||||
</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-body">
|
||||
<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">
|
||||
{% for step in funnel %}
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
|
||||
<span class="font-semibold tabular-nums">{{ step.count }}</span>
|
||||
<span class="font-semibold tabular-nums font-mono">{{ step.count }}</span>
|
||||
</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>
|
||||
{% endfor %}
|
||||
</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 class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Clubs</h2>
|
||||
{% comment %}
|
||||
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>
|
||||
<h2 class="card-title">{% lucide "building-2" size=18 %} Clubs</h2>
|
||||
{% include "controlpanel/_club_health_table.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
@@ -254,22 +168,22 @@
|
||||
data: {
|
||||
labels: data.signups.map((point) => point.month),
|
||||
datasets: [
|
||||
{ label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5") },
|
||||
{ label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9") },
|
||||
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
|
||||
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "bottom", labels: { color: ink } } },
|
||||
plugins: {legend: {position: "bottom", labels: {color: ink}}},
|
||||
scales: {
|
||||
x: { stacked: true, ticks: { color: ink }, grid: { color: grid } },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } },
|
||||
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
|
||||
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return [signups, build("revenue-chart", "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();
|
||||
|
||||
@@ -1,13 +1,65 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide %}
|
||||
{% load lucide ui %}
|
||||
|
||||
{% block heading %}Features{% endblock heading %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:flag_create' as flag_create_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="flag_create_modal" title="New feature" form=flag_form action_url=flag_create_url submit_label="Create" submit_icon="plus" %}
|
||||
|
||||
{% comment %}
|
||||
The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the
|
||||
control panel and the auth screens stay open — otherwise you could not sign in to
|
||||
turn it back off.
|
||||
{% endcomment %}
|
||||
<div class="card mb-6 bg-base-100 shadow {% if maintenance.is_active %}border-l-4 border-error{% endif %}">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="w-full">
|
||||
<h2 class="card-title text-base mb-2">{% lucide "wrench" size=18 %} Maintenance mode</h2>
|
||||
{% if maintenance.is_active %}
|
||||
<div class="flex flex-row gap-2 text-sm">
|
||||
<span class="badge badge-error gap-1">{% lucide "lock" size=12 %} Platform closed</span>
|
||||
<div>·</div>
|
||||
<div>since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
{% if maintenance.message %}
|
||||
<div class="my-4">
|
||||
<div class="font-semibold mb-1">Message</div>
|
||||
<div class="p-4 bg-base-300 border-l-4 border-info w-full font-mono">{{ maintenance.message }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70">
|
||||
Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="mt-2" method="post" action="{% url 'controlpanel:maintenance' %}">
|
||||
{% csrf_token %}
|
||||
{% if not maintenance.is_active %}
|
||||
<div class="form-control my-2 w-full pb-2">
|
||||
<label class="label" for="{{ maintenance_form.message.id_for_label }}">
|
||||
<span class="label-text">{{ maintenance_form.message.label }}</span>
|
||||
</label>
|
||||
{{ maintenance_form.message|daisy }}
|
||||
<span class="label-text-alt mt-1 block text-xs text-base-content/70">{{ maintenance_form.message.help_text }}</span>
|
||||
</div>
|
||||
<button class="btn btn-error gap-2" type="submit">{% lucide "lock" size=16 %} Close the platform</button>
|
||||
{% else %}
|
||||
<button class="btn btn-success gap-2" type="submit">{% lucide "lock-open" size=16 %} Reopen the platform</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6 bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "flag" size=18 %} Flags</h2>
|
||||
@@ -35,13 +87,13 @@
|
||||
{% elif flag.everyone is False %}
|
||||
<span class="badge badge-error">Off everywhere</span>
|
||||
{% else %}
|
||||
<span class="badge badge-ghost">Per club</span>
|
||||
<span class="badge badge-info">Per club</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ flag.clubs.count }}</td>
|
||||
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"—" }}</td>
|
||||
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"-" }}</td>
|
||||
<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>
|
||||
</tr>
|
||||
{% empty %}
|
||||
@@ -52,6 +104,12 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for flag in flags %}
|
||||
{% url 'controlpanel:flag_update' flag.pk as flag_update_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id=flag.pk|dom_id:"flag_edit_modal" title="Edit "|add:flag.name form=flag.edit_form action_url=flag_update_url submit_label="Save" submit_icon="check" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
@@ -64,7 +122,7 @@
|
||||
{% for switch in switches %}
|
||||
<tr>
|
||||
<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">
|
||||
<form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -18,11 +18,11 @@ DEFAULT_WIDGET_CLASS = "input input-bordered w-full"
|
||||
|
||||
#: Icon, default heading and daisyUI colour per message level.
|
||||
MESSAGE_ALERTS = {
|
||||
"debug": ("bug", "Debug", "alert-info"),
|
||||
"info": ("info", "Heads up", "alert-info"),
|
||||
"success": ("circle-check", "Done", "alert-success"),
|
||||
"warning": ("triangle-alert", "Careful", "alert-warning"),
|
||||
"error": ("circle-x", "Something went wrong", "alert-error"),
|
||||
"debug": ("bug", "Debug", "alert-info border-info"),
|
||||
"info": ("info", "Heads up", "alert-info border-info"),
|
||||
"success": ("circle-check", "Done", "alert-success border-success"),
|
||||
"warning": ("triangle-alert", "Careful", "alert-warning border-warning"),
|
||||
"error": ("circle-x", "Something went wrong", "alert-error border-error"),
|
||||
}
|
||||
DEFAULT_MESSAGE_ALERT = MESSAGE_ALERTS["info"]
|
||||
|
||||
@@ -32,10 +32,11 @@ def as_alert(message):
|
||||
"""Presentation for one Django message: icon, bold title, body, colour.
|
||||
|
||||
Django messages carry a level and a string — there is no title field — so the
|
||||
title comes from the level, and a call site that wants a specific one passes it
|
||||
as ``extra_tags``::
|
||||
title comes from the level, unless the message carries one as ``extra_tags``.
|
||||
Call sites queue messages with ``notify`` (controlpanel/messages.py), which sets
|
||||
exactly that from a compact ``"<level>|<title>|<body>"`` spec::
|
||||
|
||||
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
|
||||
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(",")
|
||||
|
||||
|
||||
@register.filter
|
||||
def dom_id(pk, prefix):
|
||||
"""A stable per-row DOM id, e.g. ``{{ due.pk|dom_id:"due_pay_modal" }}``.
|
||||
|
||||
Django templates cannot concatenate a string literal with a non-string filter
|
||||
argument directly (``"prefix_"|add:some_uuid`` raises), which is what a per-row modal
|
||||
id needs. This is the one place that builds one, so every call site reads the same way.
|
||||
"""
|
||||
return f"{prefix}_{pk}"
|
||||
|
||||
|
||||
@register.filter
|
||||
def daisy(field, css=None):
|
||||
"""Render a bound form field with the right daisyUI classes.
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import datetime
|
||||
import pathlib
|
||||
from decimal import Decimal
|
||||
from unittest import mock
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages.storage.base import Message
|
||||
from django.contrib.messages.storage.fallback import FallbackStorage
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from waffle import get_waffle_flag_model, get_waffle_switch_model
|
||||
|
||||
from billing.models import GRACE_DAYS, Due, Tier, TierPrice
|
||||
from billing.services import BillingError
|
||||
from billing.services.dues import record_payment, subscribe, waive
|
||||
from club.models import Club, ClubMembership, ClubRole, Season
|
||||
from events.models import Attendance, Event
|
||||
from features.models import Maintenance
|
||||
from members.models import Member
|
||||
from shop.models import Order
|
||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||
|
||||
from .messages import LEVELS, notify
|
||||
from .services.admins import grant_club_admin
|
||||
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
|
||||
from .services.statistics import (
|
||||
@@ -168,11 +177,18 @@ class ClubAdminManagementTests(ControlPanelTestBase):
|
||||
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):
|
||||
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.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):
|
||||
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):
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:admins")).status_code, 200)
|
||||
|
||||
def test_the_grant_form_renders(self):
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 200)
|
||||
def test_the_grant_form_is_post_only(self):
|
||||
# Reachable only via the "Grant access" modal on the admins page: there is no
|
||||
# standalone template to render on a GET.
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 405)
|
||||
|
||||
def test_grant_access_to_a_new_email_creates_a_staff_account(self):
|
||||
self.client.post(reverse("controlpanel:admin_add"), {"email": "New.Admin@Example.com"})
|
||||
@@ -400,9 +418,17 @@ class FeatureViewTests(ControlPanelTestBase):
|
||||
self.assertContains(response, "shop")
|
||||
self.assertContains(response, "maintenance")
|
||||
|
||||
def test_the_flag_forms_render(self):
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:flag_create")).status_code, 200)
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:flag_update", args=[self.flag.pk])).status_code, 200)
|
||||
def test_the_flag_forms_are_post_only(self):
|
||||
# Reachable only through a modal on the features page: there is no standalone
|
||||
# template to render on a GET.
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:flag_create")).status_code, 405)
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:flag_update", args=[self.flag.pk])).status_code, 405)
|
||||
|
||||
def test_an_invalid_flag_submission_redirects_with_a_message(self):
|
||||
response = self.client.post(reverse("controlpanel:flag_create"), {"name": "", "note": "", "percent": "", "everyone": ""}, follow=True)
|
||||
|
||||
self.assertRedirects(response, reverse("controlpanel:features"))
|
||||
self.assertContains(response, "This field is required")
|
||||
|
||||
def test_create_a_flag(self):
|
||||
self.client.post(reverse("controlpanel:flag_create"), {"name": "news", "note": "News module", "percent": "", "everyone": ""})
|
||||
@@ -449,33 +475,75 @@ class FeatureViewTests(ControlPanelTestBase):
|
||||
self.assertNotContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk]))
|
||||
|
||||
|
||||
class NotifyTests(TestCase):
|
||||
def request(self):
|
||||
request = RequestFactory().get("/")
|
||||
request.session = {}
|
||||
storage = FallbackStorage(request)
|
||||
request._messages = storage
|
||||
return request, storage
|
||||
|
||||
def test_splits_level_title_and_body(self):
|
||||
request, storage = self.request()
|
||||
|
||||
notify(request, "s|Club created|Ajax United is live.")
|
||||
|
||||
[message] = list(storage)
|
||||
self.assertEqual(message.level, messages.SUCCESS)
|
||||
self.assertEqual(message.extra_tags, "Club created")
|
||||
self.assertEqual(message.message, "Ajax United is live.")
|
||||
|
||||
def test_maps_every_level_code_to_its_django_level(self):
|
||||
self.assertEqual(LEVELS, {"s": messages.SUCCESS, "i": messages.INFO, "w": messages.WARNING, "e": messages.ERROR, "d": messages.DEBUG})
|
||||
|
||||
def test_a_pipe_inside_the_body_is_preserved_intact(self):
|
||||
# maxsplit=2 stops after the level and the title, so a "|" a club/tier/flag name
|
||||
# might contain stays part of the body rather than truncating it.
|
||||
request, storage = self.request()
|
||||
|
||||
notify(request, "s|Title|Before | after.")
|
||||
|
||||
[message] = list(storage)
|
||||
self.assertEqual(message.message, "Before | after.")
|
||||
|
||||
def test_an_empty_title_falls_back_to_the_generic_one_at_render_time(self):
|
||||
request, storage = self.request()
|
||||
|
||||
notify(request, "s||No custom title.")
|
||||
|
||||
[message] = list(storage)
|
||||
self.assertEqual(as_alert(message)["title"], "Done")
|
||||
|
||||
|
||||
class MessageAlertTests(TestCase):
|
||||
def alert(self, level, text, extra_tags=None):
|
||||
return as_alert(Message(level, text, extra_tags=extra_tags))
|
||||
|
||||
def test_each_level_gets_its_own_icon_title_and_colour(self):
|
||||
self.assertEqual(self.alert(messages.SUCCESS, "Saved.")["icon"], "circle-check")
|
||||
self.assertEqual(self.alert(messages.WARNING, "Careful.")["css"], "alert-warning")
|
||||
self.assertEqual(self.alert(messages.WARNING, "Careful.")["css"], "alert-warning border-warning")
|
||||
self.assertEqual(self.alert(messages.ERROR, "Boom.")["title"], "Something went wrong")
|
||||
self.assertEqual(self.alert(messages.INFO, "FYI.")["css"], "alert-info")
|
||||
self.assertEqual(self.alert(messages.INFO, "FYI.")["css"], "alert-info border-info")
|
||||
|
||||
def test_extra_tags_override_the_title(self):
|
||||
alert = self.alert(messages.SUCCESS, "Ajax United is live.", extra_tags="Club created")
|
||||
|
||||
self.assertEqual(alert["title"], "Club created")
|
||||
self.assertEqual(alert["body"], "Ajax United is live.")
|
||||
self.assertEqual(alert["css"], "alert-success") # 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):
|
||||
self.assertEqual(self.alert(999, "Odd.")["css"], "alert-info")
|
||||
self.assertEqual(self.alert(999, "Odd.")["css"], "alert-info border-info")
|
||||
|
||||
|
||||
class MessageRenderingTests(ControlPanelTestBase):
|
||||
def test_a_message_renders_as_a_soft_alert_with_icon_and_title(self):
|
||||
# club_archive queues its message through `notify`, which sets a custom title —
|
||||
# so the generic per-level one ("Careful") must not show.
|
||||
response = self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]), follow=True)
|
||||
|
||||
self.assertContains(response, "alert alert-soft alert-warning")
|
||||
self.assertContains(response, '<div class="font-bold">Careful</div>', html=False)
|
||||
self.assertContains(response, "alert alert-soft border-2 alert-warning border-warning")
|
||||
self.assertContains(response, '<div class="font-bold">Club archived</div>', html=False)
|
||||
self.assertContains(response, "<svg") # the lucide icon
|
||||
|
||||
|
||||
@@ -728,11 +796,13 @@ class PlatformChartTests(TestCase):
|
||||
|
||||
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("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"))
|
||||
|
||||
|
||||
@@ -742,8 +812,8 @@ class DashboardMetricsTests(ControlPanelTestBase):
|
||||
|
||||
self.assertContains(response, "No current season")
|
||||
self.assertContains(response, "MFA pending")
|
||||
self.assertContains(response, "Payment pending")
|
||||
self.assertContains(response, 'id="signups-chart"')
|
||||
self.assertContains(response, 'id="revenue-chart"')
|
||||
self.assertContains(response, "js/chart.js")
|
||||
self.assertIn("signups", response.context["charts"])
|
||||
|
||||
@@ -1005,6 +1075,405 @@ class ClubHealthTableTests(TestCase):
|
||||
|
||||
response = self.client.get(reverse("controlpanel:dashboard"))
|
||||
|
||||
self.assertContains(response, "Owed")
|
||||
self.assertContains(response, "Upcoming")
|
||||
self.assertContains(response, "Unpaid")
|
||||
# Health, not vanity: Plan and Dues each name something to act on, next to the counts.
|
||||
for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
|
||||
self.assertContains(response, f">{column}</th>")
|
||||
|
||||
|
||||
class ClubListHealthTests(ControlPanelTestBase):
|
||||
def test_the_list_shows_the_same_health_columns_as_the_dashboard(self):
|
||||
response = self.client.get(reverse("controlpanel:club_list"))
|
||||
|
||||
for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
|
||||
self.assertContains(response, f">{column}</th>")
|
||||
self.assertTemplateUsed(response, "controlpanel/_club_health_table.html")
|
||||
|
||||
def test_an_archived_club_is_badged_archived_rather_than_dormant(self):
|
||||
# Its subdomain does not resolve, so "nothing scheduled" is not news.
|
||||
self.club.archive()
|
||||
|
||||
response = self.client.get(reverse("controlpanel:club_list"), {"archived": "1"})
|
||||
|
||||
self.assertContains(response, "Archived")
|
||||
self.assertNotContains(response, "Dormant")
|
||||
|
||||
def test_searching_keeps_the_health_annotations(self):
|
||||
response = self.client.get(reverse("controlpanel:club_list"), {"q": "Ajax"})
|
||||
|
||||
club = response.context["clubs"][0]
|
||||
|
||||
self.assertEqual(club.active_members, 0)
|
||||
self.assertEqual(club.teams_without_coach, 0)
|
||||
|
||||
def test_the_list_does_not_fan_out_per_club(self):
|
||||
for name in ("Feyenoord", "PSV", "Twente"):
|
||||
Club.objects.create(name=name)
|
||||
|
||||
with self.assertNumQueries(1):
|
||||
[(club.outstanding, club.upcoming_events) for club in clubs_with_health()]
|
||||
|
||||
|
||||
class TemplateCommentTests(TestCase):
|
||||
def test_no_template_uses_a_multiline_hash_comment(self):
|
||||
"""Django's {# #} is single-line only — its lexer regex is not DOTALL, so a
|
||||
multi-line one is not a comment at all: it renders to the page as text."""
|
||||
templates = [path for path in pathlib.Path(settings.BASE_DIR).glob("**/templates/**/*.html") if ".venv" not in path.parts and "node_modules" not in path.parts]
|
||||
offenders = [f"{path.relative_to(settings.BASE_DIR)}:{number}" for path in templates for number, line in enumerate(path.read_text().splitlines(), start=1) if "{#" in line and "#}" not in line]
|
||||
|
||||
self.assertTrue(templates) # the glob must actually be finding our templates
|
||||
self.assertEqual(offenders, [], "use {% comment %} for multi-line comments")
|
||||
|
||||
|
||||
class PlatformDuesMetricTests(TestCase):
|
||||
"""What the clubs owe US — kept strictly apart from what members owe their clubs."""
|
||||
|
||||
def setUp(self):
|
||||
self.today = timezone.localdate()
|
||||
self.club = Club.objects.create(name="Ajax United")
|
||||
self.tier = Tier.objects.create(name="Standard")
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
|
||||
|
||||
def test_dues_owed_is_the_unpaid_balance_across_every_club(self):
|
||||
subscribe(self.club, self.tier)
|
||||
record_payment(self.club.dues.first(), Decimal("200.00"))
|
||||
|
||||
self.assertEqual(platform_attention()["dues_owed"], Decimal("300.00"))
|
||||
|
||||
def test_grace_and_overdue_are_counted_separately(self):
|
||||
in_grace = Club.objects.create(name="Grace FC")
|
||||
overdue = Club.objects.create(name="Overdue FC")
|
||||
subscribe(in_grace, self.tier, start=self.today - datetime.timedelta(days=370))
|
||||
subscribe(overdue, self.tier, start=self.today - datetime.timedelta(days=365 + GRACE_DAYS + 10))
|
||||
|
||||
attention = platform_attention()
|
||||
|
||||
self.assertEqual(attention["dues_in_grace"], 1)
|
||||
self.assertEqual(attention["dues_overdue"], 1)
|
||||
|
||||
def test_clubs_on_no_tier_are_flagged(self):
|
||||
self.assertEqual(platform_attention()["clubs_unbilled"], 1)
|
||||
|
||||
subscribe(self.club, self.tier)
|
||||
|
||||
self.assertEqual(platform_attention()["clubs_unbilled"], 0)
|
||||
|
||||
def test_renewals_pending_counts_clubs_about_to_lapse(self):
|
||||
# ~0 in normal running; a number here means the renewal cron has stopped.
|
||||
self.assertEqual(platform_attention()["renewals_pending"], 0)
|
||||
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350)) # ends in 15 days
|
||||
|
||||
self.assertEqual(platform_attention()["renewals_pending"], 1)
|
||||
|
||||
def test_the_dashboard_surfaces_pending_renewals(self):
|
||||
# The whole point of the KPI: a club about to go free is visible, though nothing is
|
||||
# owed yet, so no other figure on the page would show it.
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350))
|
||||
staff = User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
|
||||
enrol_mfa(staff)
|
||||
self.client.force_login(staff)
|
||||
|
||||
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "awaiting renewal")
|
||||
|
||||
def test_platform_dues_and_club_shop_money_are_different_charts(self):
|
||||
subscribe(self.club, self.tier)
|
||||
record_payment(self.club.dues.first(), Decimal("500.00"))
|
||||
|
||||
charts = platform_charts()
|
||||
|
||||
self.assertEqual(charts["dues"][-1]["value"], 500.0)
|
||||
self.assertEqual(charts["club_revenue"][-1]["value"], 0.0) # never ours
|
||||
|
||||
def test_the_health_table_carries_the_plan_and_what_is_owed(self):
|
||||
subscribe(self.club, self.tier)
|
||||
|
||||
club = clubs_with_health().get(pk=self.club.pk)
|
||||
|
||||
self.assertEqual(club.tier_name, "Standard")
|
||||
self.assertEqual(club.dues_owed, Decimal("500.00"))
|
||||
|
||||
def test_a_fully_paid_club_shows_when_its_cover_ends(self):
|
||||
# The end of the current paid period is the day grace would start if nothing renews.
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
record_payment(due, due.amount)
|
||||
|
||||
club = clubs_with_health().get(pk=self.club.pk)
|
||||
|
||||
self.assertEqual(club.covered_until, due.period_end)
|
||||
self.assertEqual(club.covered_status, Due.Status.PAID)
|
||||
|
||||
def test_a_waived_period_also_shows_its_cover_end(self):
|
||||
# Waived is settled too — the club is covered for that time, so its end date shows,
|
||||
# badged "waived" rather than "paid".
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
waive(due)
|
||||
|
||||
club = clubs_with_health().get(pk=self.club.pk)
|
||||
|
||||
self.assertEqual(club.covered_until, due.period_end)
|
||||
self.assertEqual(club.covered_status, Due.Status.WAIVED)
|
||||
|
||||
def test_a_club_that_owes_has_no_cover(self):
|
||||
subscribe(self.club, self.tier) # unpaid
|
||||
|
||||
club = clubs_with_health().get(pk=self.club.pk)
|
||||
self.assertIsNone(club.covered_until)
|
||||
self.assertIsNone(club.covered_status)
|
||||
|
||||
def test_the_health_table_still_costs_one_query_with_billing_on_it(self):
|
||||
subscribe(self.club, self.tier)
|
||||
subscribe(Club.objects.create(name="Feyenoord"), self.tier)
|
||||
|
||||
with self.assertNumQueries(1):
|
||||
[(club.tier_name, club.dues_owed, club.outstanding) for club in clubs_with_health()]
|
||||
|
||||
|
||||
class BillingPanelTests(ControlPanelTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.today = timezone.localdate()
|
||||
self.tier = Tier.objects.create(name="Standard")
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
|
||||
|
||||
def test_the_billing_page_lists_tiers_and_what_is_owed(self):
|
||||
subscribe(self.club, self.tier)
|
||||
|
||||
response = self.client.get(reverse("controlpanel:billing"))
|
||||
|
||||
self.assertContains(response, "Standard")
|
||||
self.assertContains(response, "500.00")
|
||||
|
||||
def test_a_tier_can_be_created_and_priced(self):
|
||||
self.client.post(reverse("controlpanel:tier_create"), {"name": "Large", "description": "", "is_active": "on"})
|
||||
tier = Tier.objects.get(name="Large")
|
||||
|
||||
self.client.post(reverse("controlpanel:tier_price_create", args=[tier.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"})
|
||||
|
||||
self.assertEqual(tier.price_on(self.today), Decimal("900.00"))
|
||||
|
||||
def test_a_rate_change_does_not_rewrite_an_open_period(self):
|
||||
subscribe(self.club, self.tier)
|
||||
|
||||
self.client.post(reverse("controlpanel:tier_price_create", args=[self.tier.pk]), {"active_from": self.today.isoformat(), "amount": "900.00"})
|
||||
|
||||
self.assertEqual(self.club.dues.first().amount, Decimal("500.00"))
|
||||
|
||||
def test_subscribing_a_club_opens_its_first_period(self):
|
||||
self.client.post(reverse("controlpanel:club_subscribe", args=[self.club.pk]), {"tier": self.tier.pk, "auto_archive": "on", "notes": ""})
|
||||
|
||||
self.assertEqual(self.club.dues.count(), 1)
|
||||
self.assertEqual(self.club.subscription.tier, self.tier)
|
||||
|
||||
def test_a_payment_can_be_recorded_and_settles_the_due(self):
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
|
||||
self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "500.00", "method": "bank_transfer", "reference": "TRX-1", "paid_at": "", "note": ""})
|
||||
|
||||
due.refresh_from_db()
|
||||
self.assertEqual(due.status, Due.Status.PAID)
|
||||
self.assertEqual(due.payments.first().recorded_by, self.staff)
|
||||
|
||||
def test_a_part_payment_leaves_a_balance(self):
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
|
||||
self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "200.00", "method": "bank_transfer", "reference": "", "paid_at": "", "note": ""})
|
||||
|
||||
due.refresh_from_db()
|
||||
self.assertEqual(due.balance, Decimal("300.00"))
|
||||
|
||||
def test_a_billing_error_is_shown_rather_than_raised(self):
|
||||
# A waived period cannot take a payment; the panel must say so, not 500.
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
self.client.post(reverse("controlpanel:due_waive", args=[due.pk]))
|
||||
|
||||
response = self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "50.00", "method": "cash", "reference": "", "paid_at": "", "note": ""}, follow=True)
|
||||
|
||||
self.assertContains(response, "cannot take a payment")
|
||||
|
||||
def test_a_period_can_be_waived(self):
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
|
||||
self.client.post(reverse("controlpanel:due_waive", args=[due.pk]))
|
||||
|
||||
due.refresh_from_db()
|
||||
self.assertEqual(due.status, Due.Status.WAIVED)
|
||||
|
||||
def test_opening_a_period_continues_from_the_last_one(self):
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
|
||||
first = self.club.dues.first()
|
||||
|
||||
self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": ""})
|
||||
|
||||
latest = self.club.dues.order_by("-period_start").first()
|
||||
self.assertEqual(latest.period_start, first.period_end + datetime.timedelta(days=1))
|
||||
|
||||
def test_reactivating_an_archived_club_restores_it(self):
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
|
||||
self.club.archive()
|
||||
|
||||
self.client.post(reverse("controlpanel:club_open_period", args=[self.club.pk]), {"start": self.today.isoformat()})
|
||||
|
||||
self.club.refresh_from_db()
|
||||
self.assertFalse(self.club.is_archived)
|
||||
|
||||
def test_the_club_page_shows_the_plan_and_its_periods(self):
|
||||
subscribe(self.club, self.tier)
|
||||
|
||||
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
|
||||
|
||||
self.assertContains(response, "Standard")
|
||||
self.assertContains(response, "INV-")
|
||||
|
||||
def test_an_invoice_downloads_as_a_pdf(self):
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
|
||||
with mock.patch("controlpanel.views.invoice_pdf", return_value=b"%PDF-1.7 fake"):
|
||||
response = self.client.get(reverse("controlpanel:due_invoice", args=[due.pk]))
|
||||
|
||||
self.assertEqual(response["Content-Type"], "application/pdf")
|
||||
self.assertIn(due.invoice.number, response["Content-Disposition"])
|
||||
|
||||
def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
|
||||
# WeasyPrint needs native libs. Without them the button must explain itself.
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
|
||||
with mock.patch("controlpanel.views.invoice_pdf", side_effect=BillingError("PDF rendering needs the native pango/cairo libraries.")):
|
||||
response = self.client.get(reverse("controlpanel:due_invoice", args=[due.pk]), follow=True)
|
||||
|
||||
self.assertContains(response, "pango")
|
||||
|
||||
|
||||
class 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")
|
||||
|
||||
@@ -18,9 +18,20 @@ urlpatterns = [
|
||||
path("clubs/<uuid:pk>/features/<int:flag_pk>/toggle/", views.ClubFeatureToggleView.as_view(), name="club_feature_toggle"),
|
||||
# Features
|
||||
path("features/", views.FeatureListView.as_view(), name="features"),
|
||||
path("features/maintenance/", views.MaintenanceView.as_view(), name="maintenance"),
|
||||
path("features/flags/new/", views.FlagCreateView.as_view(), name="flag_create"),
|
||||
path("features/flags/<int:pk>/edit/", views.FlagUpdateView.as_view(), name="flag_update"),
|
||||
path("features/switches/<int:pk>/toggle/", views.SwitchToggleView.as_view(), name="switch_toggle"),
|
||||
# Billing (platform charging the clubs)
|
||||
path("billing/", views.BillingView.as_view(), name="billing"),
|
||||
path("billing/tiers/new/", views.TierCreateView.as_view(), name="tier_create"),
|
||||
path("billing/tiers/<uuid:pk>/edit/", views.TierUpdateView.as_view(), name="tier_update"),
|
||||
path("billing/tiers/<uuid:pk>/prices/new/", views.TierPriceCreateView.as_view(), name="tier_price_create"),
|
||||
path("billing/dues/<uuid:pk>/pay/", views.RecordPaymentView.as_view(), name="due_pay"),
|
||||
path("billing/dues/<uuid:pk>/waive/", views.WaiveDueView.as_view(), name="due_waive"),
|
||||
path("billing/dues/<uuid:pk>/invoice.pdf", views.InvoicePdfView.as_view(), name="due_invoice"),
|
||||
path("clubs/<uuid:pk>/subscription/", views.SubscribeClubView.as_view(), name="club_subscribe"),
|
||||
path("clubs/<uuid:pk>/period/new/", views.OpenPeriodView.as_view(), name="club_open_period"),
|
||||
# Platform admins (superusers only)
|
||||
path("admins/", views.PlatformAdminListView.as_view(), name="admins"),
|
||||
path("admins/add/", views.PlatformAdminAddView.as_view(), name="admin_add"),
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
from django.contrib import messages
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Count
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
||||
from waffle import get_waffle_flag_model, get_waffle_switch_model
|
||||
|
||||
from billing.models import Due, Tier, TierPrice
|
||||
from billing.services import BillingError
|
||||
from billing.services.dues import next_period_start, open_period, reactivate, record_payment, subscribe, waive
|
||||
from billing.services.invoices import invoice_pdf, issue_invoice
|
||||
from club.models import Club, ClubRole
|
||||
from features.models import Maintenance
|
||||
|
||||
from .forms import ClubAdminForm, ClubForm, FlagForm, PlatformAdminForm
|
||||
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin
|
||||
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm
|
||||
from .messages import notify
|
||||
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
|
||||
from .services.admins import grant_club_admin, revoke_club_admin
|
||||
from .services.platform_admins import (
|
||||
PlatformAdminError,
|
||||
@@ -17,12 +28,26 @@ from .services.platform_admins import (
|
||||
revoke_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()
|
||||
Switch = get_waffle_switch_model()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suppress_billing_errors(request, title="Billing error"):
|
||||
"""Turn a BillingError into an error message rather than letting it propagate.
|
||||
|
||||
Fits call sites that fall through to the same redirect on the happy and unhappy path
|
||||
alike — the success message is set inside the block, the failure message by this
|
||||
context manager, and whichever fired, the caller's next line runs unchanged.
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
except BillingError as error:
|
||||
notify(request, f"e|{title}|{error}")
|
||||
|
||||
|
||||
class DashboardView(PlatformStaffRequiredMixin, TemplateView):
|
||||
template_name = "controlpanel/dashboard.html"
|
||||
|
||||
@@ -35,6 +60,7 @@ class DashboardView(PlatformStaffRequiredMixin, TemplateView):
|
||||
flags=flag_adoption(),
|
||||
charts=platform_charts(),
|
||||
clubs=clubs_with_health(),
|
||||
today=timezone.localdate(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -52,10 +78,10 @@ class ClubListView(PlatformStaffRequiredMixin, ListView):
|
||||
search = self.request.GET.get("q", "").strip()
|
||||
if search:
|
||||
clubs = clubs.filter(name__icontains=search)
|
||||
return clubs_with_totals(clubs)
|
||||
return clubs_with_health(clubs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", show_archived=self.show_archived, search=self.request.GET.get("q", ""), **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):
|
||||
@@ -65,12 +91,15 @@ class ClubCreateView(PlatformStaffRequiredMixin, CreateView):
|
||||
|
||||
def form_valid(self, 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
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("controlpanel:club_detail", args=[self.object.pk])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", **kwargs)
|
||||
|
||||
|
||||
class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView):
|
||||
model = Club
|
||||
@@ -79,12 +108,15 @@ class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView):
|
||||
|
||||
def form_valid(self, 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
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("controlpanel:club_detail", args=[self.object.pk])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", update_view=True, **kwargs)
|
||||
|
||||
|
||||
class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
|
||||
model = Club
|
||||
@@ -92,13 +124,30 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
|
||||
context_object_name = "club"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
subscription = getattr(self.object, "subscription", None)
|
||||
next_start = next_period_start(self.object)
|
||||
|
||||
# Bound per-row so each due's "Add payment" modal can render its own form without
|
||||
# the template calling DuePaymentForm(initial=...) itself.
|
||||
dues = list(self.object.dues.select_related("tier", "invoice").prefetch_related("payments"))
|
||||
for due in dues:
|
||||
if due.is_owing:
|
||||
due.payment_form = DuePaymentForm(initial={"amount": due.balance})
|
||||
|
||||
return super().get_context_data(
|
||||
nav="clubs",
|
||||
groups=club_statistics(self.object),
|
||||
attention=club_attention(self.object),
|
||||
charts=club_charts(self.object),
|
||||
subscription=subscription,
|
||||
dues=dues,
|
||||
today=timezone.localdate(),
|
||||
admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"),
|
||||
admin_form=ClubAdminForm(),
|
||||
flags=flags_for_club(self.object),
|
||||
open_period_form=OpenPeriodForm(),
|
||||
open_period_blurb=f"Next period starts {date_format(next_start, 'j M Y')} unless you say otherwise. By default it continues from the end of the last one, so a lapsed year is still owed — pick a start date to forgive the gap.",
|
||||
subscription_form=SubscriptionForm(instance=subscription) if subscription else SubscriptionForm(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -109,7 +158,7 @@ class ClubArchiveView(PlatformStaffRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
club = get_object_or_404(Club, pk=pk)
|
||||
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)
|
||||
|
||||
|
||||
@@ -117,24 +166,28 @@ class ClubRestoreView(PlatformStaffRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
club = get_object_or_404(Club, pk=pk)
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
template_name = "controlpanel/club_admin_form.html"
|
||||
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_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", club=self.club, **kwargs)
|
||||
def get_invalid_redirect_kwargs(self):
|
||||
return {"pk": self.kwargs["pk"]}
|
||||
|
||||
def form_valid(self, form):
|
||||
role = grant_club_admin(self.club, **form.cleaned_data)
|
||||
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"])
|
||||
|
||||
|
||||
@@ -143,24 +196,10 @@ class ClubAdminRemoveView(PlatformStaffRequiredMixin, View):
|
||||
role = get_object_or_404(ClubRole, pk=role_pk, club_id=pk, role=ClubRole.Roles.ADMIN)
|
||||
member = role.member
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""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():
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -182,43 +221,69 @@ class FeatureListView(PlatformStaffRequiredMixin, TemplateView):
|
||||
template_name = "controlpanel/features.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
# Bound per-row so each flag's "Edit" modal can render its own form: the template
|
||||
# can't call FlagForm(instance=flag) itself, so the form rides along on the flag.
|
||||
flags = list(Flag.objects.prefetch_related("clubs").order_by("name"))
|
||||
for flag in flags:
|
||||
flag.edit_form = FlagForm(instance=flag)
|
||||
|
||||
return super().get_context_data(
|
||||
nav="features",
|
||||
flags=Flag.objects.prefetch_related("clubs").order_by("name"),
|
||||
flags=flags,
|
||||
flag_form=FlagForm(),
|
||||
switches=Switch.objects.order_by("name"),
|
||||
maintenance=Maintenance.current(),
|
||||
maintenance_form=MaintenanceForm(),
|
||||
**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
|
||||
form_class = FlagForm
|
||||
template_name = "controlpanel/flag_form.html"
|
||||
success_url = None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="features", **kwargs)
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:features"
|
||||
|
||||
def form_valid(self, 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
|
||||
|
||||
def get_success_url(self):
|
||||
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
|
||||
form_class = FlagForm
|
||||
template_name = "controlpanel/flag_form.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="features", **kwargs)
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:features"
|
||||
|
||||
def form_valid(self, 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
|
||||
|
||||
def get_success_url(self):
|
||||
@@ -232,7 +297,8 @@ class SwitchToggleView(PlatformStaffRequiredMixin, View):
|
||||
switch = get_object_or_404(Switch, pk=pk)
|
||||
switch.active = not switch.active
|
||||
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")
|
||||
|
||||
|
||||
@@ -240,19 +306,20 @@ class PlatformAdminListView(PlatformSuperuserRequiredMixin, TemplateView):
|
||||
template_name = "controlpanel/admins.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="admins", admins=platform_admins(), **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
|
||||
template_name = "controlpanel/admin_form.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="admins", **kwargs)
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:admins"
|
||||
|
||||
def form_valid(self, form):
|
||||
user = grant_platform_access(form.cleaned_data["email"], is_superuser=form.cleaned_data["is_superuser"])
|
||||
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")
|
||||
|
||||
|
||||
@@ -267,9 +334,9 @@ class PlatformAdminUpdateView(PlatformSuperuserRequiredMixin, View):
|
||||
is_superuser=request.POST.get("is_superuser") == "1",
|
||||
)
|
||||
except PlatformAdminError as error:
|
||||
messages.error(request, str(error))
|
||||
notify(request, f"e|Couldn't update access|{error}")
|
||||
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")
|
||||
|
||||
|
||||
@@ -279,7 +346,223 @@ class PlatformAdminRevokeView(PlatformSuperuserRequiredMixin, View):
|
||||
try:
|
||||
revoke_platform_access(request.user, user)
|
||||
except PlatformAdminError as error:
|
||||
messages.error(request, str(error))
|
||||
notify(request, f"e|Couldn't revoke access|{error}")
|
||||
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")
|
||||
|
||||
|
||||
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
51
deploy/backup.sh
Executable 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
24
deploy/caddy/Caddyfile
Normal 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
9
deploy/caddy/Dockerfile
Normal 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
111
deploy/deploy-dev.sh
Executable 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
30
deploy/restore-check.sh
Executable 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"
|
||||
@@ -1,10 +1,9 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from events.models import EventSeries
|
||||
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."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
31
features/commands.py
Normal file
31
features/commands.py
Normal 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
|
||||
7
features/context_processors.py
Normal file
7
features/context_processors.py
Normal 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
74
features/middleware.py
Normal 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)
|
||||
33
features/migrations/0002_maintenance.py
Normal file
33
features/migrations/0002_maintenance.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -10,11 +10,15 @@ Everything waffle already offers (``everyone`` / ``percent`` / ``staff`` /
|
||||
``superusers`` / per-user / per-group) keeps working untouched.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from waffle.models import CACHE_EMPTY, AbstractUserFlag
|
||||
from waffle.utils import get_cache, keyfmt
|
||||
|
||||
from rosterchief.base import UUIDModel
|
||||
|
||||
#: Cache key template for a flag's club ids, mirroring waffle's own
|
||||
#: FLAG_USERS_CACHE_KEY / FLAG_GROUPS_CACHE_KEY.
|
||||
FLAG_CLUBS_CACHE_KEY = "flag:%s:clubs"
|
||||
@@ -69,3 +73,74 @@ class Flag(AbstractUserFlag):
|
||||
if self.everyone is not None:
|
||||
return self.everyone
|
||||
return club.pk in self._get_club_ids()
|
||||
|
||||
|
||||
class Maintenance(UUIDModel):
|
||||
"""Platform lock-down. One row, read on every request.
|
||||
|
||||
Cached rather than queried per request, and the cache is the same shared Redis the flags
|
||||
use — so turning maintenance on in the control panel takes effect on every worker and
|
||||
every server at once. A per-process cache would leave some workers still serving clubs.
|
||||
"""
|
||||
|
||||
CACHE_KEY = "maintenance:current"
|
||||
|
||||
#: Cached, but not for ever. Write-through makes the flip instant for the process that
|
||||
#: made it and — on the shared Redis of a real deployment — for every other one too. The
|
||||
#: TTL is the belt to that braces: on a per-process cache (a dev box with no Redis, or a
|
||||
#: misconfigured deploy) a lock-down that only reached one gunicorn worker would be worse
|
||||
#: than useless, so the others notice within ten seconds regardless.
|
||||
CACHE_SECONDS = 10
|
||||
|
||||
is_active = models.BooleanField(_("active"), default=False)
|
||||
message = models.TextField(_("message"), blank=True, help_text=_("Shown to clubs while the platform is locked down."))
|
||||
started_at = models.DateTimeField(_("started at"), null=True, blank=True)
|
||||
started_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="maintenance_windows", verbose_name=_("started by"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("maintenance")
|
||||
verbose_name_plural = _("maintenance")
|
||||
|
||||
def __str__(self):
|
||||
return "Maintenance on" if self.is_active else "Maintenance off"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
get_cache().set(self.CACHE_KEY, self, self.CACHE_SECONDS)
|
||||
|
||||
@classmethod
|
||||
def current(cls) -> Maintenance:
|
||||
"""The one row, created on first read. Cached until it changes."""
|
||||
cached = get_cache().get(cls.CACHE_KEY)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
maintenance = cls.objects.first() or cls.objects.create()
|
||||
get_cache().set(cls.CACHE_KEY, maintenance, cls.CACHE_SECONDS)
|
||||
|
||||
return maintenance
|
||||
|
||||
@classmethod
|
||||
def is_on(cls) -> bool:
|
||||
return cls.current().is_active
|
||||
|
||||
@classmethod
|
||||
def start(cls, *, message: str = "", user=None) -> Maintenance:
|
||||
maintenance = cls.current()
|
||||
maintenance.is_active = True
|
||||
maintenance.message = message
|
||||
maintenance.started_at = timezone.now()
|
||||
maintenance.started_by = user
|
||||
maintenance.save()
|
||||
|
||||
return maintenance
|
||||
|
||||
@classmethod
|
||||
def stop(cls) -> Maintenance:
|
||||
maintenance = cls.current()
|
||||
maintenance.is_active = False
|
||||
maintenance.started_at = None
|
||||
maintenance.started_by = None
|
||||
maintenance.save()
|
||||
|
||||
return maintenance
|
||||
|
||||
@@ -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.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 club.models import Club
|
||||
|
||||
from .models import Maintenance
|
||||
|
||||
Flag = get_waffle_flag_model()
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ClubScopedFlagTests(TestCase):
|
||||
@@ -94,3 +103,134 @@ class ClubScopedFlagTests(TestCase):
|
||||
self.club.flags.add(self.flag)
|
||||
|
||||
self.assertTrue(self.active_for(self.club))
|
||||
|
||||
|
||||
@override_settings(
|
||||
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
||||
)
|
||||
class MaintenanceModeTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.addCleanup(cache.clear)
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
self.user = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True)
|
||||
Authenticator.objects.create(user=self.user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
|
||||
|
||||
def club_get(self, path="/"):
|
||||
return self.client.get(path, HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
def platform_get(self, path):
|
||||
return self.client.get(path, HTTP_HOST="rosterchief.app")
|
||||
|
||||
def test_a_club_subdomain_is_closed(self):
|
||||
Maintenance.start(message="Upgrading the database.")
|
||||
|
||||
response = self.club_get("/accounts/login/")
|
||||
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertContains(response, "Upgrading the database", status_code=503)
|
||||
self.assertEqual(response["Retry-After"], "3600")
|
||||
|
||||
def test_a_club_is_open_again_when_maintenance_ends(self):
|
||||
Maintenance.start()
|
||||
Maintenance.stop()
|
||||
|
||||
self.assertEqual(self.club_get("/accounts/login/").status_code, 200)
|
||||
|
||||
def test_the_control_panel_stays_reachable(self):
|
||||
Maintenance.start()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.assertEqual(self.platform_get("/controlpanel/").status_code, 200)
|
||||
|
||||
def test_signing_in_stays_possible(self):
|
||||
# Close /accounts/ as well and you cannot sign in to turn maintenance off: a
|
||||
# lock-down with no key, fixable only from a shell.
|
||||
Maintenance.start()
|
||||
|
||||
self.assertEqual(self.platform_get("/accounts/login/").status_code, 200)
|
||||
|
||||
def test_the_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
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
from django.db import transaction
|
||||
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):
|
||||
"""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):
|
||||
super().__init__(message)
|
||||
@@ -21,12 +21,17 @@ def _is_empty(value):
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def submit_form(form, member, data, *, when=None):
|
||||
"""Create a Submission (with Answers) for ``data`` or raise FormSubmissionError."""
|
||||
def submit_form(form, member, data, *, files=None, when=None):
|
||||
"""Create a Submission (with Answers) for ``data``/``files`` or raise FormSubmissionError.
|
||||
|
||||
Validation goes through ``build_form`` — the same dynamic Django Form the UI would
|
||||
render — so a NUMBER field is actually checked as a decimal, an EMAIL as an email, a
|
||||
CHOICE against its real options, and so on, rather than a hand-rolled subset of that.
|
||||
"""
|
||||
when = when or timezone.now()
|
||||
|
||||
_check_open(form, member, when)
|
||||
cleaned = _clean_answers(form, data)
|
||||
cleaned = _clean_answers(form, data, files)
|
||||
|
||||
submission = Submission.objects.create(form=form, member=member)
|
||||
Answer.objects.bulk_create([Answer(submission=submission, field=field, value=value) for field, value in cleaned])
|
||||
@@ -48,37 +53,13 @@ def _check_open(form, member, when):
|
||||
raise FormSubmissionError("You have reached the maximum number of submissions for this form.")
|
||||
|
||||
|
||||
def _clean_answers(form, data):
|
||||
errors = {}
|
||||
cleaned = []
|
||||
|
||||
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:
|
||||
def _clean_answers(form, data, files):
|
||||
bound_form = build_form(form, data=data, files=files or {})
|
||||
if not bound_form.is_valid():
|
||||
errors = {key: list(messages) for key, messages in bound_form.errors.items()}
|
||||
raise FormSubmissionError("The submission has errors.", errors=errors)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _validate_choice(field, raw):
|
||||
if field.field_type == Field.FieldType.CHOICE:
|
||||
allowed = allowed_values(field)
|
||||
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
|
||||
# Blank optional answers are validated (they may legitimately be empty) but not
|
||||
# stored — an Answer row exists only where the submitter actually said something.
|
||||
fields_by_key = {field.key: field for field in form.fields.filter(is_active=True)}
|
||||
return [(fields_by_key[key], value) for key, value in bound_form.cleaned_data.items() if not _is_empty(value)]
|
||||
|
||||
@@ -211,6 +211,24 @@ class SubmitFormTests(FormbuilderTestBase):
|
||||
|
||||
self.assertIn("size", ctx.exception.errors)
|
||||
|
||||
def test_number_field_rejects_non_numeric_input(self):
|
||||
# Validation goes through the same dynamic Django Form the UI renders, so a
|
||||
# NUMBER field is checked as a decimal — not merely "present".
|
||||
Field.objects.create(form=self.form, key="age", label="Age", field_type=Field.FieldType.NUMBER, required=True, order=3)
|
||||
|
||||
with self.assertRaises(FormSubmissionError) as ctx:
|
||||
submit_form(self.form, self.member, {"name": "Jane", "age": "not-a-number"})
|
||||
|
||||
self.assertIn("age", ctx.exception.errors)
|
||||
|
||||
def test_email_field_rejects_an_invalid_address(self):
|
||||
Field.objects.create(form=self.form, key="contact", label="Contact", field_type=Field.FieldType.EMAIL, required=True, order=3)
|
||||
|
||||
with self.assertRaises(FormSubmissionError) as ctx:
|
||||
submit_form(self.form, self.member, {"name": "Jane", "contact": "not-an-email"})
|
||||
|
||||
self.assertIn("contact", ctx.exception.errors)
|
||||
|
||||
def test_multichoice_validation(self):
|
||||
field = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue", "wed"])
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from features.commands import CommandError, MaintenanceAwareCommand
|
||||
from members.services import MemberCsvImporter
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
class Command(MaintenanceAwareCommand):
|
||||
help = "Import club members from a CSV file."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
|
||||
@@ -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)
|
||||
|
||||
_, membership_created = ClubMembership.objects.update_or_create(
|
||||
@@ -141,6 +141,19 @@ class MemberCsvImporter:
|
||||
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:
|
||||
# Season.get_current() is tenant-scoped, so bind the row's club as the
|
||||
# active tenant for the lookup.
|
||||
|
||||
@@ -554,8 +554,9 @@ class ImportMembersCsvCommandTests(TestCase):
|
||||
self.assertFalse(Member.objects.filter(email="nofirst@example.com").exists())
|
||||
|
||||
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
|
||||
# is atomic — the member and club creation roll back too.
|
||||
# The club exists but has no season, so the row is skipped and — because
|
||||
# the row is atomic — the member creation rolls back too.
|
||||
Club.objects.create(name="New Club")
|
||||
csv_path = self.write_csv(
|
||||
"\n".join(
|
||||
[
|
||||
@@ -571,7 +572,45 @@ class ImportMembersCsvCommandTests(TestCase):
|
||||
self.assertIn("No current season for club 'New Club'.", stderr)
|
||||
self.assertIn("Rows skipped: 1.", stdout)
|
||||
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):
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tailwindcss -i ./static/src/app.css -o ./static/css/app.css --minify",
|
||||
"watch": "tailwindcss -i ./static/src/app.css -o ./static/css/app.css --watch"
|
||||
"build": "tailwindcss -i ./assets/app.css -o ./static/css/app.css --minify",
|
||||
"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": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
@@ -8,10 +8,16 @@ dependencies = [
|
||||
"django-allauth[mfa]>=65.18.0",
|
||||
"django-lucide",
|
||||
"django-phonenumber-field[phonenumbers]>=8.4.0",
|
||||
"django-redis>=7.0.0",
|
||||
"django-storages[s3]>=1.14.6",
|
||||
"django-waffle>=5.0.0",
|
||||
"gunicorn>=26.0.0",
|
||||
"pillow>=12.3.0",
|
||||
"psycopg[binary]>=3.3.4",
|
||||
"python-dateutil>=2.9.0.post0",
|
||||
"python-decouple>=3.8",
|
||||
"weasyprint>=69.0",
|
||||
"whitenoise>=6.12.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -49,7 +55,8 @@ ignore = [
|
||||
"rosterchief/settings/*" = ["F403", "F405", "E501"]
|
||||
|
||||
[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]
|
||||
django-lucide = { git = "https://github.com/bsiebens/lucide" }
|
||||
|
||||
44
rosterchief/health.py
Normal file
44
rosterchief/health.py
Normal 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
|
||||
@@ -10,6 +10,7 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||
"""
|
||||
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
|
||||
from decouple import Csv, config
|
||||
@@ -29,6 +30,12 @@ SECRET_KEY = config("DJANGO_SECRET_KEY")
|
||||
DEBUG = config("DJANGO_DEBUG", default=False, cast=bool)
|
||||
|
||||
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")
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = config("DJANGO_CSRF_TRUSTED_ORIGINS", cast=Csv(), default="")
|
||||
@@ -61,6 +68,8 @@ INSTALLED_APPS = [
|
||||
"events.apps.EventsConfig",
|
||||
"formbuilder.apps.FormbuilderConfig",
|
||||
"shop.apps.ShopConfig",
|
||||
# Platform billing: RosterChief charging the clubs. Not tenant data — see billing/models.py.
|
||||
"billing.apps.BillingConfig",
|
||||
"waffle",
|
||||
"features.apps.FeaturesConfig",
|
||||
"controlpanel.apps.ControlpanelConfig",
|
||||
@@ -73,6 +82,10 @@ WAFFLE_FLAG_MODEL = "features.Flag"
|
||||
|
||||
MIDDLEWARE = [
|
||||
"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.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -80,14 +93,23 @@ MIDDLEWARE = [
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
"authentication.middleware.RequireMFAMiddleware",
|
||||
"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.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
# Reload the browser when templates, static files or Python change. Dev only: it
|
||||
# injects a script tag into every HTML response and serves an open event stream,
|
||||
# neither of which belongs in production.
|
||||
if DEBUG:
|
||||
# Reload the browser when templates, static files or Python change. Dev only: it injects a
|
||||
# script tag into every HTML response and serves an open event stream, neither of which
|
||||
# belongs in production.
|
||||
#
|
||||
# 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"]
|
||||
MIDDLEWARE += ["django_browser_reload.middleware.BrowserReloadMiddleware"]
|
||||
|
||||
@@ -160,6 +182,7 @@ TEMPLATES = [
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"club.context_processors.branding",
|
||||
"features.context_processors.maintenance",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -207,7 +230,20 @@ USE_I18N = 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/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
@@ -217,6 +253,44 @@ STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||
MEDIA_URL = "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)
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import importlib
|
||||
from unittest import mock
|
||||
|
||||
from django.db.utils import OperationalError
|
||||
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
|
||||
|
||||
@@ -28,3 +30,59 @@ class BrowserReloadUrlTests(SimpleTestCase):
|
||||
|
||||
with self.assertRaises(Resolver404):
|
||||
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)
|
||||
|
||||
@@ -15,7 +15,11 @@ from django.views.generic import RedirectView
|
||||
|
||||
from club.views import root
|
||||
|
||||
from .health import healthz
|
||||
|
||||
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/", admin.site.urls),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
@@ -26,6 +30,11 @@ urlpatterns = [
|
||||
]
|
||||
|
||||
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.
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
@@ -11,6 +11,14 @@ from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
|
||||
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):
|
||||
"""Next per-club sequential number for the current year: ``<code>-<year>-<seq>``."""
|
||||
prefix = f"{code}-{timezone.now().year}-"
|
||||
@@ -46,10 +54,6 @@ class Product(ClubScopedModel):
|
||||
MERCHANDISE = "merchandise", _("Merchandise")
|
||||
DONATION = "donation", _("Donation")
|
||||
|
||||
class DiscountType(models.TextChoices):
|
||||
PERCENTAGE = "percentage", _("Percentage")
|
||||
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, blank=True)
|
||||
|
||||
@@ -191,10 +195,6 @@ class OrderLine(UUIDModel):
|
||||
|
||||
|
||||
class Discount(ClubScopedModel):
|
||||
class DiscountType(models.TextChoices):
|
||||
PERCENTAGE = "percentage", _("Percentage")
|
||||
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, blank=True)
|
||||
description = models.TextField(_("description"), blank=True)
|
||||
@@ -219,10 +219,6 @@ class Discount(ClubScopedModel):
|
||||
|
||||
|
||||
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"))
|
||||
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):
|
||||
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}"
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -21,6 +21,7 @@ from .models import (
|
||||
Cart,
|
||||
CartItem,
|
||||
Discount,
|
||||
DiscountType,
|
||||
Invoice,
|
||||
Order,
|
||||
OrderLine,
|
||||
@@ -251,12 +252,12 @@ class AppliedDiscountTests(ShopEntitiesTestBase):
|
||||
return AppliedDiscount.objects.create(**kwargs)
|
||||
|
||||
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%")
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
:root, :host {
|
||||
--font-sans: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
|
||||
--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-blue-500: oklch(62.3% 0.214 259.815);
|
||||
--color-black: #000;
|
||||
--color-white: #fff;
|
||||
--spacing: 0.25rem;
|
||||
@@ -30,7 +32,12 @@
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--font-weight-extrabold: 800;
|
||||
--tracking-wide: 0.025em;
|
||||
--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-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-transition-duration: 150ms;
|
||||
@@ -189,6 +196,69 @@
|
||||
}
|
||||
}
|
||||
@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 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
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 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: flex;
|
||||
@@ -1197,6 +1181,9 @@
|
||||
.collapse {
|
||||
visibility: collapse;
|
||||
}
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -1997,6 +1984,49 @@
|
||||
white-space: nowrap;
|
||||
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 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
@@ -2336,6 +2366,9 @@
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
.sticky {
|
||||
position: sticky;
|
||||
}
|
||||
.dropdown-right {
|
||||
@layer daisyui.l1.l2 {
|
||||
--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-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 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: grid;
|
||||
@@ -2777,34 +2804,25 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.stat-value {
|
||||
.modal-box {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
grid-column-start: 1;
|
||||
white-space: nowrap;
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
.stat-desc {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
.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;
|
||||
grid-row-start: 1;
|
||||
max-height: 100vh;
|
||||
width: calc(11 / 12 * 100%);
|
||||
max-width: 32rem;
|
||||
background-color: var(--color-base-100);
|
||||
padding: calc(0.25rem * 6);
|
||||
transition: translate 0.3s ease-out, scale 0.3s ease-out, opacity 0.2s ease-out 0.05s, box-shadow 0.3s ease-out;
|
||||
border-top-left-radius: var(--modal-tl, var(--radius-box));
|
||||
border-top-right-radius: var(--modal-tr, var(--radius-box));
|
||||
border-bottom-left-radius: var(--modal-bl, var(--radius-box));
|
||||
border-bottom-right-radius: var(--modal-br, var(--radius-box));
|
||||
scale: 95%;
|
||||
opacity: 0;
|
||||
box-shadow: oklch(0% 0 0/ 0.25) 0px 25px 50px -12px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
margin-top: var(--spacing);
|
||||
}
|
||||
@@ -2936,6 +2962,9 @@
|
||||
.mb-2 {
|
||||
margin-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mb-3 {
|
||||
margin-bottom: calc(var(--spacing) * 3);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -2945,9 +2974,6 @@
|
||||
.mb-10 {
|
||||
margin-bottom: calc(var(--spacing) * 10);
|
||||
}
|
||||
.ml-1 {
|
||||
margin-left: var(--spacing);
|
||||
}
|
||||
.ml-2 {
|
||||
margin-left: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -2998,17 +3024,6 @@
|
||||
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 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: grid;
|
||||
@@ -3192,6 +3207,9 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -3204,14 +3222,20 @@
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.h-12 {
|
||||
height: calc(var(--spacing) * 12);
|
||||
}
|
||||
.h-16 {
|
||||
height: calc(var(--spacing) * 16);
|
||||
}
|
||||
.h-56 {
|
||||
height: calc(var(--spacing) * 56);
|
||||
}
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
.h-screen {
|
||||
height: 100vh;
|
||||
}
|
||||
.w-12 {
|
||||
width: calc(var(--spacing) * 12);
|
||||
}
|
||||
.w-16 {
|
||||
width: calc(var(--spacing) * 16);
|
||||
@@ -3222,8 +3246,8 @@
|
||||
.w-60 {
|
||||
width: calc(var(--spacing) * 60);
|
||||
}
|
||||
.w-fit {
|
||||
width: fit-content;
|
||||
.w-64 {
|
||||
width: calc(var(--spacing) * 64);
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
@@ -3243,6 +3267,9 @@
|
||||
.flex-shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -3307,6 +3334,9 @@
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -3319,6 +3349,12 @@
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.items-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -3376,9 +3412,15 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@@ -3399,6 +3441,10 @@
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 2px;
|
||||
}
|
||||
.border-r {
|
||||
border-right-style: var(--tw-border-style);
|
||||
border-right-width: 1px;
|
||||
}
|
||||
.border-b {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 1px;
|
||||
@@ -3415,6 +3461,14 @@
|
||||
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-color: var(--color-base-300);
|
||||
}
|
||||
@@ -3427,20 +3481,33 @@
|
||||
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-color: var(--color-error);
|
||||
}
|
||||
.border-info {
|
||||
border-color: var(--color-info);
|
||||
}
|
||||
.border-success {
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
.border-warning {
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
.border-white {
|
||||
border-color: var(--color-white);
|
||||
.menu-active {
|
||||
: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 {
|
||||
background-color: var(--color-base-100);
|
||||
@@ -3448,6 +3515,12 @@
|
||||
.bg-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 {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
@@ -3495,6 +3568,31 @@
|
||||
--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 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
--btn-bg: #0000;
|
||||
@@ -3520,17 +3618,6 @@
|
||||
.p-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 {
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -3543,8 +3630,8 @@
|
||||
.py-2 {
|
||||
padding-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
.py-4 {
|
||||
padding-block: calc(var(--spacing) * 4);
|
||||
.py-6 {
|
||||
padding-block: calc(var(--spacing) * 6);
|
||||
}
|
||||
.pt-2 {
|
||||
padding-top: calc(var(--spacing) * 2);
|
||||
@@ -3555,6 +3642,9 @@
|
||||
.pb-2 {
|
||||
padding-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
.pl-8 {
|
||||
padding-left: calc(var(--spacing) * 8);
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -3629,10 +3719,17 @@
|
||||
--tw-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 {
|
||||
--tw-tracking: var(--tracking-wider);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.alert-error {
|
||||
@layer daisyui.l1.l2 {
|
||||
color: var(--color-error-content);
|
||||
@@ -3661,9 +3758,19 @@
|
||||
--alert-color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
.progress-primary {
|
||||
.progress-error {
|
||||
@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 {
|
||||
@@ -3690,6 +3797,9 @@
|
||||
.text-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
.text-neutral-content {
|
||||
color: var(--color-neutral-content);
|
||||
}
|
||||
.text-primary-content {
|
||||
color: var(--color-primary-content);
|
||||
}
|
||||
@@ -3730,6 +3840,9 @@
|
||||
.opacity-70 {
|
||||
opacity: 70%;
|
||||
}
|
||||
.opacity-80 {
|
||||
opacity: 80%;
|
||||
}
|
||||
.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));
|
||||
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);
|
||||
}
|
||||
}
|
||||
.badge-accent {
|
||||
@layer daisyui.l1.l2 {
|
||||
--badge-color: var(--color-accent);
|
||||
--badge-fg: var(--color-accent-content);
|
||||
}
|
||||
}
|
||||
.badge-error {
|
||||
@layer daisyui.l1.l2 {
|
||||
--badge-color: var(--color-error);
|
||||
--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 {
|
||||
@layer daisyui.l1.l2 {
|
||||
--badge-color: var(--color-neutral);
|
||||
@@ -3871,19 +3996,29 @@
|
||||
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 {
|
||||
@media (width >= 64rem) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.lg\:grid-cols-3 {
|
||||
.lg\:grid-cols-6 {
|
||||
@media (width >= 64rem) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.lg\:grid-cols-4 {
|
||||
@media (width >= 64rem) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -33,8 +33,14 @@
|
||||
{% block extra %}{% endblock extra %}
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-base-200">
|
||||
<div class="navbar mb-4 border-b border-base-300 bg-base-100 px-6 shadow-sm">
|
||||
{% comment %}
|
||||
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">
|
||||
{% block brand %}{% endblock brand %}
|
||||
</div>
|
||||
@@ -65,25 +71,36 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
<div class="mx-auto mt-4 w-full space-y-2 px-4">
|
||||
{% for message in messages %}
|
||||
{% with alert=message|as_alert %}
|
||||
<div class="alert alert-soft {{ 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 %}
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
{% comment %}
|
||||
The sidebar is a sibling of <main>, not inside it, so it sits outside the one
|
||||
scrolling region and stays put by construction. Pages with no menu (the auth
|
||||
screens) leave the block empty and <main> simply takes the full width.
|
||||
{% endcomment %}
|
||||
{% block menu %}{% endblock menu %}
|
||||
|
||||
<main class="mx-auto w-full py-4 px-8">
|
||||
{% block main %}{% endblock main %}
|
||||
</main>
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="mx-auto w-full px-8 py-6">
|
||||
{% 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>
|
||||
// The button cycles light -> dark -> auto. "auto" removes the attribute and the
|
||||
|
||||
24
templates/maintenance.html
Normal file
24
templates/maintenance.html
Normal 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
344
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.43.47"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/76/8c2b062b9a9f27db8d78c3183213efd2cb31493b9a81853025da5254652e/boto3-1.43.47.tar.gz", hash = "sha256:09a8ce4abab4391bf08315e2dcc449b4b33e97ce7654ee9398d9fe4ef73a33da", size = 112681, upload-time = "2026-07-13T19:32:13.511Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/86/4aaa23e1433a6f4021258b1d8e4ec129bf691e419339454806807595a93d/boto3-1.43.47-py3-none-any.whl", hash = "sha256:aef0cccd5bc4a443fb3e64fada023f7b5b9da36864cb4255b68cf93067476676", size = 140031, upload-time = "2026-07-13T19:32:11.072Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.43.47"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/2c/279bf51f68e85a12323996aa4a7f2a163da84dad949ee751caa318928ce1/botocore-1.43.47.tar.gz", hash = "sha256:9e04d8da7f9cff8a911b14284829f78b74e1ce785444833199837decb5ecc17a", size = 15696311, upload-time = "2026-07-13T19:32:01.095Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/c0/6a8f824d613c5e5b8755ce99e3cf83fd1db135f5df02da7ed5bf106abdfd/botocore-1.43.47-py3-none-any.whl", hash = "sha256:4d16559a3a1866fa7b9e651dd1422c1a033497e069f8a73cae5eebafe70ff39c", size = 15382537, upload-time = "2026-07-13T19:31:56.213Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotlicffi"
|
||||
version = "1.2.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssselect2"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tinycss2" },
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dj-database-url"
|
||||
version = "3.1.2"
|
||||
@@ -248,6 +328,36 @@ phonenumbers = [
|
||||
{ name = "phonenumbers" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-redis"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "redis" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/78/203a0cdc0f1c083a5407d01f77b58099a120bb8a5a04562f56a9bb341314/django_redis-7.0.0.tar.gz", hash = "sha256:e48491c862f4350b0747ceb1016700686fb93c4f4e0fb9c490fe6c6658ffd933", size = 64601, upload-time = "2026-06-02T14:17:48.819Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/9f/09cdb9a1eebe8533b02a7694ca787acfc1e4d93b5b6175ff99366d4e6d64/django_redis-7.0.0-py3-none-any.whl", hash = "sha256:4b23aa6e0cd0937bb1242e9a463809e6004de3ca2150f34e986306bb6220d688", size = 38932, upload-time = "2026-06-02T14:17:47.281Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-storages"
|
||||
version = "1.14.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
s3 = [
|
||||
{ name = "boto3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-waffle"
|
||||
version = "5.0.0"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "phonenumbers"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
binary = [
|
||||
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydyf"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716, upload-time = "2025-12-02T14:52:14.244Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028, upload-time = "2025-12-02T14:52:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyphen"
|
||||
version = "0.17.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "8.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rosterchief"
|
||||
version = "0.1.0"
|
||||
@@ -383,10 +617,16 @@ dependencies = [
|
||||
{ name = "django-allauth", extra = ["mfa"] },
|
||||
{ name = "django-lucide" },
|
||||
{ name = "django-phonenumber-field", extra = ["phonenumbers"] },
|
||||
{ name = "django-redis" },
|
||||
{ name = "django-storages", extra = ["s3"] },
|
||||
{ name = "django-waffle" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "python-decouple" },
|
||||
{ name = "weasyprint" },
|
||||
{ name = "whitenoise" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -403,10 +643,16 @@ requires-dist = [
|
||||
{ name = "django-allauth", extras = ["mfa"], specifier = ">=65.18.0" },
|
||||
{ name = "django-lucide", git = "https://github.com/bsiebens/lucide" },
|
||||
{ name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" },
|
||||
{ name = "django-redis", specifier = ">=7.0.0" },
|
||||
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
|
||||
{ name = "django-waffle", specifier = ">=5.0.0" },
|
||||
{ name = "gunicorn", specifier = ">=26.0.0" },
|
||||
{ name = "pillow", specifier = ">=12.3.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
{ name = "python-decouple", specifier = ">=3.8" },
|
||||
{ name = "weasyprint", specifier = ">=69.0" },
|
||||
{ name = "whitenoise", specifier = ">=6.12.0" },
|
||||
]
|
||||
|
||||
[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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.19.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/da/4bef7ce7bb989b222aa4785a413896dbec53306dfc59c6ce7d16a7ffbd6a/s3transfer-0.19.1.tar.gz", hash = "sha256:d3d6371dc3f1e5c5427b2b457bcf13bcf87bec334c95aed18642eae61f6926f3", size = 165354, upload-time = "2026-07-10T19:32:04.849Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/23/e84c64ad0e8bc59cd1b2ef98def848deff0ef3456c542afe74d51e9e8c85/s3transfer-0.19.1-py3-none-any.whl", hash = "sha256:d5fd7005ee39307455ad5f310b5ea67f4b1960d7fed5b3671ee50c249de675de", size = 90072, upload-time = "2026-07-10T19:32:03.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinycss2"
|
||||
version = "1.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyhtml5"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/1f/cfe2f6b30557c92b3f31d41707e09cef5c1efbd87392bc6c0430c46b0e4d/tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67", size = 179242, upload-time = "2026-03-05T17:06:30.704Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.2"
|
||||
@@ -467,3 +749,65 @@ sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35c
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weasyprint"
|
||||
version = "69.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
{ name = "cssselect2" },
|
||||
{ name = "fonttools", extra = ["woff"] },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydyf" },
|
||||
{ name = "pyphen" },
|
||||
{ name = "tinycss2" },
|
||||
{ name = "tinyhtml5" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834, upload-time = "2026-06-02T14:42:17.765Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/cb/208525c6bd5033d7b2589b55e07bec23d9c61bb00703cbaf20ef52c3811f/weasyprint-69.0-py3-none-any.whl", hash = "sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6", size = 322872, upload-time = "2026-06-02T14:42:15.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webencodings"
|
||||
version = "0.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whitenoise"
|
||||
version = "6.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user