Files
RosterChief/club/tenancy.py
Bernard Siebens 72e7b5e070 feat(tenancy): resolve active club from request subdomain
Add row-based multi-tenancy plumbing keyed on Club as the tenant root:

- ClubTenantMiddleware maps the request's subdomain to a Club by slug,
  storing it on request.club and in a contextvar so service-layer code
  and management commands can read it via get_current_club(). Resolution
  honours CLUBMANAGER_BASE_DOMAIN (e.g. ajax-united.clubmanager.app),
  falling back to generic slug.example.com hosts, and ignores the bare
  base domain, www, and unknown slugs.
- Club gains a unique slug (auto-derived from name on save) plus a
  ClubManager.current() accessor for the active tenant.
- ClubScopedModel gets a TenantQuerySet (.for_club()/.current()) and
  auto-fills club from the active context on save.

Contextvar helpers live in club.tenancy; Club is imported lazily there
and in clubmanager.base to avoid an import cycle with club.models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:32:05 +02:00

104 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 clubmanager.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
return Club.objects.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, "CLUBMANAGER_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