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, # 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". # 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). # In production set this to your real base domain (e.g. rosterchief.app).
CLUBMANAGER_BASE_DOMAIN=localhost 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 # ID, so ONE passkey works across every club subdomain. Change it and existing
# passkeys stop validating -- they are cryptographically bound to that domain. # passkeys stop validating -- they are cryptographically bound to that domain.
# CLUBMANAGER_RP_NAME is what the browser shows during a passkey prompt. # ROSTERCHIEF_RP_NAME is what the browser shows during a passkey prompt.
# CLUBMANAGER_RP_NAME=ClubManager # ROSTERCHIEF_RP_NAME=RosterChief
# Sessions are shared across club subdomains (log in once, all clubs). Derived # 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. # because browsers reject a Domain attribute there. Override if needed.
# DJANGO_SESSION_COOKIE_DOMAIN=.clubmanager.app # DJANGO_SESSION_COOKIE_DOMAIN=.rosterchief.app
# DJANGO_CSRF_COOKIE_DOMAIN=.clubmanager.app # DJANGO_CSRF_COOKIE_DOMAIN=.rosterchief.app
# Optional. Defaults to sqlite:///db.sqlite3 for dev; point at Postgres in prod. # 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. # 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. # Optional.
# DJANGO_TIME_ZONE=Europe/Brussels # 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 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 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. 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 > **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 > 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 > 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. 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. 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 (`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 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 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. current club** (via the tenant context below), falling back to email.
**Tenant resolution → `request.club`.** A `ClubTenantMiddleware` resolves the active club **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 `/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: variable so non-request code (services, management commands) can read it:
``` ```
# clubmanager/tenancy.py # rosterchief/tenancy.py
from contextvars import ContextVar from contextvars import ContextVar
_current_club: ContextVar = ContextVar("current_club", default=None) _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`. rows with the current season and re-scopes `unique_together`.
3.**Full multi-tenancy** — adopt **row-based multi-tenancy**, `Club` as tenant root 3.**Full multi-tenancy** — adopt **row-based multi-tenancy**, `Club` as tenant root
(§2.4). Requires: `ClubScopedModel` on every aggregate root, `Member.user` (§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 context, tenant-aware manager, per-club uniqueness, per-club roles (§3). **Supersedes
`CLAUDE.md`.** `CLAUDE.md`.**
4.**Jersey uniqueness** — unique **within a team** via a partial `UniqueConstraint` 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) ### 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. if using subdomain resolution; add `DJANGO_ALLOWED_HOSTS` accordingly.
- **CSRF:** `CSRF_TRUSTED_ORIGINS` must cover the wildcard scheme+host set - **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` / - **Cookies:** to share login across club subdomains, set `SESSION_COOKIE_DOMAIN` /
`CSRF_COOKIE_DOMAIN` to the base domain; otherwise keep per-subdomain sessions `CSRF_COOKIE_DOMAIN` to the base domain; otherwise keep per-subdomain sessions
(decide with the "cross-club users" question in §7). (decide with the "cross-club users" question in §7).
@@ -842,8 +842,8 @@ Setup:
--- ---
*Conventions cross-reference:* `clubmanager/base.py` (`UUIDModel`, `ClubScopedModel`), *Conventions cross-reference:* `rosterchief/base.py` (`UUIDModel`, `ClubScopedModel`),
`clubmanager/tenancy.py` (*to add* — tenant context/middleware, §2.4), `rosterchief/tenancy.py` (*to add* — tenant context/middleware, §2.4),
`authentication/managers.py` (`UserManager`), `authentication/services/` (service-layer `authentication/managers.py` (`UserManager`), `authentication/services/` (service-layer
pattern). pattern).
@@ -852,10 +852,10 @@ pattern).
> ### ⚠️ Banner: supersedes `CLAUDE.md` > ### ⚠️ Banner: supersedes `CLAUDE.md`
> >
> This architecture adopts **full multi-tenancy** (§2.4), which **directly contradicts** > 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 > multi-tenant — there is no `club_id` tenancy") and the project memory
> (`project_overview` — "Single-club (NOT multi-tenant)"). > (`project_overview` — "Single-club (NOT multi-tenant)").
> >
> **Action required** before/alongside implementation: update `CLAUDE.md` and the memory > **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**. > 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 ## 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. **`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 ## Commands
@@ -34,7 +34,7 @@ uv run ruff format . # format
## Configuration ## 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. 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 ## 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. - 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 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 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 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. 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. single passkey work across every club subdomain.
""" """
@@ -14,7 +14,7 @@ from allauth.mfa.adapter import DefaultMFAAdapter
from django.conf import settings from django.conf import settings
class ClubManagerMFAAdapter(DefaultMFAAdapter): class RosterChiefMFAAdapter(DefaultMFAAdapter):
def get_public_key_credential_rp_entity(self) -> dict[str, str]: def get_public_key_credential_rp_entity(self) -> dict[str, str]:
return { return {
"id": webauthn_rp_id(), "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 Falls back to the request host when no base domain is configured (e.g. a
bare ``localhost`` dev server), which keeps WebAuthn usable there. 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: if base_domain:
return base_domain return base_domain

View File

@@ -13,7 +13,7 @@ from django.urls import reverse
from club.models import Club, ClubRole from club.models import Club, ClubRole
from members.models import Member 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 from .middleware import RequireMFAMiddleware, mfa_required_for
User = get_user_model() User = get_user_model()
@@ -104,9 +104,9 @@ class UserModelTests(TestCase):
@override_settings( @override_settings(
CLUBMANAGER_BASE_DOMAIN="clubmanager.app", ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
MFA_WEBAUTHN_RP_NAME="ClubManager", MFA_WEBAUTHN_RP_NAME="RosterChief",
ALLOWED_HOSTS=[".clubmanager.app", "example.test"], ALLOWED_HOSTS=[".rosterchief.app", "example.test"],
) )
class WebAuthnRelyingPartyTests(TestCase): class WebAuthnRelyingPartyTests(TestCase):
"""A passkey is bound to a Relying Party ID (a domain). """A passkey is bound to a Relying Party ID (a domain).
@@ -119,22 +119,22 @@ class WebAuthnRelyingPartyTests(TestCase):
def rp_entity(self, host): def rp_entity(self, host):
request = RequestFactory().get("/", HTTP_HOST=host) request = RequestFactory().get("/", HTTP_HOST=host)
with context.request_context(request): 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): 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): def test_rp_id_is_identical_across_clubs(self):
# The whole point: a passkey registered at one club works at the others. # The whole point: a passkey registered at one club works at the others.
here = self.rp_entity("ajax-united.clubmanager.app") here = self.rp_entity("ajax-united.rosterchief.app")
there = self.rp_entity("rival-fc.clubmanager.app") there = self.rp_entity("rival-fc.rosterchief.app")
self.assertEqual(here["id"], there["id"]) self.assertEqual(here["id"], there["id"])
def test_rp_name_comes_from_settings(self): 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): def test_falls_back_to_the_request_host_without_a_base_domain(self):
request = RequestFactory().get("/", HTTP_HOST="example.test:8000") 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 import timezone
from django.utils.translation import gettext_lazy as _ 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 members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
class ClubManager(models.Manager): class ClubManager(models.Manager):
@@ -24,7 +24,7 @@ class ClubManager(models.Manager):
class Club(UUIDModel): class Club(UUIDModel):
name = models.CharField(_("name"), max_length=255) 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.")) 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) reset_current_club(token)
def get_club(self, request) -> Club | None: 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. # this module, so a top-level import would be circular.
from .models import Club from .models import Club
@@ -78,7 +78,7 @@ class ClubTenantMiddleware:
if not host: if not host:
return None return None
base_domain = getattr(settings, "CLUBMANAGER_BASE_DOMAIN", "").lower().strip(".") base_domain = getattr(settings, "ROSTERCHIEF_BASE_DOMAIN", "").lower().strip(".")
if base_domain: if base_domain:
# Only hosts under the configured base domain carry a tenant slug. # Only hosts under the configured base domain carry a tenant slug.

View File

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

View File

@@ -58,6 +58,7 @@ def club_statistics(club):
return [ return [
{ {
"title": "Members", "title": "Members",
"icon": "users",
"stats": [ "stats": [
("Members", memberships.values("member").distinct().count()), ("Members", memberships.values("member").distinct().count()),
("Active this season", memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0), ("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", "title": "Teams & staff",
"icon": "shield",
"stats": [ "stats": [
("Teams", Team.objects.filter(club=club).count()), ("Teams", Team.objects.filter(club=club).count()),
("Players this season", TeamMembership.objects.filter(team__club=club, season=season).count() if season else 0), ("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", "title": "Events",
"icon": "calendar-days",
"stats": [ "stats": [
("Upcoming", events.filter(start__gte=now).count()), ("Upcoming", events.filter(start__gte=now).count()),
("This season", events.filter(season=season).count() if season else 0), ("This season", events.filter(season=season).count() if season else 0),
@@ -82,6 +85,7 @@ def club_statistics(club):
}, },
{ {
"title": "Shop", "title": "Shop",
"icon": "shopping-cart",
"stats": [ "stats": [
("Orders", orders.count()), ("Orders", orders.count()),
("Revenue", _money(orders.filter(status__in=PAID_STATUSES))), ("Revenue", _money(orders.filter(status__in=PAID_STATUSES))),

View File

@@ -1,7 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load lucide %}
{% block title %} {% block title %}
{% block panel_title %}Control panel{% endblock panel_title %} · ClubManager {% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
{% endblock title %} {% endblock title %}
{% block main %} {% block main %}
@@ -17,8 +18,8 @@
</div> </div>
</div> </div>
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit"> <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: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 {% if nav == 'clubs' %}tab-active{% endif %}">Clubs</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> </div>
{% block panel %}{% endblock panel %} {% block panel %}{% endblock panel %}
{% endblock main %} {% endblock main %}

View File

@@ -1,4 +1,5 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load lucide %}
{% block heading %}{{ club.name }}{% endblock heading %} {% block heading %}{{ club.name }}{% endblock heading %}
@@ -12,16 +13,16 @@
{% endblock subheading %} {% endblock subheading %}
{% block actions %} {% 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 %} {% if club.is_archived %}
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}"> <form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
{% csrf_token %} {% 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> </form>
{% else %} {% else %}
<form method="post" action="{% url 'controlpanel:club_archive' club.pk %}"> <form method="post" action="{% url 'controlpanel:club_archive' club.pk %}">
{% csrf_token %} {% 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> </form>
{% endif %} {% endif %}
{% endblock actions %} {% endblock actions %}
@@ -36,7 +37,7 @@
{% for group in groups %} {% for group in groups %}
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <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"> <dl class="divide-y divide-base-200">
{% for label, value in group.stats %} {% for label, value in group.stats %}
<div class="flex items-center justify-between py-2"> <div class="flex items-center justify-between py-2">
@@ -53,7 +54,7 @@
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="card-title text-base">Club admins</h2> <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>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table">
@@ -72,7 +73,7 @@
<td class="text-right"> <td class="text-right">
<form method="post" action="{% url 'controlpanel:club_admin_remove' club.pk role.pk %}"> <form method="post" action="{% url 'controlpanel:club_admin_remove' club.pk role.pk %}">
{% csrf_token %} {% 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> </form>
</td> </td>
</tr> </tr>

View File

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

View File

@@ -1,9 +1,10 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load lucide %}
{% block heading %}Platform overview{% endblock heading %} {% block heading %}Platform overview{% endblock heading %}
{% block actions %} {% 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 %} {% endblock actions %}
{% block panel %} {% block panel %}

View File

@@ -56,12 +56,12 @@ class AccessTests(ControlPanelTestBase):
self.assertEqual(self.client.get(reverse("controlpanel:dashboard")).status_code, 200) 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): def test_panel_does_not_exist_on_a_club_subdomain(self):
# It manages *all* clubs, so it must not be reachable from inside one. # It manages *all* clubs, so it must not be reachable from inside one.
Club.objects.create(name="Rival FC", slug="rival-fc") 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) 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 django.utils.translation import gettext_lazy as _
from club.models import Season from club.models import Season
from clubmanager.base import ClubScopedModel, UUIDModel, validate_club_scope
from members.models import Member from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Team from teams.models import Team

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
[project] [project]
name = "clubmanager" name = "rosterchief"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"dj-database-url>=3.1.2", "dj-database-url>=3.1.2",
"django>=6.0.6", "django>=6.0.6",
"django-allauth[mfa]>=65.18.0", "django-allauth[mfa]>=65.18.0",
"django-lucide",
"django-phonenumber-field[phonenumbers]>=8.4.0", "django-phonenumber-field[phonenumbers]>=8.4.0",
"pillow>=12.3.0", "pillow>=12.3.0",
"python-dateutil>=2.9.0.post0", "python-dateutil>=2.9.0.post0",
@@ -43,7 +44,10 @@ ignore = [
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
# Settings legitimately use star imports and long generated values. # Settings legitimately use star imports and long generated values.
"clubmanager/settings/*" = ["F403", "F405", "E501"] "rosterchief/settings/*" = ["F403", "F405", "E501"]
[tool.ruff.lint.isort] [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``. 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 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() 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. Generated by 'django-admin startproject' using Django 6.0.6.
@@ -44,6 +44,7 @@ INSTALLED_APPS = [
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"phonenumber_field", "phonenumber_field",
"lucide",
# Auth: allauth deliberately WITHOUT django.contrib.sites — it is optional in # Auth: allauth deliberately WITHOUT django.contrib.sites — it is optional in
# allauth 65+, and ARCHITECTURE.md §2.4 rejects the Sites framework (Club is # allauth 65+, and ARCHITECTURE.md §2.4 rejects the Sites framework (Club is
# the tenant root, not Site). # the tenant root, not Site).
@@ -79,11 +80,11 @@ AUTHENTICATION_BACKENDS = [
] ]
# Multi-tenancy: base domain whose subdomains resolve to a club, e.g. # 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. # 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" AUTH_USER_MODEL = "authentication.User"
@@ -113,10 +114,10 @@ MFA_PASSKEY_SIGNUP_ENABLED = False
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG
# A passkey is bound to a Relying Party ID (a domain). Our adapter pins it to # 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. # — allauth's default (the request host) would bind it to a single club.
MFA_ADAPTER = "authentication.adapters.ClubManagerMFAAdapter" MFA_ADAPTER = "authentication.adapters.RosterChiefMFAAdapter"
MFA_WEBAUTHN_RP_NAME = config("CLUBMANAGER_RP_NAME", default="ClubManager") MFA_WEBAUTHN_RP_NAME = config("ROSTERCHIEF_RP_NAME", default="RosterChief")
# Where RequireMFAMiddleware sends privileged users who haven't enrolled yet. # Where RequireMFAMiddleware sends privileged users who haven't enrolled yet.
MFA_ENROLMENT_URL_NAME = "mfa_index" 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 # 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. # 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. # 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) SESSION_COOKIE_DOMAIN = config("DJANGO_SESSION_COOKIE_DOMAIN", default=SHARED_COOKIE_DOMAIN)
CSRF_COOKIE_DOMAIN = config("DJANGO_CSRF_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 # 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 ``/admin/login/`` is deliberately intercepted *before* ``admin.site.urls`` and
redirected to the allauth login, so Django staff go through the same MFA 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``. 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 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() application = get_wsgi_application()

View File

@@ -6,8 +6,8 @@ from django.utils.translation import gettext_lazy as _
from authentication.models import User from authentication.models import User
from club.models import Season from club.models import Season
from club.tenancy import require_current_club from club.tenancy import require_current_club
from clubmanager.base import ClubScopedModel, UUIDModel, validate_club_scope
from members.models import Member from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Position, Team 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 django.utils.translation import gettext_lazy as _
from club.models import Season from club.models import Season
from clubmanager.base import ClubScopedModel, UUIDModel, validate_club_scope
from members.models import Member from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
class Team(ClubScopedModel): class Team(ClubScopedModel):

View File

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

View File

@@ -1,11 +1,11 @@
{% load static %} {% load lucide static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> <title>
{% block title %}ClubManager{% endblock title %} {% block title %}RosterChief{% endblock title %}
</title> </title>
{# Apply the stored theme before first paint, otherwise the page flashes {# Apply the stored theme before first paint, otherwise the page flashes
the wrong colours. With no stored preference we set nothing, so the wrong colours. With no stored preference we set nothing, so
@@ -22,18 +22,25 @@
<body class="min-h-screen bg-base-200"> <body class="min-h-screen bg-base-200">
<div class="navbar bg-base-100 shadow-sm"> <div class="navbar bg-base-100 shadow-sm">
<div class="flex-1"> <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 %} {% 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 %} {% endif %}
</div> </div>
<div class="flex-none gap-2"> <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" <button class="btn btn-ghost btn-circle"
aria-label="Toggle theme" aria-label="Toggle theme"
data-theme-toggle data-theme-toggle
type="button"> type="button">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <span data-theme-icon="light">{% lucide "sun" size=20 %}</span>
<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="dark" class="hidden">{% lucide "moon" size=20 %}</span>
</button> </button>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
@@ -41,18 +48,30 @@
<ul tabindex="0" <ul tabindex="0"
class="menu dropdown-content z-10 mt-2 w-56 rounded-box bg-base-100 p-2 shadow"> class="menu dropdown-content z-10 mt-2 w-56 rounded-box bg-base-100 p-2 shadow">
<li> <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>
<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>
<li> <li>
<a href="{% url 'account_logout' %}">Sign out</a> <a href="{% url 'account_logout' %}">
{% lucide "log-out" size=16 %}
Sign out
</a>
</li> </li>
</ul> </ul>
</div> </div>
{% else %} {% 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 %} {% endif %}
</div> </div>
</div> </div>
@@ -69,18 +88,28 @@
{% block main %}{% endblock main %} {% block main %}{% endblock main %}
</main> </main>
<script> <script>
// No stored preference means "follow the OS", so read the effective theme // No stored preference means "follow the OS", so the effective theme has to
// from the OS when nothing is set yet. // be read from the OS whenever nothing is set.
document.querySelectorAll("[data-theme-toggle]").forEach((button) => { const effectiveTheme = () =>
button.addEventListener("click", () => {
const current =
document.documentElement.getAttribute("data-theme") || document.documentElement.getAttribute("data-theme") ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); (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); document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("theme", next); localStorage.setItem("theme", next);
showThemeIcon();
}); });
}); });
showThemeIcon();
</script> </script>
</body> </body>
</html> </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" }, { 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]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@@ -250,6 +213,11 @@ mfa = [
{ name = "qrcode" }, { name = "qrcode" },
] ]
[[package]]
name = "django-lucide"
version = "1.3.1"
source = { git = "https://github.com/bsiebens/lucide#d2cdc609c30ad09701c9d044427adef5cec87500" }
[[package]] [[package]]
name = "django-phonenumber-field" name = "django-phonenumber-field"
version = "8.4.0" 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" }, { 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]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.17" version = "0.15.17"