From 72e7b5e0705430bc88d0d89aa78991e4bcaf70cd Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Sun, 12 Jul 2026 15:32:05 +0200 Subject: [PATCH] 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 --- club/migrations/0006_club_slug_season.py | 33 ++++++++ club/models.py | 51 ++++++++++- club/tenancy.py | 103 +++++++++++++++++++++++ clubmanager/base.py | 21 +++++ clubmanager/settings.py | 6 ++ 5 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 club/migrations/0006_club_slug_season.py create mode 100644 club/tenancy.py diff --git a/club/migrations/0006_club_slug_season.py b/club/migrations/0006_club_slug_season.py new file mode 100644 index 0000000..ddb6cdb --- /dev/null +++ b/club/migrations/0006_club_slug_season.py @@ -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', + }, + ), + ] diff --git a/club/models.py b/club/models.py index ef17947..8b1c136 100644 --- a/club/models.py +++ b/club/models.py @@ -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() + + \ No newline at end of file diff --git a/club/tenancy.py b/club/tenancy.py new file mode 100644 index 0000000..a519f29 --- /dev/null +++ b/club/tenancy.py @@ -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 diff --git a/clubmanager/base.py b/clubmanager/base.py index 8b1346a..0965486 100644 --- a/clubmanager/base.py +++ b/clubmanager/base.py @@ -1,7 +1,21 @@ import uuid +from typing import TYPE_CHECKING from django.db import models +from club.tenancy import require_current_club + +if TYPE_CHECKING: + from club.models import Club + + +class TenantQuerySet(models.QuerySet): + def for_club(self, club: Club): + return self.filter(club=club) + + def current(self): + return self.filter(club=require_current_club()) + class UUIDModel(models.Model): """Abstract base class giving every model a UUID primary key""" @@ -16,6 +30,13 @@ class ClubScopedModel(UUIDModel): """Abstract base for entities owned by a single club (tenant root).""" club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="%(class)ss") + objects = TenantQuerySet.as_manager() class Meta: abstract = True + + def save(self, *args, **kwargs): + if self.club_id is None: + self.club = require_current_club() + + super().save(*args, **kwargs) diff --git a/clubmanager/settings.py b/clubmanager/settings.py index c60240b..05bde7f 100644 --- a/clubmanager/settings.py +++ b/clubmanager/settings.py @@ -55,10 +55,16 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "club.tenancy.ClubTenantMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] +# Multi-tenancy: base domain whose subdomains resolve to a club, e.g. +# "ajax-united.clubmanager.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="") + ROOT_URLCONF = "clubmanager.urls" AUTH_USER_MODEL = "authentication.User"