Nearly all of the wall clock was password hashing: there was no test-time
PASSWORD_HASHERS override, so Django's PBKDF2 default (~1.2M iterations) ran on
every create_user and every login, hundreds of times over. The fix lives in a
DiscoverRunner subclass wired in via TEST_RUNNER rather than a "test" in
sys.argv sniff in settings: a runner is only ever instantiated by `manage.py
test`, so there is no env var to mis-set and no import path by which a deployed
process can reach the weak hasher. Verified: outside the runner the hasher is
still PBKDF2. It also enables the cached template loader (the runner forces
DEBUG off *after* settings are read, so Django never turns it on by itself) and
silences django.request, whose 4xx/5xx logging buried real test output.
Second, the fixtures. Base classes were rebuilding a club, season, admin user,
membership, role and MFA authenticator once per test; those are read-only for
almost every test, so they move to setUpTestData and are built once per class.
Django hands each test its own deep copy and the per-test transaction rolls the
rows back, so the handful of tests that mutate them stay isolated -- proved with
--shuffle, --reverse and --parallel rather than assumed. Per-test work that
genuinely must stay per-test (client sign-ins, waffle cache clears that leak
across the transaction boundary) is left in setUp with a comment saying why.
Five tests removed, each strictly subsumed by another that asserts a superset;
their intent was folded into a comment on the survivor. Regression-pinning
tests -- the ones carrying comments naming the exact bug they catch -- were
left verbatim throughout.
Also closes a real gap this surfaced: teams had a cross-club position test for
TeamMembership but not for StaffAssignment, with an unused `other_coach`
fixture sitting there waiting for it.
Rejected: --parallel by default (every worker re-runs all 88 migrations, buying
~4s of wall clock for ~5x the CPU), and disabling migrations in tests (~3.5s,
but the schema would then come from models and the suite would stop catching a
broken migration).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements BILLING.md. The architecture was sound -- snapshot-on-Due,
dated prices, asymmetric dry-run commands are all kept -- so this
fixes the three hardcoded assumptions rather than rewriting.
The real defect: grace ran from period_END, so an annual club used
the whole unpaid year plus 45 days (~410 days) before anything
switched it off. Grace now runs from the period START, and every
clock is per-plan.
- Tier -> Plan (+ TierPrice -> PlanPrice, and every FK). Migration
0004 is hand-written: run non-interactively, makemigrations emits
DeleteModel+CreateModel and drops every price, subscription and
due. Its two RemoveConstraints must come first, or SQLite's
table-rebuild tries to render a constraint over a just-renamed
column. Verified by round-tripping real rows through it.
- Plan gains duration_months / renewal_lead_days / grace_days /
is_trial, with CheckConstraints and a matching clean() so the form
reports an impossible plan instead of 500ing on IntegrityError.
- Existing dues keep their stored grace_until. Re-deriving it would
put the date in the past for every open annual period and archive
the entire paying customer base on the next --commit run.
- Trials take their length from the trial plan's own duration_months;
start_trial() loses its trial_months argument.
- New BillingNotice service drives a club-facing warning: every level
on the dashboard, and on every management page once urgent.
- send_billing_reminders emails club admins, once per escalation
level so a daily cron is not a daily email. SMTP settings are
env-driven and provider-agnostic; the backend defaults to console.
- Paying does not auto-restore an archived club -- the control panel
surfaces a Reactivate prompt instead, since a club can also be
archived by hand.
TimeStampedModel goes on UUIDModel, so all 28 domain models get row birthdays in
one place. Without them the dashboard can only ever describe the present: "42
members" is knowable, "members joined this month" is not, and no metric can show
direction.
Order dropped its own created/modified -- redeclaring a field from an abstract
base is an error, and its column survives as a plain AlterField (verbose_name
only), so no order data moves.
Note for reading early charts: auto_now_add backfills existing rows with a single
migration timestamp, so everything that predates this commit shares one birthday
and will show up as a spike at that instant rather than as real history.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The clubmanager.app domain was taken, so the platform is now RosterChief
(rosterchief.app). Renames the Django project package clubmanager/ ->
rosterchief/ (git tracks it as a move, so history follows), every
`from rosterchief.base import ...`, the settings/wsgi/asgi module paths,
env vars (ROSTERCHIEF_BASE_DOMAIN / ROSTERCHIEF_RP_NAME), the MFA adapter
(RosterChiefMFAAdapter), brand text, and the docs.
Two things were deliberately NOT swept:
- club.models.ClubManager stays: it is the Django manager *for Club*, not the
brand. A blind rename would have silently broken it.
- Migrations are untouched (history is not rewritten). The only reference was a
cosmetic help_text, so a normal AlterField migration carries the new domain.
Note the WebAuthn RP ID is the base domain, so moving to rosterchief.app
cryptographically invalidates any passkey enrolled under the old one; they
cannot be migrated and must be re-enrolled. Nothing is in production, so the
real cost is zero.
Add django-lucide (from bsiebens/lucide) for icons: the theme toggle now swaps
sun/moon against the effective theme, and the control panel gets icons on its
tabs, actions and stat groups. Its classifiers stop at Django 5.0, but that is
stale metadata — verified rendering on Django 6 / Python 3.14.
Also add formbuilder, shop and controlpanel to ruff's known-first-party list,
which had drifted behind the apps that landed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An Answer's field could point at a field of a *different* form than its
submission — and since forms are club-scoped, across clubs too. Validate
field.form == submission.form in clean().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add clubmanager.base.unique_slugify(instance, value, scope=...): slugify a
source value, truncate to the field's max_length, and append -2/-3/... to
stay unique within a scope. ClubScopedModel gains a slug_source hook that
fills a blank slug (unique per club) on save.
Wire it up so every SlugField auto-populates from its natural source when
left blank (explicit values are always kept):
- shop.Product.slug <- name (per club)
- formbuilder.Form.slug <- title (per club)
- formbuilder.Field.key <- label (per form)
Club.slug already auto-populated; refactor it onto the shared helper.
Also fix shop.Product.slug's multi-tenancy bug: it was globally unique
(unique=True); make it unique per club like the others. Migrations added.
Full suite at 100% coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix multi-tenancy/integrity bugs in the models: Form.slug and Field.key
were globally unique (unique=True), so two clubs couldn't reuse a form
slug and two forms couldn't reuse a field key — make slug unique per club
(constraint already present) and key unique per form. Add a
(submission, field) uniqueness constraint on Answer.
Register all four models in the admin (Field inline on Form, Answer inline
on Submission) and add formbuilder to the admin registration smoke test.
Add a service layer:
- submit_form(form, member, data): enforces is_active / login_required /
open window / max_submissions, validates required + choice fields, and
writes a Submission with Answers atomically (FormSubmissionError carries
per-field errors).
- build_form(form): a live django.forms.Form built from a Form's active
fields, mapping each FieldType to the matching form field.
- form_report(form): a tabular overview of every submission's answers plus
per-value tallies for choice-type fields.
Full suite at 100% coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>