diff --git a/features/__init__.py b/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/features/apps.py b/features/apps.py new file mode 100644 index 0000000..1060681 --- /dev/null +++ b/features/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class FeaturesConfig(AppConfig): + name = "features" + + def ready(self): + from . import signals # noqa: F401 diff --git a/features/migrations/0001_initial.py b/features/migrations/0001_initial.py new file mode 100644 index 0000000..c7344f4 --- /dev/null +++ b/features/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.6 on 2026-07-13 14:29 + +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('club', '0011_alter_club_slug'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Flag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='The human/computer readable name.', max_length=100, unique=True, verbose_name='Name')), + ('everyone', models.BooleanField(blank=True, help_text='Flip this flag on (Yes) or off (No) for everyone, overriding all other settings. Leave as Unknown to use normally.', null=True, verbose_name='Everyone')), + ('percent', models.DecimalField(blank=True, decimal_places=1, help_text='A number between 0.0 and 99.9 to indicate a percentage of users for whom this flag will be active.', max_digits=3, null=True, verbose_name='Percent')), + ('testing', models.BooleanField(default=False, help_text='Allow this flag to be set for a session for user testing', verbose_name='Testing')), + ('superusers', models.BooleanField(default=True, help_text='Flag always active for superusers?', verbose_name='Superusers')), + ('staff', models.BooleanField(default=False, help_text='Flag always active for staff?', verbose_name='Staff')), + ('authenticated', models.BooleanField(default=False, help_text='Flag always active for authenticated users?', verbose_name='Authenticated')), + ('languages', models.TextField(blank=True, default='', help_text='Activate this flag for users with one of these languages (comma-separated list)', verbose_name='Languages')), + ('rollout', models.BooleanField(default=False, help_text='Activate roll-out mode?', verbose_name='Rollout')), + ('note', models.TextField(blank=True, help_text='Note where this Flag is used.', verbose_name='Note')), + ('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now, help_text='Date when this Flag was created.', verbose_name='Created')), + ('modified', models.DateTimeField(default=django.utils.timezone.now, help_text='Date when this Flag was last modified.', verbose_name='Modified')), + ('clubs', models.ManyToManyField(blank=True, help_text='Activate this flag for these clubs.', related_name='flags', to='club.club', verbose_name='Clubs')), + ('groups', models.ManyToManyField(blank=True, help_text='Activate this flag for these user groups.', to='auth.group', verbose_name='Groups')), + ('users', models.ManyToManyField(blank=True, help_text='Activate this flag for these users.', to=settings.AUTH_USER_MODEL, verbose_name='Users')), + ], + options={ + 'verbose_name': 'Flag', + 'verbose_name_plural': 'Flags', + 'abstract': False, + }, + ), + ] diff --git a/features/migrations/__init__.py b/features/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/features/models.py b/features/models.py new file mode 100644 index 0000000..c3385a4 --- /dev/null +++ b/features/models.py @@ -0,0 +1,71 @@ +"""Club-scoped feature flags. + +waffle's Flag model is swappable (``WAFFLE_FLAG_MODEL``, like ``AUTH_USER_MODEL``), +so we subclass it to add the one dimension this platform actually needs: which +*clubs* a feature is on for. The tenant middleware already puts ``request.club`` +on every request, so a flag resolves with a plain ``flag_is_active(request, "shop")`` +— no call site has to know about clubs. + +Everything waffle already offers (``everyone`` / ``percent`` / ``staff`` / +``superusers`` / per-user / per-group) keeps working untouched. +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from waffle.models import CACHE_EMPTY, AbstractUserFlag +from waffle.utils import get_cache, keyfmt + +#: Cache key template for a flag's club ids, mirroring waffle's own +#: FLAG_USERS_CACHE_KEY / FLAG_GROUPS_CACHE_KEY. +FLAG_CLUBS_CACHE_KEY = "flag:%s:clubs" + + +class Flag(AbstractUserFlag): + clubs = models.ManyToManyField( + "club.Club", + blank=True, + related_name="flags", + help_text=_("Activate this flag for these clubs."), + verbose_name=_("Clubs"), + ) + + def get_flush_keys(self, flush_keys=None): + flush_keys = super().get_flush_keys(flush_keys) + flush_keys.append(keyfmt(FLAG_CLUBS_CACHE_KEY, self.name)) + return flush_keys + + def _get_club_ids(self) -> set: + """Club ids this flag is on for, cached the way waffle caches its own M2Ms.""" + cache = get_cache() + cache_key = keyfmt(FLAG_CLUBS_CACHE_KEY, self.name) + + cached = cache.get(cache_key) + if cached == CACHE_EMPTY: + return set() + if cached: + return cached + + club_ids = set(self.clubs.values_list("pk", flat=True)) + if not club_ids: + cache.add(cache_key, CACHE_EMPTY) + return set() + + cache.add(cache_key, club_ids) + return club_ids + + def is_active(self, request, read_only=False): + # waffle's contract: `everyone` overrides *all* other settings. So a flag + # explicitly switched off for everyone stays off even for a targeted club, + # and club targeting only applies while `everyone` is left Unknown (None). + if self.everyone is None: + club = getattr(request, "club", None) + if club is not None and club.pk in self._get_club_ids(): + return True + + return super().is_active(request, read_only=read_only) + + def is_active_for_club(self, club) -> bool: + """Explicit check for code that holds a club but no request.""" + if self.everyone is not None: + return self.everyone + return club.pk in self._get_club_ids() diff --git a/features/signals.py b/features/signals.py new file mode 100644 index 0000000..d1ce6e7 --- /dev/null +++ b/features/signals.py @@ -0,0 +1,26 @@ +"""Keep waffle's flag cache honest when club targeting changes. + +waffle caches a flag's M2M ids and only flushes on ``save()``. Editing an M2M +does not call ``save()``, so adding or removing a club would otherwise leave a +stale cached set and the flag would keep answering with the old value. +""" + +from django.db.models.signals import m2m_changed +from django.dispatch import receiver + +from .models import Flag + +FLUSH_ACTIONS = {"post_add", "post_remove", "post_clear"} + + +@receiver(m2m_changed, sender=Flag.clubs.through) +def flush_flag_club_cache(sender, instance, action, reverse, pk_set, **kwargs): + if action not in FLUSH_ACTIONS: + return + + if isinstance(instance, Flag): + instance.flush() + else: + # Reverse edit (club.flags.add(flag)): flush each flag touched. + for flag in Flag.objects.filter(pk__in=pk_set or []): + flag.flush() diff --git a/features/tests.py b/features/tests.py new file mode 100644 index 0000000..6e447df --- /dev/null +++ b/features/tests.py @@ -0,0 +1,96 @@ +from django.core.cache import cache +from django.test import RequestFactory, TestCase +from waffle import flag_is_active, get_waffle_flag_model + +from club.models import Club + +Flag = get_waffle_flag_model() + + +class ClubScopedFlagTests(TestCase): + def setUp(self): + # waffle caches flags by name, and its cache is NOT rolled back with the + # test transaction -- a flag row recreated under the same name in the next + # test would otherwise be shadowed by the previous test's cached object. + cache.clear() + self.addCleanup(cache.clear) + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.other = Club.objects.create(name="Rival FC", slug="rival-fc") + self.flag = Flag.objects.create(name="shop") + + def request_for(self, club): + request = RequestFactory().get("/") + request.club = club + request.user = None + return request + + def active_for(self, club): + return flag_is_active(self.request_for(club), "shop") + + def test_off_for_every_club_by_default(self): + self.assertFalse(self.active_for(self.club)) + self.assertFalse(self.active_for(self.other)) + + def test_on_only_for_the_targeted_club(self): + self.flag.clubs.add(self.club) + + self.assertTrue(self.active_for(self.club)) + self.assertFalse(self.active_for(self.other)) + + def test_removing_a_club_turns_it_off_again(self): + self.flag.clubs.add(self.club) + self.flag.clubs.remove(self.club) + + self.assertFalse(self.active_for(self.club)) + + def test_everyone_true_overrides_club_targeting(self): + self.flag.everyone = True + self.flag.save() + + self.assertTrue(self.active_for(self.other)) # not targeted, still on + + def test_everyone_false_beats_club_targeting(self): + # waffle's contract: `everyone` overrides ALL other settings, so a flag + # switched off for everyone must stay off even for a targeted club. + self.flag.clubs.add(self.club) + self.flag.everyone = False + self.flag.save() + + self.assertFalse(self.active_for(self.club)) + + def test_no_club_on_the_request_is_not_active(self): + # e.g. the base domain / control panel, where there is no tenant. + self.flag.clubs.add(self.club) + + self.assertFalse(flag_is_active(self.request_for(None), "shop")) + + def test_is_active_for_club_without_a_request(self): + self.flag.clubs.add(self.club) + + self.assertTrue(self.flag.is_active_for_club(self.club)) + self.assertFalse(self.flag.is_active_for_club(self.other)) + + def test_is_active_for_club_respects_everyone(self): + self.flag.everyone = False + self.flag.save() + self.assertFalse(self.flag.is_active_for_club(self.club)) + + self.flag.everyone = True + self.flag.save() + self.assertTrue(self.flag.is_active_for_club(self.club)) + + def test_cache_is_flushed_when_club_targeting_changes(self): + # The M2M does not call save(), so without the flush signal waffle would + # keep answering from a stale cached set. + self.assertFalse(self.active_for(self.club)) # primes the cache + + self.flag.clubs.add(self.club) + + self.assertTrue(self.active_for(self.club)) + + def test_cache_is_flushed_on_reverse_edit(self): + self.assertFalse(self.active_for(self.club)) + + self.club.flags.add(self.flag) + + self.assertTrue(self.active_for(self.club)) diff --git a/pyproject.toml b/pyproject.toml index 69b8739..fbfc6cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "django-allauth[mfa]>=65.18.0", "django-lucide", "django-phonenumber-field[phonenumbers]>=8.4.0", + "django-waffle>=5.0.0", "pillow>=12.3.0", "python-dateutil>=2.9.0.post0", "python-decouple>=3.8", diff --git a/rosterchief/settings.py b/rosterchief/settings.py index e6d61e7..fb1411c 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -58,9 +58,16 @@ INSTALLED_APPS = [ "events.apps.EventsConfig", "formbuilder.apps.FormbuilderConfig", "shop.apps.ShopConfig", + "waffle", + "features.apps.FeaturesConfig", "controlpanel.apps.ControlpanelConfig", ] +# Feature flags (django-waffle). The Flag model is swappable, like AUTH_USER_MODEL: +# ours adds a `clubs` M2M so a feature can be turned on per tenant. Because the +# tenant middleware sets request.club, `flag_is_active(request, "x")` just works. +WAFFLE_FLAG_MODEL = "features.Flag" + MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", diff --git a/uv.lock b/uv.lock index aa0eebb..476e066 100644 --- a/uv.lock +++ b/uv.lock @@ -235,6 +235,18 @@ phonenumbers = [ { name = "phonenumbers" }, ] +[[package]] +name = "django-waffle" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/e1/6f533da0d4ac89f427dfd9410e39bfc14ae3a23335ecd549d76be4b2a834/django_waffle-5.0.0.tar.gz", hash = "sha256:62f9d00eedf68dafb82657beab56e601bddedc1ea1ccfef91d83df8658708509", size = 37761, upload-time = "2025-06-12T07:38:54.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/d2/6f0d664bd35a3fdd0403655c7c32ec290704923f11541ef356b180cd8fbf/django_waffle-5.0.0-py3-none-any.whl", hash = "sha256:3312851d9d926b76b9e90712355781700a383b82b5bf2b61e1f1be97532c0f3d", size = 48137, upload-time = "2025-06-12T07:38:53.698Z" }, +] + [[package]] name = "fido2" version = "2.2.1" @@ -358,6 +370,7 @@ dependencies = [ { name = "django-allauth", extra = ["mfa"] }, { name = "django-lucide" }, { name = "django-phonenumber-field", extra = ["phonenumbers"] }, + { name = "django-waffle" }, { name = "pillow" }, { name = "python-dateutil" }, { name = "python-decouple" }, @@ -376,6 +389,7 @@ requires-dist = [ { name = "django-allauth", extras = ["mfa"], specifier = ">=65.18.0" }, { name = "django-lucide", git = "https://github.com/bsiebens/lucide" }, { name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" }, + { name = "django-waffle", specifier = ">=5.0.0" }, { name = "pillow", specifier = ">=12.3.0" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "python-decouple", specifier = ">=3.8" },