From d5edb3956eda637546b68675b28e5e65132df9f5 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 19:08:51 -0600 Subject: [PATCH 1/7] feat: add feature toggle system for disabling modules and UI --- src/django_program/context_processors.py | 23 +++ src/django_program/features.py | 66 +++++++ src/django_program/settings.py | 25 +++ tests/test_features.py | 223 +++++++++++++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 src/django_program/context_processors.py create mode 100644 src/django_program/features.py create mode 100644 tests/test_features.py diff --git a/src/django_program/context_processors.py b/src/django_program/context_processors.py new file mode 100644 index 0000000..b401f6e --- /dev/null +++ b/src/django_program/context_processors.py @@ -0,0 +1,23 @@ +"""Django context processors for django-program.""" + +from typing import TYPE_CHECKING + +from django_program.settings import FeaturesConfig, get_config + +if TYPE_CHECKING: + from django.http import HttpRequest + + +def program_features(request: HttpRequest) -> dict[str, FeaturesConfig]: # noqa: ARG001 + """Expose feature toggle flags to templates. + + Add ``"django_program.context_processors.program_features"`` to the + ``context_processors`` list in your ``TEMPLATES`` setting. + + Usage in templates:: + + {% if program_features.registration_enabled %} + Registration + {% endif %} + """ + return {"program_features": get_config().features} diff --git a/src/django_program/features.py b/src/django_program/features.py new file mode 100644 index 0000000..82ed230 --- /dev/null +++ b/src/django_program/features.py @@ -0,0 +1,66 @@ +"""Feature toggle utilities for django-program. + +Provides functions to check whether specific features are enabled +in the current configuration, and a mixin for views that require +specific features. +""" + +from django.http import Http404, HttpRequest, HttpResponse + +from django_program.settings import get_config + + +def is_feature_enabled(feature: str) -> bool: + """Check if a feature is enabled. + + Args: + feature: Feature name (e.g., ``"registration"``, ``"sponsors"``, + ``"public_ui"``). + + Returns: + ``True`` if the feature is enabled, ``False`` otherwise. + + Raises: + ValueError: If the feature name is not recognized. + """ + config = get_config().features + if not config.all_ui_enabled and feature in ("public_ui", "manage_ui"): + return False + attr = f"{feature}_enabled" + if not hasattr(config, attr): + msg = f"Unknown feature: {feature!r}" + raise ValueError(msg) + return getattr(config, attr) + + +def require_feature(feature: str) -> None: + """Raise :class:`~django.http.Http404` if a feature is disabled. + + Args: + feature: Feature name to check. + + Raises: + Http404: If the feature is disabled. + """ + if not is_feature_enabled(feature): + raise Http404(f"Feature {feature!r} is not enabled") + + +class FeatureRequiredMixin: + """View mixin that returns 404 when a required feature is disabled. + + Set ``required_feature`` on the view class to the feature name. + + Example:: + + class TicketListView(FeatureRequiredMixin, ListView): + required_feature = "registration" + """ + + required_feature: str = "" + + def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: + """Check the feature toggle before dispatching the view.""" + if self.required_feature: + require_feature(self.required_feature) + return super().dispatch(request, *args, **kwargs) # type: ignore[misc] diff --git a/src/django_program/settings.py b/src/django_program/settings.py index 8a6f2ef..5961a8e 100644 --- a/src/django_program/settings.py +++ b/src/django_program/settings.py @@ -54,6 +54,25 @@ class PSFSponsorConfig: flight: str = "sponsors" +@dataclass(frozen=True, slots=True) +class FeaturesConfig: + """Feature toggles for enabling/disabling django-program modules. + + All features are enabled by default. Set to ``False`` in + ``DJANGO_PROGRAM['features']`` to disable. + """ + + registration_enabled: bool = True + sponsors_enabled: bool = True + travel_grants_enabled: bool = True + programs_enabled: bool = True + pretalx_sync_enabled: bool = True + + public_ui_enabled: bool = True + manage_ui_enabled: bool = True + all_ui_enabled: bool = True + + @dataclass(frozen=True, slots=True) class ProgramConfig: """Top-level django-program configuration.""" @@ -61,6 +80,7 @@ class ProgramConfig: stripe: StripeConfig = field(default_factory=StripeConfig) pretalx: PretalxConfig = field(default_factory=PretalxConfig) psf_sponsors: PSFSponsorConfig = field(default_factory=PSFSponsorConfig) + features: FeaturesConfig = field(default_factory=FeaturesConfig) cart_expiry_minutes: int = 30 pending_order_expiry_minutes: int = 15 order_reference_prefix: str = "ORD" @@ -87,6 +107,7 @@ def get_config() -> ProgramConfig: stripe_data = raw_data.pop("stripe", {}) pretalx_data = raw_data.pop("pretalx", {}) psf_sponsors_data = raw_data.pop("psf_sponsors", {}) + features_data = raw_data.pop("features", {}) if not isinstance(stripe_data, Mapping): msg = "DJANGO_PROGRAM['stripe'] must be a mapping (dict-like object)" raise TypeError(msg) @@ -96,11 +117,15 @@ def get_config() -> ProgramConfig: if not isinstance(psf_sponsors_data, Mapping): msg = "DJANGO_PROGRAM['psf_sponsors'] must be a mapping (dict-like object)" raise TypeError(msg) + if not isinstance(features_data, Mapping): + msg = "DJANGO_PROGRAM['features'] must be a mapping (dict-like object)" + raise TypeError(msg) config = ProgramConfig( stripe=StripeConfig(**dict(stripe_data)), pretalx=PretalxConfig(**dict(pretalx_data)), psf_sponsors=PSFSponsorConfig(**dict(psf_sponsors_data)), + features=FeaturesConfig(**dict(features_data)), **raw_data, ) _validate_program_config(config) diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..1a63577 --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,223 @@ +"""Tests for the feature toggle system.""" + +import pytest +from django.http import Http404, HttpRequest, HttpResponse +from django.test import override_settings +from django.views import View + +from django_program.context_processors import program_features +from django_program.features import FeatureRequiredMixin, is_feature_enabled, require_feature +from django_program.settings import FeaturesConfig, get_config + +# --------------------------------------------------------------------------- +# FeaturesConfig defaults +# --------------------------------------------------------------------------- + +ALL_MODULE_FEATURES = ( + "registration", + "sponsors", + "travel_grants", + "programs", + "pretalx_sync", +) + +ALL_UI_FEATURES = ( + "public_ui", + "manage_ui", + "all_ui", +) + +ALL_FEATURES = (*ALL_MODULE_FEATURES, *ALL_UI_FEATURES) + + +class TestFeaturesConfigDefaults: + """All features are enabled by default.""" + + def test_all_features_enabled_by_default(self) -> None: + config = get_config().features + for feature in ALL_FEATURES: + assert getattr(config, f"{feature}_enabled") is True + + def test_features_config_is_frozen(self) -> None: + config = get_config().features + with pytest.raises(AttributeError): + config.registration_enabled = False # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# is_feature_enabled +# --------------------------------------------------------------------------- + + +class TestIsFeatureEnabled: + """Tests for the ``is_feature_enabled`` helper.""" + + @pytest.mark.parametrize("feature", ALL_FEATURES) + def test_returns_true_by_default(self, feature: str) -> None: + assert is_feature_enabled(feature) is True + + @pytest.mark.parametrize("feature", ALL_MODULE_FEATURES) + def test_returns_false_when_disabled(self, feature: str) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {f"{feature}_enabled": False}}, + ): + assert is_feature_enabled(feature) is False + + def test_unknown_feature_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="Unknown feature"): + is_feature_enabled("nonexistent_module") + + def test_all_ui_disabled_overrides_public_ui(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"all_ui_enabled": False}}, + ): + assert is_feature_enabled("public_ui") is False + + def test_all_ui_disabled_overrides_manage_ui(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"all_ui_enabled": False}}, + ): + assert is_feature_enabled("manage_ui") is False + + def test_all_ui_disabled_does_not_affect_module_features(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"all_ui_enabled": False}}, + ): + assert is_feature_enabled("registration") is True + assert is_feature_enabled("sponsors") is True + + def test_public_ui_disabled_independently(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"public_ui_enabled": False}}, + ): + assert is_feature_enabled("public_ui") is False + assert is_feature_enabled("manage_ui") is True + + +# --------------------------------------------------------------------------- +# require_feature +# --------------------------------------------------------------------------- + + +class TestRequireFeature: + """Tests for the ``require_feature`` helper.""" + + def test_does_nothing_when_enabled(self) -> None: + require_feature("registration") + + def test_raises_http404_when_disabled(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"registration_enabled": False}}, + ): + with pytest.raises(Http404, match="registration"): + require_feature("registration") + + def test_raises_value_error_for_unknown_feature(self) -> None: + with pytest.raises(ValueError, match="Unknown feature"): + require_feature("bogus") + + +# --------------------------------------------------------------------------- +# FeatureRequiredMixin +# --------------------------------------------------------------------------- + + +class _StubView(FeatureRequiredMixin, View): + """Minimal view for testing the mixin.""" + + required_feature = "registration" + + def get(self, request: HttpRequest) -> HttpResponse: + return HttpResponse("OK") + + +class _NoFeatureView(FeatureRequiredMixin, View): + """View with no required feature set.""" + + def get(self, request: HttpRequest) -> HttpResponse: + return HttpResponse("OK") + + +class TestFeatureRequiredMixin: + """Tests for ``FeatureRequiredMixin``.""" + + def _make_request(self) -> HttpRequest: + request = HttpRequest() + request.method = "GET" + return request + + def test_dispatches_when_feature_enabled(self) -> None: + view = _StubView.as_view() + response = view(self._make_request()) + assert response.status_code == 200 + + def test_returns_404_when_feature_disabled(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"registration_enabled": False}}, + ): + view = _StubView.as_view() + with pytest.raises(Http404): + view(self._make_request()) + + def test_dispatches_when_no_required_feature_set(self) -> None: + view = _NoFeatureView.as_view() + response = view(self._make_request()) + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# Context processor +# --------------------------------------------------------------------------- + + +class TestProgramFeaturesContextProcessor: + """Tests for the ``program_features`` context processor.""" + + def test_returns_features_config(self) -> None: + request = HttpRequest() + context = program_features(request) + assert "program_features" in context + assert isinstance(context["program_features"], FeaturesConfig) + + def test_reflects_current_settings(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"sponsors_enabled": False}}, + ): + request = HttpRequest() + context = program_features(request) + assert context["program_features"].sponsors_enabled is False + assert context["program_features"].registration_enabled is True + + +# --------------------------------------------------------------------------- +# Settings integration (get_config parsing) +# --------------------------------------------------------------------------- + + +class TestFeaturesSettingsIntegration: + """Tests for features parsing inside ``get_config``.""" + + def test_features_parsed_from_settings(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"registration_enabled": False}}, + ): + config = get_config() + assert config.features.registration_enabled is False + assert config.features.sponsors_enabled is True + + def test_features_defaults_when_omitted(self) -> None: + with override_settings(DJANGO_PROGRAM={}): + config = get_config() + assert config.features == FeaturesConfig() + + def test_features_rejects_non_mapping(self) -> None: + with override_settings(DJANGO_PROGRAM={"features": ["bad"]}): + with pytest.raises(TypeError, match=r"DJANGO_PROGRAM\['features'\] must be a mapping"): + get_config() + + def test_features_rejects_unknown_field(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"unknown_toggle": True}}, + ): + with pytest.raises(TypeError): + get_config() From 504644f6799aaa61f78d51d93072c98cb4e69ac1 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 19:32:00 -0600 Subject: [PATCH 2/7] test: add missing coverage for ActivityOrganizerMixin anonymous access Co-Authored-By: Claude Opus 4.6 --- tests/test_manage/test_programs_views.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_manage/test_programs_views.py b/tests/test_manage/test_programs_views.py index eb9ca12..b61719e 100644 --- a/tests/test_manage/test_programs_views.py +++ b/tests/test_manage/test_programs_views.py @@ -697,6 +697,17 @@ def test_activity_promote_waitlisted_signup_warns_on_overbook(authed_client: Cli assert any("may now be overbooked" in message for message in msgs) +# ---- ActivityOrganizerMixin anonymous access ---- + + +@pytest.mark.django_db +def test_activity_dashboard_redirects_anonymous_user(client: Client, conference, activity): + url = reverse("manage:activity-dashboard", kwargs={"conference_slug": conference.slug, "pk": activity.pk}) + response = client.get(url) + assert response.status_code == 302 + assert "/accounts/login/" in response.url or "login" in response.url + + @pytest.mark.django_db def test_activity_promote_non_waitlisted_404(authed_client: Client, conference, activity, regular_user): signup = ActivitySignup.objects.create( From a1dcd7bc141514ee21b738a983c401eb9cb1ab41 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 19:36:53 -0600 Subject: [PATCH 3/7] chore: register feature toggles context processor in example app Co-Authored-By: Claude Opus 4.6 --- examples/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/settings.py b/examples/settings.py index 1d2c643..299b545 100644 --- a/examples/settings.py +++ b/examples/settings.py @@ -56,6 +56,7 @@ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "django_program.context_processors.program_features", ], }, }, From 005c6345f0c4826facc7d283b66fe0aa9d4184cf Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 19:42:13 -0600 Subject: [PATCH 4/7] feat: add database-backed feature flags with Django admin Co-Authored-By: Claude Opus 4.6 --- src/django_program/conference/admin.py | 48 +++- .../migrations/0004_add_feature_flags.py | 62 +++++ src/django_program/conference/models.py | 66 +++++ src/django_program/context_processors.py | 27 +- src/django_program/features.py | 97 ++++++- tests/test_features.py | 252 +++++++++++++++++- 6 files changed, 541 insertions(+), 11 deletions(-) create mode 100644 src/django_program/conference/migrations/0004_add_feature_flags.py diff --git a/src/django_program/conference/admin.py b/src/django_program/conference/admin.py index 1d1d403..f5f39be 100644 --- a/src/django_program/conference/admin.py +++ b/src/django_program/conference/admin.py @@ -3,7 +3,7 @@ from django import forms from django.contrib import admin -from django_program.conference.models import Conference, Section +from django_program.conference.models import Conference, FeatureFlags, Section SECRET_PLACEHOLDER = "\u2022" * 12 @@ -148,3 +148,49 @@ class SectionAdmin(admin.ModelAdmin): list_filter = ("conference",) search_fields = ("name", "slug") prepopulated_fields = {"slug": ("name",)} + + +@admin.register(FeatureFlags) +class FeatureFlagsAdmin(admin.ModelAdmin): + """Admin interface for per-conference feature flag overrides. + + Allows toggling individual features on or off per conference. + Leaving a field blank (``None``) uses the default from + ``DJANGO_PROGRAM["features"]`` in settings. + """ + + list_display = ( + "conference", + "registration_enabled", + "sponsors_enabled", + "travel_grants_enabled", + "programs_enabled", + "public_ui_enabled", + "updated_at", + ) + list_filter = ("conference",) + fieldsets = ( + ("Conference", {"fields": ("conference",)}), + ( + "Module Toggles", + { + "fields": ( + "registration_enabled", + "sponsors_enabled", + "travel_grants_enabled", + "programs_enabled", + "pretalx_sync_enabled", + ), + }, + ), + ( + "UI Toggles", + { + "fields": ( + "public_ui_enabled", + "manage_ui_enabled", + "all_ui_enabled", + ), + }, + ), + ) diff --git a/src/django_program/conference/migrations/0004_add_feature_flags.py b/src/django_program/conference/migrations/0004_add_feature_flags.py new file mode 100644 index 0000000..13c247a --- /dev/null +++ b/src/django_program/conference/migrations/0004_add_feature_flags.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.11 on 2026-02-14 01:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("program_conference", "0003_conference_address"), + ] + + operations = [ + migrations.CreateModel( + name="FeatureFlags", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "registration_enabled", + models.BooleanField( + blank=True, + help_text="Override registration toggle. Leave blank to use default from settings.", + null=True, + ), + ), + ("sponsors_enabled", models.BooleanField(blank=True, help_text="Override sponsors toggle.", null=True)), + ( + "travel_grants_enabled", + models.BooleanField(blank=True, help_text="Override travel grants toggle.", null=True), + ), + ( + "programs_enabled", + models.BooleanField(blank=True, help_text="Override programs/activities toggle.", null=True), + ), + ( + "pretalx_sync_enabled", + models.BooleanField(blank=True, help_text="Override Pretalx sync toggle.", null=True), + ), + ( + "public_ui_enabled", + models.BooleanField(blank=True, help_text="Override public UI toggle.", null=True), + ), + ( + "manage_ui_enabled", + models.BooleanField(blank=True, help_text="Override manage UI toggle.", null=True), + ), + ("all_ui_enabled", models.BooleanField(blank=True, help_text="Master UI switch override.", null=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "conference", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="feature_flags", + to="program_conference.conference", + ), + ), + ], + options={ + "verbose_name": "feature flags", + "verbose_name_plural": "feature flags", + }, + ), + ] diff --git a/src/django_program/conference/models.py b/src/django_program/conference/models.py index c37dbe8..8adadd8 100644 --- a/src/django_program/conference/models.py +++ b/src/django_program/conference/models.py @@ -62,3 +62,69 @@ class Meta: def __str__(self) -> str: return f"{self.name} ({self.conference.slug})" + + +class FeatureFlags(models.Model): + """Per-conference feature toggle overrides. + + Database-backed flags that override the defaults from + ``DJANGO_PROGRAM["features"]``. Changes take effect immediately + without server restart. Each conference has at most one row. + + All boolean fields are nullable: ``None`` means "use default from + settings", while an explicit ``True`` or ``False`` overrides. + """ + + conference = models.OneToOneField( + "program_conference.Conference", + on_delete=models.CASCADE, + related_name="feature_flags", + ) + registration_enabled = models.BooleanField( + null=True, + blank=True, + help_text="Override registration toggle. Leave blank to use default from settings.", + ) + sponsors_enabled = models.BooleanField( + null=True, + blank=True, + help_text="Override sponsors toggle.", + ) + travel_grants_enabled = models.BooleanField( + null=True, + blank=True, + help_text="Override travel grants toggle.", + ) + programs_enabled = models.BooleanField( + null=True, + blank=True, + help_text="Override programs/activities toggle.", + ) + pretalx_sync_enabled = models.BooleanField( + null=True, + blank=True, + help_text="Override Pretalx sync toggle.", + ) + public_ui_enabled = models.BooleanField( + null=True, + blank=True, + help_text="Override public UI toggle.", + ) + manage_ui_enabled = models.BooleanField( + null=True, + blank=True, + help_text="Override manage UI toggle.", + ) + all_ui_enabled = models.BooleanField( + null=True, + blank=True, + help_text="Master UI switch override.", + ) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "feature flags" + verbose_name_plural = "feature flags" + + def __str__(self) -> str: + return f"Feature flags for {self.conference}" diff --git a/src/django_program/context_processors.py b/src/django_program/context_processors.py index b401f6e..c99ce60 100644 --- a/src/django_program/context_processors.py +++ b/src/django_program/context_processors.py @@ -2,15 +2,34 @@ from typing import TYPE_CHECKING +from django_program.features import is_feature_enabled from django_program.settings import FeaturesConfig, get_config if TYPE_CHECKING: from django.http import HttpRequest -def program_features(request: HttpRequest) -> dict[str, FeaturesConfig]: # noqa: ARG001 +# Feature names that correspond to FeaturesConfig boolean attributes. +_FEATURE_NAMES = ( + "registration", + "sponsors", + "travel_grants", + "programs", + "pretalx_sync", + "public_ui", + "manage_ui", + "all_ui", +) + + +def program_features(request: HttpRequest) -> dict[str, FeaturesConfig | dict[str, bool]]: """Expose feature toggle flags to templates. + When the request carries a ``conference`` attribute (set by middleware + or the view), the context processor resolves per-conference DB + overrides via :func:`~django_program.features.is_feature_enabled`. + Otherwise it returns the static ``FeaturesConfig`` from settings. + Add ``"django_program.context_processors.program_features"`` to the ``context_processors`` list in your ``TEMPLATES`` setting. @@ -20,4 +39,10 @@ def program_features(request: HttpRequest) -> dict[str, FeaturesConfig]: # noqa Registration {% endif %} """ + conference = getattr(request, "conference", None) + + if conference is not None: + resolved = {f"{name}_enabled": is_feature_enabled(name, conference=conference) for name in _FEATURE_NAMES} + return {"program_features": resolved} + return {"program_features": get_config().features} diff --git a/src/django_program/features.py b/src/django_program/features.py index 82ed230..f29d51c 100644 --- a/src/django_program/features.py +++ b/src/django_program/features.py @@ -3,19 +3,62 @@ Provides functions to check whether specific features are enabled in the current configuration, and a mixin for views that require specific features. + +Features can be configured at two levels: + +1. **Settings defaults** -- ``DJANGO_PROGRAM["features"]`` in Django settings. + These require a server restart to change. +2. **Per-conference DB overrides** -- The ``FeatureFlags`` model stores + nullable booleans. When a value is not ``None`` it takes precedence + over the settings default. """ +from typing import TYPE_CHECKING + from django.http import Http404, HttpRequest, HttpResponse from django_program.settings import get_config +if TYPE_CHECKING: + from django_program.conference.models import Conference + + +def _get_db_flag(conference: Conference, attr: str) -> bool | None: + """Return the DB override for *attr*, or ``None`` when absent. + + Args: + conference: The conference instance to look up flags for. + attr: The attribute name on ``FeatureFlags`` (e.g. + ``"registration_enabled"``). + + Returns: + The explicit ``True``/``False`` override, or ``None`` if there + is no ``FeatureFlags`` row or the field is not set. + """ + try: + flags = conference.feature_flags # type: ignore[union-attr] + value: bool | None = getattr(flags, attr, None) + except Exception: # noqa: BLE001 -- RelatedObjectDoesNotExist + return None + return value + -def is_feature_enabled(feature: str) -> bool: - """Check if a feature is enabled. +def is_feature_enabled(feature: str, conference: object | None = None) -> bool: + """Check if a feature is enabled, with optional per-conference DB override. + + Resolution order: + + 1. If a *conference* is provided and has a ``FeatureFlags`` row with an + explicit value for the feature, that value wins. + 2. Otherwise the default from ``DJANGO_PROGRAM["features"]`` is used. + 3. The ``all_ui_enabled`` master switch is checked first for UI + features (``public_ui``, ``manage_ui``). Args: feature: Feature name (e.g., ``"registration"``, ``"sponsors"``, ``"public_ui"``). + conference: Optional conference instance. When provided the + database ``FeatureFlags`` row is consulted for overrides. Returns: ``True`` if the feature is enabled, ``False`` otherwise. @@ -24,25 +67,50 @@ def is_feature_enabled(feature: str) -> bool: ValueError: If the feature name is not recognized. """ config = get_config().features - if not config.all_ui_enabled and feature in ("public_ui", "manage_ui"): - return False attr = f"{feature}_enabled" + if not hasattr(config, attr): msg = f"Unknown feature: {feature!r}" raise ValueError(msg) - return getattr(config, attr) + default: bool = getattr(config, attr) + + if conference is not None: + db_all_ui = _get_db_flag(conference, "all_ui_enabled") + db_value = _get_db_flag(conference, attr) + + # Master UI switch (DB override or settings fallback) + if feature in ("public_ui", "manage_ui"): + all_ui = db_all_ui if db_all_ui is not None else config.all_ui_enabled + if not all_ui: + return False -def require_feature(feature: str) -> None: + if db_value is not None: + return db_value + + # The master UI switch was already evaluated above (using DB + # override when present, settings fallback otherwise). If we + # reached this point the master switch is on, so just return + # the settings default for this specific feature. + return default + + # No conference -- settings only + if not config.all_ui_enabled and feature in ("public_ui", "manage_ui"): + return False + return default + + +def require_feature(feature: str, conference: object | None = None) -> None: """Raise :class:`~django.http.Http404` if a feature is disabled. Args: feature: Feature name to check. + conference: Optional conference for per-conference DB override. Raises: Http404: If the feature is disabled. """ - if not is_feature_enabled(feature): + if not is_feature_enabled(feature, conference=conference): raise Http404(f"Feature {feature!r} is not enabled") @@ -50,17 +118,30 @@ class FeatureRequiredMixin: """View mixin that returns 404 when a required feature is disabled. Set ``required_feature`` on the view class to the feature name. + Optionally implement ``get_conference()`` to enable per-conference + DB overrides. Example:: class TicketListView(FeatureRequiredMixin, ListView): required_feature = "registration" + + def get_conference(self): + return self.request.conference """ required_feature: str = "" + def get_conference(self) -> object | None: + """Return the conference for per-conference feature lookups. + + Override this in subclasses to provide a conference instance. + The default returns ``None`` (settings-only checks). + """ + return None + def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: """Check the feature toggle before dispatching the view.""" if self.required_feature: - require_feature(self.required_feature) + require_feature(self.required_feature, conference=self.get_conference()) return super().dispatch(request, *args, **kwargs) # type: ignore[misc] diff --git a/tests/test_features.py b/tests/test_features.py index 1a63577..7f50b12 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -1,10 +1,13 @@ """Tests for the feature toggle system.""" import pytest +from django.contrib.admin.sites import site as admin_site +from django.db import IntegrityError from django.http import Http404, HttpRequest, HttpResponse from django.test import override_settings from django.views import View +from django_program.conference.models import Conference, FeatureFlags from django_program.context_processors import program_features from django_program.features import FeatureRequiredMixin, is_feature_enabled, require_feature from django_program.settings import FeaturesConfig, get_config @@ -45,7 +48,7 @@ def test_features_config_is_frozen(self) -> None: # --------------------------------------------------------------------------- -# is_feature_enabled +# is_feature_enabled (settings only, no conference) # --------------------------------------------------------------------------- @@ -94,6 +97,94 @@ def test_public_ui_disabled_independently(self) -> None: assert is_feature_enabled("manage_ui") is True +# --------------------------------------------------------------------------- +# is_feature_enabled with conference (DB overrides) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestIsFeatureEnabledWithConference: + """Tests for DB-backed feature flag overrides.""" + + @pytest.fixture + def conference(self): + return Conference.objects.create( + name="TestConf", + slug="testconf", + start_date="2026-07-01", + end_date="2026-07-05", + ) + + def test_no_feature_flags_row_uses_settings_default(self, conference) -> None: + assert is_feature_enabled("registration", conference=conference) is True + + def test_all_none_fields_use_settings_defaults(self, conference) -> None: + FeatureFlags.objects.create(conference=conference) + for feature in ALL_MODULE_FEATURES: + assert is_feature_enabled(feature, conference=conference) is True + + def test_explicit_false_overrides_settings_true(self, conference) -> None: + FeatureFlags.objects.create(conference=conference, registration_enabled=False) + assert is_feature_enabled("registration", conference=conference) is False + + def test_explicit_true_overrides_settings_false(self, conference) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"sponsors_enabled": False}}, + ): + FeatureFlags.objects.create(conference=conference, sponsors_enabled=True) + assert is_feature_enabled("sponsors", conference=conference) is True + + @pytest.mark.parametrize("feature", ALL_MODULE_FEATURES) + def test_none_field_falls_back_to_settings(self, conference, feature) -> None: + FeatureFlags.objects.create(conference=conference) + with override_settings( + DJANGO_PROGRAM={"features": {f"{feature}_enabled": False}}, + ): + assert is_feature_enabled(feature, conference=conference) is False + + def test_db_all_ui_false_blocks_public_ui(self, conference) -> None: + FeatureFlags.objects.create(conference=conference, all_ui_enabled=False) + assert is_feature_enabled("public_ui", conference=conference) is False + + def test_db_all_ui_false_blocks_manage_ui(self, conference) -> None: + FeatureFlags.objects.create(conference=conference, all_ui_enabled=False) + assert is_feature_enabled("manage_ui", conference=conference) is False + + def test_db_all_ui_true_overrides_settings_all_ui_false(self, conference) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"all_ui_enabled": False}}, + ): + FeatureFlags.objects.create(conference=conference, all_ui_enabled=True) + assert is_feature_enabled("public_ui", conference=conference) is True + + def test_db_public_ui_false_with_all_ui_true(self, conference) -> None: + FeatureFlags.objects.create( + conference=conference, + all_ui_enabled=True, + public_ui_enabled=False, + ) + assert is_feature_enabled("public_ui", conference=conference) is False + + def test_db_all_ui_false_does_not_affect_modules(self, conference) -> None: + FeatureFlags.objects.create(conference=conference, all_ui_enabled=False) + assert is_feature_enabled("registration", conference=conference) is True + + def test_settings_all_ui_false_no_db_override_blocks_ui(self, conference) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"all_ui_enabled": False}}, + ): + FeatureFlags.objects.create(conference=conference) + assert is_feature_enabled("public_ui", conference=conference) is False + + def test_unknown_feature_raises_with_conference(self, conference) -> None: + with pytest.raises(ValueError, match="Unknown feature"): + is_feature_enabled("bogus", conference=conference) + + def test_backward_compat_without_conference(self) -> None: + assert is_feature_enabled("registration") is True + assert is_feature_enabled("registration", conference=None) is True + + # --------------------------------------------------------------------------- # require_feature # --------------------------------------------------------------------------- @@ -117,6 +208,32 @@ def test_raises_value_error_for_unknown_feature(self) -> None: require_feature("bogus") +@pytest.mark.django_db +class TestRequireFeatureWithConference: + """Tests for ``require_feature`` with conference DB overrides.""" + + @pytest.fixture + def conference(self): + return Conference.objects.create( + name="TestConf", + slug="testconf-rf", + start_date="2026-07-01", + end_date="2026-07-05", + ) + + def test_does_nothing_when_db_override_true(self, conference) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"registration_enabled": False}}, + ): + FeatureFlags.objects.create(conference=conference, registration_enabled=True) + require_feature("registration", conference=conference) + + def test_raises_http404_when_db_override_false(self, conference) -> None: + FeatureFlags.objects.create(conference=conference, registration_enabled=False) + with pytest.raises(Http404, match="registration"): + require_feature("registration", conference=conference) + + # --------------------------------------------------------------------------- # FeatureRequiredMixin # --------------------------------------------------------------------------- @@ -138,6 +255,19 @@ def get(self, request: HttpRequest) -> HttpResponse: return HttpResponse("OK") +class _ConferenceView(FeatureRequiredMixin, View): + """View that provides a conference via get_conference().""" + + required_feature = "registration" + _conference = None + + def get_conference(self): + return self._conference + + def get(self, request: HttpRequest) -> HttpResponse: + return HttpResponse("OK") + + class TestFeatureRequiredMixin: """Tests for ``FeatureRequiredMixin``.""" @@ -164,6 +294,32 @@ def test_dispatches_when_no_required_feature_set(self) -> None: response = view(self._make_request()) assert response.status_code == 200 + def test_get_conference_returns_none_by_default(self) -> None: + mixin = FeatureRequiredMixin() + assert mixin.get_conference() is None + + +@pytest.mark.django_db +class TestFeatureRequiredMixinWithConference: + """Tests for ``FeatureRequiredMixin`` with per-conference DB overrides.""" + + def _make_request(self) -> HttpRequest: + request = HttpRequest() + request.method = "GET" + return request + + def test_mixin_uses_db_override(self) -> None: + conference = Conference.objects.create( + name="TestConf", + slug="testconf-mixin", + start_date="2026-07-01", + end_date="2026-07-05", + ) + FeatureFlags.objects.create(conference=conference, registration_enabled=False) + view = _ConferenceView.as_view(_conference=conference) + with pytest.raises(Http404): + view(self._make_request()) + # --------------------------------------------------------------------------- # Context processor @@ -189,6 +345,100 @@ def test_reflects_current_settings(self) -> None: assert context["program_features"].registration_enabled is True +@pytest.mark.django_db +class TestProgramFeaturesContextProcessorWithConference: + """Tests for context processor with per-conference DB overrides.""" + + def test_returns_dict_when_conference_present(self) -> None: + conference = Conference.objects.create( + name="TestConf", + slug="testconf-ctx", + start_date="2026-07-01", + end_date="2026-07-05", + ) + FeatureFlags.objects.create(conference=conference, registration_enabled=False) + request = HttpRequest() + request.conference = conference # type: ignore[attr-defined] + context = program_features(request) + assert isinstance(context["program_features"], dict) + assert context["program_features"]["registration_enabled"] is False + assert context["program_features"]["sponsors_enabled"] is True + + def test_returns_features_config_without_conference(self) -> None: + request = HttpRequest() + context = program_features(request) + assert isinstance(context["program_features"], FeaturesConfig) + + +# --------------------------------------------------------------------------- +# FeatureFlags model +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestFeatureFlagsModel: + """Tests for the ``FeatureFlags`` model.""" + + @pytest.fixture + def conference(self): + return Conference.objects.create( + name="TestConf", + slug="testconf-model", + start_date="2026-07-01", + end_date="2026-07-05", + ) + + def test_str_representation(self, conference) -> None: + flags = FeatureFlags.objects.create(conference=conference) + assert str(flags) == f"Feature flags for {conference}" + + def test_all_fields_default_to_none(self, conference) -> None: + flags = FeatureFlags.objects.create(conference=conference) + assert flags.registration_enabled is None + assert flags.sponsors_enabled is None + assert flags.travel_grants_enabled is None + assert flags.programs_enabled is None + assert flags.pretalx_sync_enabled is None + assert flags.public_ui_enabled is None + assert flags.manage_ui_enabled is None + assert flags.all_ui_enabled is None + + def test_one_to_one_constraint(self, conference) -> None: + FeatureFlags.objects.create(conference=conference) + with pytest.raises(IntegrityError): + FeatureFlags.objects.create(conference=conference) + + def test_cascade_delete(self, conference) -> None: + FeatureFlags.objects.create(conference=conference) + conference.delete() + assert FeatureFlags.objects.count() == 0 + + def test_updated_at_auto_set(self, conference) -> None: + flags = FeatureFlags.objects.create(conference=conference) + assert flags.updated_at is not None + + def test_reverse_relation(self, conference) -> None: + flags = FeatureFlags.objects.create(conference=conference) + assert conference.feature_flags == flags + + def test_verbose_name(self) -> None: + assert FeatureFlags._meta.verbose_name == "feature flags" + assert FeatureFlags._meta.verbose_name_plural == "feature flags" + + +# --------------------------------------------------------------------------- +# Admin registration +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestFeatureFlagsAdmin: + """Tests for ``FeatureFlagsAdmin`` registration.""" + + def test_admin_registered(self) -> None: + assert FeatureFlags in admin_site._registry + + # --------------------------------------------------------------------------- # Settings integration (get_config parsing) # --------------------------------------------------------------------------- From ad3df39dfb07c60149c492022d273a64564534fc Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 20:36:34 -0600 Subject: [PATCH 5/7] fix: address PR review comments for feature toggles Co-Authored-By: Claude Opus 4.6 --- src/django_program/context_processors.py | 25 ++++++++---------------- src/django_program/features.py | 2 +- src/django_program/settings.py | 6 +++++- tests/test_features.py | 24 +++++++++++++++++------ 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/django_program/context_processors.py b/src/django_program/context_processors.py index c99ce60..19534d4 100644 --- a/src/django_program/context_processors.py +++ b/src/django_program/context_processors.py @@ -1,13 +1,8 @@ """Django context processors for django-program.""" -from typing import TYPE_CHECKING +from django.http import HttpRequest # noqa: TC002 -- used in runtime annotation (PEP 649) from django_program.features import is_feature_enabled -from django_program.settings import FeaturesConfig, get_config - -if TYPE_CHECKING: - from django.http import HttpRequest - # Feature names that correspond to FeaturesConfig boolean attributes. _FEATURE_NAMES = ( @@ -22,13 +17,13 @@ ) -def program_features(request: HttpRequest) -> dict[str, FeaturesConfig | dict[str, bool]]: - """Expose feature toggle flags to templates. +def program_features(request: HttpRequest) -> dict[str, dict[str, bool]]: + """Expose resolved feature toggle flags to templates. + Each flag is resolved through :func:`~django_program.features.is_feature_enabled` + so that master-switch overrides (e.g. ``all_ui_enabled``) are applied. When the request carries a ``conference`` attribute (set by middleware - or the view), the context processor resolves per-conference DB - overrides via :func:`~django_program.features.is_feature_enabled`. - Otherwise it returns the static ``FeaturesConfig`` from settings. + or the view), per-conference DB overrides are included in the resolution. Add ``"django_program.context_processors.program_features"`` to the ``context_processors`` list in your ``TEMPLATES`` setting. @@ -40,9 +35,5 @@ def program_features(request: HttpRequest) -> dict[str, FeaturesConfig | dict[st {% endif %} """ conference = getattr(request, "conference", None) - - if conference is not None: - resolved = {f"{name}_enabled": is_feature_enabled(name, conference=conference) for name in _FEATURE_NAMES} - return {"program_features": resolved} - - return {"program_features": get_config().features} + resolved = {f"{name}_enabled": is_feature_enabled(name, conference=conference) for name in _FEATURE_NAMES} + return {"program_features": resolved} diff --git a/src/django_program/features.py b/src/django_program/features.py index f29d51c..4537b86 100644 --- a/src/django_program/features.py +++ b/src/django_program/features.py @@ -140,7 +140,7 @@ def get_conference(self) -> object | None: """ return None - def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: """Check the feature toggle before dispatching the view.""" if self.required_feature: require_feature(self.required_feature, conference=self.get_conference()) diff --git a/src/django_program/settings.py b/src/django_program/settings.py index 5961a8e..7d1d8d4 100644 --- a/src/django_program/settings.py +++ b/src/django_program/settings.py @@ -56,7 +56,11 @@ class PSFSponsorConfig: @dataclass(frozen=True, slots=True) class FeaturesConfig: - """Feature toggles for enabling/disabling django-program modules. + """Feature toggles for enabling/disabling django-program modules and UIs. + + Module toggles control backend functionality (registration, sponsors, etc.) + while UI toggles control the public-facing and management interfaces. + The ``all_ui_enabled`` flag acts as a master switch for all UI toggles. All features are enabled by default. Set to ``False`` in ``DJANGO_PROGRAM['features']`` to disable. diff --git a/tests/test_features.py b/tests/test_features.py index 7f50b12..67954d3 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -329,11 +329,12 @@ def test_mixin_uses_db_override(self) -> None: class TestProgramFeaturesContextProcessor: """Tests for the ``program_features`` context processor.""" - def test_returns_features_config(self) -> None: + def test_returns_resolved_dict(self) -> None: request = HttpRequest() context = program_features(request) assert "program_features" in context - assert isinstance(context["program_features"], FeaturesConfig) + assert isinstance(context["program_features"], dict) + assert context["program_features"]["registration_enabled"] is True def test_reflects_current_settings(self) -> None: with override_settings( @@ -341,8 +342,18 @@ def test_reflects_current_settings(self) -> None: ): request = HttpRequest() context = program_features(request) - assert context["program_features"].sponsors_enabled is False - assert context["program_features"].registration_enabled is True + assert context["program_features"]["sponsors_enabled"] is False + assert context["program_features"]["registration_enabled"] is True + + def test_all_ui_disabled_reflected_in_context(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"all_ui_enabled": False}}, + ): + request = HttpRequest() + context = program_features(request) + assert context["program_features"]["public_ui_enabled"] is False + assert context["program_features"]["manage_ui_enabled"] is False + assert context["program_features"]["registration_enabled"] is True @pytest.mark.django_db @@ -364,10 +375,11 @@ def test_returns_dict_when_conference_present(self) -> None: assert context["program_features"]["registration_enabled"] is False assert context["program_features"]["sponsors_enabled"] is True - def test_returns_features_config_without_conference(self) -> None: + def test_returns_resolved_dict_without_conference(self) -> None: request = HttpRequest() context = program_features(request) - assert isinstance(context["program_features"], FeaturesConfig) + assert isinstance(context["program_features"], dict) + assert context["program_features"]["registration_enabled"] is True # --------------------------------------------------------------------------- From b1b42ee138bf51fe5c9f162380125d41d16841ef Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 21:32:05 -0600 Subject: [PATCH 6/7] fix: enforce feature toggles on public views, add inline admin and auto-creation - FeatureRequiredMixin now supports tuple of features (all must be enabled) - Applied mixin to all public views: pretalx (public_ui), sponsors (sponsors+public_ui), registration (registration+public_ui), programs (programs/travel_grants+public_ui) - FeatureFlags inline on ConferenceAdmin (like Sections), standalone admin kept - Auto-create FeatureFlags via post_save signal when Conference is created - Admin widget labels show "Default (enabled)" instead of "Unknown" - Narrowed blanket Exception catch to ObjectDoesNotExist in _get_db_flag Co-Authored-By: Claude Opus 4.6 --- src/django_program/conference/admin.py | 74 +++++++++- src/django_program/conference/apps.py | 4 + src/django_program/conference/signals.py | 19 +++ src/django_program/features.py | 40 +++--- src/django_program/pretalx/views.py | 17 ++- src/django_program/programs/views.py | 53 +++++--- src/django_program/registration/views.py | 16 ++- src/django_program/sponsors/views.py | 7 +- tests/test_features.py | 165 ++++++++++++++++++----- 9 files changed, 314 insertions(+), 81 deletions(-) create mode 100644 src/django_program/conference/signals.py diff --git a/src/django_program/conference/admin.py b/src/django_program/conference/admin.py index f5f39be..f6d0cb3 100644 --- a/src/django_program/conference/admin.py +++ b/src/django_program/conference/admin.py @@ -86,13 +86,75 @@ class SectionInline(admin.TabularInline): fields = ("name", "slug", "start_date", "end_date", "order") +class FeatureFlagsForm(forms.ModelForm): + """Form for FeatureFlags that replaces 'Unknown' with 'Default (enabled)'. + + Each nullable boolean field defaults to ``None`` which means "use the + value from ``DJANGO_PROGRAM['features']`` in settings". Since the + out-of-the-box default for every feature is ``True``, the widget + label reads "Default (enabled)" instead of Django's generic "Unknown". + """ + + class Meta: + model = FeatureFlags + exclude: list[str] = [] + + def __init__(self, *args: object, **kwargs: object) -> None: + """Replace 'Unknown' widget labels with 'Default (enabled)'.""" + super().__init__(*args, **kwargs) + for field in self.fields.values(): + if isinstance(field.widget, forms.NullBooleanSelect): + field.widget.choices = [ + ("unknown", "Default (enabled)"), + ("true", "Yes — force ON"), + ("false", "No — force OFF"), + ] + + +class FeatureFlagsInline(admin.StackedInline): + """Inline editor for per-conference feature flag overrides. + + Allows toggling individual features directly from the conference + admin change form. At most one ``FeatureFlags`` row per conference. + """ + + model = FeatureFlags + form = FeatureFlagsForm + extra = 0 + max_num = 1 + fieldsets = ( + ( + "Module Toggles", + { + "fields": ( + "registration_enabled", + "sponsors_enabled", + "travel_grants_enabled", + "programs_enabled", + "pretalx_sync_enabled", + ), + }, + ), + ( + "UI Toggles", + { + "fields": ( + "public_ui_enabled", + "manage_ui_enabled", + "all_ui_enabled", + ), + }, + ), + ) + + @admin.register(Conference) class ConferenceAdmin(admin.ModelAdmin): """Admin interface for managing conferences. Groups fields into logical fieldsets: basic information, dates, third-party integrations (Pretalx and Stripe), and status metadata. - Sections are editable inline via ``SectionInline``. + Sections and feature flags are editable inline. """ form = ConferenceForm @@ -100,7 +162,7 @@ class ConferenceAdmin(admin.ModelAdmin): list_filter = ("is_active",) search_fields = ("name", "slug") prepopulated_fields = {"slug": ("name",)} - inlines = (SectionInline,) + inlines = (SectionInline, FeatureFlagsInline) fieldsets = ( ( @@ -152,13 +214,13 @@ class SectionAdmin(admin.ModelAdmin): @admin.register(FeatureFlags) class FeatureFlagsAdmin(admin.ModelAdmin): - """Admin interface for per-conference feature flag overrides. + """Standalone admin for per-conference feature flag overrides. - Allows toggling individual features on or off per conference. - Leaving a field blank (``None``) uses the default from - ``DJANGO_PROGRAM["features"]`` in settings. + Provides a list view for quick scanning across conferences. + The same data is also editable inline on the Conference admin page. """ + form = FeatureFlagsForm list_display = ( "conference", "registration_enabled", diff --git a/src/django_program/conference/apps.py b/src/django_program/conference/apps.py index c758ffe..8e7ee53 100644 --- a/src/django_program/conference/apps.py +++ b/src/django_program/conference/apps.py @@ -10,3 +10,7 @@ class DjangoProgramConferenceConfig(AppConfig): name = "django_program.conference" label = "program_conference" verbose_name = "Conference" + + def ready(self) -> None: + """Import signal handlers.""" + import django_program.conference.signals # noqa: F401, PLC0415 diff --git a/src/django_program/conference/signals.py b/src/django_program/conference/signals.py new file mode 100644 index 0000000..37361cf --- /dev/null +++ b/src/django_program/conference/signals.py @@ -0,0 +1,19 @@ +"""Signals for the conference app.""" + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from django_program.conference.models import Conference, FeatureFlags + + +@receiver(post_save, sender=Conference) +def create_feature_flags( + sender: type[Conference], # noqa: ARG001 + instance: Conference, + *, + created: bool, + **kwargs: object, # noqa: ARG001 +) -> None: + """Auto-create a FeatureFlags row when a new Conference is saved.""" + if created: + FeatureFlags.objects.get_or_create(conference=instance) diff --git a/src/django_program/features.py b/src/django_program/features.py index 4537b86..0f09b97 100644 --- a/src/django_program/features.py +++ b/src/django_program/features.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING +from django.core.exceptions import ObjectDoesNotExist from django.http import Http404, HttpRequest, HttpResponse from django_program.settings import get_config @@ -37,10 +38,9 @@ def _get_db_flag(conference: Conference, attr: str) -> bool | None: """ try: flags = conference.feature_flags # type: ignore[union-attr] - value: bool | None = getattr(flags, attr, None) - except Exception: # noqa: BLE001 -- RelatedObjectDoesNotExist + except ObjectDoesNotExist: return None - return value + return getattr(flags, attr, None) def is_feature_enabled(feature: str, conference: object | None = None) -> bool: @@ -117,31 +117,35 @@ def require_feature(feature: str, conference: object | None = None) -> None: class FeatureRequiredMixin: """View mixin that returns 404 when a required feature is disabled. - Set ``required_feature`` on the view class to the feature name. - Optionally implement ``get_conference()`` to enable per-conference - DB overrides. + Set ``required_feature`` on the view class to the feature name or a + tuple of feature names (all must be enabled). When used alongside + ``ConferenceMixin`` (placed *before* this mixin in the MRO), the + already-resolved ``self.conference`` is picked up automatically for + per-conference DB overrides. Example:: - class TicketListView(FeatureRequiredMixin, ListView): - required_feature = "registration" - - def get_conference(self): - return self.request.conference + class TicketListView(ConferenceMixin, FeatureRequiredMixin, ListView): + required_feature = ("registration", "public_ui") """ - required_feature: str = "" + required_feature: str | tuple[str, ...] = "" def get_conference(self) -> object | None: """Return the conference for per-conference feature lookups. - Override this in subclasses to provide a conference instance. - The default returns ``None`` (settings-only checks). + When ``ConferenceMixin`` runs before this mixin it sets + ``self.conference``; the default implementation returns that + attribute if present, falling back to ``None``. """ - return None + return getattr(self, "conference", None) def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: - """Check the feature toggle before dispatching the view.""" - if self.required_feature: - require_feature(self.required_feature, conference=self.get_conference()) + """Check the feature toggle(s) before dispatching the view.""" + features = self.required_feature + if isinstance(features, str): + features = (features,) if features else () + conference = self.get_conference() + for feature in features: + require_feature(feature, conference=conference) return super().dispatch(request, *args, **kwargs) # type: ignore[misc] diff --git a/src/django_program/pretalx/views.py b/src/django_program/pretalx/views.py index 3735326..607b595 100644 --- a/src/django_program/pretalx/views.py +++ b/src/django_program/pretalx/views.py @@ -16,6 +16,7 @@ from django.views.generic import DetailView, ListView, TemplateView from django_program.conference.models import Conference +from django_program.features import FeatureRequiredMixin from django_program.pretalx.models import ScheduleSlot, Speaker, Talk if TYPE_CHECKING: @@ -73,13 +74,14 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo return super().dispatch(request, *args, **kwargs) # type: ignore[misc] -class ScheduleView(ConferenceMixin, TemplateView): +class ScheduleView(ConferenceMixin, FeatureRequiredMixin, TemplateView): """Full schedule view grouped by day. Renders the conference schedule with slots organized by date. Each day is a ``(date, list[ScheduleSlot])`` tuple ordered by start time. """ + required_feature = "public_ui" template_name = "django_program/pretalx/schedule.html" def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -110,7 +112,7 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: return context -class ScheduleJSONView(ConferenceMixin, View): +class ScheduleJSONView(ConferenceMixin, FeatureRequiredMixin, View): """JSON endpoint for schedule data. Returns a JSON array of schedule slots suitable for embedding in @@ -118,6 +120,8 @@ class ScheduleJSONView(ConferenceMixin, View): times, slot type, and the linked talk code when available. """ + required_feature = "public_ui" + def get(self, _request: HttpRequest, **_kwargs: str) -> JsonResponse: """Return schedule slots as a JSON array. @@ -151,13 +155,14 @@ def get(self, _request: HttpRequest, **_kwargs: str) -> JsonResponse: return JsonResponse(data, safe=False) -class TalkDetailView(ConferenceMixin, DetailView): +class TalkDetailView(ConferenceMixin, FeatureRequiredMixin, DetailView): """Detail view for a single talk. Looks up the talk by its Pretalx code within the conference scope. Prefetches the speakers relation for display. """ + required_feature = "public_ui" template_name = "django_program/pretalx/talk_detail.html" context_object_name = "talk" @@ -187,9 +192,10 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: return context -class SpeakerListView(ConferenceMixin, ListView): +class SpeakerListView(ConferenceMixin, FeatureRequiredMixin, ListView): """List view of all speakers for a conference, ordered by name.""" + required_feature = "public_ui" template_name = "django_program/pretalx/speaker_list.html" context_object_name = "speakers" @@ -202,13 +208,14 @@ def get_queryset(self) -> QuerySet[Speaker]: return Speaker.objects.filter(conference=self.conference).order_by("name") -class SpeakerDetailView(ConferenceMixin, DetailView): +class SpeakerDetailView(ConferenceMixin, FeatureRequiredMixin, DetailView): """Detail view for a single speaker. Looks up the speaker by their Pretalx code within the conference scope. Prefetches talks for display. """ + required_feature = "public_ui" template_name = "django_program/pretalx/speaker_detail.html" context_object_name = "speaker" diff --git a/src/django_program/programs/views.py b/src/django_program/programs/views.py index ba4b941..86603cb 100644 --- a/src/django_program/programs/views.py +++ b/src/django_program/programs/views.py @@ -19,6 +19,7 @@ from django.views import View from django.views.generic import DetailView, ListView +from django_program.features import FeatureRequiredMixin from django_program.pretalx.views import ConferenceMixin from django_program.programs.forms import ( PaymentInfoForm, @@ -44,9 +45,10 @@ from django_program.pretalx.models import Talk -class ActivityListView(ConferenceMixin, ListView): +class ActivityListView(ConferenceMixin, FeatureRequiredMixin, ListView): """List view of all active activities for a conference.""" + required_feature = ("programs", "public_ui") template_name = "django_program/programs/activity_list.html" context_object_name = "activities" @@ -91,9 +93,10 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: return context -class ActivityDetailView(ConferenceMixin, DetailView): +class ActivityDetailView(ConferenceMixin, FeatureRequiredMixin, DetailView): """Detail view for a single activity with linked talks.""" + required_feature = ("programs", "public_ui") template_name = "django_program/programs/activity_detail.html" context_object_name = "activity" @@ -166,9 +169,11 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: return context -class ActivitySignupView(LoginRequiredMixin, ConferenceMixin, View): +class ActivitySignupView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """POST-only view for signing up to an activity.""" + required_feature = ("programs", "public_ui") + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Handle the signup form submission. @@ -218,9 +223,11 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR return redirect(detail_url) -class ActivityCancelSignupView(LoginRequiredMixin, ConferenceMixin, View): +class ActivityCancelSignupView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """POST-only view for cancelling an activity signup.""" + required_feature = ("programs", "public_ui") + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Cancel the user's signup and promote the next waitlisted person if applicable.""" with transaction.atomic(): @@ -251,13 +258,14 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR return redirect(reverse("programs:activity-detail", args=[self.conference.slug, activity.slug])) -class TravelGrantApplyView(LoginRequiredMixin, ConferenceMixin, View): +class TravelGrantApplyView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """View for applying for a travel grant. Uses ``TravelGrantApplicationForm`` for server-side validation of the requested amount, travel origin, and reason fields. """ + required_feature = ("travel_grants", "public_ui") template_name = "django_program/programs/travel_grant_form.html" def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 @@ -301,13 +309,14 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR return redirect(reverse("programs:travel-grant-status", args=[self.conference.slug])) -class TravelGrantStatusView(LoginRequiredMixin, ConferenceMixin, View): +class TravelGrantStatusView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """View for checking travel grant application status. Shows current grant status, action buttons based on state, visible messages from reviewers, and a message form. """ + required_feature = ("travel_grants", "public_ui") template_name = "django_program/programs/travel_grant_status.html" def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 @@ -342,9 +351,11 @@ def _get_user_grant(request: HttpRequest, conference: object) -> TravelGrant: return grant -class TravelGrantAcceptView(LoginRequiredMixin, ConferenceMixin, View): +class TravelGrantAcceptView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """POST-only view to accept an offered travel grant.""" + required_feature = ("travel_grants", "public_ui") + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Accept the offered grant.""" grant = _get_user_grant(request, self.conference) @@ -363,9 +374,11 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR return redirect(reverse("programs:travel-grant-status", args=[self.conference.slug])) -class TravelGrantDeclineView(LoginRequiredMixin, ConferenceMixin, View): +class TravelGrantDeclineView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """POST-only view to decline an offered travel grant.""" + required_feature = ("travel_grants", "public_ui") + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Decline the offered grant.""" grant = _get_user_grant(request, self.conference) @@ -384,9 +397,11 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR return redirect(reverse("programs:travel-grant-status", args=[self.conference.slug])) -class TravelGrantWithdrawView(LoginRequiredMixin, ConferenceMixin, View): +class TravelGrantWithdrawView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """POST-only view to withdraw a travel grant application.""" + required_feature = ("travel_grants", "public_ui") + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Withdraw the application.""" grant = _get_user_grant(request, self.conference) @@ -405,9 +420,10 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR return redirect(reverse("programs:travel-grant-status", args=[self.conference.slug])) -class TravelGrantEditView(LoginRequiredMixin, ConferenceMixin, View): +class TravelGrantEditView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """View for editing an existing travel grant application.""" + required_feature = ("travel_grants", "public_ui") template_name = "django_program/programs/travel_grant_form.html" def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 @@ -433,9 +449,10 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR return redirect(reverse("programs:travel-grant-status", args=[self.conference.slug])) -class TravelGrantProvideInfoView(LoginRequiredMixin, ConferenceMixin, View): +class TravelGrantProvideInfoView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """View for applicants to provide information requested by reviewers.""" + required_feature = ("travel_grants", "public_ui") template_name = "django_program/programs/travel_grant_provide_info.html" def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 @@ -467,9 +484,11 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR return redirect(reverse("programs:travel-grant-status", args=[self.conference.slug])) -class TravelGrantMessageView(LoginRequiredMixin, ConferenceMixin, View): +class TravelGrantMessageView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """POST-only view for sending a message on an existing grant.""" + required_feature = ("travel_grants", "public_ui") + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Create a visible message from the applicant.""" grant = _get_user_grant(request, self.conference) @@ -484,9 +503,10 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR return redirect(reverse("programs:travel-grant-status", args=[self.conference.slug])) -class ReceiptUploadView(LoginRequiredMixin, ConferenceMixin, View): +class ReceiptUploadView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """View for uploading and listing expense receipts.""" + required_feature = ("travel_grants", "public_ui") template_name = "django_program/programs/travel_grant_receipts.html" def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 @@ -536,9 +556,11 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR ) -class ReceiptDeleteView(LoginRequiredMixin, ConferenceMixin, View): +class ReceiptDeleteView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """POST-only view for deleting a receipt.""" + required_feature = ("travel_grants", "public_ui") + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Delete the receipt if it has not been approved or flagged.""" grant = _get_user_grant(request, self.conference) @@ -552,9 +574,10 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: return redirect(reverse("programs:travel-grant-receipts", args=[self.conference.slug])) -class PaymentInfoView(LoginRequiredMixin, ConferenceMixin, View): +class PaymentInfoView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """View for submitting or editing payment information.""" + required_feature = ("travel_grants", "public_ui") template_name = "django_program/programs/travel_grant_payment_info.html" def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 diff --git a/src/django_program/registration/views.py b/src/django_program/registration/views.py index c115229..2195855 100644 --- a/src/django_program/registration/views.py +++ b/src/django_program/registration/views.py @@ -23,6 +23,7 @@ from django.views import View from django.views.generic import DetailView, ListView +from django_program.features import FeatureRequiredMixin from django_program.pretalx.views import ConferenceMixin from django_program.registration.forms import CartItemForm, CheckoutForm, VoucherApplyForm from django_program.registration.models import ( @@ -97,13 +98,14 @@ def _cart_totals(cart: Cart) -> tuple[Decimal, Decimal, Decimal]: return subtotal, discount, total -class TicketSelectView(ConferenceMixin, ListView): +class TicketSelectView(ConferenceMixin, FeatureRequiredMixin, ListView): """Lists available ticket types for a conference. Shows ticket types that are active and do not require a voucher, ordered by display order and name. """ + required_feature = ("registration", "public_ui") template_name = "django_program/registration/ticket_select.html" context_object_name = "ticket_types" @@ -126,13 +128,14 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: return context -class CartView(LoginRequiredMixin, ConferenceMixin, View): +class CartView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """Shopping cart view for adding/removing items and applying vouchers. Handles multiple POST actions distinguished by a hidden ``action`` field: ``add_item``, ``remove_item``, and ``apply_voucher``. """ + required_feature = ("registration", "public_ui") template_name = "django_program/registration/cart.html" def _get_or_create_cart(self, request: HttpRequest) -> Cart: @@ -482,7 +485,7 @@ def _handle_remove_voucher(self, request: HttpRequest, cart: Cart) -> HttpRespon return redirect(reverse("registration:cart", args=[self.conference.slug])) -class CheckoutView(LoginRequiredMixin, ConferenceMixin, View): +class CheckoutView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """Checkout view for creating an order from the current cart. Collects billing information, creates Order and OrderLineItem records @@ -490,6 +493,7 @@ class CheckoutView(LoginRequiredMixin, ConferenceMixin, View): to the order confirmation page. """ + required_feature = ("registration", "public_ui") template_name = "django_program/registration/checkout.html" def _get_open_cart(self, request: HttpRequest) -> Cart | None: @@ -661,13 +665,14 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR return redirect(reverse("registration:order-confirmation", args=[self.conference.slug, order.reference])) -class OrderConfirmationView(LoginRequiredMixin, ConferenceMixin, DetailView): +class OrderConfirmationView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, DetailView): """Confirmation page shown immediately after checkout. Displays the order summary and line items for the just-completed checkout. """ + required_feature = ("registration", "public_ui") template_name = "django_program/registration/order_confirmation.html" context_object_name = "order" @@ -702,12 +707,13 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: return context -class OrderDetailView(LoginRequiredMixin, ConferenceMixin, DetailView): +class OrderDetailView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, DetailView): """Detail view for any order owned by the current user. Displays order information, line items, and payment history. """ + required_feature = ("registration", "public_ui") template_name = "django_program/registration/order_detail.html" context_object_name = "order" diff --git a/src/django_program/sponsors/views.py b/src/django_program/sponsors/views.py index 9ecbd8a..0827e1d 100644 --- a/src/django_program/sponsors/views.py +++ b/src/django_program/sponsors/views.py @@ -9,6 +9,7 @@ from django.shortcuts import get_object_or_404 from django.views.generic import DetailView, ListView +from django_program.features import FeatureRequiredMixin from django_program.pretalx.views import ConferenceMixin from django_program.sponsors.models import Sponsor, SponsorLevel @@ -16,9 +17,10 @@ from django.db.models import QuerySet -class SponsorListView(ConferenceMixin, ListView): +class SponsorListView(ConferenceMixin, FeatureRequiredMixin, ListView): """List view of all active sponsors for a conference, grouped by level.""" + required_feature = ("sponsors", "public_ui") template_name = "django_program/sponsors/sponsor_list.html" context_object_name = "sponsors" @@ -49,9 +51,10 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: return context -class SponsorDetailView(ConferenceMixin, DetailView): +class SponsorDetailView(ConferenceMixin, FeatureRequiredMixin, DetailView): """Detail view for a single sponsor.""" + required_feature = ("sponsors", "public_ui") template_name = "django_program/sponsors/sponsor_detail.html" context_object_name = "sponsor" diff --git a/tests/test_features.py b/tests/test_features.py index 67954d3..4492d19 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -115,65 +115,70 @@ def conference(self): end_date="2026-07-05", ) - def test_no_feature_flags_row_uses_settings_default(self, conference) -> None: - assert is_feature_enabled("registration", conference=conference) is True - def test_all_none_fields_use_settings_defaults(self, conference) -> None: - FeatureFlags.objects.create(conference=conference) for feature in ALL_MODULE_FEATURES: assert is_feature_enabled(feature, conference=conference) is True def test_explicit_false_overrides_settings_true(self, conference) -> None: - FeatureFlags.objects.create(conference=conference, registration_enabled=False) + flags = conference.feature_flags + flags.registration_enabled = False + flags.save() assert is_feature_enabled("registration", conference=conference) is False def test_explicit_true_overrides_settings_false(self, conference) -> None: with override_settings( DJANGO_PROGRAM={"features": {"sponsors_enabled": False}}, ): - FeatureFlags.objects.create(conference=conference, sponsors_enabled=True) + flags = conference.feature_flags + flags.sponsors_enabled = True + flags.save() assert is_feature_enabled("sponsors", conference=conference) is True @pytest.mark.parametrize("feature", ALL_MODULE_FEATURES) def test_none_field_falls_back_to_settings(self, conference, feature) -> None: - FeatureFlags.objects.create(conference=conference) with override_settings( DJANGO_PROGRAM={"features": {f"{feature}_enabled": False}}, ): assert is_feature_enabled(feature, conference=conference) is False def test_db_all_ui_false_blocks_public_ui(self, conference) -> None: - FeatureFlags.objects.create(conference=conference, all_ui_enabled=False) + flags = conference.feature_flags + flags.all_ui_enabled = False + flags.save() assert is_feature_enabled("public_ui", conference=conference) is False def test_db_all_ui_false_blocks_manage_ui(self, conference) -> None: - FeatureFlags.objects.create(conference=conference, all_ui_enabled=False) + flags = conference.feature_flags + flags.all_ui_enabled = False + flags.save() assert is_feature_enabled("manage_ui", conference=conference) is False def test_db_all_ui_true_overrides_settings_all_ui_false(self, conference) -> None: with override_settings( DJANGO_PROGRAM={"features": {"all_ui_enabled": False}}, ): - FeatureFlags.objects.create(conference=conference, all_ui_enabled=True) + flags = conference.feature_flags + flags.all_ui_enabled = True + flags.save() assert is_feature_enabled("public_ui", conference=conference) is True def test_db_public_ui_false_with_all_ui_true(self, conference) -> None: - FeatureFlags.objects.create( - conference=conference, - all_ui_enabled=True, - public_ui_enabled=False, - ) + flags = conference.feature_flags + flags.all_ui_enabled = True + flags.public_ui_enabled = False + flags.save() assert is_feature_enabled("public_ui", conference=conference) is False def test_db_all_ui_false_does_not_affect_modules(self, conference) -> None: - FeatureFlags.objects.create(conference=conference, all_ui_enabled=False) + flags = conference.feature_flags + flags.all_ui_enabled = False + flags.save() assert is_feature_enabled("registration", conference=conference) is True def test_settings_all_ui_false_no_db_override_blocks_ui(self, conference) -> None: with override_settings( DJANGO_PROGRAM={"features": {"all_ui_enabled": False}}, ): - FeatureFlags.objects.create(conference=conference) assert is_feature_enabled("public_ui", conference=conference) is False def test_unknown_feature_raises_with_conference(self, conference) -> None: @@ -225,11 +230,15 @@ def test_does_nothing_when_db_override_true(self, conference) -> None: with override_settings( DJANGO_PROGRAM={"features": {"registration_enabled": False}}, ): - FeatureFlags.objects.create(conference=conference, registration_enabled=True) + flags = conference.feature_flags + flags.registration_enabled = True + flags.save() require_feature("registration", conference=conference) def test_raises_http404_when_db_override_false(self, conference) -> None: - FeatureFlags.objects.create(conference=conference, registration_enabled=False) + flags = conference.feature_flags + flags.registration_enabled = False + flags.save() with pytest.raises(Http404, match="registration"): require_feature("registration", conference=conference) @@ -255,6 +264,15 @@ def get(self, request: HttpRequest) -> HttpResponse: return HttpResponse("OK") +class _MultiFeatureView(FeatureRequiredMixin, View): + """View requiring multiple features.""" + + required_feature = ("registration", "public_ui") + + def get(self, request: HttpRequest) -> HttpResponse: + return HttpResponse("OK") + + class _ConferenceView(FeatureRequiredMixin, View): """View that provides a conference via get_conference().""" @@ -298,6 +316,36 @@ def test_get_conference_returns_none_by_default(self) -> None: mixin = FeatureRequiredMixin() assert mixin.get_conference() is None + def test_get_conference_returns_self_conference_attr(self) -> None: + mixin = FeatureRequiredMixin() + sentinel = object() + mixin.conference = sentinel # type: ignore[attr-defined] + assert mixin.get_conference() is sentinel + + def test_multi_feature_dispatches_when_all_enabled(self) -> None: + view = _MultiFeatureView.as_view() + response = view(self._make_request()) + assert response.status_code == 200 + + def test_multi_feature_returns_404_when_first_disabled(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"registration_enabled": False}}, + ): + view = _MultiFeatureView.as_view() + with pytest.raises(Http404): + view(self._make_request()) + + def test_multi_feature_returns_404_when_second_disabled(self) -> None: + with override_settings( + DJANGO_PROGRAM={"features": {"public_ui_enabled": False}}, + ): + view = _MultiFeatureView.as_view() + with pytest.raises(Http404): + view(self._make_request()) + + def test_required_feature_accepts_tuple(self) -> None: + assert _MultiFeatureView.required_feature == ("registration", "public_ui") + @pytest.mark.django_db class TestFeatureRequiredMixinWithConference: @@ -315,7 +363,9 @@ def test_mixin_uses_db_override(self) -> None: start_date="2026-07-01", end_date="2026-07-05", ) - FeatureFlags.objects.create(conference=conference, registration_enabled=False) + flags = conference.feature_flags + flags.registration_enabled = False + flags.save() view = _ConferenceView.as_view(_conference=conference) with pytest.raises(Http404): view(self._make_request()) @@ -367,7 +417,9 @@ def test_returns_dict_when_conference_present(self) -> None: start_date="2026-07-01", end_date="2026-07-05", ) - FeatureFlags.objects.create(conference=conference, registration_enabled=False) + flags = conference.feature_flags + flags.registration_enabled = False + flags.save() request = HttpRequest() request.conference = conference # type: ignore[attr-defined] context = program_features(request) @@ -401,11 +453,11 @@ def conference(self): ) def test_str_representation(self, conference) -> None: - flags = FeatureFlags.objects.create(conference=conference) + flags = conference.feature_flags assert str(flags) == f"Feature flags for {conference}" def test_all_fields_default_to_none(self, conference) -> None: - flags = FeatureFlags.objects.create(conference=conference) + flags = conference.feature_flags assert flags.registration_enabled is None assert flags.sponsors_enabled is None assert flags.travel_grants_enabled is None @@ -416,21 +468,21 @@ def test_all_fields_default_to_none(self, conference) -> None: assert flags.all_ui_enabled is None def test_one_to_one_constraint(self, conference) -> None: - FeatureFlags.objects.create(conference=conference) + assert FeatureFlags.objects.filter(conference=conference).exists() with pytest.raises(IntegrityError): FeatureFlags.objects.create(conference=conference) def test_cascade_delete(self, conference) -> None: - FeatureFlags.objects.create(conference=conference) + assert FeatureFlags.objects.filter(conference=conference).exists() conference.delete() assert FeatureFlags.objects.count() == 0 def test_updated_at_auto_set(self, conference) -> None: - flags = FeatureFlags.objects.create(conference=conference) + flags = conference.feature_flags assert flags.updated_at is not None def test_reverse_relation(self, conference) -> None: - flags = FeatureFlags.objects.create(conference=conference) + flags = FeatureFlags.objects.get(conference=conference) assert conference.feature_flags == flags def test_verbose_name(self) -> None: @@ -439,17 +491,70 @@ def test_verbose_name(self) -> None: # --------------------------------------------------------------------------- -# Admin registration +# Admin inline # --------------------------------------------------------------------------- @pytest.mark.django_db class TestFeatureFlagsAdmin: - """Tests for ``FeatureFlagsAdmin`` registration.""" + """Tests for ``FeatureFlagsInline`` on ``ConferenceAdmin``.""" - def test_admin_registered(self) -> None: + def test_registered_standalone(self) -> None: assert FeatureFlags in admin_site._registry + def test_inline_on_conference_admin(self) -> None: + from django_program.conference.admin import ConferenceAdmin # noqa: PLC0415 + + inline_classes = [i.model for i in ConferenceAdmin.inlines] + assert FeatureFlags in inline_classes + + def test_inline_max_num_is_one(self) -> None: + from django_program.conference.admin import FeatureFlagsInline # noqa: PLC0415 + + assert FeatureFlagsInline.max_num == 1 + + +# --------------------------------------------------------------------------- +# Auto-creation signal +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestFeatureFlagsAutoCreation: + """Tests for automatic FeatureFlags creation on Conference save.""" + + def test_feature_flags_created_with_conference(self) -> None: + conference = Conference.objects.create( + name="NewConf", + slug="newconf", + start_date="2026-07-01", + end_date="2026-07-05", + ) + assert FeatureFlags.objects.filter(conference=conference).exists() + + def test_feature_flags_all_none_on_creation(self) -> None: + conference = Conference.objects.create( + name="NoneConf", + slug="noneconf", + start_date="2026-07-01", + end_date="2026-07-05", + ) + flags = conference.feature_flags + assert flags.registration_enabled is None + assert flags.sponsors_enabled is None + assert flags.public_ui_enabled is None + + def test_update_does_not_create_duplicate(self) -> None: + conference = Conference.objects.create( + name="DupConf", + slug="dupconf", + start_date="2026-07-01", + end_date="2026-07-05", + ) + conference.name = "DupConf Updated" + conference.save() + assert FeatureFlags.objects.filter(conference=conference).count() == 1 + # --------------------------------------------------------------------------- # Settings integration (get_config parsing) From 48628c9e3ebc933388883eaa61fae1263d90f83d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 22:04:22 -0600 Subject: [PATCH 7/7] test: cover ObjectDoesNotExist branch in _get_db_flag Co-Authored-By: Claude Opus 4.6 --- tests/test_features.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_features.py b/tests/test_features.py index 4492d19..d1cfb88 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -181,6 +181,17 @@ def test_settings_all_ui_false_no_db_override_blocks_ui(self, conference) -> Non ): assert is_feature_enabled("public_ui", conference=conference) is False + def test_falls_back_to_settings_when_feature_flags_row_missing(self, conference) -> None: + FeatureFlags.objects.filter(conference=conference).delete() + # Refresh to clear cached reverse relation + conference.refresh_from_db() + assert is_feature_enabled("registration", conference=conference) is True + + def test_db_override_absent_falls_back_for_ui_feature(self, conference) -> None: + FeatureFlags.objects.filter(conference=conference).delete() + conference.refresh_from_db() + assert is_feature_enabled("public_ui", conference=conference) is True + def test_unknown_feature_raises_with_conference(self, conference) -> None: with pytest.raises(ValueError, match="Unknown feature"): is_feature_enabled("bogus", conference=conference)