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>
Statistics labels, shop help text, and the confirm/form modal defaults
were plain strings; wrap them per CLAUDE.md's i18n convention so the
app stays translation-ready as it's written.
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>
Build out the shop domain: Cart/CartItem (one open cart per user per club),
Order/OrderLine, Discount/AppliedDiscount, Payment and Invoice.
Order and Invoice allocate a per-club, per-year sequential number
(ORD-<year>-<seq> / INV-<year>-<seq>) via shared helpers, retrying on collision
with the (club, number) unique constraint as the source of truth.
Fixes found while testing:
- Invoice had no number generator, so a second invoice in a club collided on
the empty string and could never be created.
- AppliedDiscount printed a "%" suffix even for fixed-amount discounts, and had
no (order, discount) uniqueness, so a discount could be applied twice.
Every model validates its club-scoped FKs (product/team/discount/order/season/
staff_role) against the owning club, and Member FKs against club membership.
Register all models in the admin, with FK dropdowns scoped to the owning club.
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>