feat(core): add cross-club scope validator

Add clubmanager.base.validate_club_scope(instance, owning_club_id, ...): a
shared model-clean() helper that rejects FKs leaking across clubs. Club-scoped
FKs must share the owning club; Member FKs must have a ClubMembership in it.
Unset FKs are skipped.

Nothing enforced tenant consistency on the FKs between club-scoped rows, so an
order could reference another club's product, an event another club's season,
and so on. The following commits wire this into each app's clean().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:41:57 +02:00
parent 54aace8abb
commit c320931595
3 changed files with 26 additions and 0 deletions

View File

0
club/services/access.py Normal file
View File

View File

@@ -1,8 +1,10 @@
import uuid
from typing import TYPE_CHECKING
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from club.tenancy import require_current_club
@@ -10,6 +12,30 @@ if TYPE_CHECKING:
from club.models import Club
def validate_club_scope(instance, owning_club_id, *, same_club_fields=(), member_fields=()):
"""Reject FKs that leak across clubs.
``same_club_fields`` are FKs to club-scoped models that must share
``owning_club_id``; ``member_fields`` are Member FKs whose target must have
a ClubMembership in that club. Unset (None) FKs are skipped. Call from a
model's ``clean()``.
"""
errors = {}
for field in same_club_fields:
if getattr(instance, f"{field}_id") is not None and getattr(instance, field).club_id != owning_club_id:
errors[field] = _("Must belong to the same club.")
if member_fields:
from club.models import ClubMembership
for field in member_fields:
if getattr(instance, f"{field}_id") is not None and not ClubMembership.objects.filter(club_id=owning_club_id, member=getattr(instance, field)).exists():
errors[field] = _("Must be a member of this club.")
if errors:
raise ValidationError(errors)
def unique_slugify(instance, value, *, slug_field="slug", scope=None):
"""Return a slug derived from ``value``, unique within ``scope``.