chore: rebrand ClubManager -> RosterChief, add lucide icons

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>
This commit is contained in:
2026-07-13 15:42:20 +02:00
parent 6f66df3ba4
commit eace903f05
33 changed files with 220 additions and 153 deletions

View File

@@ -12,26 +12,26 @@ DJANGO_ALLOWED_HOSTS=.localhost,127.0.0.1,[::1]
# Multi-tenancy: subdomains of this base domain resolve to a club by slug,
# e.g. http://ajax-united.localhost:8000/ -> club with slug "ajax-united".
# In production set this to your real base domain (e.g. clubmanager.app).
CLUBMANAGER_BASE_DOMAIN=localhost
# In production set this to your real base domain (e.g. rosterchief.app).
ROSTERCHIEF_BASE_DOMAIN=localhost
# Two-factor auth. CLUBMANAGER_BASE_DOMAIN doubles as the WebAuthn Relying Party
# Two-factor auth. ROSTERCHIEF_BASE_DOMAIN doubles as the WebAuthn Relying Party
# ID, so ONE passkey works across every club subdomain. Change it and existing
# passkeys stop validating -- they are cryptographically bound to that domain.
# CLUBMANAGER_RP_NAME is what the browser shows during a passkey prompt.
# CLUBMANAGER_RP_NAME=ClubManager
# ROSTERCHIEF_RP_NAME is what the browser shows during a passkey prompt.
# ROSTERCHIEF_RP_NAME=RosterChief
# Sessions are shared across club subdomains (log in once, all clubs). Derived
# from CLUBMANAGER_BASE_DOMAIN in production; left host-only on localhost
# from ROSTERCHIEF_BASE_DOMAIN in production; left host-only on localhost
# because browsers reject a Domain attribute there. Override if needed.
# DJANGO_SESSION_COOKIE_DOMAIN=.clubmanager.app
# DJANGO_CSRF_COOKIE_DOMAIN=.clubmanager.app
# DJANGO_SESSION_COOKIE_DOMAIN=.rosterchief.app
# DJANGO_CSRF_COOKIE_DOMAIN=.rosterchief.app
# Optional. Defaults to sqlite:///db.sqlite3 for dev; point at Postgres in prod.
# DJANGO_DATABASE_URL=postgres://user:pass@localhost:5432/clubmanager
# DJANGO_DATABASE_URL=postgres://user:pass@localhost:5432/rosterchief
# Optional. CSRF trusted origins (needed for subdomains in prod), comma-separated.
# DJANGO_CSRF_TRUSTED_ORIGINS=https://*.clubmanager.app
# DJANGO_CSRF_TRUSTED_ORIGINS=https://*.rosterchief.app
# Optional.
# DJANGO_TIME_ZONE=Europe/Brussels

View File

@@ -1,10 +1,10 @@
# ClubManager — Model & Domain Architecture
# RosterChief — Model & Domain Architecture
Baseline reference for implementing the domain models. This describes the **intended
shape** of the data model: what exists today, what is planned, and the conventions every
app should follow. It is a living document — update it when the model changes.
> **Tenancy: multi-tenant (row-based / shared-schema).** ClubManager is designed as a
> **Tenancy: multi-tenant (row-based / shared-schema).** RosterChief is designed as a
> **multi-tenant platform** — one deployment serves many clubs, with **`Club` as the tenant
> root**. Isolation is **row-based**: a shared database and schema where every club-owned
> row carries a `club` FK (via `ClubScopedModel`), and *all* access is scoped to the
@@ -54,9 +54,9 @@ only if the app grows unwieldy. The roadmap `members` name is reserved either wa
These are already established in code — every new model follows them.
- **UUID primary keys.** Inherit `clubmanager.base.UUIDModel` (`id = UUIDField(default=uuid4)`).
- **UUID primary keys.** Inherit `rosterchief.base.UUIDModel` (`id = UUIDField(default=uuid4)`).
Never expose sequential integer PKs.
- **`ClubScopedModel`** (`clubmanager.base`) adds the tenant `club` FK
- **`ClubScopedModel`** (`rosterchief.base`) adds the tenant `club` FK
(`related_name="%(class)ss"`) and, under multi-tenancy, a tenant-aware manager + auto
club-stamping `save()` (§2.4). **Every aggregate-root model inherits it**; leaf rows
reachable only via a scoped parent (e.g. `Attendance` via `Event`) may inherit scope from
@@ -105,12 +105,12 @@ several clubs; a `Member` is that person *within one club*. So:
current club** (via the tenant context below), falling back to email.
**Tenant resolution → `request.club`.** A `ClubTenantMiddleware` resolves the active club
per request (recommended: **subdomain**, `ajax-united.clubmanager.app`; path-prefix
per request (recommended: **subdomain**, `ajax-united.rosterchief.app`; path-prefix
`/c/<slug>/` is the alternative) and stores it on `request.club` *and* in a context
variable so non-request code (services, management commands) can read it:
```
# clubmanager/tenancy.py
# rosterchief/tenancy.py
from contextvars import ContextVar
_current_club: ContextVar = ContextVar("current_club", default=None)
@@ -740,7 +740,7 @@ Legend: `───<` one-to-many, `>───<` many-to-many via a through model
rows with the current season and re-scopes `unique_together`.
3.**Full multi-tenancy** — adopt **row-based multi-tenancy**, `Club` as tenant root
(§2.4). Requires: `ClubScopedModel` on every aggregate root, `Member.user`
`ForeignKey` (+`unique(club, user)`), tenant middleware + `clubmanager/tenancy.py`
`ForeignKey` (+`unique(club, user)`), tenant middleware + `rosterchief/tenancy.py`
context, tenant-aware manager, per-club uniqueness, per-club roles (§3). **Supersedes
`CLAUDE.md`.**
4.**Jersey uniqueness** — unique **within a team** via a partial `UniqueConstraint`
@@ -822,10 +822,10 @@ Setup:
### 8.3 Tenancy runtime config (needed once `ClubTenantMiddleware` lands)
- **Hosts:** wildcard `ALLOWED_HOSTS` for the chosen base domain (e.g. `.clubmanager.app`)
- **Hosts:** wildcard `ALLOWED_HOSTS` for the chosen base domain (e.g. `.rosterchief.app`)
if using subdomain resolution; add `DJANGO_ALLOWED_HOSTS` accordingly.
- **CSRF:** `CSRF_TRUSTED_ORIGINS` must cover the wildcard scheme+host set
(`https://*.clubmanager.app`).
(`https://*.rosterchief.app`).
- **Cookies:** to share login across club subdomains, set `SESSION_COOKIE_DOMAIN` /
`CSRF_COOKIE_DOMAIN` to the base domain; otherwise keep per-subdomain sessions
(decide with the "cross-club users" question in §7).
@@ -842,8 +842,8 @@ Setup:
---
*Conventions cross-reference:* `clubmanager/base.py` (`UUIDModel`, `ClubScopedModel`),
`clubmanager/tenancy.py` (*to add* — tenant context/middleware, §2.4),
*Conventions cross-reference:* `rosterchief/base.py` (`UUIDModel`, `ClubScopedModel`),
`rosterchief/tenancy.py` (*to add* — tenant context/middleware, §2.4),
`authentication/managers.py` (`UserManager`), `authentication/services/` (service-layer
pattern).
@@ -852,10 +852,10 @@ pattern).
> ### ⚠️ Banner: supersedes `CLAUDE.md`
>
> This architecture adopts **full multi-tenancy** (§2.4), which **directly contradicts**
> the current `CLAUDE.md` ("ClubManager is a **single-club** app … deliberately *not*
> the current `CLAUDE.md` ("RosterChief is a **single-club** app … deliberately *not*
> multi-tenant — there is no `club_id` tenancy") and the project memory
> (`project_overview` — "Single-club (NOT multi-tenant)").
>
> **Action required** before/alongside implementation: update `CLAUDE.md` and the memory
> to describe ClubManager as a **multi-tenant platform (row-based, `Club` = tenant root)**.
> to describe RosterChief as a **multi-tenant platform (row-based, `Club` = tenant root)**.
> Until that is done, where the two disagree **this document is authoritative**.

View File

@@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is
ClubManager is a sport club management app + public website, built on **Django 6.0** (Python 3.14+). As of **2026-07-11 it is designed as a multi-tenant platform** (row-based / shared-schema): one deployment serves many clubs, with `Club` as the tenant root. Every club-owned model carries a `club` FK (via `ClubScopedModel`); `User` is the only global model. This **reverses** the project's earlier single-club stance — treat older "single-club / no `club_id` tenancy" notes (in git history or memory) as obsolete.
RosterChief is a sport club management app + public website, built on **Django 6.0** (Python 3.14+). As of **2026-07-11 it is designed as a multi-tenant platform** (row-based / shared-schema): one deployment serves many clubs, with `Club` as the tenant root. Every club-owned model carries a `club` FK (via `ClubScopedModel`); `User` is the only global model. This **reverses** the project's earlier single-club stance — treat older "single-club / no `club_id` tenancy" notes (in git history or memory) as obsolete.
**`ARCHITECTURE.md` at the repo root is the authoritative model & domain design** — the tenancy mechanics, the RBAC design, and per-app model sketches all live there. Consult and update it when adding domain models.
The repo is an early build: `authentication` and `club` apps exist (`User`, `Member`, `Family`, `FamilyMembership`, `Club`, `ClubMembership`); the remaining domain apps and the tenancy plumbing (`clubmanager/tenancy.py`, tenant middleware, `ClubScopedModel` upgrade) are **planned, not yet on disk**. Verify against the actual tree before assuming a module exists.
The repo is an early build: `authentication` and `club` apps exist (`User`, `Member`, `Family`, `FamilyMembership`, `Club`, `ClubMembership`); the remaining domain apps and the tenancy plumbing (`rosterchief/tenancy.py`, tenant middleware, `ClubScopedModel` upgrade) are **planned, not yet on disk**. Verify against the actual tree before assuming a module exists.
## Commands
@@ -34,7 +34,7 @@ uv run ruff format . # format
## Configuration
Settings live in a single `clubmanager/settings.py` and read from the environment via **python-decouple** (`config(...)`), with a local `.env` file for dev. Key vars: `DJANGO_SECRET_KEY` (required), `DJANGO_DEBUG`, `DJANGO_ALLOWED_HOSTS`, `DJANGO_CSRF_TRUSTED_ORIGINS`, `DJANGO_DATABASE_URL`, `DJANGO_TIME_ZONE`.
Settings live in a single `rosterchief/settings.py` and read from the environment via **python-decouple** (`config(...)`), with a local `.env` file for dev. Key vars: `DJANGO_SECRET_KEY` (required), `DJANGO_DEBUG`, `DJANGO_ALLOWED_HOSTS`, `DJANGO_CSRF_TRUSTED_ORIGINS`, `DJANGO_DATABASE_URL`, `DJANGO_TIME_ZONE`.
The database is configured through a single `DJANGO_DATABASE_URL` (parsed by **dj-database-url**), defaulting to `sqlite:///db.sqlite3` for dev; production is intended to point at PostgreSQL via that URL. Don't hardcode DB settings — go through the env var.
@@ -52,4 +52,4 @@ Domain notes (drive modeling decisions):
## Conventions
- Ruff config anticipates a Wagtail-style codebase (`DJ` Django rules; `RUF012`/`RUF005` ignored for framework idioms; `line-length = 250`). Migrations are excluded from linting — don't hand-edit them to satisfy ruff.
- Settings files are exempt from `F403/F405/E501` (star imports allowed) under `clubmanager/settings/*` — note the config expects a settings *package*, though the current code is a single `settings.py`. If you split settings, match that path.
- 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.

View File

@@ -2,11 +2,11 @@
The MFA adapter exists for one important reason: WebAuthn credentials are bound
to a **Relying Party ID** (a domain). allauth's default RP ID is the request's
host — which under our subdomain tenancy would be ``ajax-united.clubmanager.app``,
host — which under our subdomain tenancy would be ``ajax-united.rosterchief.app``,
binding a passkey to *one club*. A member of two clubs would then need two
passkeys, and a credential registered at one club would silently fail at another.
Pinning the RP ID to the registrable parent domain (``clubmanager.app``) makes a
Pinning the RP ID to the registrable parent domain (``rosterchief.app``) makes a
single passkey work across every club subdomain.
"""
@@ -14,7 +14,7 @@ from allauth.mfa.adapter import DefaultMFAAdapter
from django.conf import settings
class ClubManagerMFAAdapter(DefaultMFAAdapter):
class RosterChiefMFAAdapter(DefaultMFAAdapter):
def get_public_key_credential_rp_entity(self) -> dict[str, str]:
return {
"id": webauthn_rp_id(),
@@ -28,7 +28,7 @@ def webauthn_rp_id() -> str:
Falls back to the request host when no base domain is configured (e.g. a
bare ``localhost`` dev server), which keeps WebAuthn usable there.
"""
base_domain = getattr(settings, "CLUBMANAGER_BASE_DOMAIN", "")
base_domain = getattr(settings, "ROSTERCHIEF_BASE_DOMAIN", "")
if base_domain:
return base_domain

View File

@@ -13,7 +13,7 @@ from django.urls import reverse
from club.models import Club, ClubRole
from members.models import Member
from .adapters import ClubManagerMFAAdapter, webauthn_rp_id
from .adapters import RosterChiefMFAAdapter, webauthn_rp_id
from .middleware import RequireMFAMiddleware, mfa_required_for
User = get_user_model()
@@ -104,9 +104,9 @@ class UserModelTests(TestCase):
@override_settings(
CLUBMANAGER_BASE_DOMAIN="clubmanager.app",
MFA_WEBAUTHN_RP_NAME="ClubManager",
ALLOWED_HOSTS=[".clubmanager.app", "example.test"],
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
MFA_WEBAUTHN_RP_NAME="RosterChief",
ALLOWED_HOSTS=[".rosterchief.app", "example.test"],
)
class WebAuthnRelyingPartyTests(TestCase):
"""A passkey is bound to a Relying Party ID (a domain).
@@ -119,22 +119,22 @@ class WebAuthnRelyingPartyTests(TestCase):
def rp_entity(self, host):
request = RequestFactory().get("/", HTTP_HOST=host)
with context.request_context(request):
return ClubManagerMFAAdapter().get_public_key_credential_rp_entity()
return RosterChiefMFAAdapter().get_public_key_credential_rp_entity()
def test_rp_id_is_the_parent_domain_not_the_club_subdomain(self):
self.assertEqual(self.rp_entity("ajax-united.clubmanager.app")["id"], "clubmanager.app")
self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["id"], "rosterchief.app")
def test_rp_id_is_identical_across_clubs(self):
# The whole point: a passkey registered at one club works at the others.
here = self.rp_entity("ajax-united.clubmanager.app")
there = self.rp_entity("rival-fc.clubmanager.app")
here = self.rp_entity("ajax-united.rosterchief.app")
there = self.rp_entity("rival-fc.rosterchief.app")
self.assertEqual(here["id"], there["id"])
def test_rp_name_comes_from_settings(self):
self.assertEqual(self.rp_entity("ajax-united.clubmanager.app")["name"], "ClubManager")
self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["name"], "RosterChief")
@override_settings(CLUBMANAGER_BASE_DOMAIN="")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="")
def test_falls_back_to_the_request_host_without_a_base_domain(self):
request = RequestFactory().get("/", HTTP_HOST="example.test:8000")

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-07-13 13:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0010_club_archived_at'),
]
operations = [
migrations.AlterField(
model_name='club',
name='slug',
field=models.SlugField(blank=True, help_text='Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app).', max_length=255, unique=True, verbose_name='slug'),
),
]

View File

@@ -4,8 +4,8 @@ from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from clubmanager.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
class ClubManager(models.Manager):
@@ -24,7 +24,7 @@ class ClubManager(models.Manager):
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.clubmanager.app)."))
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))

View File

@@ -58,7 +58,7 @@ class ClubTenantMiddleware:
reset_current_club(token)
def get_club(self, request) -> Club | None:
# Imported lazily: club.models imports clubmanager.base, which imports
# Imported lazily: club.models imports rosterchief.base, which imports
# this module, so a top-level import would be circular.
from .models import Club
@@ -78,7 +78,7 @@ class ClubTenantMiddleware:
if not host:
return None
base_domain = getattr(settings, "CLUBMANAGER_BASE_DOMAIN", "").lower().strip(".")
base_domain = getattr(settings, "ROSTERCHIEF_BASE_DOMAIN", "").lower().strip(".")
if base_domain:
# Only hosts under the configured base domain carry a tenant slug.

View File

@@ -231,8 +231,8 @@ class ClubSlugTests(TestCase):
@override_settings(
CLUBMANAGER_BASE_DOMAIN="clubmanager.app",
ALLOWED_HOSTS=[".clubmanager.app", ".example.com", ".example.org"],
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
ALLOWED_HOSTS=[".rosterchief.app", ".example.com", ".example.org"],
)
class ClubTenantMiddlewareTests(TestCase):
def setUp(self):
@@ -252,29 +252,29 @@ class ClubTenantMiddlewareTests(TestCase):
return request, response
def test_subdomain_resolves_to_club(self):
request, response = self._run("ajax-united.clubmanager.app")
request, response = self._run("ajax-united.rosterchief.app")
self.assertEqual(response, "response")
self.assertEqual(request.club, self.club)
self.assertEqual(self.captured["context_club"], self.club)
def test_subdomain_resolution_ignores_port(self):
request, _ = self._run("ajax-united.clubmanager.app:8000")
request, _ = self._run("ajax-united.rosterchief.app:8000")
self.assertEqual(request.club, self.club)
def test_unknown_subdomain_sets_none(self):
request, _ = self._run("unknown-club.clubmanager.app")
request, _ = self._run("unknown-club.rosterchief.app")
self.assertIsNone(request.club)
def test_bare_base_domain_has_no_club(self):
request, _ = self._run("clubmanager.app")
request, _ = self._run("rosterchief.app")
self.assertIsNone(request.club)
def test_www_is_treated_as_no_club(self):
request, _ = self._run("www.clubmanager.app")
request, _ = self._run("www.rosterchief.app")
self.assertIsNone(request.club)
@@ -284,17 +284,17 @@ class ClubTenantMiddlewareTests(TestCase):
self.assertIsNone(request.club)
def test_context_var_is_reset_after_request(self):
self._run("ajax-united.clubmanager.app")
self._run("ajax-united.rosterchief.app")
self.assertIsNone(get_current_club())
@override_settings(CLUBMANAGER_BASE_DOMAIN="")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="")
def test_generic_host_resolution_without_base_domain(self):
request, _ = self._run("ajax-united.example.com")
self.assertEqual(request.club, self.club)
@override_settings(CLUBMANAGER_BASE_DOMAIN="")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="")
def test_two_label_host_has_no_club_without_base_domain(self):
request, _ = self._run("example.com")
@@ -309,7 +309,7 @@ class _FakeRequest:
return self._host
@override_settings(CLUBMANAGER_BASE_DOMAIN="clubmanager.app")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app")
class GetSubdomainTests(TestCase):
def subdomain(self, host):
return ClubTenantMiddleware.get_subdomain(_FakeRequest(host))
@@ -318,10 +318,10 @@ class GetSubdomainTests(TestCase):
self.assertIsNone(self.subdomain(""))
def test_trailing_dot_is_stripped(self):
self.assertEqual(self.subdomain("ajax-united.clubmanager.app."), "ajax-united")
self.assertEqual(self.subdomain("ajax-united.rosterchief.app."), "ajax-united")
def test_nested_subdomain_uses_leftmost_label(self):
self.assertEqual(self.subdomain("a.b.clubmanager.app"), "a")
self.assertEqual(self.subdomain("a.b.rosterchief.app"), "a")
class TenantContextTests(TestCase):
@@ -537,7 +537,7 @@ class ClubArchivingTests(TestCase):
self.assertTrue(Season.objects.filter(club=self.club).exists())
@override_settings(CLUBMANAGER_BASE_DOMAIN="clubmanager.app", ALLOWED_HOSTS=[".clubmanager.app"])
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=[".rosterchief.app"])
class ArchivedClubTenancyTests(TestCase):
"""An archived club's subdomain must stop resolving — that is what makes
archiving a real deactivation rather than a cosmetic flag."""
@@ -547,7 +547,7 @@ class ArchivedClubTenancyTests(TestCase):
self.middleware = ClubTenantMiddleware(lambda request: "response")
def resolve(self):
request = RequestFactory().get("/", HTTP_HOST="ajax-united.clubmanager.app")
request = RequestFactory().get("/", HTTP_HOST="ajax-united.rosterchief.app")
self.middleware(request)
return request.club

View File

@@ -58,6 +58,7 @@ def club_statistics(club):
return [
{
"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),
@@ -67,6 +68,7 @@ def club_statistics(club):
},
{
"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),
@@ -75,6 +77,7 @@ def club_statistics(club):
},
{
"title": "Events",
"icon": "calendar-days",
"stats": [
("Upcoming", events.filter(start__gte=now).count()),
("This season", events.filter(season=season).count() if season else 0),
@@ -82,6 +85,7 @@ def club_statistics(club):
},
{
"title": "Shop",
"icon": "shopping-cart",
"stats": [
("Orders", orders.count()),
("Revenue", _money(orders.filter(status__in=PAID_STATUSES))),

View File

@@ -1,7 +1,8 @@
{% extends "base.html" %}
{% load lucide %}
{% block title %}
{% block panel_title %}Control panel{% endblock panel_title %} · ClubManager
{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
{% endblock title %}
{% block main %}
@@ -17,8 +18,8 @@
</div>
</div>
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit">
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab {% if nav == 'dashboard' %}tab-active{% endif %}">Dashboard</a>
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab {% if nav == 'clubs' %}tab-active{% endif %}">Clubs</a>
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab gap-2 {% if nav == 'dashboard' %}tab-active{% endif %}">{% lucide "layout-dashboard" size=16 %} Dashboard</a>
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab gap-2 {% if nav == 'clubs' %}tab-active{% endif %}">{% lucide "building-2" size=16 %} Clubs</a>
</div>
{% block panel %}{% endblock panel %}
{% endblock main %}

View File

@@ -1,4 +1,5 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% block heading %}{{ club.name }}{% endblock heading %}
@@ -12,16 +13,16 @@
{% endblock subheading %}
{% block actions %}
<a class="btn btn-ghost" href="{% url 'controlpanel:club_update' club.pk %}">Edit</a>
<a class="btn btn-ghost gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a>
{% if club.is_archived %}
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
{% csrf_token %}
<button class="btn btn-success" type="submit">Restore</button>
<button class="btn btn-success gap-2" type="submit">{% lucide "archive-restore" size=16 %} Restore</button>
</form>
{% else %}
<form method="post" action="{% url 'controlpanel:club_archive' club.pk %}">
{% csrf_token %}
<button class="btn btn-warning" type="submit">Archive</button>
<button class="btn btn-warning gap-2" type="submit">{% lucide "archive" size=16 %} Archive</button>
</form>
{% endif %}
{% endblock actions %}
@@ -36,7 +37,7 @@
{% for group in groups %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{{ group.title }}</h2>
<h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2>
<dl class="divide-y divide-base-200">
{% for label, value in group.stats %}
<div class="flex items-center justify-between py-2">
@@ -53,7 +54,7 @@
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">Club admins</h2>
<a class="btn btn-primary btn-sm" href="{% url 'controlpanel:club_admin_add' club.pk %}">Add admin</a>
<a class="btn btn-primary btn-sm gap-2" href="{% url 'controlpanel:club_admin_add' club.pk %}">{% lucide "user-plus" size=16 %} Add admin</a>
</div>
<div class="overflow-x-auto">
<table class="table">
@@ -72,7 +73,7 @@
<td class="text-right">
<form method="post" action="{% url 'controlpanel:club_admin_remove' club.pk role.pk %}">
{% csrf_token %}
<button class="btn btn-ghost btn-xs text-error" type="submit">Remove</button>
<button class="btn btn-ghost btn-xs gap-1 text-error" type="submit">{% lucide "trash-2" size=14 %} Remove</button>
</form>
</td>
</tr>

View File

@@ -1,4 +1,5 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% block heading %}{% if show_archived %}Archived clubs{% else %}Clubs{% endif %}{% endblock heading %}
@@ -8,7 +9,7 @@
{% else %}
<a class="btn btn-ghost" href="{% url 'controlpanel:club_list' %}?archived=1">Archived</a>
{% endif %}
<a class="btn btn-primary" href="{% url 'controlpanel:club_create' %}">New club</a>
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a>
{% endblock actions %}
{% block panel %}
@@ -19,7 +20,7 @@
value="{{ search }}"
placeholder="Search clubs…"
class="input input-bordered w-full max-w-xs">
<button class="btn" type="submit">Search</button>
<button class="btn gap-2" type="submit">{% lucide "search" size=16 %} Search</button>
</form>
<div class="card bg-base-100 shadow">
<div class="card-body">

View File

@@ -1,9 +1,10 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% block heading %}Platform overview{% endblock heading %}
{% block actions %}
<a class="btn btn-primary" href="{% url 'controlpanel:club_create' %}">New club</a>
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a>
{% endblock actions %}
{% block panel %}

View File

@@ -56,12 +56,12 @@ class AccessTests(ControlPanelTestBase):
self.assertEqual(self.client.get(reverse("controlpanel:dashboard")).status_code, 200)
@override_settings(CLUBMANAGER_BASE_DOMAIN="clubmanager.app", ALLOWED_HOSTS=[".clubmanager.app"])
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=[".rosterchief.app"])
def test_panel_does_not_exist_on_a_club_subdomain(self):
# It manages *all* clubs, so it must not be reachable from inside one.
Club.objects.create(name="Rival FC", slug="rival-fc")
response = self.client.get(reverse("controlpanel:dashboard"), headers={"host": "rival-fc.clubmanager.app"})
response = self.client.get(reverse("controlpanel:dashboard"), headers={"host": "rival-fc.rosterchief.app"})
self.assertEqual(response.status_code, 404)

View File

@@ -2,8 +2,8 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
from club.models import Season
from clubmanager.base import ClubScopedModel, UUIDModel, validate_club_scope
from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Team

View File

@@ -3,8 +3,8 @@ from django.db import models
from django.db.models import UniqueConstraint
from django.utils.translation import gettext_lazy as _
from clubmanager.base import ClubScopedModel, UUIDModel, unique_slugify
from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify
class Form(ClubScopedModel):

View File

@@ -7,7 +7,7 @@ import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clubmanager.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rosterchief.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:

View File

@@ -3,7 +3,7 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from clubmanager.base import UUIDModel
from rosterchief.base import UUIDModel
class Family(UUIDModel):

View File

@@ -1,5 +1,5 @@
{
"name": "clubmanager",
"name": "rosterchief",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,11 +1,12 @@
[project]
name = "clubmanager"
name = "rosterchief"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"dj-database-url>=3.1.2",
"django>=6.0.6",
"django-allauth[mfa]>=65.18.0",
"django-lucide",
"django-phonenumber-field[phonenumbers]>=8.4.0",
"pillow>=12.3.0",
"python-dateutil>=2.9.0.post0",
@@ -43,7 +44,10 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
# Settings legitimately use star imports and long generated values.
"clubmanager/settings/*" = ["F403", "F405", "E501"]
"rosterchief/settings/*" = ["F403", "F405", "E501"]
[tool.ruff.lint.isort]
known-first-party = ["authentication", "club", "members", "teams", "events", "news", "pages", "home", "search", "clubmanager"]
known-first-party = ["authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "news", "pages", "home", "search", "rosterchief"]
[tool.uv.sources]
django-lucide = { git = "https://github.com/bsiebens/lucide" }

View File

@@ -1,5 +1,5 @@
"""
ASGI config for clubmanager project.
ASGI config for rosterchief project.
It exposes the ASGI callable as a module-level variable named ``application``.
@@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clubmanager.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rosterchief.settings")
application = get_asgi_application()

View File

@@ -1,5 +1,5 @@
"""
Django settings for clubmanager project.
Django settings for rosterchief project.
Generated by 'django-admin startproject' using Django 6.0.6.
@@ -44,6 +44,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"phonenumber_field",
"lucide",
# Auth: allauth deliberately WITHOUT django.contrib.sites — it is optional in
# allauth 65+, and ARCHITECTURE.md §2.4 rejects the Sites framework (Club is
# the tenant root, not Site).
@@ -79,11 +80,11 @@ AUTHENTICATION_BACKENDS = [
]
# Multi-tenancy: base domain whose subdomains resolve to a club, e.g.
# "ajax-united.clubmanager.app" -> the club with slug "ajax-united". Leave
# "ajax-united.rosterchief.app" -> the club with slug "ajax-united". Leave
# unset to fall back to generic "slug.example.com" (3+ label) resolution.
CLUBMANAGER_BASE_DOMAIN = config("CLUBMANAGER_BASE_DOMAIN", default="")
ROSTERCHIEF_BASE_DOMAIN = config("ROSTERCHIEF_BASE_DOMAIN", default="")
ROOT_URLCONF = "clubmanager.urls"
ROOT_URLCONF = "rosterchief.urls"
AUTH_USER_MODEL = "authentication.User"
@@ -113,10 +114,10 @@ MFA_PASSKEY_SIGNUP_ENABLED = False
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG
# A passkey is bound to a Relying Party ID (a domain). Our adapter pins it to
# CLUBMANAGER_BASE_DOMAIN so that ONE passkey works across every club subdomain
# ROSTERCHIEF_BASE_DOMAIN so that ONE passkey works across every club subdomain
# — allauth's default (the request host) would bind it to a single club.
MFA_ADAPTER = "authentication.adapters.ClubManagerMFAAdapter"
MFA_WEBAUTHN_RP_NAME = config("CLUBMANAGER_RP_NAME", default="ClubManager")
MFA_ADAPTER = "authentication.adapters.RosterChiefMFAAdapter"
MFA_WEBAUTHN_RP_NAME = config("ROSTERCHIEF_RP_NAME", default="RosterChief")
# Where RequireMFAMiddleware sends privileged users who haven't enrolled yet.
MFA_ENROLMENT_URL_NAME = "mfa_index"
@@ -126,7 +127,7 @@ MFA_ENROLMENT_URL_NAME = "mfa_index"
# on every club (matching the one-passkey-everywhere model). Tenancy still scopes
# what you can *see* — that is the access service's job, not the cookie's.
# Browsers reject a Domain attribute on localhost, so it stays host-only in dev.
SHARED_COOKIE_DOMAIN = f".{CLUBMANAGER_BASE_DOMAIN}" if CLUBMANAGER_BASE_DOMAIN and CLUBMANAGER_BASE_DOMAIN != "localhost" else None
SHARED_COOKIE_DOMAIN = f".{ROSTERCHIEF_BASE_DOMAIN}" if ROSTERCHIEF_BASE_DOMAIN and ROSTERCHIEF_BASE_DOMAIN != "localhost" else None
SESSION_COOKIE_DOMAIN = config("DJANGO_SESSION_COOKIE_DOMAIN", default=SHARED_COOKIE_DOMAIN)
CSRF_COOKIE_DOMAIN = config("DJANGO_CSRF_COOKIE_DOMAIN", default=SHARED_COOKIE_DOMAIN)
@@ -146,7 +147,7 @@ TEMPLATES = [
},
]
WSGI_APPLICATION = "clubmanager.wsgi.application"
WSGI_APPLICATION = "rosterchief.wsgi.application"
# Database

View File

@@ -1,4 +1,4 @@
"""URL configuration for clubmanager.
"""URL configuration for rosterchief.
``/admin/login/`` is deliberately intercepted *before* ``admin.site.urls`` and
redirected to the allauth login, so Django staff go through the same MFA

View File

@@ -1,5 +1,5 @@
"""
WSGI config for clubmanager project.
WSGI config for rosterchief project.
It exposes the WSGI callable as a module-level variable named ``application``.
@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clubmanager.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rosterchief.settings")
application = get_wsgi_application()

View File

@@ -6,8 +6,8 @@ from django.utils.translation import gettext_lazy as _
from authentication.models import User
from club.models import Season
from club.tenancy import require_current_club
from clubmanager.base import ClubScopedModel, UUIDModel, validate_club_scope
from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Position, Team

File diff suppressed because one or more lines are too long

View File

@@ -3,8 +3,8 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from club.models import Season
from clubmanager.base import ClubScopedModel, UUIDModel, validate_club_scope
from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
class Team(ClubScopedModel):

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block title %}
{% block head_title %}{% endblock head_title %} · ClubManager
{% block head_title %}{% endblock head_title %} · RosterChief
{% endblock title %}
{% block main %}

View File

@@ -1,11 +1,11 @@
{% load static %}
{% load lucide static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>
{% block title %}ClubManager{% endblock title %}
{% block title %}RosterChief{% endblock title %}
</title>
{# Apply the stored theme before first paint, otherwise the page flashes
the wrong colours. With no stored preference we set nothing, so
@@ -22,18 +22,25 @@
<body class="min-h-screen bg-base-200">
<div class="navbar bg-base-100 shadow-sm">
<div class="flex-1">
<a class="btn btn-ghost text-xl" href="/">ClubManager</a>
<a class="btn btn-ghost gap-2 text-xl" href="/">
{% lucide "clipboard-list" size=22 %}
RosterChief
</a>
{% if user.is_authenticated and user.is_staff %}
<a class="btn btn-ghost btn-sm" href="{% url 'controlpanel:dashboard' %}">Control panel</a>
<a class="btn btn-ghost btn-sm gap-2" href="{% url 'controlpanel:dashboard' %}">
{% lucide "sliders-horizontal" size=16 %}
Control panel
</a>
{% endif %}
</div>
<div class="flex-none gap-2">
{# Both icons are rendered; JS shows the one matching the effective theme. #}
<button class="btn btn-ghost btn-circle"
aria-label="Toggle theme"
data-theme-toggle
type="button">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
<span data-theme-icon="light">{% lucide "sun" size=20 %}</span>
<span data-theme-icon="dark" class="hidden">{% lucide "moon" size=20 %}</span>
</button>
{% if user.is_authenticated %}
<div class="dropdown dropdown-end">
@@ -41,18 +48,30 @@
<ul tabindex="0"
class="menu dropdown-content z-10 mt-2 w-56 rounded-box bg-base-100 p-2 shadow">
<li>
<a href="{% url 'mfa_index' %}">Two-factor authentication</a>
<a href="{% url 'mfa_index' %}">
{% lucide "shield-check" size=16 %}
Two-factor authentication
</a>
</li>
<li>
<a href="{% url 'account_change_password' %}">Change password</a>
<a href="{% url 'account_change_password' %}">
{% lucide "key-round" size=16 %}
Change password
</a>
</li>
<li>
<a href="{% url 'account_logout' %}">Sign out</a>
<a href="{% url 'account_logout' %}">
{% lucide "log-out" size=16 %}
Sign out
</a>
</li>
</ul>
</div>
{% else %}
<a class="btn btn-primary btn-sm" href="{% url 'account_login' %}">Sign in</a>
<a class="btn btn-primary btn-sm gap-2" href="{% url 'account_login' %}">
{% lucide "log-in" size=16 %}
Sign in
</a>
{% endif %}
</div>
</div>
@@ -69,18 +88,28 @@
{% block main %}{% endblock main %}
</main>
<script>
// No stored preference means "follow the OS", so read the effective theme
// from the OS when nothing is set yet.
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const current =
// No stored preference means "follow the OS", so the effective theme has to
// be read from the OS whenever nothing is set.
const effectiveTheme = () =>
document.documentElement.getAttribute("data-theme") ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
const next = current === "dark" ? "light" : "dark";
const showThemeIcon = () => {
const dark = effectiveTheme() === "dark";
document.querySelectorAll('[data-theme-icon="dark"]').forEach((i) => i.classList.toggle("hidden", !dark));
document.querySelectorAll('[data-theme-icon="light"]').forEach((i) => i.classList.toggle("hidden", dark));
};
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const next = effectiveTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
showThemeIcon();
});
});
showThemeIcon();
</script>
</body>
</html>

81
uv.lock generated
View File

@@ -70,43 +70,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
]
[[package]]
name = "clubmanager"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "dj-database-url" },
{ name = "django" },
{ name = "django-allauth", extra = ["mfa"] },
{ name = "django-phonenumber-field", extra = ["phonenumbers"] },
{ name = "pillow" },
{ name = "python-dateutil" },
{ name = "python-decouple" },
]
[package.dev-dependencies]
dev = [
{ name = "coverage" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "dj-database-url", specifier = ">=3.1.2" },
{ name = "django", specifier = ">=6.0.6" },
{ name = "django-allauth", extras = ["mfa"], specifier = ">=65.18.0" },
{ name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" },
{ name = "pillow", specifier = ">=12.3.0" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
{ name = "python-decouple", specifier = ">=3.8" },
]
[package.metadata.requires-dev]
dev = [
{ name = "coverage", specifier = ">=7.15.0" },
{ name = "ruff", specifier = ">=0.15.17" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -250,6 +213,11 @@ mfa = [
{ name = "qrcode" },
]
[[package]]
name = "django-lucide"
version = "1.3.1"
source = { git = "https://github.com/bsiebens/lucide#d2cdc609c30ad09701c9d044427adef5cec87500" }
[[package]]
name = "django-phonenumber-field"
version = "8.4.0"
@@ -380,6 +348,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[[package]]
name = "rosterchief"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "dj-database-url" },
{ name = "django" },
{ name = "django-allauth", extra = ["mfa"] },
{ name = "django-lucide" },
{ name = "django-phonenumber-field", extra = ["phonenumbers"] },
{ name = "pillow" },
{ name = "python-dateutil" },
{ name = "python-decouple" },
]
[package.dev-dependencies]
dev = [
{ name = "coverage" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "dj-database-url", specifier = ">=3.1.2" },
{ name = "django", specifier = ">=6.0.6" },
{ name = "django-allauth", extras = ["mfa"], specifier = ">=65.18.0" },
{ name = "django-lucide", git = "https://github.com/bsiebens/lucide" },
{ name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" },
{ name = "pillow", specifier = ">=12.3.0" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
{ name = "python-decouple", specifier = ">=3.8" },
]
[package.metadata.requires-dev]
dev = [
{ name = "coverage", specifier = ">=7.15.0" },
{ name = "ruff", specifier = ">=0.15.17" },
]
[[package]]
name = "ruff"
version = "0.15.17"