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>
This commit is contained in:
2026-07-12 15:32:05 +02:00
parent 919a68ed3a
commit 72e7b5e070
5 changed files with 213 additions and 1 deletions

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0.6 on 2026-07-12 13:11
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0005_alter_clubmembership_member'),
]
operations = [
migrations.AddField(
model_name='club',
name='slug',
field=models.SlugField(blank=True, help_text='Drives subdomain / path resolution (e.g. ajax-united.clubmanager.app).', max_length=255, unique=True, verbose_name='slug'),
),
migrations.CreateModel(
name='Season',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('start_date', models.DateField(verbose_name='start date')),
('end_date', models.DateField(verbose_name='end date')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'season',
'verbose_name_plural': 'seasons',
},
),
]

View File

@@ -1,12 +1,27 @@
import datetime
from django.db import models
from django.utils import timezone
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from clubmanager.base import UUIDModel
from clubmanager.base import ClubScopedModel, UUIDModel
from members.models import Member
class ClubManager(models.Manager):
def current(self):
"""Return the club for the active tenant context, if any."""
from .tenancy import get_current_club
return get_current_club()
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)."))
objects = ClubManager()
class Meta:
verbose_name = _("club")
@@ -16,6 +31,21 @@ class Club(UUIDModel):
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = self._unique_slug()
super().save(*args, **kwargs)
def _unique_slug(self):
base = slugify(self.name) or "club"
slug = base
suffix = 2
existing = Club.objects.exclude(pk=self.pk)
while existing.filter(slug=slug).exists():
slug = f"{base}-{suffix}"
suffix += 1
return slug
class ClubMembership(UUIDModel):
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="member_of", verbose_name=_("member"))
@@ -31,3 +61,22 @@ class ClubMembership(UUIDModel):
def __str__(self):
return f"{self.club} - {self.member}"
class Season(ClubScopedModel):
start_date = models.DateField(_("start date"))
end_date = models.DateField(_("end date"))
def __str__(self):
return f"{self.start_date} - {self.end_date}"
class Meta:
verbose_name = _("season")
verbose_name_plural = _("seasons")
@classmethod
def get_current(cls, date: datetime.date | None = None):
if date is None:
date = timezone.now().date()

103
club/tenancy.py Normal file
View File

@@ -0,0 +1,103 @@
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