- news: NewsItemOut gains `excerpt` (truncated body); GET /news/{slug}/
fetches a single item. slug already auto-populates on save, but a
data migration backfills any pre-existing blank ones.
- games: home_team/away_team change from plain strings to {id, name,
logo_url} objects -- home links to the actual Team (logo from the
club's own logo, since teams have none of their own), away links
to the actual Opponent (which already had a logo field). Breaking
change for any existing consumer of the old string shape.
- sponsors: SponsorOut gains logo_width/logo_height, computed in
Sponsor.save() -- Pillow for raster, a bounded regex read of the
SVG root tag for vector logos (not a full XML parse, since that's
exposed to entity-expansion attacks on untrusted uploads). A data
migration backfills dimensions for existing sponsor logos.
- teams: PlayerOut gains `license`, sourced from ClubMembership (not
Member -- it's per-club, per-season), batched in one query.
A large batch of club-management features built up over one session:
- Club dashboard banner warning admins 1 month before billing ends
- Full Events/EventSeries CRUD (recurrence builder, occurrence lifecycle,
per-team permissions), with match->game rename and game-specific fields
(score, competition, live status, external game ID)
- Django-admin competition dropdown, gated per-club by feature flag
- Auto-import of RBIHF fixtures (scrape -> diff -> preview -> confirm),
with location/opponent dropdowns suggested from existing club data
- Feature-flag-gated Shop/Forms nav sections, reusing the same flag
machinery for the RBIHF import button
- Team roster now scoped to members active this season or next, sorted and
grouped by position
- Club sport type (ice hockey / other), shown in the control panel's club
subtitle
- Per-season team photo upload from the team page
- New public read-only API (Django Ninja) at /api/v1/: news, team rosters,
upcoming/live/per-team games, and sponsors -- auto-documented via Swagger
UI, CORS-enabled for a club's own external website
- Club sponsors: admin-only CRUD (logo, URL, active date window) plus a
date-windowed, optionally randomized API endpoint
- Assorted fixes: NullBooleanField dropdown rendering, cross-club event
validation timing, searchable-select chip placement, btn-neutral ->
default button style sweep, calendar-month chart windows
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
Team managers/coaches now only see their own teams, can't create teams,
and can view (but not edit) positions -- admins keep full rights.
Locations and Opponents move from read-only stubs to full CRUD, gated
to admins and management-position staff, with a country dropdown
(django-countries) instead of free text. Also: the team list shows
player/staff counts, and deleting a news item's main photo promotes
another one instead of leaving the item without one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
templates/403.html is picked up automatically by Django's default
permission_denied handler -- no urls.py wiring needed. Rendered through
the tenant's own skin (base_template, same as maintenance.html), so a
permission error still looks like the app and the navbar (sign out,
theme toggle, home link) stays reachable instead of leaving the user
stuck on a dead end.
News and NewsPhoto (team-tagged instead of categorised, one photo taggable
as main via a partial unique constraint), gated per club/services/access.py:
any current-season coach_manager, EDITOR, or ADMIN can draft and edit a news
item; only EDITOR/ADMIN can publish it, or edit it once it's live. Publishing
takes a date so it can be scheduled ahead of time rather than only right now.
Authoring/release only for now -- no member-facing reading page or public API
yet, the visibility field (internal/external/both) is there for when those land.
Club now carries its own season_start and season_duration_months,
editable via controlpanel; generate_seasons chains each new season off
the day after the club's last one ends (or its configured start, for a
club with none yet) instead of assuming every club runs Aug 1 - May 31.
Adds --resync to the generate_seasons command to clean up seasons left
over from the old hardcoded rule -- removing any that don't match a
club's current settings and aren't still referenced by real data.
Gives clubs a self-service /manage/ area for members, families, teams,
roles, and season memberships, alongside real fee-payment tracking
(FeePayment, record_payment/mark_as_paid/remaining_balance) so a
membership's paid status reflects actual money received instead of a
single manually-set flag.
Wraps the uploaded logo in the same avatar shape as the initials
fallback (so the header doesn't jump in size depending on whether a
club has one) and adds a ring in the club's own primary colour as a
plain border around it.
Also hints the browser to use its higher-quality image scaler: a
club's raster logo is often much smaller than the badge it's shown in,
and the default upscaling in some engines reads as pixelated. Doesn't
affect SVG logos, which scale losslessly regardless.
ImageField validates uploads through Pillow, which can't read SVGs, so
a club crest that's a vector logo was rejected outright. Switches to a
plain FileField with an extension allowlist (png/jpg/jpeg/gif/webp/svg)
instead, and restricts the file picker to image types via the widget's
accept attribute.
Mirrors primary_color: a club-picked hex highlight used for accents like
the avatar-initials badge, with a computed readable text colour so a
pale pick doesn't produce white-on-yellow text. The shared contrast math
moves into _content_color_for so both colours use the same rule.
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>
A club member signing in at ajax-united.rosterchief.app now sees their club's
logo, name and colours; the base domain keeps the RosterChief skin for the
control panel and Django admin.
The mechanism is `{% extends base_template %}` -- Django lets the parent be a
context variable, so the `branding` context processor picks the skin from
request.club and *every* auth screen allauth ships (login, password reset, MFA,
passkeys, and whatever it adds next) follows the tenant without a single one of
them knowing that clubs exist.
Templates split three ways: _base.html is the skeleton with no branding, and
_platform_base.html / _club_base.html dress it. The control panel extends the
platform base *explicitly* rather than through the variable, so a bug in
branding resolution can never dress the panel up as a club.
Club gains an optional logo and primary_color. Notes on both:
- No logo falls back to the club's initials, never the RosterChief mark, which
would pass our branding off as theirs.
- Club colours land in an inline :root. daisyUI declares its theme variables
inside `@layer base`, and unlayered styles beat every layered rule regardless
of specificity, so this needs no !important. --color-primary-content is derived
from WCAG relative luminance, so a club that picks pale yellow gets black text
instead of invisible white.
- primary_color is a text input, not <input type="color">: a colour picker cannot
express "no colour", so every club that never touched it would submit #000000
and silently get a black theme.
"/" now resolves per tenant (club home, or hand off to the control panel), which
is why LOGIN_REDIRECT_URL can stay "/" and allauth needs no redirect adapter.
Also folds in the theme toggle gaining a third "auto" state and the logo
switching from `content:` to background-image (content-replacement on a real
element is not supported in Firefox), both of which lived in the base template
this commit replaces.
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>
Add Club.archived_at with active()/archived() managers, archive() and
restore(). An archived club stops resolving in ClubTenantMiddleware, so its
subdomain behaves as unknown — archiving is a real deactivation, not a
cosmetic flag — while every row it owns is retained.
There is no hard-delete path, deliberately. A club with any data cannot be
deleted anyway (ClubMembership PROTECTs its Season, and the shop chain
PROTECTs more), and invoices generally must be kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adopt django-allauth with allauth.mfa, giving TOTP, WebAuthn passkeys and
recovery codes — and the signup/password-reset flows we'll need next. There was
no login UI at all before this (only /admin/), so this brings the auth stack.
The critical piece is authentication/adapters.py. A passkey is bound to a
WebAuthn Relying Party ID (a domain), and allauth derives that from the request
host — which under our subdomain tenancy would bind a passkey to a *single* club
(ajax-united.clubmanager.app) and silently fail at every other one. The adapter
pins the RP ID to CLUBMANAGER_BASE_DOMAIN so one passkey works across all clubs.
Note this cuts both ways: changing that base domain invalidates every existing
passkey.
RequireMFAMiddleware makes a second factor mandatory for anyone who can change
other people's data — Django staff/superusers and holders of an elevated
ClubRole (ADMIN/EDITOR), via the access service — while leaving it optional for
regular members. /admin/login/ is routed through allauth, since Django's own
admin login knows nothing about second factors.
allauth is installed WITHOUT django.contrib.sites (optional since allauth 65),
so ARCHITECTURE.md's rejection of the Sites framework stands and no Club.site
bridge is needed. Sessions are shared across club subdomains, matching the
one-passkey-everywhere model; tenancy still scopes what you can see.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ClubRole (ADMIN / MEMBER / EDITOR, one per member per club) and complete
club/services/access.py — the single module all authorisation routes through:
- teams_managed_by / can_edit_event -> authority: a *management* StaffAssignment
in the *current season*; ADMIN overrides club-wide. A StaffAssignment is
per-season, so a former coach's authority expires with it.
- teams_staffed_by -> visibility: *any* staff position, so support staff (physio,
kit manager) can see the roster they work with without gaining authority.
- members_visible_to -> ADMIN sees everyone linked to the club; otherwise self +
children (family graph) + the current-season players and staff of the teams
they're staffed on.
- can_edit_event -> ADMIN/EDITOR, the event's owner, or a manager of one of its
teams for that event's season.
- can_manage_shop -> ADMIN.
Fix roles_in_club, which called .unique() — not a QuerySet method, so it would
have raised AttributeError on first use.
Keep ClubRole in sync with membership status: an active ClubMembership grants
the MEMBER role and losing it withdraws that role — but an elevated role
(ADMIN/EDITOR) is never downgraded or removed, so a lapsed membership or a
season rollover can never lock an admin out.
Validate ClubMembership.season against the membership's club.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add clubmanager.base.validate_club_scope(instance, owning_club_id, ...): a
shared model-clean() helper that rejects FKs leaking across clubs. Club-scoped
FKs must share the owning club; Member FKs must have a ClubMembership in it.
Unset FKs are skipped.
Nothing enforced tenant consistency on the FKs between club-scoped rows, so an
order could reference another club's product, an event another club's season,
and so on. The following commits wire this into each app's 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>
Rework an event's audience: replace the single team FK with a teams M2M
plus invited_members and excluded_members, and add a season FK (derived
from the start date when blank) so team rosters resolve correctly. A data
migration copies existing team -> teams.
Add an attendance sync service: the effective audience is the union of the
teams' rosters for the event's season plus invited, minus excluded;
sync_event_attendances reconciles Attendance rows for future events only,
adding NO_RESPONSE rows for new members and hard-deleting rows for members
no longer invited. Signals drive it: editing an event or its audience
re-syncs that event, and adding/removing a team-roster member re-syncs
that team's future events.
Register all events models in the admin (with an attendance inline) and
add events to the admin registration smoke test. Add Season.covering()
and pillow (Opponent.logo ImageField). Full suite at 100% coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ClubMembership is now tied to a Season (plus status / fee_status / sign-up
dates) and unique per (club, member, season). The CSV importer attaches
each membership to the club's current season (Season.get_current), skipping
the row with a clear error when none exists; the now-unreachable
"clubs created" bookkeeping is removed.
Register SeasonAdmin and rebuild ClubMembershipAdmin for the new fields, and
add an admin smoke test that asserts every model in authentication/club/
members/teams is registered and its changelist + add pages load.
Convert every unique_together to a Meta UniqueConstraint (Season,
ClubMembership, FamilyMembership) per Django's guidance. Regenerate
migrations. club, members, and importer stay at 100% coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete the Season model:
- name property renders the start/end years as a "YY-YY" label (e.g.
"25-26") via strftime %y, and __str__ now returns it.
- get_current(date) returns the active club's season covering the given
date (today by default), inclusive of both boundaries, scoped through
the tenant queryset so it never crosses clubs.
Rename the tenant queryset's current() to current_club() for clarity and
update callers/tests. Cover the new behaviour; tenancy modules stay at
100%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exercise subdomain resolution (base-domain and generic hosts, unknown
slug, www, port stripping, contextvar cleanup), slug derivation and
uniqueness, the require_current_club/current() context helpers, and
ClubScopedModel save/for_club/current via Season. Add a with_club test
helper. Tenancy modules reach 100% coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add row-based multi-tenancy plumbing keyed on Club as the tenant root:
- ClubTenantMiddleware maps the request's subdomain to a Club by slug,
storing it on request.club and in a contextvar so service-layer code
and management commands can read it via get_current_club(). Resolution
honours CLUBMANAGER_BASE_DOMAIN (e.g. ajax-united.clubmanager.app),
falling back to generic slug.example.com hosts, and ignores the bare
base domain, www, and unknown slugs.
- Club gains a unique slug (auto-derived from name on save) plus a
ClubManager.current() accessor for the active tenant.
- ClubScopedModel gets a TenantQuerySet (.for_club()/.current()) and
auto-fills club from the active context on save.
Contextvar helpers live in club.tenancy; Club is imported lazily there
and in clubmanager.base to avoid an import cycle with club.models.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finish relocating Member/Family/FamilyMembership from authentication into
a dedicated members app, and revert the half-applied move of member names
onto the global User.
- members: add first_name/last_name back to Member (the whole codebase —
tests, CSV importer, club app, admin — assumes them, and login-less
children in families need a name); restore local ordering/index and the
member__last_name lookups in Family.__str__ and FamilyMembership.
- authentication: drop first_name/last_name from User; get_full_name/
get_short_name delegate to the linked member, else fall back to email.
- migrations: create members.0001_initial, repoint club.ClubMembership FK
(club.0005), delete the models from authentication (0004, rewritten to
plain DeleteModel ops in child-first order to avoid a SQLite table-remake
crash).
- fix stale imports across apps (authentication/club tests, CSV importer)
and missing imports/URLs in members tests; add missing _ import in
members/admin.py; export MemberCsvImporter from members.services.
Full suite green (64 tests), ruff clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Extend the ClubMembership model with `club`, `member`, and optional `license` fields, along with relevant constraints and ordering.
- Implement verbose names for Club and ClubMembership models and update admin configurations for better display and filtering.
- Add a `import_members_csv` management command for batch importing members, clubs, and memberships from a CSV file.
- Include extensive tests for the `import_members_csv` command, ClubMembership model, and Club model.
- Refactor related migrations, services, and test structure.
- Implement admin configurations for User, Member, Family, and FamilyMembership, with specialized inlines and filtered displays.
- Introduce `UserCreationForm` and `UserChangeForm` for streamlined user management.
- Enhance Family model with improved string representation and made name optional.
- Add `Member.contact_email` property for prioritized email retrieval.
- Include tests for the updated Family string logic, contact email functionality, and admin integration.
- Add initial migration for club models (Club, ClubMembership) and updated migration for Family in the authentication app.
- Configure IntelliJ IDEA for local SQLite database access.
- Split the accounts app into new authentication and club apps for better separation of concerns.
- Migrate the custom User, Member, and Family models to the authentication app.
- Introduce Club and ClubMembership models in the club app.
- Refactor Family model to use UUID as the primary key and consolidate family-role relationships into a new FamilyMembership model.
- Update tests, managers, and migrations to align with the new structure.