Compare commits
308 Commits
e654311321
...
eb37ed187e
| Author | SHA1 | Date | |
|---|---|---|---|
| eb37ed187e | |||
| 8c415ed46a | |||
| 240b6bd345 | |||
| c4069f73c7 | |||
| 148f3a24c9 | |||
| b9900510b1 | |||
| 1eaf92c977 | |||
| 6f16737bd9 | |||
| a13ab20a0f | |||
| 1375083739 | |||
| bf62759bfc | |||
| 5d54f1cdcc | |||
| fbaf5a8799 | |||
| 4af598500b | |||
| 3501dbce94 | |||
| 499f65ef5b | |||
| 6e3de13fd8 | |||
| cc9daabcfd | |||
| 3b64fae43f | |||
| d0e9dde8ed | |||
| 15d890a24b | |||
| 1365dcf18e | |||
| e621086f37 | |||
| 1d0c2ef299 | |||
| 3b634fda22 | |||
| fc624ebd96 | |||
| a7fdab4352 | |||
| 86ea8d1b15 | |||
| ae8e4c411d | |||
| e13b17707c | |||
| 5ef6ea5529 | |||
| c47b509207 | |||
| 3d0242749d | |||
| 618d15a3e0 | |||
| 5db885c809 | |||
| e349fbbf03 | |||
| 875250366e | |||
| 6efb00350b | |||
| 8ddae5e63b | |||
| cf1a2f0f5a | |||
| 747514f1ff | |||
| 135580d83e | |||
| 2b6a4d21bb | |||
| 62c36f47b9 | |||
| a9bf98a3c9 | |||
| dba28ffc24 | |||
| 366239e60b | |||
| 614c35861b | |||
| 3426ea8447 | |||
| 1d22a57208 | |||
| 1d159ca5de | |||
| 64d0fec547 | |||
| 1ced0619a3 | |||
| c0c7ec9f1e | |||
| 53b3c56594 | |||
| ccd4e0aa13 | |||
| e211584696 | |||
| 995f7ecfc9 | |||
| 6a9a6204ed | |||
| 9fbcc89646 | |||
| 0ce1593af1 | |||
| 4b5a4a81ec | |||
| 9c16141d06 | |||
| a35535de7c | |||
| 56e4b988c0 | |||
| 9e581370e4 | |||
| c7887637ff | |||
| 80fda6a4a4 | |||
| 5bfb6ce76e | |||
| 385e2f3c38 | |||
| 17b1c0220a | |||
| 81f8f7f7dd | |||
| 1bf3a9d37a | |||
| 65801a7843 | |||
| 40bee9af48 | |||
| 5096d0ba05 | |||
| 515480ce51 | |||
| 0e81e729b3 | |||
| 96fc58e453 | |||
| 4b2b84793f | |||
| 4dcf50c4e3 | |||
| 29c05b4be6 | |||
| 119341001a | |||
| f4429263d3 | |||
| 397b9cf687 | |||
| 59d287e978 | |||
| 2cdba6a571 | |||
| 19e1acb93a | |||
| 3559c936f7 | |||
| f41a255b61 | |||
| 13f369e10c | |||
| 29acd22b7f | |||
| 18627d2843 | |||
| 09df5d25b8 | |||
| 0ecdeac354 | |||
| 20a44a915f | |||
| 5f7ca98eae | |||
| 916f9bf8ab | |||
| 5b155fe4d4 | |||
| 3ff882d705 | |||
| 6d69ff7230 | |||
| c91a8aa782 | |||
| b423f0cf12 | |||
| 413fea59f2 | |||
| d5e396102e | |||
| 7ab7ac1982 | |||
| d2eced9a50 | |||
| 66d7de820f | |||
| 5889b3740e | |||
| 3159064e2a | |||
| 94d6cbd4e9 | |||
| 44ddf7b6eb | |||
| 8fcbf3a310 | |||
| 45380dc282 | |||
| 224a3fe4ed | |||
| 59d1fce674 | |||
| a0226d51a9 | |||
| e5427c31dd | |||
| fc3901fb5a | |||
| 8baf3f7b3d | |||
| 71a96a8212 | |||
| 331b0b8227 | |||
| 2b4cc7d527 | |||
| 441367362d | |||
| adf1120358 | |||
| bff685966d | |||
| 9f4a0ea687 | |||
| 5d3dbebd77 | |||
| 67841491a6 | |||
| 9901a90266 | |||
| ece39e51ed | |||
| 8e64b37d53 | |||
| fbefdf3680 | |||
| 7f63d646ff | |||
| b71478a018 | |||
| 400a930b1a | |||
| 4be10a57e0 | |||
| c4f0ec71c1 | |||
| b2657bb15d | |||
| cb7f56709b | |||
| ca2b1a11b5 | |||
| 744b623403 | |||
| ab1c703baf | |||
| 1d5286fbbf | |||
| ffe8a3d301 | |||
| 581cc81ba7 | |||
| e737de9140 | |||
| 5dcffefb28 | |||
| 0a9ac67b21 | |||
| 309bd4d83e | |||
| 86e28c317f | |||
| ae31c1d544 | |||
| 617271f0d0 | |||
| c2a4380b70 | |||
| fc6488ce55 | |||
| ae93406853 | |||
| 34bad16b19 | |||
| 3d03ff644c | |||
| 783b235bcd | |||
| 7fd42e6047 | |||
| fc1942575f | |||
| 2b180f3c39 | |||
| 59c485a6ac | |||
| 844b0c35cc | |||
| cd4d487161 | |||
| 101e98b4a8 | |||
| 6cd39fcb7f | |||
| e35788c6a7 | |||
| fe19a6f08a | |||
| 1be9959481 | |||
| 30be424985 | |||
| f6d5d18f0d | |||
| 9152fa77ce | |||
| d4084de3c1 | |||
| 95cb0cbe3f | |||
| 4d0f74379d | |||
| 5b8ab72982 | |||
| 1ca9a5e908 | |||
| 80c1fa1226 | |||
| 30b464eb56 | |||
| 98b8002a04 | |||
| 6ad0d6658c | |||
| 68cad0c951 | |||
| e6850232f0 | |||
| 9a4da9b136 | |||
| ab1d34cd0a | |||
| 054e1cb1be | |||
| 18e3b0306d | |||
| 92686b0755 | |||
| a3e413904f | |||
| 9a2a06180f | |||
| 2261f86596 | |||
| 859e3e5f84 | |||
| d9b4337319 | |||
| 38947196c1 | |||
| 3e0c63ec36 | |||
| ce35348b31 | |||
| 9b0af5800a | |||
| 2c4d031050 | |||
| d5f45c9404 | |||
| 8598fd2b46 | |||
| 062da00bb9 | |||
| 5bea8a4a6c | |||
| febde41214 | |||
| a86c35cbe7 | |||
| c17e7b5059 | |||
| c1ba6e269b | |||
| 0223383c9d | |||
| 90a99fb03e | |||
| 75679dcc40 | |||
| 88a6c63f9f | |||
| 075c2918b6 | |||
| ccaa9e991a | |||
| bf1ec322ce | |||
| 08bf76e762 | |||
| 438d111718 | |||
| 40255805c3 | |||
| 127d0e338e | |||
| 83caa233d7 | |||
| fc7a349f8f | |||
| f403128f57 | |||
| 91270b0cf8 | |||
| 19108407c6 | |||
| 10b113f244 | |||
| 5b51f2c945 | |||
| 4d74f3fbde | |||
| a1266378fc | |||
| a2bcb2f0c8 | |||
| 9bc5377cc5 | |||
| 7c874aa87b | |||
| 5283262e6b | |||
| 65a2f741f6 | |||
| 975426a17f | |||
| 9a616c20e4 | |||
| 2d43b0b903 | |||
| c42963c447 | |||
| 5d42691a10 | |||
| a759f763ed | |||
| 534d01d6fe | |||
| 98e5f22873 | |||
| d30b163122 | |||
| c0a44093d9 | |||
| 35d1ec45a7 | |||
| e5a93194bf | |||
| 29d61eeea8 | |||
| 60bfac9881 | |||
| 6899e203f6 | |||
| 3a6dc00e05 | |||
| 192fe5ad0e | |||
| 639807b2d2 | |||
| f2b78bb1dd | |||
| 21d3947d08 | |||
| 016206a79e | |||
| 9127be0c42 | |||
| 1dd2e5099f | |||
| bf86654e72 | |||
| 0eb6838cba | |||
| c9935d0a04 | |||
| 7900233f6d | |||
| add1ee87ae | |||
| fa213d95ee | |||
| addfc61a2c | |||
| 2b7b2b64db | |||
| b3f153a2dc | |||
| 7b18b39f49 | |||
| e44933330d | |||
| 1a2bf257da | |||
| fc51e1903c | |||
| 334c706aec | |||
| 268cbe1e06 | |||
| fed24bfee3 | |||
| eace903f05 | |||
| 6f66df3ba4 | |||
| 848a8578de | |||
| ebb8bc3db1 | |||
| 10736fd5ee | |||
| 819700ad0c | |||
| 78fdcdf138 | |||
| d43ca0cfa8 | |||
| 22d971d48c | |||
| c0816a1add | |||
| c320931595 | |||
| 54aace8abb | |||
| 57f20fe544 | |||
| 5dd3715c1f | |||
| 2653dd6d08 | |||
| c535e5dc8c | |||
| ca34d26a8e | |||
| 866716d196 | |||
| 076a9cacbc | |||
| dd2e4b2169 | |||
| 3914765f90 | |||
| cda4960ffc | |||
| 3575d8f7b1 | |||
| e85a5f6300 | |||
| 72e7b5e070 | |||
| 919a68ed3a | |||
| 638f85fa07 | |||
| 4165729d61 | |||
| f3b1ae7e82 | |||
| 19b92d61f7 | |||
| 89c12c12b1 | |||
| 44fe658efa | |||
| b30522d3d0 | |||
| 8f71bb74c0 | |||
| aa2c6329b9 | |||
| 2ead824c5d | |||
| 537c023258 |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
node_modules
|
||||||
|
staticfiles
|
||||||
|
media
|
||||||
|
db.sqlite3
|
||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
__pycache__
|
||||||
|
.coverage
|
||||||
|
.idea
|
||||||
|
.ruff_cache
|
||||||
|
ARCHITECTURE.pdf
|
||||||
10
.env.compose.example
Normal file
10
.env.compose.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Copy to .env — read by docker compose itself (not by Django).
|
||||||
|
ROSTERCHIEF_BASE_DOMAIN=rosterchief.app
|
||||||
|
ACME_EMAIL=you@example.com
|
||||||
|
|
||||||
|
# DNS-01 is the only way to get the *.rosterchief.app wildcard. Token needs DNS:Edit on the zone.
|
||||||
|
CLOUDFLARE_API_TOKEN=
|
||||||
|
|
||||||
|
POSTGRES_DB=rosterchief
|
||||||
|
POSTGRES_USER=rosterchief
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
37
.env.example
Normal file
37
.env.example
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Copy to .env and fill in. Values below are development-friendly defaults.
|
||||||
|
|
||||||
|
# Required. Generate one, e.g. `python -c "import secrets; print(secrets.token_urlsafe(50))"`.
|
||||||
|
DJANGO_SECRET_KEY=change-me
|
||||||
|
|
||||||
|
# Development toggles.
|
||||||
|
DJANGO_DEBUG=True
|
||||||
|
|
||||||
|
# Hosts Django will serve. `.localhost` matches localhost and any *.localhost
|
||||||
|
# subdomain, which the tenant middleware needs for per-club subdomains.
|
||||||
|
DJANGO_ALLOWED_HOSTS=.localhost,127.0.0.1,[::1]
|
||||||
|
|
||||||
|
# Multi-tenancy: subdomains of this base domain resolve to a club by slug,
|
||||||
|
# e.g. http://ajax-united.localhost:8000/ -> club with slug "ajax-united".
|
||||||
|
# In production set this to your real base domain (e.g. rosterchief.app).
|
||||||
|
ROSTERCHIEF_BASE_DOMAIN=localhost
|
||||||
|
|
||||||
|
# Two-factor auth. ROSTERCHIEF_BASE_DOMAIN doubles as the WebAuthn Relying Party
|
||||||
|
# ID, so ONE passkey works across every club subdomain. Change it and existing
|
||||||
|
# passkeys stop validating -- they are cryptographically bound to that domain.
|
||||||
|
# ROSTERCHIEF_RP_NAME is what the browser shows during a passkey prompt.
|
||||||
|
# ROSTERCHIEF_RP_NAME=RosterChief
|
||||||
|
|
||||||
|
# Sessions are shared across club subdomains (log in once, all clubs). Derived
|
||||||
|
# from ROSTERCHIEF_BASE_DOMAIN in production; left host-only on localhost
|
||||||
|
# because browsers reject a Domain attribute there. Override if needed.
|
||||||
|
# DJANGO_SESSION_COOKIE_DOMAIN=.rosterchief.app
|
||||||
|
# DJANGO_CSRF_COOKIE_DOMAIN=.rosterchief.app
|
||||||
|
|
||||||
|
# Optional. Defaults to sqlite:///db.sqlite3 for dev; point at Postgres in prod.
|
||||||
|
# DJANGO_DATABASE_URL=postgres://user:pass@localhost:5432/rosterchief
|
||||||
|
|
||||||
|
# Optional. CSRF trusted origins (needed for subdomains in prod), comma-separated.
|
||||||
|
# DJANGO_CSRF_TRUSTED_ORIGINS=https://*.rosterchief.app
|
||||||
|
|
||||||
|
# Optional.
|
||||||
|
# DJANGO_TIME_ZONE=Europe/Brussels
|
||||||
61
.env.production.example
Normal file
61
.env.production.example
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 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=
|
||||||
|
|
||||||
|
# --- Email: any SMTP provider. Left unset, mail is PRINTED TO THE LOG and never delivered,
|
||||||
|
# which means send_billing_reminders will look like it worked while no club hears from you.
|
||||||
|
DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
|
DJANGO_EMAIL_HOST=smtp.example.com
|
||||||
|
DJANGO_EMAIL_PORT=587
|
||||||
|
DJANGO_EMAIL_HOST_USER=
|
||||||
|
DJANGO_EMAIL_HOST_PASSWORD=
|
||||||
|
DJANGO_EMAIL_USE_TLS=True
|
||||||
|
DJANGO_DEFAULT_FROM_EMAIL=RosterChief <noreply@rosterchief.app>
|
||||||
|
# Where a club is told to reply with a billing question.
|
||||||
|
ROSTERCHIEF_BILLING_CONTACT_EMAIL=billing@rosterchief.app
|
||||||
|
|
||||||
|
# --- Email via Resend (resend.com), as an alternative to the SMTP block above ---
|
||||||
|
# Two ways to use Resend; pick one, don't set both:
|
||||||
|
# 1. Resend's SMTP relay -- no code involved, just point the SMTP settings above at it:
|
||||||
|
# DJANGO_EMAIL_HOST=smtp.resend.com
|
||||||
|
# DJANGO_EMAIL_HOST_USER=resend
|
||||||
|
# DJANGO_EMAIL_HOST_PASSWORD=<your Resend API key>
|
||||||
|
# 2. Resend's HTTP API via rosterchief.mail.ResendEmailBackend (see that module):
|
||||||
|
# DJANGO_EMAIL_BACKEND=rosterchief.mail.ResendEmailBackend
|
||||||
|
# RESEND_API_KEY=<your Resend API key>
|
||||||
|
RESEND_API_KEY=
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -10,6 +10,7 @@ local_settings.py
|
|||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
media
|
media
|
||||||
|
private_media
|
||||||
|
|
||||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||||
# in your Git repository. Update and uncomment the following line accordingly.
|
# in your Git repository. Update and uncomment the following line accordingly.
|
||||||
@@ -376,4 +377,7 @@ poetry.toml
|
|||||||
# LSP config files
|
# LSP config files
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,django%
|
# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,django%
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
staticfiles/
|
||||||
|
|||||||
12
.idea/dataSources.xml
generated
Normal file
12
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="clubmanager-dev" uuid="840b050e-fdb0-4cf8-a4e7-3edb02bfaacb">
|
||||||
|
<driver-ref>sqlite.xerial</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/db.sqlite3</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/markdown.xml
generated
Normal file
6
.idea/markdown.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="MarkdownSettings">
|
||||||
|
<option name="fileGroupingEnabled" value="true" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
2
.idea/modules.xml
generated
2
.idea/modules.xml
generated
@@ -2,7 +2,7 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ProjectModuleManager">
|
<component name="ProjectModuleManager">
|
||||||
<modules>
|
<modules>
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/ClubManager.iml" filepath="$PROJECT_DIR$/.idea/ClubManager.iml" />
|
<module fileurl="file://$PROJECT_DIR$/.idea/rosterchief.iml" filepath="$PROJECT_DIR$/.idea/rosterchief.iml" />
|
||||||
</modules>
|
</modules>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
13
.idea/pyLspTools.xml
generated
13
.idea/pyLspTools.xml
generated
@@ -1,5 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="PyToolsState">
|
||||||
|
<option name="tools">
|
||||||
|
<map>
|
||||||
|
<entry key="ruff">
|
||||||
|
<value>
|
||||||
|
<ToolEntry>
|
||||||
|
<option name="enabled" value="true" />
|
||||||
|
</ToolEntry>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
<component name="RuffConfiguration">
|
<component name="RuffConfiguration">
|
||||||
<option name="enabled" value="true" />
|
<option name="enabled" value="true" />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
10
.idea/pySourceRootDetection.xml
generated
Normal file
10
.idea/pySourceRootDetection.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="PySourceRootDetectionService">
|
||||||
|
<option name="sourcePathsSet">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
13
.idea/ClubManager.iml → .idea/rosterchief.iml
generated
13
.idea/ClubManager.iml → .idea/rosterchief.iml
generated
@@ -1,10 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<module type="PYTHON_MODULE" version="4">
|
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
|
||||||
<component name="FacetManager">
|
<component name="FacetManager">
|
||||||
<facet type="django" name="Django">
|
<facet type="django" name="Django">
|
||||||
<configuration>
|
<configuration>
|
||||||
<option name="rootFolder" value="$MODULE_DIR$" />
|
<option name="rootFolder" value="$MODULE_DIR$" />
|
||||||
<option name="settingsModule" value="clubmanager/settings.py" />
|
<option name="settingsModule" value="rosterchief/settings.py" />
|
||||||
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
|
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
|
||||||
<option name="environment" value="<map/>" />
|
<option name="environment" value="<map/>" />
|
||||||
<option name="doNotUseTestRunner" value="false" />
|
<option name="doNotUseTestRunner" value="false" />
|
||||||
@@ -14,17 +14,10 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="uv (ClubManager) (3)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="uv (ClubManager) (3)" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
<component name="TemplatesService">
|
|
||||||
<option name="TEMPLATE_CONFIGURATION" value="Django" />
|
|
||||||
<option name="TEMPLATE_FOLDERS">
|
|
||||||
<list>
|
|
||||||
<option value="$MODULE_DIR$/templates" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</module>
|
</module>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
1416
ARCHITECTURE.md
Normal file
1416
ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
ARCHITECTURE.pdf
Normal file
BIN
ARCHITECTURE.pdf
Normal file
Binary file not shown.
439
BILLING.md
Normal file
439
BILLING.md
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
# Platform billing — design
|
||||||
|
|
||||||
|
How RosterChief charges a club, and what happens when a club doesn't pay.
|
||||||
|
|
||||||
|
This is the design for a **revision** of the billing subsystem, not a rewrite. `ARCHITECTURE.md`
|
||||||
|
covers the club-owned domain; billing is the other direction — the platform charging the tenant —
|
||||||
|
and has never been documented there. Once this design is implemented, §4 (the model) gets folded
|
||||||
|
into `ARCHITECTURE.md` and this file keeps the lifecycle and operational detail, the same split
|
||||||
|
`DEPLOYMENT.md` already has with the rest of the docs.
|
||||||
|
|
||||||
|
Status: **implemented.** The four decisions left open in §10 have been taken and are recorded
|
||||||
|
there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What ships today, and what's actually wrong with it
|
||||||
|
|
||||||
|
The existing implementation (`billing/`) is structurally sound. Three things in particular are
|
||||||
|
worth keeping and are not up for renegotiation in this redesign:
|
||||||
|
|
||||||
|
- **Snapshot-on-`Due`.** `Due.tier` and `Due.amount` are frozen when the period opens and never
|
||||||
|
read back through the plan at display time. Raise a price and last year's invoice still says what
|
||||||
|
was actually charged. This is the single most important property in the subsystem — a live
|
||||||
|
lookup would rewrite financial history.
|
||||||
|
- **Dated prices.** `TierPrice.active_from` means a rate change is one new row with a future date,
|
||||||
|
not an edit to an existing one. Every period already opened keeps its amount.
|
||||||
|
- **Asymmetric commands.** `archive_overdue_clubs` reports by default and only acts with
|
||||||
|
`--commit`; `renew_subscriptions` acts by default and only previews with `--dry-run`. The
|
||||||
|
asymmetry is deliberate and correct: not archiving is a safe failure, not renewing is a silent
|
||||||
|
revenue leak.
|
||||||
|
|
||||||
|
What's wrong is narrower than "the approach": **three hardcoded assumptions**, all in
|
||||||
|
`billing/models.py`.
|
||||||
|
|
||||||
|
| # | Today | Problem |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `Due.save()` → `add_one_year(period_start)` | Every plan is annual. Duration is only overridable by passing an explicit `period_end`, which is the hack trials use. |
|
||||||
|
| 2 | `GRACE_DAYS = 45`, module constant | One grace period for every plan, editable only by deploying code. |
|
||||||
|
| 3 | `grace_until = period_end + GRACE_DAYS` | **The clock runs from the wrong end.** A club uses the entire unpaid year *and then* gets 45 days — roughly **410 days of unpaid use** before `archive_overdue_clubs` will touch it. |
|
||||||
|
|
||||||
|
Item 3 is the real defect. The requested behaviour — "unpaid → warned → archived after N days" —
|
||||||
|
isn't a tweak to the current rule, it's the opposite of it.
|
||||||
|
|
||||||
|
A fourth gap is in the UI rather than the model: the club-facing banner
|
||||||
|
(`management/views.py:89-93`) fires on *the period ending*, not on *money being owed*. A club
|
||||||
|
that owes money is told nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Target lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
[no plan] ──► TRIAL ──────► UNPAID ──► IN GRACE ──► OVERDUE ─┴─► ARCHIVED
|
||||||
|
│ │ ▲ │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
└──────────────┘ │ payment payment reactivate
|
||||||
|
auto-converts │ │ │ (+ new period)
|
||||||
|
on renewal │ ▼ ▼ │
|
||||||
|
│ PAID PAID │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
renewal opens
|
||||||
|
the next period
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trial.** A club starts on a trial plan (there can be several — a 1-month and a 3-month trial are
|
||||||
|
just two plans). It carries a price of 0, so it settles itself the moment it opens and can never
|
||||||
|
make a club archivable. When the trial period runs out, the next renewal converts the subscription
|
||||||
|
onto the pre-selected paid plan. This already works (`open_period`'s trial-conversion check) and
|
||||||
|
is kept as-is.
|
||||||
|
|
||||||
|
**Unpaid.** Every paid period — new or renewed — opens `UNPAID` with an invoice attached. From
|
||||||
|
that moment the club's admins see a warning on their management home page. This is the state the
|
||||||
|
whole redesign is about.
|
||||||
|
|
||||||
|
**In grace.** The period has started and is still unpaid. The warning escalates and gains a
|
||||||
|
countdown: *"Your club will be archived in N days."*
|
||||||
|
|
||||||
|
**Overdue.** Past `grace_until`. `archive_overdue_clubs` will now pick this club up — subject to
|
||||||
|
`Subscription.auto_archive`, which stays as the manual override for a club you're negotiating with.
|
||||||
|
|
||||||
|
**Archived.** `Club.archive()` sets `archived_at`. The subdomain stops resolving
|
||||||
|
(`ClubTenantMiddleware.get_club()` filters on `Club.objects.active()`), so the club is frozen
|
||||||
|
exactly as requested: no access, nothing destroyed. Reactivation is a deliberate platform-admin
|
||||||
|
action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The three clocks
|
||||||
|
|
||||||
|
Every plan carries three numbers. Getting them confused is the easiest way to misread this design,
|
||||||
|
so they are named for what they measure from:
|
||||||
|
|
||||||
|
| Field | Measured from | Answers |
|
||||||
|
|---|---|---|
|
||||||
|
| `duration_months` | `period_start` | How long is a period? |
|
||||||
|
| `renewal_lead_days` | `period_start`, backwards | How early is the invoice raised? |
|
||||||
|
| `grace_days` | `period_start`, forwards | How long may it stay unpaid? |
|
||||||
|
|
||||||
|
**`grace_days` runs from the period start, not from the invoice and not from the period end.**
|
||||||
|
That is the decision this redesign turns on. It means the warning window is
|
||||||
|
`renewal_lead_days + grace_days` — the club is told before the period begins *and* gets a further
|
||||||
|
grace once it has, but is cut off partway into a period it never paid for.
|
||||||
|
|
||||||
|
### Worked timelines
|
||||||
|
|
||||||
|
```
|
||||||
|
ANNUAL duration 12mo · lead 30d · grace 30d
|
||||||
|
period 1 Jan 2027 – 31 Dec 2027
|
||||||
|
|
||||||
|
2 Dec 26 1 Jan 27 31 Jan 27 31 Dec 27
|
||||||
|
│ │ │ │
|
||||||
|
INVOICE PERIOD STARTS grace ends PERIOD ENDS
|
||||||
|
│─────────────│──────────────│
|
||||||
|
30d warned 30d grace └─► ARCHIVED 1 Feb 27 if unpaid
|
||||||
|
└──────────── 60 days of warning ────────────┘
|
||||||
|
|
||||||
|
|
||||||
|
QUARTERLY duration 3mo · lead 14d · grace 30d
|
||||||
|
period 1 Jan 2027 – 31 Mar 2027
|
||||||
|
|
||||||
|
18 Dec 26 1 Jan 27 31 Jan 27 31 Mar 27
|
||||||
|
│ │ │ │
|
||||||
|
INVOICE PERIOD STARTS grace ends PERIOD ENDS
|
||||||
|
└──────── 44 days of warning ───┘
|
||||||
|
|
||||||
|
|
||||||
|
MONTHLY duration 1mo · lead 7d · grace 14d
|
||||||
|
period 1 Jan 2027 – 31 Jan 2027
|
||||||
|
|
||||||
|
25 Dec 26 1 Jan 27 15 Jan 27 31 Jan 27
|
||||||
|
│ │ │ │
|
||||||
|
INVOICE PERIOD ST. grace ends PERIOD ENDS
|
||||||
|
└─── 21 days of warning ─┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why the lead time has to be per-plan
|
||||||
|
|
||||||
|
Today's single `RENEWAL_LEAD_DAYS = 30` is silently annual-only. On a 1-month plan it would issue
|
||||||
|
the next period **before the current one had started** — periods would run away from the calendar
|
||||||
|
within a couple of cycles. Any plan shorter than 30 days is broken by a global constant, which is
|
||||||
|
why `renewal_lead_days` moves onto the plan.
|
||||||
|
|
||||||
|
### Guardrails
|
||||||
|
|
||||||
|
Two invariants, enforced as `CheckConstraint`s. `duration_months × 28` is the conservative
|
||||||
|
lower bound on days in that many months, which keeps the check expressible in SQL:
|
||||||
|
|
||||||
|
- `renewal_lead_days < duration_months * 28` — or the next invoice precedes the current period.
|
||||||
|
- `grace_days <= duration_months * 28` — or a club can never be archived before its next period is
|
||||||
|
issued, and unpaid periods silently stack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Model changes
|
||||||
|
|
||||||
|
### 4.1 `Tier` → `Plan` (rename)
|
||||||
|
|
||||||
|
The code says `Tier`, every spec and screen says "plan". Renaming now, while only ~80 references
|
||||||
|
exist, is cheaper than carrying the mismatch. `TierPrice` → `PlanPrice` follows, as do the FK
|
||||||
|
field names (`Subscription.tier` → `plan`, `Subscription.post_trial_tier` → `post_trial_plan`,
|
||||||
|
`Due.tier` → `plan`, `PlanPrice.tier` → `plan`).
|
||||||
|
|
||||||
|
### 4.2 `Plan` — new fields
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Plan(UUIDModel):
|
||||||
|
name, slug, description, is_active # unchanged
|
||||||
|
|
||||||
|
duration_months = PositiveSmallIntegerField(default=12, validators=[MinValueValidator(1)])
|
||||||
|
renewal_lead_days = PositiveSmallIntegerField(default=30)
|
||||||
|
grace_days = PositiveSmallIntegerField(default=30)
|
||||||
|
is_trial = BooleanField(default=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_trial` is an explicit flag rather than "price == 0" — a genuinely free tier is not a trial, and
|
||||||
|
the two dropdowns on the trial form need to offer different sets of plans. It also lets the control
|
||||||
|
panel keep trial plans out of the normal "change plan" picker.
|
||||||
|
|
||||||
|
`price_on(day)` is unchanged.
|
||||||
|
|
||||||
|
### 4.3 `Due` — derivation changes, fields don't
|
||||||
|
|
||||||
|
```python
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.period_end:
|
||||||
|
self.period_end = add_months(self.period_start, self.plan.duration_months) - timedelta(days=1)
|
||||||
|
if not self.grace_until:
|
||||||
|
self.grace_until = self.period_start + timedelta(days=self.plan.grace_days)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
```
|
||||||
|
|
||||||
|
`grace_until` stays a **stored** field. That is what makes it a snapshot: editing a plan's
|
||||||
|
`grace_days` afterwards must not move the archive date of a period that is already running, for the
|
||||||
|
same reason `amount` is frozen. Storing the computed *date* rather than the input *days* gets this
|
||||||
|
for free — no extra column needed.
|
||||||
|
|
||||||
|
The predicates change meaning even though only one changes shape:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def is_in_grace(self, today=None): # period running, unpaid, not yet archivable
|
||||||
|
return self.is_owing and self.period_start <= today <= self.grace_until
|
||||||
|
|
||||||
|
def is_overdue(self, today=None): # unchanged logic, new meaning of grace_until
|
||||||
|
return self.is_owing and self.grace_until < today
|
||||||
|
|
||||||
|
def days_until_archive(self, today=None): # new — drives the countdown in the banner
|
||||||
|
return (self.grace_until - today).days
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_in_grace` previously required `period_end < today`; it now requires `period_start <= today`.
|
||||||
|
An owing due before its period starts is neither in grace nor overdue — it is simply *issued*,
|
||||||
|
which is the gentlest of the three warning levels in §6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Price changes
|
||||||
|
|
||||||
|
Already correct, and worth stating precisely because the interaction with lead time is not obvious.
|
||||||
|
|
||||||
|
`open_period` snapshots `plan.price_on(period_start)` — the price in force on the day the period
|
||||||
|
**starts**, not the day the invoice is raised. Add a `PlanPrice` with `active_from = 1 Jan 2027` and
|
||||||
|
every period starting on or after that date bills at the new amount. That is exactly "adjust the
|
||||||
|
price, effective as of the club's next billing cycle".
|
||||||
|
|
||||||
|
**The edge case that will bite:** periods are issued `renewal_lead_days` early. Enter a price change
|
||||||
|
on 15 December for periods starting 1 January, and any annual period already issued on 2 December
|
||||||
|
keeps the old amount — its `Due.amount` was frozen two weeks before the new price existed. The
|
||||||
|
snapshot is behaving correctly; the *operational* rule is what matters:
|
||||||
|
|
||||||
|
> Enter a price change before the renewal lead window opens for the periods it should apply to.
|
||||||
|
|
||||||
|
Two mitigations, both recommended:
|
||||||
|
|
||||||
|
1. Document the rule where prices are added (control-panel help text).
|
||||||
|
2. Warn in the control panel when a new `PlanPrice.active_from` falls inside a period that has
|
||||||
|
already been issued, naming the affected clubs. Correcting one is then a deliberate
|
||||||
|
cancel-and-reopen, not a silent surprise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The club-facing warning
|
||||||
|
|
||||||
|
A new service — `billing/services/notices.py` — returns one small object for a club, or `None`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BillingNotice:
|
||||||
|
level: str # "info" | "warning" | "error"
|
||||||
|
due: Due
|
||||||
|
amount_outstanding: Decimal
|
||||||
|
grace_until: date
|
||||||
|
days_until_archive: int
|
||||||
|
```
|
||||||
|
|
||||||
|
| Condition | Level | Message |
|
||||||
|
|---|---|---|
|
||||||
|
| Owing, `today < period_start` | `info` | Invoice outstanding, due by *grace_until*. |
|
||||||
|
| Owing, in grace, > 7 days left | `warning` | Fees are due. Club will be archived in *N* days. |
|
||||||
|
| Owing, in grace, ≤ 7 days left | `error` | Final notice. Archived in *N* days. |
|
||||||
|
| Owing, overdue | `error` | Archiving is pending. |
|
||||||
|
| Nothing owing | — | No banner. |
|
||||||
|
|
||||||
|
Rendered in `management/home.html`, in the existing alert block, and gated on `is_club_admin` the
|
||||||
|
same way the current billing banner is — ordinary members have no business seeing platform billing.
|
||||||
|
The existing "your period ends soon / renews automatically" notice is kept, at lower priority: it
|
||||||
|
answers a different question and only shows when nothing is owed.
|
||||||
|
|
||||||
|
All strings go through `{% trans %}` / `{% blocktrans %}` with `%(name)s`-style placeholders per
|
||||||
|
`CLAUDE.md` — the countdown is a `{% blocktrans count %}` so the plural form survives translation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Services and commands
|
||||||
|
|
||||||
|
`billing/services/dues.py`:
|
||||||
|
|
||||||
|
| Function | Change |
|
||||||
|
|---|---|
|
||||||
|
| `subscribe(club, plan, …)` | Rename only. |
|
||||||
|
| `start_trial(club, trial_plan, *, post_trial_plan, start=None, …)` | **Drop `trial_months`** — the trial's length is now `trial_plan.duration_months`, and `period_end` no longer needs to be passed explicitly. |
|
||||||
|
| `open_period(…)` | Duration and grace come from the plan. Trial-conversion check unchanged. |
|
||||||
|
| `next_period_start` | Unchanged. |
|
||||||
|
| `dues_in_grace(today)` | `owing_dues().filter(period_start__lte=today, grace_until__gte=today)`. |
|
||||||
|
| `dues_overdue(today)` | Unchanged. |
|
||||||
|
| `archivable_clubs(today)` | Unchanged. |
|
||||||
|
| `subscriptions_due_for_renewal(today, lead_days=None)` | Per-plan lead. See below. |
|
||||||
|
|
||||||
|
`subscriptions_due_for_renewal` currently compares every subscription against one horizon in SQL.
|
||||||
|
With a per-plan lead the comparison is per-row. The function **already** materialises its result as
|
||||||
|
a Python list comprehension, so the honest move is to keep doing that and compare against each
|
||||||
|
plan's own lead:
|
||||||
|
|
||||||
|
```python
|
||||||
|
latest_period_end is None or latest_period_end <= today + timedelta(days=subscription.plan.renewal_lead_days)
|
||||||
|
```
|
||||||
|
|
||||||
|
At platform scale (tens to low hundreds of clubs) this is one query plus a list walk; pushing
|
||||||
|
portable date arithmetic into SQL to save that is not worth the opacity. The command's
|
||||||
|
`--lead-days` flag stays, redefined as a **global override** of the per-plan value, which is what
|
||||||
|
makes backfills and rehearsals possible.
|
||||||
|
|
||||||
|
`archive_overdue_clubs` needs no changes — but note that with the clock moving from ~410 days to
|
||||||
|
~60, this job goes from theoretical to load-bearing. Its dry-run-by-default posture matters more
|
||||||
|
now, not less, and `DEPLOYMENT.md`'s advice to run it without `--commit` for the first week should
|
||||||
|
be re-followed after this ships.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Migration plan
|
||||||
|
|
||||||
|
Four steps, in order. **Step 3 is the one that can take down live clubs.**
|
||||||
|
|
||||||
|
**1 — Rename.** `RenameModel` + `RenameField` operations. `makemigrations` will prompt for each
|
||||||
|
("Did you rename …?"); answer yes. **Inspect the generated migration by hand before running it** —
|
||||||
|
if Django emits `DeleteModel` + `CreateModel` instead of `RenameModel`, it will drop every price,
|
||||||
|
subscription and due in the table. This is the single highest-risk step in the change.
|
||||||
|
|
||||||
|
**2 — Add the new `Plan` fields**, with defaults chosen to reproduce *today's* behaviour on
|
||||||
|
existing rows: `duration_months=12`, `renewal_lead_days=30`, `is_trial=False`. `grace_days`
|
||||||
|
defaults to **30, not 45** — the number is not being carried over, because the thing it measures
|
||||||
|
from has changed.
|
||||||
|
|
||||||
|
**3 — Do NOT backfill `grace_until` on existing dues.**
|
||||||
|
|
||||||
|
> An open annual period that started in, say, March would today have `grace_until` around the
|
||||||
|
> following February. Re-derived under the new rule it becomes *March + 30 days* — a date already
|
||||||
|
> in the past. Every such club becomes instantly overdue, and the next `archive_overdue_clubs
|
||||||
|
> --commit` run switches off the entire paying customer base overnight.
|
||||||
|
|
||||||
|
Existing `Due.grace_until` values are snapshots and must be left exactly as they are. Only periods
|
||||||
|
opened *after* this ships use the new rule; every club migrates onto the new terms naturally at its
|
||||||
|
next renewal. If a specific club should move sooner, that is a deliberate one-off (cancel the open
|
||||||
|
period and reopen it), not a bulk data migration.
|
||||||
|
|
||||||
|
**4 — Retire the constants.** `GRACE_DAYS` and `RENEWAL_LEAD_DAYS` become the `default=` values on
|
||||||
|
the new `Plan` fields and are deleted from `billing/models.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Blast radius
|
||||||
|
|
||||||
|
Everything below references `Tier`, the constants, or the grace semantics, and will need touching:
|
||||||
|
|
||||||
|
| Area | Files |
|
||||||
|
|---|---|
|
||||||
|
| Models & services | `billing/models.py`, `billing/services/dues.py`, **new** `billing/services/notices.py` |
|
||||||
|
| Commands | `billing/management/commands/renew_subscriptions.py` |
|
||||||
|
| Admin | `billing/admin.py` |
|
||||||
|
| Control panel | `controlpanel/views.py` (20 refs), `forms.py` (10), `urls.py` (3), `services/statistics.py` |
|
||||||
|
| Control-panel templates | `billing.html`, `_club_billing_card.html`, `_club_health_table.html`, `dashboard.html` |
|
||||||
|
| Club-facing | `management/views.py`, `management/templates/management/home.html` |
|
||||||
|
| Invoice | `billing/templates/billing/invoice.html` |
|
||||||
|
| Tests | `billing/tests.py` (16), `controlpanel/tests.py` (17), `management/tests.py` (3), `formbuilder/tests.py` (1) |
|
||||||
|
| Docs | `ARCHITECTURE.md` (new billing section), `DEPLOYMENT.md` (archive job note) |
|
||||||
|
|
||||||
|
`controlpanel/services/statistics.py` deserves particular attention: `tier_name`,
|
||||||
|
`dues_grace_until` and `dues_period_end` all feed the club-health table, and `dues_in_grace()` /
|
||||||
|
`dues_overdue()` feed the dashboard counters. Their *numbers* will move once the clock changes,
|
||||||
|
which is expected — but the annotations themselves need renaming, not just re-pointing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Decisions taken, and the ones still open
|
||||||
|
|
||||||
|
**Taken:**
|
||||||
|
|
||||||
|
- Grace runs from `period_start`. *(Alternatives considered: from invoice issue — strictest, archives
|
||||||
|
a club before it has used anything; from `period_end` — today's behaviour, ~410 days of unpaid
|
||||||
|
use.)*
|
||||||
|
- Lead time is a per-plan field. *(Alternatives: derived from duration — invisible in the admin;
|
||||||
|
one small global constant — too tight for an annual invoice paid by bank transfer.)*
|
||||||
|
- `Tier` → `Plan`.
|
||||||
|
- Trial length comes from the trial plan's own `duration_months`, not a per-subscription number.
|
||||||
|
- `post_trial_plan` stays on `Subscription`, not on `Plan` — the same trial can convert to different
|
||||||
|
paid plans for different clubs.
|
||||||
|
|
||||||
|
**Resolved during implementation:**
|
||||||
|
|
||||||
|
1. **Full payment does NOT auto-restore an archived club.** `reactivate()` stays an explicit
|
||||||
|
platform-admin action — a club can also be archived by hand for reasons that have nothing to do
|
||||||
|
with money, and an automatic restore would silently reverse that the next time a stray payment
|
||||||
|
was recorded. Instead, `_club_billing_card.html` shows a prominent prompt on any archived club
|
||||||
|
whose dues are settled, so the deliberate act is one click away. No `archived_reason` field was
|
||||||
|
needed.
|
||||||
|
2. **The banner escalates.** `management/home.html` renders it at every level; `management/base.html`
|
||||||
|
repeats it on every *other* management page only once it reaches `error` (≤7 days, or overdue).
|
||||||
|
Shown from the moment anything is owed it would sit on every screen for weeks and train people to
|
||||||
|
ignore the one week that matters.
|
||||||
|
3. **Email reminders are in.** `send_billing_reminders` (dry-run by default, `--commit` to send)
|
||||||
|
plus provider-agnostic SMTP settings read from the environment. Reminders go **once per
|
||||||
|
escalation level**, tracked on `Due.last_reminder_level`, because the command is on a daily cron
|
||||||
|
and a club that owes money for a month must not get thirty identical emails.
|
||||||
|
4. **Online payment stays out of scope.** Every payment is still recorded by hand by a platform
|
||||||
|
admin (`record_payment`). The consequence is real and worth stating: the banner and the reminder
|
||||||
|
email both tell a club admin money is due while giving them no way to pay it in-app. They pay by
|
||||||
|
transfer; you record it.
|
||||||
|
|
||||||
|
### The email default that will catch you out
|
||||||
|
|
||||||
|
`EMAIL_BACKEND` defaults to the **console backend**, not SMTP. That is deliberate — Django's own
|
||||||
|
default tries localhost:25 and raises `ConnectionRefused` on a box with no MTA — but it means a
|
||||||
|
deployment that forgets `DJANGO_EMAIL_HOST` will watch `send_billing_reminders --commit` report
|
||||||
|
success while no club hears anything. Set the mail variables in `.env.production` (see
|
||||||
|
`.env.production.example`) before trusting the job.
|
||||||
|
|
||||||
|
## 11. Addendum: deleting a plan
|
||||||
|
|
||||||
|
Added after the initial implementation. `Due.plan` is `PROTECT` — a plan that has ever billed
|
||||||
|
anyone can never truly be removed, on purpose: `amount`, `period_end` and `grace_until` are frozen
|
||||||
|
on a `Due` precisely so a later change can't rewrite what was actually charged, and losing the plan
|
||||||
|
link off an old `Due` would do exactly that to every historical invoice. "Delete" therefore means
|
||||||
|
one of two things, chosen automatically (`billing/services/plans.py`):
|
||||||
|
|
||||||
|
- **No `Due` ever referenced the plan** (created, never actually used to bill anyone) — the row is
|
||||||
|
removed outright.
|
||||||
|
- **At least one `Due` references it** — soft-deleted instead: `Plan.deleted_at` is set and
|
||||||
|
`is_active` turned off. The row survives (so old invoices still say what they were billed under)
|
||||||
|
but is hidden from every picker and listing via `Plan.objects.visible()` — an opt-in queryset
|
||||||
|
method, same shape as `Club.objects.active()`, so the plain default manager stays unfiltered for
|
||||||
|
Django admin and anything reading historical data.
|
||||||
|
|
||||||
|
Either way, every club **currently on the plan** is unsubscribed outright — its `Subscription` row
|
||||||
|
is deleted, not just its `plan` field cleared. "No plan" was already a state the rest of the app
|
||||||
|
fully understood (every billing view already handles `getattr(club, "subscription", None)` being
|
||||||
|
`None`), so this reuses it rather than inventing a new one.
|
||||||
|
|
||||||
|
One easy-to-miss second group: a club on a **different** plan, mid-trial, configured to convert to
|
||||||
|
the plan being deleted (`Subscription.post_trial_plan`). Left alone, that club's trial would try to
|
||||||
|
convert onto a plan that no longer exists (or has been hidden) the moment `open_period()`'s
|
||||||
|
trial-conversion check next runs. Handled at delete time instead: that club's trial is ended
|
||||||
|
(`trial_ends_at` and `post_trial_plan` both cleared, per the `CheckConstraint` that requires them
|
||||||
|
set together or not at all), leaving it on the trial plan with no scheduled conversion until a
|
||||||
|
platform admin picks a new one.
|
||||||
|
|
||||||
|
The confirmation screen (`controlpanel/templates/controlpanel/plan_delete.html`) is a real page,
|
||||||
|
not a modal like every other billing action — the whole point is naming exactly which clubs are
|
||||||
|
affected, in both groups, and that list can be long.
|
||||||
59
CLAUDE.md
Normal file
59
CLAUDE.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
RosterChief is a sport club management app + public website, built on **Django 6.0** (Python 3.14+). As of **2026-07-11 it is designed as a multi-tenant platform** (row-based / shared-schema): one deployment serves many clubs, with `Club` as the tenant root. Every club-owned model carries a `club` FK (via `ClubScopedModel`); `User` is the only global model. This **reverses** the project's earlier single-club stance — treat older "single-club / no `club_id` tenancy" notes (in git history or memory) as obsolete.
|
||||||
|
|
||||||
|
**`ARCHITECTURE.md` at the repo root is the authoritative model & domain design** — the tenancy mechanics, the RBAC design, and per-app model sketches all live there. Consult and update it when adding domain models.
|
||||||
|
|
||||||
|
The repo is an early build: `authentication` and `club` apps exist (`User`, `Member`, `Family`, `FamilyMembership`, `Club`, `ClubMembership`); the remaining domain apps and the tenancy plumbing (`rosterchief/tenancy.py`, tenant middleware, `ClubScopedModel` upgrade) are **planned, not yet on disk**. Verify against the actual tree before assuming a module exists.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Dependencies and the virtualenv are managed with **uv** (`pyproject.toml` at repo root, `uv.lock` committed). Run Django/tools through `uv run` so the project venv is used.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync # install deps (incl. dev group) into .venv
|
||||||
|
uv run python manage.py runserver # dev server
|
||||||
|
uv run python manage.py migrate # apply migrations
|
||||||
|
uv run python manage.py makemigrations
|
||||||
|
uv run python manage.py createsuperuser
|
||||||
|
uv run python manage.py shell
|
||||||
|
|
||||||
|
uv run python manage.py test # run all tests (Django test runner)
|
||||||
|
uv run python manage.py test <app> # one app
|
||||||
|
uv run python manage.py test <app>.tests.<Case> # one TestCase
|
||||||
|
uv run python manage.py test <app>.tests.<Case>.<method> # one test
|
||||||
|
|
||||||
|
uv run ruff check . # lint
|
||||||
|
uv run ruff check --fix . # lint + autofix
|
||||||
|
uv run ruff format . # format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings live in a single `rosterchief/settings.py` and read from the environment via **python-decouple** (`config(...)`), with a local `.env` file for dev. Key vars: `DJANGO_SECRET_KEY` (required), `DJANGO_DEBUG`, `DJANGO_ALLOWED_HOSTS`, `DJANGO_CSRF_TRUSTED_ORIGINS`, `DJANGO_DATABASE_URL`, `DJANGO_TIME_ZONE`.
|
||||||
|
|
||||||
|
The database is configured through a single `DJANGO_DATABASE_URL` (parsed by **dj-database-url**), defaulting to `sqlite:///db.sqlite3` for dev; production is intended to point at PostgreSQL via that URL. Don't hardcode DB settings — go through the env var.
|
||||||
|
|
||||||
|
## Planned architecture
|
||||||
|
|
||||||
|
**`ARCHITECTURE.md` is the source of truth for the model design; this is a summary.** The app decomposition (`authentication`, `members`, `club`, `teams`, `events`, `news`, `pages`, `home`, `formbuilder`, `shop`, `search`) has grown past the original `pyproject.toml` isort `known-first-party` list — add new labels there as apps land. Note the `accounts` app was split into `authentication` (global login) + `club`, and people models (`Member`, `Family`) are being moved into a dedicated `members` app.
|
||||||
|
|
||||||
|
Domain notes (drive modeling decisions):
|
||||||
|
- **Multi-tenancy is the cross-cutting rule.** `Club` is the tenant root; club-owned models inherit `ClubScopedModel` (a `club` FK). Scope every query to the current tenant (`.for_club()` / `.current()`); previously-global uniqueness (slugs, season names, invoice numbers) becomes **unique per club**. Only `User` is global. See `ARCHITECTURE.md` §2.4.
|
||||||
|
- **Season** is the central organizing concept, **per club**. Team rosters, events, and attendance are season-scoped — model them with a FK to a season, not as global state.
|
||||||
|
- A **Member** (a person *within one club*) can play on one or more **Teams**, each with a position + jersey number (unique within a team), always tied to a specific season.
|
||||||
|
- **RBAC is per-club and service-layer** (not `django-guardian`, not global Django groups): `ClubRole` rows (`MEMBER` / `EDITOR` / `TREASURER` / `BOARD`) plus object-scoped roles (coach via `StaffAssignment`, parent via `FamilyMembership`), all decisions routed through an access service. Django's own permissions are used only for the platform-admin layer.
|
||||||
|
- Later modules: `formbuilder` (admin-defined dynamic forms → normalized answers → reporting) and `shop` (cart → order → payment → HTML→PDF invoices via WeasyPrint), with season-scoped `ClubMembership` tracking sign-up + fee status per season.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Ruff config anticipates a Wagtail-style codebase (`DJ` Django rules; `RUF012`/`RUF005` ignored for framework idioms; `line-length = 250`). Migrations are excluded from linting — don't hand-edit them to satisfy ruff.
|
||||||
|
- Settings files are exempt from `F403/F405/E501` (star imports allowed) under `rosterchief/settings/*` — note the config expects a settings *package*, though the current code is a single `settings.py`. If you split settings, match that path.
|
||||||
|
- **Every user-facing string must be translatable** (`USE_I18N = True`; no `.po` files exist yet, but the codebase is kept translation-ready as it's written, not audited later). This applies to templates, models, forms, and views alike:
|
||||||
|
- Templates: `{% load i18n %}`, then `{% trans "..." %}` for literal text and `{% blocktrans %}...{% endblocktrans %}` for text containing a variable (bind filter chains to a plain name first via `{% blocktrans with x=some.filtered|value %}`, or `{% blocktrans count counter=n %}...{% plural %}...{% endblocktrans %}` for pluralized counts — never hand-roll pluralization with `|pluralize`, real languages have more than two plural forms). Covers headings, buttons, table headers, empty-state text, `placeholder=`/`aria-label=` attributes — not URL names, CSS classes, icon names, `dom_id` arguments, or raw data interpolated on its own.
|
||||||
|
- Python (models, forms, views, services): `from django.utils.translation import gettext_lazy as _`, wrapping `verbose_name`, `help_text`, `Meta.verbose_name(_plural)`, form field `label`/`help_text`, `TextChoices`/`IntegerChoices` labels (never the choice *value*), and any user-facing string built in a method/view (`notify()` messages, raised `ValidationError`/`ValueError` text, dict labels rendered directly in a template).
|
||||||
|
- Never bake an interpolated value directly into a translatable string (an f-string with `{variable}` *inside* the translated text) — word order isn't guaranteed to survive translation. Use `%(name)s` placeholders instead: `_("“%(name)s” updated.") % {"name": obj}`. This matters most for `controlpanel.messages.notify(request, spec)`, whose `"<level>|<title>|<body>"` spec is assembled via f-string — translate the title and body as separate `_()`/`%()` expressions, then drop the already-resolved strings into the f-string skeleton.
|
||||||
627
DEPLOYMENT.md
Normal file
627
DEPLOYMENT.md
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
# 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 by default. `compose.yaml` mounts a `media_data`
|
||||||
|
volume, shared read-write with `web` and read-only with `caddy`, so uploads both survive a
|
||||||
|
rebuild and get served by Caddy directly (`handle_path /media/*` in the Caddyfile) rather than
|
||||||
|
round-tripping through a gunicorn worker. `rosterchief/urls.py` still serves `/media/*` itself
|
||||||
|
as a fallback whenever `AWS_STORAGE_BUCKET_NAME` is unset — needed for `compose.behind-proxy.yaml`
|
||||||
|
(no bundled Caddy there) and for `runserver`. On two boxes local disk stops working regardless
|
||||||
|
of any of this: a logo uploaded to node A is still a 404 on node B, since nothing shares the
|
||||||
|
volume between them. Setting `AWS_STORAGE_BUCKET_NAME` switches the default storage to S3 — do
|
||||||
|
it *before* you scale, not during.
|
||||||
|
|
||||||
|
**5. PDF invoices need native libraries.**
|
||||||
|
WeasyPrint binds to pango/cairo. The image installs them; a bare-metal deploy would need
|
||||||
|
them too, and a Mac needs Homebrew. This is the main reason to run the container even in
|
||||||
|
development if you touch invoicing.
|
||||||
|
|
||||||
|
## First deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Configure
|
||||||
|
cp .env.compose.example .env # read by docker compose
|
||||||
|
cp .env.production.example .env.production # read by Django
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(64))" # -> DJANGO_SECRET_KEY
|
||||||
|
|
||||||
|
# 2. Build and start
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d db redis
|
||||||
|
docker compose run --rm web python manage.py migrate
|
||||||
|
docker compose run --rm web python manage.py createsuperuser
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 3. Verify
|
||||||
|
curl -fsS https://rosterchief.app/healthz # {"status": "ok", ...}
|
||||||
|
docker compose run --rm web python manage.py check --deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
`check --deploy` is what catches an env file that forgot the HTTPS flags: they default to
|
||||||
|
**off** in code, because defaulting them to `not DEBUG` would redirect every test request to
|
||||||
|
https and break the suite anywhere `DEBUG` is unset.
|
||||||
|
|
||||||
|
### Keep DJANGO_DEBUG=False, even on the test server
|
||||||
|
|
||||||
|
A test box is still a deployment: it is behind TLS, on a real domain, with real passkeys.
|
||||||
|
`DEBUG=True` there leaks tracebacks and settings to anyone who can reach a 500, and turns off
|
||||||
|
several of the protections in this document. Use it locally, not on a server.
|
||||||
|
|
||||||
|
The app no longer *crashes* if you set it — `django_browser_reload` is a dev dependency that
|
||||||
|
the image installs with `--no-dev`, so settings guard on the module being importable rather
|
||||||
|
than assuming DEBUG implies it is there — but the reason to keep it off is not the crash.
|
||||||
|
|
||||||
|
### One dependency comes from git
|
||||||
|
|
||||||
|
`django-lucide` is our fork (`[tool.uv.sources]` in `pyproject.toml`, pinned by `uv.lock` to a
|
||||||
|
commit), so **uv shells out to `git`** to fetch it. `python:*-slim` has no git, which is why
|
||||||
|
the image builds the virtualenv in a **separate stage** that installs git, and copies the
|
||||||
|
finished `.venv` into a runtime stage that does not have it — a build tool has no business in
|
||||||
|
a production image.
|
||||||
|
|
||||||
|
Two consequences worth knowing:
|
||||||
|
|
||||||
|
- The build needs **network access to GitHub**, and the fork must stay reachable. If that ever
|
||||||
|
becomes awkward (a private runner, an air-gapped build), publish the fork to a private index
|
||||||
|
or vendor the wheel, and the git stage disappears.
|
||||||
|
- `uv.lock` pins the exact commit, so the build is reproducible even though the source is a
|
||||||
|
branch. Don't build with `--no-frozen`.
|
||||||
|
|
||||||
|
The first `docker compose up` will take a minute or two: Caddy is provisioning the wildcard
|
||||||
|
certificate over DNS-01, and DNS propagation is not instant. Watch it with
|
||||||
|
`docker compose logs -f caddy`.
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Deliberately **not** run by the container's entrypoint. With more than one web container they
|
||||||
|
would race, and a starting gunicorn worker is a bad place to discover a failed migration.
|
||||||
|
Run them once, explicitly, as part of the deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
docker compose run --rm web python manage.py migrate
|
||||||
|
docker compose up -d --no-deps web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scheduled jobs
|
||||||
|
|
||||||
|
Five jobs run on a schedule via **Celery Beat**, not host cron — see `rosterchief/settings.py`
|
||||||
|
(`CELERY_BEAT_SCHEDULE`) for the exact times and `features/jobs.py` for what each one does.
|
||||||
|
`worker` and `beat` are just the `web` image running a different command (see `compose.yaml`);
|
||||||
|
`worker` can scale to several containers, but run **exactly one `beat`** across the whole
|
||||||
|
deployment — it decides *when* a task fires, so two of them means every job runs twice (two
|
||||||
|
`archive_overdue_clubs` runs is two emails to the same club, the same "exactly one node"
|
||||||
|
reasoning the old crontab needed).
|
||||||
|
|
||||||
|
| Job | Cadence | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `extend_event_series` | daily 03:00 | materialises recurring event occurrences so the calendar never runs dry |
|
||||||
|
| `renew_subscriptions` | daily 04:00 | opens the next billing period for clubs whose current one is running out |
|
||||||
|
| `send_billing_reminders` | daily 05:00 | emails club admins about outstanding platform fees, once per escalation level |
|
||||||
|
| `archive_overdue_clubs` | daily 06:00 | archives clubs unpaid past their grace period |
|
||||||
|
| `generate_seasons` | monthly, 1st 05:00 | generates the next 2 years of seasons for every active club |
|
||||||
|
|
||||||
|
Each task always acts (no `--dry-run`/`--commit` gate) — the same as the old crontab always
|
||||||
|
passing `--commit`. Run status (started, finished, success/failure, what it returned or
|
||||||
|
raised) is recorded in `features.models.JobRun` and shown on the control panel's **Jobs**
|
||||||
|
tab, which a crontab line mailing stderr on failure never gave us.
|
||||||
|
|
||||||
|
The `manage.py <command>` versions of these still exist unchanged, for manual/dry-run use
|
||||||
|
from a shell — see each command's own `--help` (`generate_seasons --resync`, for one, is
|
||||||
|
still CLI-only: it can delete rows, so it isn't something a beat schedule runs unattended).
|
||||||
|
|
||||||
|
## 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** — the five Celery tasks in the table above, plus
|
||||||
|
`import_members_csv` when run by hand.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
A Celery task raises loudly rather than skipping quietly while the platform is closed — that
|
||||||
|
is intended, a job that silently no-ops is how a month of billing goes missing — which
|
||||||
|
`worker` logs and, via `features/signals.py`, records as a `Failed` JobRun on the control
|
||||||
|
panel's **Jobs** tab. The `manage.py` version of each command still exits non-zero the same
|
||||||
|
way and accepts `--ignore-maintenance` for the rare case you genuinely mean to run one by
|
||||||
|
hand during a window.
|
||||||
|
|
||||||
|
So a migration-heavy deploy looks like:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Close the platform in the control panel (or from a shell):
|
||||||
|
docker compose run --rm web python manage.py shell -c \
|
||||||
|
"from features.models import Maintenance; Maintenance.start(message='Upgrading. Back by 21:00.')"
|
||||||
|
|
||||||
|
# 2. Do the work — migrate is not blocked.
|
||||||
|
docker compose build
|
||||||
|
docker compose run --rm web python manage.py migrate
|
||||||
|
docker compose up -d --no-deps web
|
||||||
|
|
||||||
|
# 3. Reopen from the control panel.
|
||||||
|
```
|
||||||
|
|
||||||
|
The state lives in Redis as well as the database, so it takes effect on **every worker and
|
||||||
|
every server at once** — a per-process cache would leave some workers still serving clubs.
|
||||||
|
|
||||||
|
## Behind an existing Caddy (dev / test server)
|
||||||
|
|
||||||
|
If the box already runs Caddy on :80 and :443 — a test server sharing a host with other
|
||||||
|
sites — do **not** run ours: two Caddies cannot both hold port 80. Run the app only, publish
|
||||||
|
it on the loopback, and add a site block to the Caddy that is already there.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.behind-proxy.yaml up -d # web + db + redis, no caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
`web` publishes on `127.0.0.1:8001` (override with `WEB_PORT`). **Loopback, not 0.0.0.0** —
|
||||||
|
bound to all interfaces, a test instance is reachable at `http://<server-ip>:8001` with no
|
||||||
|
TLS, bypassing the proxy and every security header with it.
|
||||||
|
|
||||||
|
Then add a site block to the host's Caddyfile. Caddy serves any number of domains on the same
|
||||||
|
ports — TLS is chosen per connection by SNI — so a second (or tenth) site is just another
|
||||||
|
block.
|
||||||
|
|
||||||
|
### If that Caddy already does Cloudflare DNS-01
|
||||||
|
|
||||||
|
Which is the usual case: the box has a domain on Cloudflare and Caddy already has the DNS
|
||||||
|
plugin. Then set the challenge **once, globally**, and every site inherits it — no `tls`
|
||||||
|
block per site, and wildcards simply work:
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
{
|
||||||
|
email you@example.com
|
||||||
|
|
||||||
|
# Applies DNS-01 to every site below.
|
||||||
|
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- whatever the box already serves --------------------------------------
|
||||||
|
existing-thing.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- RosterChief test instance --------------------------------------------
|
||||||
|
# The bare host AND the wildcard, on one certificate.
|
||||||
|
test.rosterchief.app, *.test.rosterchief.app {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
reverse_proxy 127.0.0.1:8001 {
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### If the two domains need different tokens
|
||||||
|
|
||||||
|
Different Cloudflare accounts, or tokens scoped per zone. Drop `acme_dns` and give each site
|
||||||
|
its own `tls`; a snippet keeps it short:
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
{
|
||||||
|
email you@example.com
|
||||||
|
}
|
||||||
|
|
||||||
|
(cf) {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {args[0]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
existing-thing.example.com {
|
||||||
|
import cf {env.CF_TOKEN_EXAMPLE}
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
test.rosterchief.app, *.test.rosterchief.app {
|
||||||
|
import cf {env.CF_TOKEN_ROSTERCHIEF}
|
||||||
|
reverse_proxy 127.0.0.1:8001 {
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### What actually goes wrong
|
||||||
|
|
||||||
|
1. **The token must cover the *new* zone.** A Cloudflare token is scoped to named zones, and
|
||||||
|
an existing one almost certainly grants `Zone:DNS:Edit` on the domain it was made for and
|
||||||
|
nothing else. The new site then fails its DNS-01 challenge on a permissions error whose
|
||||||
|
text does not say so. Widen the token, or mint a second one and use the snippet form.
|
||||||
|
2. **Both hostnames must be listed.** `*.test.rosterchief.app` does **not** match
|
||||||
|
`test.rosterchief.app` — a wildcard covers exactly one label. Leave the bare host out and
|
||||||
|
the club subdomains have a certificate while the control panel does not. Hence the comma.
|
||||||
|
(Wildcards are also only one level deep: `ajax.test.…` yes, `a.b.test.…` no.)
|
||||||
|
3. **Caddy must have the DNS plugin.** Stock `caddy` cannot answer a DNS-01 challenge at all.
|
||||||
|
`caddy add-package github.com/caddy-dns/cloudflare`, or run a Caddy built like
|
||||||
|
`deploy/caddy/Dockerfile`. (If DNS-01 already works on the box, you have it.)
|
||||||
|
4. **The token must be in *Caddy's* environment**, not your shell's — `{env.…}` reads the
|
||||||
|
process it runs in:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/caddy.service.d/override.conf
|
||||||
|
[Service]
|
||||||
|
EnvironmentFile=/etc/caddy/caddy.env # CLOUDFLARE_API_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `systemctl daemon-reload && systemctl restart caddy`.
|
||||||
|
|
||||||
|
5. **`header_up X-Forwarded-Proto` is not optional**, exactly as in the bundled Caddyfile:
|
||||||
|
without it Django believes the request behind the proxy is plain HTTP.
|
||||||
|
|
||||||
|
6. **Give the test instance its own subdomain tree** and set
|
||||||
|
`ROSTERCHIEF_BASE_DOMAIN=test.rosterchief.app`. That variable drives tenant resolution,
|
||||||
|
the shared session cookie *and* the WebAuthn RP ID — point it at the production domain and
|
||||||
|
test passkeys start colliding with real ones.
|
||||||
|
|
||||||
|
### Applying and checking it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
caddy validate --config /etc/caddy/Caddyfile # syntax and modules
|
||||||
|
systemctl reload caddy # zero downtime; existing certs untouched
|
||||||
|
journalctl -u caddy -f # watch the DNS-01 challenge
|
||||||
|
|
||||||
|
curl -I https://test.rosterchief.app/healthz
|
||||||
|
curl -I https://any-club-slug.test.rosterchief.app/ # proves the WILDCARD, not just the host
|
||||||
|
```
|
||||||
|
|
||||||
|
Reloading provisions only what is new, so the existing site's certificate is not reissued.
|
||||||
|
Allow 30–60s for the DNS record to propagate before the challenge completes.
|
||||||
|
|
||||||
|
DNS needs both records, pointing at the test box:
|
||||||
|
|
||||||
|
```
|
||||||
|
A test.rosterchief.app -> <server ip>
|
||||||
|
A *.test.rosterchief.app -> <server ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
The compose project is named `rosterchief-test`, so its containers and volumes never collide
|
||||||
|
with a production stack on the same host.
|
||||||
|
|
||||||
|
### Deploying with one command
|
||||||
|
|
||||||
|
Once the server has the repo cloned at `/home/bernard/RosterChief` and its two env files in
|
||||||
|
place, `deploy/deploy-dev.sh` does a full deploy over SSH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deploy/deploy-dev.sh # deploy the current branch
|
||||||
|
BRANCH=main deploy/deploy-dev.sh
|
||||||
|
deploy/deploy-dev.sh --push # push the branch first, then deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs from your machine and does the work on the server in one SSH session: fetch the pushed
|
||||||
|
branch (a hard reset to `origin/<branch>`, since a deploy target only receives deploys), build
|
||||||
|
the image, run migrations *explicitly*, restart only `web`, and wait for `/healthz`.
|
||||||
|
|
||||||
|
It refuses to deploy a branch whose local commits are not pushed — the server pulls from git,
|
||||||
|
so unpushed work would ship stale code silently. Override the host, user, directory or branch
|
||||||
|
with the `SSH_HOST` / `SSH_USER` / `REMOTE_DIR` / `BRANCH` environment variables.
|
||||||
|
|
||||||
|
First-time setup on the server, once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.siebens.org:bernard/RosterChief.git /home/bernard/RosterChief
|
||||||
|
cd /home/bernard/RosterChief
|
||||||
|
cp .env.compose.example .env # fill in POSTGRES_PASSWORD etc.
|
||||||
|
cp .env.production.example .env.production
|
||||||
|
# then add the reverse_proxy site block to the host's Caddy (see above)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automated backups
|
||||||
|
|
||||||
|
`deploy/backup.sh` dumps the database, tars the uploads while they are still on local disk,
|
||||||
|
prunes anything older than `KEEP_DAYS`, and — if you set `BACKUP_REMOTE` — copies the lot off
|
||||||
|
the box with rclone.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deploy/backup.sh /var/backups/rosterchief
|
||||||
|
```
|
||||||
|
|
||||||
|
It writes to a `.part` file and only moves it into place once `gzip -t` says the archive is
|
||||||
|
readable and non-empty. A truncated dump that *looks* like a backup is the failure mode worth
|
||||||
|
engineering against, because you only discover it on the day you need it.
|
||||||
|
|
||||||
|
Schedule it as root on the host (single server; on several, run it on the database node):
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# Nightly at 02:30, before the billing and event jobs.
|
||||||
|
30 2 * * * cd /srv/rosterchief && BACKUP_REMOTE=b2:rosterchief-backups KEEP_DAYS=14 deploy/backup.sh /var/backups/rosterchief
|
||||||
|
|
||||||
|
# Weekly restore rehearsal into a throwaway database. This is the only line here that proves
|
||||||
|
# the others work.
|
||||||
|
0 4 * * 0 cd /srv/rosterchief && deploy/restore-check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Cron mails you on non-zero exit, and the script uses `set -Eeuo pipefail` so it *does* exit
|
||||||
|
non-zero. A backup script that fails quietly is worse than none, because you will believe you
|
||||||
|
have backups.
|
||||||
|
|
||||||
|
**Offsite matters more than frequency.** A dump sitting on the same disk as the database
|
||||||
|
survives a bad migration but not the server. `BACKUP_REMOTE` takes any rclone remote (S3,
|
||||||
|
Backblaze, a second box).
|
||||||
|
|
||||||
|
**Once uploads move to S3** (`AWS_STORAGE_BUCKET_NAME`), the script skips the media tarball:
|
||||||
|
the bucket's own versioning is the backup. Turn versioning on when you create it.
|
||||||
|
|
||||||
|
### Restoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunzip -c /var/backups/rosterchief/db-2026-07-14-0230.sql.gz \
|
||||||
|
| docker compose exec -T db psql -U rosterchief rosterchief
|
||||||
|
```
|
||||||
|
|
||||||
|
The dump is taken with `--clean --if-exists`, so it drops and recreates rather than colliding
|
||||||
|
with what is there. Rehearse it once, now, against a scratch database — not the first time you
|
||||||
|
need it.
|
||||||
|
|
||||||
|
## Backups (manual)
|
||||||
|
|
||||||
|
Two things carry state: Postgres and the uploads.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
docker compose exec -T db pg_dump -U rosterchief rosterchief | gzip > rosterchief-$(date +%F).sql.gz
|
||||||
|
|
||||||
|
# Uploads — until they are on S3, in which case the bucket's own versioning is the backup.
|
||||||
|
docker compose cp web:/app/media ./media-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore is `gunzip -c dump.sql.gz | docker compose exec -T db psql -U rosterchief rosterchief`.
|
||||||
|
Test it once, now, rather than the first time you need it.
|
||||||
|
|
||||||
|
## Sizing the server
|
||||||
|
|
||||||
|
For **1–5 clubs, ~1000 members, ~10 events per club per week**.
|
||||||
|
|
||||||
|
The short answer: **2 vCPU, 4 GB RAM, 40 GB SSD** — a €4–6/month VPS (Hetzner CX22 or
|
||||||
|
equivalent). The interesting part is *why*, because the data is not what sizes this box.
|
||||||
|
|
||||||
|
### The data is negligible
|
||||||
|
|
||||||
|
Row counts for that workload, from the actual schema (attendance dominates: every event
|
||||||
|
invites a squad, so one event is ~20 rows):
|
||||||
|
|
||||||
|
| table | rows/year | MB/year |
|
||||||
|
|---|---:|---:|
|
||||||
|
| `events.Attendance` | 52,000 | 16 |
|
||||||
|
| `events.Event` | 2,600 | 2 |
|
||||||
|
| `formbuilder` answers | 10,000 | 3 |
|
||||||
|
| `shop` orders + lines | 3,000 | 1 |
|
||||||
|
| members, memberships, rosters | ~3,000 | 1 |
|
||||||
|
| **total, with WAL and bloat** | | **~40 MB/year** |
|
||||||
|
|
||||||
|
That is **0.2 GB after five years**. Uploads are club logos — a handful of files. Invoices are
|
||||||
|
rendered on demand and never stored. Nothing here grows into a problem.
|
||||||
|
|
||||||
|
So do not size for the data. Size for the **processes**.
|
||||||
|
|
||||||
|
### What actually consumes the box
|
||||||
|
|
||||||
|
Measured, running this app under gunicorn with `DEBUG=False`, before the tuning below —
|
||||||
|
`--workers 3`, no `--preload`, Postgres and Redis on their image defaults:
|
||||||
|
|
||||||
|
| | 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** |
|
||||||
|
|
||||||
|
Since then, `Dockerfile`/`compose.yaml` were tuned for smaller boxes: `--workers 2 --preload`
|
||||||
|
(one fewer duplicated Django process, and `--preload` shares immutable memory across workers
|
||||||
|
via copy-on-write instead of each worker importing Django independently), plus trimmed Postgres
|
||||||
|
`shared_buffers`/`max_connections` and a Redis `--maxmemory` cap. Expect the gunicorn and
|
||||||
|
Postgres rows to come in lower than above — not yet re-measured, so treat the table as the
|
||||||
|
shape of where memory goes rather than exact numbers on the current config.
|
||||||
|
|
||||||
|
The table also predates `worker` and `beat` (see "Scheduled jobs"): each is one more full
|
||||||
|
Django process, not re-measured yet either, but expect each to land in the same range as one
|
||||||
|
gunicorn worker above (~50–60 MB) since it's the same app import cost with none of gunicorn's
|
||||||
|
own overhead. `beat` additionally has essentially nothing to do between firing its five daily
|
||||||
|
tasks, so it's the cheapest process in the stack to run.
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| Scheduled jobs | `worker` + `beat` containers | `worker` on any/every node; **`beat` on exactly one** |
|
||||||
|
| 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.
|
||||||
117
Dockerfile
Normal file
117
Dockerfile
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# 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
|
||||||
|
# Every directory assets/app.css's @source lines scan -- miss one here and Tailwind's build
|
||||||
|
# silently emits no utilities for classes used only in that app's templates. Locally `npm run
|
||||||
|
# build` runs against the full checkout and never shows this; only a container image, built
|
||||||
|
# from just what's COPYed here, can.
|
||||||
|
COPY assets ./assets
|
||||||
|
COPY templates ./templates
|
||||||
|
COPY controlpanel ./controlpanel
|
||||||
|
COPY billing ./billing
|
||||||
|
COPY management ./management
|
||||||
|
COPY club ./club
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# --- 2. the virtualenv -------------------------------------------------------
|
||||||
|
# Separate from the runtime for one reason: django-lucide is a *git* dependency (our lucide
|
||||||
|
# fork), so uv shells out to git to fetch it. python:*-slim has no git, and installing it in
|
||||||
|
# the runtime image would leave a build-time tool — plus its dependency tree — in production
|
||||||
|
# for the sake of one package that is already vendored into the venv by then.
|
||||||
|
FROM python:3.14-slim AS venv
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install --no-install-recommends -y git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_PYTHON_DOWNLOADS=never
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dependencies first: they change far less often than the code, so this layer caches.
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
|
||||||
|
# --- 3. the runtime ----------------------------------------------------------
|
||||||
|
FROM python:3.14-slim AS app
|
||||||
|
|
||||||
|
# WeasyPrint binds to these at import: no pango, no invoices. This is also why building the
|
||||||
|
# PDF path in a container is easier than on a Mac — apt has what Homebrew would have to.
|
||||||
|
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangoft2-1.0-0 \
|
||||||
|
libharfbuzz0b \
|
||||||
|
libffi8 \
|
||||||
|
libjpeg62-turbo \
|
||||||
|
libopenjp2-7 \
|
||||||
|
shared-mime-info \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PATH="/app/.venv/bin:$PATH" \
|
||||||
|
# gunicorn 26's control server puts a socket in $HOME. The app user has no home dir, so
|
||||||
|
# without this it logs "Permission denied: /home/rosterchief" on every boot. /app is
|
||||||
|
# already the workdir and owned by the app user, so point HOME there.
|
||||||
|
HOME="/app"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# The venv arrives fully built. Same base image, so the compiled wheels inside it are ABI
|
||||||
|
# compatible; nothing is re-resolved here, and no git is needed to run what git fetched.
|
||||||
|
COPY --from=venv /app/.venv ./.venv
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
COPY --from=css /build/static/css/app.css ./static/css/app.css
|
||||||
|
COPY --from=css /build/static/css/controlpanel.css ./static/css/controlpanel.css
|
||||||
|
COPY --from=css /build/static/css/management.css ./static/css/management.css
|
||||||
|
|
||||||
|
# collectstatic needs a settings module that imports: a throwaway key, never used at runtime.
|
||||||
|
RUN DJANGO_SECRET_KEY=build-only-not-a-secret \
|
||||||
|
DJANGO_STATICFILES_BACKEND=whitenoise.storage.CompressedManifestStaticFilesStorage \
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
# mkdir before chown, and before the volume ever mounts: media_data has nothing to copy from
|
||||||
|
# at /app/media otherwise, so Docker creates the mount point itself, owned by root — and the
|
||||||
|
# app runs as rosterchief, not root. Existing image content (even an empty, correctly-owned
|
||||||
|
# dir) is what a named volume copies its initial ownership from on first use.
|
||||||
|
RUN useradd --system --uid 1000 rosterchief \
|
||||||
|
&& mkdir -p /app/media /app/private_media \
|
||||||
|
&& 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).
|
||||||
|
# 2 workers, not 3: DEPLOYMENT.md's own sizing says this workload isn't CPU-bound, and each
|
||||||
|
# worker duplicates a full Django process — the single biggest lever on a memory-limited box.
|
||||||
|
# --preload imports the app once in the master and forks workers via copy-on-write instead of
|
||||||
|
# each re-importing Django independently (safe here: no app's ready() touches DB/Redis eagerly,
|
||||||
|
# checked club/features/news/events). --max-requests recycles a worker periodically so the one
|
||||||
|
# that happens to render a WeasyPrint invoice doesn't carry that +50-100MB forever.
|
||||||
|
CMD ["gunicorn", "rosterchief.wsgi:application", \
|
||||||
|
"--bind", "0.0.0.0:8000", \
|
||||||
|
"--workers", "2", \
|
||||||
|
"--threads", "4", \
|
||||||
|
"--preload", \
|
||||||
|
"--max-requests", "500", \
|
||||||
|
"--max-requests-jitter", "50", \
|
||||||
|
"--timeout", "60", \
|
||||||
|
"--access-logfile", "-", \
|
||||||
|
"--error-logfile", "-"]
|
||||||
BIN
Leeg Document RefereesCompetitie25-26.pdf
Normal file
BIN
Leeg Document RefereesCompetitie25-26.pdf
Normal file
Binary file not shown.
6
api/apps.py
Normal file
6
api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
name = "api"
|
||||||
|
verbose_name = "Public API"
|
||||||
17
api/errors.py
Normal file
17
api/errors.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Shared error helpers for the public API -- see api/urls.py.
|
||||||
|
|
||||||
|
Every endpoint is club-scoped via the same subdomain-based tenant resolution
|
||||||
|
the rest of the platform uses (club.tenancy.ClubTenantMiddleware sets
|
||||||
|
request.club before any view runs). A request with no club on the host --
|
||||||
|
the bare base domain, an unknown slug, an archived club -- has nothing to
|
||||||
|
serve, so it 404s the same way club.mixins.ClubStaffRequiredMixin already
|
||||||
|
404s the staff-facing app off the base domain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
|
||||||
|
def require_club(request):
|
||||||
|
if request.club is None:
|
||||||
|
raise HttpError(404, "No club found for this host.")
|
||||||
|
return request.club
|
||||||
31
api/middleware.py
Normal file
31
api/middleware.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""CORS for the public API only.
|
||||||
|
|
||||||
|
Every route under /api/v1/ is public, read-only, and unauthenticated -- no
|
||||||
|
cookies or credentials are ever involved, so there's no CSRF/session risk in
|
||||||
|
answering any origin. That's the whole reason this is a few lines here
|
||||||
|
instead of pulling in django-cors-headers for a handful of GET routes: the
|
||||||
|
rest of the site keeps Django's ordinary same-origin behaviour untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
API_PATH_PREFIX = "/api/v1/"
|
||||||
|
|
||||||
|
|
||||||
|
class PublicApiCorsMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if not request.path.startswith(API_PATH_PREFIX):
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
response = HttpResponse(status=204)
|
||||||
|
else:
|
||||||
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
response["Access-Control-Allow-Origin"] = "*"
|
||||||
|
response["Access-Control-Allow-Methods"] = "GET, OPTIONS"
|
||||||
|
response["Access-Control-Allow-Headers"] = "Content-Type"
|
||||||
|
return response
|
||||||
681
api/tests.py
Normal file
681
api/tests.py
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
import datetime
|
||||||
|
import io
|
||||||
|
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from club.models import Club, ClubMembership, Season, Sponsor
|
||||||
|
from events.models import Event, Location, Opponent
|
||||||
|
from members.models import Member
|
||||||
|
from news.models import News, NewsPhoto
|
||||||
|
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||||
|
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"],
|
||||||
|
)
|
||||||
|
class ApiTestBase(TestCase):
|
||||||
|
# The tenant, its current season and its one team back every API test and are read
|
||||||
|
# only. The handful of tests that do change them (deleting the season, giving the
|
||||||
|
# club a logo) get their own copy from setUpTestData and are rolled back with the
|
||||||
|
# per-test transaction.
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
today = timezone.localdate()
|
||||||
|
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
|
||||||
|
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
|
||||||
|
|
||||||
|
def api_get(self, path, **params):
|
||||||
|
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def api_get_base_domain(self, path, **params):
|
||||||
|
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="rosterchief.app")
|
||||||
|
|
||||||
|
|
||||||
|
class NewsApiTests(ApiTestBase):
|
||||||
|
def make_news(self, **overrides):
|
||||||
|
defaults = {"club": self.club, "title": "News", "body": "body", "status": News.Status.PUBLISHED, "published_at": timezone.now() - datetime.timedelta(hours=1), "visibility": News.Visibility.EXTERNAL}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return News.objects.create(**defaults)
|
||||||
|
|
||||||
|
def test_a_draft_is_excluded(self):
|
||||||
|
self.make_news(status=News.Status.DRAFT, published_at=None)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/news/").json()["count"], 0)
|
||||||
|
|
||||||
|
def test_a_scheduled_but_not_yet_released_item_is_excluded(self):
|
||||||
|
self.make_news(published_at=timezone.now() + datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/news/").json()["count"], 0)
|
||||||
|
|
||||||
|
def test_an_internal_only_item_is_excluded(self):
|
||||||
|
self.make_news(visibility=News.Visibility.INTERNAL)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/news/").json()["count"], 0)
|
||||||
|
|
||||||
|
def test_an_external_item_is_included(self):
|
||||||
|
item = self.make_news(visibility=News.Visibility.EXTERNAL)
|
||||||
|
|
||||||
|
data = self.api_get("/news/").json()
|
||||||
|
|
||||||
|
self.assertEqual(data["count"], 1)
|
||||||
|
self.assertEqual(data["results"][0]["id"], str(item.pk))
|
||||||
|
|
||||||
|
def test_a_both_visibility_item_is_included(self):
|
||||||
|
self.make_news(visibility=News.Visibility.BOTH)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/news/").json()["count"], 1)
|
||||||
|
|
||||||
|
def test_newest_first(self):
|
||||||
|
older = self.make_news(title="Older", published_at=timezone.now() - datetime.timedelta(days=2))
|
||||||
|
newer = self.make_news(title="Newer", published_at=timezone.now() - datetime.timedelta(hours=1))
|
||||||
|
|
||||||
|
results = self.api_get("/news/").json()["results"]
|
||||||
|
|
||||||
|
self.assertEqual([r["id"] for r in results], [str(newer.pk), str(older.pk)])
|
||||||
|
|
||||||
|
def test_photos_get_absolute_urls(self):
|
||||||
|
item = self.make_news()
|
||||||
|
NewsPhoto.objects.create(news_item=item, image="clubs/ajax-united/news/x/pic.jpg", is_main=True)
|
||||||
|
|
||||||
|
photo = self.api_get("/news/").json()["results"][0]["photos"][0]
|
||||||
|
|
||||||
|
self.assertTrue(photo["url"].startswith("http://ajax-united.rosterchief.app/media/"))
|
||||||
|
self.assertTrue(photo["is_main"])
|
||||||
|
|
||||||
|
def test_pagination_limit_and_offset(self):
|
||||||
|
for i in range(3):
|
||||||
|
self.make_news(title=f"Item {i}", published_at=timezone.now() - datetime.timedelta(hours=1, minutes=i))
|
||||||
|
|
||||||
|
data = self.api_get("/news/", limit=1, offset=1).json()
|
||||||
|
|
||||||
|
self.assertEqual(data["count"], 3)
|
||||||
|
self.assertEqual(len(data["results"]), 1)
|
||||||
|
|
||||||
|
def test_limit_is_capped(self):
|
||||||
|
self.assertEqual(self.api_get("/news/", limit=1000).json()["limit"], 100)
|
||||||
|
|
||||||
|
def test_no_news_is_an_empty_list_not_an_error(self):
|
||||||
|
response = self.api_get("/news/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["results"], [])
|
||||||
|
|
||||||
|
def test_excerpt_is_a_truncated_prefix_of_the_body(self):
|
||||||
|
self.make_news(body=" ".join(f"word{i}" for i in range(80)))
|
||||||
|
|
||||||
|
excerpt = self.api_get("/news/").json()["results"][0]["excerpt_nl"]
|
||||||
|
|
||||||
|
self.assertTrue(excerpt.startswith("word0 word1"))
|
||||||
|
self.assertTrue(excerpt.endswith("…"))
|
||||||
|
self.assertLess(len(excerpt.split()), 80)
|
||||||
|
|
||||||
|
def test_excerpt_is_unchanged_when_the_body_is_already_short(self):
|
||||||
|
item = self.make_news(body="Short body.")
|
||||||
|
|
||||||
|
excerpt = self.api_get("/news/").json()["results"][0]["excerpt_nl"]
|
||||||
|
|
||||||
|
self.assertEqual(excerpt, item.body)
|
||||||
|
|
||||||
|
def test_slug_is_auto_populated_from_the_title(self):
|
||||||
|
item = self.make_news(title="Big Win This Weekend")
|
||||||
|
|
||||||
|
self.assertEqual(item.slug, "big-win-this-weekend")
|
||||||
|
|
||||||
|
def test_get_single_news_item_by_slug(self):
|
||||||
|
item = self.make_news(title="Big Win This Weekend")
|
||||||
|
|
||||||
|
response = self.api_get(f"/news/{item.slug}/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["id"], str(item.pk))
|
||||||
|
|
||||||
|
def test_get_single_news_item_404s_for_an_unknown_slug(self):
|
||||||
|
response = self.api_get("/news/no-such-item/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_get_single_news_item_respects_visibility(self):
|
||||||
|
item = self.make_news(visibility=News.Visibility.INTERNAL)
|
||||||
|
|
||||||
|
response = self.api_get(f"/news/{item.slug}/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_body_markdown_is_rendered_to_html(self):
|
||||||
|
self.make_news(body="## Big win\n\nWe beat **Rivals FC** 4-2. [Full report](https://example.com).")
|
||||||
|
|
||||||
|
body = self.api_get("/news/").json()["results"][0]["body_nl"]
|
||||||
|
|
||||||
|
self.assertIn("<h2>Big win</h2>", body)
|
||||||
|
self.assertIn("<strong>Rivals FC</strong>", body)
|
||||||
|
self.assertIn('href="https://example.com"', body)
|
||||||
|
self.assertIn(">Full report</a>", body)
|
||||||
|
|
||||||
|
def test_body_markdown_a_single_newline_becomes_a_line_break(self):
|
||||||
|
self.make_news(body="Line one\nLine two")
|
||||||
|
|
||||||
|
body = self.api_get("/news/").json()["results"][0]["body_nl"]
|
||||||
|
|
||||||
|
self.assertIn("Line one<br", body)
|
||||||
|
|
||||||
|
def test_body_html_strips_a_script_tag(self):
|
||||||
|
self.make_news(body="Hello<script>alert('xss')</script>world")
|
||||||
|
|
||||||
|
body = self.api_get("/news/").json()["results"][0]["body_nl"]
|
||||||
|
|
||||||
|
self.assertNotIn("<script", body)
|
||||||
|
self.assertNotIn("alert(", body)
|
||||||
|
|
||||||
|
def test_body_html_strips_an_event_handler_attribute(self):
|
||||||
|
self.make_news(body='<img src="x" onerror="alert(1)">')
|
||||||
|
|
||||||
|
body = self.api_get("/news/").json()["results"][0]["body_nl"]
|
||||||
|
|
||||||
|
self.assertNotIn("onerror", body)
|
||||||
|
|
||||||
|
def test_body_html_strips_a_javascript_url(self):
|
||||||
|
self.make_news(body="[click me](javascript:alert(1))")
|
||||||
|
|
||||||
|
body = self.api_get("/news/").json()["results"][0]["body_nl"]
|
||||||
|
|
||||||
|
self.assertNotIn("javascript:", body)
|
||||||
|
|
||||||
|
def test_excerpt_strips_markdown_syntax(self):
|
||||||
|
self.make_news(body="**Bold** and a [link](https://example.com) and # not a heading here")
|
||||||
|
|
||||||
|
excerpt = self.api_get("/news/").json()["results"][0]["excerpt_nl"]
|
||||||
|
|
||||||
|
self.assertNotIn("**", excerpt)
|
||||||
|
self.assertNotIn("[link]", excerpt)
|
||||||
|
self.assertNotIn("<", excerpt)
|
||||||
|
|
||||||
|
def test_english_falls_back_to_the_original_when_not_translated(self):
|
||||||
|
self.make_news(title="Seizoensstart", body="We beginnen het seizoen.")
|
||||||
|
|
||||||
|
result = self.api_get("/news/").json()["results"][0]
|
||||||
|
|
||||||
|
self.assertEqual(result["title_en"], "Seizoensstart")
|
||||||
|
self.assertIn("We beginnen het seizoen.", result["body_en"])
|
||||||
|
self.assertEqual(result["excerpt_en"], result["excerpt_nl"])
|
||||||
|
|
||||||
|
def test_english_is_used_when_translated(self):
|
||||||
|
self.make_news(title="Seizoensstart", title_en="Season kickoff", body="We beginnen het seizoen.", body_en="We're starting the season.")
|
||||||
|
|
||||||
|
result = self.api_get("/news/").json()["results"][0]
|
||||||
|
|
||||||
|
self.assertEqual(result["title_nl"], "Seizoensstart")
|
||||||
|
self.assertEqual(result["title_en"], "Season kickoff")
|
||||||
|
self.assertIn("We beginnen het seizoen.", result["body_nl"])
|
||||||
|
self.assertIn("We're starting the season.", result["body_en"])
|
||||||
|
self.assertNotEqual(result["excerpt_en"], result["excerpt_nl"])
|
||||||
|
|
||||||
|
|
||||||
|
class TeamsApiTests(ApiTestBase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
super().setUpTestData()
|
||||||
|
cls.forward = Position.objects.create(club=cls.club, name="Forward", short_name="FW", ordering=1)
|
||||||
|
cls.defense = Position.objects.create(club=cls.club, name="Defense", short_name="DF", ordering=2)
|
||||||
|
cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
|
||||||
|
|
||||||
|
def test_list_teams(self):
|
||||||
|
data = self.api_get("/teams/").json()
|
||||||
|
|
||||||
|
self.assertEqual(data, [{"id": str(self.team.pk), "name": "First Team", "short_name": "1st", "photo_url": None}])
|
||||||
|
|
||||||
|
def test_roster_groups_players_by_position(self):
|
||||||
|
alice = Member.objects.create(first_name="Alice", last_name="Ash")
|
||||||
|
bob = Member.objects.create(first_name="Bob", last_name="Birch")
|
||||||
|
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
||||||
|
# Two forwards (ordering 1) at different jersey numbers, one defense (ordering 2).
|
||||||
|
TeamMembership.objects.create(team=self.team, member=bob, season=self.season, position=self.forward, jersey_number=9)
|
||||||
|
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
|
||||||
|
TeamMembership.objects.create(team=self.team, member=carol, season=self.season, position=self.defense, jersey_number=1)
|
||||||
|
|
||||||
|
groups = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
|
||||||
|
|
||||||
|
# Groups in Position.ordering order (Forward before Defense); within a
|
||||||
|
# group, sorted by jersey number.
|
||||||
|
self.assertEqual([g["position"] for g in groups], ["Forward", "Defense"])
|
||||||
|
self.assertEqual([p["first_name"] for p in groups[0]["players"]], ["Alice", "Bob"])
|
||||||
|
self.assertEqual([p["first_name"] for p in groups[1]["players"]], ["Carol"])
|
||||||
|
|
||||||
|
def test_a_player_entry_no_longer_repeats_its_position(self):
|
||||||
|
# The position is now the group key, not a per-player field.
|
||||||
|
alice = Member.objects.create(first_name="Alice", last_name="Ash")
|
||||||
|
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
|
||||||
|
|
||||||
|
player = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"][0]["players"][0]
|
||||||
|
|
||||||
|
self.assertNotIn("position", player)
|
||||||
|
|
||||||
|
def test_roster_includes_staff(self):
|
||||||
|
dana = Member.objects.create(first_name="Dana", last_name="Dean")
|
||||||
|
StaffAssignment.objects.create(team=self.team, member=dana, season=self.season, position=self.coach_position)
|
||||||
|
|
||||||
|
staff = self.api_get(f"/teams/{self.team.pk}/roster/").json()["staff"]
|
||||||
|
|
||||||
|
self.assertEqual(staff, [{"id": str(dana.pk), "first_name": "Dana", "last_name": "Dean", "position": "Head Coach"}])
|
||||||
|
|
||||||
|
def test_roster_is_current_season_only(self):
|
||||||
|
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
|
||||||
|
eve = Member.objects.create(first_name="Eve", last_name="Elm")
|
||||||
|
TeamMembership.objects.create(team=self.team, member=eve, season=other_season, position=self.forward, jersey_number=1)
|
||||||
|
|
||||||
|
players = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
|
||||||
|
|
||||||
|
self.assertEqual(players, [])
|
||||||
|
|
||||||
|
def test_roster_is_empty_with_no_current_season(self):
|
||||||
|
self.season.delete()
|
||||||
|
team_without_season = self.team
|
||||||
|
|
||||||
|
data = self.api_get(f"/teams/{team_without_season.pk}/roster/").json()
|
||||||
|
|
||||||
|
self.assertEqual(data["season"], None)
|
||||||
|
self.assertEqual(data["players"], [])
|
||||||
|
self.assertEqual(data["staff"], [])
|
||||||
|
|
||||||
|
def test_list_teams_includes_the_current_seasons_photo(self):
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
|
||||||
|
|
||||||
|
photo_url = self.api_get("/teams/").json()[0]["photo_url"]
|
||||||
|
|
||||||
|
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
|
||||||
|
|
||||||
|
def test_roster_includes_the_current_seasons_photo(self):
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
|
||||||
|
|
||||||
|
photo_url = self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"]
|
||||||
|
|
||||||
|
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
|
||||||
|
|
||||||
|
def test_a_photo_from_a_different_season_does_not_leak(self):
|
||||||
|
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=other_season, image="clubs/ajax-united/teams/x/00-00/pic.jpg")
|
||||||
|
|
||||||
|
self.assertIsNone(self.api_get("/teams/").json()[0]["photo_url"])
|
||||||
|
self.assertIsNone(self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"])
|
||||||
|
|
||||||
|
def test_a_team_from_another_club_404s(self):
|
||||||
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||||
|
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
|
||||||
|
|
||||||
|
response = self.api_get(f"/teams/{other_team.pk}/roster/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_a_players_license_comes_from_their_club_membership(self):
|
||||||
|
alice = Member.objects.create(first_name="Alice", last_name="Ash")
|
||||||
|
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
|
||||||
|
ClubMembership.objects.create(club=self.club, member=alice, season=self.season, license="BE-12345")
|
||||||
|
|
||||||
|
player = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"][0]["players"][0]
|
||||||
|
|
||||||
|
self.assertEqual(player["license"], "BE-12345")
|
||||||
|
|
||||||
|
def test_a_players_license_is_null_without_a_club_membership(self):
|
||||||
|
alice = Member.objects.create(first_name="Alice", last_name="Ash")
|
||||||
|
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
|
||||||
|
|
||||||
|
player = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"][0]["players"][0]
|
||||||
|
|
||||||
|
self.assertIsNone(player["license"])
|
||||||
|
|
||||||
|
|
||||||
|
class GamesApiTests(ApiTestBase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
super().setUpTestData()
|
||||||
|
cls.home_location = Location.objects.create(club=cls.club, name="Home Arena", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
|
||||||
|
cls.opponent = Opponent.objects.create(club=cls.club, name="Rivals FC")
|
||||||
|
|
||||||
|
def make_game(self, **overrides):
|
||||||
|
defaults = {"club": self.club, "title": "Game", "kind": Event.EventKind.GAME, "start": timezone.now() + datetime.timedelta(days=1), "opponent": self.opponent}
|
||||||
|
defaults.update(overrides)
|
||||||
|
event = Event.objects.create(**defaults)
|
||||||
|
event.teams.add(self.team)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def test_upcoming_games_are_listed_with_location_and_teams(self):
|
||||||
|
self.make_game(location=self.home_location)
|
||||||
|
|
||||||
|
games = self.api_get("/games/upcoming/").json()
|
||||||
|
|
||||||
|
self.assertEqual(len(games), 1)
|
||||||
|
self.assertEqual(games[0]["home_team"]["name"], "First Team")
|
||||||
|
self.assertEqual(games[0]["away_team"]["name"], "Rivals FC")
|
||||||
|
self.assertEqual(games[0]["location"]["name"], "Home Arena")
|
||||||
|
self.assertEqual(games[0]["status"], "upcoming")
|
||||||
|
|
||||||
|
def test_upcoming_excludes_cancelled_games(self):
|
||||||
|
self.make_game(cancelled=True)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
|
||||||
|
|
||||||
|
def test_upcoming_excludes_past_games(self):
|
||||||
|
self.make_game(start=timezone.now() - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
|
||||||
|
|
||||||
|
def test_upcoming_includes_a_game_already_in_progress(self):
|
||||||
|
# Started 30 minutes ago, no explicit end -- the assumed 2h window
|
||||||
|
# means it isn't finished yet, so it must still show up.
|
||||||
|
self.make_game(start=timezone.now() - datetime.timedelta(minutes=30))
|
||||||
|
|
||||||
|
games = self.api_get("/games/upcoming/").json()
|
||||||
|
|
||||||
|
self.assertEqual(len(games), 1)
|
||||||
|
self.assertEqual(games[0]["status"], "live")
|
||||||
|
|
||||||
|
def test_upcoming_excludes_a_game_past_its_explicit_end(self):
|
||||||
|
game = self.make_game(start=timezone.now() - datetime.timedelta(hours=3))
|
||||||
|
game.end = timezone.now() - datetime.timedelta(hours=1)
|
||||||
|
game.save()
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
|
||||||
|
|
||||||
|
def test_the_response_includes_an_end_time(self):
|
||||||
|
game = self.make_game()
|
||||||
|
|
||||||
|
end = self.api_get("/games/upcoming/").json()[0]["end"]
|
||||||
|
|
||||||
|
# JSON round-trips to millisecond precision -- compare with a small
|
||||||
|
# tolerance rather than an exact microsecond match.
|
||||||
|
self.assertLess(abs((datetime.datetime.fromisoformat(end) - game.end).total_seconds()), 1)
|
||||||
|
|
||||||
|
def test_upcoming_includes_tournaments(self):
|
||||||
|
self.make_game(kind=Event.EventKind.TOURNAMENT)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.api_get("/games/upcoming/").json()), 1)
|
||||||
|
|
||||||
|
def test_upcoming_excludes_other_kinds(self):
|
||||||
|
self.make_game(kind=Event.EventKind.TRAINING)
|
||||||
|
self.make_game(kind=Event.EventKind.SOCIAL)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
|
||||||
|
|
||||||
|
def test_live_endpoint_stays_game_only(self):
|
||||||
|
# is_live/scores are game-specific -- a tournament wouldn't have
|
||||||
|
# anything meaningful to show here even if flagged live.
|
||||||
|
self.make_game(kind=Event.EventKind.TOURNAMENT, start=timezone.now() - datetime.timedelta(minutes=10), is_live=True)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/live/").json(), [])
|
||||||
|
|
||||||
|
def test_count_is_respected(self):
|
||||||
|
for i in range(3):
|
||||||
|
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
|
||||||
|
|
||||||
|
self.assertEqual(len(self.api_get("/games/upcoming/", count=2).json()), 2)
|
||||||
|
|
||||||
|
def test_count_is_capped(self):
|
||||||
|
for i in range(3):
|
||||||
|
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
|
||||||
|
|
||||||
|
# Cap is 50, well above the 3 created -- just confirm an oversized
|
||||||
|
# request doesn't error and doesn't somehow exceed what exists.
|
||||||
|
response = self.api_get("/games/upcoming/", count=1000)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.json()), 3)
|
||||||
|
|
||||||
|
def test_live_games_are_listed_with_scores(self):
|
||||||
|
self.make_game(start=timezone.now() - datetime.timedelta(minutes=10), is_live=True, score_for=2, score_against=1, location=self.home_location)
|
||||||
|
|
||||||
|
games = self.api_get("/games/live/").json()
|
||||||
|
|
||||||
|
self.assertEqual(len(games), 1)
|
||||||
|
self.assertEqual(games[0]["status"], "live")
|
||||||
|
self.assertEqual(games[0]["home_score"], 2)
|
||||||
|
self.assertEqual(games[0]["away_score"], 1)
|
||||||
|
|
||||||
|
def test_live_excludes_cancelled_games(self):
|
||||||
|
self.make_game(is_live=True, cancelled=True)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/live/").json(), [])
|
||||||
|
|
||||||
|
def test_non_live_games_are_excluded_from_live_endpoint(self):
|
||||||
|
self.make_game()
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/live/").json(), [])
|
||||||
|
|
||||||
|
def test_score_relabelling_for_an_away_game(self):
|
||||||
|
# No location (or a non-home one) -- is_home_game is False, so our
|
||||||
|
# team's score_for/score_against map to the away side.
|
||||||
|
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3)
|
||||||
|
|
||||||
|
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
|
||||||
|
|
||||||
|
self.assertEqual(games[0]["home_team"]["name"], "Rivals FC")
|
||||||
|
self.assertEqual(games[0]["away_team"]["name"], "First Team")
|
||||||
|
self.assertEqual(games[0]["home_score"], 3)
|
||||||
|
self.assertEqual(games[0]["away_score"], 4)
|
||||||
|
self.assertEqual(games[0]["status"], "finished")
|
||||||
|
|
||||||
|
def test_score_relabelling_for_a_home_game(self):
|
||||||
|
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3, location=self.home_location)
|
||||||
|
|
||||||
|
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
|
||||||
|
|
||||||
|
self.assertEqual(games[0]["home_team"]["name"], "First Team")
|
||||||
|
self.assertEqual(games[0]["away_team"]["name"], "Rivals FC")
|
||||||
|
self.assertEqual(games[0]["home_score"], 4)
|
||||||
|
self.assertEqual(games[0]["away_score"], 3)
|
||||||
|
|
||||||
|
def test_team_games_includes_past_and_upcoming_for_the_current_season(self):
|
||||||
|
self.make_game(title="Past", start=timezone.now() - datetime.timedelta(days=1), score_for=1, score_against=0)
|
||||||
|
self.make_game(title="Future", start=timezone.now() + datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
|
||||||
|
|
||||||
|
self.assertEqual(len(games), 2)
|
||||||
|
|
||||||
|
def test_team_games_excludes_a_different_season(self):
|
||||||
|
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
|
||||||
|
self.make_game(start=datetime.datetime(2000, 6, 1, tzinfo=datetime.UTC), season=other_season)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
|
||||||
|
|
||||||
|
def test_team_games_excludes_cancelled(self):
|
||||||
|
self.make_game(cancelled=True)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
|
||||||
|
|
||||||
|
def test_team_games_is_empty_with_no_current_season(self):
|
||||||
|
self.make_game()
|
||||||
|
self.season.delete()
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
|
||||||
|
|
||||||
|
def test_a_team_from_another_club_404s_on_games(self):
|
||||||
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||||
|
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
|
||||||
|
|
||||||
|
response = self.api_get(f"/teams/{other_team.pk}/games/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_home_team_links_to_the_actual_team_and_the_clubs_logo(self):
|
||||||
|
# Our own teams have no logo of their own -- they're shown under the club's badge.
|
||||||
|
self.club.logo = "clubs/ajax-united/logo.png"
|
||||||
|
self.club.save()
|
||||||
|
self.make_game(location=self.home_location)
|
||||||
|
|
||||||
|
home_team = self.api_get("/games/upcoming/").json()[0]["home_team"]
|
||||||
|
|
||||||
|
self.assertEqual(home_team["id"], str(self.team.pk))
|
||||||
|
self.assertEqual(home_team["name"], "First Team")
|
||||||
|
self.assertTrue(home_team["logo_url"].startswith("http://ajax-united.rosterchief.app/media/"))
|
||||||
|
|
||||||
|
def test_away_team_links_to_the_opponent_and_its_own_logo(self):
|
||||||
|
self.opponent.logo = "opponents/rivals.png"
|
||||||
|
self.opponent.save()
|
||||||
|
self.make_game(location=self.home_location)
|
||||||
|
|
||||||
|
away_team = self.api_get("/games/upcoming/").json()[0]["away_team"]
|
||||||
|
|
||||||
|
self.assertEqual(away_team["id"], str(self.opponent.pk))
|
||||||
|
self.assertEqual(away_team["name"], "Rivals FC")
|
||||||
|
self.assertTrue(away_team["logo_url"].startswith("http://ajax-united.rosterchief.app/media/"))
|
||||||
|
|
||||||
|
def test_team_logo_url_is_null_without_a_club_logo(self):
|
||||||
|
self.make_game(location=self.home_location)
|
||||||
|
|
||||||
|
home_team = self.api_get("/games/upcoming/").json()[0]["home_team"]
|
||||||
|
|
||||||
|
self.assertIsNone(home_team["logo_url"])
|
||||||
|
|
||||||
|
|
||||||
|
class TenancyAndCorsTests(ApiTestBase):
|
||||||
|
def test_the_base_domain_404s(self):
|
||||||
|
response = self.api_get_base_domain("/news/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_a_get_response_carries_the_cors_header(self):
|
||||||
|
response = self.api_get("/news/")
|
||||||
|
|
||||||
|
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
|
||||||
|
|
||||||
|
def test_an_options_preflight_gets_a_204_with_cors_headers(self):
|
||||||
|
response = self.client.options("/api/v1/news/", HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
|
||||||
|
self.assertIn("GET", response["Access-Control-Allow-Methods"])
|
||||||
|
|
||||||
|
def test_cors_headers_are_not_added_outside_the_api(self):
|
||||||
|
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertNotIn("Access-Control-Allow-Origin", response)
|
||||||
|
|
||||||
|
def test_docs_page_resolves(self):
|
||||||
|
self.assertEqual(self.api_get("/docs").status_code, 200)
|
||||||
|
|
||||||
|
def test_openapi_schema_resolves(self):
|
||||||
|
self.assertEqual(self.api_get("/openapi.json").status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorApiTests(ApiTestBase):
|
||||||
|
def make_sponsor(self, **overrides):
|
||||||
|
today = timezone.localdate()
|
||||||
|
defaults = {"club": self.club, "name": "Acme Corp", "start_date": today - datetime.timedelta(days=10), "end_date": today + datetime.timedelta(days=10)}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return Sponsor.objects.create(**defaults)
|
||||||
|
|
||||||
|
def test_a_sponsor_covering_today_is_included(self):
|
||||||
|
self.make_sponsor()
|
||||||
|
|
||||||
|
data = self.api_get("/sponsors/").json()
|
||||||
|
|
||||||
|
self.assertEqual(len(data), 1)
|
||||||
|
self.assertEqual(data[0]["name"], "Acme Corp")
|
||||||
|
|
||||||
|
def test_a_sponsor_starting_in_the_future_is_excluded(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
self.make_sponsor(start_date=today + datetime.timedelta(days=1), end_date=None)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/sponsors/").json(), [])
|
||||||
|
|
||||||
|
def test_a_sponsor_that_already_ended_is_excluded(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
self.make_sponsor(start_date=today - datetime.timedelta(days=20), end_date=today - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/sponsors/").json(), [])
|
||||||
|
|
||||||
|
def test_a_sponsor_with_no_end_date_and_a_past_start_is_included(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
self.make_sponsor(start_date=today - datetime.timedelta(days=100), end_date=None)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
|
||||||
|
|
||||||
|
def test_a_sponsor_starting_today_is_included(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
self.make_sponsor(start_date=today, end_date=None)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
|
||||||
|
|
||||||
|
def test_a_sponsor_ending_today_is_included(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
self.make_sponsor(start_date=today - datetime.timedelta(days=10), end_date=today)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
|
||||||
|
|
||||||
|
def test_another_clubs_sponsor_never_leaks_in(self):
|
||||||
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||||
|
self.make_sponsor(club=other_club)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/sponsors/").json(), [])
|
||||||
|
|
||||||
|
def test_logo_url_is_absolute_when_set(self):
|
||||||
|
self.make_sponsor(logo="clubs/ajax-united/sponsors/x/logo.png")
|
||||||
|
|
||||||
|
logo_url = self.api_get("/sponsors/").json()[0]["logo_url"]
|
||||||
|
|
||||||
|
self.assertTrue(logo_url.startswith("http://ajax-united.rosterchief.app/media/"))
|
||||||
|
|
||||||
|
def test_logo_url_is_null_when_not_set(self):
|
||||||
|
self.make_sponsor()
|
||||||
|
|
||||||
|
self.assertIsNone(self.api_get("/sponsors/").json()[0]["logo_url"])
|
||||||
|
|
||||||
|
def test_logo_dimensions_are_computed_for_a_raster_image(self):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", (300, 150)).save(buffer, format="PNG")
|
||||||
|
logo = SimpleUploadedFile("logo.png", buffer.getvalue(), content_type="image/png")
|
||||||
|
|
||||||
|
self.make_sponsor(logo=logo)
|
||||||
|
|
||||||
|
sponsor = self.api_get("/sponsors/").json()[0]
|
||||||
|
self.assertEqual(sponsor["logo_width"], 300)
|
||||||
|
self.assertEqual(sponsor["logo_height"], 150)
|
||||||
|
|
||||||
|
def test_logo_dimensions_are_computed_for_an_svg_with_width_and_height(self):
|
||||||
|
svg = b'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"></svg>'
|
||||||
|
logo = SimpleUploadedFile("logo.svg", svg, content_type="image/svg+xml")
|
||||||
|
|
||||||
|
self.make_sponsor(logo=logo)
|
||||||
|
|
||||||
|
sponsor = self.api_get("/sponsors/").json()[0]
|
||||||
|
self.assertEqual(sponsor["logo_width"], 120)
|
||||||
|
self.assertEqual(sponsor["logo_height"], 80)
|
||||||
|
|
||||||
|
def test_logo_dimensions_fall_back_to_an_svg_viewbox(self):
|
||||||
|
svg = b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32"></svg>'
|
||||||
|
logo = SimpleUploadedFile("logo.svg", svg, content_type="image/svg+xml")
|
||||||
|
|
||||||
|
self.make_sponsor(logo=logo)
|
||||||
|
|
||||||
|
sponsor = self.api_get("/sponsors/").json()[0]
|
||||||
|
self.assertEqual(sponsor["logo_width"], 64)
|
||||||
|
self.assertEqual(sponsor["logo_height"], 32)
|
||||||
|
|
||||||
|
def test_logo_dimensions_are_null_without_a_logo(self):
|
||||||
|
self.make_sponsor()
|
||||||
|
|
||||||
|
sponsor = self.api_get("/sponsors/").json()[0]
|
||||||
|
self.assertIsNone(sponsor["logo_width"])
|
||||||
|
self.assertIsNone(sponsor["logo_height"])
|
||||||
|
|
||||||
|
def test_randomize_returns_the_same_set_of_sponsors(self):
|
||||||
|
for i in range(5):
|
||||||
|
self.make_sponsor(name=f"Sponsor {i}")
|
||||||
|
|
||||||
|
stable = {s["id"] for s in self.api_get("/sponsors/").json()}
|
||||||
|
randomized = {s["id"] for s in self.api_get("/sponsors/", randomize="true").json()}
|
||||||
|
|
||||||
|
self.assertEqual(stable, randomized)
|
||||||
|
self.assertEqual(len(stable), 5)
|
||||||
|
|
||||||
|
def test_default_order_is_stable_and_alphabetical(self):
|
||||||
|
self.make_sponsor(name="Zulu Corp")
|
||||||
|
self.make_sponsor(name="Acme Corp")
|
||||||
|
|
||||||
|
names = [s["name"] for s in self.api_get("/sponsors/").json()]
|
||||||
|
|
||||||
|
self.assertEqual(names, ["Acme Corp", "Zulu Corp"])
|
||||||
28
api/urls.py
Normal file
28
api/urls.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""The public read-only API -- see ARCHITECTURE.md and the plan this shipped
|
||||||
|
under. Mounted at /api/v1/ (rosterchief/urls.py), club-scoped by the same
|
||||||
|
subdomain-based tenant resolution every other view uses
|
||||||
|
(club.tenancy.ClubTenantMiddleware sets request.club before this ever runs).
|
||||||
|
|
||||||
|
Each domain app owns its own router and schemas (news/api.py, teams/api.py,
|
||||||
|
events/api.py) -- this module only wires them together, same reasoning as
|
||||||
|
management/controlpanel never owning domain logic themselves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
|
from club.api import router as club_router
|
||||||
|
from events.api import router as events_router
|
||||||
|
from news.api import router as news_router
|
||||||
|
from teams.api import router as teams_router
|
||||||
|
|
||||||
|
api = NinjaAPI(
|
||||||
|
title="RosterChief public API",
|
||||||
|
version="1.0.0",
|
||||||
|
description="Public, read-only data for a club's own external website: news, team rosters, fixtures, and sponsors.",
|
||||||
|
urls_namespace="api",
|
||||||
|
)
|
||||||
|
|
||||||
|
api.add_router("/news", news_router)
|
||||||
|
api.add_router("/teams", teams_router)
|
||||||
|
api.add_router("/", events_router)
|
||||||
|
api.add_router("/sponsors", club_router)
|
||||||
268
assets/app.css
Normal file
268
assets/app.css
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Scan Django templates for utility classes (Tailwind's auto-detection doesn't
|
||||||
|
know about our template dirs). */
|
||||||
|
@source "../templates";
|
||||||
|
@source "../controlpanel";
|
||||||
|
@source "../billing";
|
||||||
|
@source "../management";
|
||||||
|
@source "../club";
|
||||||
|
|
||||||
|
/* daisyUI: light is the default, dark applies automatically when the OS asks
|
||||||
|
for it. An explicit data-theme on <html> (set by the toggle) overrides both. */
|
||||||
|
@plugin "daisyui" {
|
||||||
|
themes: light --default, dark --prefersdark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fonts are self-hosted (files copied from the @fontsource packages into
|
||||||
|
static/fonts/, paths are relative to the built css at /static/css/app.css).
|
||||||
|
Google's CDN would leak every visitor's IP to a third party on page load,
|
||||||
|
which we don't want to inherit for an EU club platform.
|
||||||
|
|
||||||
|
Two subsets each: `latin` covers western europe, `latin-ext` carries the
|
||||||
|
polish/czech/turkish letters that turn up in member names. The unicode-range
|
||||||
|
means a browser only fetches latin-ext when a page actually uses those glyphs.
|
||||||
|
|
||||||
|
Ubuntu is static (it has no variable version); its real weights are 400/500/700,
|
||||||
|
so `font-semibold` (600) is synthesised up to 700 by the browser. JetBrains Mono
|
||||||
|
and Roboto are variable: one file covers the whole weight axis. */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ubuntu";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/ubuntu-latin-400-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ubuntu";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/ubuntu-latin-ext-400-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ubuntu";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/ubuntu-latin-400-italic.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ubuntu";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/ubuntu-latin-ext-400-italic.woff2") format("woff2");
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ubuntu";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/ubuntu-latin-500-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ubuntu";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/ubuntu-latin-ext-500-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ubuntu";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/ubuntu-latin-700-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ubuntu";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/ubuntu-latin-ext-700-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "JetBrains Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/jetbrains-mono-latin-wght-normal.woff2") format("woff2-variations");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "JetBrains Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/jetbrains-mono-latin-ext-wght-normal.woff2") format("woff2-variations");
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Roboto";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/roboto-latin-standard-normal.woff2") format("woff2-variations");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Roboto";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/roboto-latin-ext-standard-normal.woff2") format("woff2-variations");
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tourney is a display face for jersey numbers and the like. Its variable file
|
||||||
|
carries two axes -- weight 100-900 and width 75-125 -- so font-stretch has to be
|
||||||
|
declared as a range too, otherwise the browser clamps to the default width and
|
||||||
|
`font-stretch: 125%` (a wide shirt number) silently does nothing. */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Tourney";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-stretch: 75% 125%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/tourney-latin-standard-normal.woff2") format("woff2-variations");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Tourney";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-stretch: 75% 125%;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/tourney-latin-ext-standard-normal.woff2") format("woff2-variations");
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Setting --font-sans / --font-mono changes the body and <code> defaults; every
|
||||||
|
--font-* also generates a utility, so --font-roboto gives us `font-roboto` to
|
||||||
|
opt into Roboto where we want it. */
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
--font-ubuntu: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-roboto: "Roboto", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-tourney: "Tourney", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The logo is a background image, not `content:` -- content-replacement on a real
|
||||||
|
element (rather than ::before/::after) isn't supported in Firefox.
|
||||||
|
|
||||||
|
Default = dark-ink logo, for a light background. The media query covers "auto",
|
||||||
|
where the toggle deliberately sets no data-theme at all; the attribute selectors
|
||||||
|
are more specific, so an explicit choice always beats the OS. */
|
||||||
|
.logo {
|
||||||
|
background-image: var(--logo-dark);
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.logo {
|
||||||
|
background-image: var(--logo-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .logo {
|
||||||
|
background-image: var(--logo-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .logo {
|
||||||
|
background-image: var(--logo-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Club-uploaded logos are arbitrary raster files (often much smaller than the
|
||||||
|
badge they're displayed in), so the browser is upscaling them -- and the
|
||||||
|
default resampling some engines fall back to for that reads as pixelated.
|
||||||
|
Forcing the element onto its own GPU-composited layer makes Chrome/Safari
|
||||||
|
pick their higher-quality scaler; the image-rendering hints are a no-op
|
||||||
|
where unsupported, degrading gracefully to the browser default. Doesn't
|
||||||
|
apply to SVG logos, which scale losslessly regardless. */
|
||||||
|
.club-logo {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
image-rendering: smooth;
|
||||||
|
image-rendering: high-quality;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below `md`, a `.table-cards` table reads as a stack of cards instead of a
|
||||||
|
horizontally-scrolling grid: each row becomes a bordered block, each cell
|
||||||
|
becomes a label/value line pulling its label from `data-label` (set on the
|
||||||
|
<td> in the template -- there's no way to read the matching <th> text from
|
||||||
|
pure CSS). A <td> with no `data-label` renders as plain full-width content
|
||||||
|
instead of a label/value row -- for an image cell, a heading-style link, or
|
||||||
|
an actions row that already lays itself out. At `md` and up this reverts to
|
||||||
|
an ordinary table with nothing left over from the mobile styling. */
|
||||||
|
@media (width < 48rem) {
|
||||||
|
.table-cards, .table-cards :is(thead, tbody, tr, th, td) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cards thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cards tbody tr {
|
||||||
|
border: 1px solid var(--color-base-300);
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cards tbody tr + tr {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cards td {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-base-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cards td:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cards td[data-label]:before {
|
||||||
|
content: attr(data-label);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cards td:not([data-label]) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
1073
assets/controlpanel.css
Normal file
1073
assets/controlpanel.css
Normal file
File diff suppressed because it is too large
Load Diff
2037
assets/management.css
Normal file
2037
assets/management.css
Normal file
File diff suppressed because it is too large
Load Diff
595
assets/mobile.css
Normal file
595
assets/mobile.css
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
/* RosterChief member app -- the installed PWA (see design_handoff_rosterchief_platform/
|
||||||
|
* README.md, "Member mode (mobile, 402x874 reference)"). Its own stylesheet, not shared
|
||||||
|
* with management.css/controlpanel.css/app.css: the component vocabulary (app header,
|
||||||
|
* role switcher, bottom tab bar, hero card) has nothing in common with the desktop
|
||||||
|
* surfaces' daisyUI-shaped one.
|
||||||
|
*
|
||||||
|
* Design tokens copied from management.css's own @theme (built against the same design
|
||||||
|
* doc) rather than re-derived -- same colours, same self-hosted fonts (shared
|
||||||
|
* static/fonts/ directory, one download for every stylesheet). Two differences:
|
||||||
|
* - --radius-box is 14px here (mobile cards), not management's 0.75rem/12px (desktop
|
||||||
|
* "rounded-xl" cards) -- see the design doc's own radius table.
|
||||||
|
* - --color-ice/--color-ice-ink get their own names (management just calls the same
|
||||||
|
* #14B8E8 hex "info", since it has no coach-mode concept) -- the design doc is
|
||||||
|
* explicit that Coach mode uses ice "regardless of club", so it needs a name that
|
||||||
|
* survives club theming rather than colliding with a generic status colour.
|
||||||
|
*
|
||||||
|
* Club theming (design doc, "Club theming"): mobile/templates/mobile/base.html sets
|
||||||
|
* --tenant-club/--tenant-club-content from Club.secondary_color (app header active tab,
|
||||||
|
* primary buttons, hero eyebrow) and --tenant-navy from Club.primary_color where the
|
||||||
|
* club overrides the platform navy header -- same --tenant-* custom-property pattern
|
||||||
|
* management/templates/management/base.html already uses, so both surfaces stay
|
||||||
|
* consistent with each other and with the identity page's own live preview.
|
||||||
|
*/
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@source "../mobile";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-display: "Barlow Condensed", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
|
||||||
|
--color-ink: #0b1220;
|
||||||
|
--color-navy: var(--tenant-navy, #101e36);
|
||||||
|
--color-steel: #1b2b47;
|
||||||
|
--color-hairline: #1e2b42;
|
||||||
|
--color-paper: #f4f5f7;
|
||||||
|
--color-line: #e3e6eb;
|
||||||
|
--color-rule: #eef0f3;
|
||||||
|
--color-edge: #d6dae1;
|
||||||
|
--color-stroke: #c9cfd8;
|
||||||
|
--color-muted: #6c7787;
|
||||||
|
--color-dim: #8b95a4;
|
||||||
|
--color-slate: #3a4658;
|
||||||
|
--color-on-dark: #93a0b4;
|
||||||
|
--color-on-dark-dim: #7c8aa0;
|
||||||
|
--color-on-dark-faint: #5c6b85;
|
||||||
|
|
||||||
|
--color-club: var(--tenant-club, #e4002b);
|
||||||
|
--color-club-dark: var(--tenant-club-dark, #b00021);
|
||||||
|
--color-club-content: var(--tenant-club-content, #ffffff);
|
||||||
|
|
||||||
|
--color-ok: #14a05a;
|
||||||
|
--color-ok-bg: #e6f6ee;
|
||||||
|
--color-ok-border: #bfe7d3;
|
||||||
|
--color-ok-text: #0c7a43;
|
||||||
|
|
||||||
|
--color-warn: #f0a22e;
|
||||||
|
--color-warn-bg: #fff5e4;
|
||||||
|
--color-warn-border: #f6e0b8;
|
||||||
|
--color-warn-text: #9a6410;
|
||||||
|
--color-warn-deep: #7a4e08;
|
||||||
|
|
||||||
|
--color-danger-bg: #fdecec;
|
||||||
|
--color-danger-border: #f5c9ce;
|
||||||
|
|
||||||
|
--color-info: #14b8e8;
|
||||||
|
--color-info-bg: #eaf7fc;
|
||||||
|
--color-info-border: #c3e7f4;
|
||||||
|
--color-info-text: #0a6f91;
|
||||||
|
|
||||||
|
/* Coach-mode accent -- never club-themed, see the file banner. */
|
||||||
|
--color-ice: #14b8e8;
|
||||||
|
--color-ice-ink: #04212c;
|
||||||
|
|
||||||
|
/* Referee sign-up accent -- deliberately distinct from game/training/other's
|
||||||
|
club-red/info-blue/warn-amber (mobile/_calendar_row.html) so a "needs a
|
||||||
|
referee" row stands out from the member's own RSVP rows at a glance. */
|
||||||
|
--color-referee: #7c4dff;
|
||||||
|
--color-referee-bg: #f1ecff;
|
||||||
|
--color-referee-border: #d9caff;
|
||||||
|
--color-referee-text: #5b2fd6;
|
||||||
|
|
||||||
|
--color-row-sel: #fff7f8;
|
||||||
|
--color-row-focus: #f4f9ff;
|
||||||
|
--color-row-warn: #fffdf6;
|
||||||
|
--color-subhead: #f8f9fa;
|
||||||
|
|
||||||
|
--radius-box: 0.875rem; /* 14px -- mobile card radius, see the file banner */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- fonts: identical set to management.css/controlpanel.css, same self-hosted files. --- */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Barlow";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/barlow-latin-400-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Barlow";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/barlow-latin-500-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Barlow";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/barlow-latin-600-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Barlow";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/barlow-latin-700-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Barlow Condensed";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/barlow-condensed-latin-600-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Barlow Condensed";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/barlow-condensed-latin-700-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Barlow Condensed";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/barlow-condensed-latin-800-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/ibm-plex-mono-latin-500-normal.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
background: var(--color-paper);
|
||||||
|
color: var(--color-slate);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
/* env(safe-area-inset-*) support -- design doc: "The app content starts below a
|
||||||
|
54px status-bar inset and ends above a 26-30px home-indicator inset -- preserve
|
||||||
|
those safe areas via env(safe-area-inset-*), not fixed padding." */
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standard Alpine convention -- hides an x-cloak'd element until Alpine has
|
||||||
|
initialized, so an x-show/x-if starting state never flashes unstyled. */
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* --- App shell -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: max(env(safe-area-inset-top), 14px) 16px 14px;
|
||||||
|
background: var(--color-navy);
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Club logo (img) or initials (span) in the app header -- always a plain
|
||||||
|
circle, unlike management.css's own hexagon crest mark. */
|
||||||
|
.app-crest {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-crest-fallback {
|
||||||
|
background: var(--color-club);
|
||||||
|
color: var(--color-club-content);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrolls normally, just without the visible scrollbar chrome -- for the
|
||||||
|
person-switcher's horizontally-scrolling chip row. */
|
||||||
|
.scrollbar-hide {
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* old Edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome/Safari/WebKit */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Role switcher -- design doc "Component" table: full-width segmented pill,
|
||||||
|
bg-steel track, active segment bg-white text-ink (member mode). Only rendered
|
||||||
|
when the account holds >=1 staff role -- see mobile/templates/mobile/base.html. */
|
||||||
|
.role-switcher {
|
||||||
|
display: flex;
|
||||||
|
background: var(--color-steel);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switcher-item {
|
||||||
|
flex: 1;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-on-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switcher-item-active {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Coach mode's own active segment -- design doc: "the role switcher with
|
||||||
|
Coach active (bg-ice)", distinct from member mode's white/ink one above. */
|
||||||
|
.role-switcher-item-active-ice {
|
||||||
|
background: var(--color-ice);
|
||||||
|
color: var(--color-ice-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom tab bar -- design doc: "bg-white border-t border-line pt-2 pb-[26px]
|
||||||
|
(member)... four equal items, 48px tall". pb uses the safe-area inset instead of
|
||||||
|
the doc's fixed 26px, which was standing in for it. */
|
||||||
|
.tab-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid var(--color-line);
|
||||||
|
padding: 8px 8px max(env(safe-area-inset-bottom), 26px);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar-item {
|
||||||
|
flex: 1;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
color: var(--color-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar-item-active {
|
||||||
|
color: var(--color-club);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar-label {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Buttons ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 46px; /* design doc: "height 46px mobile" */
|
||||||
|
padding-inline: 1.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-club);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark {
|
||||||
|
background: var(--color-ink);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-positive {
|
||||||
|
background: var(--color-ok);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #fff;
|
||||||
|
border: 1.5px solid var(--color-stroke);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost-on-dark {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Status pills --------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-ok {
|
||||||
|
background: var(--color-ok-bg);
|
||||||
|
color: var(--color-ok-text);
|
||||||
|
border: 1px solid var(--color-ok-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-danger {
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
color: var(--color-club-dark);
|
||||||
|
border: 1px solid var(--color-danger-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-warn {
|
||||||
|
background: var(--color-warn-bg);
|
||||||
|
color: var(--color-warn-text);
|
||||||
|
border: 1px solid var(--color-warn-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-info {
|
||||||
|
background: var(--color-info-bg);
|
||||||
|
color: var(--color-info-text);
|
||||||
|
border: 1px solid var(--color-info-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-neutral {
|
||||||
|
background: var(--color-rule);
|
||||||
|
color: #4a5566;
|
||||||
|
border: 1px solid #dde1e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-referee {
|
||||||
|
background: var(--color-referee-bg);
|
||||||
|
color: var(--color-referee-text);
|
||||||
|
border: 1px solid var(--color-referee-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Person switcher chips (Home's own -- see mobile/templates/mobile/home.html) --- */
|
||||||
|
|
||||||
|
.person-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1.5px solid var(--color-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px 4px 4px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-chip-active {
|
||||||
|
border-color: var(--color-ink);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-chip-avatar {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-stroke);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-chip-active .person-chip-avatar {
|
||||||
|
background: var(--color-club);
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-chip-label {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Cards ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.m-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-card-dark {
|
||||||
|
background: var(--color-ink);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- File input ------------------------------------------------------------------
|
||||||
|
The bare native control (a browser-chrome "Choose Files" button + status text)
|
||||||
|
clashes with every other input on the page, which are all styled boxes -- same
|
||||||
|
fix as management's own .file-input (assets/management.css), recolored for the
|
||||||
|
mobile palette. ::file-selector-button needs `appearance: none` first or Safari
|
||||||
|
keeps drawing its own bezeled button regardless of the rest of this.
|
||||||
|
|
||||||
|
Height comes from padding-block, not an explicit height -- the button is shadow
|
||||||
|
content Safari won't vertically center via align-items no matter what, so it
|
||||||
|
floats at the top of any extra box height instead of the middle. Sizing the box
|
||||||
|
around the button's own height (padding-block split evenly: 2.75rem target box -
|
||||||
|
2 * 1px border - 1.75rem button, halved) keeps the button flush with no leftover
|
||||||
|
space for Safari to mis-center it into (same fix, same reasoning, as management's
|
||||||
|
own .file-input's own comment). */
|
||||||
|
.m-file-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-block: 0.4375rem;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--color-stroke);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-paper);
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
color: var(--color-dim);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-file-input::file-selector-button,
|
||||||
|
.m-file-input::-webkit-file-upload-button {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 1.75rem;
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--color-ink);
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-file-input:hover::file-selector-button,
|
||||||
|
.m-file-input:hover::-webkit-file-upload-button {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-file-input:focus-within {
|
||||||
|
outline: 2px solid var(--color-club);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: var(--color-club);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Coach mode shell (C1-C6) ---------------------------------------------------
|
||||||
|
Dark ink header (never club-themed) with the role switcher's own active segment
|
||||||
|
in ice-blue (see .role-switcher-item-active-ice above) -- that's the mode's
|
||||||
|
signature accent, not the header background itself. .coach-sheet sits flush
|
||||||
|
below it, edge to edge like the member shell's own header/body join -- no
|
||||||
|
rounded overlap. */
|
||||||
|
|
||||||
|
.coach-header {
|
||||||
|
padding: max(env(safe-area-inset-top), 14px) 16px 22px;
|
||||||
|
background: var(--color-ink);
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coach-sheet {
|
||||||
|
background: var(--color-paper);
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coach-tab-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative; /* anchors the "+" action's popup menu */
|
||||||
|
background: var(--color-ink);
|
||||||
|
border-top: 1px solid var(--color-hairline);
|
||||||
|
padding: 8px 8px max(env(safe-area-inset-bottom), 26px);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coach-tab-bar-item {
|
||||||
|
flex: 1;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
color: var(--color-on-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coach-tab-bar-item-active {
|
||||||
|
color: var(--color-ice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The tab bar's own "+" action -- raised above the bar line in a solid ice
|
||||||
|
circle so it reads as the one prominent action among otherwise-equal nav
|
||||||
|
items, same idea as a FAB parked inside a bottom bar. */
|
||||||
|
.coach-tab-bar-add {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coach-tab-bar-add-button {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-top: -20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-ice);
|
||||||
|
color: var(--color-ice-ink);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 4px solid var(--color-ink);
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
authentication/__init__.py
Normal file
0
authentication/__init__.py
Normal file
37
authentication/adapters.py
Normal file
37
authentication/adapters.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""allauth adapters.
|
||||||
|
|
||||||
|
The MFA adapter exists for one important reason: WebAuthn credentials are bound
|
||||||
|
to a **Relying Party ID** (a domain). allauth's default RP ID is the request's
|
||||||
|
host — which under our subdomain tenancy would be ``ajax-united.rosterchief.app``,
|
||||||
|
binding a passkey to *one club*. A member of two clubs would then need two
|
||||||
|
passkeys, and a credential registered at one club would silently fail at another.
|
||||||
|
|
||||||
|
Pinning the RP ID to the registrable parent domain (``rosterchief.app``) makes a
|
||||||
|
single passkey work across every club subdomain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from allauth.mfa.adapter import DefaultMFAAdapter
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class RosterChiefMFAAdapter(DefaultMFAAdapter):
|
||||||
|
def get_public_key_credential_rp_entity(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"id": webauthn_rp_id(),
|
||||||
|
"name": settings.MFA_WEBAUTHN_RP_NAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def webauthn_rp_id() -> str:
|
||||||
|
"""The registrable parent domain that passkeys are bound to.
|
||||||
|
|
||||||
|
Falls back to the request host when no base domain is configured (e.g. a
|
||||||
|
bare ``localhost`` dev server), which keeps WebAuthn usable there.
|
||||||
|
"""
|
||||||
|
base_domain = getattr(settings, "ROSTERCHIEF_BASE_DOMAIN", "")
|
||||||
|
if base_domain:
|
||||||
|
return base_domain
|
||||||
|
|
||||||
|
from allauth.core import context
|
||||||
|
|
||||||
|
return context.request.get_host().partition(":")[0]
|
||||||
44
authentication/admin.py
Normal file
44
authentication/admin.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from members.models import Member
|
||||||
|
|
||||||
|
from .forms import UserChangeForm, UserCreationForm
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
class MemberInline(admin.StackedInline):
|
||||||
|
"""Edit the member profile attached to a login from the User page."""
|
||||||
|
|
||||||
|
model = Member
|
||||||
|
can_delete = False
|
||||||
|
extra = 0
|
||||||
|
max_num = 1
|
||||||
|
verbose_name_plural = _("member profile")
|
||||||
|
fields = ("first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(BaseUserAdmin):
|
||||||
|
add_form = UserCreationForm
|
||||||
|
form = UserChangeForm
|
||||||
|
model = User
|
||||||
|
inlines = [MemberInline]
|
||||||
|
|
||||||
|
list_display = ("email", "full_name", "is_staff", "is_active")
|
||||||
|
list_filter = ("is_staff", "is_superuser", "is_active", "groups")
|
||||||
|
search_fields = ("email", "member__first_name", "member__last_name")
|
||||||
|
ordering = ("email",)
|
||||||
|
readonly_fields = ("last_login",)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("email", "password")}),
|
||||||
|
(_("Permissions"), {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
|
||||||
|
(_("Important dates"), {"fields": ("last_login",)}),
|
||||||
|
)
|
||||||
|
add_fieldsets = ((None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}),)
|
||||||
|
|
||||||
|
@admin.display(description=_("name"))
|
||||||
|
def full_name(self, obj):
|
||||||
|
return obj.get_full_name()
|
||||||
5
authentication/apps.py
Normal file
5
authentication/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationConfig(AppConfig):
|
||||||
|
name = "authentication"
|
||||||
20
authentication/forms.py
Normal file
20
authentication/forms.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from django.contrib.auth.forms import BaseUserCreationForm
|
||||||
|
from django.contrib.auth.forms import UserChangeForm as DjangoUserChangeForm
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreationForm(BaseUserCreationForm):
|
||||||
|
"""Add-user form for the email-based custom User (no ``username`` field)."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ("email",)
|
||||||
|
|
||||||
|
|
||||||
|
class UserChangeForm(DjangoUserChangeForm):
|
||||||
|
"""Change-user form; keeps the read-only password hash widget."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = "__all__"
|
||||||
32
authentication/managers.py
Normal file
32
authentication/managers.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from django.contrib.auth.base_user import BaseUserManager
|
||||||
|
|
||||||
|
|
||||||
|
class UserManager(BaseUserManager):
|
||||||
|
"""Manager for the email-based custom User model."""
|
||||||
|
|
||||||
|
use_in_migrations = True
|
||||||
|
|
||||||
|
def _create_user(self, email, password, **extra_fields):
|
||||||
|
if not email:
|
||||||
|
raise ValueError("Users must have an email address.")
|
||||||
|
email = self.normalize_email(email)
|
||||||
|
user = self.model(email=email, **extra_fields)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save(using=self._db)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_user(self, email, password=None, **extra_fields):
|
||||||
|
extra_fields.setdefault("is_staff", False)
|
||||||
|
extra_fields.setdefault("is_superuser", False)
|
||||||
|
return self._create_user(email, password, **extra_fields)
|
||||||
|
|
||||||
|
def create_superuser(self, email, password=None, **extra_fields):
|
||||||
|
extra_fields.setdefault("is_staff", True)
|
||||||
|
extra_fields.setdefault("is_superuser", True)
|
||||||
|
|
||||||
|
if extra_fields.get("is_staff") is not True:
|
||||||
|
raise ValueError("Superuser must have is_staff=True.")
|
||||||
|
if extra_fields.get("is_superuser") is not True:
|
||||||
|
raise ValueError("Superuser must have is_superuser=True.")
|
||||||
|
|
||||||
|
return self._create_user(email, password, **extra_fields)
|
||||||
49
authentication/middleware.py
Normal file
49
authentication/middleware.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Force MFA enrolment for privileged users.
|
||||||
|
|
||||||
|
Anyone who can change other people's data must have a second factor: Django
|
||||||
|
staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN, EDITOR, or
|
||||||
|
MEMBER_ADMIN) in *any* club. Regular members may enrol, but aren't forced to.
|
||||||
|
|
||||||
|
Enrolled users are challenged for their second factor by allauth at login; this
|
||||||
|
middleware only handles the other half — a privileged user who has never
|
||||||
|
enrolled is redirected to the MFA setup page until they do.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from allauth.mfa.utils import is_mfa_enabled
|
||||||
|
from django.conf import settings
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from club.models import ClubRole
|
||||||
|
|
||||||
|
#: Paths a not-yet-enrolled user must still reach (to enrol, or to log out).
|
||||||
|
#: ``/__reload__/`` is django-browser-reload's event stream, which only exists
|
||||||
|
#: under DEBUG — without it, live reload dies on the enrolment page itself.
|
||||||
|
EXEMPT_PREFIXES = ("/accounts/", "/static/", "/media/", "/__reload__/")
|
||||||
|
|
||||||
|
ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR, ClubRole.Roles.MEMBER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
def mfa_required_for(user) -> bool:
|
||||||
|
"""Privileged users must hold a second factor."""
|
||||||
|
if user.is_staff or user.is_superuser:
|
||||||
|
return True
|
||||||
|
return ClubRole.objects.filter(member__user=user, role__in=ELEVATED_ROLES).exists()
|
||||||
|
|
||||||
|
|
||||||
|
class RequireMFAMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if self.needs_enrolment(request):
|
||||||
|
return redirect(reverse(settings.MFA_ENROLMENT_URL_NAME))
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
def needs_enrolment(self, request) -> bool:
|
||||||
|
user = getattr(request, "user", None)
|
||||||
|
if user is None or not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
if request.path.startswith(EXEMPT_PREFIXES):
|
||||||
|
return False
|
||||||
|
return mfa_required_for(user) and not is_mfa_enabled(user)
|
||||||
94
authentication/migrations/0001_initial.py
Normal file
94
authentication/migrations/0001_initial.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-02 07:33
|
||||||
|
|
||||||
|
import authentication.managers
|
||||||
|
import django.db.models.deletion
|
||||||
|
import phonenumber_field.modelfields
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Family',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'family',
|
||||||
|
'verbose_name_plural': 'families',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='User',
|
||||||
|
fields=[
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
|
||||||
|
('is_staff', models.BooleanField(default=False)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'user',
|
||||||
|
'verbose_name_plural': 'users',
|
||||||
|
'ordering': ['email'],
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', authentication.managers.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Member',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('first_name', models.CharField(max_length=150)),
|
||||||
|
('last_name', models.CharField(max_length=150)),
|
||||||
|
('date_of_birth', models.DateField(blank=True, null=True)),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254)),
|
||||||
|
('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None)),
|
||||||
|
('emergency_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None)),
|
||||||
|
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'member',
|
||||||
|
'verbose_name_plural': 'members',
|
||||||
|
'ordering': ['last_name', 'first_name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FamilyMembership',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('role', models.CharField(choices=[('parent', 'parent'), ('child', 'child'), ('guardian', 'guardian'), ('other', 'other')], default='parent', max_length=255)),
|
||||||
|
('family', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='authentication.family')),
|
||||||
|
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='family_memberships', to='authentication.member')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'family membership',
|
||||||
|
'verbose_name_plural': 'family memberships',
|
||||||
|
'ordering': ['family', 'role', 'member__last_name', 'member__first_name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='member',
|
||||||
|
index=models.Index(fields=['last_name', 'first_name'], name='authenticat_last_na_0a0eca_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='familymembership',
|
||||||
|
unique_together={('family', 'member')},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
authentication/migrations/0002_alter_family_name.py
Normal file
18
authentication/migrations/0002_alter_family_name.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-02 14:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='family',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-05 13:50
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import phonenumber_field.modelfields
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0002_alter_family_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='family',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(blank=True, max_length=255, verbose_name='name'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='familymembership',
|
||||||
|
name='family',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='authentication.family', verbose_name='family'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='familymembership',
|
||||||
|
name='member',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='family_memberships', to='authentication.member', verbose_name='member'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='familymembership',
|
||||||
|
name='role',
|
||||||
|
field=models.CharField(choices=[('parent', 'parent'), ('child', 'child'), ('guardian', 'guardian'), ('other', 'other')], default='parent', max_length=255, verbose_name='role'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='member',
|
||||||
|
name='date_of_birth',
|
||||||
|
field=models.DateField(blank=True, null=True, verbose_name='date of birth'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='member',
|
||||||
|
name='email',
|
||||||
|
field=models.EmailField(blank=True, max_length=254, verbose_name='email'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='member',
|
||||||
|
name='emergency_phone',
|
||||||
|
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='emergency phone number'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='member',
|
||||||
|
name='first_name',
|
||||||
|
field=models.CharField(max_length=150, verbose_name='first name'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='member',
|
||||||
|
name='last_name',
|
||||||
|
field=models.CharField(max_length=150, verbose_name='last name'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='member',
|
||||||
|
name='phone',
|
||||||
|
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='phone number'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='member',
|
||||||
|
name='user',
|
||||||
|
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member', to=settings.AUTH_USER_MODEL, verbose_name='user'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='email',
|
||||||
|
field=models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='email'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='is_active',
|
||||||
|
field=models.BooleanField(default=True, verbose_name='is active?'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='is_staff',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='is staff?'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-11 22:09
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0003_alter_family_name_alter_familymembership_family_and_more'),
|
||||||
|
('club', '0005_alter_clubmembership_member'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='FamilyMembership',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Family',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Member',
|
||||||
|
),
|
||||||
|
]
|
||||||
0
authentication/migrations/__init__.py
Normal file
0
authentication/migrations/__init__.py
Normal file
41
authentication/models.py
Normal file
41
authentication/models.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
|
from django.contrib.auth.models import PermissionsMixin
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from .managers import UserManager
|
||||||
|
|
||||||
|
|
||||||
|
class User(AbstractBaseUser, PermissionsMixin):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
email = models.EmailField(_("email"), unique=True, db_index=True)
|
||||||
|
|
||||||
|
is_staff = models.BooleanField(_("is staff?"), default=False)
|
||||||
|
is_active = models.BooleanField(_("is active?"), default=True)
|
||||||
|
|
||||||
|
objects = UserManager()
|
||||||
|
|
||||||
|
USERNAME_FIELD = "email"
|
||||||
|
REQUIRED_FIELDS = []
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("user")
|
||||||
|
verbose_name_plural = _("users")
|
||||||
|
ordering = ["email"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.get_full_name()
|
||||||
|
|
||||||
|
def get_full_name(self):
|
||||||
|
member = getattr(self, "member", None)
|
||||||
|
if member is not None:
|
||||||
|
return member.get_full_name()
|
||||||
|
return self.email
|
||||||
|
|
||||||
|
def get_short_name(self):
|
||||||
|
member = getattr(self, "member", None)
|
||||||
|
if member is not None:
|
||||||
|
return member.get_short_name()
|
||||||
|
return self.email
|
||||||
591
authentication/tests.py
Normal file
591
authentication/tests.py
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from allauth.core import context
|
||||||
|
from allauth.mfa.models import Authenticator
|
||||||
|
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.core import mail
|
||||||
|
from django.db import IntegrityError
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.test import RequestFactory, TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from club.models import Club, ClubRole
|
||||||
|
from members.models import Member
|
||||||
|
|
||||||
|
from .adapters import RosterChiefMFAAdapter, webauthn_rp_id
|
||||||
|
from .middleware import RequireMFAMiddleware, mfa_required_for
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def enrol_mfa(user):
|
||||||
|
"""Give ``user`` a second factor (enough for is_mfa_enabled)."""
|
||||||
|
return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
|
||||||
|
|
||||||
|
|
||||||
|
class UserManagerTests(TestCase):
|
||||||
|
def test_create_user_defaults(self):
|
||||||
|
user = User.objects.create_user(email="alice@example.com", password="secret123")
|
||||||
|
|
||||||
|
self.assertEqual(user.email, "alice@example.com")
|
||||||
|
self.assertTrue(user.check_password("secret123"))
|
||||||
|
self.assertFalse(user.is_staff)
|
||||||
|
self.assertFalse(user.is_superuser)
|
||||||
|
self.assertTrue(user.is_active)
|
||||||
|
|
||||||
|
def test_create_user_requires_email(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
User.objects.create_user(email="", password="secret123")
|
||||||
|
|
||||||
|
def test_create_user_normalizes_email_domain(self):
|
||||||
|
# BaseUserManager lowercases the domain part of the address.
|
||||||
|
user = User.objects.create_user(email="Bob@Example.COM", password="secret123")
|
||||||
|
|
||||||
|
self.assertEqual(user.email, "Bob@example.com")
|
||||||
|
|
||||||
|
def test_create_user_password_is_hashed(self):
|
||||||
|
user = User.objects.create_user(email="carol@example.com", password="secret123")
|
||||||
|
|
||||||
|
self.assertNotEqual(user.password, "secret123")
|
||||||
|
|
||||||
|
def test_create_user_without_password_is_unusable(self):
|
||||||
|
user = User.objects.create_user(email="dave@example.com")
|
||||||
|
|
||||||
|
self.assertFalse(user.has_usable_password())
|
||||||
|
|
||||||
|
def test_create_superuser_defaults(self):
|
||||||
|
admin = User.objects.create_superuser(email="admin@example.com", password="secret123")
|
||||||
|
|
||||||
|
self.assertTrue(admin.is_staff)
|
||||||
|
self.assertTrue(admin.is_superuser)
|
||||||
|
self.assertTrue(admin.is_active)
|
||||||
|
|
||||||
|
def test_create_superuser_rejects_non_staff(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
User.objects.create_superuser(email="admin@example.com", password="x", is_staff=False)
|
||||||
|
|
||||||
|
def test_create_superuser_rejects_non_superuser(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
User.objects.create_superuser(email="admin@example.com", password="x", is_superuser=False)
|
||||||
|
|
||||||
|
|
||||||
|
class UserModelTests(TestCase):
|
||||||
|
def test_email_is_username_field(self):
|
||||||
|
self.assertEqual(User.USERNAME_FIELD, "email")
|
||||||
|
self.assertEqual(User.REQUIRED_FIELDS, [])
|
||||||
|
|
||||||
|
def test_email_is_unique(self):
|
||||||
|
User.objects.create_user(email="dup@example.com", password="x")
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
User.objects.create_user(email="dup@example.com", password="y")
|
||||||
|
|
||||||
|
def test_pk_is_uuid(self):
|
||||||
|
user = User.objects.create_user(email="uuid@example.com", password="x")
|
||||||
|
self.assertIsInstance(user.pk, uuid.UUID)
|
||||||
|
|
||||||
|
def test_str_and_names_fall_back_to_email_without_member(self):
|
||||||
|
user = User.objects.create_user(email="lonely@example.com", password="x")
|
||||||
|
|
||||||
|
self.assertEqual(str(user), "lonely@example.com")
|
||||||
|
self.assertEqual(user.get_full_name(), "lonely@example.com")
|
||||||
|
self.assertEqual(user.get_short_name(), "lonely@example.com")
|
||||||
|
|
||||||
|
def test_str_and_names_use_linked_member(self):
|
||||||
|
user = User.objects.create_user(email="linked@example.com", password="x")
|
||||||
|
Member.objects.create(user=user, first_name="Jane", last_name="Doe")
|
||||||
|
|
||||||
|
# Re-fetch so the reverse OneToOne relation is resolved from the DB.
|
||||||
|
user = User.objects.get(pk=user.pk)
|
||||||
|
|
||||||
|
self.assertEqual(str(user), "Jane Doe")
|
||||||
|
self.assertEqual(user.get_full_name(), "Jane Doe")
|
||||||
|
self.assertEqual(user.get_short_name(), "Jane")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||||
|
MFA_WEBAUTHN_RP_NAME="RosterChief",
|
||||||
|
ALLOWED_HOSTS=[".rosterchief.app", "example.test"],
|
||||||
|
)
|
||||||
|
class WebAuthnRelyingPartyTests(TestCase):
|
||||||
|
"""A passkey is bound to a Relying Party ID (a domain).
|
||||||
|
|
||||||
|
allauth's default RP ID is the request host, which under our subdomain
|
||||||
|
tenancy would bind a passkey to a single club. We pin it to the registrable
|
||||||
|
parent domain so ONE passkey works across every club.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def rp_entity(self, host):
|
||||||
|
request = RequestFactory().get("/", HTTP_HOST=host)
|
||||||
|
with context.request_context(request):
|
||||||
|
return RosterChiefMFAAdapter().get_public_key_credential_rp_entity()
|
||||||
|
|
||||||
|
def test_rp_id_is_the_parent_domain_not_the_club_subdomain(self):
|
||||||
|
self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["id"], "rosterchief.app")
|
||||||
|
|
||||||
|
def test_rp_id_is_identical_across_clubs(self):
|
||||||
|
# The whole point: a passkey registered at one club works at the others.
|
||||||
|
here = self.rp_entity("ajax-united.rosterchief.app")
|
||||||
|
there = self.rp_entity("rival-fc.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(here["id"], there["id"])
|
||||||
|
|
||||||
|
def test_rp_name_comes_from_settings(self):
|
||||||
|
self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["name"], "RosterChief")
|
||||||
|
|
||||||
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="")
|
||||||
|
def test_falls_back_to_the_request_host_without_a_base_domain(self):
|
||||||
|
request = RequestFactory().get("/", HTTP_HOST="example.test:8000")
|
||||||
|
|
||||||
|
with context.request_context(request):
|
||||||
|
self.assertEqual(webauthn_rp_id(), "example.test")
|
||||||
|
|
||||||
|
|
||||||
|
class MFARequirementTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
# Read-only for every test here: each one brings its own user and role.
|
||||||
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
|
||||||
|
def make_user(self, email, **kwargs):
|
||||||
|
return User.objects.create_user(email=email, password="pw-secret-123", **kwargs)
|
||||||
|
|
||||||
|
def with_role(self, user, role):
|
||||||
|
member = Member.objects.create(user=user, first_name="Ada", last_name="Min")
|
||||||
|
ClubRole.objects.create(club=self.club, member=member, role=role)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def test_staff_must_have_mfa(self):
|
||||||
|
self.assertTrue(mfa_required_for(self.make_user("staff@example.com", is_staff=True)))
|
||||||
|
|
||||||
|
def test_superuser_must_have_mfa(self):
|
||||||
|
self.assertTrue(mfa_required_for(User.objects.create_superuser(email="root@example.com", password="pw-secret-123")))
|
||||||
|
|
||||||
|
def test_club_admin_must_have_mfa(self):
|
||||||
|
user = self.with_role(self.make_user("admin@example.com"), ClubRole.Roles.ADMIN)
|
||||||
|
|
||||||
|
self.assertTrue(mfa_required_for(user))
|
||||||
|
|
||||||
|
def test_editor_must_have_mfa(self):
|
||||||
|
user = self.with_role(self.make_user("editor@example.com"), ClubRole.Roles.EDITOR)
|
||||||
|
|
||||||
|
self.assertTrue(mfa_required_for(user))
|
||||||
|
|
||||||
|
def test_plain_member_does_not_need_mfa(self):
|
||||||
|
user = self.with_role(self.make_user("member@example.com"), ClubRole.Roles.MEMBER)
|
||||||
|
|
||||||
|
self.assertFalse(mfa_required_for(user))
|
||||||
|
|
||||||
|
def test_user_without_any_role_does_not_need_mfa(self):
|
||||||
|
self.assertFalse(mfa_required_for(self.make_user("nobody@example.com")))
|
||||||
|
|
||||||
|
|
||||||
|
class RequireMFAMiddlewareTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.middleware = RequireMFAMiddleware(lambda request: HttpResponse("ok"))
|
||||||
|
|
||||||
|
def dispatch(self, user, path="/"):
|
||||||
|
request = self.factory.get(path)
|
||||||
|
request.user = user
|
||||||
|
return self.middleware(request)
|
||||||
|
|
||||||
|
def make_staff(self):
|
||||||
|
return User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
|
||||||
|
|
||||||
|
def test_anonymous_passes_through(self):
|
||||||
|
self.assertEqual(self.dispatch(AnonymousUser()).content, b"ok")
|
||||||
|
|
||||||
|
def test_unprivileged_user_passes_through(self):
|
||||||
|
user = User.objects.create_user(email="plain@example.com", password="pw-secret-123")
|
||||||
|
|
||||||
|
self.assertEqual(self.dispatch(user).content, b"ok")
|
||||||
|
|
||||||
|
def test_privileged_user_without_mfa_is_sent_to_enrolment(self):
|
||||||
|
response = self.dispatch(self.make_staff())
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, reverse("mfa_index"))
|
||||||
|
|
||||||
|
def test_privileged_user_can_still_reach_the_enrolment_pages(self):
|
||||||
|
# Otherwise they'd be redirected in a loop and could never enrol.
|
||||||
|
response = self.dispatch(self.make_staff(), path="/accounts/2fa/totp/activate/")
|
||||||
|
|
||||||
|
self.assertEqual(response.content, b"ok")
|
||||||
|
|
||||||
|
def test_enrolled_privileged_user_passes_through(self):
|
||||||
|
staff = self.make_staff()
|
||||||
|
enrol_mfa(staff)
|
||||||
|
|
||||||
|
self.assertEqual(self.dispatch(staff).content, b"ok")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLoginRoutingTests(TestCase):
|
||||||
|
def test_admin_login_is_routed_through_allauth(self):
|
||||||
|
# Django's own admin login knows nothing about second factors.
|
||||||
|
response = self.client.get("/admin/login/", {"next": "/admin/"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
redirect = urlparse(response.url)
|
||||||
|
self.assertEqual(redirect.path, reverse("account_login"))
|
||||||
|
# The original destination survives the hop (percent-encoded).
|
||||||
|
self.assertEqual(parse_qs(redirect.query)["next"], ["/admin/"])
|
||||||
|
|
||||||
|
|
||||||
|
class AuthFormRenderingTests(TestCase):
|
||||||
|
"""Every allauth form must actually render its fields.
|
||||||
|
|
||||||
|
Regression: the `fields` element passed `attrs.exclude` straight into a filter.
|
||||||
|
On a page that never sets it, resolving a filter *argument* raises
|
||||||
|
VariableDoesNotExist — which Django swallows inside {% if %} and reads as false —
|
||||||
|
so every field was silently dropped from every form except the login page (the one
|
||||||
|
page that does pass `exclude`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_the_login_form_renders_its_fields(self):
|
||||||
|
self.assertContains(self.client.get(reverse("account_login")), 'name="login"')
|
||||||
|
|
||||||
|
def test_the_password_reset_form_renders_its_fields(self):
|
||||||
|
self.assertContains(self.client.get(reverse("account_reset_password")), 'name="email"')
|
||||||
|
|
||||||
|
def test_self_registration_is_closed(self):
|
||||||
|
# A club has no reason to let a stranger create an account: they're made by
|
||||||
|
# an admin, by the family-registration form, or by an approved parent claim
|
||||||
|
# (members/views.py). The route is shadowed rather than removed so that the
|
||||||
|
# `account_signup` name allauth's own templates reverse still resolves.
|
||||||
|
response = self.client.get(reverse("account_signup"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertNotContains(response, 'name="password1"', status_code=403)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||||
|
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
||||||
|
)
|
||||||
|
class PasswordResetEmailTests(TestCase):
|
||||||
|
"""allauth auto-attaches templates/account/email/password_reset_key_message.html
|
||||||
|
as an HTML alternative next to its own .txt body -- see
|
||||||
|
allauth.account.adapter.DefaultAccountAdapter.render_mail, which looks for
|
||||||
|
"<prefix>_message.<ext>" for ext in [TEMPLATE_EXTENSION ("html", unset here), "txt"].
|
||||||
|
No Python override needed; this only exercises the template."""
|
||||||
|
|
||||||
|
def test_the_html_email_carries_the_clubs_branding_on_a_club_subdomain(self):
|
||||||
|
Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
User.objects.create_user(email="parent@example.com", password="pw-secret-123")
|
||||||
|
|
||||||
|
response = self.client.post(reverse("account_reset_password"), {"email": "parent@example.com"}, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
[(html_body, mimetype)] = mail.outbox[0].alternatives
|
||||||
|
self.assertEqual(mimetype, "text/html")
|
||||||
|
self.assertIn("Ajax United", html_body)
|
||||||
|
self.assertIn("/accounts/password/reset/key/", html_body)
|
||||||
|
|
||||||
|
def test_the_html_email_falls_back_to_rosterchief_branding_off_a_club_subdomain(self):
|
||||||
|
# The base domain has no tenant, e.g. a platform control-panel user
|
||||||
|
# resetting their own password -- club.context_processors.branding
|
||||||
|
# leaves `club` unset there, so the template must not assume one.
|
||||||
|
User.objects.create_user(email="admin@example.com", password="pw-secret-123")
|
||||||
|
|
||||||
|
self.client.post(reverse("account_reset_password"), {"email": "admin@example.com"})
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
[(html_body, mimetype)] = mail.outbox[0].alternatives
|
||||||
|
self.assertEqual(mimetype, "text/html")
|
||||||
|
# The wordmark splits "Chief" into its own <span> for the sky-blue accent
|
||||||
|
# (matching templates/_platform_base.html), so the two halves aren't
|
||||||
|
# contiguous text in the raw HTML -- check for both rather than the
|
||||||
|
# combined word.
|
||||||
|
self.assertIn("Roster", html_body)
|
||||||
|
self.assertIn("Chief", html_body)
|
||||||
|
|
||||||
|
def test_the_password_reset_key_html_template_renders_directly(self):
|
||||||
|
# Mirrors authentication.tests.AuthFormRenderingTests' direct-render
|
||||||
|
# style: exercises the template's own branching (club vs. none, logo
|
||||||
|
# vs. initials) without going through the full request/email pipeline.
|
||||||
|
club = Club.objects.create(name="Ajax United", slug="ajax-united", primary_color="#1e40af")
|
||||||
|
|
||||||
|
with_club = render_to_string("account/email/password_reset_key_message.html", {"club": club, "password_reset_url": "https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/"})
|
||||||
|
self.assertIn("Ajax United", with_club)
|
||||||
|
self.assertIn("AU", with_club) # initials fallback: no logo set
|
||||||
|
self.assertIn("https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/", with_club)
|
||||||
|
|
||||||
|
def test_the_initials_badge_text_contrasts_against_the_fallback_colour(self):
|
||||||
|
# Club has no secondary_color, so the badge falls back to #ec4899 --
|
||||||
|
# black text (contrast_color("#ec4899")) reads far better on it than
|
||||||
|
# the white the template used to hardcode. See
|
||||||
|
# club/templatetags/club_email.py::contrast_color.
|
||||||
|
club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
|
||||||
|
rendered = render_to_string("account/email/password_reset_key_message.html", {"club": club, "password_reset_url": "https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/"})
|
||||||
|
|
||||||
|
self.assertIn("background-color:#ec4899", rendered)
|
||||||
|
self.assertIn("color:#000000", rendered)
|
||||||
|
|
||||||
|
def test_the_reset_button_text_contrasts_against_the_no_club_fallback_colour(self):
|
||||||
|
# No club at all (the base-domain flow) -- the button falls back to
|
||||||
|
# #0ea5e9, RosterChief's own sky blue, which also needs black text
|
||||||
|
# for a passing contrast ratio, not the white previously hardcoded.
|
||||||
|
rendered = render_to_string("account/email/password_reset_key_message.html", {"club": None, "password_reset_url": "https://rosterchief.app/accounts/password/reset/key/abc-def/", "current_site": None})
|
||||||
|
|
||||||
|
self.assertIn("background-color:#0ea5e9", rendered)
|
||||||
|
self.assertIn("color:#000000", rendered)
|
||||||
|
|
||||||
|
without_club = render_to_string("account/email/password_reset_key_message.html", {"club": None, "password_reset_url": "https://rosterchief.app/accounts/password/reset/key/abc-def/", "current_site": SimpleNamespace(name="rosterchief.app")})
|
||||||
|
self.assertIn("Roster", without_club)
|
||||||
|
self.assertIn("Chief", without_club)
|
||||||
|
self.assertIn("https://rosterchief.app/accounts/password/reset/key/abc-def/", without_club)
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorPageTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
enrol_mfa(User.objects.create_user(email="mfa@example.com", password="pw-secret-123"))
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# The test client is per-test, so the sign-in itself cannot be hoisted.
|
||||||
|
# Password accepted, second factor still owed: this is the 2FA challenge page.
|
||||||
|
self.response = self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||||
|
|
||||||
|
def test_the_code_field_renders_as_an_otp_input(self):
|
||||||
|
self.assertContains(self.response, 'name="code"')
|
||||||
|
self.assertContains(self.response, "otp otp-lg")
|
||||||
|
|
||||||
|
def test_the_input_comes_after_the_boxes(self):
|
||||||
|
# daisyUI places each box with nth-child, which counts every child. With the input
|
||||||
|
# first, all six boxes shift a stride right, the container grows to seven strides
|
||||||
|
# and the ::after focus marker appears as a phantom seventh box.
|
||||||
|
html = self.response.content.decode()
|
||||||
|
otp = html[html.index('class="otp otp-lg"') : html.index('name="code"')]
|
||||||
|
|
||||||
|
self.assertEqual(otp.count("<span></span>"), 6)
|
||||||
|
|
||||||
|
def test_the_boxes_are_wrapped_in_a_label_so_tapping_focuses_the_input(self):
|
||||||
|
# daisyUI's overlaid otp input carries `pointer-events: none` (so clicks land on the
|
||||||
|
# boxes, not a naked input) — which also means a tap on the boxes never reaches the
|
||||||
|
# input directly. A <label for> is what closes that gap: browsers focus a labelled
|
||||||
|
# control on click regardless of the control's own pointer-events. Without this
|
||||||
|
# wrapper the field cannot be entered on a touchscreen, which has no Tab key to fall
|
||||||
|
# back on.
|
||||||
|
html = self.response.content.decode()
|
||||||
|
label_start = html.index('<label class="contents"')
|
||||||
|
otp_start = html.index('class="otp otp-lg"')
|
||||||
|
|
||||||
|
self.assertLess(label_start, otp_start)
|
||||||
|
self.assertIn('for="id_code"', html[label_start : label_start + 60])
|
||||||
|
|
||||||
|
def test_the_otp_field_has_no_placeholder(self):
|
||||||
|
# allauth sets placeholder="Code"; inside the boxes it reads as a typed-in code.
|
||||||
|
self.assertNotContains(self.response, 'placeholder="Code"')
|
||||||
|
|
||||||
|
def test_cancel_sits_beside_sign_in_and_is_not_primary(self):
|
||||||
|
self.assertContains(self.response, '<button class="btn btn-outline gap-2" type="submit" form="logout-from-stage">')
|
||||||
|
self.assertContains(self.response, '<button class="btn btn-primary gap-2" type="submit">')
|
||||||
|
|
||||||
|
def test_cancel_has_a_form_to_submit(self):
|
||||||
|
self.assertContains(self.response, 'id="logout-from-stage"')
|
||||||
|
|
||||||
|
def test_the_security_key_button_is_an_accent_button_with_a_working_form(self):
|
||||||
|
self.assertContains(self.response, "btn btn-accent")
|
||||||
|
self.assertContains(self.response, 'form="webauthn_form"')
|
||||||
|
# The id lives on the form element — without it the button submits nothing.
|
||||||
|
self.assertContains(self.response, 'id="webauthn_form"')
|
||||||
|
self.assertContains(self.response, "allauth.webauthn.forms.authenticateForm")
|
||||||
|
|
||||||
|
|
||||||
|
class MfaPageTests(TestCase):
|
||||||
|
"""Every MFA screen must render. They are built from allauth's `element` primitives,
|
||||||
|
so styling lives in the element overrides rather than in eight page templates."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# A real password login (not force_login) so allauth counts it as a recent
|
||||||
|
# authentication and doesn't bounce the sensitive pages to reauthenticate.
|
||||||
|
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||||
|
|
||||||
|
def test_the_manage_page_renders_a_panel_per_authenticator(self):
|
||||||
|
response = self.client.get(reverse("mfa_index"))
|
||||||
|
|
||||||
|
self.assertContains(response, "Authenticator App")
|
||||||
|
self.assertContains(response, "card border")
|
||||||
|
|
||||||
|
def test_the_security_key_list_renders(self):
|
||||||
|
# Regression: allauth's template does {% load humanize %}, which raised
|
||||||
|
# TemplateSyntaxError until django.contrib.humanize was installed.
|
||||||
|
self.assertEqual(self.client.get(reverse("mfa_list_webauthn")).status_code, 200)
|
||||||
|
|
||||||
|
def test_the_totp_activate_page_boxes_the_code_and_plates_the_qr(self):
|
||||||
|
response = self.client.get(reverse("mfa_activate_totp"))
|
||||||
|
|
||||||
|
self.assertContains(response, "otp otp-lg")
|
||||||
|
# The QR is dark-on-transparent: without a white plate it is unscannable on the
|
||||||
|
# dark theme.
|
||||||
|
self.assertContains(response, "bg-white p-3")
|
||||||
|
self.assertContains(response, "font-mono") # the secret, to be copied by hand
|
||||||
|
|
||||||
|
def test_the_deactivate_button_is_destructive(self):
|
||||||
|
enrol_mfa(self.user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("mfa_index"))
|
||||||
|
|
||||||
|
# allauth tags it "danger" — it must not look like the safe action.
|
||||||
|
self.assertContains(response, "btn-error")
|
||||||
|
|
||||||
|
def test_reauthenticating_with_a_code_boxes_the_input(self):
|
||||||
|
enrol_mfa(self.user)
|
||||||
|
|
||||||
|
self.assertContains(self.client.get(reverse("mfa_reauthenticate")), "otp otp-lg")
|
||||||
|
|
||||||
|
|
||||||
|
class ActionBarTests(TestCase):
|
||||||
|
"""A form's action bar is drawn when the actions slot has content.
|
||||||
|
|
||||||
|
Regression: it was keyed on `no_visible_fields`, which allauth sets to say a form has
|
||||||
|
no visible *fields* — logout and TOTP deactivate are a bare csrf token plus a button.
|
||||||
|
Keying the bar on it hid the button on exactly the pages that are nothing but a button.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||||
|
|
||||||
|
def test_the_sign_out_page_has_its_button(self):
|
||||||
|
response = self.client.get(reverse("account_logout"))
|
||||||
|
|
||||||
|
self.assertContains(response, "Sign Out")
|
||||||
|
self.assertContains(response, 'type="submit"')
|
||||||
|
|
||||||
|
def test_the_totp_deactivate_page_has_its_button(self):
|
||||||
|
enrol_mfa(self.user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("mfa_deactivate_totp"))
|
||||||
|
|
||||||
|
self.assertContains(response, "btn-error")
|
||||||
|
self.assertContains(response, 'type="submit"')
|
||||||
|
|
||||||
|
|
||||||
|
class SignOutPageTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.response = self.client.get(reverse("account_logout"))
|
||||||
|
|
||||||
|
def test_sign_out_and_cancel_sit_side_by_side_with_icons(self):
|
||||||
|
html = self.response.content.decode()
|
||||||
|
cancel = html[html.index('<a class="btn btn-outline gap-2" href="/">') :]
|
||||||
|
sign_out = html[html.index('<button class="btn btn-primary gap-2"') :]
|
||||||
|
|
||||||
|
self.assertIn("<svg", cancel[: cancel.index("</a>")])
|
||||||
|
self.assertIn("<svg", sign_out[: sign_out.index("</button>")])
|
||||||
|
|
||||||
|
def test_cancel_does_not_sign_you_out(self):
|
||||||
|
# It is a link, not a submit: only the POST logs you out.
|
||||||
|
self.client.get("/")
|
||||||
|
|
||||||
|
self.assertTrue(self.client.session.get("_auth_user_id"))
|
||||||
|
|
||||||
|
def test_signing_out_still_works(self):
|
||||||
|
self.client.post(reverse("account_logout"))
|
||||||
|
|
||||||
|
self.assertIsNone(self.client.session.get("_auth_user_id"))
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordPageTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||||
|
self.response = self.client.get(reverse("account_change_password"))
|
||||||
|
|
||||||
|
def test_the_fields_have_no_visible_labels(self):
|
||||||
|
# allauth gives each a placeholder, so the label would only repeat it.
|
||||||
|
self.assertNotContains(self.response, '<span class="label-text">Current Password</span>')
|
||||||
|
self.assertContains(self.response, 'name="oldpassword"')
|
||||||
|
self.assertContains(self.response, 'name="password1"')
|
||||||
|
|
||||||
|
def test_the_new_password_keeps_its_help_text(self):
|
||||||
|
self.assertContains(self.response, "id_password1_helptext")
|
||||||
|
|
||||||
|
def test_the_current_password_is_set_apart_from_the_new_one(self):
|
||||||
|
self.assertContains(self.response, "mt-10")
|
||||||
|
|
||||||
|
def test_forgot_password_is_an_accent_button_and_both_actions_have_icons(self):
|
||||||
|
html = self.response.content.decode()
|
||||||
|
forgot = html[html.index("btn-accent") :]
|
||||||
|
submit = html[html.index('class="btn btn-primary gap-2"') :]
|
||||||
|
|
||||||
|
self.assertIn("<svg", forgot[: forgot.index("</a>")])
|
||||||
|
self.assertIn("<svg", submit[: submit.index("</button>")])
|
||||||
|
|
||||||
|
|
||||||
|
class MfaButtonIconTests(TestCase):
|
||||||
|
"""Every button on the MFA screens carries an icon, and the recovery-code actions are
|
||||||
|
ranked: View is primary, Download and Generate are outline. Generate throws away the
|
||||||
|
codes you already have, so it must not read as the obvious thing to click."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Sign in *before* enrolling: a user who already holds a second factor is stopped at
|
||||||
|
# the 2FA challenge and never reaches these pages.
|
||||||
|
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||||
|
enrol_mfa(self.user)
|
||||||
|
RecoveryCodes.activate(self.user).instance.save()
|
||||||
|
|
||||||
|
def buttons(self, url):
|
||||||
|
"""Every <a class="btn"> / <button class="btn"> in the page body, minus the navbar."""
|
||||||
|
html = self.client.get(url, follow=True).content.decode()
|
||||||
|
body = html[html.index("<main") :]
|
||||||
|
return re.findall(r'<(?:a|button)[^>]*class="btn[^"]*"[^>]*>(.*?)</(?:a|button)>', body, re.S)
|
||||||
|
|
||||||
|
def test_every_button_on_the_manage_page_has_an_icon(self):
|
||||||
|
found = self.buttons(reverse("mfa_index"))
|
||||||
|
|
||||||
|
self.assertTrue(found)
|
||||||
|
for button in found:
|
||||||
|
self.assertIn("<svg", button)
|
||||||
|
|
||||||
|
def test_download_and_generate_are_outline_buttons(self):
|
||||||
|
html = self.client.get(reverse("mfa_index"), follow=True).content.decode()
|
||||||
|
|
||||||
|
self.assertEqual(html.count("btn-outline"), 2) # Download + Generate, not View
|
||||||
|
|
||||||
|
def test_the_panel_actions_are_spaced_off_the_body_text(self):
|
||||||
|
self.assertContains(self.client.get(reverse("mfa_index"), follow=True), "card-actions mt-4")
|
||||||
|
|
||||||
|
def test_every_button_on_the_deactivate_page_has_an_icon(self):
|
||||||
|
for button in self.buttons(reverse("mfa_deactivate_totp")):
|
||||||
|
self.assertIn("<svg", button)
|
||||||
|
|
||||||
|
def test_every_button_on_the_add_security_key_page_has_an_icon(self):
|
||||||
|
for button in self.buttons(reverse("mfa_add_webauthn")):
|
||||||
|
self.assertIn("<svg", button)
|
||||||
|
|
||||||
|
def test_the_activate_page_gives_the_code_box_no_visible_label(self):
|
||||||
|
Authenticator.objects.filter(user=self.user, type=Authenticator.Type.TOTP).delete()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("mfa_activate_totp"), follow=True)
|
||||||
|
|
||||||
|
self.assertContains(response, "otp otp-lg")
|
||||||
|
self.assertNotContains(response, '<span class="label-text">Code</span>')
|
||||||
0
billing/__init__.py
Normal file
0
billing/__init__.py
Normal file
55
billing/admin.py
Normal file
55
billing/admin.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Due, DuePayment, Plan, PlanPrice, Subscription
|
||||||
|
|
||||||
|
|
||||||
|
class PlanPriceInline(admin.TabularInline):
|
||||||
|
model = PlanPrice
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Plan)
|
||||||
|
class PlanAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "duration_months", "renewal_lead_days", "grace_days", "is_trial", "is_active"]
|
||||||
|
list_filter = ["is_active", "is_trial"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
prepopulated_fields = {"slug": ["name"]}
|
||||||
|
inlines = [PlanPriceInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PlanPrice)
|
||||||
|
class PlanPriceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["plan", "amount", "active_from"]
|
||||||
|
list_filter = ["plan"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Subscription)
|
||||||
|
class SubscriptionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["club", "plan", "auto_renew", "auto_archive"]
|
||||||
|
list_filter = ["plan", "auto_renew", "auto_archive"]
|
||||||
|
search_fields = ["club__name"]
|
||||||
|
|
||||||
|
|
||||||
|
class DuePaymentInline(admin.TabularInline):
|
||||||
|
model = DuePayment
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ["recorded_by"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Due)
|
||||||
|
class DueAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["club", "plan", "period_start", "period_end", "grace_until", "amount", "amount_paid", "status"]
|
||||||
|
list_filter = ["status", "plan"]
|
||||||
|
search_fields = ["club__name"]
|
||||||
|
# Money is settled by the billing service, which re-derives these from the payments.
|
||||||
|
# period_end/grace_until are snapshots taken when the period opened -- editing a plan
|
||||||
|
# afterwards must not move them, and neither should a hand edit here.
|
||||||
|
readonly_fields = ["amount_paid", "status", "paid_at", "period_end", "grace_until"]
|
||||||
|
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.plan}, {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."))
|
||||||
56
billing/management/commands/renew_subscriptions.py
Normal file
56
billing/management/commands/renew_subscriptions.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Issue the next billing period for clubs whose current one is running out.
|
||||||
|
|
||||||
|
Unlike archive_overdue_clubs, this ACTS by default and only previews with --dry-run. The
|
||||||
|
asymmetry is deliberate and runs the other way: archiving switches off a paying customer, so
|
||||||
|
not acting is the safe failure. Here, not acting means a club keeps using the platform for
|
||||||
|
free — and because nothing is owed, no dashboard number goes red and the archive job never
|
||||||
|
fires either. A missed renewal is silent, and silence is the expensive failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
|
||||||
|
from billing.services import BillingError
|
||||||
|
from billing.services.dues import renew, subscriptions_due_for_renewal
|
||||||
|
from features.commands import MaintenanceAwareCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(MaintenanceAwareCommand):
|
||||||
|
help = "Open the next billing period for clubs whose current period ends soon."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Report what would be issued, and issue nothing.")
|
||||||
|
parser.add_argument("--lead-days", type=int, default=None, help="Override every plan's own renewal lead. Left off, each plan uses its own.")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
due_for_renewal = subscriptions_due_for_renewal(lead_days=options["lead_days"])
|
||||||
|
|
||||||
|
if not due_for_renewal:
|
||||||
|
self.stdout.write(self.style.SUCCESS("Nothing to renew."))
|
||||||
|
return
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
for subscription in due_for_renewal:
|
||||||
|
club = subscription.club
|
||||||
|
|
||||||
|
if options["dry_run"]:
|
||||||
|
self.stdout.write(f"would renew {club} — {subscription.plan}, current period ends {subscription.latest_period_end or 'never opened'}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
due = renew(subscription)
|
||||||
|
except BillingError as error:
|
||||||
|
# One unpriced plan must not stop every other club from being billed.
|
||||||
|
failures.append(f"{club}: {error}")
|
||||||
|
self.stdout.write(self.style.ERROR(f"{club} — {error}"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"{club} — {due.period_start} to {due.period_end}, {due.amount} ({due.invoice.number})"))
|
||||||
|
|
||||||
|
if options["dry_run"]:
|
||||||
|
self.stdout.write(self.style.WARNING(f"\nDry run: {len(due_for_renewal)} club(s) would be renewed."))
|
||||||
|
return
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
# Non-zero, so cron mails you: a club that could not be billed is revenue quietly
|
||||||
|
# not being collected.
|
||||||
|
raise CommandError(f"{len(failures)} club(s) could not be renewed:\n " + "\n ".join(failures))
|
||||||
64
billing/management/commands/send_billing_reminders.py
Normal file
64
billing/management/commands/send_billing_reminders.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Email club admins about platform fees they owe.
|
||||||
|
|
||||||
|
Reports by default and only sends with --commit, the same posture as archive_overdue_clubs
|
||||||
|
and for a related reason: this one mails paying customers, and a bad clock, a bad import or a
|
||||||
|
rehearsal against production data should cost you a confusing dry-run listing rather than a
|
||||||
|
mailshot you cannot recall.
|
||||||
|
|
||||||
|
Idempotent across runs by design -- one reminder per due per escalation level, tracked on
|
||||||
|
Due.last_reminder_level -- so a daily cron does not produce a daily email.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
|
||||||
|
from billing.services.reminders import reminders_to_send, send_reminder
|
||||||
|
from club.models import Club
|
||||||
|
from features.commands import MaintenanceAwareCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(MaintenanceAwareCommand):
|
||||||
|
help = "Email club admins about outstanding platform fees (dry run unless --commit)."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--commit", action="store_true", help="Actually send. Without this the command only reports.")
|
||||||
|
parser.add_argument("--force", action="store_true", help="Re-send even where a reminder already went out at this level.")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
clubs = Club.objects.active().select_related("subscription", "subscription__plan").order_by("name")
|
||||||
|
results = reminders_to_send(clubs, force=options["force"])
|
||||||
|
|
||||||
|
sendable = [result for result in results if result.sent]
|
||||||
|
skipped = [result for result in results if not result.sent]
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
self.stdout.write(self.style.SUCCESS("Nothing owing. No reminders to send."))
|
||||||
|
return
|
||||||
|
|
||||||
|
for result in skipped:
|
||||||
|
style = self.style.WARNING if result.recipients else self.style.ERROR
|
||||||
|
self.stdout.write(style(f"{result.club} — skipped: {result.skipped_reason}"))
|
||||||
|
|
||||||
|
for result in sendable:
|
||||||
|
self.stdout.write(f"{result.notice.level:>7} · {result.club} — €{result.notice.amount_outstanding} owed, {result.notice.days_until_archive}d to archive → {', '.join(result.recipients)}")
|
||||||
|
|
||||||
|
if not options["commit"]:
|
||||||
|
self.stdout.write(self.style.WARNING(f"\nDry run: {len(sendable)} reminder(s) would be sent. Re-run with --commit to send them."))
|
||||||
|
return
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
for result in sendable:
|
||||||
|
try:
|
||||||
|
send_reminder(result.club, result.notice, recipients=result.recipients)
|
||||||
|
except OSError as error:
|
||||||
|
# One bad address or a momentary SMTP failure must not stop the rest of the
|
||||||
|
# run: the clubs further down the list are the ones closest to being archived.
|
||||||
|
failures.append(f"{result.club}: {error}")
|
||||||
|
self.stdout.write(self.style.ERROR(f"{result.club} — {error}"))
|
||||||
|
|
||||||
|
sent = len(sendable) - len(failures)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"\nSent {sent} reminder(s)."))
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
# Non-zero so cron mails you: a club that could not be warned is a club that gets
|
||||||
|
# archived without notice.
|
||||||
|
raise CommandError(f"{len(failures)} reminder(s) could not be sent:\n " + "\n ".join(failures))
|
||||||
138
billing/migrations/0001_initial.py
Normal file
138
billing/migrations/0001_initial.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-13 23:40
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0013_club_created_club_modified_clubmembership_created_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Tier',
|
||||||
|
fields=[
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||||
|
('slug', models.SlugField(blank=True, max_length=255, unique=True, verbose_name='slug')),
|
||||||
|
('description', models.TextField(blank=True, verbose_name='description')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Inactive tiers keep billing existing subscriptions but cannot be chosen for new ones.', verbose_name='active')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'tier',
|
||||||
|
'verbose_name_plural': 'tiers',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Due',
|
||||||
|
fields=[
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='amount')),
|
||||||
|
('amount_paid', models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Kept in step with the payments by the billing service.', max_digits=10, verbose_name='amount paid')),
|
||||||
|
('period_start', models.DateField(verbose_name='period start')),
|
||||||
|
('period_end', models.DateField(blank=True, verbose_name='period end')),
|
||||||
|
('grace_until', models.DateField(blank=True, help_text='Past this date an unpaid club is archived.', verbose_name='grace until')),
|
||||||
|
('status', models.CharField(choices=[('unpaid', 'unpaid'), ('partial', 'partially paid'), ('paid', 'paid'), ('waived', 'waived'), ('cancelled', 'cancelled')], default='unpaid', max_length=20, verbose_name='status')),
|
||||||
|
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='paid at')),
|
||||||
|
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dues', to='club.club', verbose_name='club')),
|
||||||
|
('tier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dues', to='billing.tier', verbose_name='tier')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'due',
|
||||||
|
'verbose_name_plural': 'dues',
|
||||||
|
'ordering': ['-period_start', 'club__name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DuePayment',
|
||||||
|
fields=[
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')),
|
||||||
|
('method', models.CharField(choices=[('bank_transfer', 'bank transfer'), ('card', 'card'), ('cash', 'cash'), ('other', 'other')], default='bank_transfer', max_length=20, verbose_name='method')),
|
||||||
|
('reference', models.CharField(blank=True, help_text='Bank reference, transaction id — whatever lets you find this again.', max_length=255, verbose_name='reference')),
|
||||||
|
('paid_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='paid at')),
|
||||||
|
('note', models.TextField(blank=True, verbose_name='note')),
|
||||||
|
('due', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='billing.due', verbose_name='due')),
|
||||||
|
('recorded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recorded_due_payments', to=settings.AUTH_USER_MODEL, verbose_name='recorded by')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'due payment',
|
||||||
|
'verbose_name_plural': 'due payments',
|
||||||
|
'ordering': ['-paid_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Invoice',
|
||||||
|
fields=[
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('number', models.CharField(blank=True, max_length=32, unique=True, verbose_name='number')),
|
||||||
|
('issued_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='issued at')),
|
||||||
|
('due', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='invoice', to='billing.due', verbose_name='due')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'invoice',
|
||||||
|
'verbose_name_plural': 'invoices',
|
||||||
|
'ordering': ['-issued_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Subscription',
|
||||||
|
fields=[
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('auto_archive', models.BooleanField(default=True, help_text='Archive this club when a period goes unpaid past its grace period.', verbose_name='auto archive')),
|
||||||
|
('notes', models.TextField(blank=True, verbose_name='notes')),
|
||||||
|
('club', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='club.club', verbose_name='club')),
|
||||||
|
('tier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='billing.tier', verbose_name='tier')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'subscription',
|
||||||
|
'verbose_name_plural': 'subscriptions',
|
||||||
|
'ordering': ['club__name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TierPrice',
|
||||||
|
fields=[
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('active_from', models.DateField(help_text='Periods opening on or after this date are billed at this amount.', verbose_name='active from')),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='amount')),
|
||||||
|
('tier', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='billing.tier', verbose_name='tier')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'tier price',
|
||||||
|
'verbose_name_plural': 'tier prices',
|
||||||
|
'ordering': ['tier__name', '-active_from'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='due',
|
||||||
|
constraint=models.UniqueConstraint(fields=('club', 'period_start'), name='unique_due_per_club_per_period'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='tierprice',
|
||||||
|
constraint=models.UniqueConstraint(fields=('tier', 'active_from'), name='unique_tier_price_per_start_date'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
billing/migrations/0002_subscription_auto_renew.py
Normal file
18
billing/migrations/0002_subscription_auto_renew.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-14 22:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='auto_renew',
|
||||||
|
field=models.BooleanField(default=True, help_text='Issue the next period automatically before this one ends. Off means you invoice this club by hand.', verbose_name='auto renew'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-04 10:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0002_subscription_auto_renew'),
|
||||||
|
('club', '0017_club_season_duration_months_club_season_start'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='due',
|
||||||
|
name='is_trial',
|
||||||
|
field=models.BooleanField(default=False, help_text="This period was opened as a trial. A durable marker on the row itself -- the subscription's own trial fields are cleared once it converts.", verbose_name='trial period'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='post_trial_tier',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='The plan this club switches to automatically once its trial ends.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='billing.tier', verbose_name='post-trial tier'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='trial_ends_at',
|
||||||
|
field=models.DateField(blank=True, help_text='Set while this club is on a trial. The tier switches to post_trial_tier the next time a period is opened after this date.', null=True, verbose_name='trial ends at'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='subscription',
|
||||||
|
constraint=models.CheckConstraint(condition=models.Q(models.Q(('post_trial_tier__isnull', True), ('trial_ends_at__isnull', True)), models.Q(('post_trial_tier__isnull', False), ('trial_ends_at__isnull', False)), _connector='OR'), name='trial_fields_set_together'),
|
||||||
|
),
|
||||||
|
]
|
||||||
34
billing/migrations/0004_rename_tier_to_plan.py
Normal file
34
billing/migrations/0004_rename_tier_to_plan.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Rename Tier -> Plan, and every field that pointed at it.
|
||||||
|
|
||||||
|
Hand-written rather than generated. `makemigrations` only detects a rename by asking
|
||||||
|
interactively; run non-interactively it emits DeleteModel + CreateModel instead, which drops
|
||||||
|
every price, subscription and due in the table. RenameModel/RenameField preserve the data.
|
||||||
|
|
||||||
|
The two RemoveConstraints have to come FIRST. Both constraints name fields this migration is
|
||||||
|
about to rename (`tier`, `post_trial_tier`), and SQLite implements a rename by rebuilding the
|
||||||
|
table -- which re-renders every constraint on it. Left in place, the rebuild tries to emit a
|
||||||
|
constraint over a column that no longer exists under that name and dies with
|
||||||
|
FieldDoesNotExist. 0005 adds them back under the new field names.
|
||||||
|
|
||||||
|
Split from the field additions (0005) so this migration is pure renaming and can be read --
|
||||||
|
and if necessary reversed -- without any other change mixed into it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("billing", "0003_due_is_trial_subscription_post_trial_tier_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveConstraint(model_name="tierprice", name="unique_tier_price_per_start_date"),
|
||||||
|
migrations.RemoveConstraint(model_name="subscription", name="trial_fields_set_together"),
|
||||||
|
migrations.RenameModel(old_name="Tier", new_name="Plan"),
|
||||||
|
migrations.RenameModel(old_name="TierPrice", new_name="PlanPrice"),
|
||||||
|
migrations.RenameField(model_name="planprice", old_name="tier", new_name="plan"),
|
||||||
|
migrations.RenameField(model_name="subscription", old_name="tier", new_name="plan"),
|
||||||
|
migrations.RenameField(model_name="subscription", old_name="post_trial_tier", new_name="post_trial_plan"),
|
||||||
|
migrations.RenameField(model_name="due", old_name="tier", new_name="plan"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-08 16:30
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.db.models.expressions
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0004_rename_tier_to_plan'),
|
||||||
|
('club', '0020_sponsor_logo_height_sponsor_logo_width'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='plan',
|
||||||
|
options={'ordering': ['name'], 'verbose_name': 'plan', 'verbose_name_plural': 'plans'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='planprice',
|
||||||
|
options={'ordering': ['plan__name', '-active_from'], 'verbose_name': 'plan price', 'verbose_name_plural': 'plan prices'},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='duration_months',
|
||||||
|
field=models.PositiveSmallIntegerField(default=12, help_text='How long one billing period runs.', validators=[django.core.validators.MinValueValidator(1)], verbose_name='duration (months)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='grace_days',
|
||||||
|
field=models.PositiveSmallIntegerField(default=30, help_text='Days after a period starts before an unpaid club is archived.', verbose_name='grace (days)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='is_trial',
|
||||||
|
field=models.BooleanField(default=False, help_text='Offered as a trial rather than as a paid plan. A trial converts to the plan chosen on the subscription once it runs out.', verbose_name='trial plan'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='renewal_lead_days',
|
||||||
|
field=models.PositiveSmallIntegerField(default=30, help_text="Raise the next period's invoice this many days before that period starts.", verbose_name='renewal lead (days)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='due',
|
||||||
|
name='grace_until',
|
||||||
|
field=models.DateField(blank=True, help_text='Past this date an unpaid club is archived. Measured from the period start, not its end.', verbose_name='grace until'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='due',
|
||||||
|
name='plan',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dues', to='billing.plan', verbose_name='plan'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='plan',
|
||||||
|
name='is_active',
|
||||||
|
field=models.BooleanField(default=True, help_text='Inactive plans keep billing existing subscriptions but cannot be chosen for new ones.', verbose_name='active'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='planprice',
|
||||||
|
name='plan',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='billing.plan', verbose_name='plan'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='plan',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='billing.plan', verbose_name='plan'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='post_trial_plan',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='The plan this club switches to automatically once its trial ends.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='billing.plan', verbose_name='post-trial plan'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='trial_ends_at',
|
||||||
|
field=models.DateField(blank=True, help_text='Set while this club is on a trial. The plan switches to the post-trial plan the next time a period is opened after this date.', null=True, verbose_name='trial ends at'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='plan',
|
||||||
|
constraint=models.CheckConstraint(condition=models.Q(('renewal_lead_days__lt', django.db.models.expressions.CombinedExpression(models.F('duration_months'), '*', models.Value(28)))), name='renewal_lead_shorter_than_duration'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='plan',
|
||||||
|
constraint=models.CheckConstraint(condition=models.Q(('grace_days__lte', django.db.models.expressions.CombinedExpression(models.F('duration_months'), '*', models.Value(28)))), name='grace_no_longer_than_duration'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='planprice',
|
||||||
|
constraint=models.UniqueConstraint(fields=('plan', 'active_from'), name='unique_plan_price_per_start_date'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='subscription',
|
||||||
|
constraint=models.CheckConstraint(condition=models.Q(models.Q(('post_trial_plan__isnull', True), ('trial_ends_at__isnull', True)), models.Q(('post_trial_plan__isnull', False), ('trial_ends_at__isnull', False)), _connector='OR'), name='trial_fields_set_together'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-08 16:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0005_alter_plan_options_alter_planprice_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='due',
|
||||||
|
name='last_reminder_level',
|
||||||
|
field=models.CharField(blank=True, editable=False, max_length=20, verbose_name='last reminder level'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='due',
|
||||||
|
name='last_reminder_sent_at',
|
||||||
|
field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='last reminder sent at'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
billing/migrations/0007_plan_deleted_at.py
Normal file
18
billing/migrations/0007_plan_deleted_at.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-08 17:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0006_due_last_reminder_level_due_last_reminder_sent_at'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='deleted_at',
|
||||||
|
field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='deleted at'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
billing/migrations/__init__.py
Normal file
0
billing/migrations/__init__.py
Normal file
370
billing/models.py
Normal file
370
billing/models.py
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
"""What the platform charges a club.
|
||||||
|
|
||||||
|
Deliberately NOT club-scoped. `shop` is a club charging its members — tenant data, owned by
|
||||||
|
the club. This is RosterChief charging the club: platform-owned, and no club user ever sees
|
||||||
|
it. Nothing here inherits ClubScopedModel: these rows reference a Club, they are not owned
|
||||||
|
by one, and a tenant-scoped manager would be exactly the wrong default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from dateutil import relativedelta
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import F, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from rosterchief.base import UUIDModel, unique_slugify
|
||||||
|
|
||||||
|
ZERO = Decimal("0.00")
|
||||||
|
|
||||||
|
#: Conservative lower bound on the number of days in a month, used to express the plan's
|
||||||
|
#: clock invariants as CheckConstraints — month arithmetic is not available in SQL, and
|
||||||
|
#: under-counting is the safe direction for a guard rail.
|
||||||
|
DAYS_PER_MONTH_FLOOR = 28
|
||||||
|
|
||||||
|
# Defaults for a new plan, chosen to reproduce the annual billing the platform started with.
|
||||||
|
DEFAULT_DURATION_MONTHS = 12
|
||||||
|
DEFAULT_RENEWAL_LEAD_DAYS = 30
|
||||||
|
DEFAULT_GRACE_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def add_months(day: date, months: int) -> date:
|
||||||
|
return day + relativedelta.relativedelta(months=months)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanQuerySet(models.QuerySet):
|
||||||
|
def visible(self):
|
||||||
|
"""Excludes soft-deleted plans -- see billing.services.plans.delete_plan.
|
||||||
|
|
||||||
|
Opt-in, same shape as club.models.ClubManager.active(): the default manager stays
|
||||||
|
unfiltered (Django admin, and anything reading historical data, sees everything),
|
||||||
|
and every picker/listing a platform admin actually chooses from calls this.
|
||||||
|
"""
|
||||||
|
return self.filter(deleted_at__isnull=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Plan(UUIDModel):
|
||||||
|
"""What a club is billed on: a duration, a set of clocks, and a dated price.
|
||||||
|
|
||||||
|
The price itself lives in PlanPrice, which is dated. The three day/month numbers here
|
||||||
|
are the plan's *clocks*, and they are named for what they measure from — see BILLING.md
|
||||||
|
§3, because confusing them is the easy mistake:
|
||||||
|
|
||||||
|
* ``duration_months`` — how long a period runs, from its start.
|
||||||
|
* ``renewal_lead_days`` — how far BEFORE a period starts its invoice is raised.
|
||||||
|
* ``grace_days`` — how long AFTER a period starts it may remain unpaid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 plans keep billing existing subscriptions but cannot be chosen for new ones."))
|
||||||
|
|
||||||
|
duration_months = models.PositiveSmallIntegerField(_("duration (months)"), default=DEFAULT_DURATION_MONTHS, validators=[MinValueValidator(1)], help_text=_("How long one billing period runs."))
|
||||||
|
renewal_lead_days = models.PositiveSmallIntegerField(_("renewal lead (days)"), default=DEFAULT_RENEWAL_LEAD_DAYS, help_text=_("Raise the next period's invoice this many days before that period starts."))
|
||||||
|
grace_days = models.PositiveSmallIntegerField(_("grace (days)"), default=DEFAULT_GRACE_DAYS, help_text=_("Days after a period starts before an unpaid club is archived."))
|
||||||
|
|
||||||
|
is_trial = models.BooleanField(
|
||||||
|
_("trial plan"),
|
||||||
|
default=False,
|
||||||
|
help_text=_("Offered as a trial rather than as a paid plan. A trial converts to the plan chosen on the subscription once it runs out."),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Not user-editable: set by billing.services.plans.delete_plan. Due.plan is PROTECT, so
|
||||||
|
# a plan that has ever billed anyone can never actually be removed -- deleting it hides
|
||||||
|
# it (and clears every club currently on it) instead, so past invoices still say what
|
||||||
|
# they were billed under. See that module's docstring for the full reasoning.
|
||||||
|
deleted_at = models.DateTimeField(_("deleted at"), null=True, blank=True, editable=False)
|
||||||
|
|
||||||
|
objects = PlanQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("plan")
|
||||||
|
verbose_name_plural = _("plans")
|
||||||
|
ordering = ["name"]
|
||||||
|
constraints = [
|
||||||
|
# Lead longer than the period itself would raise the next invoice before the
|
||||||
|
# current period had even started, and periods would run away from the calendar.
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=Q(renewal_lead_days__lt=F("duration_months") * DAYS_PER_MONTH_FLOOR),
|
||||||
|
name="renewal_lead_shorter_than_duration",
|
||||||
|
),
|
||||||
|
# Grace longer than the period means the next period is issued while this one is
|
||||||
|
# still in grace: unpaid periods stack and the club is never archived.
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=Q(grace_days__lte=F("duration_months") * DAYS_PER_MONTH_FLOOR),
|
||||||
|
name="grace_no_longer_than_duration",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_deleted(self) -> bool:
|
||||||
|
return self.deleted_at is not None
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""The same two invariants the CheckConstraints enforce, as form errors.
|
||||||
|
|
||||||
|
Without this a form would hand the database an impossible plan and get back an
|
||||||
|
IntegrityError -- a 500 rather than "that lead is longer than the period".
|
||||||
|
"""
|
||||||
|
if not self.duration_months:
|
||||||
|
return
|
||||||
|
|
||||||
|
period_days = self.duration_months * DAYS_PER_MONTH_FLOOR
|
||||||
|
errors = {}
|
||||||
|
if self.renewal_lead_days is not None and self.renewal_lead_days >= period_days:
|
||||||
|
errors["renewal_lead_days"] = _("Must be shorter than the period itself (under %(days)s days for this duration), or the next invoice would be raised before the current period starts.") % {"days": period_days}
|
||||||
|
if self.grace_days is not None and self.grace_days > period_days:
|
||||||
|
errors["grace_days"] = _("Must not be longer than the period itself (at most %(days)s days for this duration), or unpaid periods stack up and the club is never archived.") % {"days": period_days}
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise ValidationError(errors)
|
||||||
|
|
||||||
|
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 plan 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 PlanPrice(UUIDModel):
|
||||||
|
"""A dated price for a plan.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name="prices", verbose_name=_("plan"))
|
||||||
|
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 = _("plan price")
|
||||||
|
verbose_name_plural = _("plan prices")
|
||||||
|
ordering = ["plan__name", "-active_from"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["plan", "active_from"], name="unique_plan_price_per_start_date"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.plan} — {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"))
|
||||||
|
plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("plan"))
|
||||||
|
auto_renew = models.BooleanField(_("auto renew"), default=True, help_text=_("Issue the next period automatically before this one ends. Off means you invoice this club by hand."))
|
||||||
|
auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period."))
|
||||||
|
notes = models.TextField(_("notes"), blank=True)
|
||||||
|
|
||||||
|
trial_ends_at = models.DateField(_("trial ends at"), null=True, blank=True, help_text=_("Set while this club is on a trial. The plan switches to the post-trial plan the next time a period is opened after this date."))
|
||||||
|
post_trial_plan = models.ForeignKey(Plan, on_delete=models.PROTECT, null=True, blank=True, related_name="+", verbose_name=_("post-trial plan"), help_text=_("The plan this club switches to automatically once its trial ends."))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("subscription")
|
||||||
|
verbose_name_plural = _("subscriptions")
|
||||||
|
ordering = ["club__name"]
|
||||||
|
constraints = [
|
||||||
|
# Both set together or neither -- a trial with no target plan (or a target
|
||||||
|
# plan with no trial end date) is a half-configured state nothing should read.
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=Q(trial_ends_at__isnull=True, post_trial_plan__isnull=True) | Q(trial_ends_at__isnull=False, post_trial_plan__isnull=False),
|
||||||
|
name="trial_fields_set_together",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.club} — {self.plan}"
|
||||||
|
|
||||||
|
|
||||||
|
class Due(UUIDModel):
|
||||||
|
"""One billing period for one club.
|
||||||
|
|
||||||
|
``plan`` and ``amount`` are snapshots taken when the period opens, never read back
|
||||||
|
through the plan 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.
|
||||||
|
|
||||||
|
``period_end`` and ``grace_until`` are snapshots for the same reason. They are stored as
|
||||||
|
*dates* rather than as the plan's duration/grace *numbers*, which is what makes editing a
|
||||||
|
plan afterwards leave every period already running exactly where it was.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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"))
|
||||||
|
plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name="dues", verbose_name=_("plan"))
|
||||||
|
|
||||||
|
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. Measured from the period start, not its end."))
|
||||||
|
|
||||||
|
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.UNPAID)
|
||||||
|
paid_at = models.DateTimeField(_("paid at"), null=True, blank=True)
|
||||||
|
|
||||||
|
is_trial = models.BooleanField(_("trial period"), default=False, help_text=_("This period was opened as a trial. A durable marker on the row itself -- the subscription's own trial fields are cleared once it converts."))
|
||||||
|
|
||||||
|
# Reminders are sent once per escalation level, not once per run: the cron job runs daily,
|
||||||
|
# and a club that owes money for a month must not get thirty identical emails. Storing the
|
||||||
|
# level last sent (rather than a date) means an escalation always gets through, and nothing
|
||||||
|
# else does. See billing/services/reminders.py.
|
||||||
|
last_reminder_level = models.CharField(_("last reminder level"), max_length=20, blank=True, editable=False)
|
||||||
|
last_reminder_sent_at = models.DateTimeField(_("last reminder sent at"), null=True, blank=True, editable=False)
|
||||||
|
|
||||||
|
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 for the plan's duration from its start, and the grace runs from that
|
||||||
|
# same start -- NOT from the period end. Measured from the end, a club would get the
|
||||||
|
# whole unpaid period plus the grace on top (~410 days on an annual plan) before
|
||||||
|
# anything switched it off. Derived here so no caller can open a period without them.
|
||||||
|
if not self.period_end:
|
||||||
|
self.period_end = add_months(self.period_start, self.plan.duration_months) - timedelta(days=1)
|
||||||
|
if not self.grace_until:
|
||||||
|
self.grace_until = self.period_start + timedelta(days=self.plan.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_issued_ahead(self, today: date | None = None) -> bool:
|
||||||
|
"""Billed and owing, but the period it covers has not started yet.
|
||||||
|
|
||||||
|
The gentlest of the three owing states: the invoice was raised during the plan's
|
||||||
|
renewal lead window, and nothing is late yet.
|
||||||
|
"""
|
||||||
|
today = today or timezone.localdate()
|
||||||
|
|
||||||
|
return self.is_owing and today < self.period_start
|
||||||
|
|
||||||
|
def is_in_grace(self, today: date | None = None) -> bool:
|
||||||
|
"""The period has started and is still unpaid, but is not archivable yet."""
|
||||||
|
today = today or timezone.localdate()
|
||||||
|
|
||||||
|
return self.is_owing and self.period_start <= 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
|
||||||
|
|
||||||
|
def days_until_archive(self, today: date | None = None) -> int:
|
||||||
|
"""Days left before this period makes the club archivable. Negative once past."""
|
||||||
|
today = today or timezone.localdate()
|
||||||
|
|
||||||
|
return (self.grace_until - today).days
|
||||||
|
|
||||||
|
|
||||||
|
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 plan 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.
|
||||||
|
"""
|
||||||
240
billing/services/dues.py
Normal file
240
billing/services/dues.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""The billing lifecycle. Views and the archive command go through here, never through the
|
||||||
|
models directly — a Due whose amount_paid disagrees with its payments is a wrong invoice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import DateField, OuterRef, Subquery, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from billing.models import ZERO, Due, DuePayment, Plan, Subscription, add_months
|
||||||
|
from billing.services import BillingError
|
||||||
|
from billing.services.invoices import issue_invoice
|
||||||
|
|
||||||
|
|
||||||
|
def subscribe(club, plan: Plan, *, start: date | None = None, auto_archive: bool = True, auto_renew: bool = True) -> Subscription:
|
||||||
|
"""Put a club on a plan and open its first period."""
|
||||||
|
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"plan": plan, "auto_archive": auto_archive, "auto_renew": auto_renew})
|
||||||
|
open_period(club, start=start)
|
||||||
|
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def start_trial(club, trial_plan: Plan, *, post_trial_plan: Plan, start: date | None = None, auto_renew: bool = True, auto_archive: bool = True) -> Due:
|
||||||
|
"""Put a club on a trial that switches itself to ``post_trial_plan`` the moment the trial
|
||||||
|
period is renewed -- see open_period()'s trial-conversion check.
|
||||||
|
|
||||||
|
The trial's length is the trial plan's own ``duration_months``: a 1-month and a 3-month
|
||||||
|
trial are two plans, not one plan plus a number passed at the call site.
|
||||||
|
|
||||||
|
Only for a club with no subscription yet -- converting an existing paying subscription
|
||||||
|
into a trial is a different, deliberately unsupported operation for now.
|
||||||
|
"""
|
||||||
|
if getattr(club, "subscription", None) is not None:
|
||||||
|
raise BillingError(f"{club} is already subscribed -- use Change plan instead.")
|
||||||
|
|
||||||
|
start = start or next_period_start(club)
|
||||||
|
trial_end = add_months(start, trial_plan.duration_months) - timedelta(days=1)
|
||||||
|
|
||||||
|
Subscription.objects.create(club=club, plan=trial_plan, trial_ends_at=trial_end, post_trial_plan=post_trial_plan, auto_renew=auto_renew, auto_archive=auto_archive)
|
||||||
|
|
||||||
|
return open_period(club, start=start, period_end=trial_end, is_trial=True)
|
||||||
|
|
||||||
|
|
||||||
|
def next_period_start(club, today: date | None = None) -> date:
|
||||||
|
"""Where the club's next period begins.
|
||||||
|
|
||||||
|
The day after the last one ended — not today. A club that pays two months late has still
|
||||||
|
used those two months, and restarting the clock at the payment date would quietly gift
|
||||||
|
them away. Callers can override; that is what the start field on the renew form is for.
|
||||||
|
"""
|
||||||
|
today = today or timezone.localdate()
|
||||||
|
last = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()
|
||||||
|
|
||||||
|
return last.period_end + timedelta(days=1) if last else today
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def open_period(club, *, start: date | None = None, plan: Plan | None = None, period_end: date | None = None, is_trial: bool = False) -> Due:
|
||||||
|
"""Issue the next due for a club, snapshotting the plan and the price of the day."""
|
||||||
|
subscription = getattr(club, "subscription", None)
|
||||||
|
if plan is None:
|
||||||
|
if subscription is None:
|
||||||
|
raise BillingError(f"{club} has no plan: put it on a subscription before billing it.")
|
||||||
|
# A trial that has run its course: swap onto the pre-selected plan before billing
|
||||||
|
# the next period, rather than silently renewing the trial plan forever. Checked
|
||||||
|
# here (not in renew()) so it fires whether this period was opened by the renewal
|
||||||
|
# command or by a platform admin clicking "Open period"/"Reactivate" by hand --
|
||||||
|
# both call open_period() directly.
|
||||||
|
if subscription.trial_ends_at is not None and (start or next_period_start(club)) > subscription.trial_ends_at:
|
||||||
|
subscription.plan = subscription.post_trial_plan
|
||||||
|
subscription.trial_ends_at = None
|
||||||
|
subscription.post_trial_plan = None
|
||||||
|
subscription.save(update_fields=["plan", "trial_ends_at", "post_trial_plan"])
|
||||||
|
plan = subscription.plan
|
||||||
|
|
||||||
|
start = start or next_period_start(club)
|
||||||
|
|
||||||
|
amount = plan.price_on(start)
|
||||||
|
if amount is None:
|
||||||
|
raise BillingError(f"{plan} 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, plan=plan, amount=amount, period_start=start, period_end=period_end, is_trial=is_trial)
|
||||||
|
if amount == ZERO:
|
||||||
|
# Nothing is actually owed -- left at the default UNPAID, this would eventually
|
||||||
|
# trip is_overdue() and get a free club archived for non-payment of nothing.
|
||||||
|
due.status = Due.Status.PAID
|
||||||
|
due.paid_at = timezone.now()
|
||||||
|
due.save(update_fields=["status", "paid_at"])
|
||||||
|
issue_invoice(due) # every period is billable the moment it opens
|
||||||
|
|
||||||
|
return due
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def record_payment(due: Due, amount: Decimal, *, method=DuePayment.Method.BANK_TRANSFER, reference: str = "", paid_at=None, note: str = "", user=None) -> DuePayment:
|
||||||
|
"""Log money against a due and re-derive its status from the payments."""
|
||||||
|
if due.status in (Due.Status.WAIVED, Due.Status.CANCELLED):
|
||||||
|
raise BillingError(f"This period is {due.get_status_display()}; it cannot take a payment.")
|
||||||
|
if amount <= ZERO:
|
||||||
|
raise BillingError("A payment must be for a positive amount.")
|
||||||
|
|
||||||
|
payment = DuePayment.objects.create(due=due, amount=amount, method=method, reference=reference, paid_at=paid_at or timezone.now(), note=note, recorded_by=user)
|
||||||
|
_resettle(due)
|
||||||
|
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def remove_payment(payment: DuePayment) -> None:
|
||||||
|
"""Undo a mis-keyed payment, then re-derive the due from what is left."""
|
||||||
|
due = payment.due
|
||||||
|
payment.delete()
|
||||||
|
_resettle(due)
|
||||||
|
|
||||||
|
|
||||||
|
def _resettle(due: Due) -> None:
|
||||||
|
"""Recompute amount_paid and status from the payments on record.
|
||||||
|
|
||||||
|
Summed from the payments rather than incremented: an increment drifts the moment a
|
||||||
|
payment is edited or deleted, and the drift is invisible — the number still looks like
|
||||||
|
money.
|
||||||
|
"""
|
||||||
|
paid = due.payments.aggregate(total=Sum("amount"))["total"] or ZERO
|
||||||
|
|
||||||
|
due.amount_paid = paid
|
||||||
|
if paid >= due.amount:
|
||||||
|
due.status = Due.Status.PAID
|
||||||
|
due.paid_at = due.payments.order_by("-paid_at").first().paid_at
|
||||||
|
elif paid > ZERO:
|
||||||
|
due.status = Due.Status.PARTIAL
|
||||||
|
due.paid_at = None
|
||||||
|
else:
|
||||||
|
due.status = Due.Status.UNPAID
|
||||||
|
due.paid_at = None
|
||||||
|
due.save(update_fields=["amount_paid", "status", "paid_at", "modified"])
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def waive(due: Due, *, note: str = "") -> Due:
|
||||||
|
"""Write a period off. It stops owing, and stops counting towards archiving."""
|
||||||
|
if due.payments.exists():
|
||||||
|
raise BillingError("This period has payments against it; remove them before waiving it.")
|
||||||
|
|
||||||
|
due.status = Due.Status.WAIVED
|
||||||
|
due.save(update_fields=["status", "modified"])
|
||||||
|
|
||||||
|
return due
|
||||||
|
|
||||||
|
|
||||||
|
def owing_dues():
|
||||||
|
return Due.objects.filter(status__in=Due.OWING)
|
||||||
|
|
||||||
|
|
||||||
|
def dues_in_grace(today: date | None = None):
|
||||||
|
"""Period started, unpaid, not yet archivable.
|
||||||
|
|
||||||
|
Bounded below by ``period_start``, not ``period_end``: grace now runs from the start of
|
||||||
|
the period, so a due is in grace *during* the period it covers, not after it.
|
||||||
|
"""
|
||||||
|
today = today or timezone.localdate()
|
||||||
|
|
||||||
|
return owing_dues().filter(period_start__lte=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", "plan").order_by("club__name")
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def reactivate(club, *, start: date | None = None) -> Due:
|
||||||
|
"""Bring an archived club back and bill it again.
|
||||||
|
|
||||||
|
The new period defaults to continuing from the last one, so a lapsed year is still owed.
|
||||||
|
Pass ``start`` to forgive the gap and begin today instead.
|
||||||
|
"""
|
||||||
|
club.restore()
|
||||||
|
|
||||||
|
return open_period(club, start=start)
|
||||||
|
|
||||||
|
|
||||||
|
def subscriptions_due_for_renewal(today: date | None = None, lead_days: int | None = None):
|
||||||
|
"""Clubs whose next period should be issued now.
|
||||||
|
|
||||||
|
Each plan sets its own ``renewal_lead_days``: a single global lead is silently annual-only,
|
||||||
|
and on a 1-month plan a 30-day lead would issue the next period before the current one had
|
||||||
|
started. ``lead_days`` overrides every plan's own value — that is what makes a rehearsal or
|
||||||
|
a backfill possible, and it is what the command's --lead-days flag passes.
|
||||||
|
|
||||||
|
The per-plan comparison is done in Python rather than SQL. The function already
|
||||||
|
materialised its result as a list, and date arithmetic against a field value is not
|
||||||
|
portably expressible across SQLite and Postgres; at platform scale (tens to low hundreds of
|
||||||
|
clubs) this is one query plus a list walk.
|
||||||
|
|
||||||
|
Idempotent by construction: a club that has just been renewed has a latest period ending a
|
||||||
|
full duration out, which is past its horizon, so it cannot be picked up twice. Running the
|
||||||
|
job twice a day is harmless.
|
||||||
|
|
||||||
|
A subscription with no period at all (its only due was cancelled) counts too — a club on a
|
||||||
|
plan and billed for nothing is the leak this whole job exists to close.
|
||||||
|
"""
|
||||||
|
today = today or timezone.localdate()
|
||||||
|
|
||||||
|
latest_period_end = Subquery(
|
||||||
|
Due.objects.filter(club=OuterRef("club")).exclude(status=Due.Status.CANCELLED).order_by("-period_end").values("period_end")[:1],
|
||||||
|
output_field=DateField(),
|
||||||
|
)
|
||||||
|
|
||||||
|
subscriptions = Subscription.objects.filter(auto_renew=True, club__archived_at__isnull=True).select_related("club", "plan").annotate(latest_period_end=latest_period_end).order_by("club__name")
|
||||||
|
|
||||||
|
def is_due(subscription) -> bool:
|
||||||
|
if subscription.latest_period_end is None:
|
||||||
|
return True
|
||||||
|
lead = subscription.plan.renewal_lead_days if lead_days is None else lead_days
|
||||||
|
|
||||||
|
return subscription.latest_period_end <= today + timedelta(days=lead)
|
||||||
|
|
||||||
|
return [subscription for subscription in subscriptions if is_due(subscription)]
|
||||||
|
|
||||||
|
|
||||||
|
def renew(subscription: Subscription) -> Due:
|
||||||
|
"""Open the club's next period, continuing from the last one."""
|
||||||
|
return open_period(subscription.club)
|
||||||
40
billing/services/invoices.py
Normal file
40
billing/services/invoices.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Invoice PDFs.
|
||||||
|
|
||||||
|
The PDF is rendered on demand from the Due's frozen snapshot (plan, 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)
|
||||||
77
billing/services/notices.py
Normal file
77
billing/services/notices.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""What a club's own admins are told about money they owe the platform.
|
||||||
|
|
||||||
|
Separate from dues.py because the audience is different: everything in dues.py is read by
|
||||||
|
platform staff in the control panel, and this is the one piece of billing a *club* sees. It
|
||||||
|
returns data, never rendered text — the wording lives in the template so it can be translated,
|
||||||
|
and the same notice feeds both the on-screen banner and the reminder email.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from billing.models import Due
|
||||||
|
|
||||||
|
#: Inside this many days of being archived, the notice stops being a warning and becomes a
|
||||||
|
#: final one — which is also the point at which it follows the admin onto every page.
|
||||||
|
URGENT_DAYS = 7
|
||||||
|
|
||||||
|
INFO = "info"
|
||||||
|
WARNING = "warning"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BillingNotice:
|
||||||
|
"""The single most pressing thing a club owes, and how alarmed to be about it."""
|
||||||
|
|
||||||
|
level: str
|
||||||
|
due: Due
|
||||||
|
amount_outstanding: Decimal
|
||||||
|
period_start: date
|
||||||
|
grace_until: date
|
||||||
|
days_until_archive: int
|
||||||
|
#: False when the subscription has auto_archive off. Money is still owed and still worth
|
||||||
|
#: saying so, but the countdown must not claim an archiving that will never happen.
|
||||||
|
will_archive: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_urgent(self) -> bool:
|
||||||
|
return self.level == ERROR
|
||||||
|
|
||||||
|
|
||||||
|
def club_billing_notice(club, today: date | None = None) -> BillingNotice | None:
|
||||||
|
"""The notice for ``club``, or None when it owes nothing.
|
||||||
|
|
||||||
|
Picks the due with the earliest ``grace_until`` when several are owing: that is the one
|
||||||
|
that will archive the club first, so it is the one worth shouting about.
|
||||||
|
"""
|
||||||
|
today = today or timezone.localdate()
|
||||||
|
|
||||||
|
due = club.dues.filter(status__in=Due.OWING).select_related("plan").order_by("grace_until").first()
|
||||||
|
if due is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
subscription = getattr(club, "subscription", None)
|
||||||
|
will_archive = subscription.auto_archive if subscription is not None else False
|
||||||
|
days_left = due.days_until_archive(today)
|
||||||
|
|
||||||
|
if due.is_overdue(today):
|
||||||
|
level = ERROR
|
||||||
|
elif due.is_in_grace(today):
|
||||||
|
level = ERROR if days_left <= URGENT_DAYS else WARNING
|
||||||
|
else:
|
||||||
|
# Issued during the plan's renewal lead window: billed, but nothing is late yet.
|
||||||
|
level = INFO
|
||||||
|
|
||||||
|
return BillingNotice(
|
||||||
|
level=level,
|
||||||
|
due=due,
|
||||||
|
amount_outstanding=due.balance,
|
||||||
|
period_start=due.period_start,
|
||||||
|
grace_until=due.grace_until,
|
||||||
|
days_until_archive=days_left,
|
||||||
|
will_archive=will_archive,
|
||||||
|
)
|
||||||
85
billing/services/plans.py
Normal file
85
billing/services/plans.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Deleting a plan.
|
||||||
|
|
||||||
|
Due.plan is PROTECT -- a plan that has ever billed anyone can never truly be removed, and
|
||||||
|
must not be: `amount`, `period_end` and `grace_until` are frozen on a Due precisely so a
|
||||||
|
later change can't rewrite what was actually charged, and losing the plan link off an old
|
||||||
|
Due would do exactly that to every historical invoice. So "delete" means one of two things,
|
||||||
|
chosen automatically depending on whether the plan has billing history:
|
||||||
|
|
||||||
|
* No Due ever referenced it (created, then never actually used to bill anyone) -- the row
|
||||||
|
itself is removed.
|
||||||
|
* At least one Due references it -- soft-deleted instead (Plan.deleted_at set, is_active
|
||||||
|
turned off): hidden from every picker and listing (Plan.objects.visible()), but the row
|
||||||
|
survives so every old invoice still says what it was for.
|
||||||
|
|
||||||
|
Either way, every club CURRENTLY on the plan is unsubscribed outright -- its Subscription
|
||||||
|
row deleted, not just its `plan` field cleared. "No plan" is already a state the rest of the
|
||||||
|
app fully understands (every billing view already handles `getattr(club, "subscription",
|
||||||
|
None)` being None), so there is no new state to teach it.
|
||||||
|
|
||||||
|
A second, easy-to-miss group: a club on a DIFFERENT plan, mid-trial, configured to convert
|
||||||
|
to THIS plan once its trial ends (Subscription.post_trial_plan). Deleting the target plan
|
||||||
|
out from under that trial can't be allowed to raise a stale IntegrityError days or weeks
|
||||||
|
later when the trial tries to convert -- so it's handled now, at delete time: that club's
|
||||||
|
trial is ended (trial_ends_at and post_trial_plan both cleared, per the CheckConstraint that
|
||||||
|
requires them set together or not at all), leaving it on the trial plan with no scheduled
|
||||||
|
conversion until a platform admin picks a new one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from billing.models import Plan, Subscription
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlanDeletionImpact:
|
||||||
|
plan: Plan
|
||||||
|
#: Clubs currently ON this plan -- lose it entirely (Subscription row deleted).
|
||||||
|
unsubscribed_clubs: list
|
||||||
|
#: Clubs on a different plan, mid-trial, configured to convert to this one -- their
|
||||||
|
#: trial's landing plan is cleared, leaving no scheduled conversion.
|
||||||
|
broken_trial_clubs: list
|
||||||
|
#: Whether the Plan row itself will be removed (True) or only hidden (False, because
|
||||||
|
#: it has billing history).
|
||||||
|
will_hard_delete: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_impact(self) -> bool:
|
||||||
|
return bool(self.unsubscribed_clubs or self.broken_trial_clubs)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_deletion_impact(plan: Plan) -> PlanDeletionImpact:
|
||||||
|
"""What deleting `plan` right now would do -- read-only, for the confirmation screen.
|
||||||
|
|
||||||
|
delete_plan() re-derives the same two lists itself rather than trust one computed here
|
||||||
|
moments earlier and possibly stale by the time the platform admin actually confirms.
|
||||||
|
"""
|
||||||
|
unsubscribed = Subscription.objects.filter(plan=plan).select_related("club").order_by("club__name")
|
||||||
|
broken_trial = Subscription.objects.filter(post_trial_plan=plan).exclude(plan=plan).select_related("club").order_by("club__name")
|
||||||
|
|
||||||
|
return PlanDeletionImpact(
|
||||||
|
plan=plan,
|
||||||
|
unsubscribed_clubs=[subscription.club for subscription in unsubscribed],
|
||||||
|
broken_trial_clubs=[subscription.club for subscription in broken_trial],
|
||||||
|
will_hard_delete=not plan.dues.exists(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def delete_plan(plan: Plan) -> PlanDeletionImpact:
|
||||||
|
impact = plan_deletion_impact(plan)
|
||||||
|
|
||||||
|
Subscription.objects.filter(plan=plan).delete()
|
||||||
|
Subscription.objects.filter(post_trial_plan=plan).update(trial_ends_at=None, post_trial_plan=None)
|
||||||
|
|
||||||
|
if impact.will_hard_delete:
|
||||||
|
plan.delete()
|
||||||
|
else:
|
||||||
|
plan.deleted_at = timezone.now()
|
||||||
|
plan.is_active = False
|
||||||
|
plan.save(update_fields=["deleted_at", "is_active", "modified"])
|
||||||
|
|
||||||
|
return impact
|
||||||
98
billing/services/reminders.py
Normal file
98
billing/services/reminders.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Emailing a club's admins about money it owes the platform.
|
||||||
|
|
||||||
|
Built on the same BillingNotice the on-screen banner uses (notices.py), so the email and the
|
||||||
|
banner can never disagree about how much is owed or how long is left.
|
||||||
|
|
||||||
|
**Sent once per escalation level, not once per run.** The command is on a daily cron; a club
|
||||||
|
that owes money for a month must not receive thirty identical emails. ``Due.last_reminder_level``
|
||||||
|
records the level last mailed, so an escalation (info -> warning -> error) always gets through
|
||||||
|
and a repeat of the same level never does.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from billing.services.notices import BillingNotice, club_billing_notice
|
||||||
|
from club.models import ClubRole
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReminderResult:
|
||||||
|
club: object
|
||||||
|
notice: BillingNotice
|
||||||
|
recipients: list[str]
|
||||||
|
sent: bool
|
||||||
|
skipped_reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def admin_emails(club) -> list[str]:
|
||||||
|
"""Every club admin we can actually reach, de-duplicated and order-stable.
|
||||||
|
|
||||||
|
A club with admins but no email addresses returns empty — the caller reports that rather
|
||||||
|
than silently counting it as reminded.
|
||||||
|
"""
|
||||||
|
roles = ClubRole.objects.filter(club=club, role=ClubRole.Roles.ADMIN).select_related("member", "member__user").order_by("member__last_name", "member__first_name")
|
||||||
|
|
||||||
|
seen, emails = set(), []
|
||||||
|
for role in roles:
|
||||||
|
email = role.member.contact_email
|
||||||
|
if email and email not in seen:
|
||||||
|
seen.add(email)
|
||||||
|
emails.append(email)
|
||||||
|
|
||||||
|
return emails
|
||||||
|
|
||||||
|
|
||||||
|
def needs_reminder(due, notice: BillingNotice) -> bool:
|
||||||
|
"""True when this due has not yet been mailed at its current level."""
|
||||||
|
return due.last_reminder_level != notice.level
|
||||||
|
|
||||||
|
|
||||||
|
def send_reminder(club, notice: BillingNotice, *, recipients: list[str]) -> None:
|
||||||
|
"""Render and send one reminder, then record the level so it is not repeated."""
|
||||||
|
context = {
|
||||||
|
"club": club,
|
||||||
|
"notice": notice,
|
||||||
|
"due": notice.due,
|
||||||
|
"billing_contact": settings.BILLING_CONTACT_EMAIL,
|
||||||
|
}
|
||||||
|
subject = render_to_string("billing/email/reminder_subject.txt", context).strip()
|
||||||
|
text_body = render_to_string("billing/email/reminder.txt", context)
|
||||||
|
|
||||||
|
message = EmailMultiAlternatives(subject=subject, body=text_body, from_email=settings.DEFAULT_FROM_EMAIL, to=recipients)
|
||||||
|
message.send(fail_silently=False)
|
||||||
|
|
||||||
|
notice.due.last_reminder_level = notice.level
|
||||||
|
notice.due.last_reminder_sent_at = timezone.now()
|
||||||
|
notice.due.save(update_fields=["last_reminder_level", "last_reminder_sent_at", "modified"])
|
||||||
|
|
||||||
|
|
||||||
|
def reminders_to_send(clubs, today=None, *, force: bool = False) -> list[ReminderResult]:
|
||||||
|
"""Work out who would be reminded, without sending anything.
|
||||||
|
|
||||||
|
Returned whether or not each one is actually sendable, so the command can report a club
|
||||||
|
with no reachable admin instead of skipping it in silence — an unreachable club is exactly
|
||||||
|
the one that gets archived without ever having been told.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for club in clubs:
|
||||||
|
notice = club_billing_notice(club, today)
|
||||||
|
if notice is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
recipients = admin_emails(club)
|
||||||
|
if not recipients:
|
||||||
|
results.append(ReminderResult(club=club, notice=notice, recipients=[], sent=False, skipped_reason=_("no club admin with an email address")))
|
||||||
|
continue
|
||||||
|
if not force and not needs_reminder(notice.due, notice):
|
||||||
|
results.append(ReminderResult(club=club, notice=notice, recipients=recipients, sent=False, skipped_reason=_("already reminded at this level")))
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append(ReminderResult(club=club, notice=notice, recipients=recipients, sent=True))
|
||||||
|
|
||||||
|
return results
|
||||||
92
billing/tasks.py
Normal file
92
billing/tasks.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Celery tasks behind the billing beat schedule entries (see
|
||||||
|
rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py).
|
||||||
|
|
||||||
|
Each mirrors its management command's *acting* behaviour exactly -- manage.py's own
|
||||||
|
--dry-run/--commit flags exist for a human at a terminal to preview first (see
|
||||||
|
billing/management/commands/), which a beat schedule has no terminal to do. These always
|
||||||
|
act, the same as the crontab entries they replace always passed --commit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from billing.services import BillingError
|
||||||
|
from billing.services.dues import archivable_clubs, renew, subscriptions_due_for_renewal
|
||||||
|
from billing.services.reminders import reminders_to_send, send_reminder
|
||||||
|
from club.models import Club
|
||||||
|
from features.models import JobToggle, Maintenance
|
||||||
|
|
||||||
|
|
||||||
|
def _stand_down():
|
||||||
|
# Loud, not silent -- see events/tasks.py for why these raise instead of skipping quietly.
|
||||||
|
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_enabled(name):
|
||||||
|
if not JobToggle.is_enabled(name):
|
||||||
|
raise RuntimeError("This job is disabled in the control panel.")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="billing.tasks.renew_subscriptions")
|
||||||
|
def renew_subscriptions():
|
||||||
|
if Maintenance.is_on():
|
||||||
|
_stand_down()
|
||||||
|
_check_enabled("billing.tasks.renew_subscriptions")
|
||||||
|
|
||||||
|
due_for_renewal = subscriptions_due_for_renewal()
|
||||||
|
if not due_for_renewal:
|
||||||
|
return "Nothing to renew."
|
||||||
|
|
||||||
|
renewed, failures = 0, []
|
||||||
|
for subscription in due_for_renewal:
|
||||||
|
try:
|
||||||
|
renew(subscription)
|
||||||
|
renewed += 1
|
||||||
|
except BillingError as error:
|
||||||
|
# One unpriced plan must not stop every other club from being billed.
|
||||||
|
failures.append(f"{subscription.club}: {error}")
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
raise RuntimeError(f"Renewed {renewed} club(s), {len(failures)} failed:\n " + "\n ".join(failures))
|
||||||
|
|
||||||
|
return f"Renewed {renewed} club(s)."
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="billing.tasks.send_billing_reminders")
|
||||||
|
def send_billing_reminders():
|
||||||
|
if Maintenance.is_on():
|
||||||
|
_stand_down()
|
||||||
|
_check_enabled("billing.tasks.send_billing_reminders")
|
||||||
|
|
||||||
|
clubs = Club.objects.active().select_related("subscription", "subscription__plan").order_by("name")
|
||||||
|
sendable = [result for result in reminders_to_send(clubs) if result.sent]
|
||||||
|
|
||||||
|
if not sendable:
|
||||||
|
return "Nothing owing. No reminders to send."
|
||||||
|
|
||||||
|
sent, failures = 0, []
|
||||||
|
for result in sendable:
|
||||||
|
try:
|
||||||
|
send_reminder(result.club, result.notice, recipients=result.recipients)
|
||||||
|
sent += 1
|
||||||
|
except OSError as error:
|
||||||
|
# One bad address or a momentary SMTP failure must not stop the rest of the run.
|
||||||
|
failures.append(f"{result.club}: {error}")
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
raise RuntimeError(f"Sent {sent} reminder(s), {len(failures)} failed:\n " + "\n ".join(failures))
|
||||||
|
|
||||||
|
return f"Sent {sent} reminder(s)."
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="billing.tasks.archive_overdue_clubs")
|
||||||
|
def archive_overdue_clubs():
|
||||||
|
if Maintenance.is_on():
|
||||||
|
_stand_down()
|
||||||
|
_check_enabled("billing.tasks.archive_overdue_clubs")
|
||||||
|
|
||||||
|
overdue = list(archivable_clubs(timezone.localdate()))
|
||||||
|
for due in overdue:
|
||||||
|
due.club.archive()
|
||||||
|
|
||||||
|
return f"Archived {len(overdue)} club(s)."
|
||||||
14
billing/templates/billing/email/reminder.txt
Normal file
14
billing/templates/billing/email/reminder.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% load i18n %}{% blocktrans with club=club.name %}Hello,
|
||||||
|
|
||||||
|
This is a reminder about the RosterChief platform fees for {{ club }}.{% endblocktrans %}
|
||||||
|
|
||||||
|
{% blocktrans with amount=notice.amount_outstanding %}Outstanding: EUR {{ amount }}{% endblocktrans %}
|
||||||
|
{% blocktrans with start=due.period_start|date:"j M Y" end=due.period_end|date:"j M Y" %}Period: {{ start }} to {{ end }}{% endblocktrans %}
|
||||||
|
{% if due.invoice %}{% blocktrans with number=due.invoice.number %}Invoice: {{ number }}{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
{% if notice.will_archive %}{% if notice.days_until_archive < 0 %}{% trans "This club is now past its payment deadline and is due to be archived. While archived, nobody can sign in and the club's site stops resolving. No data is deleted, and access is restored as soon as payment is received." %}{% else %}{% blocktrans count days=notice.days_until_archive %}If we do not receive payment within {{ days }} day, this club will be archived. While archived, nobody can sign in and the club's site stops resolving. No data is deleted.{% plural %}If we do not receive payment within {{ days }} days, this club will be archived. While archived, nobody can sign in and the club's site stops resolving. No data is deleted.{% endblocktrans %}{% endif %}{% else %}{% trans "Please settle this to keep your account in good standing." %}{% endif %}
|
||||||
|
|
||||||
|
{% blocktrans with contact=billing_contact %}If you have already paid, or you think this is a mistake, reply to {{ contact }} and we will sort it out.{% endblocktrans %}
|
||||||
|
|
||||||
|
{% trans "Thank you," %}
|
||||||
|
{% trans "RosterChief" %}
|
||||||
1
billing/templates/billing/email/reminder_subject.txt
Normal file
1
billing/templates/billing/email/reminder_subject.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{% load i18n %}{% if notice.level == 'error' and notice.will_archive %}{% blocktrans with club=club.name %}Action required: {{ club }} is about to be archived{% endblocktrans %}{% else %}{% blocktrans with club=club.name %}Platform fees are due for {{ club }}{% endblocktrans %}{% endif %}
|
||||||
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.official_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.plan.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>
|
||||||
980
billing/tests.py
Normal file
980
billing/tests.py
Normal file
@@ -0,0 +1,980 @@
|
|||||||
|
import datetime
|
||||||
|
import sys
|
||||||
|
from decimal import Decimal
|
||||||
|
from io import StringIO
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
from django.db.utils import IntegrityError
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from authentication.models import User
|
||||||
|
from club.models import Club, ClubRole
|
||||||
|
from members.models import Member
|
||||||
|
|
||||||
|
from .models import DEFAULT_DURATION_MONTHS, DEFAULT_GRACE_DAYS, DEFAULT_RENEWAL_LEAD_DAYS, Due, Invoice, Plan, PlanPrice, Subscription, add_months
|
||||||
|
from .services import BillingError
|
||||||
|
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, renew, start_trial, subscribe, subscriptions_due_for_renewal, waive
|
||||||
|
from .services.invoices import invoice_pdf, issue_invoice, render_pdf
|
||||||
|
from .services.notices import club_billing_notice
|
||||||
|
from .services.plans import delete_plan, plan_deletion_impact
|
||||||
|
from .services.reminders import admin_emails, reminders_to_send, send_reminder
|
||||||
|
|
||||||
|
|
||||||
|
class BillingTestBase(TestCase):
|
||||||
|
# setUpTestData, not setUp: the club and the priced plan are read-only scaffolding for
|
||||||
|
# every subclass, so they are built once per class. Django hands each test its own deep
|
||||||
|
# copy and rolls the database back afterwards, so the tests that archive the club or
|
||||||
|
# soft-delete the plan still start from a clean slate.
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.today = timezone.localdate()
|
||||||
|
cls.club = Club.objects.create(name="Ajax United")
|
||||||
|
cls.plan = Plan.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.
|
||||||
|
PlanPrice.objects.create(plan=cls.plan, active_from=cls.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, plan=self.plan)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanPriceTests(BillingTestBase):
|
||||||
|
def test_the_price_in_force_is_the_latest_one_that_has_started(self):
|
||||||
|
PlanPrice.objects.create(plan=self.plan, active_from=self.today, amount=Decimal("600.00"))
|
||||||
|
|
||||||
|
self.assertEqual(self.plan.price_on(self.today - datetime.timedelta(days=1)), Decimal("500.00"))
|
||||||
|
self.assertEqual(self.plan.price_on(self.today), Decimal("600.00"))
|
||||||
|
|
||||||
|
def test_a_future_price_does_not_apply_yet(self):
|
||||||
|
PlanPrice.objects.create(plan=self.plan, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
|
||||||
|
|
||||||
|
self.assertEqual(self.plan.price_on(self.today), Decimal("500.00"))
|
||||||
|
|
||||||
|
def test_a_plan_with_no_price_yet_cannot_be_billed(self):
|
||||||
|
# None must never be read as free.
|
||||||
|
empty = Plan.objects.create(name="Enterprise")
|
||||||
|
|
||||||
|
self.assertIsNone(empty.price_on(self.today))
|
||||||
|
|
||||||
|
with self.assertRaises(BillingError):
|
||||||
|
open_period(self.club, plan=empty)
|
||||||
|
|
||||||
|
|
||||||
|
class PeriodTests(BillingTestBase):
|
||||||
|
def test_a_period_runs_for_the_plans_duration(self):
|
||||||
|
due = self.bill(start=datetime.date(2026, 3, 1))
|
||||||
|
|
||||||
|
self.assertEqual(due.period_end, datetime.date(2027, 2, 28))
|
||||||
|
|
||||||
|
def test_grace_is_measured_from_the_period_start_not_its_end(self):
|
||||||
|
# The whole point of the redesign: measured from the end, an annual club would get
|
||||||
|
# ~410 days of unpaid use before anything switched it off.
|
||||||
|
due = self.bill(start=datetime.date(2026, 3, 1))
|
||||||
|
|
||||||
|
self.assertEqual(due.grace_until, datetime.date(2026, 3, 1) + datetime.timedelta(days=DEFAULT_GRACE_DAYS))
|
||||||
|
self.assertLess(due.grace_until, due.period_end)
|
||||||
|
|
||||||
|
def test_a_leap_day_period_does_not_explode(self):
|
||||||
|
# 29 February has no counterpart in a common year.
|
||||||
|
self.assertEqual(add_months(datetime.date(2028, 2, 29), 12), 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()
|
||||||
|
PlanPrice.objects.create(plan=self.plan, 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_plan_cannot_be_billed(self):
|
||||||
|
with self.assertRaises(BillingError):
|
||||||
|
open_period(Club.objects.create(name="Feyenoord"))
|
||||||
|
|
||||||
|
def test_subscribing_puts_a_club_on_a_plan_and_opens_a_period(self):
|
||||||
|
club = Club.objects.create(name="Feyenoord")
|
||||||
|
|
||||||
|
subscribe(club, self.plan)
|
||||||
|
|
||||||
|
self.assertEqual(Subscription.objects.get(club=club).plan, self.plan)
|
||||||
|
self.assertEqual(club.dues.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentTests(BillingTestBase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
super().setUpTestData()
|
||||||
|
cls.due = open_period(cls.club, plan=cls.plan)
|
||||||
|
|
||||||
|
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 = DEFAULT_GRACE_DAYS + 10
|
||||||
|
|
||||||
|
def test_a_started_but_unpaid_period_inside_grace_is_in_grace(self):
|
||||||
|
# Grace runs from the period START now, so this is a period that began a few days
|
||||||
|
# ago and has not been paid -- not one that has already run its full length.
|
||||||
|
due = self.bill(start=self.today - datetime.timedelta(days=5))
|
||||||
|
|
||||||
|
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_issued_ahead_of_its_start_is_not_yet_in_grace(self):
|
||||||
|
due = self.bill(start=self.today + datetime.timedelta(days=10))
|
||||||
|
|
||||||
|
self.assertTrue(due.is_issued_ahead(self.today))
|
||||||
|
self.assertFalse(due.is_in_grace(self.today))
|
||||||
|
self.assertFalse(due.is_overdue(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.plan, 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.plan, 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.plan, start=self.today - datetime.timedelta(days=self.LAPSED))
|
||||||
|
self.club.archive()
|
||||||
|
|
||||||
|
self.assertEqual(archivable_clubs(self.today).count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveCommandTests(BillingTestBase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
super().setUpTestData()
|
||||||
|
subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=DEFAULT_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):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
super().setUpTestData()
|
||||||
|
# Through subscribe(), not open_period(): reactivating reads the club's plan off its
|
||||||
|
# subscription, and a club billed without one cannot be re-billed later.
|
||||||
|
subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=400))
|
||||||
|
cls.first = cls.club.dues.first()
|
||||||
|
cls.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"), plan=self.plan).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_invoice_is_billed_to_the_clubs_legal_name_when_set(self):
|
||||||
|
self.club.legal_name = "Ajax United VZW"
|
||||||
|
self.club.save(update_fields=["legal_name"])
|
||||||
|
due = self.bill()
|
||||||
|
|
||||||
|
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("Ajax United VZW", html)
|
||||||
|
|
||||||
|
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.plan), "Standard")
|
||||||
|
self.assertIn("500.00", str(self.plan.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.plan)))
|
||||||
|
|
||||||
|
|
||||||
|
class RenewalTests(BillingTestBase):
|
||||||
|
"""The leak this closes: a club whose period lapses with its last due PAID owes nothing,
|
||||||
|
so dues_overdue() is empty, so archive_overdue_clubs never fires — and the club keeps
|
||||||
|
using the platform for free while every number on the dashboard stays green."""
|
||||||
|
|
||||||
|
def ending_in(self, days, **kwargs):
|
||||||
|
"""A club whose current period ends `days` from now."""
|
||||||
|
club = Club.objects.create(name=f"Club {days}")
|
||||||
|
subscribe(club, self.plan, start=self.today - datetime.timedelta(days=365 - days), **kwargs)
|
||||||
|
return club
|
||||||
|
|
||||||
|
def test_a_club_nearing_its_end_date_is_picked_up(self):
|
||||||
|
club = self.ending_in(20)
|
||||||
|
|
||||||
|
due = [s.club for s in subscriptions_due_for_renewal()]
|
||||||
|
|
||||||
|
self.assertIn(club, due)
|
||||||
|
|
||||||
|
def test_a_club_with_a_period_beyond_the_horizon_is_left_alone(self):
|
||||||
|
club = self.ending_in(200)
|
||||||
|
|
||||||
|
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
|
||||||
|
def test_renewing_continues_from_the_last_period(self):
|
||||||
|
club = self.ending_in(20)
|
||||||
|
first = club.dues.first()
|
||||||
|
|
||||||
|
renew(club.subscription)
|
||||||
|
|
||||||
|
latest = club.dues.order_by("-period_start").first()
|
||||||
|
self.assertEqual(latest.period_start, first.period_end + datetime.timedelta(days=1))
|
||||||
|
self.assertEqual(club.dues.count(), 2)
|
||||||
|
|
||||||
|
def test_running_twice_does_not_bill_twice(self):
|
||||||
|
# Idempotent by construction: once renewed, the club's latest period ends a year out,
|
||||||
|
# which is past the horizon.
|
||||||
|
club = self.ending_in(20)
|
||||||
|
|
||||||
|
call_command("renew_subscriptions", stdout=StringIO())
|
||||||
|
call_command("renew_subscriptions", stdout=StringIO())
|
||||||
|
|
||||||
|
self.assertEqual(club.dues.count(), 2)
|
||||||
|
|
||||||
|
def test_a_club_that_opted_out_is_not_renewed(self):
|
||||||
|
club = self.ending_in(20, auto_renew=False)
|
||||||
|
|
||||||
|
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
|
||||||
|
def test_an_archived_club_is_not_renewed(self):
|
||||||
|
# Reactivation is the way back, and it opens a period of its own.
|
||||||
|
club = self.ending_in(20)
|
||||||
|
club.archive()
|
||||||
|
|
||||||
|
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
|
||||||
|
def test_the_new_period_is_billed_at_the_price_in_force_then(self):
|
||||||
|
club = self.ending_in(20)
|
||||||
|
PlanPrice.objects.create(plan=self.plan, active_from=self.today, amount=Decimal("900.00"))
|
||||||
|
|
||||||
|
due = renew(club.subscription)
|
||||||
|
|
||||||
|
self.assertEqual(due.amount, Decimal("900.00")) # the new rate
|
||||||
|
self.assertEqual(club.dues.order_by("period_start").first().amount, Decimal("500.00")) # the old one, untouched
|
||||||
|
|
||||||
|
def test_the_new_period_is_invoiced(self):
|
||||||
|
club = self.ending_in(20)
|
||||||
|
|
||||||
|
due = renew(club.subscription)
|
||||||
|
|
||||||
|
self.assertTrue(due.invoice.number.startswith("INV-"))
|
||||||
|
|
||||||
|
def test_a_dry_run_issues_nothing(self):
|
||||||
|
club = self.ending_in(20)
|
||||||
|
out = StringIO()
|
||||||
|
|
||||||
|
call_command("renew_subscriptions", "--dry-run", stdout=out)
|
||||||
|
|
||||||
|
self.assertEqual(club.dues.count(), 1)
|
||||||
|
self.assertIn("would renew", out.getvalue())
|
||||||
|
|
||||||
|
def test_the_command_issues_by_default(self):
|
||||||
|
# The opposite asymmetry to archiving: NOT acting is the expensive failure here,
|
||||||
|
# because a club that is never billed is never chased either.
|
||||||
|
club = self.ending_in(20)
|
||||||
|
|
||||||
|
call_command("renew_subscriptions", stdout=StringIO())
|
||||||
|
|
||||||
|
self.assertEqual(club.dues.count(), 2)
|
||||||
|
|
||||||
|
def test_an_unpriced_plan_fails_loudly_without_stopping_the_others(self):
|
||||||
|
priced = self.ending_in(20)
|
||||||
|
broken = Club.objects.create(name="Unpriced FC")
|
||||||
|
subscribe(broken, self.plan, start=self.today - datetime.timedelta(days=350))
|
||||||
|
# Its next period starts beyond the last price... by removing every price, it cannot bill.
|
||||||
|
PlanPrice.objects.all().delete()
|
||||||
|
cheap = Plan.objects.create(name="Cheap")
|
||||||
|
PlanPrice.objects.create(plan=cheap, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
|
||||||
|
priced.subscription.plan = cheap
|
||||||
|
priced.subscription.save()
|
||||||
|
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command("renew_subscriptions", stdout=StringIO(), stderr=StringIO())
|
||||||
|
|
||||||
|
# ...and the club that COULD be billed still was.
|
||||||
|
self.assertEqual(priced.dues.count(), 2)
|
||||||
|
|
||||||
|
def test_a_subscription_with_no_period_at_all_is_renewed(self):
|
||||||
|
club = Club.objects.create(name="Orphan FC")
|
||||||
|
Subscription.objects.create(club=club, plan=self.plan)
|
||||||
|
|
||||||
|
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
|
||||||
|
def test_it_says_so_when_there_is_nothing_to_renew(self):
|
||||||
|
self.assertIn("Nothing to renew", self.run_renewal())
|
||||||
|
|
||||||
|
def run_renewal(self, *args):
|
||||||
|
out = StringIO()
|
||||||
|
call_command("renew_subscriptions", *args, stdout=out)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class RenewedButUnpaidTests(BillingTestBase):
|
||||||
|
"""A club auto-renewed that never pays the new fee flows through the ordinary
|
||||||
|
unpaid -> grace -> overdue -> archive path. Renewal creates a normal Due; it does not
|
||||||
|
create a special case, and the safety net that the never-billed club slipped past now
|
||||||
|
fires, because there IS an unpaid due."""
|
||||||
|
|
||||||
|
def lapsed_club(self):
|
||||||
|
"""A club on its first, PAID period — far enough back that a renewal from its end is
|
||||||
|
itself already past grace, so only the renewal's payment state decides the outcome."""
|
||||||
|
club = Club.objects.create(name="Renewed FC")
|
||||||
|
subscribe(club, self.plan, start=self.today - datetime.timedelta(days=800))
|
||||||
|
first = club.dues.first()
|
||||||
|
record_payment(first, first.amount) # the FIRST period is settled; only the renewal is in question
|
||||||
|
return club
|
||||||
|
|
||||||
|
def test_an_unpaid_renewal_becomes_overdue_and_archivable(self):
|
||||||
|
club = self.lapsed_club()
|
||||||
|
renewed = renew(club.subscription) # continues from the first period's end, unpaid
|
||||||
|
|
||||||
|
self.assertTrue(renewed.is_overdue(self.today))
|
||||||
|
self.assertIn(renewed, dues_overdue(self.today))
|
||||||
|
self.assertIn(club, [d.club for d in archivable_clubs(self.today)])
|
||||||
|
|
||||||
|
def test_a_paid_renewal_is_not_chased(self):
|
||||||
|
club = self.lapsed_club()
|
||||||
|
renewed = renew(club.subscription)
|
||||||
|
record_payment(renewed, renewed.amount)
|
||||||
|
|
||||||
|
self.assertNotIn(club, [d.club for d in archivable_clubs(self.today)])
|
||||||
|
|
||||||
|
|
||||||
|
class TrialTests(BillingTestBase):
|
||||||
|
"""A club with no subscription yet can be started on a short trial that switches
|
||||||
|
itself to a pre-selected plan automatically once the trial period is renewed --
|
||||||
|
see billing.services.dues.start_trial and the trial-conversion check in
|
||||||
|
open_period()."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
super().setUpTestData()
|
||||||
|
# A trial is a plan whose own duration_months IS the trial length -- there is no
|
||||||
|
# trial_months argument any more.
|
||||||
|
cls.trial_plan = Plan.objects.create(name="Trial", duration_months=2, is_trial=True, grace_days=14, renewal_lead_days=7)
|
||||||
|
PlanPrice.objects.create(plan=cls.trial_plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
|
||||||
|
|
||||||
|
def test_start_trial_creates_a_short_trial_period(self):
|
||||||
|
due = start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
|
||||||
|
|
||||||
|
subscription = self.club.subscription
|
||||||
|
self.assertEqual(subscription.plan, self.trial_plan)
|
||||||
|
self.assertEqual(subscription.post_trial_plan, self.plan)
|
||||||
|
self.assertEqual(subscription.trial_ends_at, due.period_end)
|
||||||
|
self.assertTrue(due.is_trial)
|
||||||
|
# Roughly 2 months, nowhere near the standard ~1-year period.
|
||||||
|
self.assertLess((due.period_end - due.period_start).days, 65)
|
||||||
|
|
||||||
|
def test_start_trial_refuses_if_already_subscribed(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
|
||||||
|
with self.assertRaises(BillingError):
|
||||||
|
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
|
||||||
|
|
||||||
|
def test_the_trials_length_comes_from_its_plan(self):
|
||||||
|
due = start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
|
||||||
|
|
||||||
|
self.assertEqual(due.period_end, add_months(due.period_start, 2) - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
def test_renewing_after_the_trial_switches_to_the_post_trial_plan(self):
|
||||||
|
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
|
||||||
|
|
||||||
|
due = renew(self.club.subscription)
|
||||||
|
|
||||||
|
self.club.refresh_from_db()
|
||||||
|
self.assertEqual(self.club.subscription.plan, self.plan)
|
||||||
|
self.assertIsNone(self.club.subscription.trial_ends_at)
|
||||||
|
self.assertIsNone(self.club.subscription.post_trial_plan)
|
||||||
|
self.assertEqual(due.plan, self.plan)
|
||||||
|
self.assertFalse(due.is_trial)
|
||||||
|
self.assertEqual(due.amount, Decimal("500.00"))
|
||||||
|
|
||||||
|
def test_manually_opening_the_next_period_also_switches_plan(self):
|
||||||
|
# Same conversion must fire via the control panel's "Open period" button, which
|
||||||
|
# calls open_period() directly rather than renew().
|
||||||
|
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
|
||||||
|
|
||||||
|
open_period(self.club)
|
||||||
|
|
||||||
|
self.club.refresh_from_db()
|
||||||
|
self.assertEqual(self.club.subscription.plan, self.plan)
|
||||||
|
|
||||||
|
def test_a_trial_nearing_its_end_is_picked_up_for_renewal(self):
|
||||||
|
# Inside the TRIAL PLAN's own 7-day lead, not the 30-day one an annual plan uses:
|
||||||
|
# a 2-month trial renewed a month early would be renewed before it had begun.
|
||||||
|
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan, start=self.today - datetime.timedelta(days=57))
|
||||||
|
|
||||||
|
self.assertIn(self.club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
|
||||||
|
def test_a_trial_outside_its_own_lead_window_is_not_yet_renewed(self):
|
||||||
|
# Same trial 7 days earlier in its life: an annual plan's 30-day lead would have
|
||||||
|
# picked this up, and the per-plan lead is exactly what stops that.
|
||||||
|
start_trial(self.club, self.trial_plan, post_trial_plan=self.plan, start=self.today - datetime.timedelta(days=40))
|
||||||
|
|
||||||
|
self.assertNotIn(self.club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
|
||||||
|
def test_a_zero_amount_trial_is_created_already_paid(self):
|
||||||
|
free_plan = Plan.objects.create(name="Free Trial")
|
||||||
|
PlanPrice.objects.create(plan=free_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
|
||||||
|
|
||||||
|
due = start_trial(self.club, free_plan, post_trial_plan=self.plan)
|
||||||
|
|
||||||
|
self.assertEqual(due.status, Due.Status.PAID)
|
||||||
|
self.assertIsNotNone(due.paid_at)
|
||||||
|
far_future = due.grace_until + datetime.timedelta(days=100)
|
||||||
|
self.assertNotIn(due, dues_overdue(far_future))
|
||||||
|
self.assertNotIn(self.club, [d.club for d in archivable_clubs(far_future)])
|
||||||
|
|
||||||
|
|
||||||
|
class PlanClockTests(BillingTestBase):
|
||||||
|
"""The three per-plan clocks, and the constraints that keep them sane -- see BILLING.md §3."""
|
||||||
|
|
||||||
|
def make_plan(self, **kwargs):
|
||||||
|
# A short plan cannot keep the annual defaults -- 30 days' lead on a 1-month period is
|
||||||
|
# exactly what the constraints forbid, so scale them down with the duration.
|
||||||
|
months = kwargs.get("duration_months", DEFAULT_DURATION_MONTHS)
|
||||||
|
defaults = {"name": f"Plan {Plan.objects.count()}", "renewal_lead_days": min(DEFAULT_RENEWAL_LEAD_DAYS, months * 7), "grace_days": min(DEFAULT_GRACE_DAYS, months * 14)}
|
||||||
|
plan = Plan.objects.create(**defaults | kwargs)
|
||||||
|
PlanPrice.objects.create(plan=plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("10.00"))
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def test_a_monthly_plan_gets_a_one_month_period(self):
|
||||||
|
plan = self.make_plan(duration_months=1)
|
||||||
|
|
||||||
|
due = open_period(self.club, plan=plan, start=datetime.date(2026, 3, 1))
|
||||||
|
|
||||||
|
self.assertEqual(due.period_end, datetime.date(2026, 3, 31))
|
||||||
|
|
||||||
|
def test_a_quarterly_plan_gets_a_three_month_period(self):
|
||||||
|
plan = self.make_plan(duration_months=3)
|
||||||
|
|
||||||
|
due = open_period(self.club, plan=plan, start=datetime.date(2026, 3, 1))
|
||||||
|
|
||||||
|
self.assertEqual(due.period_end, datetime.date(2026, 5, 31))
|
||||||
|
|
||||||
|
def test_grace_days_are_per_plan(self):
|
||||||
|
plan = self.make_plan(duration_months=1, grace_days=14)
|
||||||
|
|
||||||
|
due = open_period(self.club, plan=plan, start=datetime.date(2026, 3, 1))
|
||||||
|
|
||||||
|
self.assertEqual(due.grace_until, datetime.date(2026, 3, 15))
|
||||||
|
|
||||||
|
def test_editing_a_plans_grace_does_not_move_an_open_period(self):
|
||||||
|
# grace_until is a stored snapshot for the same reason `amount` is: repricing the
|
||||||
|
# plan must not silently re-date an archiving already in flight.
|
||||||
|
plan = self.make_plan(grace_days=30)
|
||||||
|
due = open_period(self.club, plan=plan, start=self.today)
|
||||||
|
original = due.grace_until
|
||||||
|
|
||||||
|
plan.grace_days = 1
|
||||||
|
plan.save(update_fields=["grace_days"])
|
||||||
|
due.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(due.grace_until, original)
|
||||||
|
|
||||||
|
def test_a_lead_longer_than_the_period_is_rejected(self):
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
Plan.objects.create(name="Runaway", duration_months=1, renewal_lead_days=90)
|
||||||
|
|
||||||
|
def test_grace_longer_than_the_period_is_rejected(self):
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
Plan.objects.create(name="Never archives", duration_months=1, grace_days=90)
|
||||||
|
|
||||||
|
def test_full_clean_reports_an_impossible_lead_as_a_form_error(self):
|
||||||
|
# Not an IntegrityError/500: a platform admin typing this into the plan form should
|
||||||
|
# be told which field is wrong.
|
||||||
|
plan = Plan(name="Runaway", duration_months=1, renewal_lead_days=90, grace_days=14)
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError) as caught:
|
||||||
|
plan.full_clean()
|
||||||
|
|
||||||
|
self.assertIn("renewal_lead_days", caught.exception.error_dict)
|
||||||
|
|
||||||
|
def test_renewal_lead_is_read_from_each_plan(self):
|
||||||
|
monthly = self.make_plan(duration_months=1, renewal_lead_days=7, grace_days=14)
|
||||||
|
club = Club.objects.create(name="Monthly FC")
|
||||||
|
# Period ends in 3 days: inside a 7-day lead, well outside an annual plan's 30.
|
||||||
|
subscribe(club, monthly, start=self.today - datetime.timedelta(days=27))
|
||||||
|
|
||||||
|
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
|
||||||
|
def test_an_explicit_lead_days_overrides_every_plan(self):
|
||||||
|
monthly = self.make_plan(duration_months=1, renewal_lead_days=1, grace_days=14)
|
||||||
|
club = Club.objects.create(name="Override FC")
|
||||||
|
subscribe(club, monthly, start=self.today - datetime.timedelta(days=20))
|
||||||
|
|
||||||
|
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal(lead_days=30)])
|
||||||
|
|
||||||
|
|
||||||
|
class BillingNoticeTests(BillingTestBase):
|
||||||
|
"""What a club's own admins are told -- see billing/services/notices.py."""
|
||||||
|
|
||||||
|
def test_no_notice_when_nothing_is_owed(self):
|
||||||
|
due = self.bill()
|
||||||
|
record_payment(due, Decimal("500.00"))
|
||||||
|
|
||||||
|
self.assertIsNone(club_billing_notice(self.club, self.today))
|
||||||
|
|
||||||
|
def test_no_notice_for_a_club_that_was_never_billed(self):
|
||||||
|
self.assertIsNone(club_billing_notice(self.club, self.today))
|
||||||
|
|
||||||
|
def test_a_period_issued_ahead_of_its_start_is_only_informational(self):
|
||||||
|
self.bill(start=self.today + datetime.timedelta(days=10))
|
||||||
|
|
||||||
|
self.assertEqual(club_billing_notice(self.club, self.today).level, "info")
|
||||||
|
|
||||||
|
def test_an_unpaid_started_period_warns(self):
|
||||||
|
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
notice = club_billing_notice(self.club, self.today)
|
||||||
|
|
||||||
|
self.assertEqual(notice.level, "warning")
|
||||||
|
self.assertEqual(notice.amount_outstanding, Decimal("500.00"))
|
||||||
|
self.assertFalse(notice.is_urgent)
|
||||||
|
|
||||||
|
def test_the_last_week_before_archiving_is_urgent(self):
|
||||||
|
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS - 2))
|
||||||
|
|
||||||
|
notice = club_billing_notice(self.club, self.today)
|
||||||
|
|
||||||
|
self.assertEqual(notice.level, "error")
|
||||||
|
self.assertTrue(notice.is_urgent)
|
||||||
|
self.assertEqual(notice.days_until_archive, 2)
|
||||||
|
|
||||||
|
def test_an_overdue_period_is_urgent_with_a_negative_countdown(self):
|
||||||
|
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 5))
|
||||||
|
|
||||||
|
notice = club_billing_notice(self.club, self.today)
|
||||||
|
|
||||||
|
self.assertEqual(notice.level, "error")
|
||||||
|
self.assertLess(notice.days_until_archive, 0)
|
||||||
|
|
||||||
|
def test_auto_archive_off_still_reports_the_debt_but_promises_no_archiving(self):
|
||||||
|
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=1), auto_archive=False)
|
||||||
|
|
||||||
|
notice = club_billing_notice(self.club, self.today)
|
||||||
|
|
||||||
|
self.assertEqual(notice.amount_outstanding, Decimal("500.00"))
|
||||||
|
self.assertFalse(notice.will_archive)
|
||||||
|
|
||||||
|
def test_the_soonest_archiving_due_is_the_one_reported(self):
|
||||||
|
self.bill(start=self.today - datetime.timedelta(days=1))
|
||||||
|
later = self.bill(start=self.today + datetime.timedelta(days=400))
|
||||||
|
|
||||||
|
self.assertNotEqual(club_billing_notice(self.club, self.today).due, later)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingReminderTests(BillingTestBase):
|
||||||
|
"""Reminder emails -- see billing/services/reminders.py. Sent once per escalation
|
||||||
|
level, because the command is on a daily cron."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
super().setUpTestData()
|
||||||
|
user = User.objects.create_user(email="admin@ajax.example", password="pw-secret-123")
|
||||||
|
member = Member.objects.create(user=user, first_name="Ada", last_name="Admin")
|
||||||
|
ClubRole.objects.create(club=cls.club, member=member, role=ClubRole.Roles.ADMIN)
|
||||||
|
subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=1))
|
||||||
|
cls.due = cls.club.dues.first()
|
||||||
|
|
||||||
|
def test_a_reminder_goes_to_the_club_admins(self):
|
||||||
|
self.assertEqual(admin_emails(self.club), ["admin@ajax.example"])
|
||||||
|
|
||||||
|
def make_overdue_club(self, name, *, auto_archive):
|
||||||
|
# A separate club rather than resubscribing self.club: open_period() caches
|
||||||
|
# club.subscription on the instance it's given, so calling subscribe() twice
|
||||||
|
# against the SAME Python object -- only possible by reusing one across two calls,
|
||||||
|
# never a real request -- would read the first call's now-stale cached subscription.
|
||||||
|
club = Club.objects.create(name=name)
|
||||||
|
user = User.objects.create_user(email=f"{name.lower()}@ajax.example", password="pw-secret-123")
|
||||||
|
member = Member.objects.create(user=user, first_name="A", last_name="Admin")
|
||||||
|
ClubRole.objects.create(club=club, member=member, role=ClubRole.Roles.ADMIN)
|
||||||
|
subscribe(club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 5), auto_archive=auto_archive)
|
||||||
|
return club
|
||||||
|
|
||||||
|
def test_an_overdue_reminder_threatens_archiving_when_it_will_happen(self):
|
||||||
|
club = self.make_overdue_club("Archive On", auto_archive=True)
|
||||||
|
|
||||||
|
notice = club_billing_notice(club, self.today)
|
||||||
|
self.assertTrue(notice.will_archive)
|
||||||
|
send_reminder(club, notice, recipients=["archive-on@ajax.example"])
|
||||||
|
|
||||||
|
self.assertIn("about to be archived", mail.outbox[0].subject)
|
||||||
|
self.assertIn("archived", mail.outbox[0].body)
|
||||||
|
|
||||||
|
def test_an_overdue_reminder_does_not_threaten_archiving_when_auto_archive_is_off(self):
|
||||||
|
# This is the bug the two-column-modal review turned up: the subject branched only
|
||||||
|
# on notice.level, so a club that will NEVER be archived still got told it was
|
||||||
|
# "about to be archived" -- while the body correctly said otherwise.
|
||||||
|
club = self.make_overdue_club("Archive Off", auto_archive=False)
|
||||||
|
|
||||||
|
notice = club_billing_notice(club, self.today)
|
||||||
|
self.assertFalse(notice.will_archive)
|
||||||
|
send_reminder(club, notice, recipients=["archive-off@ajax.example"])
|
||||||
|
|
||||||
|
self.assertNotIn("about to be archived", mail.outbox[0].subject)
|
||||||
|
self.assertNotIn("archived", mail.outbox[0].body)
|
||||||
|
self.assertIn("good standing", mail.outbox[0].body)
|
||||||
|
|
||||||
|
def test_sending_records_the_level_and_fills_the_outbox(self):
|
||||||
|
notice = club_billing_notice(self.club, self.today)
|
||||||
|
send_reminder(self.club, notice, recipients=["admin@ajax.example"])
|
||||||
|
|
||||||
|
self.due.refresh_from_db()
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertEqual(self.due.last_reminder_level, notice.level)
|
||||||
|
self.assertIsNotNone(self.due.last_reminder_sent_at)
|
||||||
|
|
||||||
|
def test_a_second_run_at_the_same_level_sends_nothing(self):
|
||||||
|
notice = club_billing_notice(self.club, self.today)
|
||||||
|
send_reminder(self.club, notice, recipients=["admin@ajax.example"])
|
||||||
|
self.due.refresh_from_db()
|
||||||
|
|
||||||
|
results = reminders_to_send([self.club], self.today)
|
||||||
|
|
||||||
|
self.assertFalse(results[0].sent)
|
||||||
|
self.assertIn("already reminded", results[0].skipped_reason)
|
||||||
|
|
||||||
|
def test_an_escalation_gets_through(self):
|
||||||
|
send_reminder(self.club, club_billing_notice(self.club, self.today), recipients=["admin@ajax.example"])
|
||||||
|
|
||||||
|
# Far enough on that the same due is now urgent rather than merely a warning.
|
||||||
|
later = self.today + datetime.timedelta(days=DEFAULT_GRACE_DAYS)
|
||||||
|
results = reminders_to_send([self.club], later)
|
||||||
|
|
||||||
|
self.assertTrue(results[0].sent)
|
||||||
|
self.assertEqual(results[0].notice.level, "error")
|
||||||
|
|
||||||
|
def test_force_resends_at_the_same_level(self):
|
||||||
|
send_reminder(self.club, club_billing_notice(self.club, self.today), recipients=["admin@ajax.example"])
|
||||||
|
self.due.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertTrue(reminders_to_send([self.club], self.today, force=True)[0].sent)
|
||||||
|
|
||||||
|
def test_a_club_with_no_reachable_admin_is_reported_not_skipped_silently(self):
|
||||||
|
ClubRole.objects.all().delete()
|
||||||
|
|
||||||
|
results = reminders_to_send([self.club], self.today)
|
||||||
|
|
||||||
|
self.assertFalse(results[0].sent)
|
||||||
|
self.assertIn("no club admin", results[0].skipped_reason)
|
||||||
|
|
||||||
|
def test_a_settled_club_produces_no_reminder(self):
|
||||||
|
record_payment(self.due, Decimal("500.00"))
|
||||||
|
|
||||||
|
self.assertEqual(reminders_to_send([self.club], self.today), [])
|
||||||
|
|
||||||
|
|
||||||
|
class PlanVisibilityTests(BillingTestBase):
|
||||||
|
"""Plan.objects.visible() -- see PlanQuerySet."""
|
||||||
|
|
||||||
|
def test_a_plain_plan_is_visible(self):
|
||||||
|
self.assertIn(self.plan, Plan.objects.visible())
|
||||||
|
|
||||||
|
def test_a_soft_deleted_plan_is_excluded(self):
|
||||||
|
self.plan.deleted_at = timezone.now()
|
||||||
|
self.plan.save(update_fields=["deleted_at"])
|
||||||
|
|
||||||
|
self.assertNotIn(self.plan, Plan.objects.visible())
|
||||||
|
|
||||||
|
def test_the_default_manager_still_returns_a_soft_deleted_plan(self):
|
||||||
|
# Django admin, and anything reading historical data, must still be able to find it.
|
||||||
|
self.plan.deleted_at = timezone.now()
|
||||||
|
self.plan.save(update_fields=["deleted_at"])
|
||||||
|
|
||||||
|
self.assertIn(self.plan, Plan.objects.all())
|
||||||
|
|
||||||
|
|
||||||
|
class PlanDeletionTests(BillingTestBase):
|
||||||
|
"""billing.services.plans -- see its module docstring for the full reasoning."""
|
||||||
|
|
||||||
|
def test_a_never_billed_plan_is_hard_deleted(self):
|
||||||
|
unused = Plan.objects.create(name="Unused")
|
||||||
|
|
||||||
|
impact = delete_plan(unused)
|
||||||
|
|
||||||
|
self.assertTrue(impact.will_hard_delete)
|
||||||
|
self.assertFalse(Plan.objects.filter(pk=unused.pk).exists())
|
||||||
|
|
||||||
|
def test_a_plan_with_a_due_cannot_be_hard_deleted(self):
|
||||||
|
self.bill()
|
||||||
|
|
||||||
|
impact = delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertFalse(impact.will_hard_delete)
|
||||||
|
self.assertTrue(Plan.objects.filter(pk=self.plan.pk).exists())
|
||||||
|
|
||||||
|
def test_a_cancelled_due_still_protects_the_plan(self):
|
||||||
|
# PROTECT does not care about the referencing row's own status -- a cancelled due is
|
||||||
|
# still a row, and financial history includes rows nobody expects to see again.
|
||||||
|
due = self.bill()
|
||||||
|
due.status = Due.Status.CANCELLED
|
||||||
|
due.save(update_fields=["status"])
|
||||||
|
|
||||||
|
impact = delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertFalse(impact.will_hard_delete)
|
||||||
|
|
||||||
|
def test_soft_delete_marks_the_plan_inactive_and_deleted(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
|
||||||
|
delete_plan(self.plan)
|
||||||
|
self.plan.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertTrue(self.plan.is_deleted)
|
||||||
|
self.assertFalse(self.plan.is_active)
|
||||||
|
|
||||||
|
def test_deleting_unsubscribes_the_club_entirely_rather_than_nulling_a_field(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
|
||||||
|
delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertFalse(Subscription.objects.filter(club=self.club).exists())
|
||||||
|
|
||||||
|
def test_a_deleted_plans_historical_due_is_untouched(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
due = self.club.dues.first()
|
||||||
|
amount, period_end, grace_until = due.amount, due.period_end, due.grace_until
|
||||||
|
|
||||||
|
delete_plan(self.plan)
|
||||||
|
due.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(due.plan_id, self.plan.pk)
|
||||||
|
self.assertEqual(due.amount, amount)
|
||||||
|
self.assertEqual(due.period_end, period_end)
|
||||||
|
self.assertEqual(due.grace_until, grace_until)
|
||||||
|
|
||||||
|
def test_a_club_not_on_the_plan_is_unaffected(self):
|
||||||
|
other_plan = Plan.objects.create(name="Other")
|
||||||
|
PlanPrice.objects.create(plan=other_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
|
||||||
|
untouched = Club.objects.create(name="Untouched FC")
|
||||||
|
subscribe(untouched, other_plan)
|
||||||
|
|
||||||
|
delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertTrue(Subscription.objects.filter(club=untouched, plan=other_plan).exists())
|
||||||
|
|
||||||
|
def test_a_trial_scheduled_to_convert_to_the_deleted_plan_is_cleared(self):
|
||||||
|
trial_plan = Plan.objects.create(name="Trial", is_trial=True, duration_months=2, renewal_lead_days=7, grace_days=14)
|
||||||
|
PlanPrice.objects.create(plan=trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("0.00"))
|
||||||
|
club = Club.objects.create(name="Mid Trial FC")
|
||||||
|
start_trial(club, trial_plan, post_trial_plan=self.plan)
|
||||||
|
|
||||||
|
impact = delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertEqual([c.pk for c in impact.broken_trial_clubs], [club.pk])
|
||||||
|
club.refresh_from_db()
|
||||||
|
subscription = club.subscription
|
||||||
|
self.assertEqual(subscription.plan, trial_plan)
|
||||||
|
self.assertIsNone(subscription.trial_ends_at)
|
||||||
|
self.assertIsNone(subscription.post_trial_plan)
|
||||||
|
|
||||||
|
def test_a_club_currently_on_the_plan_is_not_also_counted_as_a_broken_trial(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
|
||||||
|
impact = delete_plan(self.plan)
|
||||||
|
|
||||||
|
self.assertEqual(impact.unsubscribed_clubs, [self.club])
|
||||||
|
self.assertEqual(impact.broken_trial_clubs, [])
|
||||||
|
|
||||||
|
def test_plan_deletion_impact_is_read_only(self):
|
||||||
|
subscribe(self.club, self.plan)
|
||||||
|
|
||||||
|
plan_deletion_impact(self.plan)
|
||||||
|
|
||||||
|
self.assertTrue(Subscription.objects.filter(club=self.club).exists())
|
||||||
|
self.assertFalse(self.plan.is_deleted)
|
||||||
0
club/__init__.py
Normal file
0
club/__init__.py
Normal file
90
club/admin.py
Normal file
90
club/admin.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from .models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Club)
|
||||||
|
class ClubAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "slug", "sport_type"]
|
||||||
|
list_filter = ["sport_type"]
|
||||||
|
search_fields = ["name", "slug"]
|
||||||
|
prepopulated_fields = {"slug": ["name"]}
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Sponsor)
|
||||||
|
class SponsorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "club", "start_date", "end_date"]
|
||||||
|
list_filter = ["club"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Season)
|
||||||
|
class SeasonAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["__str__", "club", "start_date", "end_date"]
|
||||||
|
list_filter = ["club"]
|
||||||
|
search_fields = ["club__name"]
|
||||||
|
ordering = ["club", "-start_date"]
|
||||||
|
|
||||||
|
|
||||||
|
class FeePaymentInline(admin.TabularInline):
|
||||||
|
model = FeePayment
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ["recorded_by"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ClubMembership)
|
||||||
|
class ClubMembershipAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "fee_amount", "amount_paid", "license"]
|
||||||
|
search_fields = ["club__name", "member__last_name", "member__first_name", "license"]
|
||||||
|
list_filter = ["club", "season", "status", "fee_status"]
|
||||||
|
raw_id_fields = ["member"]
|
||||||
|
# Money is settled by club.services.fees, which re-derives fee_status from the payments.
|
||||||
|
readonly_fields = ["amount_paid", "fee_status"]
|
||||||
|
fieldsets = [
|
||||||
|
[None, {"fields": ["club", "season", "member"]}],
|
||||||
|
[_("Membership"), {"fields": ["license", "status", "fee_status", "fee_amount", "amount_paid"]}],
|
||||||
|
[_("Dates"), {"fields": ["signed_up_at", "activated_at"]}],
|
||||||
|
]
|
||||||
|
inlines = [FeePaymentInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FeePayment)
|
||||||
|
class FeePaymentAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["membership", "amount", "method", "paid_at", "recorded_by"]
|
||||||
|
list_filter = ["method"]
|
||||||
|
search_fields = ["membership__club__name", "membership__member__last_name", "reference"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DuesInvoice)
|
||||||
|
class DuesInvoiceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["number", "membership", "amount", "due_date", "sent_at", "reminder_count"]
|
||||||
|
list_filter = ["club"]
|
||||||
|
search_fields = ["number", "membership__member__last_name", "membership__member__first_name"]
|
||||||
|
raw_id_fields = ["membership"]
|
||||||
|
readonly_fields = ["number"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ClubRole)
|
||||||
|
class ClubRoleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["club__name", "member__last_name", "member__first_name", "role"]
|
||||||
|
search_fields = ["club__name", "member__last_name", "member__first_name"]
|
||||||
|
list_filter = ["club", "role"]
|
||||||
|
raw_id_fields = ["member"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(OnboardingRequirement)
|
||||||
|
class OnboardingRequirementAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "club", "requires_document", "is_active"]
|
||||||
|
list_filter = ["club", "is_active", "requires_document"]
|
||||||
|
search_fields = ["name", "club__name"]
|
||||||
|
ordering = ["club", "name"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MemberRequirementStatus)
|
||||||
|
class MemberRequirementStatusAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["membership", "requirement", "is_complete", "completed_at", "completed_by"]
|
||||||
|
list_filter = ["requirement__club", "is_complete", "requirement"]
|
||||||
|
search_fields = ["membership__member__last_name", "membership__member__first_name", "requirement__name"]
|
||||||
|
raw_id_fields = ["membership"]
|
||||||
47
club/api.py
Normal file
47
club/api.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Public read-only sponsors endpoint -- see api/urls.py for how this is
|
||||||
|
mounted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from ninja import Router, Schema
|
||||||
|
|
||||||
|
from api.errors import require_club
|
||||||
|
|
||||||
|
from .services.sponsors import active_sponsors
|
||||||
|
|
||||||
|
router = Router(tags=["sponsors"])
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorOut(Schema):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
logo_url: str | None
|
||||||
|
logo_width: int | None
|
||||||
|
logo_height: int | None
|
||||||
|
url: str | None
|
||||||
|
start_date: date
|
||||||
|
end_date: date | None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_sponsor_out(sponsor, request) -> SponsorOut:
|
||||||
|
return SponsorOut(
|
||||||
|
id=sponsor.pk,
|
||||||
|
name=sponsor.name,
|
||||||
|
logo_url=request.build_absolute_uri(sponsor.logo.url) if sponsor.logo else None,
|
||||||
|
logo_width=sponsor.logo_width,
|
||||||
|
logo_height=sponsor.logo_height,
|
||||||
|
url=sponsor.url or None,
|
||||||
|
start_date=sponsor.start_date,
|
||||||
|
end_date=sponsor.end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response=list[SponsorOut], summary="Active sponsors")
|
||||||
|
def list_sponsors(request, randomize: bool = False):
|
||||||
|
"""See club.services.sponsors.active_sponsors for what "active" means and
|
||||||
|
why `randomize=true` shuffles in Python rather than in SQL."""
|
||||||
|
club = require_club(request)
|
||||||
|
sponsors = active_sponsors(club, randomize=randomize)
|
||||||
|
return [_to_sponsor_out(sponsor, request) for sponsor in sponsors]
|
||||||
8
club/apps.py
Normal file
8
club/apps.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ClubConfig(AppConfig):
|
||||||
|
name = "club"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from . import signals # noqa: F401
|
||||||
46
club/context_processors.py
Normal file
46
club/context_processors.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Tenant-aware page branding.
|
||||||
|
|
||||||
|
Every page inherits its chrome from ``base_template``. On a club subdomain that
|
||||||
|
resolves to the club-branded skin, on the base domain to the platform one — the
|
||||||
|
control panel's own industrial design system (assets/controlpanel.css) — so the
|
||||||
|
auth screens (login, password reset, MFA, passkeys — anything allauth ships, now
|
||||||
|
or later) follow the tenant without a single template of their own knowing that
|
||||||
|
clubs exist. templates/403.html and templates/maintenance.html extend
|
||||||
|
``base_template`` directly too, so they follow the same split.
|
||||||
|
|
||||||
|
A club subdomain serves two very different chromes, though: the public club site
|
||||||
|
(daisyUI, assets/app.css) and the management app (assets/management.css) live on
|
||||||
|
the same tenant, distinguished only by path. Without the checks below, a staff
|
||||||
|
member clicking "Change password" from inside the management app would land back
|
||||||
|
on the club's *public* skin -- jarring, and visually nothing like where they just
|
||||||
|
were. MANAGEMENT_BASE_TEMPLATE picks up management/base.html's own chrome instead,
|
||||||
|
for two cases: a request path directly under /manage/ (matching management/urls.py's
|
||||||
|
own hardcoded "manage/" prefix in rosterchief/urls.py -- e.g. a 403 on a management
|
||||||
|
page), and the session flag ClubStaffRequiredMixin.dispatch sets on every management
|
||||||
|
view (club/mixins.py) -- needed because allauth's password-change/MFA/logout screens
|
||||||
|
live under /accounts/, outside /manage/, so the path check alone can't see they were
|
||||||
|
reached from the management app's own user menu.
|
||||||
|
|
||||||
|
The control panel's own pages deliberately do *not* use this: controlpanel/base.html
|
||||||
|
hardcodes itself, so no branding bug can ever dress the platform panel up as a club.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLATFORM_BASE_TEMPLATE = "controlpanel/_auth_base.html"
|
||||||
|
CLUB_BASE_TEMPLATE = "_club_base.html"
|
||||||
|
MANAGEMENT_BASE_TEMPLATE = "management/_auth_base.html"
|
||||||
|
|
||||||
|
|
||||||
|
def branding(request):
|
||||||
|
club = getattr(request, "club", None) # set by ClubTenantMiddleware
|
||||||
|
|
||||||
|
if club and (request.path.startswith("/manage/") or request.session.get("management_context")):
|
||||||
|
base_template = MANAGEMENT_BASE_TEMPLATE
|
||||||
|
elif club:
|
||||||
|
base_template = CLUB_BASE_TEMPLATE
|
||||||
|
else:
|
||||||
|
base_template = PLATFORM_BASE_TEMPLATE
|
||||||
|
|
||||||
|
return {
|
||||||
|
"club": club,
|
||||||
|
"base_template": base_template,
|
||||||
|
}
|
||||||
0
club/management/__init__.py
Normal file
0
club/management/__init__.py
Normal file
0
club/management/commands/__init__.py
Normal file
0
club/management/commands/__init__.py
Normal file
57
club/management/commands/generate_seasons.py
Normal file
57
club/management/commands/generate_seasons.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Generate seasons ahead of time for every active club.
|
||||||
|
|
||||||
|
Meant to run on a schedule (cron): safe to call repeatedly, since generate_seasons
|
||||||
|
skips whatever already exists. No --dry-run/--commit gate on generation itself --
|
||||||
|
unlike archiving a club or billing it, creating a future season row is additive
|
||||||
|
and idempotent, same reasoning as extend_event_series (materialising occurrences).
|
||||||
|
|
||||||
|
--resync is different: it can delete rows (any season that doesn't match a
|
||||||
|
club's *current* season_start/season_duration_months, and isn't referenced by a
|
||||||
|
membership), so it defaults to reporting only -- pass --commit alongside it to
|
||||||
|
actually remove anything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from club.models import Club
|
||||||
|
from club.services.seasons import generate_seasons, resync_seasons
|
||||||
|
from features.commands import MaintenanceAwareCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(MaintenanceAwareCommand):
|
||||||
|
help = "Generate seasons up to N years ahead for every active club (default 2)."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--years", type=int, default=2, help="How many years ahead to generate seasons for (default 2).")
|
||||||
|
parser.add_argument("--resync", action="store_true", help="Before generating, remove any existing seasons that don't match the club's current settings (skips any still referenced by a membership).")
|
||||||
|
parser.add_argument("--commit", action="store_true", help="With --resync, actually delete the seasons found to be wrong. Without it, --resync only reports what it would remove.")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
until = timezone.localdate() + relativedelta(years=options["years"])
|
||||||
|
clubs = Club.objects.active()
|
||||||
|
|
||||||
|
if options["resync"]:
|
||||||
|
total_removed, total_kept = 0, 0
|
||||||
|
for club in clubs:
|
||||||
|
removed, kept = resync_seasons(club, until, commit=options["commit"])
|
||||||
|
total_removed += len(removed)
|
||||||
|
total_kept += len(kept)
|
||||||
|
if removed:
|
||||||
|
verb = "Removed" if options["commit"] else "Would remove"
|
||||||
|
self.stdout.write(f"{club}: {verb} {len(removed)} season(s) that no longer match its settings.")
|
||||||
|
if kept:
|
||||||
|
self.stdout.write(self.style.WARNING(f"{club}: {len(kept)} season(s) don't match its settings but are still in use, left alone."))
|
||||||
|
|
||||||
|
if not options["commit"] and total_removed:
|
||||||
|
self.stdout.write(self.style.WARNING("Dry run -- pass --commit to actually delete these."))
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Resync: {total_removed} season(s) {'removed' if options['commit'] else 'to remove'}, {total_kept} kept (in use)."))
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for club in clubs:
|
||||||
|
created = generate_seasons(club, until)
|
||||||
|
total += len(created)
|
||||||
|
if created:
|
||||||
|
self.stdout.write(f"{club}: generated {len(created)} season(s).")
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Done. Generated {total} season(s) across {clubs.count()} club(s)."))
|
||||||
36
club/migrations/0001_initial.py
Normal file
36
club/migrations/0001_initial.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-02 14:24
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Club',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'club',
|
||||||
|
'verbose_name_plural': 'clubs',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ClubMembership',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-05 13:50
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0003_alter_family_name_alter_familymembership_family_and_more'),
|
||||||
|
('club', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='club',
|
||||||
|
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='members', to='club.club'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='license',
|
||||||
|
field=models.CharField(default=1, max_length=250),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='member',
|
||||||
|
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='member_of', to='authentication.member'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-05 20:51
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0003_alter_family_name_alter_familymembership_family_and_more'),
|
||||||
|
('club', '0002_clubmembership_club_clubmembership_license_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='clubmembership',
|
||||||
|
options={'ordering': ['club', 'member__last_name', 'member__first_name'], 'verbose_name': 'club membership', 'verbose_name_plural': 'club memberships'},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='club',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(max_length=255, verbose_name='name'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='club',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='club.club', verbose_name='club'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='license',
|
||||||
|
field=models.CharField(max_length=250, verbose_name='license'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='member',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_of', to='authentication.member', verbose_name='member'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='clubmembership',
|
||||||
|
unique_together={('club', 'member')},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
club/migrations/0004_alter_clubmembership_license.py
Normal file
18
club/migrations/0004_alter_clubmembership_license.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-05 20:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0003_alter_clubmembership_options_alter_club_name_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='license',
|
||||||
|
field=models.CharField(blank=True, max_length=250, verbose_name='license'),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
club/migrations/0005_alter_clubmembership_member.py
Normal file
20
club/migrations/0005_alter_clubmembership_member.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-11 22:09
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0004_alter_clubmembership_license'),
|
||||||
|
('members', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='member',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_of', to='members.member', verbose_name='member'),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
club/migrations/0006_club_slug_season.py
Normal file
33
club/migrations/0006_club_slug_season.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-12 13:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0005_alter_clubmembership_member'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='slug',
|
||||||
|
field=models.SlugField(blank=True, help_text='Drives subdomain / path resolution (e.g. ajax-united.clubmanager.app).', max_length=255, unique=True, verbose_name='slug'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Season',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('start_date', models.DateField(verbose_name='start date')),
|
||||||
|
('end_date', models.DateField(verbose_name='end date')),
|
||||||
|
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'season',
|
||||||
|
'verbose_name_plural': 'seasons',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-12 13:43
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0006_club_slug_season'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='season',
|
||||||
|
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='club.season', verbose_name='season'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='club',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-12 14:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0007_clubmembership_season_alter_clubmembership_club'),
|
||||||
|
('members', '0002_alter_familymembership_unique_together_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='clubmembership',
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='activated_at',
|
||||||
|
field=models.DateField(blank=True, null=True, verbose_name='activated at'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='fee_status',
|
||||||
|
field=models.CharField(choices=[('unpaid', 'unpaid'), ('paid', 'paid'), ('partially_paid', 'partially paid'), ('waived', 'waived')], default='unpaid', max_length=250, verbose_name='fee status'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='signed_up_at',
|
||||||
|
field=models.DateField(blank=True, null=True, verbose_name='signed up at'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('active', 'active'), ('pending', 'pending'), ('lapsed', 'lapsed'), ('cancelled', 'cancelled')], default='pending', max_length=250, verbose_name='status'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='clubmembership',
|
||||||
|
constraint=models.UniqueConstraint(fields=('club', 'member', 'season'), name='unique_member_per_club_per_season'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='season',
|
||||||
|
constraint=models.UniqueConstraint(fields=('club', 'start_date', 'end_date'), name='unique_season_dates_per_club'),
|
||||||
|
),
|
||||||
|
]
|
||||||
31
club/migrations/0009_clubrole.py
Normal file
31
club/migrations/0009_clubrole.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-12 21:43
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0008_alter_clubmembership_unique_together_and_more'),
|
||||||
|
('members', '0002_alter_familymembership_unique_together_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ClubRole',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('role', models.CharField(choices=[('admin', 'admin'), ('member', 'member'), ('editor', 'editor')], default='member', max_length=250, verbose_name='role')),
|
||||||
|
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||||
|
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roles', to='members.member', verbose_name='member')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'club role',
|
||||||
|
'verbose_name_plural': 'club roles',
|
||||||
|
'ordering': ['club', 'member__last_name', 'member__first_name'],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('club', 'member'), name='unique_member_per_club')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
club/migrations/0010_club_archived_at.py
Normal file
18
club/migrations/0010_club_archived_at.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-13 13:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0009_clubrole'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='archived_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Archived clubs stop resolving on their subdomain, but their data is retained.', null=True, verbose_name='archived at'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
club/migrations/0011_alter_club_slug.py
Normal file
18
club/migrations/0011_alter_club_slug.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-13 13:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0010_club_archived_at'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='club',
|
||||||
|
name='slug',
|
||||||
|
field=models.SlugField(blank=True, help_text='Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app).', max_length=255, unique=True, verbose_name='slug'),
|
||||||
|
),
|
||||||
|
]
|
||||||
25
club/migrations/0012_club_logo_club_primary_color.py
Normal file
25
club/migrations/0012_club_logo_club_primary_color.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-13 17:33
|
||||||
|
|
||||||
|
import club.models
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0011_alter_club_slug'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='logo',
|
||||||
|
field=models.ImageField(blank=True, help_text="Shown on the club's own pages. Without one, the club's initials are used.", upload_to=club.models.club_logo_path, verbose_name='logo'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='primary_color',
|
||||||
|
field=models.CharField(blank=True, help_text="Hex colour for buttons and links on the club's pages, e.g. #1e40af.", max_length=7, validators=[django.core.validators.RegexValidator('^#[0-9a-fA-F]{6}$', 'Enter a colour as a hex value, e.g. #1e40af.')], verbose_name='primary colour'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-13 22:51
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0012_club_logo_club_primary_color'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='created',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 565739, tzinfo=datetime.timezone.utc), verbose_name='created'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='modified',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='modified'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='created',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 565911, tzinfo=datetime.timezone.utc), verbose_name='created'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='modified',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='modified'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubrole',
|
||||||
|
name='created',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 565939, tzinfo=datetime.timezone.utc), verbose_name='created'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubrole',
|
||||||
|
name='modified',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='modified'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='season',
|
||||||
|
name='created',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2026, 7, 13, 22, 51, 35, 565961, tzinfo=datetime.timezone.utc), verbose_name='created'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='season',
|
||||||
|
name='modified',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='modified'),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
club/migrations/0014_club_secondary_color.py
Normal file
19
club/migrations/0014_club_secondary_color.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-24 16:30
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0013_club_created_club_modified_clubmembership_created_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='secondary_color',
|
||||||
|
field=models.CharField(blank=True, help_text="Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour.", max_length=7, validators=[django.core.validators.RegexValidator('^#[0-9a-fA-F]{6}$', 'Enter a colour as a hex value, e.g. #be185d.')], verbose_name='secondary colour'),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
club/migrations/0015_alter_club_logo.py
Normal file
20
club/migrations/0015_alter_club_logo.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-24 16:40
|
||||||
|
|
||||||
|
import club.models
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0014_club_secondary_color'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='club',
|
||||||
|
name='logo',
|
||||||
|
field=models.FileField(blank=True, help_text="Shown on the club's own pages. Without one, the club's initials are used.", upload_to=club.models.club_logo_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'])], verbose_name='logo'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-03 11:22
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0015_alter_club_logo'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='amount_paid',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), help_text='Kept in step with payments by the fee service; not hand-edited.', max_digits=10, verbose_name='amount paid'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clubmembership',
|
||||||
|
name='fee_amount',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='fee amount'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FeePayment',
|
||||||
|
fields=[
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')),
|
||||||
|
('method', models.CharField(choices=[('bank_transfer', 'bank transfer'), ('cash', 'cash'), ('card', 'card'), ('other', 'other')], default='bank_transfer', max_length=20, verbose_name='method')),
|
||||||
|
('reference', models.CharField(blank=True, help_text='Bank reference, transaction id — whatever lets you find this again.', max_length=255, verbose_name='reference')),
|
||||||
|
('paid_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='paid at')),
|
||||||
|
('note', models.TextField(blank=True, verbose_name='note')),
|
||||||
|
('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='club.clubmembership', verbose_name='membership')),
|
||||||
|
('recorded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recorded_fee_payments', to=settings.AUTH_USER_MODEL, verbose_name='recorded by')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'fee payment',
|
||||||
|
'verbose_name_plural': 'fee payments',
|
||||||
|
'ordering': ['-paid_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-03 14:30
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='season_duration_months',
|
||||||
|
field=models.PositiveSmallIntegerField(default=12, help_text='How many months a season lasts, counted from its start date.', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(24)], verbose_name='season duration (months)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='season_start',
|
||||||
|
field=models.DateField(default=datetime.date(2000, 8, 1), help_text='Which day of the year a season begins — only the month and day are used, the year is ignored.', verbose_name='season start'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
club/migrations/0018_club_sport_type.py
Normal file
18
club/migrations/0018_club_sport_type.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-04 22:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0017_club_season_duration_months_club_season_start'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='sport_type',
|
||||||
|
field=models.CharField(choices=[('ice_hockey', 'Ice hockey'), ('other', 'Other')], default='other', help_text='Which sport this club plays -- determines which competitions and score fetchers are relevant to it.', max_length=20, verbose_name='sport'),
|
||||||
|
),
|
||||||
|
]
|
||||||
36
club/migrations/0019_sponsor.py
Normal file
36
club/migrations/0019_sponsor.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-06 15:02
|
||||||
|
|
||||||
|
import club.models
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0018_club_sport_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Sponsor',
|
||||||
|
fields=[
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||||
|
('logo', models.FileField(blank=True, upload_to=club.models.sponsor_logo_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'])], verbose_name='logo')),
|
||||||
|
('url', models.URLField(blank=True, help_text="The sponsor's own website, if they have one.", verbose_name='URL')),
|
||||||
|
('start_date', models.DateField(verbose_name='start date')),
|
||||||
|
('end_date', models.DateField(blank=True, help_text='Leave blank to keep this sponsor active indefinitely once it starts.', null=True, verbose_name='end date')),
|
||||||
|
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'sponsor',
|
||||||
|
'verbose_name_plural': 'sponsors',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-07 14:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from club.services.images import get_image_dimensions
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_logo_dimensions(apps, schema_editor):
|
||||||
|
"""Existing sponsors uploaded a logo before these fields existed, so
|
||||||
|
Sponsor.save()'s new dimension computation never ran for them."""
|
||||||
|
Sponsor = apps.get_model("club", "Sponsor")
|
||||||
|
for sponsor in Sponsor.objects.exclude(logo=""):
|
||||||
|
width, height = get_image_dimensions(sponsor.logo)
|
||||||
|
Sponsor.objects.filter(pk=sponsor.pk).update(logo_width=width, logo_height=height)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0019_sponsor'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sponsor',
|
||||||
|
name='logo_height',
|
||||||
|
field=models.PositiveIntegerField(blank=True, editable=False, null=True, verbose_name='logo height'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sponsor',
|
||||||
|
name='logo_width',
|
||||||
|
field=models.PositiveIntegerField(blank=True, editable=False, null=True, verbose_name='logo width'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(backfill_logo_dimensions, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user