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,
|
# 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
|
||||||
|
|||||||
@@ -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**.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
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 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."))
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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))),
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "clubmanager",
|
"name": "rosterchief",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
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" },
|
{ 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user