Compare commits
238 Commits
c42963c447
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d8ee59e3a1 | |||
| 19ea65cae8 | |||
| 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 | |||
| a759f763ed |
@@ -36,3 +36,26 @@ DJANGO_STATICFILES_BACKEND=whitenoise.storage.CompressedManifestStaticFilesStora
|
||||
# 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=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
media
|
||||
private_media
|
||||
|
||||
# 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.
|
||||
|
||||
2
.idea/modules.xml
generated
2
.idea/modules.xml
generated
@@ -2,7 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<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>
|
||||
</component>
|
||||
</project>
|
||||
13
.idea/pyLspTools.xml
generated
13
.idea/pyLspTools.xml
generated
@@ -1,5 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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">
|
||||
<option name="enabled" value="true" />
|
||||
</component>
|
||||
|
||||
12
.idea/ClubManager.iml → .idea/rosterchief.iml
generated
12
.idea/ClubManager.iml → .idea/rosterchief.iml
generated
@@ -1,10 +1,10 @@
|
||||
<?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">
|
||||
<facet type="django" name="Django">
|
||||
<configuration>
|
||||
<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="environment" value="<map/>" />
|
||||
<option name="doNotUseTestRunner" value="false" />
|
||||
@@ -20,12 +20,4 @@
|
||||
<orderEntry type="jdk" jdkName="uv (ClubManager) (3)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_CONFIGURATION" value="Django" />
|
||||
<option name="TEMPLATE_FOLDERS">
|
||||
<list>
|
||||
<option value="$MODULE_DIR$/templates" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</module>
|
||||
601
ARCHITECTURE.md
601
ARCHITECTURE.md
@@ -26,6 +26,12 @@ The `isort` `known-first-party` roadmap lists nine apps. The build split the ori
|
||||
two apps (`formbuilder`, `shop`) are added **beyond the original roadmap** — add all new
|
||||
labels to `known-first-party` in `pyproject.toml` when they land. The target decomposition:
|
||||
|
||||
> Several rows below (`teams`, `events`, `members`, `club`'s `Season`/`ClubRole`) are marked
|
||||
> "planned" but have since been built out considerably further than this table reflects —
|
||||
> it hasn't been kept in lockstep with every session's work. Only `formbuilder` and the new
|
||||
> `evaluations` row have been corrected here; treat the rest as directional, not current,
|
||||
> and verify against the actual tree (per `CLAUDE.md`) before relying on a "planned" marker.
|
||||
|
||||
| App | Status | Responsibility | Models |
|
||||
|------------------|--------------|-----------------------------------------------------------|--------|
|
||||
| `authentication` | **built** | Login identity + tenancy/role services (global, cross-club) | `User` |
|
||||
@@ -33,12 +39,13 @@ labels to `known-first-party` in `pyproject.toml` when they land. The target dec
|
||||
| `club` | **built** | Tenant root, **season**, season-scoped affiliation, club roles | `Club`, `Season` *(planned)*, `ClubMembership`, `ClubRole` *(planned)* |
|
||||
| `teams` | planned | Teams and season rosters | `Team`, `TeamMembership`, `StaffAssignment` |
|
||||
| `events` | planned | Training / matches / social events + attendance | `Event`, `Attendance` |
|
||||
| `news` | planned | Editorial news for the public site | `Article`, `Category` |
|
||||
| `news` | **built** | Club news: coach_manager-authored, editor-released | `News`, `NewsPhoto` |
|
||||
| `pages` | planned | Flat CMS pages for the public site | `Page` |
|
||||
| `home` | planned | Homepage composition / featured content | `HomeConfig` (per-club) or config-only |
|
||||
| `formbuilder` | planned | Admin-defined dynamic forms + submissions + reporting | `Form`, `Field`, `Submission`, `Answer` |
|
||||
| `formbuilder` | **partial** | Admin-defined dynamic forms + submissions + reporting — models, dynamic-form-class builder, and the submit service are built (§5.6); no view/template renders a form for someone to fill in yet | `Form`, `Field`, `Submission`, `Answer` |
|
||||
| `shop` | planned | Cart-like shop, orders, payments, PDF invoices | `Product`, `Cart`, `CartItem`, `Order`, `OrderLine`, `Payment`, `Invoice` |
|
||||
| `search` | planned | Site search (likely no models; index/config only) | — |
|
||||
| `evaluations` | **design only** | Player evaluations: customizable rubric (reuses `formbuilder`), restricted to a new `EVALUATOR` role/ADMIN, player profile (skills + attendance + notes) (§5.8) | `EvaluationSettings`, `PlayerEvaluation` |
|
||||
|
||||
**`User` stays global** (one login identity across the whole platform); everything else
|
||||
that belongs to a club is tenant-scoped (§2.4). This is why `Member` — a *person within a
|
||||
@@ -217,17 +224,31 @@ platform-operator layer (`is_staff` / `is_superuser` in Django admin).
|
||||
```
|
||||
ClubRole(ClubScopedModel) # ClubScopedModel -> carries `club` (§2.4)
|
||||
member FK Member (CASCADE, related_name="roles")
|
||||
role CharField (TextChoices: MEMBER | EDITOR | TREASURER | BOARD)
|
||||
role CharField (TextChoices: MEMBER | EDITOR | TREASURER | BOARD | EVALUATOR)
|
||||
Meta: unique_together (club, member, role)
|
||||
```
|
||||
|
||||
| Role | Grants (representative) |
|
||||
|-------------|------------------------------------------------------------------------------|
|
||||
| *Public* | Anonymous — no row; read-only public site of that club. |
|
||||
| `MEMBER` | View own + family data, own rosters/attendance, own orders/invoices, submit member-only forms. |
|
||||
| `EDITOR` | Manage that club's `news`, `pages`, `formbuilder` content. |
|
||||
| `TREASURER` | Manage that club's `shop`: products, orders, payments, issue/void invoices. |
|
||||
| `BOARD` | Full management of that club: members, roles, all of the above. |
|
||||
> ⚠️ **This table is aspirational, not current.** The actually-implemented
|
||||
> `ClubRole.Roles` (`club/models.py`) is `ADMIN | MEMBER | EDITOR | MEMBER_ADMIN` — no
|
||||
> `TREASURER`/`BOARD` yet, and `MEMBER_ADMIN` (full read/write on people, short of
|
||||
> Finance/Club identity/role-granting — see `club.services.access.can_manage_members`)
|
||||
> isn't reflected below either. Treat this table as the target shape; verify the real
|
||||
> enum before writing code against it. `EVALUATOR` (§5.8) is **new, not yet added** to
|
||||
> either the aspirational list here or the real code enum.
|
||||
|
||||
| Role | Grants (representative) |
|
||||
|--------------|--------------------------------------------------------------------------------|
|
||||
| *Public* | Anonymous — no row; read-only public site of that club. |
|
||||
| `MEMBER` | View own + family data, own rosters/attendance, own orders/invoices, submit member-only forms. |
|
||||
| `EDITOR` | Manage that club's `news`, `pages`, `formbuilder` content. |
|
||||
| `TREASURER` | Manage that club's `shop`: products, orders, payments, issue/void invoices. |
|
||||
| `BOARD` | Full management of that club: members, roles, all of the above. |
|
||||
| `EVALUATOR` | Write + view **every** player evaluation club-wide (§5.8) — independent of `MEMBER_ADMIN`/coach `StaffAssignment`, since evaluating isn't the same trust boundary as either (a technical director might get this without full people-management access; a team's own coach doesn't get it just for coaching that team — see §5.8's own access note). Never granted to the evaluated player or their guardians. |
|
||||
|
||||
`news` is the one place a `ClubRole` and a derived role (`COACH_MANAGER`, see below)
|
||||
share a single workflow rather than each owning a separate permission: drafting is
|
||||
open to EDITOR/ADMIN *or* any coach_manager, but only EDITOR/ADMIN may publish —
|
||||
see §5.4.
|
||||
|
||||
`COACH` / `TEAM_MANAGER` are deliberately **not** `ClubRole`s — being a coach is always
|
||||
*of a team*, so it lives on `StaffAssignment` (§5.3). "Is this user a coach at this club?"
|
||||
@@ -291,6 +312,27 @@ a `FamilyRole` (`parent` / `child` / `guardian` / `other`), `unique_together (fa
|
||||
member)`; `Family.guardians` / `Family.children` are role-derived querysets. Powers the
|
||||
"parents see their children's data" object-scope (§3.1).
|
||||
|
||||
**`Group`** *(built)* + **`GroupMembership`** — a generic, tenant-scoped, **opaque** named
|
||||
collection of members: "all coaches", "all team managers", an ad-hoc committee. Deliberately
|
||||
minimal (`name` + a through-membership, same shape as `Family`/`FamilyMembership`) — it
|
||||
carries **no knowledge of any specific consumer** (not team-scoped, not referee-scoped, not
|
||||
anything-scoped). Any feature wanting to use "a named set of people" for something specific
|
||||
builds its own connective model elsewhere rather than teaching `Group` about that use case —
|
||||
see `teams.RefereeProfile` (§5.2), which deliberately does **not** go through `Group` even
|
||||
though an earlier draft of that feature did; referee eligibility is a fact about a *member*,
|
||||
not about group membership.
|
||||
|
||||
```
|
||||
Group(ClubScopedModel) # -> carries `club`
|
||||
name CharField
|
||||
Meta: UniqueConstraint(club, name)
|
||||
|
||||
GroupMembership(UUIDModel) # club implied by group
|
||||
group FK Group (CASCADE, related_name="memberships")
|
||||
member FK Member (CASCADE, related_name="group_memberships")
|
||||
Meta: UniqueConstraint(group, member)
|
||||
```
|
||||
|
||||
### `club`
|
||||
|
||||
**`Club`** — **the tenant root** (§2.4). Currently just `name`; extend with `slug` (unique,
|
||||
@@ -302,6 +344,47 @@ season-scoped** (§5.1): it gains a `season` FK and sign-up / fee-status fields,
|
||||
is one member's affiliation for one season (`unique_together (club, member, season)`). This
|
||||
is the record the `MEMBER` role and shop fulfilment key off of (§3.4, §5.7).
|
||||
|
||||
### `billing` — what the platform charges a club
|
||||
|
||||
**Deliberately NOT club-scoped, and the only app that isn't.** `shop` (§5.7) is a club charging
|
||||
its *members* — tenant data, owned by the club. `billing` is RosterChief charging the *club*:
|
||||
platform-owned, never visible to a club user except as the one notice described below. 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.
|
||||
|
||||
**`Plan`** — a duration and three clocks, named for what they measure *from*, which is the easy
|
||||
thing to get wrong: `duration_months` (period length, 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 stay unpaid). Two `CheckConstraint`s keep them coherent. `is_trial` marks a plan offered as a
|
||||
trial; a trial's length is simply its own `duration_months`. `deleted_at` is a soft-delete marker:
|
||||
`Due.plan` is `PROTECT`, so a plan that has ever billed anyone can't really be removed — "delete"
|
||||
hides it (`Plan.objects.visible()` excludes it) and unsubscribes every club currently on it instead;
|
||||
see `billing/services/plans.py` and `BILLING.md` §11.
|
||||
|
||||
**`PlanPrice`** — a dated price (`active_from`). A rate change is a new row, never an edit, so
|
||||
every period already opened keeps what it was billed at.
|
||||
|
||||
**`Subscription`** — one per club (`OneToOneField`): its current `plan`, `auto_renew`,
|
||||
`auto_archive`, and the trial pair (`trial_ends_at` + `post_trial_plan`, constrained to be set
|
||||
together or not at all).
|
||||
|
||||
**`Due`** — one billing period for one club, and **the snapshot boundary**. `plan`, `amount`,
|
||||
`period_end` and `grace_until` are all frozen when the period opens and never read back through
|
||||
the plan at display time: raise a price or edit a plan's grace and last year's invoice must still
|
||||
say what was actually charged. Storing the computed *dates* rather than the plan's *numbers* is
|
||||
what buys that.
|
||||
|
||||
**`DuePayment`** / **`Invoice`** — money received against a due (several may land on one), and the
|
||||
gapless per-year invoice number. The PDF itself is rendered on demand from the `Due` snapshot;
|
||||
only the number is stored.
|
||||
|
||||
All lifecycle changes go through `billing/services/` — `dues.py` (open, renew, pay, waive,
|
||||
archive), `notices.py` (the one club-facing warning), `reminders.py` (its email). Never through
|
||||
the models directly: a `Due` whose `amount_paid` disagrees with its payments is a wrong invoice.
|
||||
|
||||
**`BILLING.md` is the authoritative document for this app** — the lifecycle, the worked timelines,
|
||||
and the migration hazards live there rather than here.
|
||||
|
||||
---
|
||||
|
||||
## 5. Planned models (design)
|
||||
@@ -337,14 +420,125 @@ Season(ClubScopedModel) # -> carries `club`
|
||||
ClubMembership(ClubScopedModel) # -> carries `club`
|
||||
member FK Member (CASCADE, related_name="club_memberships")
|
||||
season FK Season (PROTECT, related_name="memberships")
|
||||
kind CharField (TextChoices: member | guardian) # default member
|
||||
license CharField (blank) # federation license for that season
|
||||
status CharField (TextChoices: pending | active | lapsed | cancelled)
|
||||
fee_status CharField (TextChoices: unpaid | partial | paid | waived)
|
||||
signed_up_at DateTimeField (null) # when the member registered for the season
|
||||
activated_at DateTimeField (null) # when membership became active (usually on payment)
|
||||
activated_at DateTimeField (null) # when membership became active (admin approval only, never on payment alone)
|
||||
Meta: unique_together (club, member, season); ordering = ["-season__start_date", ...]
|
||||
```
|
||||
|
||||
- **`kind` separates a member from a guardian** *(built)*. A guardian is a parent attached
|
||||
to the club only through their child: they hold the login, can be contacted and can sit in
|
||||
a Group, but they are **not a member** — no fee, absent from the member list, the fee list
|
||||
and every member KPI (club + platform), and not eligible for a roster *or* a staff spot. A
|
||||
parent who also plays or coaches is a `member` who happens to be a parent; the two facts
|
||||
are independent, which is why this is its own field rather than being inferred from
|
||||
`members.FamilyMembership.role`. Before this existed, `members/services/family.py` enrolled
|
||||
a parent exactly like the child, so every parent counted as a member — a data migration
|
||||
reclassifies them, deliberately skipping anyone who plays, is on a team's staff, or holds
|
||||
an elevated ClubRole.
|
||||
- **Onboarding a legacy roster: `members.ParentClaim`** *(built)*. The migration path for a
|
||||
club that arrives with a list of children and no parent records at all. Children import
|
||||
without logins, each into a **family of their own** — that shape *is* the "nobody is
|
||||
responsible for this child" state (`members/services/claims.py::families_awaiting_a_parent`),
|
||||
so there is no flag to drift out of step with reality, and the family drops off the worklist
|
||||
by itself the moment a parent joins it. `family_role=child` with a blank `family_group` is
|
||||
what asks for it; any other lone role is still a mistake in the file.
|
||||
- **Verification is a human decision, deliberately.** A parent submits a public form
|
||||
(`/claim/`) with the child's name and date of birth as **free text — no search, no
|
||||
autocomplete, no confirmation of whether the child was found**, because the page is
|
||||
reachable without logging in and anything that resolved the child would turn it into a way
|
||||
to enumerate the club's children. An admin matches it from a queue against a shortlist that
|
||||
only ever contains children with nobody on file, so approving can never quietly re-parent a
|
||||
child who already has one. The alternatives were rejected: a claim code needs a delivery
|
||||
channel the club may not have, and matching on name plus birthday hands out someone else's
|
||||
child to whoever guesses a birthday.
|
||||
- **The claim form is also the registration.** Open self-registration is closed
|
||||
(`club.views.signup_closed` shadows `account_signup` rather than removing the route, so the
|
||||
URL name allauth's own templates reverse still resolves). Accounts are created by an admin,
|
||||
by the family-registration form, or on claim approval — never by a stranger, which also
|
||||
keeps the review queue from being a spam target. The account is created **on approval**, not
|
||||
on submission, so a public form can't fill the user table.
|
||||
- **An approved parent lands as a guardian**, with the login and the family link but no
|
||||
membership and no fee. If they also play, an admin flips `kind` on their membership
|
||||
afterwards; approving a claim is not the place to decide it. They then get a password-reset
|
||||
link and a minimal "my family" page (`members/views.py::MyFamilyView`) — the seam a real
|
||||
parent portal would grow from.
|
||||
- **`Club.contact_email`** *(built)* — the club's own public address, set from the control
|
||||
panel next to `legal_name`. Shown to the parent both in the submission flash and in the
|
||||
approval email, as somewhere to write if something's wrong — falls back to nothing shown at
|
||||
all when unset, same pattern as `legal_name`/`official_name`.
|
||||
- **The flash after submitting is worded and timed to reveal nothing.** Sent *before* any
|
||||
lookup happens, from a fixed string that never varies with whether a matching child was
|
||||
actually found (`members/views.py::ParentClaimView.form_valid`) — a message that differed
|
||||
would be exactly the enumeration channel free-text matching was built to avoid.
|
||||
- **Approval emails a real one-time set-password link**, built with allauth's own token
|
||||
generator (`default_token_generator`/`user_pk_to_url_str`) so it lands in the same flow the
|
||||
login page's own reset would send them to, rather than a second, parallel one that could
|
||||
drift out of step with it. Sending is never allowed to fail the approval — the family link
|
||||
and the guardian row are real either way, and a briefly unreachable mail server must not
|
||||
cost the parent their place in the queue; the admin sees a distinct warning message
|
||||
(`management/views.py::ParentClaimApproveView`) telling them the email didn't go and to have
|
||||
the parent use "Forgot your password?" instead.
|
||||
- **A signed-in parent claiming a second child links, never duplicates.**
|
||||
`ParentClaim.submitted_by_user` (nullable FK to `authentication.User`) records who was
|
||||
actually logged in when a claim was submitted — kept separate from the free-text
|
||||
`parent_email`/name fields, which stay exactly as typed as the admin's audit trail even when
|
||||
they drift from the account. When set, `members/views.py::ParentClaimView` drops the parent
|
||||
fields from the form entirely (shown as read-only "submitting as…" text, not just pre-filled
|
||||
inputs — a disabled input doesn't even submit, and a pre-filled-but-editable one still lets a
|
||||
typo through) and populates them server-side from the `Member` instead of trusting POST data.
|
||||
On approval, `members/services/claims.py::approve_claim` resolves the parent via that FK
|
||||
(never re-derived from email) and, if they already belong to a `Family`, moves the newly
|
||||
claimed child's `FamilyMembership` onto *that* family rather than leaving them split across
|
||||
two households — the child's now-empty solo family is cleaned up the same way
|
||||
`members/services/family.py::detach_from_family` already does elsewhere. No existing family →
|
||||
falls back to the original behaviour of joining the child's own family. This is the one place
|
||||
a parent, once they exist, is the anchor for "one household," not the child.
|
||||
- **The "already dealt with" history is scoped to the current season.** Last season's approved
|
||||
and rejected claims are real history but not something a club needs staring at year after
|
||||
year, so `management/views.py::ParentClaimListView` filters `reviewed` to
|
||||
`reviewed_at__date__gte=` the current season's start (empty, not erroring, when there's no
|
||||
current season) rather than dropping the section outright.
|
||||
- **Why a field and not a separate model.** Everything that answers "is this person attached
|
||||
to this club" already reads through `ClubMembership` — tenancy scoping, group membership,
|
||||
the club-wide event audience — and a second kind of link would need a parallel path through
|
||||
all of it. What changes is only who *counts*.
|
||||
- **Guardians are carried forward across seasons.** Their tie to the club isn't really
|
||||
seasonal (it lasts as long as the child is there) but it rides on a per-season row, so
|
||||
`club/services/seasons.py::_carry_guardians_into` copies them when a season is created and
|
||||
`members/services/family.py::carry_guardians_forward` covers a guardian added after later
|
||||
seasons already existed. Copied from the *immediately preceding* season only, so a guardian
|
||||
an admin deliberately removed stays removed rather than being resurrected from an old row.
|
||||
- **Excluding guardians is a subtraction, not a narrower filter.**
|
||||
`club/services/access.py::members_visible_to` subtracts `_guardians_only(club)` rather than
|
||||
matching only member-kind rows: a bare MEMBER `ClubRole` with no `ClubMembership` is a real
|
||||
state (someone the club knows but hasn't signed up yet), and narrowing the role branch to
|
||||
weed guardians out would take those people with it. Pass `include_guardians=True` where the
|
||||
page is about a *person* rather than about members — a guardian's own detail page, editing
|
||||
them, the group pickers, or a family page (whose parents are the whole point).
|
||||
- **The members list makes the member/guardian split visible instead of silent.**
|
||||
`management/views.py::MemberListView` reads `?kind=member|guardian|both` (default `member`,
|
||||
unchanged from before this filter existed) — without it, a guardian was simply absent from
|
||||
the page with nothing explaining why, which read as a bug rather than the intended "guardians
|
||||
aren't members" design. `management/views.py::FamilyListView` (`management:family_list`) is
|
||||
the complementary household-first view: one row per family, its parents/guardians in one
|
||||
column and its children in another, with the family name and a separate "Edit" action both
|
||||
landing on `family_detail` — matching the "Edit goes to the overview, not a bare rename form"
|
||||
convention the Groups list already established.
|
||||
- **List views are paginated** (`MemberListView`, `EventListView`, `TeamListView`,
|
||||
`GroupListView`, `NewsListView`, `FamilyListView`; `paginate_by = 25`) through one shared
|
||||
`management/templates/management/_pagination.html` partial, using Django's `{% querystring %}`
|
||||
tag so a page link preserves whatever `?q=`/`?kind=`/etc. filter is active rather than
|
||||
resetting it.
|
||||
- **The sidebar surfaces two counts that would otherwise require opening the page to see**:
|
||||
pending parent claims, and games in the next 10 with no referee assigned (the latter shares
|
||||
its query with `RefereeManagementDashboardView`'s own KPI, factored out so the two can't drift
|
||||
apart). Both are admin-gated the same way the rest of that nav section is, and both render
|
||||
`0` explicitly rather than hiding the badge — the point is to make "nothing outstanding" just
|
||||
as visible as "something needs attention," not to declutter the zero case away.
|
||||
- One row per member **per season** — sign-up and fee payment are tracked independently
|
||||
each season. `unique_together` moves from `(club, member)` → `(club, member, season)`
|
||||
(a data migration must backfill existing rows with the current season).
|
||||
@@ -389,6 +583,82 @@ number — modeled by `TeamMembership`, exactly matching the domain note.
|
||||
extra tenancy field is needed on the constraint.
|
||||
- `StaffAssignment` drives the coach/manager object-scope (§3.1–3.2) — it *is* the "is a
|
||||
coach of this team" fact; no `ClubRole` mirrors it.
|
||||
- **Bulk add is a row formset, not a table of every member** (`TeamBulkAddView`, and the same
|
||||
shape for groups in `GroupBulkAddView`). Each row picks one person from a searchable select
|
||||
and one `Position`; the *position* decides what the row means — `Position.staff_position`
|
||||
true ⇒ a `StaffAssignment`, otherwise a `TeamMembership` with an optional jersey number and
|
||||
captain/alternate-captain flags — so there's no separate "player or staff?" control that
|
||||
could disagree with the position picked. Jersey number and captaincy exist only on
|
||||
`TeamMembership`, so a staff row rejects them (and the row script greys them out, keyed off
|
||||
the `data-staff` marker `PositionSelect` stamps on staff options). Captain *and* alternate
|
||||
captain on one row is refused as self-contradictory, but how many captains a team may have
|
||||
is left alone: neither the model nor the single-add form constrains it, and inventing the
|
||||
rule in one entry path only would be worse than not having it.
|
||||
A playing coach is simply two rows. Rows are cloned client-side from the formset's
|
||||
`empty_form` (`static/js/bulk-add-rows.js`); removing one deletes the node and deliberately
|
||||
leaves `TOTAL_FORMS` alone, since Django reads a form whose fields are absent from the POST
|
||||
as an unchanged extra and skips it — safe, unlike re-indexing live inputs. The earlier
|
||||
design rendered *every* eligible member as a table row, which a club with a hundred-plus
|
||||
members can't use, and its search was a GET round-trip that discarded anything already
|
||||
ticked. **All-or-nothing on submit**: one bad row re-renders the page with every row still
|
||||
filled in and the offending field flagged, rather than saving the good rows and losing the
|
||||
rest (a partial save is far more costly when the rows were typed by hand). Cross-row checks
|
||||
no single row can see — the same person twice, two rows claiming one jersey — live on the
|
||||
formset's `clean()`; per-row checks (already assigned, jersey already taken by an existing
|
||||
entry) live on the row form. Eligibility is never trusted from the POST: the member field's
|
||||
queryset is `eligible_roster_members`, so an id that was never offered fails its own lookup.
|
||||
The member `choices` are built once in the view and assigned onto each row's field —
|
||||
a `ModelChoiceField` otherwise re-runs its queryset per form, i.e. once per row.
|
||||
|
||||
**As built, `Team` also carries `referee_management`** (`TextChoices`: `club` | `federation`,
|
||||
default `club`) — whether the *club* arranges referees for this team's home games, or the
|
||||
*federation* does. A federation-managed team is left out of the referee tools **entirely**:
|
||||
no eligibility, no assignment, no entry on the referee management dashboard (§5.3) — see
|
||||
`events/services/referees.py::needs_referee_management(event)`, the single gate every
|
||||
referee-facing screen reads through.
|
||||
|
||||
**`RefereeLevel`** *(built)* — a club-defined referee qualification tier ("Regional",
|
||||
"National", ...), admin-managed like `Position` (own name, own ordering, no fixed list).
|
||||
**Owns which teams it qualifies for** — eligibility is a property of the *level*, not of the
|
||||
individual referee: a club configures a handful of levels once, each unlocking a tier of
|
||||
teams, rather than hand-picking teams per referee.
|
||||
|
||||
```
|
||||
RefereeLevel(ClubScopedModel) # -> carries `club`
|
||||
name CharField
|
||||
ordering PositiveSmallIntegerField (default=0)
|
||||
teams M2M Team (blank=True, related_name="referee_levels")
|
||||
Meta: UniqueConstraint(club, name); ordering = ["ordering", "name"]
|
||||
```
|
||||
|
||||
**`RefereeProfile`** *(built)* — a **member-level** fact: which level this member holds and
|
||||
how long it's valid for. Which teams that translates to is *derived* (`eligible_teams`),
|
||||
never picked per member. Managed from the member's own page (`management`), read (not
|
||||
edited) from the team's own page too. Deliberately **not** routed through `members.Group` —
|
||||
eligibility is a property of a person, not of a group they might belong to; see the note on
|
||||
`Group` above for why an earlier draft that did this was reworked. It also deliberately does
|
||||
**not** put `teams` directly on the profile — a later draft of this feature did that too,
|
||||
before the levels-own-the-teams shape replaced it, matching how real officiating
|
||||
qualifications actually work (a certification tier unlocks a tier of competitions).
|
||||
|
||||
```
|
||||
RefereeProfile(UUIDModel) # club reachable via member -- Member itself has no club FK
|
||||
member OneToOneField members.Member (CASCADE, related_name="referee_profile")
|
||||
level FK RefereeLevel (PROTECT, null=True, blank=True, related_name="referees")
|
||||
valid_until DateField (null=True, blank=True)
|
||||
```
|
||||
|
||||
- **`is_currently_valid`** (property): `valid_until` is set and hasn't passed — a pure date
|
||||
check, independent of whether a level is even set.
|
||||
- **`is_eligible`** (property): the full gate every consumer reads through (the event assign
|
||||
panel, the team page, the referees list) — `level` is set **and** `is_currently_valid`.
|
||||
Once `valid_until` passes, `is_eligible` flips to `False` and the referee drops out of
|
||||
every eligibility query until the date is extended; nothing else needs to change.
|
||||
- **`eligible_teams`** (property): `level.teams.all()` when `is_eligible`, else empty.
|
||||
- One `RefereeProfile` per member (`OneToOneField`) rather than a field bag on `Member`
|
||||
itself, matching this file's general pattern of keeping `Member` a plain identity record
|
||||
and hanging every role-specific fact off its own small table (`ClubMembership`,
|
||||
`StaffAssignment`, `TeamMembership`, and now this).
|
||||
|
||||
### 5.3 `events`
|
||||
|
||||
@@ -416,18 +686,156 @@ Attendance(UUIDModel) # through model Event <-> Member
|
||||
club-wide (`team=None`), so `season` stays a first-class FK. Keep it consistent in a
|
||||
service/clean().
|
||||
|
||||
### 5.4 `news`, `pages`, `home` (public site / editorial)
|
||||
**As built, `Attendance` also carries `showed_up`** (nullable bool, default `None`) —
|
||||
deliberately separate from `status`: `status` is the RSVP, `showed_up` is whether they
|
||||
actually turned up, set by a check-in. `None` means "never checked in" (true for every
|
||||
row today — there's no check-in UI yet, only Django admin); a "no-show" is
|
||||
`status in (present, selected)` and `showed_up is False`, and is *never* inferred from
|
||||
a missing check-in. See `events/services/attendance.py::record_check_in` and
|
||||
`management/views.py::TeamDetailView`'s attendance panel.
|
||||
|
||||
**As built, an `Event`'s (and `EventSeries`') audience is teams + groups + invited/excluded
|
||||
members, or the whole club** — `teams` (existing) sits alongside `groups`
|
||||
(`M2M members.Group`, blank) and `club_wide` (`BooleanField`, default `False`). All three
|
||||
feed `events/services/attendance.py::effective_members`: teams contribute their
|
||||
season-scoped roster, groups contribute *every current* `GroupMembership` (Group has no
|
||||
season concept, unlike Team, so this isn't season-scoped the way a team roster is), and
|
||||
`invited_members`/`excluded_members` still layer on top/subtract as before — **unless**
|
||||
`club_wide` is set, which replaces teams+groups with every member holding an ACTIVE
|
||||
`ClubMembership` for the event's season instead (`invited_members`/`excluded_members` still
|
||||
apply on top of that too). `club_wide` can't be combined with `teams`/`groups` — enforced in
|
||||
`EventForm`/`EventSeriesForm.clean()` (`clean_club_wide_excludes_teams_and_groups`), not the
|
||||
DB (an M2M can't be checked from a `CheckConstraint`, and can't be validated in `Event.clean()`
|
||||
either — M2M state isn't available until the row has a PK). `events/signals.py` re-syncs a
|
||||
group's future events on `GroupMembership` change, and a club_wide event's future rows on
|
||||
`ClubMembership` change, the same way a `TeamMembership` change already re-syncs team events.
|
||||
- **Authorization**: `club.services.access.groups_manageable_by(user, club)` mirrors
|
||||
`teams_managed_by` — every group for an ADMIN, else only the ones the user is themselves a
|
||||
`GroupMembership` of (Group has no manager/owner concept the way Team does via
|
||||
`StaffAssignment`, so membership is the only claim there is). A non-admin must select at
|
||||
least one team they manage **or** group they belong to when creating/editing an event
|
||||
(`EventAudienceFormMixin.clean_audience_requires_a_claim_for_non_admins`) — `club_wide`
|
||||
itself stays admin-only (the field is removed from the form entirely for anyone else, not
|
||||
just rejected on submit). `club.mixins.EventManagerRequiredMixin` (gates edit/delete/detach/
|
||||
etc. on an *existing* event/series) checks the same two claims via `get_teams()`/
|
||||
`get_groups()` (the latter defaults to none, so a view that never deals with groups doesn't
|
||||
need to override it) — without this, a non-admin who created a group-only event via the
|
||||
broader create-time gate would immediately be locked out of managing what they just made.
|
||||
|
||||
**As built, a GAME-kind `Event` defaults its own `end`** — `Event.save()` sets
|
||||
`end = start + events.models.ASSUMED_EVENT_DURATION` (2 hours) whenever a game is saved
|
||||
with no explicit `end`, and never overwrites one that's already set. Other event kinds are
|
||||
untouched — `end` stays blank for them unless explicitly given one. The public games API
|
||||
(`events/api.py`, `GET /games/upcoming/`) reads through this: it returns every non-cancelled
|
||||
game/tournament that **hasn't finished yet** (`end` — explicit, defaulted, or, for the rare
|
||||
un-saved-since / non-GAME row still lacking one, `start` within the assumed window — is at or
|
||||
after now), not just ones that haven't started, so a game already in progress keeps showing up
|
||||
until its window closes; `GameOut.end` is always populated the same way, and `status` treats
|
||||
"started but before its (assumed) end, not flagged `is_live`" as `"live"` too, so a game
|
||||
`/games/upcoming/` still lists never turns around and calls itself `"finished"`.
|
||||
|
||||
**As built, `Event` also carries `max_referees`** (`PositiveSmallIntegerField`, default
|
||||
`2`) and **`EventReferee`** *(built)* — referee sign-up/assignment for a **home game**
|
||||
only (`Event.is_home_game`), staff-assigned for now (self-service subscribe is a planned
|
||||
extension, §7). A referee row is either a club member **or** an externally-logged name
|
||||
(e.g. a federation-appointed referee the club still needs to pay), never both/neither, and
|
||||
carries its own payment snapshot:
|
||||
|
||||
```
|
||||
news.Article(ClubScopedModel) # -> carries `club`
|
||||
title, slug (SlugField), body (TextField)
|
||||
excerpt (blank), cover_image (ImageField, null)
|
||||
author FK members.Member (SET_NULL, null, related_name="articles")
|
||||
category FK news.Category (SET_NULL, null)
|
||||
is_published BooleanField; published_at DateTimeField (null)
|
||||
Meta: unique_together (club, slug); ordering = ["-published_at"]
|
||||
EventReferee(UUIDModel) # club implied by event
|
||||
event FK Event (CASCADE, related_name="referees")
|
||||
member FK Member (CASCADE, null=True, blank=True, related_name="referee_assignments")
|
||||
external_name CharField (blank=True) # set instead of member for a non-member referee
|
||||
assigned_by FK Member (SET_NULL, null=True, related_name="+")
|
||||
fee DecimalField (default 0.00)
|
||||
km DecimalField (null=True, blank=True)
|
||||
km_rate DecimalField (null=True, blank=True) # snapshotted per assignment, not a
|
||||
# live club-wide setting
|
||||
Meta: unique_together (event, member); CheckConstraint XOR(member, external_name)
|
||||
display_name / is_external / km_total / total_payable # computed properties
|
||||
```
|
||||
|
||||
news.Category(ClubScopedModel): name, slug # Meta: unique_together (club, slug)
|
||||
- **Eligibility** comes from `teams.RefereeProfile.is_eligible`/`eligible_teams` (§5.2): a
|
||||
member is eligible to referee an event if their profile is currently eligible (a level is
|
||||
set and its validity hasn't passed) and that level qualifies for one of the event's
|
||||
`teams`. `events/services/referees.py::eligible_referees(event)` computes this, and is
|
||||
empty for anything `needs_referee_management(event)` says no to — not a home game, or a
|
||||
home game whose team(s) are all federation-managed (§5.2). External referees bypass
|
||||
eligibility entirely (`add_external_referee`) — they're logged by name only, not vetted
|
||||
against a level.
|
||||
- **Assignment is admin-only for now**, stricter than most event actions (a team
|
||||
manager/coach can edit the event itself, but not the referee panel's assign/remove/fee
|
||||
controls) — see `EventRefereeAssignView`/`EventRefereeRemoveView`/
|
||||
`EventRefereeAddExternalView`/`EventRefereeFeeUpdateView` (all `ClubAdminRequiredMixin`)
|
||||
and `EventDetailView`'s separate `can_manage_referees` flag. A team manager still **sees**
|
||||
the panel (who's assigned, capacity, fees) — visibility and authority are deliberately
|
||||
split here, same reasoning as §3's "coach visibility ≠ coach authority" for team rosters.
|
||||
- **The referee management dashboard** (`management:referee_management`, admin-only) is the
|
||||
one-stop alternative to hunting through individual events: every upcoming home game
|
||||
`needs_referee_management`, with inline assign/remove/add-external/fee-editing (posting to
|
||||
the same views the event detail page uses, returning to the dashboard via a `next` param
|
||||
rather than the event detail page). It leads with KPI tiles (games in view, without a
|
||||
referee, partially staffed, fully staffed) and a button-based range filter (this
|
||||
week/this+next week/next 10/25/50 — an ISO-week window for the calendar options, a flat
|
||||
slice for the count ones), then lists games grouped by date as compact tiles; each tile's
|
||||
"Manage" button opens a `<dialog>` with the full assign/remove/external/fee panel so the
|
||||
list itself stays scannable. Both the dashboard and the event detail page share one
|
||||
`_referee_assignment_panel.html` include so this UI never drifts out of sync between them.
|
||||
- **`max_referees` is a hard ceiling everywhere** — staff and external assignment included.
|
||||
Enforced in `_lock_and_check_capacity()` (shared by `assign_referee()` and
|
||||
`add_external_referee()`), which locks the `Event` row (`select_for_update`) for the
|
||||
duration of the count-check + write so two admins assigning at the same moment can't both
|
||||
squeeze past the ceiling.
|
||||
- **Schedule conflicts are a soft warning, never a block.** `conflicting_events(member,
|
||||
event)` finds other events overlapping this one's time window where the member is part of
|
||||
the expected audience (`effective_members`, reused from the attendance service above) — the
|
||||
UI shows it (⚠ + tooltip on the assign control) but a human decides; an event with no
|
||||
explicit `end` is assumed to run `events.models.ASSUMED_EVENT_DURATION` (2 hours) for this
|
||||
check. External referees have no conflict check (no member to check a schedule against).
|
||||
`ASSUMED_EVENT_DURATION` is also what `Event.save()` writes into `end` for a GAME with none
|
||||
set (below) — the *other* event kinds still leave `end` blank rather than defaulting it, so
|
||||
this read-time fallback still matters for them.
|
||||
- **`assigned_by` is required for now** (admin-only assignment). A future self-service
|
||||
sign-up would make it nullable to mean "the referee signed themself up" rather than adding
|
||||
a parallel model — see §7.
|
||||
- **The referee payment form is a downloadable PDF** (`event_referee_form_pdf`,
|
||||
`EventRefereeFormPdfView`, admin-only, WeasyPrint via `management/pdf.py`'s lazy-import
|
||||
pattern), modeled directly on the club's existing paper form: game details, referee names,
|
||||
a fee+km breakdown per referee, and blank signature lines (referee always; team manager
|
||||
left blank — not reliably known at print time). The header uses `Club.official_name`
|
||||
(`legal_name` if the club has set one, else plain `name` — §2.2) and the club's home
|
||||
`Location` address; the body's payment sentence uses the plain `name` — mirroring the
|
||||
original paper form, which itself uses a longer legal form up top and a shorter one in the
|
||||
body text.
|
||||
|
||||
### 5.4 `news`, `pages`, `home` (public site / editorial)
|
||||
|
||||
**`news` is built** (as of the coach_manager-authoring / editor-release-flow work) —
|
||||
team-tagged instead of categorised, with a two-step release flow rather than a bare
|
||||
`is_published` flag:
|
||||
|
||||
```
|
||||
news.News(ClubScopedModel) # -> carries `club`
|
||||
title, slug (SlugField, auto from title), body (TextField)
|
||||
title_en CharField (blank) -- optional English translation of `title`
|
||||
body_en TextField (blank) -- optional English translation of `body`
|
||||
teams M2M teams.Team (blank -- empty means club-wide)
|
||||
visibility CharField (TextChoices: internal | external | both)
|
||||
status CharField (TextChoices: draft | published)
|
||||
published_at DateTimeField (null) -- may be in the future: a *scheduled* release,
|
||||
not a cron-flipped field (see below)
|
||||
created_by FK members.Member (SET_NULL, null, related_name="news_items")
|
||||
Meta: unique_together (club, slug); ordering = ["-created"]
|
||||
|
||||
news.NewsPhoto(UUIDModel) # club reached via news_item, not directly scoped
|
||||
news_item FK news.News (CASCADE, related_name="photos")
|
||||
image ImageField
|
||||
is_main BooleanField
|
||||
ordering PositiveSmallIntegerField
|
||||
Meta: UniqueConstraint(fields=["news_item"], condition=Q(is_main=True))
|
||||
-- a partial unique index enforcing "at most one main photo per item"
|
||||
at the DB level, the same trick teams.Position uses for
|
||||
management_position_implies_staff_position.
|
||||
|
||||
pages.Page(ClubScopedModel) # flat CMS pages: "About", "Contact", ...
|
||||
title, slug, body (TextField)
|
||||
@@ -439,12 +847,40 @@ home.HomeConfig(ClubScopedModel) # one row PER CLUB: featured articles/teams,
|
||||
# (unique_together (club,) — one per tenant). May be config-only.
|
||||
```
|
||||
|
||||
- **`Article.author` links to `members.Member`** (decision §7 #5) — attribution is to a
|
||||
- **Authoring vs. releasing are deliberately separate authorities**
|
||||
(`club/services/access.py::can_add_news`/`can_publish_news`/`can_edit_news`): any
|
||||
current-season coach_manager (management-position `StaffAssignment`), EDITOR, or
|
||||
ADMIN can draft a `News` item and edit it while it's a draft; only EDITOR/ADMIN can
|
||||
move it to `published` (or edit it once it is) — a physio or plain staff member can't
|
||||
post news, and a coach_manager can't push their own draft live.
|
||||
- **Scheduling needs no cron job.** `published_at` can be set in the future; `status`
|
||||
already reads `PUBLISHED` (it passed the editor's release gate) but `News.is_scheduled`
|
||||
is true until that moment passes. A later public/member-facing consumer just filters
|
||||
`status=PUBLISHED, published_at__lte=now()` — nothing has to flip a row at the
|
||||
scheduled instant.
|
||||
- **`created_by` links to `members.Member`** (decision §7 #5) — attribution is to a
|
||||
club person, not a raw login; `SET_NULL` so deleting a member doesn't erase their posts.
|
||||
- `slug`s back clean public URLs and feed `search`; they are **unique per club** (§2.4), so
|
||||
two clubs can both have `/news/season-kickoff`. Resolve within the request's club.
|
||||
- `cover_image` / hero images use `ImageField` → **media storage must be configured** (§8).
|
||||
If page/news trees grow, consider a tree library later — start flat.
|
||||
- `visibility` (internal/external/both) is enforced by the public read-only API
|
||||
(`news/api.py`, mounted under `api/`) — only `external`/`both` items, published and
|
||||
past their release date, are ever returned. No member-facing internal reading page
|
||||
exists yet; that's later work.
|
||||
- **`title`/`body` are Dutch (the club's own language, and the only one required);
|
||||
`title_en`/`body_en` are an optional English translation**, both left blank by
|
||||
default. Nothing computes or stores a fallback — `News.effective_title_en` /
|
||||
`effective_body_en` resolve it on read (`title_en or title`), so translating a Dutch
|
||||
edit later never leaves a stale English copy behind, and every existing row gets
|
||||
correct fallback behaviour with no backfill. The public API always returns both
|
||||
languages in one call (`title_nl`/`body_nl`/`excerpt_nl` alongside
|
||||
`title_en`/`body_en`/`excerpt_en`, the latter three via the `effective_*` properties
|
||||
so they're never blank) — no `?lang=` param, the consumer picks what it needs. The
|
||||
control panel's news form lays the two languages out in side-by-side columns
|
||||
(`management/templates/management/news_form.html`); the detail page only shows an
|
||||
"English" section when a translation was actually added, not the fallback-filled
|
||||
text under a second heading.
|
||||
- `NewsPhoto.image` / hero images use `ImageField` → **media storage must be configured**
|
||||
(§8). If page/news trees grow, consider a tree library later — start flat.
|
||||
|
||||
### 5.5 `search`
|
||||
|
||||
@@ -691,6 +1127,67 @@ per-member entitlements would extend this — add an eligibility rule / code fie
|
||||
auto-apply service on top of the same model when that need is real, rather than a parallel
|
||||
mechanism.
|
||||
|
||||
### 5.8 `evaluations` — player evaluations *(new app, design only)*
|
||||
|
||||
Coaching-staff-only assessment of a player: a customizable rubric (skills, ratings, notes),
|
||||
recorded per team/season, feeding a player profile alongside their attendance history —
|
||||
**never visible to the evaluated player or their guardians**, regardless of how they're
|
||||
otherwise permitted (a parent with `EVALUATOR` sees every *other* child's evaluations but
|
||||
not their own kid's — see the access note below).
|
||||
|
||||
**Built on `formbuilder` (§5.6), not a parallel form engine.** `formbuilder`'s `Form`/
|
||||
`Field`/`Submission`/`Answer` already do everything a rubric needs (admin-defined fields,
|
||||
normalized answers, a submit service) — reuse them literally. The one thing `formbuilder`
|
||||
can't express is *who the submission is about*: `Submission.member` is the **submitter**
|
||||
(here, the evaluator), and a generic form has no notion of a separate subject. Rather than
|
||||
growing `formbuilder.Submission` an evaluation-specific field, `evaluations` owns a thin
|
||||
envelope that pairs a `Submission` with the player it was about:
|
||||
|
||||
```
|
||||
EvaluationSettings(club OneToOne) # which Form is *the* current rubric
|
||||
form FK formbuilder.Form (PROTECT)
|
||||
|
||||
PlayerEvaluation(UUIDModel) # the "this was about whom, on which roster" envelope
|
||||
player FK members.Member (CASCADE, related_name="evaluations_received")
|
||||
team FK teams.Team (CASCADE)
|
||||
season FK club.Season (PROTECT)
|
||||
submission OneToOne FK formbuilder.Submission (CASCADE) # evaluator + Answers live here
|
||||
Meta: ordering = ["-created"]
|
||||
```
|
||||
|
||||
- **Rubric changes don't corrupt history.** Swapping the active rubric re-points
|
||||
`EvaluationSettings.form` at a new `Form`; existing `PlayerEvaluation`s keep referencing
|
||||
their original `Form`/`Field`s (already immutable/`PROTECT`-ed once submissions exist —
|
||||
§5.6's own design notes), so an old evaluation still renders with the questions it was
|
||||
actually scored against.
|
||||
- **One rubric, club-wide** (not per team/age-group) — simplest, and keeps every age group
|
||||
on a comparable scale. Revisit only if a club actually needs per-team rubrics.
|
||||
- **Entry point**: an "Evaluate" action from the team roster (same "act from the page the
|
||||
data lives on" pattern as the referee-assignment panel, §5.3) renders the active rubric's
|
||||
`Form` for one player (`formbuilder.services.form_factory.build_form`) and, on submit,
|
||||
calls `formbuilder.services.submission.submit_form` then wraps the resulting `Submission`
|
||||
in a `PlayerEvaluation` — one transaction.
|
||||
- **Player profile** (a tab on the member detail page, gated the same as everything else
|
||||
here): evaluation history (each `PlayerEvaluation`'s answers + evaluator + date), games
|
||||
played **per team** ("U12: 14 games · U14: 6 games" — `Attendance` grouped by
|
||||
`event__teams`, no new tracking), and attendance rate (already-existing `Attendance`
|
||||
data, same definition `team_attendance_rate` uses, §5.2). No trend chart in v1.
|
||||
|
||||
**Access.** A new `ClubRole.EVALUATOR` (§3.2), club-wide and independent of `MEMBER_ADMIN`
|
||||
and coach `StaffAssignment` — evaluating is its own trust boundary, not "manages people" or
|
||||
"coaches this team." `club/services/access.py` gets `can_evaluate_players(user, club) =
|
||||
is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EVALUATOR)`. Gated at
|
||||
the view/mixin level (a `EvaluatorRequiredMixin`, mirroring `MemberAdminRequiredMixin`) —
|
||||
nothing evaluation-related is ever built into a mobile/member-facing context, not hidden
|
||||
behind a flag: the member app's views simply never query it.
|
||||
|
||||
**Build order**: (1) the `EVALUATOR` role + `can_evaluate_players`; (2) `EvaluationSettings`
|
||||
+ `PlayerEvaluation` models + migration; (3) the "Evaluate" entry point + submit flow; (4)
|
||||
the player-profile tab. `formbuilder`'s own model/service layer (§5.6) is **already built**
|
||||
— no prerequisite work needed there beyond, eventually, its own missing render/submit *view*
|
||||
if a public-facing form ever needs one (evaluations builds its own player-scoped one instead
|
||||
of waiting on that).
|
||||
|
||||
---
|
||||
|
||||
## 6. Entity-relationship overview
|
||||
@@ -707,9 +1204,13 @@ User 1───< Member (FK, unique per club) # User is GLOBAL — no clu
|
||||
│
|
||||
├───< TeamMembership >─── Team ───> Season
|
||||
├───< StaffAssignment >─── Team (= "coach of this team", §3.2)
|
||||
├───< GroupMembership >─── Group (opaque -- no team/referee link)
|
||||
├─1:1─ RefereeProfile ──> RefereeLevel >──< Team
|
||||
│ (profile's valid_until gates eligibility; level owns teams)
|
||||
│
|
||||
├───< Attendance >─── Event ───> Season
|
||||
│ └───> Team (nullable)
|
||||
├───< EventReferee >─── Event (assigned_by another Member; home games only)
|
||||
│
|
||||
├───< Submission >─── Form ───< Field (Submission ──< Answer >── Field)
|
||||
│
|
||||
@@ -759,6 +1260,17 @@ Legend: `───<` one-to-many, `>───<` many-to-many via a through model
|
||||
presets on a `pending` order (each = a snapshotting `AppliedDiscount` row) before
|
||||
`finalize()`, rather than typing values. Presets stack against the same subtotal base;
|
||||
optional per-row value override for one-offs. Adds an `Order.pending → finalized` step.
|
||||
10. ✅ **Referee self-service sign-up** — **built**, superseding the "still open" note this
|
||||
replaces. A `RefereeSignup(event, member, status: invited|accepted|declined)` model
|
||||
tracks the invite/response; `sync_referee_invites` (wired from the same signal points
|
||||
as attendance sync, §5.3) auto-invites every eligible referee the moment a home game
|
||||
needs one. Accepting calls the existing `assign_referee(event, member, assigned_by=
|
||||
None)` — capacity-checked in the one place admin assignment already enforces it, so it
|
||||
lands as a real `EventReferee` row with no separate sync step (`assigned_by=None`
|
||||
marks it self-service, exactly the extension point this doc's earlier note predicted).
|
||||
Surfaced on the mobile Calendar and event detail page, scoped to every managed person
|
||||
(not just the account's own `self.me`) — a referee-eligible child is exactly as real
|
||||
as a referee-eligible parent.
|
||||
|
||||
Infrastructure/config for the above (media storage, dependencies + exact setup) is
|
||||
specified in **§8**.
|
||||
@@ -781,6 +1293,13 @@ specified in **§8**.
|
||||
(checkout-date anchor, recommended, frozen total) or by *paying* before it (payment-date
|
||||
anchor, mutable total)? Doc implements checkout-date; confirm no club needs the literal
|
||||
"paid before date" semantics (§5.7.1).
|
||||
- **Player evaluations** (§5.8) — designed, not built. Open question worth confirming
|
||||
before implementation: should `PlayerEvaluation` be strictly one-per-(player, team,
|
||||
season, evaluator) or allow several evaluators to each leave their own entry for the same
|
||||
player/season (the design as written allows the latter — no uniqueness constraint — since
|
||||
a rubric answered once per submission is `formbuilder`'s own natural shape, and multiple
|
||||
coaches' perspectives on the same player seems like a feature, not a bug, but it hasn't
|
||||
been explicitly confirmed).
|
||||
|
||||
---
|
||||
|
||||
@@ -840,6 +1359,42 @@ Setup:
|
||||
`stripe`) + webhook endpoint that creates/confirms `Payment`s (§5.7).
|
||||
- **Excel export** for form reporting beyond CSV: `openpyxl`.
|
||||
|
||||
### 8.5 Email — SMTP or Resend *(built)*
|
||||
|
||||
Every Django-sent email — allauth's password reset, `send_billing_reminders`, anything else
|
||||
that goes through `django.core.mail` — follows whichever `EMAIL_BACKEND` is configured; no
|
||||
per-feature wiring. Console backend by default (§ "Email" in `settings.py`), so a deployment
|
||||
that forgets to configure mail prints to the log instead of raising against `localhost:25`.
|
||||
|
||||
Two ways to use **Resend** (resend.com), no third-party SDK either way:
|
||||
- **SMTP relay, zero code**: point the stock `django.core.mail.backends.smtp.EmailBackend` at
|
||||
`smtp.resend.com` with `resend` as the username and the API key as the password.
|
||||
- **HTTP API**: `rosterchief.mail.ResendEmailBackend` (`DJANGO_EMAIL_BACKEND=rosterchief.mail.ResendEmailBackend`,
|
||||
`RESEND_API_KEY=…`) posts each message straight to Resend's `/emails` endpoint via `requests`
|
||||
(already a dependency, so no new one needed for this). Handles plain text, the HTML
|
||||
alternative on an `EmailMultiAlternatives`, cc/bcc/reply-to, and base64-encoded attachments;
|
||||
`fail_silently` is honoured the same way Django's own backends honour it. Confirmed against
|
||||
Resend's own docs: `POST /emails` accepts `html` alongside `text` (and will derive `text` from
|
||||
`html` if only the former is given), so this backend needed no changes to carry the HTML
|
||||
templates below.
|
||||
- **HTML email templates** *(built)*. `templates/email/_base.html` is the one shared shell
|
||||
(inline styles only, ~600px single-column table — email clients strip `<style>` blocks and
|
||||
ignore linked stylesheets, so daisyUI/Tailwind's compiled CSS is irrelevant here) with a
|
||||
`{% include "email/_button.html" %}` for a styled CTA link. Every HTML email extends it and
|
||||
supplies a plain-text sibling — `members/services/claims.py::send_claim_approved_email` builds
|
||||
an `EmailMultiAlternatives` from `claim_approved.txt` + `claim_approved.html`
|
||||
(`.attach_alternative(html, "text/html")`), the same never-fail-the-caller contract as before
|
||||
(`except OSError: return False`). `club/templatetags/club_email.py::absolute_media_url` turns
|
||||
`Club.logo` (a relative `FileSystemStorage` URL) into something an inbox can actually fetch,
|
||||
by resolving it against the `request` carried in the template context.
|
||||
- **allauth's password-reset email is overridden**, not left at its default plain text —
|
||||
`templates/account/email/password_reset_key_message.html` sits next to allauth's own
|
||||
`.txt` at the path its `render_mail` already looks for (no Python-side override needed;
|
||||
allauth auto-attaches a same-named `.html` as the HTML alternative). Branches on whether a
|
||||
`club` is in context (a reset requested from a club subdomain gets that club's branding) and
|
||||
falls back to a plain RosterChief wordmark otherwise (the platform-level login has no club to
|
||||
brand it with).
|
||||
|
||||
---
|
||||
|
||||
*Conventions cross-reference:* `rosterchief/base.py` (`UUIDModel`, `ClubScopedModel`),
|
||||
|
||||
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.
|
||||
@@ -53,3 +53,7 @@ Domain notes (drive modeling decisions):
|
||||
|
||||
- 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.
|
||||
|
||||
106
DEPLOYMENT.md
106
DEPLOYMENT.md
@@ -34,9 +34,15 @@ Caddy terminates TLS, so without it Django believes every request is plain HTTP:
|
||||
`header_up X-Forwarded-Proto`); don't remove either.
|
||||
|
||||
**4. Uploads must move to object storage before the second app server.**
|
||||
Club logos go to `MEDIA_ROOT` on local disk. On one box that is fine. On two, a logo
|
||||
uploaded to node A is a 404 on node B. Setting `AWS_STORAGE_BUCKET_NAME` switches the
|
||||
default storage to S3 — do it *before* you scale, not during.
|
||||
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
|
||||
@@ -111,20 +117,30 @@ docker compose up -d --no-deps web
|
||||
|
||||
## Scheduled jobs
|
||||
|
||||
Two commands need to run on a schedule. Put them on the **host**, not in a container, and on
|
||||
**exactly one node** when you have several — three nodes archiving the same club is three
|
||||
emails to the same club.
|
||||
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).
|
||||
|
||||
```cron
|
||||
# Bill: archive clubs unpaid past their grace period.
|
||||
# Run it WITHOUT --commit for the first week and read the output. The flag exists because
|
||||
# this switches off paying customers: a bad clock or a bad cron should cost you an email,
|
||||
# not a morning of angry clubs.
|
||||
0 6 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py archive_overdue_clubs --commit
|
||||
| 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 |
|
||||
|
||||
# Events: extend recurring series so the calendar never runs dry.
|
||||
0 3 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py extend_event_series
|
||||
```
|
||||
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
|
||||
|
||||
@@ -135,16 +151,19 @@ Control panel → **Features → Maintenance mode**. While it is on:
|
||||
you with no way to turn it back off;
|
||||
- `/healthz` keeps answering on every host, or the load balancer would take the node out of
|
||||
rotation and the control panel with it;
|
||||
- the **scheduled jobs stand down** — `archive_overdue_clubs`, `extend_event_series` and
|
||||
`import_members_csv` refuse to run.
|
||||
- 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.
|
||||
|
||||
The scheduled jobs exit **non-zero** while the platform is closed, so cron will mail you.
|
||||
That is intended: a job that silently skips itself is how a month of billing goes missing. If
|
||||
you genuinely mean to run one during a window, pass `--ignore-maintenance`.
|
||||
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:
|
||||
|
||||
@@ -298,6 +317,35 @@ 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,
|
||||
@@ -388,7 +436,8 @@ 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`:
|
||||
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 |
|
||||
|---|---|
|
||||
@@ -399,6 +448,19 @@ Measured, running this app under gunicorn with `DEBUG=False`:
|
||||
| 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:
|
||||
|
||||
@@ -542,7 +604,7 @@ Nothing in the code changes. What changes is where the services live:
|
||||
| Cache / flags | `redis` container | managed Redis (or your existing one) |
|
||||
| Uploads | local disk | **S3 bucket** (`AWS_STORAGE_BUCKET_NAME`) |
|
||||
| Static files | WhiteNoise, in the image | unchanged — that is why WhiteNoise is there |
|
||||
| Cron | host crontab | one node only |
|
||||
| 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
|
||||
|
||||
46
Dockerfile
46
Dockerfile
@@ -8,10 +8,16 @@ 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
|
||||
|
||||
|
||||
@@ -27,7 +33,18 @@ RUN apt-get update && apt-get install --no-install-recommends -y git ca-certific
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
# Bytecode precompilation is off, not just given a longer leash: locally,
|
||||
# phonenumbers' largest generated geodata/data*.py files (~900KB of literal
|
||||
# dict data each) compile in ~0.1s, but the build host needed 300s+ for a
|
||||
# single one of the 29 such files uv tried to compile here -- a ~3000x gap
|
||||
# that a bigger UV_COMPILE_BYTECODE_TIMEOUT can't fix, since 29 files at that
|
||||
# rate could still blow past any reasonable build budget. That gap points to
|
||||
# something on the host itself (cross-arch QEMU emulation or memory pressure
|
||||
# during the build), worth root-causing separately. Leaving bytecode
|
||||
# compilation off means each container pays a first-import cost instead
|
||||
# (--preload/prefork make that once per boot, not per request) -- slower
|
||||
# startup until the host issue is fixed, but a build that reliably finishes.
|
||||
ENV UV_COMPILE_BYTECODE=0 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=never
|
||||
|
||||
@@ -57,7 +74,11 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
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
|
||||
|
||||
@@ -67,13 +88,21 @@ 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
|
||||
|
||||
RUN useradd --system --uid 1000 rosterchief && chown -R rosterchief /app
|
||||
# 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
|
||||
@@ -81,10 +110,19 @@ 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", "3", \
|
||||
"--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.
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
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)
|
||||
@@ -5,6 +5,8 @@
|
||||
@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. */
|
||||
@@ -193,3 +195,74 @@
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Force MFA enrolment for privileged users.
|
||||
|
||||
Anyone who can change other people's data must have a second factor: Django
|
||||
staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN or EDITOR)
|
||||
in *any* club. Regular members may enrol, but aren't forced to.
|
||||
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
|
||||
@@ -21,7 +21,7 @@ from club.models import ClubRole
|
||||
#: 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)
|
||||
ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR, ClubRole.Roles.MEMBER_ADMIN)
|
||||
|
||||
|
||||
def mfa_required_for(user) -> bool:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import re
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from allauth.core import context
|
||||
@@ -7,8 +8,10 @@ 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
|
||||
|
||||
@@ -145,8 +148,10 @@ class WebAuthnRelyingPartyTests(TestCase):
|
||||
|
||||
|
||||
class MFARequirementTests(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
@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)
|
||||
@@ -232,9 +237,6 @@ class AdminLoginRoutingTests(TestCase):
|
||||
# The original destination survives the hop (percent-encoded).
|
||||
self.assertEqual(parse_qs(redirect.query)["next"], ["/admin/"])
|
||||
|
||||
def test_allauth_login_page_loads(self):
|
||||
self.assertEqual(self.client.get(reverse("account_login")).status_code, 200)
|
||||
|
||||
|
||||
class AuthFormRenderingTests(TestCase):
|
||||
"""Every allauth form must actually render its fields.
|
||||
@@ -252,14 +254,104 @@ class AuthFormRenderingTests(TestCase):
|
||||
def test_the_password_reset_form_renders_its_fields(self):
|
||||
self.assertContains(self.client.get(reverse("account_reset_password")), 'name="email"')
|
||||
|
||||
def test_the_signup_form_renders_its_fields(self):
|
||||
self.assertContains(self.client.get(reverse("account_signup")), 'name="password1"')
|
||||
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):
|
||||
user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||
enrol_mfa(user)
|
||||
# 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)
|
||||
|
||||
@@ -276,6 +368,20 @@ class TwoFactorPageTests(TestCase):
|
||||
|
||||
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"')
|
||||
@@ -299,8 +405,11 @@ 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):
|
||||
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||
# A real password login (not force_login) so allauth counts it as a recent
|
||||
# authentication and doesn't bounce the sensitive pages to reauthenticate.
|
||||
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||
@@ -347,8 +456,11 @@ class ActionBarTests(TestCase):
|
||||
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.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||
|
||||
def test_the_sign_out_page_has_its_button(self):
|
||||
@@ -367,8 +479,11 @@ class ActionBarTests(TestCase):
|
||||
|
||||
|
||||
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.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||
self.client.force_login(self.user)
|
||||
self.response = self.client.get(reverse("account_logout"))
|
||||
|
||||
@@ -393,8 +508,11 @@ class SignOutPageTests(TestCase):
|
||||
|
||||
|
||||
class ChangePasswordPageTests(TestCase):
|
||||
def setUp(self):
|
||||
@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"))
|
||||
|
||||
@@ -424,8 +542,11 @@ class MfaButtonIconTests(TestCase):
|
||||
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):
|
||||
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
|
||||
# Sign in *before* enrolling: a user who already holds a second factor is stopped at
|
||||
# the 2FA challenge and never reaches these pages.
|
||||
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Due, DuePayment, Subscription, Tier, TierPrice
|
||||
from .models import Due, DuePayment, Plan, PlanPrice, Subscription
|
||||
|
||||
|
||||
class TierPriceInline(admin.TabularInline):
|
||||
model = TierPrice
|
||||
class PlanPriceInline(admin.TabularInline):
|
||||
model = PlanPrice
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Tier)
|
||||
class TierAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "is_active"]
|
||||
list_filter = ["is_active"]
|
||||
@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 = [TierPriceInline]
|
||||
inlines = [PlanPriceInline]
|
||||
|
||||
|
||||
@admin.register(TierPrice)
|
||||
class TierPriceAdmin(admin.ModelAdmin):
|
||||
list_display = ["tier", "amount", "active_from"]
|
||||
list_filter = ["tier"]
|
||||
@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", "tier", "auto_archive"]
|
||||
list_filter = ["tier", "auto_archive"]
|
||||
list_display = ["club", "plan", "auto_renew", "auto_archive"]
|
||||
list_filter = ["plan", "auto_renew", "auto_archive"]
|
||||
search_fields = ["club__name"]
|
||||
|
||||
|
||||
@@ -38,11 +38,13 @@ class DuePaymentInline(admin.TabularInline):
|
||||
|
||||
@admin.register(Due)
|
||||
class DueAdmin(admin.ModelAdmin):
|
||||
list_display = ["club", "tier", "period_start", "period_end", "amount", "amount_paid", "status"]
|
||||
list_filter = ["status", "tier"]
|
||||
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.
|
||||
readonly_fields = ["amount_paid", "status", "paid_at"]
|
||||
# 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]
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class Command(MaintenanceAwareCommand):
|
||||
|
||||
for due in overdue:
|
||||
days = (today - due.grace_until).days
|
||||
self.stdout.write(f"{due.club} — {due.tier}, {due.balance} owed, grace ended {due.grace_until} ({days} day{'s'[: days != 1]} ago)")
|
||||
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."))
|
||||
|
||||
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))
|
||||
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'),
|
||||
),
|
||||
]
|
||||
@@ -9,9 +9,12 @@ 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 _
|
||||
|
||||
@@ -19,35 +22,112 @@ from rosterchief.base import UUIDModel, unique_slugify
|
||||
|
||||
ZERO = Decimal("0.00")
|
||||
|
||||
#: A club stays live for six weeks past the end of an unpaid period before it is archived.
|
||||
GRACE_DAYS = 45
|
||||
#: 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_one_year(day: date) -> date:
|
||||
"""The day one year on. 29 February has no counterpart in a common year, so it falls
|
||||
back to the 28th rather than raising."""
|
||||
try:
|
||||
return day.replace(year=day.year + 1)
|
||||
except ValueError:
|
||||
return day.replace(year=day.year + 1, day=28)
|
||||
def add_months(day: date, months: int) -> date:
|
||||
return day + relativedelta.relativedelta(months=months)
|
||||
|
||||
|
||||
class Tier(UUIDModel):
|
||||
"""A price band. The price itself lives in TierPrice, which is dated."""
|
||||
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 tiers keep billing existing subscriptions but cannot be chosen for new ones."))
|
||||
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 = _("tier")
|
||||
verbose_name_plural = _("tiers")
|
||||
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)
|
||||
@@ -56,7 +136,7 @@ class Tier(UUIDModel):
|
||||
def price_on(self, day: date | None = None) -> Decimal | None:
|
||||
"""The price in force on ``day`` — the latest one that had started by then.
|
||||
|
||||
None means the tier had no price yet on that date. Callers must treat that as
|
||||
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()
|
||||
@@ -65,52 +145,68 @@ class Tier(UUIDModel):
|
||||
return price.amount if price else None
|
||||
|
||||
|
||||
class TierPrice(UUIDModel):
|
||||
"""A dated price for a tier.
|
||||
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.
|
||||
"""
|
||||
|
||||
tier = models.ForeignKey(Tier, on_delete=models.CASCADE, related_name="prices", verbose_name=_("tier"))
|
||||
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 = _("tier price")
|
||||
verbose_name_plural = _("tier prices")
|
||||
ordering = ["tier__name", "-active_from"]
|
||||
verbose_name = _("plan price")
|
||||
verbose_name_plural = _("plan prices")
|
||||
ordering = ["plan__name", "-active_from"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["tier", "active_from"], name="unique_tier_price_per_start_date"),
|
||||
models.UniqueConstraint(fields=["plan", "active_from"], name="unique_plan_price_per_start_date"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tier} — {self.amount} from {self.active_from}"
|
||||
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"))
|
||||
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("tier"))
|
||||
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.tier}"
|
||||
return f"{self.club} — {self.plan}"
|
||||
|
||||
|
||||
class Due(UUIDModel):
|
||||
"""One billing period for one club.
|
||||
|
||||
``tier`` and ``amount`` are snapshots taken when the period opens, never read back
|
||||
through the tier at display time: raise the price and last year's period must still say
|
||||
``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):
|
||||
@@ -124,18 +220,27 @@ class Due(UUIDModel):
|
||||
OWING = (Status.UNPAID, Status.PARTIAL)
|
||||
|
||||
club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="dues", verbose_name=_("club"))
|
||||
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="dues", verbose_name=_("tier"))
|
||||
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."))
|
||||
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")
|
||||
@@ -148,12 +253,14 @@ class Due(UUIDModel):
|
||||
return f"{self.club} — {self.period_start} to {self.period_end}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# A period runs a rolling year from its start and the grace hangs off its end.
|
||||
# Derived here so no caller can open a period without them.
|
||||
# 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_one_year(self.period_start) - timedelta(days=1)
|
||||
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_end + timedelta(days=GRACE_DAYS)
|
||||
self.grace_until = self.period_start + timedelta(days=self.plan.grace_days)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
@@ -164,11 +271,21 @@ class Due(UUIDModel):
|
||||
def is_owing(self) -> bool:
|
||||
return self.status in self.OWING
|
||||
|
||||
def is_in_grace(self, today: date | None = None) -> bool:
|
||||
"""The period has ended unpaid, but the club is not archivable yet."""
|
||||
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 self.period_end < today <= self.grace_until
|
||||
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."""
|
||||
@@ -176,6 +293,12 @@ class Due(UUIDModel):
|
||||
|
||||
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.
|
||||
@@ -210,7 +333,7 @@ class DuePayment(UUIDModel):
|
||||
class Invoice(UUIDModel):
|
||||
"""The bill for one period.
|
||||
|
||||
Only the number and the issue date are stored: the money, the tier and the dates are
|
||||
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.
|
||||
|
||||
@@ -6,22 +6,44 @@ from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum
|
||||
from django.db.models import DateField, OuterRef, Subquery, Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from billing.models import ZERO, Due, DuePayment, Subscription, Tier
|
||||
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, tier: Tier, *, start: date | None = None, auto_archive: bool = True) -> Subscription:
|
||||
"""Put a club on a tier and open its first period."""
|
||||
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"tier": tier, "auto_archive": auto_archive})
|
||||
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.
|
||||
|
||||
@@ -36,23 +58,40 @@ def next_period_start(club, today: date | None = None) -> date:
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def open_period(club, *, start: date | None = None, tier: Tier | None = None) -> Due:
|
||||
"""Issue the next due for a club, snapshotting the tier and the price of the day."""
|
||||
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)
|
||||
tier = tier or (subscription.tier if subscription else None)
|
||||
if tier is None:
|
||||
raise BillingError(f"{club} has no tier: put it on a subscription before billing it.")
|
||||
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 = tier.price_on(start)
|
||||
amount = plan.price_on(start)
|
||||
if amount is None:
|
||||
raise BillingError(f"{tier} has no price in force on {start:%d %b %Y}. Add one before opening the period.")
|
||||
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, tier=tier, amount=amount, period_start=start)
|
||||
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
|
||||
@@ -114,15 +153,19 @@ def waive(due: Due, *, note: str = "") -> Due:
|
||||
return due
|
||||
|
||||
|
||||
def owing_dues(today: date | None = None):
|
||||
def owing_dues():
|
||||
return Due.objects.filter(status__in=Due.OWING)
|
||||
|
||||
|
||||
def dues_in_grace(today: date | None = None):
|
||||
"""Period over, unpaid, not yet archivable."""
|
||||
"""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_end__lt=today, grace_until__gte=today)
|
||||
return owing_dues().filter(period_start__lte=today, grace_until__gte=today)
|
||||
|
||||
|
||||
def dues_overdue(today: date | None = None):
|
||||
@@ -138,7 +181,7 @@ def archivable_clubs(today: date | None = None):
|
||||
A club with auto_archive off is deliberately spared — that flag is how you keep a club
|
||||
you are negotiating with from being switched off overnight.
|
||||
"""
|
||||
return dues_overdue(today).filter(club__archived_at__isnull=True, club__subscription__auto_archive=True).select_related("club", "tier").order_by("club__name")
|
||||
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
|
||||
@@ -151,3 +194,47 @@ def reactivate(club, *, start: date | None = None) -> Due:
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Invoice PDFs.
|
||||
|
||||
The PDF is rendered on demand from the Due's frozen snapshot (tier, amount, dates), so it
|
||||
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.
|
||||
"""
|
||||
|
||||
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 %}
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="parties">
|
||||
<div>
|
||||
<h2>Billed to</h2>
|
||||
<div><strong>{{ club.name }}</strong></div>
|
||||
<div><strong>{{ club.official_name }}</strong></div>
|
||||
<div class="muted">{{ club.slug }}.rosterchief.app</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
@@ -72,7 +72,7 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ due.tier.name }}</strong> — platform subscription
|
||||
<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>
|
||||
|
||||
739
billing/tests.py
739
billing/tests.py
@@ -4,63 +4,84 @@ 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 club.models import Club
|
||||
from authentication.models import User
|
||||
from club.models import Club, ClubRole
|
||||
from members.models import Member
|
||||
|
||||
from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year
|
||||
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, subscribe, waive
|
||||
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):
|
||||
def setUp(self):
|
||||
self.today = timezone.localdate()
|
||||
self.club = Club.objects.create(name="Ajax United")
|
||||
self.tier = Tier.objects.create(name="Standard")
|
||||
# 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.
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
|
||||
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, tier=self.tier)
|
||||
return open_period(club or self.club, start=start, plan=self.plan)
|
||||
|
||||
|
||||
class TierPriceTests(BillingTestBase):
|
||||
class PlanPriceTests(BillingTestBase):
|
||||
def test_the_price_in_force_is_the_latest_one_that_has_started(self):
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("600.00"))
|
||||
PlanPrice.objects.create(plan=self.plan, active_from=self.today, amount=Decimal("600.00"))
|
||||
|
||||
self.assertEqual(self.tier.price_on(self.today - datetime.timedelta(days=1)), Decimal("500.00"))
|
||||
self.assertEqual(self.tier.price_on(self.today), Decimal("600.00"))
|
||||
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):
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
|
||||
PlanPrice.objects.create(plan=self.plan, active_from=self.today + datetime.timedelta(days=30), amount=Decimal("600.00"))
|
||||
|
||||
self.assertEqual(self.tier.price_on(self.today), Decimal("500.00"))
|
||||
self.assertEqual(self.plan.price_on(self.today), Decimal("500.00"))
|
||||
|
||||
def test_a_tier_with_no_price_yet_cannot_be_billed(self):
|
||||
def test_a_plan_with_no_price_yet_cannot_be_billed(self):
|
||||
# None must never be read as free.
|
||||
empty = Tier.objects.create(name="Enterprise")
|
||||
empty = Plan.objects.create(name="Enterprise")
|
||||
|
||||
self.assertIsNone(empty.price_on(self.today))
|
||||
|
||||
with self.assertRaises(BillingError):
|
||||
open_period(self.club, tier=empty)
|
||||
open_period(self.club, plan=empty)
|
||||
|
||||
|
||||
class PeriodTests(BillingTestBase):
|
||||
def test_a_period_runs_a_rolling_year_with_a_grace_tail(self):
|
||||
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))
|
||||
self.assertEqual(due.grace_until, due.period_end + datetime.timedelta(days=GRACE_DAYS))
|
||||
|
||||
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_one_year(datetime.date(2028, 2, 29)), datetime.date(2029, 2, 28))
|
||||
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,
|
||||
@@ -74,7 +95,7 @@ class PeriodTests(BillingTestBase):
|
||||
|
||||
def test_the_amount_is_snapshotted_at_the_price_of_the_day(self):
|
||||
due = self.bill()
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today + datetime.timedelta(days=1), amount=Decimal("900.00"))
|
||||
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.
|
||||
@@ -86,23 +107,24 @@ class PeriodTests(BillingTestBase):
|
||||
with self.assertRaises(BillingError):
|
||||
self.bill(start=self.today)
|
||||
|
||||
def test_a_club_with_no_tier_cannot_be_billed(self):
|
||||
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_tier_and_opens_a_period(self):
|
||||
def test_subscribing_puts_a_club_on_a_plan_and_opens_a_period(self):
|
||||
club = Club.objects.create(name="Feyenoord")
|
||||
|
||||
subscribe(club, self.tier)
|
||||
subscribe(club, self.plan)
|
||||
|
||||
self.assertEqual(Subscription.objects.get(club=club).tier, self.tier)
|
||||
self.assertEqual(Subscription.objects.get(club=club).plan, self.plan)
|
||||
self.assertEqual(club.dues.count(), 1)
|
||||
|
||||
|
||||
class PaymentTests(BillingTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.due = self.bill()
|
||||
@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"))
|
||||
@@ -174,15 +196,24 @@ class PaymentTests(BillingTestBase):
|
||||
|
||||
|
||||
class GraceAndArchiveTests(BillingTestBase):
|
||||
LAPSED = 365 + GRACE_DAYS + 10
|
||||
LAPSED = DEFAULT_GRACE_DAYS + 10
|
||||
|
||||
def test_a_period_past_its_end_but_inside_grace_is_in_grace(self):
|
||||
due = self.bill(start=self.today - datetime.timedelta(days=370))
|
||||
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))
|
||||
|
||||
@@ -199,28 +230,29 @@ class GraceAndArchiveTests(BillingTestBase):
|
||||
self.assertNotIn(due, dues_overdue(self.today))
|
||||
|
||||
def test_an_overdue_club_is_archivable(self):
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
|
||||
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.tier, start=self.today - datetime.timedelta(days=self.LAPSED), auto_archive=False)
|
||||
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.tier, start=self.today - datetime.timedelta(days=self.LAPSED))
|
||||
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):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=365 + GRACE_DAYS + 10))
|
||||
@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()
|
||||
@@ -250,13 +282,14 @@ class ArchiveCommandTests(BillingTestBase):
|
||||
|
||||
|
||||
class ReactivationTests(BillingTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Through subscribe(), not open_period(): reactivating reads the club's tier off its
|
||||
@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(self.club, self.tier, start=self.today - datetime.timedelta(days=400))
|
||||
self.first = self.club.dues.first()
|
||||
self.club.archive()
|
||||
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)
|
||||
@@ -281,7 +314,7 @@ class InvoiceTests(BillingTestBase):
|
||||
# Unlike the shop's per-club order numbers: these are OUR invoices, and one sequence
|
||||
# covers every club we bill.
|
||||
first = self.bill(start=self.today).invoice
|
||||
second = open_period(Club.objects.create(name="Feyenoord"), tier=self.tier).invoice
|
||||
second = open_period(Club.objects.create(name="Feyenoord"), plan=self.plan).invoice
|
||||
|
||||
year = timezone.now().year
|
||||
self.assertEqual(first.number, f"INV-{year}-00001")
|
||||
@@ -309,6 +342,17 @@ class InvoiceTests(BillingTestBase):
|
||||
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.
|
||||
@@ -329,9 +373,608 @@ class ModelStringTests(BillingTestBase):
|
||||
due = self.bill()
|
||||
payment = record_payment(due, Decimal("10.00"))
|
||||
|
||||
self.assertEqual(str(self.tier), "Standard")
|
||||
self.assertIn("500.00", str(self.tier.prices.first()))
|
||||
self.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.tier)))
|
||||
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)
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Club, ClubMembership, ClubRole, Season
|
||||
from .models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
||||
|
||||
|
||||
@admin.register(Club)
|
||||
class ClubAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "slug"]
|
||||
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"]
|
||||
@@ -20,17 +28,42 @@ class SeasonAdmin(admin.ModelAdmin):
|
||||
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", "license"]
|
||||
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"]}],
|
||||
[_("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)
|
||||
@@ -39,3 +72,19 @@ class ClubRoleAdmin(admin.ModelAdmin):
|
||||
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]
|
||||
@@ -1,23 +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 RosterChief one, so
|
||||
the auth screens (login, password reset, MFA, passkeys — anything allauth ships,
|
||||
now or later) follow the tenant without a single template of their own knowing
|
||||
that clubs exist.
|
||||
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.
|
||||
|
||||
The control panel deliberately does *not* use this: it hardcodes the platform
|
||||
base, so no branding bug can ever dress the platform panel up as a club.
|
||||
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 = "_platform_base.html"
|
||||
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": CLUB_BASE_TEMPLATE if club else PLATFORM_BASE_TEMPLATE,
|
||||
"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)."))
|
||||
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),
|
||||
]
|
||||
18
club/migrations/0021_club_legal_name.py
Normal file
18
club/migrations/0021_club_legal_name.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-09 18:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0020_sponsor_logo_height_sponsor_logo_width'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='legal_name',
|
||||
field=models.CharField(blank=True, help_text='Full registered name (e.g. including a legal form like VZW/ASBL), used on official documents. Falls back to the name above when left blank.', max_length=255, verbose_name='legal name'),
|
||||
),
|
||||
]
|
||||
23
club/migrations/0022_clubmembership_kind.py
Normal file
23
club/migrations/0022_clubmembership_kind.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-11 13:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0021_club_legal_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='clubmembership',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('member', 'member'), ('guardian', 'guardian')], default='member', help_text="A guardian is attached to the club only as a parent of a member -- they hold the login, but don't count as a member themselves and owe no fee. A parent who also plays is a member.", max_length=20, verbose_name='kind'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='club',
|
||||
name='legal_name',
|
||||
field=models.CharField(blank=True, help_text='Full registered name (e.g. including a legal form like VZW/ASBL), used on official documents. Falls back to club name if blank.', max_length=255, verbose_name='legal name'),
|
||||
),
|
||||
]
|
||||
58
club/migrations/0023_backfill_guardian_memberships.py
Normal file
58
club/migrations/0023_backfill_guardian_memberships.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Reclassify existing family parents as guardians.
|
||||
|
||||
Before ``kind`` existed, members/services/family.py enrolled a parent exactly like
|
||||
the child they were registering, so every parent already in the database holds a
|
||||
full membership and is counted as a member.
|
||||
|
||||
The guard matters more than the rule: anyone who is *also* on a roster, on a
|
||||
team's staff, or holds an elevated ClubRole is left as a member. A parent who
|
||||
plays, coaches or runs the club is a member who happens to have children there,
|
||||
and silently demoting them would strip them out of the member list and their own
|
||||
team's roster eligibility. Anything ambiguous stays as it is -- an admin can flip
|
||||
a membership to guardian by hand, which is cheap; noticing that someone quietly
|
||||
vanished is not.
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def backfill_guardians(apps, schema_editor):
|
||||
ClubMembership = apps.get_model("club", "ClubMembership")
|
||||
FamilyMembership = apps.get_model("members", "FamilyMembership")
|
||||
TeamMembership = apps.get_model("teams", "TeamMembership")
|
||||
StaffAssignment = apps.get_model("teams", "StaffAssignment")
|
||||
ClubRole = apps.get_model("club", "ClubRole")
|
||||
|
||||
parent_ids = set(FamilyMembership.objects.filter(role__in=["parent", "guardian"]).values_list("member_id", flat=True))
|
||||
if not parent_ids:
|
||||
return
|
||||
|
||||
for membership in ClubMembership.objects.filter(member_id__in=parent_ids).iterator():
|
||||
club_id, member_id = membership.club_id, membership.member_id
|
||||
|
||||
plays = TeamMembership.objects.filter(member_id=member_id, team__club_id=club_id).exists()
|
||||
on_staff = StaffAssignment.objects.filter(member_id=member_id, team__club_id=club_id).exists()
|
||||
runs_the_club = ClubRole.objects.filter(member_id=member_id, club_id=club_id, role__in=["admin", "editor"]).exists()
|
||||
if plays or on_staff or runs_the_club:
|
||||
continue
|
||||
|
||||
membership.kind = "guardian"
|
||||
# Guardians owe nothing; clear any fee the old parent-as-member flow left behind.
|
||||
membership.fee_amount = 0
|
||||
membership.save(update_fields=["kind", "fee_amount"])
|
||||
|
||||
|
||||
def restore_members(apps, schema_editor):
|
||||
"""Everything was a member before this migration ran."""
|
||||
ClubMembership = apps.get_model("club", "ClubMembership")
|
||||
ClubMembership.objects.filter(kind="guardian").update(kind="member")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("club", "0022_clubmembership_kind"),
|
||||
("members", "0004_group_groupmembership_and_more"),
|
||||
("teams", "0009_team_referee_management"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(backfill_guardians, restore_members)]
|
||||
18
club/migrations/0024_club_contact_email.py
Normal file
18
club/migrations/0024_club_contact_email.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-11 16:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0023_backfill_guardian_memberships'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='contact_email',
|
||||
field=models.EmailField(blank=True, help_text="The club's public address, shown to people the club writes to or asks to get in touch -- e.g. a parent claiming a child. Falls back to nothing being shown at all, so it's worth setting.", max_length=254, verbose_name='contact email'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-16 20:42
|
||||
|
||||
import club.models
|
||||
import django.core.files.storage
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0024_club_contact_email'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OnboardingRequirement',
|
||||
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=100, verbose_name='name')),
|
||||
('description', models.TextField(blank=True, help_text="Shown to staff on the member's checklist.", verbose_name='description')),
|
||||
('requires_document', models.BooleanField(default=False, help_text='Staff can attach a file (e.g. the certificate itself) when marking this complete.', verbose_name='requires a document')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Inactive requirements no longer apply to new memberships, but existing statuses are kept.', verbose_name='active')),
|
||||
('order', models.PositiveIntegerField(default=0, help_text='Lower numbers show first on the checklist.', verbose_name='order')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'onboarding requirement',
|
||||
'verbose_name_plural': 'onboarding requirements',
|
||||
'ordering': ['order', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MemberRequirementStatus',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('is_complete', models.BooleanField(default=False, verbose_name='complete')),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='completed at')),
|
||||
('document', models.FileField(blank=True, help_text="Stored privately -- readable only through this member's own page, never a direct link.", storage=django.core.files.storage.FileSystemStorage(base_url=None, location='/Users/bernard/Code/PycharmProjects/RosterChief/private_media'), upload_to=club.models.onboarding_document_path, verbose_name='document')),
|
||||
('note', models.TextField(blank=True, help_text='Staff-only, e.g. how or when this was received.', verbose_name='note')),
|
||||
('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='completed by')),
|
||||
('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requirement_statuses', to='club.clubmembership', verbose_name='membership')),
|
||||
('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statuses', to='club.onboardingrequirement', verbose_name='requirement')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'member requirement status',
|
||||
'verbose_name_plural': 'member requirement statuses',
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='onboardingrequirement',
|
||||
constraint=models.UniqueConstraint(fields=('club', 'name'), name='unique_onboarding_requirement_name_per_club'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='memberrequirementstatus',
|
||||
constraint=models.UniqueConstraint(fields=('membership', 'requirement'), name='unique_requirement_status_per_membership'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-17 11:39
|
||||
|
||||
import club.models
|
||||
import rosterchief.storage
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0025_onboardingrequirement_memberrequirementstatus_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='memberrequirementstatus',
|
||||
name='is_bypassed',
|
||||
field=models.BooleanField(default=False, verbose_name='bypassed'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='onboardingrequirement',
|
||||
name='blocked_event_kinds',
|
||||
field=models.JSONField(blank=True, default=list, help_text="Event kinds a member can't be invited to or selected for while this is open. Empty means purely informational.", verbose_name='blocks selection for'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='clubrole',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('admin', 'admin'), ('member', 'member'), ('editor', 'editor'), ('member_admin', 'member admin')], default='member', max_length=250, verbose_name='role'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='memberrequirementstatus',
|
||||
name='document',
|
||||
field=models.FileField(blank=True, help_text="Stored privately -- readable only through this member's own page, never a direct link.", storage=rosterchief.storage.PrivateStorage(location='/Users/bernard/Code/PycharmProjects/RosterChief/private_media'), upload_to=club.models.onboarding_document_path, verbose_name='document'),
|
||||
),
|
||||
]
|
||||
18
club/migrations/0027_club_website.py
Normal file
18
club/migrations/0027_club_website.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-20 07:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0026_memberrequirementstatus_is_bypassed_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='website',
|
||||
field=models.URLField(blank=True, help_text="The club's own site, if it has one -- shown alongside its RosterChief pages, not used for anything else yet.", verbose_name='website'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-20 07:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0027_club_website'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='onboardingrequirement',
|
||||
options={'ordering': ['name'], 'verbose_name': 'onboarding requirement', 'verbose_name_plural': 'onboarding requirements'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='onboardingrequirement',
|
||||
name='order',
|
||||
),
|
||||
]
|
||||
39
club/migrations/0029_duesinvoice.py
Normal file
39
club/migrations/0029_duesinvoice.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-20 07:46
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0028_alter_onboardingrequirement_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DuesInvoice',
|
||||
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=255, verbose_name='number')),
|
||||
('amount', models.DecimalField(decimal_places=2, help_text='The outstanding balance at the time this was sent — not re-read from the membership afterwards.', max_digits=10, verbose_name='amount')),
|
||||
('due_date', models.DateField(verbose_name='due date')),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='sent at')),
|
||||
('sent_to_email', models.EmailField(blank=True, max_length=254, verbose_name='sent to')),
|
||||
('sent_to_guardian', models.BooleanField(default=False, help_text="The member had no email on file, so a parent/guardian's was used instead.", verbose_name='sent to a parent/guardian')),
|
||||
('last_reminder_sent_at', models.DateTimeField(blank=True, null=True, verbose_name='last reminder sent at')),
|
||||
('reminder_count', models.PositiveIntegerField(default=0, verbose_name='reminders sent')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
('membership', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='dues_invoice', to='club.clubmembership', verbose_name='membership')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'dues invoice',
|
||||
'verbose_name_plural': 'dues invoices',
|
||||
'ordering': ['-sent_at'],
|
||||
'constraints': [models.UniqueConstraint(fields=('club', 'number'), name='unique_dues_invoice_number_per_club')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-21 15:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0029_duesinvoice'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='legal_address',
|
||||
field=models.CharField(blank=True, help_text="Street address for official documents (invoices, the referee payment form). Falls back to the home location's address when left blank -- set this when the club's registered address isn't where it actually plays.", max_length=255, verbose_name='legal address'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='legal_city',
|
||||
field=models.CharField(blank=True, max_length=255, verbose_name='legal city'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='legal_zip_code',
|
||||
field=models.CharField(blank=True, max_length=255, verbose_name='legal zip code'),
|
||||
),
|
||||
]
|
||||
19
club/migrations/0031_club_event_background.py
Normal file
19
club/migrations/0031_club_event_background.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-21 16:10
|
||||
|
||||
import club.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0030_club_legal_address_club_legal_city_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='event_background',
|
||||
field=models.ImageField(blank=True, help_text='A generic background photo for event screens in the mobile app, shown in black & white under a dark gradient. Without one, a plain dark background is used.', upload_to=club.models.club_event_background_path, verbose_name='event background'),
|
||||
),
|
||||
]
|
||||
162
club/mixins.py
Normal file
162
club/mixins.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.http import Http404
|
||||
from waffle import flag_is_active
|
||||
|
||||
from members.models import Group
|
||||
|
||||
from .services.access import can_add_news, can_edit_news, can_manage_members, can_publish_news, groups_manageable_by, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
|
||||
|
||||
|
||||
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
|
||||
"""Gate for the club-facing management UI.
|
||||
|
||||
Two rules, the mirror image of ``controlpanel.mixins.PlatformStaffRequiredMixin``:
|
||||
|
||||
* **Club subdomain only.** This UI manages *one* club, so it doesn't exist on the
|
||||
base domain — same reasoning as the control panel refusing to exist on a club
|
||||
subdomain, just inverted.
|
||||
* **Staff only.** ADMIN/EDITOR, or a current-season ``StaffAssignment`` (coach,
|
||||
team manager, ...) — see ``has_management_access``. The plain MEMBER role every
|
||||
active player/club member holds automatically does *not* count: a club member
|
||||
with neither is a player/parent, and belongs in the separate app that serves
|
||||
them.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if getattr(request, "club", None) is None:
|
||||
raise Http404("The management app is not available on the base domain.")
|
||||
# Read by club/context_processors.py's branding() -- allauth's password-change/MFA/
|
||||
# logout screens live under /accounts/, not /manage/, so a path check alone can't
|
||||
# tell they were reached from the management app's own user menu. This sticks for
|
||||
# the rest of the session (nothing clears it back to False on a public-site visit),
|
||||
# which is the right default for the common case of one person, one role.
|
||||
request.session["management_context"] = True
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def test_func(self):
|
||||
return has_management_access(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""ADMIN role only (a platform superuser always passes too, see
|
||||
is_club_admin) — genuinely admin-only ground: Finance/Shop, Club identity,
|
||||
Sponsors, seasons, and granting/revoking ClubRole itself. Everything a
|
||||
MEMBER_ADMIN may also touch uses MemberAdminRequiredMixin below instead."""
|
||||
|
||||
def test_func(self):
|
||||
return is_club_admin(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class MemberAdminRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""ADMIN, a platform superuser, or MEMBER_ADMIN specifically -- full read/write
|
||||
on people: members, families, groups, parent claims, member import, teams
|
||||
(roster/staff/CRUD), referee levels, referee management, and onboarding
|
||||
requirements. Deliberately does NOT cover Finance/Shop, Club identity,
|
||||
Sponsors, or role-granting (role_list/role_create/role_revoke stay
|
||||
ClubAdminRequiredMixin) -- a MEMBER_ADMIN must never be able to grant
|
||||
themselves, or anyone else, real ADMIN."""
|
||||
|
||||
def test_func(self):
|
||||
return can_manage_members(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class FeatureRequiredMixin(ClubAdminRequiredMixin):
|
||||
"""Gate for a whole management section (shop, forms, ...) this club doesn't
|
||||
have at all unless its waffle Flag (see the ``features`` app, set per-club
|
||||
from the control panel's Features page) is active for it. Checked before
|
||||
the admin-only test below and as a plain 404 rather than folded into
|
||||
``test_func``'s 403: a club with the feature off doesn't have a permissions
|
||||
problem, the section just doesn't exist there, same reasoning as
|
||||
``ClubStaffRequiredMixin`` 404ing the whole app off the base domain.
|
||||
|
||||
Subclasses set ``feature_flag`` to the Flag's name, e.g. ``"shop"``.
|
||||
"""
|
||||
|
||||
feature_flag: str = ""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
club = getattr(request, "club", None)
|
||||
if club is not None and not flag_is_active(request, self.feature_flag):
|
||||
raise Http404(f"The “{self.feature_flag}” feature isn't enabled for this club.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""A manager of *this* team, or a club ADMIN. ``self.get_team()`` must return the
|
||||
``Team`` the view acts on (e.g. from the URL's ``pk``) before ``test_func`` runs.
|
||||
"""
|
||||
|
||||
def get_team(self):
|
||||
raise NotImplementedError("Subclasses must return the Team this view acts on.")
|
||||
|
||||
def test_func(self):
|
||||
user, club = self.request.user, self.request.club
|
||||
if is_club_admin(user, club):
|
||||
return True
|
||||
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
|
||||
|
||||
|
||||
class EventManagerRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""Admin, a manager of at least one of this event's/series' *current*
|
||||
teams, or a member of at least one of its groups. ``self.get_teams()``
|
||||
must return the Team queryset/iterable the view acts on (e.g.
|
||||
``self.get_object().teams.all()``) before ``test_func`` runs; override
|
||||
``get_groups()`` the same way for a view whose object can carry groups
|
||||
(it defaults to none, so most subclasses only need get_teams()). Events/
|
||||
series aren't single-team/-group like a roster entry -- both are M2M, so
|
||||
authority is "belongs to at least one", not "belongs to the one". A
|
||||
club_wide event has no equivalent membership claim to check -- it's
|
||||
admin-only to create in the first place (EventForm), so the plain
|
||||
is_club_admin check below already covers it."""
|
||||
|
||||
def get_teams(self):
|
||||
raise NotImplementedError("Subclasses must return the Teams this view acts on.")
|
||||
|
||||
def get_groups(self):
|
||||
return Group.objects.none()
|
||||
|
||||
def test_func(self):
|
||||
user, club = self.request.user, self.request.club
|
||||
if is_club_admin(user, club):
|
||||
return True
|
||||
if teams_managed_by(user, club).filter(pk__in=self.get_teams().values_list("pk", flat=True)).exists():
|
||||
return True
|
||||
return groups_manageable_by(user, club).filter(pk__in=self.get_groups().values_list("pk", flat=True)).exists()
|
||||
|
||||
|
||||
class ManagementPositionRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""ADMIN, or anyone with a current-season *management*-position
|
||||
StaffAssignment on any team -- unlike ``TeamManagerRequiredMixin``, the
|
||||
entity here (Location, Opponent, ...) isn't scoped to one team, so "manager
|
||||
of this team" doesn't apply; any management position qualifies."""
|
||||
|
||||
def test_func(self):
|
||||
return is_club_admin(self.request.user, self.request.club) or is_coach_manager(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class NewsAuthorRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
|
||||
author club content in the first place (creating a draft)."""
|
||||
|
||||
def test_func(self):
|
||||
return can_add_news(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class NewsPublisherRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""ADMIN/EDITOR only -- the release-flow gate for pushing a news item live
|
||||
(or pulling it back)."""
|
||||
|
||||
def test_func(self):
|
||||
return can_publish_news(self.request.user, self.request.club)
|
||||
|
||||
|
||||
class NewsEditRequiredMixin(ClubStaffRequiredMixin):
|
||||
"""Whoever may edit *this* news item right now: broad while it's a draft,
|
||||
editor/admin-only once published. ``self.get_news_item()`` must return the
|
||||
News the view acts on before ``test_func`` runs."""
|
||||
|
||||
def get_news_item(self):
|
||||
raise NotImplementedError("Subclasses must return the News item this view acts on.")
|
||||
|
||||
def test_func(self):
|
||||
return can_edit_news(self.request.user, self.get_news_item())
|
||||
420
club/models.py
420
club/models.py
@@ -1,12 +1,17 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.db.models import Q, UniqueConstraint
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from members.models import Member
|
||||
from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
|
||||
from rosterchief.storage import private_storage
|
||||
|
||||
|
||||
class ClubManager(models.Manager):
|
||||
@@ -27,11 +32,56 @@ def club_logo_path(instance: Club, filename: str) -> str:
|
||||
return f"clubs/{instance.slug}/{filename}"
|
||||
|
||||
|
||||
class Club(UUIDModel):
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
|
||||
def club_event_background_path(instance: Club, filename: str) -> str:
|
||||
return f"clubs/{instance.slug}/event-background/{filename}"
|
||||
|
||||
logo = models.ImageField(_("logo"), upload_to=club_logo_path, blank=True, help_text=_("Shown on the club's own pages. Without one, the club's initials are used."))
|
||||
|
||||
class Club(UUIDModel):
|
||||
class SportType(models.TextChoices):
|
||||
"""Which sport this club plays. Only two options for now -- expand this as
|
||||
more sport-specific competition fetchers (see events.competition) are added."""
|
||||
|
||||
ICE_HOCKEY = "ice_hockey", _("Ice hockey")
|
||||
OTHER = "other", _("Other")
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
legal_name = models.CharField(_("legal name"), max_length=255, blank=True, help_text=_("Full registered name (e.g. including a legal form like VZW/ASBL), used on official documents. Falls back to club name if blank."))
|
||||
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
|
||||
contact_email = models.EmailField(
|
||||
_("contact email"),
|
||||
blank=True,
|
||||
help_text=_("The club's public address, shown to people the club writes to or asks to get in touch -- e.g. a parent claiming a child. Falls back to nothing being shown at all, so it's worth setting."),
|
||||
)
|
||||
website = models.URLField(_("website"), blank=True, help_text=_("The club's own site, if it has one -- shown alongside its RosterChief pages, not used for anything else yet."))
|
||||
|
||||
legal_address = models.CharField(
|
||||
_("legal address"),
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text=_("Street address for official documents (invoices, the referee payment form). Falls back to the home location's address when left blank -- set this when the club's registered address isn't where it actually plays."),
|
||||
)
|
||||
legal_zip_code = models.CharField(_("legal zip code"), max_length=255, blank=True)
|
||||
legal_city = models.CharField(_("legal city"), max_length=255, blank=True)
|
||||
|
||||
logo = models.FileField(
|
||||
_("logo"),
|
||||
upload_to=club_logo_path,
|
||||
blank=True,
|
||||
# A plain FileField, not ImageField: Pillow (which ImageField validates through)
|
||||
# cannot read SVGs, and club crests are commonly vector logos.
|
||||
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
|
||||
help_text=_("Shown on the club's own pages. Without one, the club's initials are used."),
|
||||
)
|
||||
event_background = models.ImageField(
|
||||
_("event background"),
|
||||
upload_to=club_event_background_path,
|
||||
blank=True,
|
||||
# A real photo, unlike logo -- shown in black & white under a dark
|
||||
# gradient (see mobile/templates/mobile/event_detail.html), so an
|
||||
# ImageField (Pillow-validated, unlike logo's plain FileField) is the
|
||||
# right fit here: this is never a vector crest.
|
||||
help_text=_("A generic background photo for event screens in the mobile app, shown in black & white under a dark gradient. Without one, a plain dark background is used."),
|
||||
)
|
||||
primary_color = models.CharField(
|
||||
_("primary colour"),
|
||||
max_length=7,
|
||||
@@ -40,8 +90,36 @@ class Club(UUIDModel):
|
||||
help_text=_("Hex colour for buttons and links on the club's pages, e.g. #1e40af."),
|
||||
)
|
||||
|
||||
secondary_color = models.CharField(
|
||||
_("secondary colour"),
|
||||
max_length=7,
|
||||
blank=True,
|
||||
validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$", _("Enter a colour as a hex value, e.g. #be185d."))],
|
||||
help_text=_("Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour."),
|
||||
)
|
||||
|
||||
sport_type = models.CharField(
|
||||
_("sport"),
|
||||
max_length=20,
|
||||
choices=SportType.choices,
|
||||
default=SportType.OTHER,
|
||||
help_text=_("Which sport this club plays -- determines which competitions and score fetchers are relevant to it."),
|
||||
)
|
||||
|
||||
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))
|
||||
|
||||
season_start = models.DateField(
|
||||
_("season start"),
|
||||
default=datetime.date(2000, 8, 1),
|
||||
help_text=_("Which day of the year a season begins — only the month and day are used, the year is ignored."),
|
||||
)
|
||||
season_duration_months = models.PositiveSmallIntegerField(
|
||||
_("season duration (months)"),
|
||||
default=12,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(24)],
|
||||
help_text=_("How many months a season lasts, counted from its start date."),
|
||||
)
|
||||
|
||||
objects = ClubManager()
|
||||
|
||||
class Meta:
|
||||
@@ -61,6 +139,12 @@ class Club(UUIDModel):
|
||||
def is_archived(self) -> bool:
|
||||
return self.archived_at is not None
|
||||
|
||||
@property
|
||||
def official_name(self) -> str:
|
||||
"""The name official documents (e.g. the referee payment form) should
|
||||
show -- `legal_name` when the club has set one, else the everyday `name`."""
|
||||
return self.legal_name or self.name
|
||||
|
||||
@property
|
||||
def initials(self) -> str:
|
||||
"""Stand-in for a missing logo. Never the RosterChief mark — that would
|
||||
@@ -69,19 +153,29 @@ class Club(UUIDModel):
|
||||
|
||||
@property
|
||||
def primary_content_color(self) -> str:
|
||||
"""Readable text colour to sit *on* ``primary_color``.
|
||||
"""Readable text colour to sit *on* ``primary_color``. See ``_content_color_for``."""
|
||||
return self._content_color_for(self.primary_color)
|
||||
|
||||
@property
|
||||
def secondary_content_color(self) -> str:
|
||||
"""Readable text colour to sit *on* ``secondary_color``. See ``_content_color_for``."""
|
||||
return self._content_color_for(self.secondary_color)
|
||||
|
||||
@staticmethod
|
||||
def _content_color_for(hex_color: str) -> str:
|
||||
"""Black or white, whichever reads on ``hex_color``.
|
||||
|
||||
A club picking a pale yellow would otherwise get white-on-yellow buttons.
|
||||
Relative luminance per WCAG, with its 0.179 threshold for black vs white.
|
||||
"""
|
||||
if not self.primary_color:
|
||||
if not hex_color:
|
||||
return ""
|
||||
|
||||
def channel(value: int) -> float:
|
||||
fraction = value / 255
|
||||
return fraction / 12.92 if fraction <= 0.04045 else ((fraction + 0.055) / 1.055) ** 2.4
|
||||
|
||||
red, green, blue = (channel(int(self.primary_color[index : index + 2], 16)) for index in (1, 3, 5))
|
||||
red, green, blue = (channel(int(hex_color[index : index + 2], 16)) for index in (1, 3, 5))
|
||||
luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
||||
|
||||
return "#000000" if luminance > 0.179 else "#ffffff"
|
||||
@@ -103,6 +197,53 @@ class Club(UUIDModel):
|
||||
self.save(update_fields=["archived_at"])
|
||||
|
||||
|
||||
def sponsor_logo_path(instance: Sponsor, filename: str) -> str:
|
||||
return f"clubs/{instance.club.slug}/sponsors/{instance.pk}/{filename}"
|
||||
|
||||
|
||||
class Sponsor(ClubScopedModel):
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
logo = models.FileField(
|
||||
_("logo"),
|
||||
upload_to=sponsor_logo_path,
|
||||
blank=True,
|
||||
# A plain FileField, not ImageField: same reasoning as Club.logo -- a
|
||||
# sponsor's own logo is just as commonly a vector file, and ImageField's
|
||||
# Pillow validation can't read those.
|
||||
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
|
||||
)
|
||||
# Not user-editable: recomputed from the logo file itself on every save, same reasoning
|
||||
# NewsPhoto/TeamPhoto don't need this -- FileField (not ImageField) means Django never
|
||||
# populates width/height on its own. The public API exposes these so a consumer can lay
|
||||
# out a sponsor strip without waiting on the image to load.
|
||||
logo_width = models.PositiveIntegerField(_("logo width"), null=True, blank=True, editable=False)
|
||||
logo_height = models.PositiveIntegerField(_("logo height"), null=True, blank=True, editable=False)
|
||||
url = models.URLField(_("URL"), blank=True, help_text=_("The sponsor's own website, if they have one."))
|
||||
|
||||
start_date = models.DateField(_("start date"))
|
||||
end_date = models.DateField(_("end date"), null=True, blank=True, help_text=_("Leave blank to keep this sponsor active indefinitely once it starts."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("sponsor")
|
||||
verbose_name_plural = _("sponsors")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
if self.end_date is not None and self.start_date is not None and self.end_date < self.start_date:
|
||||
raise ValidationError({"end_date": _("End date can't be before the start date.")})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Deferred: club.services (via its __init__) imports back from club.models, so a
|
||||
# module-level import here would be circular.
|
||||
from club.services.images import get_image_dimensions
|
||||
|
||||
self.logo_width, self.logo_height = get_image_dimensions(self.logo) if self.logo else (None, None)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Season(ClubScopedModel):
|
||||
start_date = models.DateField(_("start date"))
|
||||
end_date = models.DateField(_("end date"))
|
||||
@@ -135,8 +276,26 @@ class Season(ClubScopedModel):
|
||||
"""Return ``club``'s season covering ``date`` (no tenant context needed)."""
|
||||
return cls.objects.filter(club=club, start_date__lte=date, end_date__gte=date).first()
|
||||
|
||||
@classmethod
|
||||
def next_after(cls, club, date: datetime.date):
|
||||
"""Return ``club``'s soonest season starting after ``date`` (no tenant
|
||||
context needed) -- the season that follows the one covering ``date``."""
|
||||
return cls.objects.filter(club=club, start_date__gt=date).order_by("start_date").first()
|
||||
|
||||
@classmethod
|
||||
def before(cls, club, season):
|
||||
"""Return ``club``'s most recent season starting before ``season`` --
|
||||
e.g. the management dashboard's member-count trend compares against
|
||||
this. Mirrors next_after's own "adjacent by date" reasoning, just
|
||||
looking the other way."""
|
||||
return cls.objects.filter(club=club, start_date__lt=season.start_date).order_by("-start_date").first()
|
||||
|
||||
|
||||
class ClubMembership(ClubScopedModel):
|
||||
class Kind(models.TextChoices):
|
||||
MEMBER = "member", _("member")
|
||||
GUARDIAN = "guardian", _("guardian")
|
||||
|
||||
class StatusChoices(models.TextChoices):
|
||||
ACTIVE = "active", _("active")
|
||||
PENDING = "pending", _("pending")
|
||||
@@ -152,10 +311,21 @@ class ClubMembership(ClubScopedModel):
|
||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="member_of", verbose_name=_("member"))
|
||||
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="memberships", verbose_name=_("season"))
|
||||
|
||||
kind = models.CharField(
|
||||
_("kind"),
|
||||
max_length=20,
|
||||
choices=Kind.choices,
|
||||
default=Kind.MEMBER,
|
||||
help_text=_("A guardian is attached to the club only as a parent of a member -- they hold the login, but don't count as a member themselves and owe no fee. A parent who also plays is a member."),
|
||||
)
|
||||
|
||||
license = models.CharField(_("license"), max_length=250, blank=True)
|
||||
status = models.CharField(_("status"), max_length=250, choices=StatusChoices.choices, default=StatusChoices.PENDING)
|
||||
fee_status = models.CharField(_("fee status"), max_length=250, choices=FeeStatus.choices, default=FeeStatus.UNPAID)
|
||||
|
||||
fee_amount = models.DecimalField(_("fee amount"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True)
|
||||
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True, help_text=_("Kept in step with payments by the fee service; not hand-edited."))
|
||||
|
||||
signed_up_at = models.DateField(_("signed up at"), blank=True, null=True)
|
||||
activated_at = models.DateField(_("activated at"), blank=True, null=True)
|
||||
|
||||
@@ -170,8 +340,234 @@ class ClubMembership(ClubScopedModel):
|
||||
def __str__(self):
|
||||
return f"{self.club} - {self.member}"
|
||||
|
||||
@property
|
||||
def is_guardian(self) -> bool:
|
||||
"""Attached to the club as a parent of a member, not as one themselves.
|
||||
|
||||
Guardians are deliberately kept as ClubMembership rows rather than given
|
||||
their own model: everything that answers "is this person attached to this
|
||||
club" (tenancy scoping, group membership, the event audience) already
|
||||
reads through this table, and a second kind of link would need a parallel
|
||||
path through all of it. What changes is only who *counts* -- the member
|
||||
list, the fee list and every member KPI filter on ``kind``.
|
||||
"""
|
||||
return self.kind == self.Kind.GUARDIAN
|
||||
|
||||
@property
|
||||
def open_requirement_count(self) -> int:
|
||||
"""How many active onboarding requirements this membership hasn't resolved
|
||||
yet (completed or bypassed) -- see OnboardingRequirement's docstring for why
|
||||
this is separate from status/fee_status. One query per call; for a list of
|
||||
memberships, annotate with club.services.onboarding.annotate_onboarding_status
|
||||
instead."""
|
||||
met = set(self.requirement_statuses.filter(Q(is_complete=True) | Q(is_bypassed=True)).values_list("requirement_id", flat=True))
|
||||
required = set(OnboardingRequirement.objects.filter(club_id=self.club_id, is_active=True).values_list("pk", flat=True))
|
||||
return len(required - met)
|
||||
|
||||
@property
|
||||
def onboarding_complete(self) -> bool:
|
||||
return self.open_requirement_count == 0
|
||||
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.club_id, same_club_fields=("season",))
|
||||
# A guardian owes nothing -- they're not a member. Caught here rather than
|
||||
# silently zeroed on save so a mistaken import row says so out loud.
|
||||
if self.is_guardian and self.fee_amount:
|
||||
raise ValidationError({"fee_amount": _("A guardian doesn't hold a membership, so they can't owe a fee.")})
|
||||
|
||||
|
||||
class FeePayment(UUIDModel):
|
||||
"""Money received against one membership's fee. Several may land on one
|
||||
membership: a family paying in two installments must not read as unpaid, and
|
||||
the part that did arrive has to be recorded somewhere. Not itself club-scoped
|
||||
-- its club is reached through ``membership``, same as DuePayment/Due."""
|
||||
|
||||
class Method(models.TextChoices):
|
||||
BANK_TRANSFER = "bank_transfer", _("bank transfer")
|
||||
CASH = "cash", _("cash")
|
||||
CARD = "card", _("card")
|
||||
OTHER = "other", _("other")
|
||||
|
||||
membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="payments", verbose_name=_("membership"))
|
||||
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
|
||||
method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
|
||||
reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
|
||||
paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
|
||||
note = models.TextField(_("note"), blank=True)
|
||||
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_fee_payments", verbose_name=_("recorded by"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("fee payment")
|
||||
verbose_name_plural = _("fee payments")
|
||||
ordering = ["-paid_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.membership} — {self.amount}"
|
||||
|
||||
|
||||
class DuesInvoice(ClubScopedModel):
|
||||
"""A record of asking one membership's fee to be paid — not itself the source of
|
||||
truth for what's owed or settled (that's still ``ClubMembership.fee_amount``/
|
||||
``amount_paid``/``fee_status``, via ``club.services.fees``). Sending one snapshots
|
||||
the outstanding balance and a due date so a later fee change or reminder never
|
||||
silently rewrites a bill someone already received; whether it still needs chasing
|
||||
is read live off the membership's own ``fee_status``, since a payment recorded
|
||||
through any route settles the same balance this invoice asked for.
|
||||
|
||||
One per membership (see ``club.services.invoicing``): "send" creates it if
|
||||
missing, "resend" re-snapshots the balance and pushes the due date out again on
|
||||
the existing row, so a membership never accumulates a history of stale invoices.
|
||||
"""
|
||||
|
||||
membership = models.OneToOneField(ClubMembership, on_delete=models.CASCADE, related_name="dues_invoice", verbose_name=_("membership"))
|
||||
number = models.CharField(_("number"), max_length=255, blank=True)
|
||||
|
||||
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, help_text=_("The outstanding balance at the time this was sent — not re-read from the membership afterwards."))
|
||||
due_date = models.DateField(_("due date"))
|
||||
|
||||
sent_at = models.DateTimeField(_("sent at"), null=True, blank=True)
|
||||
sent_to_email = models.EmailField(_("sent to"), blank=True)
|
||||
sent_to_guardian = models.BooleanField(_("sent to a parent/guardian"), default=False, help_text=_("The member had no email on file, so a parent/guardian's was used instead."))
|
||||
|
||||
last_reminder_sent_at = models.DateTimeField(_("last reminder sent at"), null=True, blank=True)
|
||||
reminder_count = models.PositiveIntegerField(_("reminders sent"), default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("dues invoice")
|
||||
verbose_name_plural = _("dues invoices")
|
||||
ordering = ["-sent_at"]
|
||||
constraints = [
|
||||
UniqueConstraint(fields=["club", "number"], name="unique_dues_invoice_number_per_club"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.number or _("Unsent invoice for %(member)s") % {"member": self.membership.member}
|
||||
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.club_id, same_club_fields=("membership",))
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
return self.membership.fee_status == ClubMembership.FeeStatus.PAID
|
||||
|
||||
@property
|
||||
def is_overdue(self) -> bool:
|
||||
return bool(self.sent_at) and not self.is_paid and self.due_date < timezone.now().date()
|
||||
|
||||
def generate_number(self) -> str:
|
||||
"""Next per-club invoice number for the current year: ``DUE-<year>-<seq>``.
|
||||
Same shape as shop.models.Invoice's numbering, duplicated rather than shared
|
||||
across the two apps — see that module's own numbering helpers."""
|
||||
prefix = f"DUE-{timezone.now().year}-"
|
||||
sequences = [int(suffix) for existing in DuesInvoice.objects.filter(club=self.club, number__startswith=prefix).values_list("number", flat=True) if (suffix := existing.removeprefix(prefix)).isdigit()]
|
||||
return f"{prefix}{max(sequences, default=0) + 1:05d}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.number:
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
# Retrying on a numbering collision (two invoices allocated the same
|
||||
# sequence in the same instant) rather than locking: this only ever
|
||||
# fires once, on first send, so a rare retry is cheaper than a lock
|
||||
# held around every save.
|
||||
for attempt in range(5):
|
||||
self.number = self.generate_number()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
return super().save(*args, **kwargs)
|
||||
except IntegrityError:
|
||||
self.number = ""
|
||||
if attempt == 4:
|
||||
raise
|
||||
|
||||
|
||||
def onboarding_document_path(instance: MemberRequirementStatus, filename: str) -> str:
|
||||
return f"clubs/{instance.membership.club.slug}/onboarding/{instance.membership_id}/{filename}"
|
||||
|
||||
|
||||
class OnboardingRequirement(ClubScopedModel):
|
||||
"""A club-defined item every member must satisfy after signing up or renewing --
|
||||
e.g. "provide a medical certificate", "upload a photo".
|
||||
|
||||
``ClubMembership.fee_status`` is still driven by payment alone (see
|
||||
``club.services.fees._sync_fee_status``) and this never touches it -- a member
|
||||
reads as paid *and* still has an open checklist, both true at once. ``status``
|
||||
is different: paying in full only ever settles ``fee_status`` now -- it never
|
||||
flips ``status`` to ACTIVE by itself. The only path there is the deliberately
|
||||
manual one, ``club.services.onboarding.approve_one``/``approve_all_clean``, run
|
||||
by an admin from the Sign-up page, which additionally requires every blocking
|
||||
requirement to be resolved first. Nothing flips status automatically just
|
||||
because the fee cleared or the last checklist item was ticked (checklist actions
|
||||
aren't even admin-gated); activation is always that one deliberate admin step,
|
||||
so a membership can be fully paid *and* fully checked off and still sit PENDING
|
||||
until someone actually clicks Approve.
|
||||
|
||||
``blocked_event_kinds`` is what makes a specific requirement matter before that
|
||||
point: a club can decide e.g. a medical certificate blocks GAME invitations/
|
||||
selection but not TRAINING ones, so a provisionally-rostered member (see
|
||||
``events.services.attendance.effective_members``) can still be invited to practice
|
||||
while their paperwork is outstanding. Empty means "informational only" -- open or
|
||||
not, it never blocks anything. Stored as a plain list of ``events.models.Event.
|
||||
EventKind`` values (not a FK/enum at the DB layer) specifically to avoid a
|
||||
club -> events import cycle (events already imports club for Event.club); the
|
||||
form layer (management/forms.py) is what actually validates against EventKind.
|
||||
|
||||
``MemberRequirementStatus`` tracks completion per ``ClubMembership`` (so a fresh
|
||||
checklist starts each season, matching how membership itself is season-scoped).
|
||||
"""
|
||||
|
||||
name = models.CharField(_("name"), max_length=100)
|
||||
description = models.TextField(_("description"), blank=True, help_text=_("Shown to staff on the member's checklist."))
|
||||
requires_document = models.BooleanField(_("requires a document"), default=False, help_text=_("Staff can attach a file (e.g. the certificate itself) when marking this complete."))
|
||||
blocked_event_kinds = models.JSONField(_("blocks selection for"), default=list, blank=True, help_text=_("Event kinds a member can't be invited to or selected for while this is open. Empty means purely informational."))
|
||||
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive requirements no longer apply to new memberships, but existing statuses are kept."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("onboarding requirement")
|
||||
verbose_name_plural = _("onboarding requirements")
|
||||
# Alphabetical, not a configurable sequence: every active requirement
|
||||
# blocks equally and there's no set order to complete them in, so
|
||||
# ordering here is purely for a stable, predictable listing.
|
||||
ordering = ["name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["club", "name"], name="unique_onboarding_requirement_name_per_club"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class MemberRequirementStatus(UUIDModel):
|
||||
"""Whether one ``ClubMembership`` has satisfied one ``OnboardingRequirement``,
|
||||
this season. Not itself club-scoped -- its club is reached through ``membership``,
|
||||
same reasoning as ``FeePayment`` above."""
|
||||
|
||||
membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="requirement_statuses", verbose_name=_("membership"))
|
||||
requirement = models.ForeignKey(OnboardingRequirement, on_delete=models.CASCADE, related_name="statuses", verbose_name=_("requirement"))
|
||||
is_complete = models.BooleanField(_("complete"), default=False)
|
||||
#: Distinct from is_complete -- "confirmed, not needed for this person" (e.g. they
|
||||
#: already have a recent photo on file) reads differently from "actually received"
|
||||
#: on a checklist/audit, even though both equally stop this item from blocking
|
||||
#: anything (see club.services.onboarding.is_open). Mutually exclusive with
|
||||
#: is_complete in practice (mark_bypassed/mark_complete each clear the other).
|
||||
is_bypassed = models.BooleanField(_("bypassed"), default=False)
|
||||
completed_at = models.DateTimeField(_("completed at"), null=True, blank=True)
|
||||
completed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+", verbose_name=_("completed by"))
|
||||
document = models.FileField(_("document"), storage=private_storage, upload_to=onboarding_document_path, blank=True, help_text=_("Stored privately -- readable only through this member's own page, never a direct link."))
|
||||
note = models.TextField(_("note"), blank=True, help_text=_("Staff-only, e.g. how or when this was received."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("member requirement status")
|
||||
verbose_name_plural = _("member requirement statuses")
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["membership", "requirement"], name="unique_requirement_status_per_membership"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.membership} — {self.requirement}"
|
||||
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.membership.club_id, same_club_fields=("requirement",))
|
||||
|
||||
|
||||
class ClubRole(ClubScopedModel):
|
||||
@@ -179,6 +575,12 @@ class ClubRole(ClubScopedModel):
|
||||
ADMIN = "admin", _("admin")
|
||||
MEMBER = "member", _("member")
|
||||
EDITOR = "editor", _("editor")
|
||||
#: Full read/write on people (members, families, groups, parent claims,
|
||||
#: teams, referee setup, onboarding requirements) without Finance/Shop,
|
||||
#: Club identity, Sponsors, or the ability to grant/revoke ClubRole itself
|
||||
#: -- see club.services.access.can_manage_members and
|
||||
#: club.mixins.MemberAdminRequiredMixin for exactly what that covers.
|
||||
MEMBER_ADMIN = "member_admin", _("member admin")
|
||||
|
||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="roles", verbose_name=_("member"))
|
||||
role = models.CharField(_("role"), max_length=250, choices=Roles.choices, default=Roles.MEMBER)
|
||||
|
||||
@@ -20,9 +20,9 @@ from django.db.models import Q, QuerySet
|
||||
from django.utils import timezone
|
||||
|
||||
from authentication.models import User
|
||||
from club.models import Club, ClubRole, Season
|
||||
from club.models import Club, ClubMembership, ClubRole, Season
|
||||
from events.models import Event
|
||||
from members.models import FamilyMembership, Member
|
||||
from members.models import FamilyMembership, Group, Member
|
||||
from teams.models import StaffAssignment, Team
|
||||
|
||||
#: Derived (never stored) roles.
|
||||
@@ -45,8 +45,45 @@ def has_club_role(user: User, club: Club, role: ClubRole.Roles) -> bool:
|
||||
return ClubRole.objects.filter(member__user=user, club=club, role=role).exists()
|
||||
|
||||
|
||||
def is_platform_superuser(user: User) -> bool:
|
||||
"""A Django superuser sees and manages every club as if they held ADMIN there,
|
||||
with no ClubRole row needed -- the platform-operator override. Already forced
|
||||
through MFA regardless (authentication.middleware.mfa_required_for checks
|
||||
is_superuser directly), so this bypass never skips that."""
|
||||
return bool(user and user.is_authenticated and user.is_superuser)
|
||||
|
||||
|
||||
def is_club_admin(user: User, club: Club) -> bool:
|
||||
return has_club_role(user, club, ClubRole.Roles.ADMIN)
|
||||
return is_platform_superuser(user) or has_club_role(user, club, ClubRole.Roles.ADMIN)
|
||||
|
||||
|
||||
def is_member_admin(user: User, club: Club) -> bool:
|
||||
"""MEMBER_ADMIN: full read/write on people (members, families, groups, parent
|
||||
claims, teams, referee setup, onboarding requirements) without Finance/Shop,
|
||||
Club identity, Sponsors, or the ability to grant/revoke ClubRole itself --
|
||||
see can_manage_members for the actual gate, this is just the role check."""
|
||||
return has_club_role(user, club, ClubRole.Roles.MEMBER_ADMIN)
|
||||
|
||||
|
||||
def can_manage_members(user: User, club: Club) -> bool:
|
||||
"""The gate for club.mixins.MemberAdminRequiredMixin -- real ADMIN (which already
|
||||
includes the superuser bypass), or MEMBER_ADMIN specifically."""
|
||||
return is_club_admin(user, club) or is_member_admin(user, club)
|
||||
|
||||
|
||||
def has_management_access(user: User, club: Club) -> bool:
|
||||
"""Anyone with real authority in the club: ADMIN/EDITOR/MEMBER_ADMIN, a platform
|
||||
superuser, or *any* current-season staff assignment (coach, team manager,
|
||||
physio, ...).
|
||||
|
||||
Deliberately excludes the plain MEMBER role -- every signed-up player (or club
|
||||
member generally) holds that automatically the moment their ClubMembership goes
|
||||
active (club/signals.py), so it says nothing about whether someone is staff.
|
||||
"""
|
||||
if is_platform_superuser(user):
|
||||
return True
|
||||
elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR, ClubRole.Roles.MEMBER_ADMIN)).exists()
|
||||
return elevated or teams_staffed_by(user, club).exists()
|
||||
|
||||
|
||||
def is_coach_manager(user: User, club: Club) -> bool:
|
||||
@@ -80,6 +117,15 @@ def teams_managed_by(user: User, club: Club) -> QuerySet[Team]:
|
||||
).distinct()
|
||||
|
||||
|
||||
def groups_manageable_by(user: User, club: Club) -> QuerySet[Group]:
|
||||
"""Groups the user may schedule an event for: all for an ADMIN, else only
|
||||
the ones they're themselves a member of -- unlike Team, Group has no
|
||||
manager/owner concept, so membership is the only claim there is to check."""
|
||||
if is_club_admin(user, club):
|
||||
return Group.objects.filter(club=club)
|
||||
return Group.objects.filter(club=club, memberships__member__user=user).distinct()
|
||||
|
||||
|
||||
def teams_staffed_by(user: User, club: Club) -> QuerySet[Team]:
|
||||
"""Teams the user is on the staff of this season, management or not.
|
||||
|
||||
@@ -93,15 +139,41 @@ def teams_staffed_by(user: User, club: Club) -> QuerySet[Team]:
|
||||
).distinct()
|
||||
|
||||
|
||||
def members_visible_to(user: User, club: Club) -> QuerySet[Member]:
|
||||
def _guardians_only(club: Club) -> QuerySet[Member]:
|
||||
"""People whose *only* tie to ``club`` is being a parent of a member.
|
||||
|
||||
Subtracted rather than filtered out at the source, because a bare MEMBER
|
||||
ClubRole with no ClubMembership is a real state -- someone the club knows
|
||||
but hasn't signed up for a season yet -- and narrowing the role branch to
|
||||
weed guardians out would take those people with it. Anyone who also holds a
|
||||
real membership, plays, is on a team's staff or runs the club is a member
|
||||
who happens to be a parent, and stays visible.
|
||||
"""
|
||||
return Member.objects.filter(member_of__club=club, member_of__kind=ClubMembership.Kind.GUARDIAN).exclude(
|
||||
Q(member_of__club=club, member_of__kind=ClubMembership.Kind.MEMBER)
|
||||
| Q(team_memberships__team__club=club)
|
||||
| Q(staff_assignments__team__club=club)
|
||||
| Q(roles__club=club, roles__role__in=[ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR])
|
||||
)
|
||||
|
||||
|
||||
def members_visible_to(user: User, club: Club, *, include_guardians: bool = False) -> QuerySet[Member]:
|
||||
"""Members the user may see.
|
||||
|
||||
ADMIN: everyone linked to the club (membership, roster, staff or role).
|
||||
Otherwise: themselves, their children, and the current-season players *and*
|
||||
staff of every team they're staffed on.
|
||||
|
||||
Guardians -- parents attached to the club only through a child, see
|
||||
``ClubMembership.Kind`` -- are **excluded by default**: they aren't members,
|
||||
so they don't belong in a member list or any member count. Pass
|
||||
``include_guardians=True`` where the page is about a *person* rather than
|
||||
about members: opening a guardian's own detail page, editing them, putting
|
||||
them in a group, or showing a family (whose parents are the whole point).
|
||||
"""
|
||||
if is_club_admin(user, club):
|
||||
return Member.objects.filter(Q(member_of__club=club) | Q(team_memberships__team__club=club) | Q(staff_assignments__team__club=club) | Q(roles__club=club)).distinct()
|
||||
attached = Member.objects.filter(Q(member_of__club=club) | Q(team_memberships__team__club=club) | Q(staff_assignments__team__club=club) | Q(roles__club=club)).distinct()
|
||||
return attached if include_guardians else attached.exclude(pk__in=_guardians_only(club))
|
||||
|
||||
me = Member.objects.filter(user=user).first()
|
||||
if me is None:
|
||||
@@ -139,3 +211,22 @@ def can_edit_event(user: User, event: Event) -> bool:
|
||||
|
||||
def can_manage_shop(user: User, club: Club) -> bool:
|
||||
return is_club_admin(user, club)
|
||||
|
||||
|
||||
def can_add_news(user: User, club: Club) -> bool:
|
||||
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
|
||||
author club content, not just anyone on staff (a physio shouldn't post news)."""
|
||||
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR) or is_coach_manager(user, club)
|
||||
|
||||
|
||||
def can_publish_news(user: User, club: Club) -> bool:
|
||||
"""Only ADMIN/EDITOR may push a news item live -- the release-flow gate."""
|
||||
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR)
|
||||
|
||||
|
||||
def can_edit_news(user: User, news_item) -> bool:
|
||||
"""Broad while it's a draft (anyone who could create one); editor/admin-only
|
||||
once published -- an editor is accountable for what's actually live."""
|
||||
if news_item.status == news_item.Status.PUBLISHED:
|
||||
return can_publish_news(user, news_item.club)
|
||||
return can_add_news(user, news_item.club)
|
||||
|
||||
80
club/services/fees.py
Normal file
80
club/services/fees.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Recording money received against a membership's fee.
|
||||
|
||||
Mirrors billing.services.dues.record_payment for a different kind of money: a
|
||||
member's own club fee, not the club's platform subscription. amount_paid is kept in
|
||||
step here, never recomputed by re-aggregating FeePayment on every read.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import F
|
||||
|
||||
from club.models import ClubMembership, FeePayment
|
||||
|
||||
|
||||
def remaining_balance(membership):
|
||||
return max(membership.fee_amount - membership.amount_paid, Decimal("0.00"))
|
||||
|
||||
|
||||
def open_dues_rows(club, people, season):
|
||||
"""Every season-dues row still owed by ``people`` in ``season`` -- shared by
|
||||
mobile's Home dues card and its Payments & dues screen so the two never
|
||||
drift out of sync on what counts as "still open". WAIVED memberships and
|
||||
fully-paid balances are excluded."""
|
||||
if season is None or not people:
|
||||
return []
|
||||
|
||||
memberships = ClubMembership.objects.filter(club=club, member__in=people, season=season).exclude(fee_status=ClubMembership.FeeStatus.WAIVED).select_related("dues_invoice", "member")
|
||||
rows = []
|
||||
for membership in memberships:
|
||||
balance = remaining_balance(membership)
|
||||
if balance > 0:
|
||||
rows.append({"membership": membership, "balance": balance, "invoice": getattr(membership, "dues_invoice", None)})
|
||||
return rows
|
||||
|
||||
|
||||
def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
|
||||
"""Record money received against one membership's fee. Several payments may
|
||||
land on one membership -- a family paying in two installments must not read as
|
||||
unpaid. Updates amount_paid and re-syncs fee_status to match; membership.status
|
||||
is untouched -- see _sync_fee_status."""
|
||||
payment = FeePayment.objects.create(membership=membership, amount=amount, method=method, reference=reference, note=note, recorded_by=recorded_by)
|
||||
|
||||
membership.amount_paid = F("amount_paid") + amount
|
||||
membership.save(update_fields=["amount_paid"])
|
||||
membership.refresh_from_db(fields=["amount_paid"])
|
||||
_sync_fee_status(membership)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
def mark_as_paid(membership, *, recorded_by=None):
|
||||
"""The "settle this one" action behind both the per-row and bulk buttons. If
|
||||
there's a real remaining balance, records it as a payment (auditable, shows up
|
||||
in history); if fee_amount was never priced (remaining is 0), just flips the
|
||||
flags directly -- there's no real transaction to log."""
|
||||
remaining = remaining_balance(membership)
|
||||
if remaining > 0:
|
||||
record_payment(membership, amount=remaining, method=FeePayment.Method.OTHER, note="Marked as paid", recorded_by=recorded_by)
|
||||
else:
|
||||
_sync_fee_status(membership, force_paid=True)
|
||||
|
||||
|
||||
def _sync_fee_status(membership, *, force_paid=False):
|
||||
if membership.fee_status == ClubMembership.FeeStatus.WAIVED:
|
||||
return # manual, independent of payments -- this never overrides it
|
||||
|
||||
if force_paid or (membership.fee_amount > 0 and membership.amount_paid >= membership.fee_amount):
|
||||
new_status = ClubMembership.FeeStatus.PAID
|
||||
elif membership.amount_paid > 0:
|
||||
new_status = ClubMembership.FeeStatus.PARTIALLY_PAID
|
||||
else:
|
||||
new_status = ClubMembership.FeeStatus.UNPAID
|
||||
|
||||
# fee_status only -- membership.status is never touched here. Paying in full
|
||||
# used to also flip status straight to ACTIVE on its own; now that's exclusively
|
||||
# club.services.onboarding.approve_one/approve_all_clean's call, so a paid-up
|
||||
# membership still waits on that deliberate admin step. See OnboardingRequirement's
|
||||
# docstring (club/models.py) for why.
|
||||
membership.fee_status = new_status
|
||||
membership.save(update_fields=["fee_status"])
|
||||
82
club/services/images.py
Normal file
82
club/services/images.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Dimensions for uploads that aren't Django ImageFields.
|
||||
|
||||
Logos (Club.logo, Sponsor.logo) are plain FileFields, not ImageFields --
|
||||
Pillow can't validate SVGs, and crests/sponsor logos are commonly vector
|
||||
files -- so there's no automatic width_field/height_field the way there
|
||||
would be on an ImageField. This fills that gap: Pillow for raster formats,
|
||||
a bounded regex read of the root <svg> tag for vector ones (not a full XML
|
||||
parse -- this reads untrusted uploads, and a parser is exposed to entity
|
||||
expansion attacks a plain attribute read never is).
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
_SVG_TAG_RE = re.compile(rb"<svg\b[^>]*>", re.IGNORECASE | re.DOTALL)
|
||||
_WIDTH_RE = re.compile(rb"""\bwidth\s*=\s*["']([^"']+)["']""", re.IGNORECASE)
|
||||
_HEIGHT_RE = re.compile(rb"""\bheight\s*=\s*["']([^"']+)["']""", re.IGNORECASE)
|
||||
_VIEWBOX_RE = re.compile(rb"""\bviewBox\s*=\s*["']\s*([\d.+-]+)[ ,]+([\d.+-]+)[ ,]+([\d.+-]+)[ ,]+([\d.+-]+)""", re.IGNORECASE)
|
||||
_LEADING_NUMBER_RE = re.compile(r"[\d.]+")
|
||||
|
||||
#: The root <svg> tag is always near the top of the file -- no need to read
|
||||
#: (or regex-scan) anything past a small header.
|
||||
_SVG_HEAD_BYTES = 8192
|
||||
|
||||
|
||||
def _svg_length(raw: bytes) -> int | None:
|
||||
"""Parse an SVG length attribute (``"200"``, ``"200px"``) to a rounded
|
||||
int, or None if it's relative (``"100%"``) and so not a real pixel size."""
|
||||
text = raw.decode("utf-8", errors="ignore").strip()
|
||||
if text.endswith("%"):
|
||||
return None
|
||||
match = _LEADING_NUMBER_RE.match(text)
|
||||
return round(float(match.group(0))) if match else None
|
||||
|
||||
|
||||
def _svg_dimensions(file) -> tuple[int | None, int | None]:
|
||||
try:
|
||||
file.seek(0)
|
||||
head = file.read(_SVG_HEAD_BYTES)
|
||||
except OSError:
|
||||
return None, None
|
||||
# Reset for whatever reads the file next (e.g. FileField writing it to storage).
|
||||
file.seek(0)
|
||||
|
||||
tag_match = _SVG_TAG_RE.search(head)
|
||||
svg_tag = tag_match.group(0) if tag_match else head
|
||||
|
||||
width_match, height_match = _WIDTH_RE.search(svg_tag), _HEIGHT_RE.search(svg_tag)
|
||||
if width_match and height_match:
|
||||
width, height = _svg_length(width_match.group(1)), _svg_length(height_match.group(1))
|
||||
if width and height:
|
||||
return width, height
|
||||
|
||||
viewbox_match = _VIEWBOX_RE.search(svg_tag)
|
||||
if viewbox_match:
|
||||
_, _, width, height = viewbox_match.groups()
|
||||
return round(float(width)), round(float(height))
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def get_image_dimensions(file) -> tuple[int | None, int | None]:
|
||||
"""Best-effort (width, height) for an uploaded logo -- (None, None) if the
|
||||
file can't be read as an image (corrupt upload, unrecognised format)."""
|
||||
if not file:
|
||||
return None, None
|
||||
|
||||
name = getattr(file, "name", "") or ""
|
||||
if name.lower().endswith(".svg"):
|
||||
return _svg_dimensions(file)
|
||||
|
||||
try:
|
||||
file.seek(0)
|
||||
with Image.open(file) as image:
|
||||
size = image.size
|
||||
# Reset for whatever reads the file next (e.g. FileField writing it to storage) --
|
||||
# only on the success path, since a failed open/read leaves nothing to rewind.
|
||||
file.seek(0)
|
||||
return size
|
||||
except (OSError, UnidentifiedImageError):
|
||||
return None, None
|
||||
188
club/services/invoicing.py
Normal file
188
club/services/invoicing.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Dues invoices: asking a member (or their parent/guardian) to pay an outstanding
|
||||
membership fee, and chasing it if the due date passes unpaid.
|
||||
|
||||
Kept separate from club.services.fees on purpose: fees.py owns what's actually owed
|
||||
and settled (fee_amount/amount_paid/fee_status), this module only owns the paper
|
||||
trail of having asked for it. A DuesInvoice's own "paid" reading is always the live
|
||||
membership.fee_status -- never a flag duplicated here that could drift out of step.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
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_lazy as _
|
||||
|
||||
from club.models import ClubMembership, DuesInvoice
|
||||
from club.services.fees import remaining_balance
|
||||
from events.models import Location
|
||||
|
||||
|
||||
class DuesInvoicePDFError(Exception):
|
||||
"""Raised when WeasyPrint's native libraries aren't available."""
|
||||
|
||||
|
||||
def recipient_for(member) -> tuple[str, bool]:
|
||||
"""Best email to invoice ``member`` at: their own, else the first parent/guardian
|
||||
who has one. Empty string means nobody reachable at all -- the caller must not
|
||||
create or send an invoice in that case."""
|
||||
if member.contact_email:
|
||||
return member.contact_email, False
|
||||
|
||||
for guardian in member.guardians.order_by("last_name", "first_name"):
|
||||
if guardian.contact_email:
|
||||
return guardian.contact_email, True
|
||||
|
||||
return "", False
|
||||
|
||||
|
||||
def create_or_resend_invoice(membership: ClubMembership, *, due_in_days: int, recipient_email: str, sent_to_guardian: bool) -> DuesInvoice:
|
||||
"""Create the membership's one invoice, or re-snapshot it if it already has one.
|
||||
Never touches reminder_count/last_reminder_sent_at -- a fresh send earns a fresh
|
||||
reminder clock, but that's set by the reminder path itself, not reset here, since
|
||||
a resend before any reminder went out has nothing to reset."""
|
||||
invoice, _created = DuesInvoice.objects.get_or_create(
|
||||
club=membership.club,
|
||||
membership=membership,
|
||||
defaults={"amount": remaining_balance(membership), "due_date": timezone.now().date() + timedelta(days=due_in_days)},
|
||||
)
|
||||
invoice.amount = remaining_balance(membership)
|
||||
invoice.due_date = timezone.now().date() + timedelta(days=due_in_days)
|
||||
invoice.sent_at = timezone.now()
|
||||
invoice.sent_to_email = recipient_email
|
||||
invoice.sent_to_guardian = sent_to_guardian
|
||||
# get_or_create's own save (for a new row) already assigned invoice.number,
|
||||
# so it's always set by this point -- update_fields never needs to include it.
|
||||
invoice.save(update_fields=["amount", "due_date", "sent_at", "sent_to_email", "sent_to_guardian", "modified"])
|
||||
return invoice
|
||||
|
||||
|
||||
def _email_context(invoice: DuesInvoice, *, request=None) -> dict:
|
||||
return {"club": invoice.club, "invoice": invoice, "membership": invoice.membership, "member": invoice.membership.member, "request": request}
|
||||
|
||||
|
||||
def _attach_pdf(message: EmailMultiAlternatives, invoice: DuesInvoice) -> None:
|
||||
"""Best-effort: a club running without WeasyPrint's native libraries still gets
|
||||
the invoice email itself, just without the PDF -- everything the PDF shows is
|
||||
already in the email body."""
|
||||
try:
|
||||
pdf_bytes = invoice_pdf(invoice)
|
||||
except DuesInvoicePDFError:
|
||||
return
|
||||
message.attach(f"{invoice.number}.pdf", pdf_bytes, "application/pdf")
|
||||
|
||||
|
||||
def send_invoice_email(invoice: DuesInvoice, *, request=None) -> bool:
|
||||
"""Mail the branded invoice to invoice.sent_to_email. Never fatal: the invoice
|
||||
row (and its sent_at stamp) exists whether or not the mail leaves the building --
|
||||
see members.services.claims.send_claim_approved_email for the same reasoning."""
|
||||
if not invoice.sent_to_email:
|
||||
return False
|
||||
|
||||
context = _email_context(invoice, request=request)
|
||||
subject = " ".join(render_to_string("club/email/dues_invoice_subject.txt", context).split())
|
||||
text_body = render_to_string("club/email/dues_invoice.txt", context).strip() + "\n"
|
||||
html_body = render_to_string("club/email/dues_invoice.html", context)
|
||||
|
||||
message = EmailMultiAlternatives(subject, text_body, settings.DEFAULT_FROM_EMAIL, [invoice.sent_to_email])
|
||||
message.attach_alternative(html_body, "text/html")
|
||||
_attach_pdf(message, invoice)
|
||||
|
||||
try:
|
||||
message.send(fail_silently=False)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def invoices_due_for_reminder(club, today=None):
|
||||
"""Sent, unpaid (and not waived -- nothing's owed there), past their own due
|
||||
date. Reminders are opt-in per club-wide button push, not a cron job, so there's
|
||||
no "already reminded today" guard here -- see MembershipSendInvoiceRemindersView."""
|
||||
today = today or timezone.now().date()
|
||||
return (
|
||||
DuesInvoice.objects.filter(club=club, sent_at__isnull=False, due_date__lt=today)
|
||||
.exclude(membership__fee_status__in=[ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED])
|
||||
.select_related("membership__member")
|
||||
)
|
||||
|
||||
|
||||
def send_reminder_email(invoice: DuesInvoice, *, request=None) -> bool:
|
||||
if not invoice.sent_to_email:
|
||||
return False
|
||||
|
||||
context = _email_context(invoice, request=request)
|
||||
subject = " ".join(render_to_string("club/email/dues_invoice_reminder_subject.txt", context).split())
|
||||
text_body = render_to_string("club/email/dues_invoice_reminder.txt", context).strip() + "\n"
|
||||
html_body = render_to_string("club/email/dues_invoice_reminder.html", context)
|
||||
|
||||
message = EmailMultiAlternatives(subject, text_body, settings.DEFAULT_FROM_EMAIL, [invoice.sent_to_email])
|
||||
message.attach_alternative(html_body, "text/html")
|
||||
_attach_pdf(message, invoice)
|
||||
|
||||
try:
|
||||
message.send(fail_silently=False)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
invoice.last_reminder_sent_at = timezone.now()
|
||||
invoice.reminder_count += 1
|
||||
invoice.save(update_fields=["last_reminder_sent_at", "reminder_count", "modified"])
|
||||
return True
|
||||
|
||||
|
||||
def send_reminders(club, *, request=None) -> tuple[int, int]:
|
||||
"""Push-button "remind everyone past due" -- returns (sent, failed)."""
|
||||
sent = failed = 0
|
||||
for invoice in invoices_due_for_reminder(club):
|
||||
if send_reminder_email(invoice, request=request):
|
||||
sent += 1
|
||||
else:
|
||||
failed += 1
|
||||
return sent, failed
|
||||
|
||||
|
||||
def render_pdf(html: str) -> bytes:
|
||||
"""Same lazy-import shape as management.pdf.render_pdf/billing.services.invoices.render_pdf
|
||||
-- WeasyPrint binds to native pango/cairo libraries, and a machine without them
|
||||
must still be able to run the app; this only fails when someone actually asks
|
||||
for a PDF. Not shared with either of those: an app depending on another app's
|
||||
PDF error type for a two-line function isn't worth the coupling."""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
except (ImportError, OSError) as error:
|
||||
raise DuesInvoicePDFError(_("PDF rendering needs the native pango/cairo libraries (on macOS: brew install pango).")) from error
|
||||
|
||||
return HTML(string=html).write_pdf()
|
||||
|
||||
|
||||
def resolve_document_address(club):
|
||||
"""The address to print on an official document header (a dues invoice,
|
||||
the referee payment form) -- the club's own ``legal_address`` when set,
|
||||
else its home ground (``events.models.Location``, ``is_home=True``), so
|
||||
a club that hasn't set a legal address yet still gets *something* rather
|
||||
than a blank header.
|
||||
|
||||
``Location.is_home`` itself is purely about telling a home game from an
|
||||
away one -- this is the one place its address doubles as a stand-in for
|
||||
an actual registered/mailing address, and only when the club hasn't set
|
||||
one of its own. Returns an object exposing ``.address``/``.zip_code``/
|
||||
``.city`` either way (a plain namespace for the legal-address branch, the
|
||||
real ``Location`` for the fallback), or ``None`` when neither is set.
|
||||
"""
|
||||
if club.legal_address:
|
||||
return SimpleNamespace(address=club.legal_address, zip_code=club.legal_zip_code, city=club.legal_city)
|
||||
return Location.objects.filter(club=club, is_home=True).first()
|
||||
|
||||
|
||||
def invoice_pdf(invoice: DuesInvoice) -> bytes:
|
||||
# Same header convention as management/event_referee_form_pdf.html: the club's
|
||||
# legal name (official_name falls back to the everyday name when unset) and its
|
||||
# document address -- never an event-specific location, since a dues invoice
|
||||
# isn't tied to any one event.
|
||||
document_address = resolve_document_address(invoice.club)
|
||||
html = render_to_string("club/dues_invoice_pdf.html", {"club": invoice.club, "invoice": invoice, "membership": invoice.membership, "member": invoice.membership.member, "document_address": document_address})
|
||||
return render_pdf(html)
|
||||
253
club/services/onboarding.py
Normal file
253
club/services/onboarding.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Per-member onboarding checklist -- see OnboardingRequirement's docstring
|
||||
(club/models.py) for why fee_status stays untouched by any of this, and for
|
||||
why approve_one/approve_all_clean below are the only way to reach
|
||||
ClubMembership.status ACTIVE (fee_status alone, even fully PAID, never does).
|
||||
|
||||
No signal pre-creates a MemberRequirementStatus row when a membership is created
|
||||
or a requirement is added: "required, no row yet" and "required, row with
|
||||
is_complete=is_bypassed=False" both mean the same thing (not done), so there is
|
||||
nothing to backfill either way -- a club adding a new requirement mid-season
|
||||
immediately shows it as open on every existing membership, and deactivating one
|
||||
immediately stops asking for it, with no migration-shaped cleanup step in either
|
||||
direction.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import ClubMembership, MemberRequirementStatus, OnboardingRequirement
|
||||
from members.models import Member
|
||||
|
||||
#: Shared by every "is this item resolved" check below -- resolved means it no
|
||||
#: longer blocks anything, whether that's because it was actually completed or
|
||||
#: because staff decided it doesn't apply to this person.
|
||||
_RESOLVED = Q(is_complete=True) | Q(is_bypassed=True)
|
||||
|
||||
|
||||
def checklist_for(membership):
|
||||
"""Every active requirement for this membership's club, each paired with its
|
||||
status row if one exists (or None -- not started). One query for the
|
||||
requirements, one for the statuses that exist; the membership detail page
|
||||
renders exactly this list under its Documents tab."""
|
||||
requirements = OnboardingRequirement.objects.filter(club_id=membership.club_id, is_active=True)
|
||||
statuses = {status.requirement_id: status for status in membership.requirement_statuses.select_related("completed_by")}
|
||||
|
||||
return [(requirement, statuses.get(requirement.pk)) for requirement in requirements]
|
||||
|
||||
|
||||
def mark_complete(membership, requirement, *, user, document=None, note=""):
|
||||
"""Actually received/verified -- as opposed to mark_bypassed, "not needed for
|
||||
this person". Clears any prior bypass: the two are mutually exclusive."""
|
||||
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
|
||||
status.is_complete = True
|
||||
status.is_bypassed = False
|
||||
status.completed_at = timezone.now()
|
||||
status.completed_by = user
|
||||
status.note = note
|
||||
if document:
|
||||
status.document = document
|
||||
status.save()
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def mark_bypassed(membership, requirement, *, user, note=""):
|
||||
"""Confirmed not needed for this member (e.g. they already have a recent
|
||||
photo on file) -- stops the item blocking anything, same as mark_complete,
|
||||
but reads correctly on the checklist/audit trail as a deliberate staff
|
||||
decision rather than a document actually received. A note is expected here
|
||||
(not enforced at this layer -- see RequirementBypassForm) since "why" is the
|
||||
whole point of a bypass in a way it isn't for an ordinary completion."""
|
||||
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
|
||||
status.is_complete = False
|
||||
status.is_bypassed = True
|
||||
status.completed_at = timezone.now()
|
||||
status.completed_by = user
|
||||
status.note = note
|
||||
status.document = None
|
||||
status.save()
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def mark_incomplete(membership, requirement):
|
||||
"""Undo a mark_complete/mark_bypassed -- kept as a row (not deleted) so the
|
||||
document/note a club already collected isn't thrown away by an accidental
|
||||
toggle."""
|
||||
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
|
||||
status.is_complete = False
|
||||
status.is_bypassed = False
|
||||
status.completed_at = None
|
||||
status.completed_by = None
|
||||
status.save()
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def annotate_onboarding_status(queryset):
|
||||
"""`queryset` of ClubMembership, returned as a list with each row given an
|
||||
`.onboarding_open` attribute (count of unresolved active requirements) -- the
|
||||
list-page equivalent of the `open_requirement_count` property, in a fixed
|
||||
number of queries regardless of list size rather than the N+1 a per-row
|
||||
property call would cost across a whole table."""
|
||||
memberships = list(queryset)
|
||||
if not memberships:
|
||||
return memberships
|
||||
|
||||
required_by_club = {}
|
||||
for club_id in {membership.club_id for membership in memberships}:
|
||||
required_by_club[club_id] = set(OnboardingRequirement.objects.filter(club_id=club_id, is_active=True).values_list("pk", flat=True))
|
||||
|
||||
met_by_membership = defaultdict(set)
|
||||
statuses = MemberRequirementStatus.objects.filter(membership_id__in=[membership.pk for membership in memberships]).filter(_RESOLVED)
|
||||
for membership_id, requirement_id in statuses.values_list("membership_id", "requirement_id"):
|
||||
met_by_membership[membership_id].add(requirement_id)
|
||||
|
||||
for membership in memberships:
|
||||
required = required_by_club.get(membership.club_id, set())
|
||||
membership.onboarding_open = len(required - met_by_membership[membership.pk])
|
||||
|
||||
return memberships
|
||||
|
||||
|
||||
def members_with_open_requirements(club, season):
|
||||
"""Members whose current-season membership has at least one unresolved active
|
||||
requirement -- the same condition the dashboard's "Missing documentation" KPI
|
||||
counts (management.views.HomeView), reused here for the member list's own
|
||||
?docs=open filter. None when there's no season to check against."""
|
||||
if season is None:
|
||||
return Member.objects.none()
|
||||
|
||||
memberships = list(ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER))
|
||||
annotate_onboarding_status(memberships)
|
||||
member_ids = [membership.member_id for membership in memberships if membership.onboarding_open]
|
||||
return Member.objects.filter(pk__in=member_ids)
|
||||
|
||||
|
||||
def blocking_event_kinds(membership) -> set:
|
||||
"""Every event kind currently blocked for this membership by at least one open
|
||||
(not complete, not bypassed) active requirement -- e.g. {"game"} while a medical
|
||||
certificate is outstanding but nothing blocks training. Powers the Sign-up page's
|
||||
detail pane and member_detail's Documents tab ("blocks: Games" next to an open
|
||||
item), so staff can see exactly what's at stake without reading every requirement."""
|
||||
blocked = set()
|
||||
for requirement, status in checklist_for(membership):
|
||||
if status is not None and (status.is_complete or status.is_bypassed):
|
||||
continue
|
||||
blocked.update(requirement.blocked_event_kinds)
|
||||
return blocked
|
||||
|
||||
|
||||
def blocked_member_ids_for_event(club, season, event_kind) -> set:
|
||||
"""Member ids that must NOT be invited to (or selectable for) an event of
|
||||
`event_kind` this season, because at least one active requirement that blocks
|
||||
that kind is still open on their current-season membership. Bulk, not per-member
|
||||
-- events.services.attendance.effective_members() calls this once per event save,
|
||||
not once per candidate member.
|
||||
|
||||
A member with no current-season ClubMembership.MEMBER row at all isn't covered
|
||||
here -- effective_members() already wouldn't include them (they're not on any
|
||||
roster to begin with), so there's nothing to subtract.
|
||||
|
||||
Filtered in Python, not via a `blocked_event_kinds__contains=[event_kind]`
|
||||
queryset lookup -- JSONField `contains` isn't supported on SQLite (only
|
||||
Postgres/MySQL/Oracle), and a club's own requirement count is always small
|
||||
enough that fetching them all costs nothing worth optimising away."""
|
||||
blocking_requirement_ids = {requirement.pk for requirement in OnboardingRequirement.objects.filter(club=club, is_active=True) if event_kind in requirement.blocked_event_kinds}
|
||||
if not blocking_requirement_ids:
|
||||
return set()
|
||||
|
||||
memberships = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER)
|
||||
resolved_by_membership = defaultdict(set)
|
||||
statuses = MemberRequirementStatus.objects.filter(membership__in=memberships, requirement_id__in=blocking_requirement_ids).filter(_RESOLVED)
|
||||
for membership_id, requirement_id in statuses.values_list("membership_id", "requirement_id"):
|
||||
resolved_by_membership[membership_id].add(requirement_id)
|
||||
|
||||
blocked_member_ids = set()
|
||||
for membership_id, member_id in memberships.values_list("pk", "member_id"):
|
||||
if blocking_requirement_ids - resolved_by_membership.get(membership_id, set()):
|
||||
blocked_member_ids.add(member_id)
|
||||
return blocked_member_ids
|
||||
|
||||
|
||||
def open_requirements_blocking(member, club, season, event_kind) -> list:
|
||||
"""The specific, still-open `OnboardingRequirement`s blocking `member` from
|
||||
`event_kind` events this season -- the per-member, explain-*why* mirror of
|
||||
`blocked_member_ids_for_event`'s bulk set. Built for the member-facing "you
|
||||
can't sign up for this yet" card (mobile app): unlike that function, this
|
||||
one is meant to be called once for a person looking at one event, not once
|
||||
per event for a whole club, so the per-member cost here is fine.
|
||||
|
||||
Empty (never blocked) for a member with no current-season ClubMembership.MEMBER
|
||||
row -- same "nothing to check" reasoning as blocked_member_ids_for_event's own."""
|
||||
membership = ClubMembership.objects.filter(club=club, season=season, member=member, kind=ClubMembership.Kind.MEMBER).first()
|
||||
if membership is None:
|
||||
return []
|
||||
|
||||
blocking = [requirement for requirement in OnboardingRequirement.objects.filter(club=club, is_active=True) if event_kind in requirement.blocked_event_kinds]
|
||||
if not blocking:
|
||||
return []
|
||||
|
||||
resolved_ids = set(MemberRequirementStatus.objects.filter(membership=membership, requirement__in=blocking).filter(_RESOLVED).values_list("requirement_id", flat=True))
|
||||
return [requirement for requirement in blocking if requirement.pk not in resolved_ids]
|
||||
|
||||
|
||||
#: Fee states "clean" enough to activate on -- PARTIALLY_PAID/UNPAID never are.
|
||||
_CLEAN_FEE_STATUSES = (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED)
|
||||
|
||||
|
||||
def is_signup_clean(membership) -> bool:
|
||||
"""Paid up (or waived) and every active requirement resolved -- what both
|
||||
approve_all_clean and approve_one gate on, and what the Sign-up page's
|
||||
per-member Approve button enables/disables against. Not itself a shortcut
|
||||
for "already active": a membership can be exactly this clean and still be
|
||||
PENDING, waiting on this deliberately manual step."""
|
||||
return membership.fee_status in _CLEAN_FEE_STATUSES and membership.onboarding_complete
|
||||
|
||||
|
||||
def approve_one(membership) -> bool:
|
||||
"""Admin-triggered single activation from the Sign-up page's detail panel --
|
||||
same rule and same reasoning as approve_all_clean, just one membership instead
|
||||
of a whole season's queue. Returns whether it actually activated (False if it
|
||||
wasn't PENDING or wasn't clean)."""
|
||||
if membership.status != ClubMembership.StatusChoices.PENDING or not is_signup_clean(membership):
|
||||
return False
|
||||
membership.status = ClubMembership.StatusChoices.ACTIVE
|
||||
update_fields = ["status"]
|
||||
if membership.activated_at is None:
|
||||
membership.activated_at = timezone.localdate()
|
||||
update_fields.append("activated_at")
|
||||
membership.save(update_fields=update_fields)
|
||||
return True
|
||||
|
||||
|
||||
def approve_all_clean(club, season) -> int:
|
||||
"""Admin-triggered bulk activation from the Sign-up page -- the *only* path to
|
||||
ClubMembership.status ACTIVE (see OnboardingRequirement's docstring: paying in
|
||||
full only settles fee_status now, club.services.fees._sync_fee_status never
|
||||
touches status). Only ever moves PENDING -> ACTIVE, and only for a membership
|
||||
that is both paid up (fee_status PAID or WAIVED) and has resolved every active
|
||||
requirement -- "manual documentation check to be done by the admin" means
|
||||
clicking this once everything has actually been checked, not something that runs
|
||||
on its own. Returns how many memberships were activated."""
|
||||
memberships = list(
|
||||
ClubMembership.objects.filter(
|
||||
club=club,
|
||||
season=season,
|
||||
kind=ClubMembership.Kind.MEMBER,
|
||||
status=ClubMembership.StatusChoices.PENDING,
|
||||
fee_status__in=_CLEAN_FEE_STATUSES,
|
||||
)
|
||||
)
|
||||
annotate_onboarding_status(memberships)
|
||||
ready = [membership for membership in memberships if membership.onboarding_open == 0]
|
||||
today = timezone.localdate()
|
||||
for membership in ready:
|
||||
membership.status = ClubMembership.StatusChoices.ACTIVE
|
||||
if membership.activated_at is None:
|
||||
membership.activated_at = today
|
||||
if ready:
|
||||
ClubMembership.objects.bulk_update(ready, ["status", "activated_at"])
|
||||
return len(ready)
|
||||
131
club/services/seasons.py
Normal file
131
club/services/seasons.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Generating a club's seasons ahead of time.
|
||||
|
||||
Season (club/models.py) has no stored notion of "when a season starts" -- that
|
||||
lives on Club instead (season_start, season_duration_months), since different
|
||||
clubs run their year on different cycles. Same shape as
|
||||
events/services/recurrence.py's generate_occurrences: materialise missing rows
|
||||
up to a horizon, get_or_create per row, safe to call repeatedly.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.db.models import ProtectedError
|
||||
from django.db.models.deletion import Collector
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Season
|
||||
|
||||
|
||||
def _initial_season_start(club, today):
|
||||
"""The most recent occurrence of the club's configured season_start that is
|
||||
not later than ``today`` -- so a club with no seasons yet gets one covering
|
||||
"now" (or the most recently completed one), not an arbitrary future year."""
|
||||
anchor = club.season_start
|
||||
start = datetime.date(today.year, anchor.month, anchor.day)
|
||||
if start > today:
|
||||
start = datetime.date(today.year - 1, anchor.month, anchor.day)
|
||||
return start
|
||||
|
||||
|
||||
def _season_end(start, club):
|
||||
return start + relativedelta(months=club.season_duration_months) - datetime.timedelta(days=1)
|
||||
|
||||
|
||||
def generate_seasons(club, until):
|
||||
"""Materialise seasons for ``club`` from wherever it last left off -- the day
|
||||
after its latest season's end_date, or its configured season_start if it has
|
||||
none yet -- through ``until``. get_or_create per row (matches the
|
||||
unique_season_dates_per_club constraint exactly), safe to call repeatedly.
|
||||
"""
|
||||
latest = Season.objects.filter(club=club).order_by("-end_date").first()
|
||||
start = latest.end_date + datetime.timedelta(days=1) if latest else _initial_season_start(club, timezone.localdate())
|
||||
|
||||
created = []
|
||||
while start <= until:
|
||||
end = _season_end(start, club)
|
||||
season, was_created = Season.objects.get_or_create(club=club, start_date=start, end_date=end)
|
||||
if was_created:
|
||||
_carry_guardians_into(club, season)
|
||||
created.append(season)
|
||||
start = end + datetime.timedelta(days=1)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def _carry_guardians_into(club, season):
|
||||
"""Copy the previous season's guardians into a season that has just been created.
|
||||
|
||||
The other half of members.services.family.carry_guardians_forward, which
|
||||
covers a guardian added *after* the later seasons already existed. Between
|
||||
them a parent keeps their tie to the club across every season boundary --
|
||||
without which they would quietly drop off the club while their child stayed
|
||||
enrolled, which is exactly the state a guardian exists to prevent.
|
||||
|
||||
Copied from the immediately preceding season, not from "any season ever", so
|
||||
a guardian an admin deliberately removed stays removed rather than being
|
||||
resurrected from an older row.
|
||||
"""
|
||||
from club.models import ClubMembership
|
||||
|
||||
previous = Season.objects.filter(club=club, start_date__lt=season.start_date).order_by("-start_date").first()
|
||||
if previous is None:
|
||||
return
|
||||
|
||||
guardians = ClubMembership.objects.filter(club=club, season=previous, kind=ClubMembership.Kind.GUARDIAN)
|
||||
ClubMembership.objects.bulk_create(
|
||||
[ClubMembership(club=club, member_id=guardian.member_id, season=season, kind=ClubMembership.Kind.GUARDIAN, status=guardian.status, signed_up_at=guardian.signed_up_at) for guardian in guardians],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
|
||||
def _expected_season_dates(club, until):
|
||||
"""The (start_date, end_date) pairs generate_seasons would produce for
|
||||
``club`` from scratch, ignoring whatever already exists -- used by
|
||||
resync_seasons to tell "matches the club's current settings" from "doesn't"."""
|
||||
start = _initial_season_start(club, timezone.localdate())
|
||||
expected = set()
|
||||
while start <= until:
|
||||
end = _season_end(start, club)
|
||||
expected.add((start, end))
|
||||
start = end + datetime.timedelta(days=1)
|
||||
return expected
|
||||
|
||||
|
||||
def _is_referenced(season):
|
||||
"""Whether deleting ``season`` would hit a PROTECT on any of its relations
|
||||
(ClubMembership, StaffAssignment, TeamMembership, Event, ...) without
|
||||
actually deleting anything."""
|
||||
collector = Collector(using=season._state.db)
|
||||
try:
|
||||
collector.collect([season])
|
||||
except ProtectedError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resync_seasons(club, until, *, commit=False):
|
||||
"""Find seasons for ``club`` that don't match what its *current*
|
||||
season_start/season_duration_months would produce (e.g. left over from a
|
||||
different rule, or from before those settings were changed), within the
|
||||
same horizon generate_seasons would cover.
|
||||
|
||||
A season is only ever removed if nothing references it through a PROTECTed
|
||||
relation -- a season already in use is reported as kept, never silently
|
||||
dropped. With commit=False (the default) nothing is deleted; the caller
|
||||
gets back what *would* happen.
|
||||
"""
|
||||
expected = _expected_season_dates(club, until)
|
||||
|
||||
removed, kept = [], []
|
||||
for season in Season.objects.filter(club=club):
|
||||
if (season.start_date, season.end_date) in expected:
|
||||
continue
|
||||
if _is_referenced(season):
|
||||
kept.append(season)
|
||||
else:
|
||||
removed.append(season)
|
||||
if commit:
|
||||
season.delete()
|
||||
|
||||
return removed, kept
|
||||
30
club/services/sponsors.py
Normal file
30
club/services/sponsors.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Which sponsors are currently "live" -- shared by the public API
|
||||
(club/api.py, the club's own external website) and the mobile member app's
|
||||
Home screen (mobile/views.py), so both read the same definition of "active"
|
||||
rather than each re-deriving it.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from ..models import Sponsor
|
||||
|
||||
|
||||
def active_sponsors(club, *, randomize=False):
|
||||
"""Sponsors currently live for ``club``: ``start_date`` has passed and
|
||||
either there's no ``end_date`` (runs indefinitely once started) or it
|
||||
hasn't passed yet. Both bounds are inclusive of today.
|
||||
|
||||
``randomize=True`` shuffles the result (e.g. for a sponsor strip that
|
||||
shouldn't always lead with the same one) -- shuffled in Python after a
|
||||
stable-ordered fetch rather than an ORDER BY RANDOM(), which sponsor
|
||||
counts are far too small to need and which SQLite/Postgres don't even
|
||||
express the same way.
|
||||
"""
|
||||
today = timezone.localdate()
|
||||
sponsors = list(Sponsor.objects.filter(club=club, start_date__lte=today).filter(Q(end_date__isnull=True) | Q(end_date__gte=today)).order_by("name"))
|
||||
if randomize:
|
||||
random.shuffle(sponsors)
|
||||
return sponsors
|
||||
36
club/tasks.py
Normal file
36
club/tasks.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Celery task behind the `generate-seasons` beat schedule entry (see
|
||||
rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py).
|
||||
|
||||
Mirrors `manage.py generate_seasons`'s default behaviour (generate, not --resync) exactly --
|
||||
that command still exists, unchanged, for manual use from a shell, including --resync, which
|
||||
this task deliberately does not run unattended (see club/management/commands/generate_seasons.py:
|
||||
--resync can delete rows, so it isn't something a beat schedule should do on its own).
|
||||
"""
|
||||
|
||||
from celery import shared_task
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Club
|
||||
from club.services.seasons import generate_seasons as generate_seasons_for_club
|
||||
from features.models import JobToggle, Maintenance
|
||||
|
||||
#: How far ahead to generate, matching the management command's own default.
|
||||
YEARS_AHEAD = 2
|
||||
|
||||
|
||||
@shared_task(name="club.tasks.generate_seasons")
|
||||
def generate_seasons():
|
||||
if Maintenance.is_on():
|
||||
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
|
||||
if not JobToggle.is_enabled("club.tasks.generate_seasons"):
|
||||
raise RuntimeError("This job is disabled in the control panel.")
|
||||
|
||||
until = timezone.localdate() + relativedelta(years=YEARS_AHEAD)
|
||||
clubs = Club.objects.active()
|
||||
|
||||
total = 0
|
||||
for club in clubs:
|
||||
total += len(generate_seasons_for_club(club, until))
|
||||
|
||||
return f"Generated {total} season(s) across {clubs.count()} club(s)."
|
||||
100
club/templates/club/dues_invoice_pdf.html
Normal file
100
club/templates/club/dues_invoice_pdf.html
Normal file
@@ -0,0 +1,100 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% comment %}
|
||||
Rendered by WeasyPrint, not a browser -- same convention as billing/templates/billing/invoice.html
|
||||
and management/templates/management/membership_list_pdf.html: a standalone document with its
|
||||
own print stylesheet, no app.css. Branded off the club's own colours (falling back to the same
|
||||
shades club.templatetags.club_email's HTML emails use) rather than a fixed accent, since this
|
||||
is the club's invoice to its own member, not RosterChief's to the club.
|
||||
{% endcomment %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ invoice.number }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm;
|
||||
@bottom-center {
|
||||
content: "{{ club.name }} — {% trans "invoice" %} {{ invoice.number }} — " counter(page) " / " 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; align-items: flex-start; margin-bottom: 12mm; }
|
||||
.accent { color: {{ club.primary_color|default:"#4f46e5" }}; }
|
||||
.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>{{ club.official_name }}</h1>
|
||||
{% if document_address %}
|
||||
<div class="muted">{{ document_address.address }}</div>
|
||||
<div class="muted">{{ document_address.zip_code }} {{ document_address.city }}</div>
|
||||
{% endif %}
|
||||
{% if club.contact_email %}<div class="muted">{{ club.contact_email }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="right">
|
||||
<h1 class="accent">{% trans "Invoice" %}</h1>
|
||||
<div><strong>{{ invoice.number }}</strong></div>
|
||||
{% if invoice.sent_at %}<div class="muted">{% blocktrans with date=invoice.sent_at|date:"j F Y" %}Issued {{ date }}{% endblocktrans %}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="parties">
|
||||
<div>
|
||||
<h2>{% trans "Billed to" %}</h2>
|
||||
<div><strong>{{ member }}</strong></div>
|
||||
{% if invoice.sent_to_email %}<div class="muted">{{ invoice.sent_to_email }}{% if invoice.sent_to_guardian %} ({% trans "parent/guardian" %}){% endif %}</div>{% endif %}
|
||||
</div>
|
||||
<div class="right">
|
||||
<h2>{% trans "Season" %}</h2>
|
||||
<div>{{ membership.season }}</div>
|
||||
<div class="muted">{% blocktrans with date=invoice.due_date|date:"j F Y" %}Due {{ date }}{% endblocktrans %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<th class="right">{% trans "Amount" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{% trans "Membership fee" %}</strong>
|
||||
<div class="muted">{{ club.name }} — {{ membership.season }}</div>
|
||||
</td>
|
||||
<td class="right">€{{ invoice.amount }}</td>
|
||||
</tr>
|
||||
<tr class="total">
|
||||
<td>{% trans "Balance due" %}</td>
|
||||
<td class="right balance {% if invoice.is_paid %}paid{% else %}owed{% endif %}">€{{ invoice.amount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if invoice.is_paid %}
|
||||
<p class="paid"><strong>{% trans "Paid in full." %}</strong> {% trans "Thank you." %}</p>
|
||||
{% else %}
|
||||
<p class="muted">{% blocktrans with date=invoice.due_date|date:"j F Y" %}Payable by {{ date }}.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
72
club/templates/club/email/dues_invoice.html
Normal file
72
club/templates/club/email/dues_invoice.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "email/_base.html" %}
|
||||
{% load i18n club_email %}
|
||||
|
||||
{% comment %}
|
||||
HTML sibling of dues_invoice.txt -- same content, same context (club, membership,
|
||||
member, invoice, request), laid out for an inbox. Kept in lockstep with the .txt
|
||||
version by hand, same as members/templates/members/email/claim_approved.html.
|
||||
{% endcomment %}
|
||||
|
||||
{% block title %}{% blocktrans with club=club.name number=invoice.number %}{{ club }} — invoice {{ number }}{% endblocktrans %}{% endblock title %}
|
||||
|
||||
{% block preheader %}{% blocktrans with club=club.name %}{{ club }} has sent you an invoice for a membership fee.{% endblocktrans %}{% endblock preheader %}
|
||||
|
||||
{% block header %}
|
||||
{% absolute_media_url club.logo as logo_url %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td valign="middle">
|
||||
{% if club.logo %}
|
||||
<img src="{{ logo_url }}" alt="{{ club.name }}" width="48" height="48" style="display:block; width:48px; height:48px; border-radius:24px; object-fit:contain; background-color:#f3f4f6;">
|
||||
{% else %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_color|default:"#ec4899"|contrast_color }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
|
||||
{{ club.initials }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding-left:14px;" valign="middle">
|
||||
<span style="font-family: Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; color:#111827;">{{ club.name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock header %}
|
||||
|
||||
{% block content %}
|
||||
<p style="margin:0 0 16px 0;">{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}</p>
|
||||
|
||||
<p style="margin:0 0 20px 0;">{% blocktrans with club=club.name season=membership.season %}{{ club }} has sent you an invoice for your {{ season }} membership fee.{% endblocktrans %}</p>
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0; border:1px solid #e5e7eb; border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:16px 20px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#6b7280;">{% trans "Amount due" %}</td>
|
||||
<td align="right" style="font-size:18px; font-weight:bold; color:#111827;">€{{ invoice.amount }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#6b7280; padding-top:6px;">{% trans "Due date" %}</td>
|
||||
<td align="right" style="font-size:13px; color:#111827; padding-top:6px;">{{ invoice.due_date|date:"j F Y" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#6b7280; padding-top:6px;">{% trans "Invoice number" %}</td>
|
||||
<td align="right" style="font-size:13px; color:#111827; padding-top:6px;">{{ invoice.number }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;">{% trans "A PDF copy of this invoice is attached." %}</p>
|
||||
{% endblock content %}
|
||||
|
||||
{% block footer %}
|
||||
{% if club.contact_email %}
|
||||
<p style="margin:0 0 8px 0;">{% blocktrans with email=club.contact_email %}Questions about this invoice? Reply to this note or write to {{ email }}.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p style="margin:0;">{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}</p>
|
||||
{% endblock footer %}
|
||||
11
club/templates/club/email/dues_invoice.txt
Normal file
11
club/templates/club/email/dues_invoice.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
{% load i18n %}{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}
|
||||
|
||||
{% blocktrans with club=club.name season=membership.season %}{{ club }} has sent you an invoice for your {{ season }} membership fee.{% endblocktrans %}
|
||||
|
||||
{% trans "Amount due:" %} €{{ invoice.amount }}
|
||||
{% trans "Due date:" %} {{ invoice.due_date|date:"j F Y" }}
|
||||
{% trans "Invoice number:" %} {{ invoice.number }}
|
||||
{% if club.contact_email %}
|
||||
{% blocktrans with email=club.contact_email %}Questions about this invoice? Reply to this note or write to {{ email }}.{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}
|
||||
73
club/templates/club/email/dues_invoice_reminder.html
Normal file
73
club/templates/club/email/dues_invoice_reminder.html
Normal file
@@ -0,0 +1,73 @@
|
||||
{% extends "email/_base.html" %}
|
||||
{% load i18n club_email %}
|
||||
|
||||
{% comment %}
|
||||
HTML sibling of dues_invoice_reminder.txt -- same content, same context (club,
|
||||
membership, member, invoice, request). Same shell/branding as dues_invoice.html,
|
||||
just a different message and no "PDF attached" line (the reminder re-attaches
|
||||
the same PDF the original invoice did, but leads with the overdue note instead).
|
||||
{% endcomment %}
|
||||
|
||||
{% block title %}{% blocktrans with club=club.name number=invoice.number %}Reminder: {{ club }} invoice {{ number }} is overdue{% endblocktrans %}{% endblock title %}
|
||||
|
||||
{% block preheader %}{% blocktrans with club=club.name %}A membership fee invoice from {{ club }} is still unpaid.{% endblocktrans %}{% endblock preheader %}
|
||||
|
||||
{% block header %}
|
||||
{% absolute_media_url club.logo as logo_url %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td valign="middle">
|
||||
{% if club.logo %}
|
||||
<img src="{{ logo_url }}" alt="{{ club.name }}" width="48" height="48" style="display:block; width:48px; height:48px; border-radius:24px; object-fit:contain; background-color:#f3f4f6;">
|
||||
{% else %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_color|default:"#ec4899"|contrast_color }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
|
||||
{{ club.initials }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding-left:14px;" valign="middle">
|
||||
<span style="font-family: Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; color:#111827;">{{ club.name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock header %}
|
||||
|
||||
{% block content %}
|
||||
<p style="margin:0 0 16px 0;">{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}</p>
|
||||
|
||||
<p style="margin:0 0 20px 0;">{% blocktrans with club=club.name date=invoice.due_date|date:"j F Y" %}This is a reminder that {{ club }}'s invoice for your membership fee was due on {{ date }} and is still unpaid.{% endblocktrans %}</p>
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0; border:1px solid #fca5a5; background-color:#fef2f2; border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:16px 20px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#991b1b;">{% trans "Amount due" %}</td>
|
||||
<td align="right" style="font-size:18px; font-weight:bold; color:#991b1b;">€{{ invoice.amount }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#991b1b; padding-top:6px;">{% trans "Was due" %}</td>
|
||||
<td align="right" style="font-size:13px; color:#991b1b; padding-top:6px;">{{ invoice.due_date|date:"j F Y" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px; color:#991b1b; padding-top:6px;">{% trans "Invoice number" %}</td>
|
||||
<td align="right" style="font-size:13px; color:#991b1b; padding-top:6px;">{{ invoice.number }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;">{% trans "A PDF copy of this invoice is attached." %}</p>
|
||||
{% endblock content %}
|
||||
|
||||
{% block footer %}
|
||||
{% if club.contact_email %}
|
||||
<p style="margin:0 0 8px 0;">{% blocktrans with email=club.contact_email %}Already paid? Let us know at {{ email }} so we can update our records.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p style="margin:0;">{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}</p>
|
||||
{% endblock footer %}
|
||||
10
club/templates/club/email/dues_invoice_reminder.txt
Normal file
10
club/templates/club/email/dues_invoice_reminder.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
{% load i18n %}{% blocktrans with name=member.first_name %}Hello {{ name }},{% endblocktrans %}
|
||||
|
||||
{% blocktrans with club=club.name date=invoice.due_date|date:"j F Y" %}This is a reminder that {{ club }}'s invoice for your membership fee was due on {{ date }} and is still unpaid.{% endblocktrans %}
|
||||
|
||||
{% trans "Amount due:" %} €{{ invoice.amount }}
|
||||
{% trans "Invoice number:" %} {{ invoice.number }}
|
||||
{% if club.contact_email %}
|
||||
{% blocktrans with email=club.contact_email %}Already paid? Let us know at {{ email }} so we can update our records.{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}
|
||||
@@ -0,0 +1 @@
|
||||
{% load i18n %}{% blocktrans with club=club.name number=invoice.number %}Reminder: {{ club }} invoice {{ number }} is overdue{% endblocktrans %}
|
||||
1
club/templates/club/email/dues_invoice_subject.txt
Normal file
1
club/templates/club/email/dues_invoice_subject.txt
Normal file
@@ -0,0 +1 @@
|
||||
{% load i18n %}{% blocktrans with club=club.name number=invoice.number %}{{ club }} — invoice {{ number }}{% endblocktrans %}
|
||||
@@ -12,6 +12,9 @@
|
||||
You are signed in as <span class="font-semibold">{{ user.get_full_name|default:user.email }}</span>.
|
||||
</p>
|
||||
<p class="text-sm opacity-70">The club site lands here. For now this page exists so signing in has somewhere to go.</p>
|
||||
<div class="card-actions pt-2">
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'members:my_family' %}">{% lucide "users" size=14 %} My family</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
0
club/templatetags/__init__.py
Normal file
0
club/templatetags/__init__.py
Normal file
43
club/templatetags/club_email.py
Normal file
43
club/templatetags/club_email.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Template helper shared by every HTML email that shows a club's logo.
|
||||
|
||||
An <img> in an email has no page to resolve a relative /media/... URL against
|
||||
the way a browser tab would -- the inbox fetches it cold. Storage backends that
|
||||
already return an absolute URL (e.g. S3 in production) are unaffected; this
|
||||
only matters for the local FileSystemStorage used in dev, where FieldFile.url
|
||||
is relative.
|
||||
"""
|
||||
|
||||
from django import template
|
||||
|
||||
from club.models import Club
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def contrast_color(hex_color):
|
||||
"""Black or white, whichever reads on ``hex_color`` -- for a literal
|
||||
fallback background (e.g. ``club.secondary_color|default:"#ec4899"``)
|
||||
rather than a club's own colour, which already has a matching
|
||||
``primary_content_color``/``secondary_content_color`` computed for it.
|
||||
|
||||
Chaining this onto the *same* expression used for the background --
|
||||
``club.secondary_color|default:"#ec4899"|contrast_color`` -- rather than
|
||||
hardcoding a second, independently-guessed text colour is what keeps the
|
||||
two from drifting apart: a pale fallback and a dark one both get the
|
||||
contrast Club._content_color_for would compute for them either way.
|
||||
"""
|
||||
return Club._content_color_for(hex_color)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def absolute_media_url(context, file_field):
|
||||
"""An absolute URL for ``file_field`` (e.g. ``club.logo``), for use in an
|
||||
email. Falls back to the field's own (possibly relative) ``.url`` when
|
||||
there's no request in the template context to build an absolute one from --
|
||||
better a relative URL than a hard error while rendering the email."""
|
||||
if not file_field:
|
||||
return ""
|
||||
|
||||
request = context.get("request")
|
||||
return request.build_absolute_uri(file_field.url) if request is not None else file_field.url
|
||||
1427
club/tests.py
1427
club/tests.py
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.shortcuts import redirect
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
|
||||
@@ -21,3 +21,13 @@ def root(request):
|
||||
return redirect("controlpanel:dashboard")
|
||||
|
||||
return ClubHomeView.as_view()(request)
|
||||
|
||||
|
||||
def signup_closed(request):
|
||||
"""Self-registration is closed -- see rosterchief/urls.py.
|
||||
|
||||
Shadows allauth's own signup route rather than removing it, so the
|
||||
`account_signup` URL name every allauth template reverses still resolves and
|
||||
the login page doesn't 500 looking for it.
|
||||
"""
|
||||
return render(request, "account/signup_closed.html", status=403)
|
||||
|
||||
@@ -19,6 +19,14 @@ services:
|
||||
env_file: .env.production
|
||||
ports:
|
||||
- "127.0.0.1:${WEB_PORT:-8001}:8000"
|
||||
volumes:
|
||||
# Uploaded club logos, while storage is local disk (see rosterchief/urls.py). Without
|
||||
# this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps
|
||||
# running fine in between.
|
||||
- media_data:/app/media
|
||||
# Private uploads (e.g. a member's medical certificate) -- see compose.yaml's own
|
||||
# comment on this volume for why it's absent from every other service here.
|
||||
- private_media_data:/app/private_media
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -31,6 +39,32 @@ services:
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
worker:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
# See compose.yaml for what runs here and why.
|
||||
command: ["celery", "-A", "rosterchief", "worker", "--loglevel=info", "--concurrency=2"]
|
||||
volumes:
|
||||
- media_data:/app/media
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
beat:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
# Exactly ONE of these across the whole deployment -- see compose.yaml.
|
||||
command: ["celery", "-A", "rosterchief", "beat", "--loglevel=info"]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
@@ -38,6 +72,8 @@ services:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-rosterchief}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-rosterchief}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a database password}
|
||||
# See compose.yaml for why these are trimmed from the defaults.
|
||||
command: ["postgres", "-c", "shared_buffers=64MB", "-c", "max_connections=20"]
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -49,7 +85,10 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||
# Cache AND Celery broker for worker/beat above -- see compose.yaml's redis comment.
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "32mb", "--maxmemory-policy", "allkeys-lru"]
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
media_data:
|
||||
private_media_data:
|
||||
|
||||
63
compose.yaml
63
compose.yaml
@@ -25,6 +25,9 @@ services:
|
||||
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
# Read-only: Caddy serves club logos straight off disk instead of round-tripping every
|
||||
# image request through a gunicorn worker. Same volume `web` writes uploads into.
|
||||
- media_data:/srv/media:ro
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
@@ -32,6 +35,15 @@ services:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
volumes:
|
||||
# Uploaded club logos, while storage is local disk (see rosterchief/urls.py). Without
|
||||
# this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps
|
||||
# running fine in between.
|
||||
- media_data:/app/media
|
||||
# Private uploads (e.g. a member's medical certificate -- see rosterchief/storage.py).
|
||||
# Deliberately NOT mounted into `caddy` below, unlike media_data: nothing should be able
|
||||
# to serve this except the authenticated Django view that reads it.
|
||||
- private_media_data:/app/private_media
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -44,6 +56,38 @@ services:
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
worker:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
# The scheduled platform jobs (see billing/tasks.py, club/tasks.py, events/tasks.py) run
|
||||
# here, dispatched by `beat` below over the same Redis `web` uses as a cache — see
|
||||
# rosterchief/settings.py's "Task queue (Celery)" section. Several of these are safe to
|
||||
# scale; `beat` is not (see its own comment).
|
||||
command: ["celery", "-A", "rosterchief", "worker", "--loglevel=info", "--concurrency=2"]
|
||||
volumes:
|
||||
- media_data:/app/media
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
beat:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
# The scheduler -- decides *when* each task in CELERY_BEAT_SCHEDULE fires and hands it to
|
||||
# a worker. Run exactly ONE of these: two beats would each independently decide it's time
|
||||
# and every job runs twice (two archive_overdue_clubs runs is two emails to the same club,
|
||||
# same reasoning as the old crontab's "exactly one node" -- see DEPLOYMENT.md).
|
||||
command: ["celery", "-A", "rosterchief", "beat", "--loglevel=info"]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
@@ -51,6 +95,11 @@ services:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-rosterchief}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-rosterchief}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a database password}
|
||||
# shared_buffers/max_connections default to 128MB / 100 — sized for a much bigger database
|
||||
# than this app's (DEPLOYMENT.md: ~0.2GB after 5 years). 20 connections is comfortably above
|
||||
# 2 gunicorn workers x 4 threads plus the odd `manage.py` one-off; trimmed both for the box,
|
||||
# not for the data.
|
||||
command: ["postgres", "-c", "shared_buffers=64MB", "-c", "max_connections=20"]
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -62,11 +111,19 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||
# Cache only, so nothing here needs to survive a restart. It is not optional though: it
|
||||
# is what keeps every gunicorn worker agreeing about which feature flags are on.
|
||||
# Doubles as the Celery broker/result backend for `worker`/`beat` (see rosterchief/settings.py)
|
||||
# as well as the cache. maxmemory-policy allkeys-lru is right for a cache — evict rather than
|
||||
# grow unbounded — but it means a queued task message COULD be evicted under memory pressure
|
||||
# before a worker consumes it, same as a Redis restart drops anything queued (--save "",
|
||||
# --appendonly no: nothing here persists by design). Acceptable at this job volume (five
|
||||
# scheduled tasks a day; a missed one runs at its next scheduled time regardless, per
|
||||
# CELERY_BEAT_SCHEDULE); if that stops being true, give Celery its own Redis instance rather
|
||||
# than changing this cache's eviction policy to suit it.
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "32mb", "--maxmemory-policy", "allkeys-lru"]
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
media_data:
|
||||
private_media_data:
|
||||
|
||||
16
controlpanel/context_processors.py
Normal file
16
controlpanel/context_processors.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""So the command bar's status indicator (base.html) can reflect real job health on every
|
||||
control panel page, not just the dashboard, without every view remembering to pass it.
|
||||
|
||||
Guarded to controlpanel pages only -- unlike features.context_processors.maintenance (a
|
||||
cached read, cheap anywhere), this runs a real query, and every other page on the platform
|
||||
(club subdomains, the public site) has no command bar to show it on.
|
||||
"""
|
||||
|
||||
from .services.jobs import recent_job_failures
|
||||
|
||||
|
||||
def job_health(request):
|
||||
if not (request.resolver_match and request.resolver_match.app_name == "controlpanel"):
|
||||
return {}
|
||||
|
||||
return {"failed_jobs": recent_job_failures()}
|
||||
@@ -4,8 +4,9 @@ from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from waffle import get_waffle_flag_model
|
||||
|
||||
from billing.models import DuePayment, Subscription, Tier, TierPrice
|
||||
from billing.models import DuePayment, Plan, PlanPrice, Subscription
|
||||
from club.models import Club
|
||||
from events.models import Competition, Location
|
||||
|
||||
from .services.admins import find_member_by_email
|
||||
|
||||
@@ -13,18 +14,38 @@ from .services.admins import find_member_by_email
|
||||
class ClubForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["name", "slug", "logo", "primary_color"]
|
||||
fields = ["name", "legal_name", "contact_email", "website", "slug", "sport_type", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"]
|
||||
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
|
||||
# Deliberately a text input, not <input type="color">: a colour picker cannot
|
||||
# express "no colour" -- it would submit #000000 for every club that never
|
||||
# touched it, and every club would silently get a black theme.
|
||||
widgets = {"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"})}
|
||||
widgets = {
|
||||
"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}),
|
||||
"secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}),
|
||||
"logo": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp,image/svg+xml"}),
|
||||
"season_start": forms.DateInput(attrs={"type": "date"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["slug"].required = False
|
||||
|
||||
|
||||
class HomeLocationForm(forms.ModelForm):
|
||||
"""Create or update the club's home ground -- this *is* an events.Location row
|
||||
(flagged ``is_home``), the same one that shows up under the club's own
|
||||
Teams > Locations page, so the two stay in sync by construction rather than
|
||||
needing anything to keep them that way."""
|
||||
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = ["name", "address", "city", "zip_code", "country"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["country"].widget.attrs.update({"data-searchable": "true", "data-search-placeholder": _("Type a country to search...")})
|
||||
|
||||
|
||||
class ClubAdminForm(forms.Form):
|
||||
"""Grant club-admin rights to an email address, creating the person if new."""
|
||||
|
||||
@@ -61,34 +82,94 @@ class FlagForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class TierForm(forms.ModelForm):
|
||||
class CompetitionForm(forms.ModelForm):
|
||||
"""Metadata for events.services.competitions.fetch_game_info's per-club gate --
|
||||
`module` is a dotted import path to a class named `name` that implements
|
||||
`update_game_information(event=...)`; there is no such class for a new
|
||||
competition until one is actually written, but that's fine here, same as
|
||||
editing this by hand in the Django admin today: fetch_game_info already
|
||||
catches the resulting ImportError/AttributeError and treats it as "nothing to
|
||||
fetch from" rather than a 500, so this form doesn't need to validate the path
|
||||
against real code to be safe to use."""
|
||||
|
||||
class Meta:
|
||||
model = Tier
|
||||
fields = ["name", "description", "is_active"]
|
||||
model = Competition
|
||||
fields = ["name", "module", "sport_type", "flag"]
|
||||
help_texts = {
|
||||
"module": _("Dotted path to the Python module implementing this competition's data source, e.g. events.services.competitions.cehl."),
|
||||
}
|
||||
|
||||
|
||||
class TierPriceForm(forms.ModelForm):
|
||||
class PlanForm(forms.ModelForm):
|
||||
"""Field order is chosen for the two-column modal (see _form_fields.html): description
|
||||
spans both columns, so pairing name with duration and the two day-counts with each other
|
||||
fills every row instead of leaving half of one empty.
|
||||
|
||||
name | duration_months
|
||||
description ....................... (spans both)
|
||||
renewal_lead_days | grace_days
|
||||
is_trial | is_active
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = TierPrice
|
||||
model = Plan
|
||||
fields = ["name", "duration_months", "description", "renewal_lead_days", "grace_days", "is_trial", "is_active"]
|
||||
|
||||
|
||||
class PlanPriceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = PlanPrice
|
||||
fields = ["active_from", "amount"]
|
||||
widgets = {"active_from": forms.DateInput(attrs={"type": "date"})}
|
||||
help_texts = {"active_from": _("Periods opening on or after this date are billed at this amount. Existing periods keep the amount they were billed at.")}
|
||||
help_texts = {
|
||||
"active_from": _(
|
||||
"Periods opening on or after this date are billed at this amount. Existing periods keep the amount "
|
||||
"they were billed at — including any already issued during a plan's renewal lead window, so enter a "
|
||||
"price change before that window opens."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class SubscriptionForm(forms.ModelForm):
|
||||
"""Put a club on a tier. The first period opens when the subscription is created."""
|
||||
"""Put a club on a plan. The first period opens when the subscription is created."""
|
||||
|
||||
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("First period starts"), help_text=_("Left blank, the period starts today."))
|
||||
|
||||
class Meta:
|
||||
model = Subscription
|
||||
fields = ["tier", "auto_archive", "notes"]
|
||||
fields = ["plan", "auto_renew", "auto_archive", "notes"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# An inactive tier still bills its existing subscriptions, but must not be picked up
|
||||
# by a new one — which is the whole point of retiring a tier.
|
||||
self.fields["tier"].queryset = Tier.objects.filter(is_active=True)
|
||||
# An inactive plan still bills its existing subscriptions, but must not be picked up
|
||||
# by a new one — which is the whole point of retiring a plan. Trial plans are excluded
|
||||
# too: they are reached through the trial form, which converts them properly.
|
||||
self.fields["plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=False)
|
||||
|
||||
|
||||
class TrialForm(forms.Form):
|
||||
"""Put a club with no subscription yet on a trial that switches itself to
|
||||
``post_trial_plan`` automatically once it ends -- see billing.services.dues.start_trial.
|
||||
|
||||
There is no length field: a trial's length is its plan's own ``duration_months``, so a
|
||||
1-month and a 3-month trial are two plans rather than one plan plus a number typed here.
|
||||
"""
|
||||
|
||||
trial_plan = forms.ModelChoiceField(queryset=Plan.objects.none(), label=_("Trial plan"), help_text=_("What this club is billed on during the trial. Its length is the plan's own duration."))
|
||||
post_trial_plan = forms.ModelChoiceField(queryset=Plan.objects.none(), label=_("Then switch to"), help_text=_("The plan it lands on automatically once the trial ends."))
|
||||
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("Trial starts"), help_text=_("Left blank, the trial starts today."))
|
||||
# Same two switches SubscriptionForm offers. Without them here a trial could only be
|
||||
# started on the defaults, and the only way to change them afterwards is the "Change
|
||||
# plan" modal -- which ends the trial as a side effect.
|
||||
auto_renew = forms.BooleanField(required=False, initial=True, label=_("Auto renew"), help_text=_("Issue the next period automatically before this one ends."))
|
||||
auto_archive = forms.BooleanField(required=False, initial=True, label=_("Auto archive"), help_text=_("Archive this club when a period goes unpaid past its grace period."))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Same reasoning as SubscriptionForm: a retired plan keeps billing whoever is
|
||||
# already on it, but must not be offered for a new trial or a new plan either.
|
||||
self.fields["trial_plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=True)
|
||||
self.fields["post_trial_plan"].queryset = Plan.objects.visible().filter(is_active=True, is_trial=False)
|
||||
|
||||
|
||||
class DuePaymentForm(forms.Form):
|
||||
|
||||
35
controlpanel/messages.py
Normal file
35
controlpanel/messages.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""A compact way to queue a Django message that carries its own title.
|
||||
|
||||
Django's messages framework has no title field — a call site that wants one passes it
|
||||
as ``extra_tags`` (``messages.success(request, body, extra_tags="Club created")``), which
|
||||
reads fine written out but is easy to forget, so in practice every message ends up on
|
||||
the generic per-level heading (`as_alert`'s "Done" / "Careful" / "Something went wrong").
|
||||
|
||||
``notify`` folds level, title and body into one string instead: ``"<level>|<title>|<body>"``.
|
||||
One call, title included, nothing to forget. `as_alert` (controlpanel/templatetags/ui.py)
|
||||
reads the title back off ``extra_tags`` at render time — unchanged from before.
|
||||
"""
|
||||
|
||||
from django.contrib import messages
|
||||
|
||||
#: One letter per Django message level. `notify` picks the level from the spec string;
|
||||
#: `as_alert` picks the icon/colour/fallback-title from the level the message actually
|
||||
#: carries (via ``level_tag``), so the two stay in step by construction.
|
||||
LEVELS = {
|
||||
"s": messages.SUCCESS,
|
||||
"i": messages.INFO,
|
||||
"w": messages.WARNING,
|
||||
"e": messages.ERROR,
|
||||
"d": messages.DEBUG,
|
||||
}
|
||||
|
||||
|
||||
def notify(request, spec: str, **kwargs) -> None:
|
||||
"""Queue a message from a ``"<level>|<title>|<body>"`` spec.
|
||||
|
||||
``level`` is one of ``s`` (success), ``i`` (info), ``w`` (warning), ``e`` (error),
|
||||
``d`` (debug). An empty title (``"s||Body text"``) falls back to the generic
|
||||
per-level heading, same as never passing ``extra_tags`` at all.
|
||||
"""
|
||||
level_code, title, body = spec.split("|", 2)
|
||||
messages.add_message(request, LEVELS[level_code], body, extra_tags=title, **kwargs)
|
||||
@@ -1,5 +1,8 @@
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.http import Http404
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from .messages import notify
|
||||
|
||||
|
||||
class PlatformStaffRequiredMixin(UserPassesTestMixin):
|
||||
@@ -38,3 +41,21 @@ class PlatformSuperuserRequiredMixin(PlatformStaffRequiredMixin):
|
||||
|
||||
def test_func(self):
|
||||
return self.request.user.is_superuser
|
||||
|
||||
|
||||
class RedirectOnInvalidMixin:
|
||||
"""A form submitted from a modal has nowhere sensible to re-render on error: the page
|
||||
that opened it has already moved on, and the view has no standalone template of its
|
||||
own. Redirect back to ``invalid_redirect_url_name`` instead, with the errors flattened
|
||||
into messages, rather than Django's default of re-rendering ``template_name``.
|
||||
"""
|
||||
|
||||
invalid_redirect_url_name = None
|
||||
|
||||
def get_invalid_redirect_kwargs(self):
|
||||
return {}
|
||||
|
||||
def form_invalid(self, form):
|
||||
for error in form.errors.values():
|
||||
notify(self.request, f"e|Couldn't save|{' '.join(error)}")
|
||||
return redirect(self.invalid_redirect_url_name, **self.get_invalid_redirect_kwargs())
|
||||
|
||||
52
controlpanel/services/jobs.py
Normal file
52
controlpanel/services/jobs.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Read side of the scheduled-job history for the control panel's Jobs tab and the
|
||||
Platform dashboard's job log / failed-jobs tile.
|
||||
|
||||
``features.jobs.JOB_REGISTRY`` is what a job *is* (label, description, schedule);
|
||||
``features.models.JobRun`` is what actually happened, written by the Celery signal handlers
|
||||
in features/signals.py. This module just joins the two for a template.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from features.jobs import JOB_REGISTRY
|
||||
from features.models import JobRun, JobToggle
|
||||
|
||||
#: Runs shown per job on the Jobs tab -- enough to see a pattern (a job that fails every
|
||||
#: third day, say) without the page turning into a full audit log.
|
||||
RECENT_RUNS = 10
|
||||
|
||||
#: What counts as "recent" for the dashboard's failed-jobs KPI tile.
|
||||
FAILURE_WINDOW_HOURS = 24
|
||||
|
||||
#: Rows in the Platform dashboard's job log card.
|
||||
JOB_LOG_ROWS = 8
|
||||
|
||||
|
||||
def job_overview():
|
||||
"""One entry per registered job, its most recent runs, and a shortcut to the latest."""
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"label": meta["label"],
|
||||
"description": meta["description"],
|
||||
"schedule": meta["schedule"],
|
||||
"enabled": JobToggle.is_enabled(name),
|
||||
"runs": (runs := list(JobRun.objects.filter(name=name)[:RECENT_RUNS])),
|
||||
"latest": runs[0] if runs else None,
|
||||
}
|
||||
for name, meta in JOB_REGISTRY.items()
|
||||
]
|
||||
|
||||
|
||||
def recent_job_failures(hours=FAILURE_WINDOW_HOURS):
|
||||
"""Failures in the last `hours` -- the platform-health "failed jobs" signal. A number
|
||||
that sits here is exactly what a dead beat schedule or a broken task looks like."""
|
||||
since = timezone.now() - timedelta(hours=hours)
|
||||
return JobRun.objects.filter(status=JobRun.Status.FAILURE, started_at__gte=since)
|
||||
|
||||
|
||||
def recent_job_runs(limit=JOB_LOG_ROWS):
|
||||
"""Every job's runs, most recent first, for the dashboard's Job log card."""
|
||||
return JobRun.objects.all()[:limit]
|
||||
@@ -10,15 +10,17 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Count, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value
|
||||
from django.db.models import Count, DateField, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value
|
||||
from django.db.models.functions import Coalesce, TruncMonth
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from waffle import get_waffle_flag_model
|
||||
|
||||
from authentication.middleware import ELEVATED_ROLES
|
||||
from billing.models import Due, DuePayment, Subscription
|
||||
from billing.services.dues import dues_in_grace, dues_overdue
|
||||
from billing.services.dues import dues_in_grace, dues_overdue, subscriptions_due_for_renewal
|
||||
from club.models import Club, ClubMembership, ClubRole, Season
|
||||
from events.models import Attendance, Event
|
||||
from members.models import Member
|
||||
@@ -39,7 +41,10 @@ def clubs_with_totals(queryset=None):
|
||||
"""Clubs annotated with headline counts (one query, no N+1)."""
|
||||
clubs = Club.objects.all() if queryset is None else queryset
|
||||
return clubs.annotate(
|
||||
member_count=Count("clubmemberships__member", distinct=True),
|
||||
# Members only: a guardian is attached to the club as a parent of a member,
|
||||
# not as one, so counting them would overstate every club's size (and the
|
||||
# onboarding funnel's "with members" step).
|
||||
member_count=Count("clubmemberships__member", filter=Q(clubmemberships__kind=ClubMembership.Kind.MEMBER), distinct=True),
|
||||
team_count=Count("teams", distinct=True),
|
||||
event_count=Count("events", distinct=True),
|
||||
admin_count=Count("clubroles", filter=Q(clubroles__role=ClubRole.Roles.ADMIN), distinct=True),
|
||||
@@ -64,6 +69,8 @@ def clubs_with_health(queryset=None, today=None, now=None):
|
||||
clubs = Club.objects.active() if queryset is None else queryset
|
||||
|
||||
in_season = Q(season__start_date__lte=today, season__end_date__gte=today)
|
||||
# A period the club is covered for, most recent first — paid or waived, both settled.
|
||||
_covered = Due.objects.filter(club=OuterRef("pk"), status__in=(Due.Status.PAID, Due.Status.WAIVED)).order_by("-period_end")
|
||||
managed_this_season = Q(
|
||||
staff_assignments__season__start_date__lte=today,
|
||||
staff_assignments__season__end_date__gte=today,
|
||||
@@ -73,28 +80,71 @@ def clubs_with_health(queryset=None, today=None, now=None):
|
||||
return (
|
||||
clubs.annotate(
|
||||
has_season=Exists(Season.objects.filter(club=OuterRef("pk"), start_date__lte=today, end_date__gte=today)),
|
||||
active_members=_subquery(ClubMembership.objects.filter(in_season, status=ClubMembership.StatusChoices.ACTIVE), Count("pk"), IntegerField()),
|
||||
unpaid_members=_subquery(ClubMembership.objects.filter(in_season, fee_status=ClubMembership.FeeStatus.UNPAID), Count("pk"), IntegerField()),
|
||||
active_members=_subquery(ClubMembership.objects.filter(in_season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE), Count("pk"), IntegerField()),
|
||||
unpaid_members=_subquery(ClubMembership.objects.filter(in_season, kind=ClubMembership.Kind.MEMBER, fee_status=ClubMembership.FeeStatus.UNPAID), Count("pk"), IntegerField()),
|
||||
outstanding=_subquery(Order.objects.filter(status__in=OWED_STATUSES), Sum("total"), DecimalField(max_digits=10, decimal_places=2)),
|
||||
upcoming_events=_subquery(Event.objects.filter(start__gte=now, start__lte=now + timedelta(days=DORMANT_DAYS)), Count("pk"), IntegerField()),
|
||||
team_count=_subquery(Team.objects.all(), Count("pk"), IntegerField()),
|
||||
teams_managed=_subquery(Team.objects.filter(managed_this_season), Count("pk", distinct=True), IntegerField()),
|
||||
admin_count=_subquery(ClubRole.objects.filter(role=ClubRole.Roles.ADMIN), Count("pk"), IntegerField()),
|
||||
tier_name=Subquery(Subscription.objects.filter(club=OuterRef("pk")).values("tier__name")[:1]),
|
||||
plan_name=Subquery(Subscription.objects.filter(club=OuterRef("pk")).values("plan__name")[:1]),
|
||||
dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)),
|
||||
dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]),
|
||||
dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]),
|
||||
# How far the club is covered: the furthest-out period that is settled. PAID and
|
||||
# WAIVED both mean nothing is owed for that period, and its end is the day grace
|
||||
# would start if nothing renews — so both count. `covered_status` is read from the
|
||||
# same top row, so the table can badge "paid" vs "waived". Null when the club owes
|
||||
# or was never billed.
|
||||
covered_until=Subquery(_covered.values("period_end")[:1], output_field=DateField()),
|
||||
covered_status=Subquery(_covered.values("status")[:1]),
|
||||
)
|
||||
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
|
||||
#: Risk tiers for the dashboard's "Club health" table, high risk first. Derived from signals
|
||||
#: `clubs_with_health` already annotates -- no separate query, and nothing here is invented:
|
||||
#: a club with no season covering today cannot take a signup, and dues past their grace date
|
||||
#: are exactly what the archive job is about to act on.
|
||||
RISK_HIGH, RISK_WATCH, RISK_OK = "high", "watch", "ok"
|
||||
|
||||
|
||||
def club_risk(club, today):
|
||||
"""The risk tier, plus a human reason naming exactly which signal tripped it -- so the
|
||||
dashboard can show *why*, not just a colour. Checked in the same order as the tier
|
||||
logic below: the first matching condition is the one reported."""
|
||||
if not club.has_season:
|
||||
return RISK_HIGH, _("No season covers today")
|
||||
if club.dues_grace_until is not None and club.dues_grace_until < today:
|
||||
return RISK_HIGH, _("Dues overdue past grace")
|
||||
if not club.upcoming_events:
|
||||
return RISK_WATCH, _("No events in the next 30 days")
|
||||
if club.dues_owed:
|
||||
return RISK_WATCH, _("Dues outstanding")
|
||||
return RISK_OK, _("Nothing needs attention")
|
||||
|
||||
|
||||
def clubs_by_risk(queryset=None, today=None):
|
||||
"""`clubs_with_health`, ordered highest risk first -- the dashboard's Club health table
|
||||
is "sorted by risk" per the design, and risk is exactly the thing that table is for."""
|
||||
today = today or timezone.localdate()
|
||||
order = {RISK_HIGH: 0, RISK_WATCH: 1, RISK_OK: 2}
|
||||
|
||||
clubs = list(clubs_with_health(queryset, today=today))
|
||||
for club in clubs:
|
||||
club.risk, club.risk_reason = club_risk(club, today)
|
||||
clubs.sort(key=lambda club: order[club.risk])
|
||||
|
||||
return clubs
|
||||
|
||||
|
||||
def platform_totals():
|
||||
return {
|
||||
"clubs": Club.objects.active().count(),
|
||||
"archived_clubs": Club.objects.archived().count(),
|
||||
"members": Member.objects.count(),
|
||||
"members": Member.objects.filter(member_of__kind=ClubMembership.Kind.MEMBER).distinct().count(),
|
||||
"admins": ClubRole.objects.filter(role=ClubRole.Roles.ADMIN).count(),
|
||||
}
|
||||
|
||||
@@ -139,10 +189,24 @@ def onboarding_funnel():
|
||||
total = len(clubs)
|
||||
|
||||
return [
|
||||
{"label": "Clubs", "count": total, "icon": "building-2"},
|
||||
{"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
|
||||
{"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "shield"},
|
||||
{"label": "With events", "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
|
||||
{"label": _("Clubs"), "count": total, "icon": "building-2"},
|
||||
{"label": _("With members"), "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
|
||||
{"label": _("With a team"), "count": sum(1 for club in clubs if club.team_count), "icon": "trophy"},
|
||||
{"label": _("With events"), "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
|
||||
]
|
||||
|
||||
|
||||
def flags_for_club(club):
|
||||
"""Every flag, annotated with whether it is on for this club and why."""
|
||||
enabled_ids = set(club.flags.values_list("pk", flat=True))
|
||||
return [
|
||||
{
|
||||
"flag": flag,
|
||||
"enabled": flag.pk in enabled_ids,
|
||||
# `everyone` overrides club targeting, so the per-club toggle is moot.
|
||||
"overridden": flag.everyone is not None,
|
||||
}
|
||||
for flag in get_waffle_flag_model().objects.order_by("name")
|
||||
]
|
||||
|
||||
|
||||
@@ -172,6 +236,10 @@ def platform_attention():
|
||||
"dues_in_grace": dues_in_grace().count(),
|
||||
"dues_overdue": dues_overdue().count(),
|
||||
"clubs_unbilled": Club.objects.active().filter(subscription__isnull=True).count(),
|
||||
# Normally ~0: the renewal job keeps it there. A number that sits here means cron is
|
||||
# dead, and a club is about to use the platform for free — silently, because nothing is
|
||||
# owed, so no other number on this page would go red.
|
||||
"renewals_pending": len(subscriptions_due_for_renewal()),
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +253,7 @@ def _dues_owed():
|
||||
def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY):
|
||||
"""A dense month-by-month series — zero-filled, because a chart that silently skips
|
||||
empty months draws a smooth line over a month where nothing happened."""
|
||||
start = (timezone.now() - timedelta(days=30 * months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
start = (timezone.now() - relativedelta(months=months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
rows = queryset.filter(**{f"{field}__gte": start}).annotate(month=TruncMonth(field)).values("month").annotate(value=value).order_by("month")
|
||||
found = {row["month"].strftime("%Y-%m"): row["value"] or 0 for row in rows if row["month"]}
|
||||
@@ -235,12 +303,12 @@ def renewal_rate(club, season):
|
||||
if previous is None:
|
||||
return None
|
||||
|
||||
was_active = ClubMembership.objects.filter(club=club, season=previous, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
was_active = ClubMembership.objects.filter(club=club, season=previous, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
total = was_active.count()
|
||||
if not total:
|
||||
return None
|
||||
|
||||
returned = ClubMembership.objects.filter(club=club, season=season, member__in=was_active.values("member")).count()
|
||||
returned = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER, member__in=was_active.values("member")).count()
|
||||
|
||||
return round(100 * returned / total)
|
||||
|
||||
@@ -255,7 +323,7 @@ def new_members(club, season):
|
||||
if season is None:
|
||||
return Member.objects.none()
|
||||
|
||||
seen_before = ClubMembership.objects.filter(club=club, season__start_date__lt=season.start_date).values("member")
|
||||
seen_before = ClubMembership.objects.filter(club=club, season__start_date__lt=season.start_date, kind=ClubMembership.Kind.MEMBER).values("member")
|
||||
|
||||
return Member.objects.filter(member_of__club=club, member_of__season=season).exclude(pk__in=seen_before).distinct()
|
||||
|
||||
@@ -270,9 +338,9 @@ def signup_split(club=None, months=MONTHS_OF_HISTORY):
|
||||
Each member's earliest season is resolved once up front rather than per row: the same
|
||||
question asked inside a loop is one query per membership.
|
||||
"""
|
||||
start = (timezone.now() - timedelta(days=30 * months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
start = (timezone.now() - relativedelta(months=months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
memberships = ClubMembership.objects.all() if club is None else ClubMembership.objects.filter(club=club)
|
||||
memberships = ClubMembership.objects.filter(kind=ClubMembership.Kind.MEMBER) if club is None else ClubMembership.objects.filter(club=club, kind=ClubMembership.Kind.MEMBER)
|
||||
|
||||
first_season = {}
|
||||
for club_id, member_id, season_start in memberships.values_list("club_id", "member_id", "season__start_date"):
|
||||
@@ -313,7 +381,7 @@ def unrostered_members(club, season):
|
||||
|
||||
rostered = TeamMembership.objects.filter(team__club=club, season=season).values("member")
|
||||
|
||||
return Member.objects.filter(member_of__club=club, member_of__season=season, member_of__status=ClubMembership.StatusChoices.ACTIVE).exclude(pk__in=rostered).distinct()
|
||||
return Member.objects.filter(member_of__club=club, member_of__season=season, member_of__kind=ClubMembership.Kind.MEMBER, member_of__status=ClubMembership.StatusChoices.ACTIVE).exclude(pk__in=rostered).distinct()
|
||||
|
||||
|
||||
def fee_aging(club):
|
||||
@@ -323,7 +391,7 @@ def fee_aging(club):
|
||||
owed = Order.objects.filter(club=club, status__in=OWED_STATUSES)
|
||||
|
||||
buckets = []
|
||||
for label, older_than, newer_than in (("0-30 days", 0, 30), ("30-60 days", 30, 60), ("60+ days", 60, None)):
|
||||
for label, older_than, newer_than in ((_("0-30 days"), 0, 30), (_("30-60 days"), 30, 60), (_("60+ days"), 60, None)):
|
||||
rows = owed.filter(created__lte=now - timedelta(days=older_than))
|
||||
if newer_than is not None:
|
||||
rows = rows.filter(created__gt=now - timedelta(days=newer_than))
|
||||
@@ -360,7 +428,7 @@ def attendance_rates(club, season):
|
||||
def club_attention(club):
|
||||
"""A club's own numbers that are supposed to be zero."""
|
||||
season = Season.covering(club, timezone.localdate())
|
||||
memberships = ClubMembership.objects.filter(club=club)
|
||||
memberships = ClubMembership.objects.filter(club=club, kind=ClubMembership.Kind.MEMBER)
|
||||
|
||||
return {
|
||||
"season": season,
|
||||
@@ -379,7 +447,7 @@ def club_attention(club):
|
||||
|
||||
def club_charts(club):
|
||||
season = Season.covering(club, timezone.localdate())
|
||||
memberships = ClubMembership.objects.filter(club=club, season=season) if season else ClubMembership.objects.none()
|
||||
memberships = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER) if season else ClubMembership.objects.none()
|
||||
|
||||
return {
|
||||
"signups": signup_split(club),
|
||||
@@ -387,10 +455,10 @@ def club_charts(club):
|
||||
"fees": [
|
||||
{"label": label, "value": memberships.filter(fee_status=status).count()}
|
||||
for status, label in (
|
||||
(ClubMembership.FeeStatus.PAID, "Paid"),
|
||||
(ClubMembership.FeeStatus.PARTIALLY_PAID, "Partial"),
|
||||
(ClubMembership.FeeStatus.UNPAID, "Unpaid"),
|
||||
(ClubMembership.FeeStatus.WAIVED, "Waived"),
|
||||
(ClubMembership.FeeStatus.PAID, _("Paid")),
|
||||
(ClubMembership.FeeStatus.PARTIALLY_PAID, _("Partial")),
|
||||
(ClubMembership.FeeStatus.UNPAID, _("Unpaid")),
|
||||
(ClubMembership.FeeStatus.WAIVED, _("Waived")),
|
||||
)
|
||||
],
|
||||
}
|
||||
@@ -401,46 +469,46 @@ def club_statistics(club):
|
||||
season = Season.covering(club, timezone.localdate())
|
||||
now = timezone.now()
|
||||
|
||||
memberships = ClubMembership.objects.filter(club=club)
|
||||
memberships = ClubMembership.objects.filter(club=club, kind=ClubMembership.Kind.MEMBER)
|
||||
events = Event.objects.filter(club=club)
|
||||
orders = Order.objects.filter(club=club)
|
||||
|
||||
return [
|
||||
{
|
||||
"title": "Members",
|
||||
"title": _("Members"),
|
||||
"icon": "users",
|
||||
"stats": [
|
||||
("Members", memberships.values("member").distinct().count()),
|
||||
("Active this season", memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0),
|
||||
("Pending", memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()),
|
||||
("Lapsed", memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()),
|
||||
(_("Members"), memberships.values("member").distinct().count()),
|
||||
(_("Active this season"), memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0),
|
||||
(_("Pending"), memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()),
|
||||
(_("Lapsed"), memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Teams & staff",
|
||||
"title": _("Teams & staff"),
|
||||
"icon": "shield",
|
||||
"stats": [
|
||||
("Teams", Team.objects.filter(club=club).count()),
|
||||
("Players this season", TeamMembership.objects.filter(team__club=club, season=season).count() if season else 0),
|
||||
("Staff this season", StaffAssignment.objects.filter(team__club=club, season=season).count() if season else 0),
|
||||
(_("Teams"), Team.objects.filter(club=club).count()),
|
||||
(_("Players this season"), TeamMembership.objects.filter(team__club=club, season=season).count() if season else 0),
|
||||
(_("Staff this season"), StaffAssignment.objects.filter(team__club=club, season=season).count() if season else 0),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Events",
|
||||
"title": _("Events"),
|
||||
"icon": "calendar-days",
|
||||
"stats": [
|
||||
("Upcoming", events.filter(start__gte=now).count()),
|
||||
("This season", events.filter(season=season).count() if season else 0),
|
||||
(_("Upcoming"), events.filter(start__gte=now).count()),
|
||||
(_("This season"), events.filter(season=season).count() if season else 0),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Shop",
|
||||
"title": _("Shop"),
|
||||
"icon": "shopping-cart",
|
||||
"stats": [
|
||||
("Orders", orders.count()),
|
||||
("Revenue", _money(orders.filter(status__in=PAID_STATUSES))),
|
||||
("Outstanding", _money(orders.filter(status__in=OWED_STATUSES))),
|
||||
("Open carts", Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()),
|
||||
(_("Orders"), orders.count()),
|
||||
(_("Revenue"), _money(orders.filter(status__in=PAID_STATUSES))),
|
||||
(_("Outstanding"), _money(orders.filter(status__in=OWED_STATUSES))),
|
||||
(_("Open carts"), Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
112
controlpanel/templates/controlpanel/_auth_base.html
Normal file
112
controlpanel/templates/controlpanel/_auth_base.html
Normal file
@@ -0,0 +1,112 @@
|
||||
{% load static lucide ui %}
|
||||
|
||||
{% comment %}
|
||||
Standalone shell for every sitewide allauth screen -- login, password change/reset,
|
||||
MFA, passkeys, recovery codes -- plus 403.html/maintenance.html, rendered whenever
|
||||
there is no club tenant (see club/context_processors.py: this is
|
||||
PLATFORM_BASE_TEMPLATE). Same industrial design language as controlpanel/base.html --
|
||||
dark ink chrome, Barlow/Barlow Condensed/IBM Plex Mono, assets/controlpanel.css --
|
||||
but deliberately simpler: one centred card on a dark page, not a full command-bar
|
||||
app shell, since these are public entrance screens for the whole platform (every
|
||||
club admin and base-domain account), not the control panel itself.
|
||||
|
||||
Block names match what templates/_base.html used to provide (head_title, extra_head,
|
||||
main, extra_body) rather than inventing new ones: templates/allauth/layouts/base.html
|
||||
and templates/403.html/maintenance.html target those names directly, and both are
|
||||
shared with the club-branded skin (_club_base.html, still on assets/app.css and real
|
||||
daisyUI, untouched) -- give them a different block name here and they would have
|
||||
nothing to override on this side of the fork.
|
||||
{% endcomment %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>
|
||||
{% block head_title %}{% endblock head_title %} · RosterChief
|
||||
</title>
|
||||
|
||||
<link rel="stylesheet" href="{% static 'css/controlpanel.css' %}">
|
||||
{% block extra_head %}{% endblock extra_head %}
|
||||
</head>
|
||||
|
||||
<body class="flex min-h-screen flex-col items-center gap-10 bg-ink px-4 py-14 font-sans text-slate">
|
||||
{# Explicit bg-ink here too, not just on <body>: the white-on-dark brand mark must stay legible on its own. #}
|
||||
<div class="flex w-full max-w-md items-center justify-between gap-4 bg-ink py-1">
|
||||
<a class="flex min-w-0 shrink-0 items-center gap-2.5" href="/">
|
||||
{# The real mark, not the .crest clip-path fallback (that's for clubs with no logo of their own) -- white-on-dark variant for this page. #}
|
||||
<img class="h-8 w-8 shrink-0" src="{% static 'images/rosterchief-white.svg' %}" alt="" width="32" height="32">
|
||||
<span class="font-display text-xl font-extrabold tracking-[.1em] text-white uppercase">RosterChief</span>
|
||||
</a>
|
||||
<a class="flex shrink-0 items-center gap-1.5 font-mono text-xs text-on-dark-dim hover:text-white" href="/">
|
||||
{% lucide "arrow-left" size=14 %} Back to site
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<main class="flex w-full max-w-md flex-1 flex-col justify-center gap-4">
|
||||
{% if messages %}
|
||||
<div class="flex flex-col gap-2">
|
||||
{% for message in messages %}
|
||||
{% with alert=message|as_alert %}
|
||||
<div class="alert {{ alert.css }}" role="alert">
|
||||
{% lucide alert.icon size=18 %}
|
||||
<div>
|
||||
<div class="font-display text-sm font-bold tracking-wide uppercase">{{ alert.title }}</div>
|
||||
<div class="text-sm">{{ alert.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block main %}{% endblock main %}
|
||||
</main>
|
||||
|
||||
<p class="font-mono text-[11px] text-on-dark-faint">© {% now "Y" %} RosterChief</p>
|
||||
|
||||
{% comment %}
|
||||
A TOTP code is 6 characters, a recovery code 8, and allauth accepts either in
|
||||
the same field (templates/allauth/elements/fields.html). The boxed .otp layout
|
||||
only fits six, so past that this falls back to a plain .input-lg rather than
|
||||
letting the text spill out of the boxes.
|
||||
|
||||
The real <input>'s own text is invisible (assets/controlpanel.css: `.otp input`
|
||||
is `color: transparent`, only its caret shows) -- this writes each typed
|
||||
character into its matching <span> directly instead, which is exact by
|
||||
construction. A pure-CSS letter-spacing overlay (spacing the real glyphs to
|
||||
match the box pitch) was tried first and drifted more with every character
|
||||
typed, in a way font-metric tuning couldn't reliably fix.
|
||||
{% endcomment %}
|
||||
<script>
|
||||
document.querySelectorAll("[data-otp]").forEach((otp) => {
|
||||
const input = otp.querySelector("input");
|
||||
const boxes = otp.querySelectorAll("span");
|
||||
if (!input) return;
|
||||
|
||||
const fit = () => {
|
||||
const boxed = input.value.length <= boxes.length;
|
||||
otp.classList.toggle("otp", boxed);
|
||||
otp.classList.toggle("otp-lg", boxed);
|
||||
boxes.forEach((box, index) => {
|
||||
box.classList.toggle("hidden", !boxed);
|
||||
box.textContent = boxed ? input.value[index] || "" : "";
|
||||
});
|
||||
input.classList.toggle("input", !boxed);
|
||||
input.classList.toggle("input-lg", !boxed);
|
||||
};
|
||||
|
||||
input.addEventListener("input", fit);
|
||||
fit();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% comment %}
|
||||
allauth puts page-level scripts and out-of-form markup here -- notably the
|
||||
hidden `mfa_login` form the passkey button submits on the login page. Without
|
||||
this block that form is never rendered and "Sign in with a passkey" is dead.
|
||||
{% endcomment %}
|
||||
{% block extra_body %}{% endblock extra_body %}
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user