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:
0
features/__init__.py
Normal file
0
features/__init__.py
Normal file
8
features/apps.py
Normal file
8
features/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FeaturesConfig(AppConfig):
|
||||
name = "features"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
45
features/migrations/0001_initial.py
Normal file
45
features/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
features/migrations/__init__.py
Normal file
0
features/migrations/__init__.py
Normal file
71
features/models.py
Normal file
71
features/models.py
Normal 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
26
features/signals.py
Normal 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
96
features/tests.py
Normal 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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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
14
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user