Compare commits
10 Commits
192fe5ad0e
...
534d01d6fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/
|
||||
|
||||
539
DEPLOYMENT.md
Normal file
539
DEPLOYMENT.md
Normal file
@@ -0,0 +1,539 @@
|
||||
# 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
73
Dockerfile
Normal file
73
Dockerfile
Normal file
@@ -0,0 +1,73 @@
|
||||
# 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 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/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
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
|
||||
|
||||
COPY . .
|
||||
COPY --from=css /build/static/css/app.css ./static/css/app.css
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
|
||||
|
||||
# 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_archive"]
|
||||
list_filter = ["tier", "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."))
|
||||
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'),
|
||||
),
|
||||
]
|
||||
0
billing/migrations/__init__.py
Normal file
0
billing/migrations/__init__.py
Normal file
247
billing/models.py
Normal file
247
billing/models.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""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
|
||||
|
||||
|
||||
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_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.
|
||||
"""
|
||||
153
billing/services/dues.py
Normal file
153
billing/services/dues.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""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 Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from billing.models import 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) -> 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})
|
||||
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(today: date | None = None):
|
||||
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)
|
||||
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>
|
||||
337
billing/tests.py
Normal file
337
billing/tests.py
Normal file
@@ -0,0 +1,337 @@
|
||||
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.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, subscribe, 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)))
|
||||
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_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."),
|
||||
)
|
||||
|
||||
@@ -17,6 +17,8 @@ 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
|
||||
from club.models import Club, ClubMembership, ClubRole, Season
|
||||
from events.models import Attendance, Event
|
||||
from members.models import Member
|
||||
@@ -78,6 +80,10 @@ 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]),
|
||||
)
|
||||
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
|
||||
.order_by("name")
|
||||
@@ -160,9 +166,22 @@ 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(),
|
||||
}
|
||||
|
||||
|
||||
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 +202,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")),
|
||||
}
|
||||
|
||||
|
||||
|
||||
40
controlpanel/templates/controlpanel/_billing_form.html
Normal file
40
controlpanel/templates/controlpanel/_billing_form.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% load lucide ui %}
|
||||
|
||||
{% comment %}
|
||||
The shell every billing form uses: card, fields, cancel + submit. Included with
|
||||
`heading`, `blurb`, `submit_label`, `submit_icon` and `cancel_url`.
|
||||
{% endcomment %}
|
||||
<div class="card max-w-xl bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
{% if blurb %}<p class="text-sm opacity-70">{{ blurb }}</p>{% endif %}
|
||||
<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 block 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="{{ cancel_url }}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||
<button class="btn btn-primary gap-2" type="submit">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:"Save" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
83
controlpanel/templates/controlpanel/_club_health_table.html
Normal file
83
controlpanel/templates/controlpanel/_club_health_table.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{% 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 class="text-right">Members</th>
|
||||
<th class="text-right">Unpaid</th>
|
||||
<th class="text-right">Owed</th>
|
||||
<th>Plan</th>
|
||||
<th class="text-right">Dues</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 club.is_archived %}
|
||||
{% comment %}
|
||||
An archived club's subdomain does not resolve, so "dormant" and
|
||||
"no season" would be noise: of course nothing is scheduled.
|
||||
{% endcomment %}
|
||||
<span class="badge badge-warning badge-xs gap-1">{% lucide "archive" size=10 %} Archived</span>
|
||||
{% else %}
|
||||
{% 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 %}
|
||||
{% 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>
|
||||
{% if club.tier_name %}
|
||||
<span class="text-sm">{{ club.tier_name }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning badge-xs">Not billed</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right tabular-nums">
|
||||
{% if not club.dues_owed %}
|
||||
{% if club.tier_name %}<span class="badge badge-success badge-xs">Paid</span>{% endif %}
|
||||
{% else %}
|
||||
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
|
||||
{% if club.dues_grace_until < today %}
|
||||
<span class="badge badge-error badge-xs">Overdue</span>
|
||||
{% elif club.dues_period_end < today %}
|
||||
<span class="badge badge-warning badge-xs">Grace</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</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="9" class="text-center opacity-60">{{ empty_message|default:"No clubs yet." }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -6,6 +6,16 @@
|
||||
{% endblock title %}
|
||||
|
||||
{% block main %}
|
||||
{% if maintenance_on %}
|
||||
<div class="alert alert-error mb-6">
|
||||
{% lucide "wrench" size=20 %}
|
||||
<span>
|
||||
<strong>The platform is closed for maintenance.</strong>
|
||||
Clubs see a maintenance page and the scheduled jobs are standing down.
|
||||
</span>
|
||||
<a class="btn btn-sm" href="{% url 'controlpanel:features' %}">Reopen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-bold">
|
||||
@@ -22,6 +32,7 @@
|
||||
<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:billing' %}" class="tab gap-2 {% if nav == 'billing' %}tab-active{% endif %}">{% lucide "receipt-euro" size=16 %} Billing</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>
|
||||
|
||||
117
controlpanel/templates/controlpanel/billing.html
Normal file
117
controlpanel/templates/controlpanel/billing.html
Normal file
@@ -0,0 +1,117 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide %}
|
||||
|
||||
{% block heading %}Billing{% endblock heading %}
|
||||
{% block subheading %}<p class="text-sm opacity-70">What the platform charges its clubs.</p>{% endblock subheading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:tier_create' %}">{% lucide "plus" size=16 %} New tier</a>
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="card mb-6 bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Tiers</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 is a new dated price. Periods already billed keep the amount they were issued at.</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tier</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="text-right">
|
||||
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_price_create' tier.pk %}">{% lucide "euro" size=14 %} New price</a>
|
||||
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_update' tier.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center opacity-60">No tiers yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>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>
|
||||
{% 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-ghost">{{ due.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a class="btn btn-primary btn-xs gap-1" href="{% url 'controlpanel:due_pay' due.pk %}">{% lucide "banknote" size=14 %} Record payment</a>
|
||||
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-text" size=14 %} Invoice</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center opacity-60">Nothing outstanding.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
@@ -213,6 +213,102 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:club_subscribe' club.pk %}">
|
||||
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
|
||||
</a>
|
||||
{% if subscription %}
|
||||
<a class="btn btn-primary btn-sm gap-2" href="{% url 'controlpanel:club_open_period' club.pk %}">
|
||||
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
|
||||
</a>
|
||||
{% 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 <strong>{{ subscription.tier.name }}</strong>.
|
||||
{% 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>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>
|
||||
{% 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-ghost">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-ghost">{{ due.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if due.is_owing %}
|
||||
<a class="btn btn-primary btn-xs gap-1" href="{% url 'controlpanel:due_pay' due.pk %}">{% lucide "banknote" size=14 %} Pay</a>
|
||||
{% if not due.payments.all %}
|
||||
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-ghost btn-xs gap-1" type="submit">{% lucide "ban" size=14 %} Waive</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-text" size=14 %} 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
@@ -24,39 +24,11 @@
|
||||
</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 %}
|
||||
|
||||
@@ -37,11 +37,18 @@
|
||||
<div class="text-xs opacity-60">Admins locked out until they enrol</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 {% if attention.dues_owed %}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">Unpaid across every club</div>
|
||||
{% comment %}
|
||||
What the CLUBS owe US. Not to be confused with the club-shop money below,
|
||||
which members owe their clubs and is never ours.
|
||||
{% endcomment %}
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "receipt-euro" size=16 %} Dues owed</div>
|
||||
<div class="text-3xl font-bold tabular-nums">€{{ 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +65,8 @@
|
||||
</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>
|
||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Platform dues per month</h2>
|
||||
<p class="text-sm opacity-70">What clubs paid us. Club-shop money is theirs, not ours.</p>
|
||||
<div class="h-56">
|
||||
<canvas id="revenue-chart"></canvas>
|
||||
</div>
|
||||
@@ -138,60 +146,17 @@
|
||||
<div class="stat-title">Club admins</div>
|
||||
<div class="stat-value">{{ totals.admins }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Not billed</div>
|
||||
<div class="stat-value {% if attention.clubs_unbilled %}text-warning{% endif %}">{{ attention.clubs_unbilled }}</div>
|
||||
<div class="stat-desc">Clubs on no tier</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>
|
||||
{% include "controlpanel/_club_health_table.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
@@ -269,7 +234,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
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,5 +1,5 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide %}
|
||||
{% load lucide ui %}
|
||||
|
||||
{% block heading %}Features{% endblock heading %}
|
||||
|
||||
@@ -8,6 +8,48 @@
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
{% 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>
|
||||
<h2 class="card-title text-base">{% lucide "wrench" size=18 %} Maintenance mode</h2>
|
||||
{% if maintenance.is_active %}
|
||||
<p class="text-sm">
|
||||
<span class="badge badge-error gap-1">{% lucide "lock" size=12 %} Platform closed</span>
|
||||
since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}.
|
||||
</p>
|
||||
{% if maintenance.message %}<p class="mt-1 text-sm opacity-70">“{{ maintenance.message }}”</p>{% 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 max-w-xl">
|
||||
<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-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>
|
||||
|
||||
14
controlpanel/templates/controlpanel/payment_form.html
Normal file
14
controlpanel/templates/controlpanel/payment_form.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
|
||||
{% block heading %}Record payment — {{ due.club }}{% endblock heading %}
|
||||
|
||||
{% block subheading %}
|
||||
<p class="text-sm opacity-70">
|
||||
{{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }} · €{{ due.amount|floatformat:2 }} billed · €{{ due.balance|floatformat:2 }} outstanding
|
||||
</p>
|
||||
{% endblock subheading %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:club_detail' due.club.pk as club_url %}
|
||||
{% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Record payment" submit_icon="banknote" blurb="Part payments are fine: they accumulate against the period until it is settled." %}
|
||||
{% endblock panel %}
|
||||
12
controlpanel/templates/controlpanel/period_form.html
Normal file
12
controlpanel/templates/controlpanel/period_form.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
|
||||
{% block heading %}{% if club.is_archived %}Reactivate{% else %}Open period{% endif %} — {{ club }}{% endblock heading %}
|
||||
|
||||
{% block subheading %}
|
||||
<p class="text-sm opacity-70">Next period starts {{ next_start|date:"j M Y" }} unless you say otherwise.</p>
|
||||
{% endblock subheading %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:club_detail' club.pk as club_url %}
|
||||
{% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Open period" submit_icon="calendar-plus" blurb="By default the period continues from the end of the last one, so a lapsed year is still owed. Pick a start date to forgive the gap." %}
|
||||
{% endblock panel %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
|
||||
{% block heading %}{% if subscription %}Change plan{% else %}Start billing{% endif %} — {{ club }}{% endblock heading %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:club_detail' club.pk as club_url %}
|
||||
{% include "controlpanel/_billing_form.html" with cancel_url=club_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." %}
|
||||
{% endblock panel %}
|
||||
8
controlpanel/templates/controlpanel/tier_form.html
Normal file
8
controlpanel/templates/controlpanel/tier_form.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
|
||||
{% block heading %}{% if object %}Edit {{ object }}{% else %}New tier{% endif %}{% endblock heading %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:billing' as billing_url %}
|
||||
{% include "controlpanel/_billing_form.html" with cancel_url=billing_url submit_label="Save tier" submit_icon="layers" blurb="A tier has no price until you add one. A tier with no price cannot be billed." %}
|
||||
{% endblock panel %}
|
||||
8
controlpanel/templates/controlpanel/tier_price_form.html
Normal file
8
controlpanel/templates/controlpanel/tier_price_form.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
|
||||
{% block heading %}New price for {{ tier }}{% endblock heading %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:billing' as billing_url %}
|
||||
{% include "controlpanel/_billing_form.html" with cancel_url=billing_url submit_label="Add price" submit_icon="euro" blurb="Prices are dated, never edited: periods already opened keep the amount they were billed at, so this cannot rewrite an invoice you have already sent." %}
|
||||
{% endblock panel %}
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
@@ -12,8 +15,12 @@ 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
|
||||
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
|
||||
@@ -728,11 +735,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"))
|
||||
|
||||
|
||||
@@ -1008,3 +1017,337 @@ class ClubHealthTableTests(TestCase):
|
||||
self.assertContains(response, "Owed")
|
||||
self.assertContains(response, "Upcoming")
|
||||
self.assertContains(response, "Unpaid")
|
||||
|
||||
|
||||
class ClubListHealthTests(ControlPanelTestBase):
|
||||
def test_the_list_shows_the_same_health_columns_as_the_dashboard(self):
|
||||
response = self.client.get(reverse("controlpanel:club_list"))
|
||||
|
||||
self.assertContains(response, "Owed")
|
||||
self.assertContains(response, "Upcoming")
|
||||
self.assertContains(response, "Unpaid")
|
||||
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_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_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_render(self):
|
||||
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, 200, url)
|
||||
|
||||
def test_the_payment_form_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:due_pay", args=[due.pk]))
|
||||
|
||||
self.assertEqual(response.context["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,13 +1,21 @@
|
||||
from django.contrib import messages
|
||||
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.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 .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm
|
||||
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin
|
||||
from .services.admins import grant_club_admin, revoke_club_admin
|
||||
from .services.platform_admins import (
|
||||
@@ -17,7 +25,7 @@ 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, onboarding_funnel, platform_attention, platform_charts, platform_totals
|
||||
|
||||
Flag = get_waffle_flag_model()
|
||||
Switch = get_waffle_switch_model()
|
||||
@@ -35,6 +43,7 @@ class DashboardView(PlatformStaffRequiredMixin, TemplateView):
|
||||
flags=flag_adoption(),
|
||||
charts=platform_charts(),
|
||||
clubs=clubs_with_health(),
|
||||
today=timezone.localdate(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -52,10 +61,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):
|
||||
@@ -97,6 +106,9 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
|
||||
groups=club_statistics(self.object),
|
||||
attention=club_attention(self.object),
|
||||
charts=club_charts(self.object),
|
||||
subscription=getattr(self.object, "subscription", None),
|
||||
dues=self.object.dues.select_related("tier", "invoice").prefetch_related("payments"),
|
||||
today=timezone.localdate(),
|
||||
admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"),
|
||||
flags=flags_for_club(self.object),
|
||||
**kwargs,
|
||||
@@ -186,10 +198,28 @@ class FeatureListView(PlatformStaffRequiredMixin, TemplateView):
|
||||
nav="features",
|
||||
flags=Flag.objects.prefetch_related("clubs").order_by("name"),
|
||||
switches=Switch.objects.order_by("name"),
|
||||
maintenance=Maintenance.current(),
|
||||
maintenance_form=MaintenanceForm(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class MaintenanceView(PlatformStaffRequiredMixin, View):
|
||||
"""Close the platform, or open it again."""
|
||||
|
||||
def post(self, request):
|
||||
if Maintenance.is_on():
|
||||
Maintenance.stop()
|
||||
messages.success(request, "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)
|
||||
messages.warning(request, "Platform closed. Every club subdomain now serves a maintenance page, and the scheduled jobs stand down.")
|
||||
|
||||
return redirect("controlpanel:features")
|
||||
|
||||
|
||||
class FlagCreateView(PlatformStaffRequiredMixin, CreateView):
|
||||
model = Flag
|
||||
form_class = FlagForm
|
||||
@@ -283,3 +313,198 @@ class PlatformAdminRevokeView(PlatformSuperuserRequiredMixin, View):
|
||||
else:
|
||||
messages.warning(request, f"{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()
|
||||
return super().get_context_data(
|
||||
nav="billing",
|
||||
tiers=Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")),
|
||||
owing=Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until"),
|
||||
today=today,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class TierCreateView(PlatformStaffRequiredMixin, CreateView):
|
||||
model = Tier
|
||||
form_class = TierForm
|
||||
template_name = "controlpanel/tier_form.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="billing", **kwargs)
|
||||
|
||||
def get_success_url(self):
|
||||
messages.success(self.request, f"Tier “{self.object}” created. Give it a price before billing anyone.")
|
||||
return reverse("controlpanel:billing")
|
||||
|
||||
|
||||
class TierUpdateView(PlatformStaffRequiredMixin, UpdateView):
|
||||
model = Tier
|
||||
form_class = TierForm
|
||||
template_name = "controlpanel/tier_form.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="billing", **kwargs)
|
||||
|
||||
def get_success_url(self):
|
||||
messages.success(self.request, f"Tier “{self.object}” updated.")
|
||||
return reverse("controlpanel:billing")
|
||||
|
||||
|
||||
class TierPriceCreateView(PlatformStaffRequiredMixin, 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."""
|
||||
|
||||
model = TierPrice
|
||||
form_class = TierPriceForm
|
||||
template_name = "controlpanel/tier_price_form.html"
|
||||
|
||||
@property
|
||||
def tier(self):
|
||||
return get_object_or_404(Tier, pk=self.kwargs["pk"])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="billing", tier=self.tier, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.tier = self.tier
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"{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, FormView):
|
||||
"""Put a club on a tier, which opens its first period."""
|
||||
|
||||
form_class = SubscriptionForm
|
||||
template_name = "controlpanel/subscription_form.html"
|
||||
|
||||
@property
|
||||
def club(self):
|
||||
return get_object_or_404(Club, pk=self.kwargs["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 get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", club=self.club, subscription=getattr(self.club, "subscription", None), **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
club = self.club
|
||||
existing = getattr(club, "subscription", None)
|
||||
try:
|
||||
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()
|
||||
messages.success(self.request, f"{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"])
|
||||
messages.success(self.request, f"{club} is on {form.cleaned_data['tier']}. Its first period is open.")
|
||||
except BillingError as error:
|
||||
messages.error(self.request, str(error))
|
||||
|
||||
return redirect("controlpanel:club_detail", pk=club.pk)
|
||||
|
||||
|
||||
class RecordPaymentView(PlatformStaffRequiredMixin, FormView):
|
||||
form_class = DuePaymentForm
|
||||
template_name = "controlpanel/payment_form.html"
|
||||
|
||||
@property
|
||||
def due(self):
|
||||
return get_object_or_404(Due.objects.select_related("club", "tier"), pk=self.kwargs["pk"])
|
||||
|
||||
def get_initial(self):
|
||||
return {"amount": self.due.balance}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", due=self.due, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
due = self.due
|
||||
try:
|
||||
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()
|
||||
messages.success(self.request, f"€{form.cleaned_data['amount']} recorded. {due.get_status_display().capitalize()} — €{due.balance} outstanding.")
|
||||
except BillingError as error:
|
||||
messages.error(self.request, str(error))
|
||||
|
||||
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)
|
||||
try:
|
||||
waive(due)
|
||||
messages.warning(request, f"Period {due.period_start} to {due.period_end} waived. Nothing is owed and the club will not be archived for it.")
|
||||
except BillingError as error:
|
||||
messages.error(request, str(error))
|
||||
|
||||
return redirect("controlpanel:club_detail", pk=due.club_id)
|
||||
|
||||
|
||||
class OpenPeriodView(PlatformStaffRequiredMixin, FormView):
|
||||
"""Renew a club, or reactivate an archived one."""
|
||||
|
||||
form_class = OpenPeriodForm
|
||||
template_name = "controlpanel/period_form.html"
|
||||
|
||||
@property
|
||||
def club(self):
|
||||
return get_object_or_404(Club, pk=self.kwargs["pk"])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
club = self.club
|
||||
return super().get_context_data(nav="clubs", club=club, next_start=next_period_start(club), **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
club = self.club
|
||||
start = form.cleaned_data.get("start")
|
||||
try:
|
||||
due = reactivate(club, start=start) if club.is_archived else open_period(club, start=start)
|
||||
messages.success(self.request, f"Period {due.period_start} to {due.period_end} opened for €{due.amount}. Invoice {due.invoice.number}.")
|
||||
except BillingError as error:
|
||||
messages.error(self.request, str(error))
|
||||
|
||||
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.
|
||||
messages.error(request, str(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
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -61,6 +61,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 +75,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,6 +86,8 @@ 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",
|
||||
]
|
||||
@@ -160,6 +168,7 @@ TEMPLATES = [
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"club.context_processors.branding",
|
||||
"features.context_processors.maintenance",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -207,7 +216,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 +239,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,51 @@ 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"])
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
--font-ubuntu: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-roboto: "Roboto", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-tourney: "Tourney", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
@@ -189,6 +191,67 @@
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.diff {
|
||||
@layer daisyui.l1.l2 {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
webkit-user-select: none;
|
||||
user-select: none;
|
||||
align-items: normal;
|
||||
grid-template-rows: 1fr 1.8rem 1fr;
|
||||
direction: ltr;
|
||||
container-type: inline-size;
|
||||
grid-template-columns: auto 1fr;
|
||||
&:focus-visible, &:has(.diff-item-1:focus-visible) {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 2px;
|
||||
outline-offset: 1px;
|
||||
outline-color: var(--color-base-content);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 2px;
|
||||
outline-offset: 1px;
|
||||
outline-color: var(--color-base-content);
|
||||
.diff-resizer {
|
||||
min-width: 95cqi;
|
||||
max-width: 95cqi;
|
||||
}
|
||||
}
|
||||
&:has(.diff-item-1:focus-visible) {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 2px;
|
||||
outline-offset: 1px;
|
||||
.diff-resizer {
|
||||
min-width: 5cqi;
|
||||
max-width: 5cqi;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.diff-item-2 {
|
||||
&::after {
|
||||
height: 2.4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
|
||||
&:focus {
|
||||
.diff-resizer {
|
||||
min-width: 5cqi;
|
||||
max-width: 5cqi;
|
||||
}
|
||||
}
|
||||
&:has(.diff-item-1:focus) {
|
||||
.diff-resizer {
|
||||
min-width: 95cqi;
|
||||
max-width: 95cqi;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.tooltip {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
@@ -726,6 +789,57 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.diff-item-2 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
grid-column-start: 1;
|
||||
grid-row: span 3 / span 3;
|
||||
grid-row-start: 1;
|
||||
&:after {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: calc(1 / 2 * 100%);
|
||||
right: 1px;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
border-radius: calc(infinity * 1px);
|
||||
background-color: var(--color-base-100);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--color-base-100) 98%, transparent);
|
||||
}
|
||||
width: 1.2rem;
|
||||
height: 1.8rem;
|
||||
border: 2px solid var(--color-base-100);
|
||||
content: "";
|
||||
box-shadow: 0 0 0 2px #0000002a;
|
||||
outline: 2px solid var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
outline: 2px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
|
||||
}
|
||||
outline-offset: -3px;
|
||||
translate: 50% -50%;
|
||||
transition: height 0.3s linear(0, 0.931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);
|
||||
}
|
||||
> * {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100cqi;
|
||||
max-width: none;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
@supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
|
||||
&:after {
|
||||
--tw-content: none;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.dropdown {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
@@ -974,6 +1088,23 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.loading {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
pointer-events: none;
|
||||
display: inline-block;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-color: currentcolor;
|
||||
vertical-align: middle;
|
||||
width: calc(var(--size-selector, 0.25rem) * 6);
|
||||
mask-size: 100%;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='8s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='6s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='6s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
}
|
||||
.collapse {
|
||||
&:not(td, tr, colgroup) {
|
||||
visibility: revert-layer;
|
||||
@@ -1141,6 +1272,44 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.\!filter {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
[type="radio"] {
|
||||
width: auto !important;
|
||||
}
|
||||
input {
|
||||
overflow: hidden !important;
|
||||
opacity: 100% !important;
|
||||
scale: 1 !important;
|
||||
transition: visibility 0.1s allow-discrete, margin 0.1s, opacity 0.3s, padding 0.3s, border-width 0.1s !important;
|
||||
&.filter-reset {
|
||||
aspect-ratio: 1 / 1 !important;
|
||||
&::after {
|
||||
--tw-content: "×" !important;
|
||||
content: var(--tw-content) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
> input:not(:last-child), > :not(:last-child) input {
|
||||
margin-inline-end: 0.25rem !important;
|
||||
}
|
||||
}
|
||||
@layer daisyui.l1 {
|
||||
&:not(:has(:checked:not(.filter-reset))) :is(.filter-reset, [type="reset"]):not(:focus-visible) {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
&:not(:has(:checked:not(.filter-reset))) :is(.filter-reset, [type="reset"]):not(:focus-visible), &:not(:has(:focus-visible)):has(:checked:not(.filter-reset, [type="checkbox"])) :is(input, button):not(:checked, .filter-reset, [type="reset"]) {
|
||||
margin-inline: 0 !important;
|
||||
width: 0 !important;
|
||||
padding-inline: 0 !important;
|
||||
opacity: 0% !important;
|
||||
scale: 0 !important;
|
||||
border-width: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: flex;
|
||||
@@ -1179,6 +1348,36 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.validator-hint {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
visibility: hidden;
|
||||
margin-top: calc(0.25rem * 2);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
.validator {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
&:user-valid, &:has(:user-valid) {
|
||||
&, &:focus, &:checked, &[aria-checked="true"], &:focus-within {
|
||||
--input-color: var(--color-success);
|
||||
}
|
||||
}
|
||||
&:user-invalid, &:has(:user-invalid), &[aria-invalid]:not([aria-invalid="false"]), &:has([aria-invalid]:not([aria-invalid="false"])) {
|
||||
&, &:focus, &:checked, &[aria-checked="true"], &:focus-within {
|
||||
--input-color: var(--color-error);
|
||||
}
|
||||
& ~ .validator-hint {
|
||||
visibility: visible;
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:user-invalid, &:has(:user-invalid), &[aria-invalid]:not([aria-invalid="false"]), &:has([aria-invalid]:not([aria-invalid="false"])) {
|
||||
& ~ .validator-hint {
|
||||
display: revert-layer;
|
||||
}
|
||||
}
|
||||
}
|
||||
.collapse-open {
|
||||
@layer daisyui.l1.l2 {
|
||||
grid-template-rows: max-content 1fr;
|
||||
@@ -1197,6 +1396,9 @@
|
||||
.collapse {
|
||||
visibility: collapse;
|
||||
}
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -1547,6 +1749,105 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.range {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
appearance: none;
|
||||
webkit-appearance: none;
|
||||
--range-thumb: var(--color-base-100);
|
||||
--range-thumb-size: calc(var(--size-selector, 0.25rem) * 6);
|
||||
--range-progress: currentColor;
|
||||
--range-fill: 1;
|
||||
--range-p: 0.25rem;
|
||||
--range-bg: currentColor;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
--range-bg: color-mix(in oklab, currentColor 10%, #0000);
|
||||
}
|
||||
--range-fill-x: calc(
|
||||
(var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)
|
||||
);
|
||||
--range-fill-y: 0;
|
||||
--range-fill-spread: calc(100cqw * var(--range-fill));
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
background-color: transparent;
|
||||
vertical-align: middle;
|
||||
width: clamp(3rem, 20rem, 100%);
|
||||
--radius-selector-max: calc(
|
||||
var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
|
||||
);
|
||||
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
|
||||
border: none;
|
||||
height: var(--range-thumb-size);
|
||||
[dir="rtl"] & {
|
||||
--range-dir: -1;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
&::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
background-color: var(--range-bg);
|
||||
border-radius: var(--radius-selector);
|
||||
height: calc(var(--range-thumb-size) * 0.5);
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
&::-webkit-slider-runnable-track {
|
||||
border: 1px solid;
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
&::-moz-range-track {
|
||||
border: 1px solid;
|
||||
}
|
||||
}
|
||||
&::-webkit-slider-thumb {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
|
||||
background-color: var(--range-thumb);
|
||||
height: var(--range-thumb-size);
|
||||
width: var(--range-thumb-size);
|
||||
border: var(--range-p) solid;
|
||||
appearance: none;
|
||||
webkit-appearance: none;
|
||||
inset-block-start: 50%;
|
||||
color: var(--range-progress);
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
|
||||
}
|
||||
}
|
||||
&::-moz-range-track {
|
||||
width: 100%;
|
||||
background-color: var(--range-bg);
|
||||
border-radius: var(--radius-selector);
|
||||
height: calc(var(--range-thumb-size) * 0.5);
|
||||
}
|
||||
&::-moz-range-thumb {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
|
||||
background-color: currentColor;
|
||||
height: var(--range-thumb-size);
|
||||
width: var(--range-thumb-size);
|
||||
border: var(--range-p) solid;
|
||||
color: var(--range-progress);
|
||||
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
|
||||
}
|
||||
}
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 30%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.indicator {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
@@ -1788,6 +2089,27 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.diff-resizer {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
z-index: 2;
|
||||
grid-column-start: 1;
|
||||
grid-row-start: 2;
|
||||
height: calc(0.25rem * 3);
|
||||
width: 50cqi;
|
||||
max-width: calc(100cqi - 1rem);
|
||||
min-width: 1rem;
|
||||
resize: horizontal;
|
||||
overflow: hidden;
|
||||
opacity: 0%;
|
||||
transform: scaleY(5) translate(0.32rem, 50%);
|
||||
cursor: ew-resize;
|
||||
transform-origin: 100% 100%;
|
||||
clip-path: inset(calc(100% - 0.75rem) 0 0 calc(100% - 0.75rem));
|
||||
transition: min-width 0.3s ease-out, max-width 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
.select {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
@@ -1974,6 +2296,42 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.swap {
|
||||
@layer daisyui.l1.l2 {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
cursor: pointer;
|
||||
place-content: center;
|
||||
vertical-align: middle;
|
||||
webkit-user-select: none;
|
||||
user-select: none;
|
||||
input {
|
||||
appearance: none;
|
||||
border: none;
|
||||
}
|
||||
> * {
|
||||
grid-column-start: 1;
|
||||
grid-row-start: 1;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
transition-property: transform, rotate, opacity;
|
||||
transition-duration: 0.2s;
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
.swap-on, .swap-indeterminate, input:indeterminate ~ .swap-on {
|
||||
opacity: 0%;
|
||||
}
|
||||
input:is(:checked, :indeterminate) {
|
||||
& ~ .swap-off {
|
||||
opacity: 0%;
|
||||
}
|
||||
}
|
||||
input:checked ~ .swap-on, input:indeterminate ~ .swap-indeterminate {
|
||||
opacity: 100%;
|
||||
backface-visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
.collapse-title {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
grid-column-start: 1;
|
||||
@@ -2330,12 +2688,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
.sticky {
|
||||
position: sticky;
|
||||
}
|
||||
.dropdown-right {
|
||||
@layer daisyui.l1.l2 {
|
||||
--anchor-h: right;
|
||||
@@ -2870,6 +3237,62 @@
|
||||
.my-6 {
|
||||
margin-block: calc(var(--spacing) * 6);
|
||||
}
|
||||
.breadcrumbs {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-block: calc(0.25rem * 2);
|
||||
> menu, > ul, > ol {
|
||||
display: flex;
|
||||
min-height: min-content;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
> li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
> * {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
gap: calc(0.25rem * 2);
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
&:focus {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
@media (forced-colors: active) {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
& + *:before {
|
||||
content: "";
|
||||
margin-inline-start: calc(0.25rem * 2);
|
||||
margin-inline-end: calc(0.25rem * 3);
|
||||
display: block;
|
||||
height: calc(0.25rem * 1.5);
|
||||
width: calc(0.25rem * 1.5);
|
||||
opacity: 40%;
|
||||
rotate: 45deg;
|
||||
border-top: 1px solid;
|
||||
border-right: 1px solid;
|
||||
background-color: #0000;
|
||||
}
|
||||
[dir="rtl"] & + *:before {
|
||||
rotate: -135deg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.label {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: inline-flex;
|
||||
@@ -3098,6 +3521,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.fieldset {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: grid;
|
||||
gap: calc(0.25rem * 1.5);
|
||||
padding-block: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: max-content;
|
||||
}
|
||||
}
|
||||
.card-actions {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: flex;
|
||||
@@ -3180,9 +3613,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.\!hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
.contents {
|
||||
display: contents;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
@@ -3192,6 +3631,9 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -3204,6 +3646,12 @@
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.table-caption {
|
||||
display: table-caption;
|
||||
}
|
||||
.table-cell {
|
||||
display: table-cell;
|
||||
}
|
||||
.h-16 {
|
||||
height: calc(var(--spacing) * 16);
|
||||
}
|
||||
@@ -3319,6 +3767,9 @@
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -3555,6 +4006,9 @@
|
||||
.pb-2 {
|
||||
padding-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
.pl-8 {
|
||||
padding-left: calc(var(--spacing) * 8);
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -3699,6 +4153,12 @@
|
||||
.text-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.lowercase {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tabular-nums {
|
||||
--tw-numeric-spacing: tabular-nums;
|
||||
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
|
||||
@@ -3730,6 +4190,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);
|
||||
@@ -3746,6 +4209,17 @@
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
.blur {
|
||||
--tw-blur: blur(8px);
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
}
|
||||
.invert {
|
||||
--tw-invert: invert(100%);
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
}
|
||||
.\!filter {
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,) !important;
|
||||
}
|
||||
.filter {
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
}
|
||||
@@ -3836,6 +4310,12 @@
|
||||
--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);
|
||||
|
||||
File diff suppressed because one or more lines are too long
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