From c320931595f1c147429b95a9d32b7dc3b9e2af91 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Mon, 13 Jul 2026 14:41:57 +0200 Subject: [PATCH] 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 --- club/services/__init__.py | 0 club/services/access.py | 0 clubmanager/base.py | 26 ++++++++++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 club/services/__init__.py create mode 100644 club/services/access.py diff --git a/club/services/__init__.py b/club/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/club/services/access.py b/club/services/access.py new file mode 100644 index 0000000..e69de29 diff --git a/clubmanager/base.py b/clubmanager/base.py index a5b2716..173b25a 100644 --- a/clubmanager/base.py +++ b/clubmanager/base.py @@ -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``.