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:
20
.env.example
20
.env.example
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
18
club/migrations/0011_alter_club_slug.py
Normal file
18
club/migrations/0011_alter_club_slug.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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."))
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "clubmanager",
|
||||
"name": "rosterchief",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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.
|
||||
// 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 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 current =
|
||||
document.documentElement.getAttribute("data-theme") ||
|
||||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
||||
const next = current === "dark" ? "light" : "dark";
|
||||
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
81
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user