Add club-scoped feature flags on django-waffle

Adds a `features` app with a swappable waffle Flag (WAFFLE_FLAG_MODEL) that
gains a m2m to Club, so a feature can be rolled out club by club.

Two things worth calling out:

- `everyone` keeps waffle's contract of overriding *all* other targeting, so
  club targeting is only consulted when `everyone is None`. This keeps
  `everyone = False` usable as a hard kill-switch.
- m2m edits don't call save(), so waffle's per-flag cache would go stale when
  clubs are added or removed. A m2m_changed receiver flushes it from both
  directions, and get_flush_keys() drops the club-set key alongside waffle's own.

Note for future flag tests: waffle's cache is not rolled back with the test
transaction, so tests touching flags must clear it (see features/tests.py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 16:42:22 +02:00
parent eace903f05
commit fed24bfee3
10 changed files with 268 additions and 0 deletions

0
features/__init__.py Normal file
View File

8
features/apps.py Normal file
View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class FeaturesConfig(AppConfig):
name = "features"
def ready(self):
from . import signals # noqa: F401

View File

@@ -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,
},
),
]

View File

71
features/models.py Normal file
View File

@@ -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()

26
features/signals.py Normal file
View File

@@ -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()

96
features/tests.py Normal file
View File

@@ -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))

View File

@@ -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",

View File

@@ -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",

14
uv.lock generated
View File

@@ -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" },