Files
RosterChief/club/tenancy.py
Bernard Siebens eace903f05 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>
2026-07-13 15:42:20 +02:00

105 lines
3.2 KiB
Python

from __future__ import annotations
from contextvars import ContextVar, Token
from typing import TYPE_CHECKING
from django.conf import settings
if TYPE_CHECKING:
from .models import Club
__current_club: ContextVar = ContextVar("current_club", default=None)
def set_current_club(club: Club | None) -> Token:
"""Bind ``club`` to the current context and return a reset token."""
return __current_club.set(club)
def reset_current_club(token: Token) -> None:
"""Restore the club that was active before ``set_current_club``."""
__current_club.reset(token)
def get_current_club() -> Club | None:
return __current_club.get()
def require_current_club() -> Club:
club = get_current_club()
if club is None:
raise RuntimeError("No active club in context.")
return club
class ClubTenantMiddleware:
"""Resolve the active club from the request's subdomain.
The club whose ``slug`` matches the left-most host label (below the
configured base domain) is stored on ``request.club`` and pushed onto the
``current_club`` context variable for the duration of the request, so
service-layer code and managers can read it via ``get_current_club()``.
Requests that don't map to a club (bare base domain, ``www``, localhost,
an unknown slug) get ``request.club = None``.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
club = self.get_club(request)
request.club = club
token = set_current_club(club)
try:
return self.get_response(request)
finally:
reset_current_club(token)
def get_club(self, request) -> Club | None:
# Imported lazily: club.models imports rosterchief.base, which imports
# this module, so a top-level import would be circular.
from .models import Club
subdomain = self.get_subdomain(request)
if not subdomain:
return None
# Archived clubs stop resolving: their subdomain behaves as unknown.
return Club.objects.active().filter(slug=subdomain).first()
@staticmethod
def get_subdomain(request) -> str | None:
"""Extract the tenant slug from the request host, or ``None``."""
host = request.get_host().split(":")[0].lower().rstrip(".")
if not host:
return None
base_domain = getattr(settings, "ROSTERCHIEF_BASE_DOMAIN", "").lower().strip(".")
if base_domain:
# Only hosts under the configured base domain carry a tenant slug.
if host == base_domain:
return None
suffix = f".{base_domain}"
if not host.endswith(suffix):
return None
label = host[: -len(suffix)]
else:
# No base domain configured: treat "slug.example.com" style hosts
# (3+ labels) as tenant-bearing; leave bare/localhost hosts alone.
labels = host.split(".")
if len(labels) < 3:
return None
label = ".".join(labels[:-2])
# Use the left-most label only; ignore the marketing/www host.
subdomain = label.split(".")[0]
if subdomain in ("", "www"):
return None
return subdomain