Source code for django_program.features

"""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.

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.core.exceptions import ObjectDoesNotExist
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]
    except ObjectDoesNotExist:
        return None
    return getattr(flags, attr, None)


[docs] 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. Raises: ValueError: If the feature name is not recognized. """ config = get_config().features attr = f"{feature}_enabled" if not hasattr(config, attr): msg = f"Unknown feature: {feature!r}" raise ValueError(msg) 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 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
[docs] 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, conference=conference): raise Http404(f"Feature {feature!r} is not enabled")
[docs] class FeatureRequiredMixin: """View mixin that returns 404 when a required feature is disabled. 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(ConferenceMixin, FeatureRequiredMixin, ListView): required_feature = ("registration", "public_ui") """ required_feature: str | tuple[str, ...] = ""
[docs] def get_conference(self) -> object | None: """Return the conference for per-conference feature lookups. When ``ConferenceMixin`` runs before this mixin it sets ``self.conference``; the default implementation returns that attribute if present, falling back to ``None``. """ return getattr(self, "conference", None)
[docs] def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: """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]