Skip to content
1 change: 1 addition & 0 deletions examples/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
},
},
Expand Down
114 changes: 111 additions & 3 deletions src/django_program/conference/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -86,21 +86,83 @@ 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
list_display = ("name", "slug", "start_date", "end_date", "is_active")
list_filter = ("is_active",)
search_fields = ("name", "slug")
prepopulated_fields = {"slug": ("name",)}
inlines = (SectionInline,)
inlines = (SectionInline, FeatureFlagsInline)

fieldsets = (
(
Expand Down Expand Up @@ -148,3 +210,49 @@ class SectionAdmin(admin.ModelAdmin):
list_filter = ("conference",)
search_fields = ("name", "slug")
prepopulated_fields = {"slug": ("name",)}


@admin.register(FeatureFlags)
class FeatureFlagsAdmin(admin.ModelAdmin):
"""Standalone admin for per-conference feature flag overrides.

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",
"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",
),
},
),
)
4 changes: 4 additions & 0 deletions src/django_program/conference/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
62 changes: 62 additions & 0 deletions src/django_program/conference/migrations/0004_add_feature_flags.py
Original file line number Diff line number Diff line change
@@ -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",
},
),
]
66 changes: 66 additions & 0 deletions src/django_program/conference/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
19 changes: 19 additions & 0 deletions src/django_program/conference/signals.py
Original file line number Diff line number Diff line change
@@ -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)
39 changes: 39 additions & 0 deletions src/django_program/context_processors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Django context processors for django-program."""

from django.http import HttpRequest # noqa: TC002 -- used in runtime annotation (PEP 649)

from django_program.features import is_feature_enabled

# 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, 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), 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.

Usage in templates::

{% if program_features.registration_enabled %}
<a href="{% url 'registration:ticket-list' %}">Registration</a>
{% endif %}
"""
conference = getattr(request, "conference", None)
resolved = {f"{name}_enabled": is_feature_enabled(name, conference=conference) for name in _FEATURE_NAMES}
return {"program_features": resolved}
Loading
Loading