From 697f1763e68a3ad66be604583753940cfb4d76a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:13:06 +0000 Subject: [PATCH 1/6] Add FeatureFlags module for admin-managed runtime overrides Persists per-flag overrides in DB on top of the framework's in-memory FeatureFlagRegistry. Service mirrors DB rows to the registry at boot (on_startup hydration) and on every mutation, so is_enabled() stays a plain in-process lookup with no per-request DB hit. REST API + Inertia admin page lock behind feature_flags.view / feature_flags.manage. Stale overrides (for flags no module registers anymore) are hidden from list_flags() but kept in the DB so reintroducing the flag picks them up. --- ...8bf117_add_feature_flags_override_table.py | 59 ++++++ host/pyproject.toml | 2 + .../feature_flags/feature_flags/__init__.py | 1 + .../feature_flags/feature_flags/constants.py | 21 ++ .../feature_flags/contracts/__init__.py | 15 ++ .../feature_flags/contracts/schemas.py | 41 ++++ .../feature_flags/contracts/service.py | 19 ++ modules/feature_flags/feature_flags/deps.py | 27 +++ .../feature_flags/endpoints/__init__.py | 0 .../feature_flags/endpoints/api.py | 77 +++++++ .../feature_flags/endpoints/views.py | 69 +++++++ .../feature_flags/locales/en.json | 28 +++ modules/feature_flags/feature_flags/models.py | 29 +++ modules/feature_flags/feature_flags/module.py | 74 +++++++ .../feature_flags/pages/Browse.tsx | 192 ++++++++++++++++++ modules/feature_flags/feature_flags/py.typed | 0 .../feature_flags/feature_flags/service.py | 99 +++++++++ modules/feature_flags/package.json | 16 ++ modules/feature_flags/pyproject.toml | 23 +++ .../tests/test_feature_flags_module.py | 180 ++++++++++++++++ modules/feature_flags/tsconfig.json | 11 + package-lock.json | 17 ++ packages/i18n/src/generated-resources.ts | 20 ++ packages/i18n/src/keys.generated.ts | 29 +++ pyproject.toml | 3 +- 25 files changed, 1051 insertions(+), 1 deletion(-) create mode 100644 host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py create mode 100644 modules/feature_flags/feature_flags/__init__.py create mode 100644 modules/feature_flags/feature_flags/constants.py create mode 100644 modules/feature_flags/feature_flags/contracts/__init__.py create mode 100644 modules/feature_flags/feature_flags/contracts/schemas.py create mode 100644 modules/feature_flags/feature_flags/contracts/service.py create mode 100644 modules/feature_flags/feature_flags/deps.py create mode 100644 modules/feature_flags/feature_flags/endpoints/__init__.py create mode 100644 modules/feature_flags/feature_flags/endpoints/api.py create mode 100644 modules/feature_flags/feature_flags/endpoints/views.py create mode 100644 modules/feature_flags/feature_flags/locales/en.json create mode 100644 modules/feature_flags/feature_flags/models.py create mode 100644 modules/feature_flags/feature_flags/module.py create mode 100644 modules/feature_flags/feature_flags/pages/Browse.tsx create mode 100644 modules/feature_flags/feature_flags/py.typed create mode 100644 modules/feature_flags/feature_flags/service.py create mode 100644 modules/feature_flags/package.json create mode 100644 modules/feature_flags/pyproject.toml create mode 100644 modules/feature_flags/tests/test_feature_flags_module.py create mode 100644 modules/feature_flags/tsconfig.json diff --git a/host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py b/host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py new file mode 100644 index 00000000..4c468a88 --- /dev/null +++ b/host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py @@ -0,0 +1,59 @@ +"""add feature_flags override table + +Revision ID: cf714c8bf117 +Revises: 1fe7590fc594 +Create Date: 2026-04-19 13:58:41.316854 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "cf714c8bf117" +down_revision: str | None = "1fe7590fc594" +branch_labels: str | Sequence[str] | None = ("feature_flags",) +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # On PostgreSQL, create the `feature_flags` schema before creating tables. + if op.get_context().dialect.name == "postgresql": + op.execute("CREATE SCHEMA IF NOT EXISTS feature_flags") + + op.create_table( + "feature_flags_override", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_feature_flags_override")), + sa.UniqueConstraint("name", name="uq_feature_flags_override_name"), + ) + op.create_index( + op.f("ix_feature_flags_override_name"), + "feature_flags_override", + ["name"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_feature_flags_override_name"), + table_name="feature_flags_override", + ) + op.drop_table("feature_flags_override") + + # On PostgreSQL, drop the `feature_flags` schema. + if op.get_context().dialect.name == "postgresql": + op.execute("DROP SCHEMA IF EXISTS feature_flags") diff --git a/host/pyproject.toml b/host/pyproject.toml index 38e4b956..4e29e6d3 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "background-tasks", "file-storage", "settings", + "feature-flags", "python-multipart>=0.0.6", ] @@ -24,3 +25,4 @@ permissions = { workspace = true } background-tasks = { workspace = true } file-storage = { workspace = true } settings = { workspace = true } +feature-flags = { workspace = true } diff --git a/modules/feature_flags/feature_flags/__init__.py b/modules/feature_flags/feature_flags/__init__.py new file mode 100644 index 00000000..65e18805 --- /dev/null +++ b/modules/feature_flags/feature_flags/__init__.py @@ -0,0 +1 @@ +"""FeatureFlags module.""" diff --git a/modules/feature_flags/feature_flags/constants.py b/modules/feature_flags/feature_flags/constants.py new file mode 100644 index 00000000..6bd3cc7a --- /dev/null +++ b/modules/feature_flags/feature_flags/constants.py @@ -0,0 +1,21 @@ +"""Stable identifiers for the feature_flags module.""" + +# Permission identifiers +PERM_FEATURE_FLAGS_VIEW = "feature_flags.view" +PERM_FEATURE_FLAGS_MANAGE = "feature_flags.manage" + +# DB table name (SQLite lacks per-module schemas, so the prefix is load-bearing) +TABLE_OVERRIDE = "feature_flags_override" +UQ_OVERRIDE_NAME = "uq_feature_flags_override_name" + +# i18n namespace — matches locale_dirs() key +LOCALE_NAMESPACE = "feature_flags" + +# Menu metadata +MENU_LABEL = "Feature Flags" +MENU_URL = "/feature_flags" +MENU_ICON = "flag" +MENU_ORDER = 45 + +# Inertia page identifiers +PAGE_BROWSE = "FeatureFlags/Browse" diff --git a/modules/feature_flags/feature_flags/contracts/__init__.py b/modules/feature_flags/feature_flags/contracts/__init__.py new file mode 100644 index 00000000..6b8ee160 --- /dev/null +++ b/modules/feature_flags/feature_flags/contracts/__init__.py @@ -0,0 +1,15 @@ +"""feature_flags contracts — public interface for other modules.""" + +from feature_flags.contracts.schemas import ( + FeatureFlagOverrideOut, + FeatureFlagView, + ToggleRequest, +) +from feature_flags.contracts.service import IFeatureFlagService + +__all__ = [ + "FeatureFlagOverrideOut", + "FeatureFlagView", + "IFeatureFlagService", + "ToggleRequest", +] diff --git a/modules/feature_flags/feature_flags/contracts/schemas.py b/modules/feature_flags/feature_flags/contracts/schemas.py new file mode 100644 index 00000000..b9544e89 --- /dev/null +++ b/modules/feature_flags/feature_flags/contracts/schemas.py @@ -0,0 +1,41 @@ +"""SQLModel DTOs for the feature_flags module.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import ConfigDict +from sqlmodel import Field, SQLModel + + +class FeatureFlagOverrideOut(SQLModel): + """Stored override row as returned by the service/API.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + enabled: bool + created_at: datetime | None = None + updated_at: datetime | None = None + + +class FeatureFlagView(SQLModel): + """A flag as shown in the admin UI. + + Combines the in-code definition (from ``FeatureFlagRegistry``) with the + persisted override (from the DB) so the UI can distinguish a flag that + runs on its default from one that an admin has manually overridden. + """ + + name: str + description: str = "" + default_enabled: bool + enabled: bool + overridden: bool + + +class ToggleRequest(SQLModel): + """Body for PUT /api/feature_flags/{name} — sets an override.""" + + enabled: bool = Field(...) diff --git a/modules/feature_flags/feature_flags/contracts/service.py b/modules/feature_flags/feature_flags/contracts/service.py new file mode 100644 index 00000000..0f0148d6 --- /dev/null +++ b/modules/feature_flags/feature_flags/contracts/service.py @@ -0,0 +1,19 @@ +"""FeatureFlag service protocol — the public contract other modules depend on.""" + +from __future__ import annotations + +from typing import Protocol + +from simple_module_core.feature_flags import FeatureFlagRegistry + +from feature_flags.contracts.schemas import FeatureFlagOverrideOut, FeatureFlagView + + +class IFeatureFlagService(Protocol): + """Interface for feature-flag management operations.""" + + async def list_overrides(self) -> list[FeatureFlagOverrideOut]: ... + async def list_flags(self, registry: FeatureFlagRegistry) -> list[FeatureFlagView]: ... + async def set_override(self, name: str, enabled: bool) -> FeatureFlagOverrideOut: ... + async def clear_override(self, name: str) -> bool: ... + async def hydrate_registry(self, registry: FeatureFlagRegistry) -> int: ... diff --git a/modules/feature_flags/feature_flags/deps.py b/modules/feature_flags/feature_flags/deps.py new file mode 100644 index 00000000..84f87ca9 --- /dev/null +++ b/modules/feature_flags/feature_flags/deps.py @@ -0,0 +1,27 @@ +"""FastAPI dependencies for the feature_flags module.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, Request +from simple_module_core.feature_flags import FeatureFlagRegistry +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from feature_flags.service import FeatureFlagService + + +async def get_feature_flag_service( + db: AsyncSession = Depends(get_db), +) -> FeatureFlagService: + return FeatureFlagService(db) + + +def get_feature_flag_registry(request: Request) -> FeatureFlagRegistry: + """Return the process-wide FeatureFlagRegistry owned by the framework.""" + return request.app.state.sm.feature_flags + + +FeatureFlagServiceDep = Annotated[FeatureFlagService, Depends(get_feature_flag_service)] +FeatureFlagRegistryDep = Annotated[FeatureFlagRegistry, Depends(get_feature_flag_registry)] diff --git a/modules/feature_flags/feature_flags/endpoints/__init__.py b/modules/feature_flags/feature_flags/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/feature_flags/feature_flags/endpoints/api.py b/modules/feature_flags/feature_flags/endpoints/api.py new file mode 100644 index 00000000..bfcf164b --- /dev/null +++ b/modules/feature_flags/feature_flags/endpoints/api.py @@ -0,0 +1,77 @@ +"""REST API endpoints for feature_flags management.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from simple_module_hosting.permissions import RequiresPermission + +from feature_flags.constants import PERM_FEATURE_FLAGS_MANAGE, PERM_FEATURE_FLAGS_VIEW +from feature_flags.contracts.schemas import FeatureFlagView, ToggleRequest +from feature_flags.deps import FeatureFlagRegistryDep, FeatureFlagServiceDep + +router = APIRouter() + + +@router.get( + "/", + response_model=list[FeatureFlagView], + dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_VIEW))], +) +async def list_flags( + service: FeatureFlagServiceDep, + registry: FeatureFlagRegistryDep, +) -> list[FeatureFlagView]: + return await service.list_flags(registry) + + +@router.get( + "/{name}", + response_model=FeatureFlagView, + dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_VIEW))], +) +async def get_flag( + name: str, + service: FeatureFlagServiceDep, + registry: FeatureFlagRegistryDep, +) -> FeatureFlagView: + for flag in await service.list_flags(registry): + if flag.name == name: + return flag + raise HTTPException(status_code=404, detail="Feature flag not registered") + + +@router.put( + "/{name}", + response_model=FeatureFlagView, + dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE))], +) +async def set_override( + name: str, + body: ToggleRequest, + service: FeatureFlagServiceDep, + registry: FeatureFlagRegistryDep, +) -> FeatureFlagView: + # Refuse overrides for names the registry doesn't know — a typo here would + # silently persist dead rows that list_flags() filters out later. + if name not in {f.name for f in registry.all_flags}: + raise HTTPException(status_code=404, detail="Feature flag not registered") + await service.set_override(name, body.enabled, registry=registry) + for flag in await service.list_flags(registry): + if flag.name == name: + return flag + raise HTTPException(status_code=500, detail="Override applied but flag vanished") + + +@router.delete( + "/{name}", + status_code=204, + dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE))], +) +async def clear_override( + name: str, + service: FeatureFlagServiceDep, + registry: FeatureFlagRegistryDep, +) -> None: + cleared = await service.clear_override(name, registry=registry) + if not cleared: + raise HTTPException(status_code=404, detail="No override set for this flag") diff --git a/modules/feature_flags/feature_flags/endpoints/views.py b/modules/feature_flags/feature_flags/endpoints/views.py new file mode 100644 index 00000000..64556fa9 --- /dev/null +++ b/modules/feature_flags/feature_flags/endpoints/views.py @@ -0,0 +1,69 @@ +"""Inertia view endpoints for feature_flags admin UI.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from inertia import InertiaResponse +from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.permissions import RequiresPermission +from starlette.responses import RedirectResponse + +from feature_flags.constants import ( + MENU_URL, + PAGE_BROWSE, + PERM_FEATURE_FLAGS_MANAGE, + PERM_FEATURE_FLAGS_VIEW, +) +from feature_flags.deps import FeatureFlagRegistryDep, FeatureFlagServiceDep + +router = APIRouter() + + +@router.get( + "/", + response_model=None, + dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_VIEW))], +) +async def browse( + inertia: InertiaDep, + service: FeatureFlagServiceDep, + registry: FeatureFlagRegistryDep, +) -> InertiaResponse: + flags = await service.list_flags(registry) + return await inertia.render( + PAGE_BROWSE, + {"flags": [f.model_dump(mode="json") for f in flags]}, + ) + + +@router.post( + "/{name}/toggle", + response_model=None, + dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE))], +) +async def toggle_action( + name: str, + request: Request, + service: FeatureFlagServiceDep, + registry: FeatureFlagRegistryDep, +) -> RedirectResponse: + if name not in {f.name for f in registry.all_flags}: + return RedirectResponse(MENU_URL, status_code=303) + body = await request.json() + enabled = bool(body.get("enabled", False)) + await service.set_override(name, enabled, registry=registry) + return RedirectResponse(MENU_URL, status_code=303) + + +@router.post( + "/{name}/clear", + response_model=None, + dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE))], +) +async def clear_action( + name: str, + service: FeatureFlagServiceDep, + registry: FeatureFlagRegistryDep, +) -> RedirectResponse: + await service.clear_override(name, registry=registry) + return RedirectResponse(MENU_URL, status_code=303) diff --git a/modules/feature_flags/feature_flags/locales/en.json b/modules/feature_flags/feature_flags/locales/en.json new file mode 100644 index 00000000..786cb5d0 --- /dev/null +++ b/modules/feature_flags/feature_flags/locales/en.json @@ -0,0 +1,28 @@ +{ + "browse": { + "title": "Feature Flags", + "description": "Toggle features exposed by installed modules. Overrides persist across restarts.", + "empty_title": "No feature flags registered", + "empty_description": "Modules declare flags via register_feature_flags(); install one to see it here.", + "count_one": "{count} flag", + "count_other": "{count} flags" + }, + "table": { + "name": "Flag", + "description": "Description", + "default": "Default", + "status": "Status", + "actions": "Actions", + "enabled": "Enabled", + "disabled": "Disabled", + "overridden": "Override active", + "following_default": "Following default", + "clear_override": "Clear override" + }, + "toasts": { + "enabled": "\"{name}\" enabled", + "disabled": "\"{name}\" disabled", + "cleared": "Override cleared for \"{name}\"", + "toggle_failed": "Failed to update flag" + } +} diff --git a/modules/feature_flags/feature_flags/models.py b/modules/feature_flags/feature_flags/models.py new file mode 100644 index 00000000..736a0a62 --- /dev/null +++ b/modules/feature_flags/feature_flags/models.py @@ -0,0 +1,29 @@ +"""SQLModel tables for the feature_flags module. + +Stores persisted overrides for flags that modules declare via +``register_feature_flags``. The definition of a flag (name, description, +default) lives in code; this table only records admin-applied overrides so +they survive across app restarts. +""" + +from __future__ import annotations + +from simple_module_db.base import create_module_base +from simple_module_db.mixins import AuditMixin +from sqlalchemy import UniqueConstraint +from sqlmodel import Field + +from feature_flags.constants import TABLE_OVERRIDE, UQ_OVERRIDE_NAME + +Base = create_module_base("feature_flags") + + +class FeatureFlagOverride(Base, AuditMixin, table=True): # ty: ignore[unsupported-base] + """Persisted override for a single feature flag.""" + + __tablename__ = TABLE_OVERRIDE + __table_args__ = (UniqueConstraint("name", name=UQ_OVERRIDE_NAME),) + + id: int | None = Field(default=None, primary_key=True) + name: str = Field(max_length=200, index=True) + enabled: bool = Field(default=False) diff --git a/modules/feature_flags/feature_flags/module.py b/modules/feature_flags/feature_flags/module.py new file mode 100644 index 00000000..906ba3c0 --- /dev/null +++ b/modules/feature_flags/feature_flags/module.py @@ -0,0 +1,74 @@ +"""FeatureFlags module definition.""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path + +from fastapi import APIRouter, FastAPI +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.module import ModuleBase, ModuleMeta +from simple_module_core.permissions import PermissionRegistry + +from feature_flags.constants import ( + LOCALE_NAMESPACE, + MENU_ICON, + MENU_LABEL, + MENU_ORDER, + MENU_URL, + PERM_FEATURE_FLAGS_MANAGE, + PERM_FEATURE_FLAGS_VIEW, +) + + +class FeatureFlagsModule(ModuleBase): + meta = ModuleMeta( + name="FeatureFlags", + route_prefix="/api/feature_flags", + view_prefix="/feature_flags", + ) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from feature_flags.endpoints.api import router as api + from feature_flags.endpoints.views import router as views + + api_router.include_router(api) + view_router.include_router(views) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label=MENU_LABEL, + url=MENU_URL, + icon=MENU_ICON, + order=MENU_ORDER, + section=MenuSection.SIDEBAR, + ) + ) + + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group( + "Feature Flags", + [ + PERM_FEATURE_FLAGS_VIEW, + PERM_FEATURE_FLAGS_MANAGE, + ], + ) + + def locale_dirs(self) -> dict[str, Path]: + base = Path(str(importlib.resources.files(__package__) / "locales")) + return {LOCALE_NAMESPACE: base} + + async def on_startup(self, app: FastAPI) -> None: + """Load every persisted override into the in-memory registry. + + Called once, after DB init and before the app starts serving. From + here on ``registry.is_enabled`` reflects admin overrides even for + requests that don't hit this module's endpoints. + """ + from feature_flags.service import FeatureFlagService + + sm = app.state.sm + async with sm.db.session_factory() as session: + service = FeatureFlagService(session) + await service.hydrate_registry(sm.feature_flags) diff --git a/modules/feature_flags/feature_flags/pages/Browse.tsx b/modules/feature_flags/feature_flags/pages/Browse.tsx new file mode 100644 index 00000000..042b3790 --- /dev/null +++ b/modules/feature_flags/feature_flags/pages/Browse.tsx @@ -0,0 +1,192 @@ +import { router, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; +import { PageShell } from '@simple-module/ui/components/PageShell'; +import { Badge } from '@simple-module/ui/components/ui/badge'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { Card } from '@simple-module/ui/components/ui/card'; +import { + Empty, + EmptyDescription, + EmptyMedia, + EmptyTitle, +} from '@simple-module/ui/components/ui/empty'; +import { Switch } from '@simple-module/ui/components/ui/switch'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simple-module/ui/components/ui/table'; +import { usePermissions } from '@simple-module/ui/hooks/use-permissions'; +import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; +import { Flag, RotateCcw } from 'lucide-react'; +import { toast } from 'sonner'; + +interface FeatureFlag { + name: string; + description: string; + default_enabled: boolean; + enabled: boolean; + overridden: boolean; +} + +interface Props { + flags: FeatureFlag[]; +} + +function Browse() { + const { flags } = usePage<{ props: Props }>().props as unknown as Props; + const { t } = useT(); + const { can } = usePermissions(); + const canManage = can('feature_flags.manage'); + + function handleToggle(flag: FeatureFlag, next: boolean) { + router.post( + `/feature_flags/${flag.name}/toggle`, + { enabled: next }, + { + preserveScroll: true, + onSuccess: () => + toast.success( + next + ? t(keys.feature_flags.toasts.enabled, { name: flag.name }) + : t(keys.feature_flags.toasts.disabled, { name: flag.name }), + ), + onError: () => toast.error(t(keys.feature_flags.toasts.toggle_failed)), + }, + ); + } + + function handleClear(flag: FeatureFlag) { + router.post( + `/feature_flags/${flag.name}/clear`, + {}, + { + preserveScroll: true, + onSuccess: () => toast.success(t(keys.feature_flags.toasts.cleared, { name: flag.name })), + onError: () => toast.error(t(keys.feature_flags.toasts.toggle_failed)), + }, + ); + } + + return ( + +
+ {flags.length > 0 && ( +

+ {t(keys.feature_flags.browse.count, { count: flags.length })} +

+ )} +
+ + + + + + {t(keys.feature_flags.table.name)} + + {t(keys.feature_flags.table.description)} + + + {t(keys.feature_flags.table.default)} + + {t(keys.feature_flags.table.status)} + {canManage && ( + + {t(keys.feature_flags.table.actions)} + + )} + + + + {flags.map((flag) => ( + + +
+ {flag.name} + {flag.overridden && ( + + {t(keys.feature_flags.table.overridden)} + + )} +
+
+ + + {flag.description || '—'} + + + + + {flag.default_enabled + ? t(keys.feature_flags.table.enabled) + : t(keys.feature_flags.table.disabled)} + + + +
+ handleToggle(flag, checked === true)} + disabled={!canManage} + aria-label={flag.name} + /> + + {flag.enabled + ? t(keys.feature_flags.table.enabled) + : t(keys.feature_flags.table.disabled)} + +
+
+ {canManage && ( + + {flag.overridden ? ( + + ) : ( + + {t(keys.feature_flags.table.following_default)} + + )} + + )} +
+ ))} + {flags.length === 0 && ( + + + + + + + {t(keys.feature_flags.browse.empty_title)} + + {t(keys.feature_flags.browse.empty_description)} + + + + + )} +
+
+
+
+ ); +} + +Browse.layout = (page: React.ReactNode) => {page}; +export default Browse; diff --git a/modules/feature_flags/feature_flags/py.typed b/modules/feature_flags/feature_flags/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/modules/feature_flags/feature_flags/service.py b/modules/feature_flags/feature_flags/service.py new file mode 100644 index 00000000..32b87878 --- /dev/null +++ b/modules/feature_flags/feature_flags/service.py @@ -0,0 +1,99 @@ +"""FeatureFlagService — manages persisted overrides and syncs them to the registry.""" + +from __future__ import annotations + +from simple_module_core.feature_flags import FeatureFlagRegistry +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from feature_flags.contracts.schemas import FeatureFlagOverrideOut, FeatureFlagView +from feature_flags.models import FeatureFlagOverride + + +class FeatureFlagService: + """Read/write persisted overrides and keep the in-memory registry in sync. + + The registry is the source of truth for *which* flags exist and their + defaults — those come from module code. This service owns the persisted + overrides: anything an admin toggles in the UI is written here and + mirrored to ``registry._overrides`` so ``is_enabled`` returns the right + value for subsequent requests. + """ + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_overrides(self) -> list[FeatureFlagOverrideOut]: + result = await self.db.execute( + select(FeatureFlagOverride).order_by(FeatureFlagOverride.name) + ) + return [FeatureFlagOverrideOut.model_validate(row) for row in result.scalars()] + + async def _get_by_name(self, name: str) -> FeatureFlagOverride | None: + result = await self.db.execute( + select(FeatureFlagOverride).where(FeatureFlagOverride.name == name) + ) + return result.scalar_one_or_none() + + async def list_flags(self, registry: FeatureFlagRegistry) -> list[FeatureFlagView]: + """Join registered definitions with persisted overrides for admin display. + + Definitions the registry doesn't know about are silently dropped — an + override whose flag was removed from code is stale and should be + cleared rather than surfaced as a "ghost" row. + """ + overrides = {o.name: o.enabled for o in await self.list_overrides()} + views: list[FeatureFlagView] = [] + for flag in sorted(registry.all_flags, key=lambda f: f.name): + overridden = flag.name in overrides + effective = overrides[flag.name] if overridden else flag.default_enabled + views.append( + FeatureFlagView( + name=flag.name, + description=flag.description, + default_enabled=flag.default_enabled, + enabled=effective, + overridden=overridden, + ) + ) + return views + + async def set_override( + self, + name: str, + enabled: bool, + registry: FeatureFlagRegistry | None = None, + ) -> FeatureFlagOverrideOut: + """Upsert an override and (optionally) mirror it to the in-memory registry.""" + existing = await self._get_by_name(name) + if existing is None: + existing = FeatureFlagOverride(name=name, enabled=enabled) + self.db.add(existing) + else: + existing.enabled = enabled + await self.db.flush() + await self.db.refresh(existing) + if registry is not None: + registry.set_override(name, enabled) + return FeatureFlagOverrideOut.model_validate(existing) + + async def clear_override( + self, + name: str, + registry: FeatureFlagRegistry | None = None, + ) -> bool: + """Delete an override and revert the registry to the flag's default.""" + existing = await self._get_by_name(name) + if existing is None: + return False + await self.db.delete(existing) + if registry is not None: + registry.clear_override(name) + return True + + async def hydrate_registry(self, registry: FeatureFlagRegistry) -> int: + """Load every persisted override into the registry. Called once at boot.""" + overrides = await self.list_overrides() + for ovr in overrides: + registry.set_override(ovr.name, ovr.enabled) + return len(overrides) diff --git a/modules/feature_flags/package.json b/modules/feature_flags/package.json new file mode 100644 index 00000000..d93af1a8 --- /dev/null +++ b/modules/feature_flags/package.json @@ -0,0 +1,16 @@ +{ + "name": "@simple-module/feature_flags", + "version": "0.1.0", + "private": true, + "description": "Frontend assets for the FeatureFlags module", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@inertiajs/react": "^2.0.0", + "@simple-module/ui": "*" + }, + "devDependencies": { + "@simple-module/tsconfig": "*" + }, + "dependencies": {} +} diff --git a/modules/feature_flags/pyproject.toml b/modules/feature_flags/pyproject.toml new file mode 100644 index 00000000..357d70d5 --- /dev/null +++ b/modules/feature_flags/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "feature-flags" +version = "0.1.0" +description = "The FeatureFlags module" +authors = [] +requires-python = ">=3.12" +dependencies = [ + "simple-module-core", + "simple-module-db", + "simple-module-hosting", +] + +[project.entry-points.simple_module] +feature_flags = "feature_flags.module:FeatureFlagsModule" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv.sources] +simple-module-core = { workspace = true } +simple-module-db = { workspace = true } +simple-module-hosting = { workspace = true } diff --git a/modules/feature_flags/tests/test_feature_flags_module.py b/modules/feature_flags/tests/test_feature_flags_module.py new file mode 100644 index 00000000..82dccc7d --- /dev/null +++ b/modules/feature_flags/tests/test_feature_flags_module.py @@ -0,0 +1,180 @@ +"""Tests for the feature_flags module: service, registry sync, API, lifecycle.""" + +from __future__ import annotations + +import httpx +from feature_flags.service import FeatureFlagService +from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry +from sqlalchemy.ext.asyncio import AsyncSession + + +def _registry_with(*flags: tuple[str, str, bool]) -> FeatureFlagRegistry: + reg = FeatureFlagRegistry() + for name, desc, default in flags: + reg.add(FeatureFlagDefinition(name=name, description=desc, default_enabled=default)) + return reg + + +# ── FeatureFlagService ───────────────────────────────────────────── + + +class TestFeatureFlagService: + async def test_set_override_inserts_row_and_mirrors_to_registry(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with(("beta_ui", "Beta UI", False)) + + result = await svc.set_override("beta_ui", True, registry=reg) + + assert result.name == "beta_ui" + assert result.enabled is True + assert reg.is_enabled("beta_ui") is True + + async def test_set_override_twice_updates_in_place(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with(("beta_ui", "", False)) + + await svc.set_override("beta_ui", True, registry=reg) + await svc.set_override("beta_ui", False, registry=reg) + + rows = await svc.list_overrides() + assert len(rows) == 1 + assert rows[0].enabled is False + assert reg.is_enabled("beta_ui") is False + + async def test_clear_override_removes_row_and_reverts_registry(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with(("beta_ui", "", False)) + await svc.set_override("beta_ui", True, registry=reg) + + cleared = await svc.clear_override("beta_ui", registry=reg) + + assert cleared is True + assert await svc.list_overrides() == [] + assert reg.is_enabled("beta_ui") is False # back to default + + async def test_clear_override_unknown_flag_is_noop(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + assert await svc.clear_override("ghost") is False + + async def test_list_flags_joins_registry_with_overrides(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with( + ("a.flag", "A", False), + ("b.flag", "B", True), + ) + await svc.set_override("a.flag", True, registry=reg) + + views = await svc.list_flags(reg) + + by_name = {v.name: v for v in views} + assert by_name["a.flag"].enabled is True + assert by_name["a.flag"].overridden is True + assert by_name["a.flag"].default_enabled is False + assert by_name["b.flag"].enabled is True + assert by_name["b.flag"].overridden is False + + async def test_list_flags_drops_stale_overrides(self, db_session: AsyncSession): + """Overrides for flags the registry no longer knows about are hidden. + + The row stays in the DB (so a reintroduced flag picks it back up), but + it doesn't leak into the admin list as a ghost entry. + """ + svc = FeatureFlagService(db_session) + reg_old = _registry_with(("legacy", "", False)) + await svc.set_override("legacy", True, registry=reg_old) + + reg_new = _registry_with(("current", "", False)) + views = await svc.list_flags(reg_new) + + assert [v.name for v in views] == ["current"] + + async def test_hydrate_registry_applies_all_overrides(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + # simulate overrides persisted from a previous boot + await svc.set_override("a", True) + await svc.set_override("b", False) + + fresh = _registry_with(("a", "", False), ("b", "", True)) + count = await svc.hydrate_registry(fresh) + + assert count == 2 + assert fresh.is_enabled("a") is True + assert fresh.is_enabled("b") is False + + +# ── REST API ─────────────────────────────────────────────────────── + + +class TestFeatureFlagsAPI: + async def test_list_flags_returns_registered_flags( + self, authenticated_client: httpx.AsyncClient + ): + # Products module registers `products.bulk_import` + resp = await authenticated_client.get("/api/feature_flags/") + assert resp.status_code == 200 + names = {f["name"] for f in resp.json()} + assert "products.bulk_import" in names + + async def test_set_override_flips_flag(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.put( + "/api/feature_flags/products.bulk_import", + json={"enabled": True}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "products.bulk_import" + assert body["enabled"] is True + assert body["overridden"] is True + + async def test_set_override_unknown_flag_404s(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.put( + "/api/feature_flags/does.not.exist", + json={"enabled": True}, + ) + assert resp.status_code == 404 + + async def test_clear_override_reverts_to_default(self, authenticated_client: httpx.AsyncClient): + # Create the override first + await authenticated_client.put( + "/api/feature_flags/products.bulk_import", + json={"enabled": True}, + ) + resp = await authenticated_client.delete("/api/feature_flags/products.bulk_import") + assert resp.status_code == 204 + + # Follow-up read shows the default state, no override + follow = await authenticated_client.get("/api/feature_flags/products.bulk_import") + assert follow.status_code == 200 + assert follow.json()["overridden"] is False + + async def test_clear_override_without_any_404s(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.delete("/api/feature_flags/products.bulk_import") + assert resp.status_code == 404 + + async def test_get_flag_returns_view(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/feature_flags/products.bulk_import") + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "products.bulk_import" + assert "default_enabled" in body + assert "overridden" in body + + +# ── Module lifecycle ─────────────────────────────────────────────── + + +class TestFeatureFlagsModuleLifecycle: + async def test_on_startup_hydrates_registry_from_db(self, app): + """After lifespan, overrides stored before startup should be on the registry.""" + from feature_flags.models import FeatureFlagOverride + + sm = app.state.sm + async with sm.db.session_factory() as session: + session.add(FeatureFlagOverride(name="products.bulk_import", enabled=True)) + await session.commit() + + # Re-run the module's on_startup against the same app to simulate a boot + from feature_flags.module import FeatureFlagsModule + + await FeatureFlagsModule().on_startup(app) + assert sm.feature_flags.is_enabled("products.bulk_import") is True diff --git a/modules/feature_flags/tsconfig.json b/modules/feature_flags/tsconfig.json new file mode 100644 index 00000000..18834d7a --- /dev/null +++ b/modules/feature_flags/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@simple-module/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./feature_flags/*"], + "@simple-module/ui/*": ["../../packages/ui/src/*"] + } + }, + "include": ["feature_flags/**/*.ts", "feature_flags/**/*.tsx"] +} diff --git a/package-lock.json b/package-lock.json index ed9d3aed..2c465399 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,6 +104,19 @@ "react-dom": "^19.0.0" } }, + "modules/feature_flags": { + "name": "@simple-module/feature_flags", + "version": "0.1.0", + "devDependencies": { + "@simple-module/tsconfig": "*" + }, + "peerDependencies": { + "@inertiajs/react": "^2.0.0", + "@simple-module/ui": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/products": { "name": "@simple-module/products", "version": "0.1.0", @@ -4404,6 +4417,10 @@ "resolved": "modules/dashboard", "link": true }, + "node_modules/@simple-module/feature_flags": { + "resolved": "modules/feature_flags", + "link": true + }, "node_modules/@simple-module/i18n": { "resolved": "packages/i18n", "link": true diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 3f965256..dc91872e 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -59,6 +59,26 @@ export default { 'dashboard.home.title': '', 'dashboard.home.welcome_card_title': '', 'dashboard.home.welcome_message': '', + 'feature_flags.browse.count_one': '', + 'feature_flags.browse.count_other': '', + 'feature_flags.browse.description': '', + 'feature_flags.browse.empty_description': '', + 'feature_flags.browse.empty_title': '', + 'feature_flags.browse.title': '', + 'feature_flags.table.actions': '', + 'feature_flags.table.clear_override': '', + 'feature_flags.table.default': '', + 'feature_flags.table.description': '', + 'feature_flags.table.disabled': '', + 'feature_flags.table.enabled': '', + 'feature_flags.table.following_default': '', + 'feature_flags.table.name': '', + 'feature_flags.table.overridden': '', + 'feature_flags.table.status': '', + 'feature_flags.toasts.cleared': '', + 'feature_flags.toasts.disabled': '', + 'feature_flags.toasts.enabled': '', + 'feature_flags.toasts.toggle_failed': '', 'file_storage.actions.delete': '', 'file_storage.actions.download': '', 'file_storage.browse.count_one': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index dff2232d..aeaaced6 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -87,6 +87,35 @@ export const keys = { welcome_message: 'dashboard.home.welcome_message', }, }, + feature_flags: { + browse: { + count: 'feature_flags.browse.count', + count_one: 'feature_flags.browse.count_one', + count_other: 'feature_flags.browse.count_other', + description: 'feature_flags.browse.description', + empty_description: 'feature_flags.browse.empty_description', + empty_title: 'feature_flags.browse.empty_title', + title: 'feature_flags.browse.title', + }, + table: { + actions: 'feature_flags.table.actions', + clear_override: 'feature_flags.table.clear_override', + default: 'feature_flags.table.default', + description: 'feature_flags.table.description', + disabled: 'feature_flags.table.disabled', + enabled: 'feature_flags.table.enabled', + following_default: 'feature_flags.table.following_default', + name: 'feature_flags.table.name', + overridden: 'feature_flags.table.overridden', + status: 'feature_flags.table.status', + }, + toasts: { + cleared: 'feature_flags.toasts.cleared', + disabled: 'feature_flags.toasts.disabled', + enabled: 'feature_flags.toasts.enabled', + toggle_failed: 'feature_flags.toasts.toggle_failed', + }, + }, file_storage: { actions: { delete: 'file_storage.actions.delete', diff --git a/pyproject.toml b/pyproject.toml index f23e298c..b99db12f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ extra-paths = [ "modules/background_tasks", "modules/file_storage", "modules/settings", + "modules/feature_flags", "host", ] @@ -101,7 +102,7 @@ invalid-argument-type = "ignore" [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] +testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] markers = [ "e2e: end-to-end tests requiring a live browser", "perf: performance benchmarks (opt-in; run via `make bench`)", From 24f30d46a0e8a65c3002a320d67c42dd9a9815b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:38:57 +0000 Subject: [PATCH 2/6] Add per-tenant overrides on top of system-level feature flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flags can now be overridden at two scopes: * system — applies to every request (the previous default behavior) * tenant — applies only when is_enabled() is called with a matching tenant_id; per-tenant value beats the system value Resolution at runtime: tenant override > system override > definition default. The override table grows a (scope, scope_id) pair with composite unique on (scope, scope_id, name); SCOPE_TENANT rows store the tenant id in scope_id, SCOPE_SYSTEM rows use the empty string. The registry holds two maps (system, tenant) so is_enabled() stays O(1). The admin UI gains a tenant selector and shows the inherited system value next to each tenant-scoped toggle so it's obvious what clearing the override would revert to. --- .../core/simple_module_core/feature_flags.py | 48 +++- framework/core/tests/test_feature_flags.py | 48 ++++ ...8bf117_add_feature_flags_override_table.py | 29 ++- .../feature_flags/feature_flags/constants.py | 19 +- .../feature_flags/contracts/schemas.py | 20 +- .../feature_flags/endpoints/api.py | 86 ++++++- .../feature_flags/endpoints/views.py | 45 +++- .../feature_flags/locales/en.json | 14 +- modules/feature_flags/feature_flags/models.py | 19 +- .../feature_flags/pages/Browse.tsx | 103 +++++++- .../feature_flags/feature_flags/service.py | 139 +++++++++-- .../tests/test_feature_flags_api.py | 147 ++++++++++++ .../tests/test_feature_flags_module.py | 180 -------------- .../tests/test_feature_flags_service.py | 220 ++++++++++++++++++ packages/i18n/src/generated-resources.ts | 12 + packages/i18n/src/keys.generated.ts | 12 + 16 files changed, 891 insertions(+), 250 deletions(-) create mode 100644 modules/feature_flags/tests/test_feature_flags_api.py delete mode 100644 modules/feature_flags/tests/test_feature_flags_module.py create mode 100644 modules/feature_flags/tests/test_feature_flags_service.py diff --git a/framework/core/simple_module_core/feature_flags.py b/framework/core/simple_module_core/feature_flags.py index 08eebbfa..b9709c03 100644 --- a/framework/core/simple_module_core/feature_flags.py +++ b/framework/core/simple_module_core/feature_flags.py @@ -1,4 +1,13 @@ -"""Feature flag registry — modules declare toggleable features.""" +"""Feature flag registry — modules declare toggleable features. + +Overrides live at two scopes: + +* **system** — applies to every request unless a tenant override exists +* **tenant** — applies only when ``is_enabled`` is called with a matching + ``tenant_id``; a per-tenant value beats the system override + +Resolution order: tenant override > system override > definition default. +""" from __future__ import annotations @@ -15,26 +24,45 @@ class FeatureFlagDefinition: class FeatureFlagRegistry: - """Central registry of all feature flags.""" + """In-memory registry of flag definitions and their resolved overrides.""" def __init__(self) -> None: self._flags: dict[str, FeatureFlagDefinition] = {} - self._overrides: dict[str, bool] = {} + self._system_overrides: dict[str, bool] = {} + self._tenant_overrides: dict[tuple[str, str], bool] = {} def add(self, flag: FeatureFlagDefinition) -> None: self._flags[flag.name] = flag - def is_enabled(self, name: str) -> bool: - if name in self._overrides: - return self._overrides[name] + def is_enabled(self, name: str, tenant_id: str | None = None) -> bool: + if tenant_id is not None: + tenant_value = self._tenant_overrides.get((name, tenant_id)) + if tenant_value is not None: + return tenant_value + if name in self._system_overrides: + return self._system_overrides[name] flag = self._flags.get(name) return flag.default_enabled if flag else False - def set_override(self, name: str, enabled: bool) -> None: - self._overrides[name] = enabled + def set_override(self, name: str, enabled: bool, tenant_id: str | None = None) -> None: + if tenant_id is None: + self._system_overrides[name] = enabled + else: + self._tenant_overrides[(name, tenant_id)] = enabled + + def clear_override(self, name: str, tenant_id: str | None = None) -> None: + if tenant_id is None: + self._system_overrides.pop(name, None) + else: + self._tenant_overrides.pop((name, tenant_id), None) + + def tenant_override(self, name: str, tenant_id: str) -> bool | None: + """Return the per-tenant override for ``(name, tenant_id)`` if set, else None.""" + return self._tenant_overrides.get((name, tenant_id)) - def clear_override(self, name: str) -> None: - self._overrides.pop(name, None) + def system_override(self, name: str) -> bool | None: + """Return the system-level override for ``name`` if set, else None.""" + return self._system_overrides.get(name) @property def all_flags(self) -> list[FeatureFlagDefinition]: diff --git a/framework/core/tests/test_feature_flags.py b/framework/core/tests/test_feature_flags.py index 75ca4669..aa8b8269 100644 --- a/framework/core/tests/test_feature_flags.py +++ b/framework/core/tests/test_feature_flags.py @@ -38,3 +38,51 @@ async def test_all_flags(self): reg.add(FeatureFlagDefinition(name="a")) reg.add(FeatureFlagDefinition(name="b")) assert len(reg.all_flags) == 2 + + +class TestTenantOverrides: + async def test_tenant_override_beats_system_override(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=False)) + reg.set_override("beta_ui", True) # system on + reg.set_override("beta_ui", False, tenant_id="acme") # acme off + + assert reg.is_enabled("beta_ui", tenant_id="acme") is False + assert reg.is_enabled("beta_ui", tenant_id="other") is True # falls back to system + assert reg.is_enabled("beta_ui") is True # no tenant context: system value + + async def test_tenant_override_falls_back_to_default_when_no_system(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=False)) + reg.set_override("beta_ui", True, tenant_id="acme") + + assert reg.is_enabled("beta_ui", tenant_id="acme") is True + assert reg.is_enabled("beta_ui", tenant_id="other") is False + + async def test_clear_tenant_override_only_clears_that_tenant(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=False)) + reg.set_override("beta_ui", True, tenant_id="acme") + reg.set_override("beta_ui", True, tenant_id="globex") + + reg.clear_override("beta_ui", tenant_id="acme") + + assert reg.is_enabled("beta_ui", tenant_id="acme") is False + assert reg.is_enabled("beta_ui", tenant_id="globex") is True + + async def test_clear_system_override_does_not_touch_tenant_overrides(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=False)) + reg.set_override("beta_ui", True) + reg.set_override("beta_ui", True, tenant_id="acme") + + reg.clear_override("beta_ui") # system + + assert reg.is_enabled("beta_ui") is False # system gone, default false + assert reg.is_enabled("beta_ui", tenant_id="acme") is True # tenant intact + + async def test_inspectors_return_none_when_unset(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui")) + assert reg.system_override("beta_ui") is None + assert reg.tenant_override("beta_ui", "acme") is None diff --git a/host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py b/host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py index 4c468a88..53bb9d0d 100644 --- a/host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py +++ b/host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py @@ -34,10 +34,29 @@ def upgrade() -> None: sa.Column("created_by", sa.String(length=255), nullable=True), sa.Column("updated_by", sa.String(length=255), nullable=True), sa.Column("id", sa.Integer(), nullable=False), + sa.Column("scope", sa.String(length=10), nullable=False, server_default="system"), + sa.Column("scope_id", sa.String(length=64), nullable=False, server_default=""), sa.Column("name", sa.String(length=200), nullable=False), sa.Column("enabled", sa.Boolean(), nullable=False), sa.PrimaryKeyConstraint("id", name=op.f("pk_feature_flags_override")), - sa.UniqueConstraint("name", name="uq_feature_flags_override_name"), + sa.UniqueConstraint( + "scope", + "scope_id", + "name", + name="uq_feature_flags_override_scope_scope_id_name", + ), + ) + op.create_index( + op.f("ix_feature_flags_override_scope"), + "feature_flags_override", + ["scope"], + unique=False, + ) + op.create_index( + op.f("ix_feature_flags_override_scope_id"), + "feature_flags_override", + ["scope_id"], + unique=False, ) op.create_index( op.f("ix_feature_flags_override_name"), @@ -52,6 +71,14 @@ def downgrade() -> None: op.f("ix_feature_flags_override_name"), table_name="feature_flags_override", ) + op.drop_index( + op.f("ix_feature_flags_override_scope_id"), + table_name="feature_flags_override", + ) + op.drop_index( + op.f("ix_feature_flags_override_scope"), + table_name="feature_flags_override", + ) op.drop_table("feature_flags_override") # On PostgreSQL, drop the `feature_flags` schema. diff --git a/modules/feature_flags/feature_flags/constants.py b/modules/feature_flags/feature_flags/constants.py index 6bd3cc7a..8ec4af00 100644 --- a/modules/feature_flags/feature_flags/constants.py +++ b/modules/feature_flags/feature_flags/constants.py @@ -6,7 +6,19 @@ # DB table name (SQLite lacks per-module schemas, so the prefix is load-bearing) TABLE_OVERRIDE = "feature_flags_override" -UQ_OVERRIDE_NAME = "uq_feature_flags_override_name" +UQ_OVERRIDE_SCOPE_NAME = "uq_feature_flags_override_scope_scope_id_name" + +# Override scopes — system applies globally, tenant applies only when a request +# carries that tenant_id. Resolution: tenant > system > definition default. +SCOPE_SYSTEM = "system" +SCOPE_TENANT = "tenant" +ALL_SCOPES = (SCOPE_SYSTEM, SCOPE_TENANT) +# Empty string (not NULL) so the composite unique works on PG, where multiple +# NULLs would collide-free and let two "system" rows for the same flag exist. +SYSTEM_SCOPE_ID = "" + +SCOPE_MAX_LENGTH = 10 +SCOPE_ID_MAX_LENGTH = 64 # i18n namespace — matches locale_dirs() key LOCALE_NAMESPACE = "feature_flags" @@ -19,3 +31,8 @@ # Inertia page identifiers PAGE_BROWSE = "FeatureFlags/Browse" + +# Routing +API_PREFIX = "/api/feature_flags" +VIEW_PREFIX = "/feature_flags" +QP_TENANT_ID = "tenant_id" diff --git a/modules/feature_flags/feature_flags/contracts/schemas.py b/modules/feature_flags/feature_flags/contracts/schemas.py index b9544e89..ddbea503 100644 --- a/modules/feature_flags/feature_flags/contracts/schemas.py +++ b/modules/feature_flags/feature_flags/contracts/schemas.py @@ -14,6 +14,8 @@ class FeatureFlagOverrideOut(SQLModel): model_config = ConfigDict(from_attributes=True) id: int + scope: str + scope_id: str name: str enabled: bool created_at: datetime | None = None @@ -21,11 +23,14 @@ class FeatureFlagOverrideOut(SQLModel): class FeatureFlagView(SQLModel): - """A flag as shown in the admin UI. - - Combines the in-code definition (from ``FeatureFlagRegistry``) with the - persisted override (from the DB) so the UI can distinguish a flag that - runs on its default from one that an admin has manually overridden. + """A flag as shown in the admin UI for a given scope. + + ``enabled`` is the value an ``is_enabled(name, tenant_id=...)`` call + would return for this scope. ``overridden`` reports whether the row + that produced ``enabled`` lives at *this* scope (vs. inherited from + system or default). ``system_enabled`` exposes the underlying system + value when viewing a tenant scope, so the UI can show "inheriting: + on/off" next to the toggle. """ name: str @@ -33,9 +38,12 @@ class FeatureFlagView(SQLModel): default_enabled: bool enabled: bool overridden: bool + # Only populated when listing under a tenant scope; helps the UI render + # what would happen if the tenant override were cleared. + system_enabled: bool | None = None class ToggleRequest(SQLModel): - """Body for PUT /api/feature_flags/{name} — sets an override.""" + """Body for PUT /api/feature_flags/[tenant/{tenant_id}/]{name} — sets an override.""" enabled: bool = Field(...) diff --git a/modules/feature_flags/feature_flags/endpoints/api.py b/modules/feature_flags/feature_flags/endpoints/api.py index bfcf164b..32ae4033 100644 --- a/modules/feature_flags/feature_flags/endpoints/api.py +++ b/modules/feature_flags/feature_flags/endpoints/api.py @@ -1,17 +1,38 @@ -"""REST API endpoints for feature_flags management.""" +"""REST API endpoints for feature_flags management. + +Two scopes are exposed: + +* **system** — flat routes (``GET /``, ``PUT /{name}``, ``DELETE /{name}``) +* **tenant** — routes under ``/tenant/{tenant_id}/...`` for per-tenant overrides +""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException +from simple_module_core.feature_flags import FeatureFlagRegistry from simple_module_hosting.permissions import RequiresPermission -from feature_flags.constants import PERM_FEATURE_FLAGS_MANAGE, PERM_FEATURE_FLAGS_VIEW +from feature_flags.constants import ( + PERM_FEATURE_FLAGS_MANAGE, + PERM_FEATURE_FLAGS_VIEW, + SCOPE_TENANT, +) from feature_flags.contracts.schemas import FeatureFlagView, ToggleRequest from feature_flags.deps import FeatureFlagRegistryDep, FeatureFlagServiceDep router = APIRouter() +def _ensure_registered(name: str, registry: FeatureFlagRegistry) -> None: + # Refuse overrides for names the registry doesn't know — a typo here would + # silently persist dead rows that list_flags() filters out later. + if name not in {f.name for f in registry.all_flags}: + raise HTTPException(status_code=404, detail="Feature flag not registered") + + +# ── System scope ────────────────────────────────────────────────── + + @router.get( "/", response_model=list[FeatureFlagView], @@ -51,10 +72,7 @@ async def set_override( service: FeatureFlagServiceDep, registry: FeatureFlagRegistryDep, ) -> FeatureFlagView: - # Refuse overrides for names the registry doesn't know — a typo here would - # silently persist dead rows that list_flags() filters out later. - if name not in {f.name for f in registry.all_flags}: - raise HTTPException(status_code=404, detail="Feature flag not registered") + _ensure_registered(name, registry) await service.set_override(name, body.enabled, registry=registry) for flag in await service.list_flags(registry): if flag.name == name: @@ -75,3 +93,59 @@ async def clear_override( cleared = await service.clear_override(name, registry=registry) if not cleared: raise HTTPException(status_code=404, detail="No override set for this flag") + + +# ── Tenant scope ────────────────────────────────────────────────── + + +@router.get( + "/tenant/{tenant_id}", + response_model=list[FeatureFlagView], + dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_VIEW))], +) +async def list_flags_for_tenant( + tenant_id: str, + service: FeatureFlagServiceDep, + registry: FeatureFlagRegistryDep, +) -> list[FeatureFlagView]: + return await service.list_flags(registry, tenant_id=tenant_id) + + +@router.put( + "/tenant/{tenant_id}/{name}", + response_model=FeatureFlagView, + dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE))], +) +async def set_tenant_override( + tenant_id: str, + name: str, + body: ToggleRequest, + service: FeatureFlagServiceDep, + registry: FeatureFlagRegistryDep, +) -> FeatureFlagView: + _ensure_registered(name, registry) + await service.set_override( + name, body.enabled, registry=registry, scope=SCOPE_TENANT, scope_id=tenant_id + ) + for flag in await service.list_flags(registry, tenant_id=tenant_id): + if flag.name == name: + return flag + raise HTTPException(status_code=500, detail="Override applied but flag vanished") + + +@router.delete( + "/tenant/{tenant_id}/{name}", + status_code=204, + dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE))], +) +async def clear_tenant_override( + tenant_id: str, + name: str, + service: FeatureFlagServiceDep, + registry: FeatureFlagRegistryDep, +) -> None: + cleared = await service.clear_override( + name, registry=registry, scope=SCOPE_TENANT, scope_id=tenant_id + ) + if not cleared: + raise HTTPException(status_code=404, detail="No override set for this flag") diff --git a/modules/feature_flags/feature_flags/endpoints/views.py b/modules/feature_flags/feature_flags/endpoints/views.py index 64556fa9..dc0cd2e8 100644 --- a/modules/feature_flags/feature_flags/endpoints/views.py +++ b/modules/feature_flags/feature_flags/endpoints/views.py @@ -1,4 +1,10 @@ -"""Inertia view endpoints for feature_flags admin UI.""" +"""Inertia view endpoints for feature_flags admin UI. + +The browse page renders either system-scope or a tenant-scope view of the +flags. Tenant scope is selected via a ``tenant_id`` query string. The form +actions for toggling/clearing accept the same ``tenant_id`` query so the +frontend doesn't need separate routes per scope. +""" from __future__ import annotations @@ -13,12 +19,27 @@ PAGE_BROWSE, PERM_FEATURE_FLAGS_MANAGE, PERM_FEATURE_FLAGS_VIEW, + QP_TENANT_ID, + SCOPE_SYSTEM, + SCOPE_TENANT, + SYSTEM_SCOPE_ID, ) from feature_flags.deps import FeatureFlagRegistryDep, FeatureFlagServiceDep router = APIRouter() +def _redirect_for_tenant(tenant_id: str | None) -> RedirectResponse: + target = MENU_URL if not tenant_id else f"{MENU_URL}?{QP_TENANT_ID}={tenant_id}" + return RedirectResponse(target, status_code=303) + + +def _scope_args(tenant_id: str | None) -> dict[str, str]: + if tenant_id: + return {"scope": SCOPE_TENANT, "scope_id": tenant_id} + return {"scope": SCOPE_SYSTEM, "scope_id": SYSTEM_SCOPE_ID} + + @router.get( "/", response_model=None, @@ -28,11 +49,17 @@ async def browse( inertia: InertiaDep, service: FeatureFlagServiceDep, registry: FeatureFlagRegistryDep, + tenant_id: str | None = None, ) -> InertiaResponse: - flags = await service.list_flags(registry) + flags = await service.list_flags(registry, tenant_id=tenant_id) + tenants = await service.list_tenants_with_overrides() return await inertia.render( PAGE_BROWSE, - {"flags": [f.model_dump(mode="json") for f in flags]}, + { + "flags": [f.model_dump(mode="json") for f in flags], + "tenant_id": tenant_id, + "tenants": tenants, + }, ) @@ -46,13 +73,14 @@ async def toggle_action( request: Request, service: FeatureFlagServiceDep, registry: FeatureFlagRegistryDep, + tenant_id: str | None = None, ) -> RedirectResponse: if name not in {f.name for f in registry.all_flags}: - return RedirectResponse(MENU_URL, status_code=303) + return _redirect_for_tenant(tenant_id) body = await request.json() enabled = bool(body.get("enabled", False)) - await service.set_override(name, enabled, registry=registry) - return RedirectResponse(MENU_URL, status_code=303) + await service.set_override(name, enabled, registry=registry, **_scope_args(tenant_id)) + return _redirect_for_tenant(tenant_id) @router.post( @@ -64,6 +92,7 @@ async def clear_action( name: str, service: FeatureFlagServiceDep, registry: FeatureFlagRegistryDep, + tenant_id: str | None = None, ) -> RedirectResponse: - await service.clear_override(name, registry=registry) - return RedirectResponse(MENU_URL, status_code=303) + await service.clear_override(name, registry=registry, **_scope_args(tenant_id)) + return _redirect_for_tenant(tenant_id) diff --git a/modules/feature_flags/feature_flags/locales/en.json b/modules/feature_flags/feature_flags/locales/en.json index 786cb5d0..fd5b7908 100644 --- a/modules/feature_flags/feature_flags/locales/en.json +++ b/modules/feature_flags/feature_flags/locales/en.json @@ -5,7 +5,17 @@ "empty_title": "No feature flags registered", "empty_description": "Modules declare flags via register_feature_flags(); install one to see it here.", "count_one": "{count} flag", - "count_other": "{count} flags" + "count_other": "{count} flags", + "viewing_system": "Viewing system-level flags. Switch scope to override per tenant.", + "viewing_tenant": "Viewing overrides for tenant \"{tenant_id}\". Tenant overrides beat the system value.", + "tenant_id_label": "Tenant ID", + "tenant_id_placeholder": "e.g. acme", + "scope_label": "Scope", + "scope_system": "System (all tenants)", + "scope_tenant": "Specific tenant", + "go": "View", + "back_to_system": "Back to system view", + "tenants_with_overrides": "Tenants with overrides" }, "table": { "name": "Flag", @@ -17,6 +27,8 @@ "disabled": "Disabled", "overridden": "Override active", "following_default": "Following default", + "following_system": "Following system", + "system_value": "System: {value}", "clear_override": "Clear override" }, "toasts": { diff --git a/modules/feature_flags/feature_flags/models.py b/modules/feature_flags/feature_flags/models.py index 736a0a62..4923e79e 100644 --- a/modules/feature_flags/feature_flags/models.py +++ b/modules/feature_flags/feature_flags/models.py @@ -4,6 +4,10 @@ ``register_feature_flags``. The definition of a flag (name, description, default) lives in code; this table only records admin-applied overrides so they survive across app restarts. + +A row's ``scope`` selects whether the override applies globally +(``system``, ``scope_id=""``) or to a single tenant (``tenant``, +``scope_id=``). Resolution at runtime: tenant > system > default. """ from __future__ import annotations @@ -13,17 +17,26 @@ from sqlalchemy import UniqueConstraint from sqlmodel import Field -from feature_flags.constants import TABLE_OVERRIDE, UQ_OVERRIDE_NAME +from feature_flags.constants import ( + SCOPE_ID_MAX_LENGTH, + SCOPE_MAX_LENGTH, + SCOPE_SYSTEM, + SYSTEM_SCOPE_ID, + TABLE_OVERRIDE, + UQ_OVERRIDE_SCOPE_NAME, +) Base = create_module_base("feature_flags") class FeatureFlagOverride(Base, AuditMixin, table=True): # ty: ignore[unsupported-base] - """Persisted override for a single feature flag.""" + """Persisted override for a single feature flag at a given scope.""" __tablename__ = TABLE_OVERRIDE - __table_args__ = (UniqueConstraint("name", name=UQ_OVERRIDE_NAME),) + __table_args__ = (UniqueConstraint("scope", "scope_id", "name", name=UQ_OVERRIDE_SCOPE_NAME),) id: int | None = Field(default=None, primary_key=True) + scope: str = Field(default=SCOPE_SYSTEM, max_length=SCOPE_MAX_LENGTH, index=True) + scope_id: str = Field(default=SYSTEM_SCOPE_ID, max_length=SCOPE_ID_MAX_LENGTH, index=True) name: str = Field(max_length=200, index=True) enabled: bool = Field(default=False) diff --git a/modules/feature_flags/feature_flags/pages/Browse.tsx b/modules/feature_flags/feature_flags/pages/Browse.tsx index 042b3790..934c79bf 100644 --- a/modules/feature_flags/feature_flags/pages/Browse.tsx +++ b/modules/feature_flags/feature_flags/pages/Browse.tsx @@ -10,6 +10,7 @@ import { EmptyMedia, EmptyTitle, } from '@simple-module/ui/components/ui/empty'; +import { Input } from '@simple-module/ui/components/ui/input'; import { Switch } from '@simple-module/ui/components/ui/switch'; import { Table, @@ -22,6 +23,7 @@ import { import { usePermissions } from '@simple-module/ui/hooks/use-permissions'; import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; import { Flag, RotateCcw } from 'lucide-react'; +import { useState } from 'react'; import { toast } from 'sonner'; interface FeatureFlag { @@ -30,21 +32,34 @@ interface FeatureFlag { default_enabled: boolean; enabled: boolean; overridden: boolean; + system_enabled: boolean | null; } interface Props { flags: FeatureFlag[]; + tenant_id: string | null; + tenants: string[]; +} + +function buildPath(tenantId: string | null) { + return tenantId ? `/feature_flags?tenant_id=${encodeURIComponent(tenantId)}` : '/feature_flags'; +} + +function actionUrl(name: string, action: 'toggle' | 'clear', tenantId: string | null) { + const base = `/feature_flags/${name}/${action}`; + return tenantId ? `${base}?tenant_id=${encodeURIComponent(tenantId)}` : base; } function Browse() { - const { flags } = usePage<{ props: Props }>().props as unknown as Props; + const { flags, tenant_id, tenants } = usePage<{ props: Props }>().props as unknown as Props; const { t } = useT(); const { can } = usePermissions(); const canManage = can('feature_flags.manage'); + const [tenantInput, setTenantInput] = useState(tenant_id ?? ''); function handleToggle(flag: FeatureFlag, next: boolean) { router.post( - `/feature_flags/${flag.name}/toggle`, + actionUrl(flag.name, 'toggle', tenant_id), { enabled: next }, { preserveScroll: true, @@ -61,7 +76,7 @@ function Browse() { function handleClear(flag: FeatureFlag) { router.post( - `/feature_flags/${flag.name}/clear`, + actionUrl(flag.name, 'clear', tenant_id), {}, { preserveScroll: true, @@ -71,11 +86,68 @@ function Browse() { ); } + function visitTenant(value: string) { + router.visit(buildPath(value.trim() || null)); + } + return ( + +
{ + e.preventDefault(); + visitTenant(tenantInput); + }} + > +
+ + setTenantInput(e.target.value)} + placeholder={t(keys.feature_flags.browse.tenant_id_placeholder)} + /> +
+ + {tenant_id && ( + + )} +
+ {tenants.length > 0 && ( +
+ + {t(keys.feature_flags.browse.tenants_with_overrides)}: + + {tenants.map((tid) => ( + + ))} +
+ )} +

+ {tenant_id + ? t(keys.feature_flags.browse.viewing_tenant, { tenant_id }) + : t(keys.feature_flags.browse.viewing_system)} +

+
+
{flags.length > 0 && (

@@ -136,11 +208,22 @@ function Browse() { disabled={!canManage} aria-label={flag.name} /> - - {flag.enabled - ? t(keys.feature_flags.table.enabled) - : t(keys.feature_flags.table.disabled)} - +

+ + {flag.enabled + ? t(keys.feature_flags.table.enabled) + : t(keys.feature_flags.table.disabled)} + + {tenant_id && flag.system_enabled !== null && ( + + {t(keys.feature_flags.table.system_value, { + value: flag.system_enabled + ? t(keys.feature_flags.table.enabled) + : t(keys.feature_flags.table.disabled), + })} + + )} +
{canManage && ( @@ -159,7 +242,9 @@ function Browse() { ) : ( - {t(keys.feature_flags.table.following_default)} + {tenant_id + ? t(keys.feature_flags.table.following_system) + : t(keys.feature_flags.table.following_default)} )} diff --git a/modules/feature_flags/feature_flags/service.py b/modules/feature_flags/feature_flags/service.py index 32b87878..f9aca610 100644 --- a/modules/feature_flags/feature_flags/service.py +++ b/modules/feature_flags/feature_flags/service.py @@ -1,4 +1,14 @@ -"""FeatureFlagService — manages persisted overrides and syncs them to the registry.""" +"""FeatureFlagService — manages persisted overrides and syncs them to the registry. + +Two scopes are supported: + +* **system** — applied to every request (mirrored to the registry's + system override map at write time) +* **tenant** — applied only when ``is_enabled`` is called with a matching + ``tenant_id`` (mirrored to the per-tenant override map) + +Resolution at runtime: tenant override > system override > definition default. +""" from __future__ import annotations @@ -6,94 +16,173 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from feature_flags.constants import SCOPE_SYSTEM, SCOPE_TENANT, SYSTEM_SCOPE_ID from feature_flags.contracts.schemas import FeatureFlagOverrideOut, FeatureFlagView from feature_flags.models import FeatureFlagOverride +def _registry_tenant_id(scope: str, scope_id: str) -> str | None: + """Translate a (scope, scope_id) pair into the registry's tenant_id arg.""" + return scope_id if scope == SCOPE_TENANT else None + + class FeatureFlagService: """Read/write persisted overrides and keep the in-memory registry in sync. The registry is the source of truth for *which* flags exist and their defaults — those come from module code. This service owns the persisted - overrides: anything an admin toggles in the UI is written here and - mirrored to ``registry._overrides`` so ``is_enabled`` returns the right - value for subsequent requests. + overrides at both system and tenant scope, mirroring every mutation back + into the registry so ``is_enabled`` returns the right value without an + extra DB hit per request. """ def __init__(self, db: AsyncSession) -> None: self.db = db + # ── Listing ───────────────────────────────────────────────────── + async def list_overrides(self) -> list[FeatureFlagOverrideOut]: + """Every persisted override across all scopes, ordered for stable display.""" result = await self.db.execute( - select(FeatureFlagOverride).order_by(FeatureFlagOverride.name) + select(FeatureFlagOverride).order_by( + FeatureFlagOverride.scope, + FeatureFlagOverride.scope_id, + FeatureFlagOverride.name, + ) ) return [FeatureFlagOverrideOut.model_validate(row) for row in result.scalars()] - async def _get_by_name(self, name: str) -> FeatureFlagOverride | None: + async def list_tenants_with_overrides(self) -> list[str]: + """Distinct tenant_ids that have at least one tenant-scope override.""" result = await self.db.execute( - select(FeatureFlagOverride).where(FeatureFlagOverride.name == name) + select(FeatureFlagOverride.scope_id) + .where(FeatureFlagOverride.scope == SCOPE_TENANT) + .distinct() + .order_by(FeatureFlagOverride.scope_id) ) - return result.scalar_one_or_none() + return list(result.scalars()) - async def list_flags(self, registry: FeatureFlagRegistry) -> list[FeatureFlagView]: + async def list_flags( + self, registry: FeatureFlagRegistry, tenant_id: str | None = None + ) -> list[FeatureFlagView]: """Join registered definitions with persisted overrides for admin display. - Definitions the registry doesn't know about are silently dropped — an - override whose flag was removed from code is stale and should be - cleared rather than surfaced as a "ghost" row. + When ``tenant_id`` is None, the view is system-scoped: ``enabled`` + reflects the system override (or default), and ``overridden`` is + true when a system row exists. When ``tenant_id`` is set, the view + is for that tenant: ``enabled`` is the resolved value the tenant + would see at runtime, ``overridden`` flags whether *this tenant* + has its own override, and ``system_enabled`` reports the value that + would apply if the tenant override were cleared. + + Definitions the registry doesn't know about are silently dropped — + an override whose flag was removed from code is stale and shouldn't + leak into the admin list as a ghost entry. """ - overrides = {o.name: o.enabled for o in await self.list_overrides()} + rows = await self.list_overrides() + system_overrides: dict[str, bool] = { + row.name: row.enabled + for row in rows + if row.scope == SCOPE_SYSTEM and row.scope_id == SYSTEM_SCOPE_ID + } + tenant_overrides: dict[str, bool] = ( + { + row.name: row.enabled + for row in rows + if row.scope == SCOPE_TENANT and row.scope_id == tenant_id + } + if tenant_id is not None + else {} + ) + views: list[FeatureFlagView] = [] for flag in sorted(registry.all_flags, key=lambda f: f.name): - overridden = flag.name in overrides - effective = overrides[flag.name] if overridden else flag.default_enabled + system_enabled = system_overrides.get(flag.name, flag.default_enabled) + if tenant_id is None: + views.append( + FeatureFlagView( + name=flag.name, + description=flag.description, + default_enabled=flag.default_enabled, + enabled=system_enabled, + overridden=flag.name in system_overrides, + ) + ) + continue + has_tenant = flag.name in tenant_overrides + effective = tenant_overrides[flag.name] if has_tenant else system_enabled views.append( FeatureFlagView( name=flag.name, description=flag.description, default_enabled=flag.default_enabled, enabled=effective, - overridden=overridden, + overridden=has_tenant, + system_enabled=system_enabled, ) ) return views + # ── Internals ─────────────────────────────────────────────────── + + async def _find(self, scope: str, scope_id: str, name: str) -> FeatureFlagOverride | None: + result = await self.db.execute( + select(FeatureFlagOverride).where( + FeatureFlagOverride.scope == scope, + FeatureFlagOverride.scope_id == scope_id, + FeatureFlagOverride.name == name, + ) + ) + return result.scalar_one_or_none() + + # ── Mutations ─────────────────────────────────────────────────── + async def set_override( self, name: str, enabled: bool, registry: FeatureFlagRegistry | None = None, + scope: str = SCOPE_SYSTEM, + scope_id: str = SYSTEM_SCOPE_ID, ) -> FeatureFlagOverrideOut: - """Upsert an override and (optionally) mirror it to the in-memory registry.""" - existing = await self._get_by_name(name) + """Upsert an override at the given scope and mirror it to the registry.""" + existing = await self._find(scope, scope_id, name) if existing is None: - existing = FeatureFlagOverride(name=name, enabled=enabled) + existing = FeatureFlagOverride( + scope=scope, scope_id=scope_id, name=name, enabled=enabled + ) self.db.add(existing) else: existing.enabled = enabled await self.db.flush() await self.db.refresh(existing) if registry is not None: - registry.set_override(name, enabled) + registry.set_override(name, enabled, tenant_id=_registry_tenant_id(scope, scope_id)) return FeatureFlagOverrideOut.model_validate(existing) async def clear_override( self, name: str, registry: FeatureFlagRegistry | None = None, + scope: str = SCOPE_SYSTEM, + scope_id: str = SYSTEM_SCOPE_ID, ) -> bool: - """Delete an override and revert the registry to the flag's default.""" - existing = await self._get_by_name(name) + """Delete the override at the given scope and revert the registry layer.""" + existing = await self._find(scope, scope_id, name) if existing is None: return False await self.db.delete(existing) if registry is not None: - registry.clear_override(name) + registry.clear_override(name, tenant_id=_registry_tenant_id(scope, scope_id)) return True async def hydrate_registry(self, registry: FeatureFlagRegistry) -> int: - """Load every persisted override into the registry. Called once at boot.""" + """Load every persisted override (system + tenant) into the registry at boot.""" overrides = await self.list_overrides() for ovr in overrides: - registry.set_override(ovr.name, ovr.enabled) + registry.set_override( + ovr.name, + ovr.enabled, + tenant_id=_registry_tenant_id(ovr.scope, ovr.scope_id), + ) return len(overrides) diff --git a/modules/feature_flags/tests/test_feature_flags_api.py b/modules/feature_flags/tests/test_feature_flags_api.py new file mode 100644 index 00000000..f1b311f7 --- /dev/null +++ b/modules/feature_flags/tests/test_feature_flags_api.py @@ -0,0 +1,147 @@ +"""REST API tests for feature_flags — system + tenant scope endpoints.""" + +from __future__ import annotations + +import httpx + +# ── System scope ────────────────────────────────────────────────── + + +class TestFeatureFlagsAPI: + async def test_list_flags_returns_registered_flags( + self, authenticated_client: httpx.AsyncClient + ): + # Products module registers `products.bulk_import` + resp = await authenticated_client.get("/api/feature_flags/") + assert resp.status_code == 200 + names = {f["name"] for f in resp.json()} + assert "products.bulk_import" in names + + async def test_set_override_flips_flag(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.put( + "/api/feature_flags/products.bulk_import", + json={"enabled": True}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "products.bulk_import" + assert body["enabled"] is True + assert body["overridden"] is True + + async def test_set_override_unknown_flag_404s(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.put( + "/api/feature_flags/does.not.exist", + json={"enabled": True}, + ) + assert resp.status_code == 404 + + async def test_clear_override_reverts_to_default(self, authenticated_client: httpx.AsyncClient): + await authenticated_client.put( + "/api/feature_flags/products.bulk_import", + json={"enabled": True}, + ) + resp = await authenticated_client.delete("/api/feature_flags/products.bulk_import") + assert resp.status_code == 204 + + follow = await authenticated_client.get("/api/feature_flags/products.bulk_import") + assert follow.status_code == 200 + assert follow.json()["overridden"] is False + + async def test_clear_override_without_any_404s(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.delete("/api/feature_flags/products.bulk_import") + assert resp.status_code == 404 + + async def test_get_flag_returns_view(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/feature_flags/products.bulk_import") + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "products.bulk_import" + assert "default_enabled" in body + assert "overridden" in body + + +# ── Tenant scope ────────────────────────────────────────────────── + + +class TestFeatureFlagsTenantAPI: + async def test_set_tenant_override_creates_tenant_specific_row( + self, authenticated_client: httpx.AsyncClient + ): + resp = await authenticated_client.put( + "/api/feature_flags/tenant/acme/products.bulk_import", + json={"enabled": True}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "products.bulk_import" + assert body["enabled"] is True + assert body["overridden"] is True # acme has its own row + + async def test_tenant_override_does_not_leak_into_system_view( + self, authenticated_client: httpx.AsyncClient + ): + await authenticated_client.put( + "/api/feature_flags/tenant/acme/products.bulk_import", + json={"enabled": True}, + ) + resp = await authenticated_client.get("/api/feature_flags/products.bulk_import") + assert resp.status_code == 200 + # System view: no tenant_id → tenant override is invisible + assert resp.json()["overridden"] is False + + async def test_list_for_tenant_shows_tenant_resolution( + self, authenticated_client: httpx.AsyncClient + ): + # System on, tenant off → tenant view should report enabled=False + await authenticated_client.put( + "/api/feature_flags/products.bulk_import", + json={"enabled": True}, + ) + await authenticated_client.put( + "/api/feature_flags/tenant/acme/products.bulk_import", + json={"enabled": False}, + ) + resp = await authenticated_client.get("/api/feature_flags/tenant/acme") + assert resp.status_code == 200 + flag = next(f for f in resp.json() if f["name"] == "products.bulk_import") + assert flag["enabled"] is False + assert flag["overridden"] is True + assert flag["system_enabled"] is True + + async def test_clear_tenant_override_reverts_to_system( + self, authenticated_client: httpx.AsyncClient + ): + await authenticated_client.put( + "/api/feature_flags/products.bulk_import", + json={"enabled": True}, + ) + await authenticated_client.put( + "/api/feature_flags/tenant/acme/products.bulk_import", + json={"enabled": False}, + ) + resp = await authenticated_client.delete( + "/api/feature_flags/tenant/acme/products.bulk_import" + ) + assert resp.status_code == 204 + + follow = await authenticated_client.get("/api/feature_flags/tenant/acme") + flag = next(f for f in follow.json() if f["name"] == "products.bulk_import") + assert flag["enabled"] is True # back to system value + assert flag["overridden"] is False + + async def test_clear_tenant_override_without_any_404s( + self, authenticated_client: httpx.AsyncClient + ): + resp = await authenticated_client.delete( + "/api/feature_flags/tenant/acme/products.bulk_import" + ) + assert resp.status_code == 404 + + async def test_set_tenant_override_unknown_flag_404s( + self, authenticated_client: httpx.AsyncClient + ): + resp = await authenticated_client.put( + "/api/feature_flags/tenant/acme/does.not.exist", + json={"enabled": True}, + ) + assert resp.status_code == 404 diff --git a/modules/feature_flags/tests/test_feature_flags_module.py b/modules/feature_flags/tests/test_feature_flags_module.py deleted file mode 100644 index 82dccc7d..00000000 --- a/modules/feature_flags/tests/test_feature_flags_module.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Tests for the feature_flags module: service, registry sync, API, lifecycle.""" - -from __future__ import annotations - -import httpx -from feature_flags.service import FeatureFlagService -from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry -from sqlalchemy.ext.asyncio import AsyncSession - - -def _registry_with(*flags: tuple[str, str, bool]) -> FeatureFlagRegistry: - reg = FeatureFlagRegistry() - for name, desc, default in flags: - reg.add(FeatureFlagDefinition(name=name, description=desc, default_enabled=default)) - return reg - - -# ── FeatureFlagService ───────────────────────────────────────────── - - -class TestFeatureFlagService: - async def test_set_override_inserts_row_and_mirrors_to_registry(self, db_session: AsyncSession): - svc = FeatureFlagService(db_session) - reg = _registry_with(("beta_ui", "Beta UI", False)) - - result = await svc.set_override("beta_ui", True, registry=reg) - - assert result.name == "beta_ui" - assert result.enabled is True - assert reg.is_enabled("beta_ui") is True - - async def test_set_override_twice_updates_in_place(self, db_session: AsyncSession): - svc = FeatureFlagService(db_session) - reg = _registry_with(("beta_ui", "", False)) - - await svc.set_override("beta_ui", True, registry=reg) - await svc.set_override("beta_ui", False, registry=reg) - - rows = await svc.list_overrides() - assert len(rows) == 1 - assert rows[0].enabled is False - assert reg.is_enabled("beta_ui") is False - - async def test_clear_override_removes_row_and_reverts_registry(self, db_session: AsyncSession): - svc = FeatureFlagService(db_session) - reg = _registry_with(("beta_ui", "", False)) - await svc.set_override("beta_ui", True, registry=reg) - - cleared = await svc.clear_override("beta_ui", registry=reg) - - assert cleared is True - assert await svc.list_overrides() == [] - assert reg.is_enabled("beta_ui") is False # back to default - - async def test_clear_override_unknown_flag_is_noop(self, db_session: AsyncSession): - svc = FeatureFlagService(db_session) - assert await svc.clear_override("ghost") is False - - async def test_list_flags_joins_registry_with_overrides(self, db_session: AsyncSession): - svc = FeatureFlagService(db_session) - reg = _registry_with( - ("a.flag", "A", False), - ("b.flag", "B", True), - ) - await svc.set_override("a.flag", True, registry=reg) - - views = await svc.list_flags(reg) - - by_name = {v.name: v for v in views} - assert by_name["a.flag"].enabled is True - assert by_name["a.flag"].overridden is True - assert by_name["a.flag"].default_enabled is False - assert by_name["b.flag"].enabled is True - assert by_name["b.flag"].overridden is False - - async def test_list_flags_drops_stale_overrides(self, db_session: AsyncSession): - """Overrides for flags the registry no longer knows about are hidden. - - The row stays in the DB (so a reintroduced flag picks it back up), but - it doesn't leak into the admin list as a ghost entry. - """ - svc = FeatureFlagService(db_session) - reg_old = _registry_with(("legacy", "", False)) - await svc.set_override("legacy", True, registry=reg_old) - - reg_new = _registry_with(("current", "", False)) - views = await svc.list_flags(reg_new) - - assert [v.name for v in views] == ["current"] - - async def test_hydrate_registry_applies_all_overrides(self, db_session: AsyncSession): - svc = FeatureFlagService(db_session) - # simulate overrides persisted from a previous boot - await svc.set_override("a", True) - await svc.set_override("b", False) - - fresh = _registry_with(("a", "", False), ("b", "", True)) - count = await svc.hydrate_registry(fresh) - - assert count == 2 - assert fresh.is_enabled("a") is True - assert fresh.is_enabled("b") is False - - -# ── REST API ─────────────────────────────────────────────────────── - - -class TestFeatureFlagsAPI: - async def test_list_flags_returns_registered_flags( - self, authenticated_client: httpx.AsyncClient - ): - # Products module registers `products.bulk_import` - resp = await authenticated_client.get("/api/feature_flags/") - assert resp.status_code == 200 - names = {f["name"] for f in resp.json()} - assert "products.bulk_import" in names - - async def test_set_override_flips_flag(self, authenticated_client: httpx.AsyncClient): - resp = await authenticated_client.put( - "/api/feature_flags/products.bulk_import", - json={"enabled": True}, - ) - assert resp.status_code == 200 - body = resp.json() - assert body["name"] == "products.bulk_import" - assert body["enabled"] is True - assert body["overridden"] is True - - async def test_set_override_unknown_flag_404s(self, authenticated_client: httpx.AsyncClient): - resp = await authenticated_client.put( - "/api/feature_flags/does.not.exist", - json={"enabled": True}, - ) - assert resp.status_code == 404 - - async def test_clear_override_reverts_to_default(self, authenticated_client: httpx.AsyncClient): - # Create the override first - await authenticated_client.put( - "/api/feature_flags/products.bulk_import", - json={"enabled": True}, - ) - resp = await authenticated_client.delete("/api/feature_flags/products.bulk_import") - assert resp.status_code == 204 - - # Follow-up read shows the default state, no override - follow = await authenticated_client.get("/api/feature_flags/products.bulk_import") - assert follow.status_code == 200 - assert follow.json()["overridden"] is False - - async def test_clear_override_without_any_404s(self, authenticated_client: httpx.AsyncClient): - resp = await authenticated_client.delete("/api/feature_flags/products.bulk_import") - assert resp.status_code == 404 - - async def test_get_flag_returns_view(self, authenticated_client: httpx.AsyncClient): - resp = await authenticated_client.get("/api/feature_flags/products.bulk_import") - assert resp.status_code == 200 - body = resp.json() - assert body["name"] == "products.bulk_import" - assert "default_enabled" in body - assert "overridden" in body - - -# ── Module lifecycle ─────────────────────────────────────────────── - - -class TestFeatureFlagsModuleLifecycle: - async def test_on_startup_hydrates_registry_from_db(self, app): - """After lifespan, overrides stored before startup should be on the registry.""" - from feature_flags.models import FeatureFlagOverride - - sm = app.state.sm - async with sm.db.session_factory() as session: - session.add(FeatureFlagOverride(name="products.bulk_import", enabled=True)) - await session.commit() - - # Re-run the module's on_startup against the same app to simulate a boot - from feature_flags.module import FeatureFlagsModule - - await FeatureFlagsModule().on_startup(app) - assert sm.feature_flags.is_enabled("products.bulk_import") is True diff --git a/modules/feature_flags/tests/test_feature_flags_service.py b/modules/feature_flags/tests/test_feature_flags_service.py new file mode 100644 index 00000000..082f8d7b --- /dev/null +++ b/modules/feature_flags/tests/test_feature_flags_service.py @@ -0,0 +1,220 @@ +"""Tests for FeatureFlagService and module lifecycle (system + tenant scopes).""" + +from __future__ import annotations + +from feature_flags.constants import SCOPE_TENANT +from feature_flags.service import FeatureFlagService +from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry +from sqlalchemy.ext.asyncio import AsyncSession + + +def _registry_with(*flags: tuple[str, str, bool]) -> FeatureFlagRegistry: + reg = FeatureFlagRegistry() + for name, desc, default in flags: + reg.add(FeatureFlagDefinition(name=name, description=desc, default_enabled=default)) + return reg + + +# ── System scope ────────────────────────────────────────────────── + + +class TestFeatureFlagServiceSystem: + async def test_set_override_inserts_row_and_mirrors_to_registry(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with(("beta_ui", "Beta UI", False)) + + result = await svc.set_override("beta_ui", True, registry=reg) + + assert result.name == "beta_ui" + assert result.scope == "system" + assert result.enabled is True + assert reg.is_enabled("beta_ui") is True + + async def test_set_override_twice_updates_in_place(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with(("beta_ui", "", False)) + + await svc.set_override("beta_ui", True, registry=reg) + await svc.set_override("beta_ui", False, registry=reg) + + rows = await svc.list_overrides() + assert len(rows) == 1 + assert rows[0].enabled is False + assert reg.is_enabled("beta_ui") is False + + async def test_clear_override_removes_row_and_reverts_registry(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with(("beta_ui", "", False)) + await svc.set_override("beta_ui", True, registry=reg) + + cleared = await svc.clear_override("beta_ui", registry=reg) + + assert cleared is True + assert await svc.list_overrides() == [] + assert reg.is_enabled("beta_ui") is False # back to default + + async def test_clear_override_unknown_flag_is_noop(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + assert await svc.clear_override("ghost") is False + + async def test_list_flags_joins_registry_with_overrides(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with( + ("a.flag", "A", False), + ("b.flag", "B", True), + ) + await svc.set_override("a.flag", True, registry=reg) + + views = await svc.list_flags(reg) + + by_name = {v.name: v for v in views} + assert by_name["a.flag"].enabled is True + assert by_name["a.flag"].overridden is True + assert by_name["a.flag"].default_enabled is False + assert by_name["b.flag"].enabled is True + assert by_name["b.flag"].overridden is False + + async def test_list_flags_drops_stale_overrides(self, db_session: AsyncSession): + """Overrides for flags the registry no longer knows about are hidden. + + The row stays in the DB (so a reintroduced flag picks it back up), but + it doesn't leak into the admin list as a ghost entry. + """ + svc = FeatureFlagService(db_session) + reg_old = _registry_with(("legacy", "", False)) + await svc.set_override("legacy", True, registry=reg_old) + + reg_new = _registry_with(("current", "", False)) + views = await svc.list_flags(reg_new) + + assert [v.name for v in views] == ["current"] + + +# ── Tenant scope ────────────────────────────────────────────────── + + +class TestFeatureFlagServiceTenant: + async def test_tenant_override_stored_separately_from_system(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with(("beta_ui", "", False)) + + await svc.set_override("beta_ui", True, registry=reg) # system on + await svc.set_override("beta_ui", False, registry=reg, scope=SCOPE_TENANT, scope_id="acme") + + rows = await svc.list_overrides() + assert len(rows) == 2 + # tenant row beats system for that tenant + assert reg.is_enabled("beta_ui", tenant_id="acme") is False + # other tenants fall back to system + assert reg.is_enabled("beta_ui", tenant_id="other") is True + # No tenant context: pure system value + assert reg.is_enabled("beta_ui") is True + + async def test_clear_tenant_override_only_clears_that_tenant(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with(("beta_ui", "", False)) + await svc.set_override("beta_ui", True, registry=reg, scope=SCOPE_TENANT, scope_id="acme") + await svc.set_override("beta_ui", True, registry=reg, scope=SCOPE_TENANT, scope_id="globex") + + cleared = await svc.clear_override( + "beta_ui", registry=reg, scope=SCOPE_TENANT, scope_id="acme" + ) + + assert cleared is True + assert reg.is_enabled("beta_ui", tenant_id="acme") is False + assert reg.is_enabled("beta_ui", tenant_id="globex") is True + + async def test_list_flags_for_tenant_shows_resolved_and_system(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with(("beta_ui", "", False)) + await svc.set_override("beta_ui", True, registry=reg) # system on + await svc.set_override("beta_ui", False, registry=reg, scope=SCOPE_TENANT, scope_id="acme") + + views = await svc.list_flags(reg, tenant_id="acme") + + view = next(v for v in views if v.name == "beta_ui") + assert view.enabled is False # tenant value wins + assert view.overridden is True # acme has its own row + assert view.system_enabled is True # what they'd inherit if cleared + + async def test_list_flags_for_tenant_without_override_inherits_system( + self, db_session: AsyncSession + ): + svc = FeatureFlagService(db_session) + reg = _registry_with(("beta_ui", "", False)) + await svc.set_override("beta_ui", True, registry=reg) # system on + + views = await svc.list_flags(reg, tenant_id="acme") + + view = next(v for v in views if v.name == "beta_ui") + assert view.enabled is True # inherits from system + assert view.overridden is False # acme has no row of its own + assert view.system_enabled is True + + async def test_list_tenants_with_overrides(self, db_session: AsyncSession): + svc = FeatureFlagService(db_session) + reg = _registry_with(("a", "", False), ("b", "", False)) + await svc.set_override("a", True, registry=reg, scope=SCOPE_TENANT, scope_id="acme") + await svc.set_override("b", True, registry=reg, scope=SCOPE_TENANT, scope_id="globex") + await svc.set_override("a", True, registry=reg) # system, ignored + + tenants = await svc.list_tenants_with_overrides() + + assert tenants == ["acme", "globex"] + + async def test_hydrate_registry_applies_system_and_tenant_overrides( + self, db_session: AsyncSession + ): + svc = FeatureFlagService(db_session) + await svc.set_override("a", True) # system + await svc.set_override("b", False) # system + await svc.set_override("a", False, scope=SCOPE_TENANT, scope_id="acme") + + fresh = _registry_with(("a", "", True), ("b", "", True)) + count = await svc.hydrate_registry(fresh) + + assert count == 3 + assert fresh.is_enabled("a") is True # system override + assert fresh.is_enabled("a", tenant_id="acme") is False # tenant beats system + assert fresh.is_enabled("a", tenant_id="other") is True # falls back to system + assert fresh.is_enabled("b") is False + + +# ── Module lifecycle ─────────────────────────────────────────────── + + +class TestFeatureFlagsModuleLifecycle: + async def test_on_startup_hydrates_registry_from_db(self, app): + """After lifespan, overrides stored before startup should be on the registry.""" + from feature_flags.models import FeatureFlagOverride + + sm = app.state.sm + async with sm.db.session_factory() as session: + session.add( + FeatureFlagOverride( + scope="system", + scope_id="", + name="products.bulk_import", + enabled=True, + ) + ) + session.add( + FeatureFlagOverride( + scope="tenant", + scope_id="acme", + name="products.bulk_import", + enabled=False, + ) + ) + await session.commit() + + from feature_flags.module import FeatureFlagsModule + + await FeatureFlagsModule().on_startup(app) + + # System: enabled + assert sm.feature_flags.is_enabled("products.bulk_import") is True + # acme tenant: disabled (tenant beats system) + assert sm.feature_flags.is_enabled("products.bulk_import", tenant_id="acme") is False + # other tenant: still inherits system + assert sm.feature_flags.is_enabled("products.bulk_import", tenant_id="other") is True diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index dc91872e..8cf3ffc8 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -59,12 +59,22 @@ export default { 'dashboard.home.title': '', 'dashboard.home.welcome_card_title': '', 'dashboard.home.welcome_message': '', + 'feature_flags.browse.back_to_system': '', 'feature_flags.browse.count_one': '', 'feature_flags.browse.count_other': '', 'feature_flags.browse.description': '', 'feature_flags.browse.empty_description': '', 'feature_flags.browse.empty_title': '', + 'feature_flags.browse.go': '', + 'feature_flags.browse.scope_label': '', + 'feature_flags.browse.scope_system': '', + 'feature_flags.browse.scope_tenant': '', + 'feature_flags.browse.tenant_id_label': '', + 'feature_flags.browse.tenant_id_placeholder': '', + 'feature_flags.browse.tenants_with_overrides': '', 'feature_flags.browse.title': '', + 'feature_flags.browse.viewing_system': '', + 'feature_flags.browse.viewing_tenant': '', 'feature_flags.table.actions': '', 'feature_flags.table.clear_override': '', 'feature_flags.table.default': '', @@ -72,9 +82,11 @@ export default { 'feature_flags.table.disabled': '', 'feature_flags.table.enabled': '', 'feature_flags.table.following_default': '', + 'feature_flags.table.following_system': '', 'feature_flags.table.name': '', 'feature_flags.table.overridden': '', 'feature_flags.table.status': '', + 'feature_flags.table.system_value': '', 'feature_flags.toasts.cleared': '', 'feature_flags.toasts.disabled': '', 'feature_flags.toasts.enabled': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index aeaaced6..7d739e2f 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -89,13 +89,23 @@ export const keys = { }, feature_flags: { browse: { + back_to_system: 'feature_flags.browse.back_to_system', count: 'feature_flags.browse.count', count_one: 'feature_flags.browse.count_one', count_other: 'feature_flags.browse.count_other', description: 'feature_flags.browse.description', empty_description: 'feature_flags.browse.empty_description', empty_title: 'feature_flags.browse.empty_title', + go: 'feature_flags.browse.go', + scope_label: 'feature_flags.browse.scope_label', + scope_system: 'feature_flags.browse.scope_system', + scope_tenant: 'feature_flags.browse.scope_tenant', + tenant_id_label: 'feature_flags.browse.tenant_id_label', + tenant_id_placeholder: 'feature_flags.browse.tenant_id_placeholder', + tenants_with_overrides: 'feature_flags.browse.tenants_with_overrides', title: 'feature_flags.browse.title', + viewing_system: 'feature_flags.browse.viewing_system', + viewing_tenant: 'feature_flags.browse.viewing_tenant', }, table: { actions: 'feature_flags.table.actions', @@ -105,9 +115,11 @@ export const keys = { disabled: 'feature_flags.table.disabled', enabled: 'feature_flags.table.enabled', following_default: 'feature_flags.table.following_default', + following_system: 'feature_flags.table.following_system', name: 'feature_flags.table.name', overridden: 'feature_flags.table.overridden', status: 'feature_flags.table.status', + system_value: 'feature_flags.table.system_value', }, toasts: { cleared: 'feature_flags.toasts.cleared', From 0fb11f33b08cafb8877b2fd75173c0af218ce965 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 20:35:39 +0000 Subject: [PATCH 3/6] Simplify feature_flags service: in-memory reads, fewer DB round-trips list_flags now reads override state from the in-memory registry instead of refetching the overrides table on every admin page load. set_override skips no-op writes and the redundant post-flush refresh; API write handlers build the response view directly via service.build_view rather than re-listing all flags. Also aligns IFeatureFlagService with the concrete service signatures and trims narrative comments and section dividers. --- .../feature_flags/feature_flags/constants.py | 13 +-- .../feature_flags/contracts/schemas.py | 2 - .../feature_flags/contracts/service.py | 22 +++- .../feature_flags/endpoints/api.py | 39 ++----- .../feature_flags/feature_flags/service.py | 105 +++++++++--------- .../tests/test_feature_flags_api.py | 5 - .../tests/test_feature_flags_service.py | 9 -- 7 files changed, 87 insertions(+), 108 deletions(-) diff --git a/modules/feature_flags/feature_flags/constants.py b/modules/feature_flags/feature_flags/constants.py index 8ec4af00..98a0aad8 100644 --- a/modules/feature_flags/feature_flags/constants.py +++ b/modules/feature_flags/feature_flags/constants.py @@ -1,38 +1,31 @@ """Stable identifiers for the feature_flags module.""" -# Permission identifiers PERM_FEATURE_FLAGS_VIEW = "feature_flags.view" PERM_FEATURE_FLAGS_MANAGE = "feature_flags.manage" -# DB table name (SQLite lacks per-module schemas, so the prefix is load-bearing) +# Prefix is load-bearing on SQLite, which has no per-module schemas. TABLE_OVERRIDE = "feature_flags_override" UQ_OVERRIDE_SCOPE_NAME = "uq_feature_flags_override_scope_scope_id_name" -# Override scopes — system applies globally, tenant applies only when a request -# carries that tenant_id. Resolution: tenant > system > definition default. SCOPE_SYSTEM = "system" SCOPE_TENANT = "tenant" ALL_SCOPES = (SCOPE_SYSTEM, SCOPE_TENANT) -# Empty string (not NULL) so the composite unique works on PG, where multiple -# NULLs would collide-free and let two "system" rows for the same flag exist. +# Empty string, not NULL: PG treats NULLs as distinct in unique indexes, +# which would let two "system" rows for the same flag coexist. SYSTEM_SCOPE_ID = "" SCOPE_MAX_LENGTH = 10 SCOPE_ID_MAX_LENGTH = 64 -# i18n namespace — matches locale_dirs() key LOCALE_NAMESPACE = "feature_flags" -# Menu metadata MENU_LABEL = "Feature Flags" MENU_URL = "/feature_flags" MENU_ICON = "flag" MENU_ORDER = 45 -# Inertia page identifiers PAGE_BROWSE = "FeatureFlags/Browse" -# Routing API_PREFIX = "/api/feature_flags" VIEW_PREFIX = "/feature_flags" QP_TENANT_ID = "tenant_id" diff --git a/modules/feature_flags/feature_flags/contracts/schemas.py b/modules/feature_flags/feature_flags/contracts/schemas.py index ddbea503..830fd4c1 100644 --- a/modules/feature_flags/feature_flags/contracts/schemas.py +++ b/modules/feature_flags/feature_flags/contracts/schemas.py @@ -38,8 +38,6 @@ class FeatureFlagView(SQLModel): default_enabled: bool enabled: bool overridden: bool - # Only populated when listing under a tenant scope; helps the UI render - # what would happen if the tenant override were cleared. system_enabled: bool | None = None diff --git a/modules/feature_flags/feature_flags/contracts/service.py b/modules/feature_flags/feature_flags/contracts/service.py index 0f0148d6..fa3c6e2c 100644 --- a/modules/feature_flags/feature_flags/contracts/service.py +++ b/modules/feature_flags/feature_flags/contracts/service.py @@ -13,7 +13,23 @@ class IFeatureFlagService(Protocol): """Interface for feature-flag management operations.""" async def list_overrides(self) -> list[FeatureFlagOverrideOut]: ... - async def list_flags(self, registry: FeatureFlagRegistry) -> list[FeatureFlagView]: ... - async def set_override(self, name: str, enabled: bool) -> FeatureFlagOverrideOut: ... - async def clear_override(self, name: str) -> bool: ... + async def list_tenants_with_overrides(self) -> list[str]: ... + async def list_flags( + self, registry: FeatureFlagRegistry, tenant_id: str | None = None + ) -> list[FeatureFlagView]: ... + async def set_override( + self, + name: str, + enabled: bool, + registry: FeatureFlagRegistry | None = None, + scope: str = ..., + scope_id: str = ..., + ) -> FeatureFlagOverrideOut: ... + async def clear_override( + self, + name: str, + registry: FeatureFlagRegistry | None = None, + scope: str = ..., + scope_id: str = ..., + ) -> bool: ... async def hydrate_registry(self, registry: FeatureFlagRegistry) -> int: ... diff --git a/modules/feature_flags/feature_flags/endpoints/api.py b/modules/feature_flags/feature_flags/endpoints/api.py index 32ae4033..ae5f1818 100644 --- a/modules/feature_flags/feature_flags/endpoints/api.py +++ b/modules/feature_flags/feature_flags/endpoints/api.py @@ -1,10 +1,4 @@ -"""REST API endpoints for feature_flags management. - -Two scopes are exposed: - -* **system** — flat routes (``GET /``, ``PUT /{name}``, ``DELETE /{name}``) -* **tenant** — routes under ``/tenant/{tenant_id}/...`` for per-tenant overrides -""" +"""REST API endpoints for feature_flags management.""" from __future__ import annotations @@ -24,15 +18,11 @@ def _ensure_registered(name: str, registry: FeatureFlagRegistry) -> None: - # Refuse overrides for names the registry doesn't know — a typo here would - # silently persist dead rows that list_flags() filters out later. + # A typo here would silently persist a dead row that ``list_flags`` filters out. if name not in {f.name for f in registry.all_flags}: raise HTTPException(status_code=404, detail="Feature flag not registered") -# ── System scope ────────────────────────────────────────────────── - - @router.get( "/", response_model=list[FeatureFlagView], @@ -55,10 +45,10 @@ async def get_flag( service: FeatureFlagServiceDep, registry: FeatureFlagRegistryDep, ) -> FeatureFlagView: - for flag in await service.list_flags(registry): - if flag.name == name: - return flag - raise HTTPException(status_code=404, detail="Feature flag not registered") + view = service.build_view(registry, name) + if view is None: + raise HTTPException(status_code=404, detail="Feature flag not registered") + return view @router.put( @@ -74,10 +64,9 @@ async def set_override( ) -> FeatureFlagView: _ensure_registered(name, registry) await service.set_override(name, body.enabled, registry=registry) - for flag in await service.list_flags(registry): - if flag.name == name: - return flag - raise HTTPException(status_code=500, detail="Override applied but flag vanished") + view = service.build_view(registry, name) + assert view is not None # _ensure_registered already proved it exists + return view @router.delete( @@ -95,9 +84,6 @@ async def clear_override( raise HTTPException(status_code=404, detail="No override set for this flag") -# ── Tenant scope ────────────────────────────────────────────────── - - @router.get( "/tenant/{tenant_id}", response_model=list[FeatureFlagView], @@ -127,10 +113,9 @@ async def set_tenant_override( await service.set_override( name, body.enabled, registry=registry, scope=SCOPE_TENANT, scope_id=tenant_id ) - for flag in await service.list_flags(registry, tenant_id=tenant_id): - if flag.name == name: - return flag - raise HTTPException(status_code=500, detail="Override applied but flag vanished") + view = service.build_view(registry, name, tenant_id=tenant_id) + assert view is not None # _ensure_registered already proved it exists + return view @router.delete( diff --git a/modules/feature_flags/feature_flags/service.py b/modules/feature_flags/feature_flags/service.py index f9aca610..3be9565d 100644 --- a/modules/feature_flags/feature_flags/service.py +++ b/modules/feature_flags/feature_flags/service.py @@ -1,18 +1,13 @@ """FeatureFlagService — manages persisted overrides and syncs them to the registry. -Two scopes are supported: - -* **system** — applied to every request (mirrored to the registry's - system override map at write time) -* **tenant** — applied only when ``is_enabled`` is called with a matching - ``tenant_id`` (mirrored to the per-tenant override map) - -Resolution at runtime: tenant override > system override > definition default. +Resolution semantics (tenant > system > default) live in +``simple_module_core.feature_flags``; this layer only persists overrides and +mirrors mutations into the registry. """ from __future__ import annotations -from simple_module_core.feature_flags import FeatureFlagRegistry +from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -26,6 +21,23 @@ def _registry_tenant_id(scope: str, scope_id: str) -> str | None: return scope_id if scope == SCOPE_TENANT else None +def _build_view( + flag: FeatureFlagDefinition, + *, + enabled: bool, + overridden: bool, + system_enabled: bool | None = None, +) -> FeatureFlagView: + return FeatureFlagView( + name=flag.name, + description=flag.description, + default_enabled=flag.default_enabled, + enabled=enabled, + overridden=overridden, + system_enabled=system_enabled, + ) + + class FeatureFlagService: """Read/write persisted overrides and keep the in-memory registry in sync. @@ -39,8 +51,6 @@ class FeatureFlagService: def __init__(self, db: AsyncSession) -> None: self.db = db - # ── Listing ───────────────────────────────────────────────────── - async def list_overrides(self) -> list[FeatureFlagOverrideOut]: """Every persisted override across all scopes, ordered for stable display.""" result = await self.db.execute( @@ -75,55 +85,48 @@ async def list_flags( has its own override, and ``system_enabled`` reports the value that would apply if the tenant override were cleared. - Definitions the registry doesn't know about are silently dropped — - an override whose flag was removed from code is stale and shouldn't - leak into the admin list as a ghost entry. + Reads override state from the in-memory registry (kept in sync by + every mutation and rehydrated at startup) rather than re-querying + the DB, so admin page loads don't pay an O(rows) scan per request. """ - rows = await self.list_overrides() - system_overrides: dict[str, bool] = { - row.name: row.enabled - for row in rows - if row.scope == SCOPE_SYSTEM and row.scope_id == SYSTEM_SCOPE_ID - } - tenant_overrides: dict[str, bool] = ( - { - row.name: row.enabled - for row in rows - if row.scope == SCOPE_TENANT and row.scope_id == tenant_id - } - if tenant_id is not None - else {} - ) - views: list[FeatureFlagView] = [] for flag in sorted(registry.all_flags, key=lambda f: f.name): - system_enabled = system_overrides.get(flag.name, flag.default_enabled) + system_value = registry.system_override(flag.name) + system_enabled = system_value if system_value is not None else flag.default_enabled if tenant_id is None: views.append( - FeatureFlagView( - name=flag.name, - description=flag.description, - default_enabled=flag.default_enabled, - enabled=system_enabled, - overridden=flag.name in system_overrides, - ) + _build_view(flag, enabled=system_enabled, overridden=system_value is not None) ) continue - has_tenant = flag.name in tenant_overrides - effective = tenant_overrides[flag.name] if has_tenant else system_enabled + tenant_value = registry.tenant_override(flag.name, tenant_id) views.append( - FeatureFlagView( - name=flag.name, - description=flag.description, - default_enabled=flag.default_enabled, - enabled=effective, - overridden=has_tenant, + _build_view( + flag, + enabled=tenant_value if tenant_value is not None else system_enabled, + overridden=tenant_value is not None, system_enabled=system_enabled, ) ) return views - # ── Internals ─────────────────────────────────────────────────── + def build_view( + self, registry: FeatureFlagRegistry, name: str, tenant_id: str | None = None + ) -> FeatureFlagView | None: + """Single-flag variant of ``list_flags`` for write-path responses.""" + flag = next((f for f in registry.all_flags if f.name == name), None) + if flag is None: + return None + system_value = registry.system_override(name) + system_enabled = system_value if system_value is not None else flag.default_enabled + if tenant_id is None: + return _build_view(flag, enabled=system_enabled, overridden=system_value is not None) + tenant_value = registry.tenant_override(name, tenant_id) + return _build_view( + flag, + enabled=tenant_value if tenant_value is not None else system_enabled, + overridden=tenant_value is not None, + system_enabled=system_enabled, + ) async def _find(self, scope: str, scope_id: str, name: str) -> FeatureFlagOverride | None: result = await self.db.execute( @@ -135,8 +138,6 @@ async def _find(self, scope: str, scope_id: str, name: str) -> FeatureFlagOverri ) return result.scalar_one_or_none() - # ── Mutations ─────────────────────────────────────────────────── - async def set_override( self, name: str, @@ -152,10 +153,10 @@ async def set_override( scope=scope, scope_id=scope_id, name=name, enabled=enabled ) self.db.add(existing) - else: + await self.db.flush() + elif existing.enabled != enabled: existing.enabled = enabled - await self.db.flush() - await self.db.refresh(existing) + await self.db.flush() if registry is not None: registry.set_override(name, enabled, tenant_id=_registry_tenant_id(scope, scope_id)) return FeatureFlagOverrideOut.model_validate(existing) diff --git a/modules/feature_flags/tests/test_feature_flags_api.py b/modules/feature_flags/tests/test_feature_flags_api.py index f1b311f7..8e3ecc1c 100644 --- a/modules/feature_flags/tests/test_feature_flags_api.py +++ b/modules/feature_flags/tests/test_feature_flags_api.py @@ -4,8 +4,6 @@ import httpx -# ── System scope ────────────────────────────────────────────────── - class TestFeatureFlagsAPI: async def test_list_flags_returns_registered_flags( @@ -60,9 +58,6 @@ async def test_get_flag_returns_view(self, authenticated_client: httpx.AsyncClie assert "overridden" in body -# ── Tenant scope ────────────────────────────────────────────────── - - class TestFeatureFlagsTenantAPI: async def test_set_tenant_override_creates_tenant_specific_row( self, authenticated_client: httpx.AsyncClient diff --git a/modules/feature_flags/tests/test_feature_flags_service.py b/modules/feature_flags/tests/test_feature_flags_service.py index 082f8d7b..53c8435a 100644 --- a/modules/feature_flags/tests/test_feature_flags_service.py +++ b/modules/feature_flags/tests/test_feature_flags_service.py @@ -15,9 +15,6 @@ def _registry_with(*flags: tuple[str, str, bool]) -> FeatureFlagRegistry: return reg -# ── System scope ────────────────────────────────────────────────── - - class TestFeatureFlagServiceSystem: async def test_set_override_inserts_row_and_mirrors_to_registry(self, db_session: AsyncSession): svc = FeatureFlagService(db_session) @@ -90,9 +87,6 @@ async def test_list_flags_drops_stale_overrides(self, db_session: AsyncSession): assert [v.name for v in views] == ["current"] -# ── Tenant scope ────────────────────────────────────────────────── - - class TestFeatureFlagServiceTenant: async def test_tenant_override_stored_separately_from_system(self, db_session: AsyncSession): svc = FeatureFlagService(db_session) @@ -180,9 +174,6 @@ async def test_hydrate_registry_applies_system_and_tenant_overrides( assert fresh.is_enabled("b") is False -# ── Module lifecycle ─────────────────────────────────────────────── - - class TestFeatureFlagsModuleLifecycle: async def test_on_startup_hydrates_registry_from_db(self, app): """After lifespan, overrides stored before startup should be on the registry.""" From 32c65bc85cd88b6d7b2e4241c9b830c1443faf14 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 08:46:50 +0000 Subject: [PATCH 4/6] =?UTF-8?q?Drop=20IFeatureFlagService=20Protocol=20?= =?UTF-8?q?=E2=80=94=20no=20alternate=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the rule documented in #39: ship a service Protocol only for real extension points. Nothing outside feature_flags imports the interface; deps.py wires the concrete service and endpoints type-hint against FeatureFlagService directly. --- .../feature_flags/contracts/__init__.py | 2 -- .../feature_flags/contracts/service.py | 35 ------------------- 2 files changed, 37 deletions(-) delete mode 100644 modules/feature_flags/feature_flags/contracts/service.py diff --git a/modules/feature_flags/feature_flags/contracts/__init__.py b/modules/feature_flags/feature_flags/contracts/__init__.py index 6b8ee160..beaa3cfb 100644 --- a/modules/feature_flags/feature_flags/contracts/__init__.py +++ b/modules/feature_flags/feature_flags/contracts/__init__.py @@ -5,11 +5,9 @@ FeatureFlagView, ToggleRequest, ) -from feature_flags.contracts.service import IFeatureFlagService __all__ = [ "FeatureFlagOverrideOut", "FeatureFlagView", - "IFeatureFlagService", "ToggleRequest", ] diff --git a/modules/feature_flags/feature_flags/contracts/service.py b/modules/feature_flags/feature_flags/contracts/service.py deleted file mode 100644 index fa3c6e2c..00000000 --- a/modules/feature_flags/feature_flags/contracts/service.py +++ /dev/null @@ -1,35 +0,0 @@ -"""FeatureFlag service protocol — the public contract other modules depend on.""" - -from __future__ import annotations - -from typing import Protocol - -from simple_module_core.feature_flags import FeatureFlagRegistry - -from feature_flags.contracts.schemas import FeatureFlagOverrideOut, FeatureFlagView - - -class IFeatureFlagService(Protocol): - """Interface for feature-flag management operations.""" - - async def list_overrides(self) -> list[FeatureFlagOverrideOut]: ... - async def list_tenants_with_overrides(self) -> list[str]: ... - async def list_flags( - self, registry: FeatureFlagRegistry, tenant_id: str | None = None - ) -> list[FeatureFlagView]: ... - async def set_override( - self, - name: str, - enabled: bool, - registry: FeatureFlagRegistry | None = None, - scope: str = ..., - scope_id: str = ..., - ) -> FeatureFlagOverrideOut: ... - async def clear_override( - self, - name: str, - registry: FeatureFlagRegistry | None = None, - scope: str = ..., - scope_id: str = ..., - ) -> bool: ... - async def hydrate_registry(self, registry: FeatureFlagRegistry) -> int: ... From fdebb56786f7f91bfbbe8413e659c76a87a00ca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 09:03:02 +0000 Subject: [PATCH 5/6] Add one-line flag-check helpers for module consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumers previously had to fetch the registry off request.app.state.sm, read request.state.tenant_id, and call is_enabled with both — four lines of ceremony for a single check. Add three helpers in simple_module_core.feature_flags: - is_flag_enabled(request, name): tenant-aware check in one line - flag_enabled(name): FastAPI dep factory yielding bool - require_flag(name): FastAPI dep factory that 404s when off All three resolve tenant via request.state.tenant_id set by TenantMiddleware, so consumers never have to thread tenant_id manually. Living in core (not the feature_flags module) keeps plugin consumers from taking a runtime dependency on the admin UI module. Document the three patterns in docs/module-authoring.md. --- docs/module-authoring.md | 48 ++++++++ framework/core/simple_module_core/__init__.py | 11 +- .../core/simple_module_core/feature_flags.py | 56 +++++++++ framework/core/tests/test_feature_flags.py | 110 +++++++++++++++++- 4 files changed, 223 insertions(+), 2 deletions(-) diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 9b219dc4..05ad47a1 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -94,6 +94,54 @@ version, raising `FrameworkVersionError` with the offending modules named. - Inertia plumbing - Logging format +## Feature flags + +Declare flags in `register_feature_flags` and gate behaviour with the +helpers from `simple_module_core`. All three are tenant-aware: they read +`request.state.tenant_id` (populated by `TenantMiddleware`) and resolve +tenant override > system override > definition default. + +```python +# module.py +from simple_module_core import FeatureFlagDefinition, FeatureFlagRegistry + +class MyModule(ModuleBase): + def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: + registry.add(FeatureFlagDefinition( + name="my_module.bulk_import", + description="Enable CSV bulk import", + default_enabled=False, + )) +``` + +```python +# endpoints/api.py — three ways to consume +from typing import Annotated +from fastapi import APIRouter, Depends, Request +from simple_module_core import flag_enabled, is_flag_enabled, require_flag + +router = APIRouter() + +# 1. Gate a whole endpoint — 404 when off +@router.post("/bulk", dependencies=[Depends(require_flag("my_module.bulk_import"))]) +async def bulk_import(...): ... + +# 2. Inject the value into your handler +@router.get("/") +async def list_items( + bulk_on: Annotated[bool, Depends(flag_enabled("my_module.bulk_import"))], +): + if bulk_on: ... + +# 3. Check ad-hoc inside any handler that already has Request +@router.get("/dashboard") +async def dashboard(request: Request): + if is_flag_enabled(request, "my_module.bulk_import"): ... +``` + +Outside of an HTTP request (background tasks, CLI), pass the registry and +tenant explicitly: `registry.is_enabled(name, tenant_id=tenant)`. + ## Settings Each module's settings are loaded via its `register_settings(app)` hook. diff --git a/framework/core/simple_module_core/__init__.py b/framework/core/simple_module_core/__init__.py index da8251ac..eabd70ae 100644 --- a/framework/core/simple_module_core/__init__.py +++ b/framework/core/simple_module_core/__init__.py @@ -20,7 +20,13 @@ NotFoundError, ValidationError, ) -from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry +from simple_module_core.feature_flags import ( + FeatureFlagDefinition, + FeatureFlagRegistry, + flag_enabled, + is_flag_enabled, + require_flag, +) from simple_module_core.health import HealthCheck, HealthCheckResult, HealthRegistry, HealthStatus from simple_module_core.i18n import I18nRegistry, Translator from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection @@ -58,8 +64,11 @@ "ValidationError", "check_framework_compatibility", "discover_modules", + "flag_enabled", "get_module_package_name", + "is_flag_enabled", "print_diagnostics", + "require_flag", "run_diagnostics", "topological_sort", ] diff --git a/framework/core/simple_module_core/feature_flags.py b/framework/core/simple_module_core/feature_flags.py index b9709c03..a8dd59b8 100644 --- a/framework/core/simple_module_core/feature_flags.py +++ b/framework/core/simple_module_core/feature_flags.py @@ -7,12 +7,19 @@ ``tenant_id``; a per-tenant value beats the system override Resolution order: tenant override > system override > definition default. + +Consumers check a flag inside a FastAPI endpoint via ``is_flag_enabled``, +``flag_enabled``, or ``require_flag`` — all tenant-aware using +``request.state.tenant_id`` set by ``TenantMiddleware``. """ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from fastapi import HTTPException, Request + @dataclass(frozen=True) class FeatureFlagDefinition: @@ -67,3 +74,52 @@ def system_override(self, name: str) -> bool | None: @property def all_flags(self) -> list[FeatureFlagDefinition]: return list(self._flags.values()) + + +def is_flag_enabled(request: Request, name: str) -> bool: + """Return whether ``name`` is enabled for this request's tenant context. + + Reads the registry from ``request.app.state.sm.feature_flags`` and the + tenant from ``request.state.tenant_id`` (set by ``TenantMiddleware``). + Without a tenant on the request, falls back to the system value or the + definition default. + """ + registry: FeatureFlagRegistry = request.app.state.sm.feature_flags + tenant_id: str | None = getattr(request.state, "tenant_id", None) + return registry.is_enabled(name, tenant_id=tenant_id) + + +def flag_enabled(name: str) -> Callable[[Request], bool]: + """FastAPI dep factory — yields ``True`` when the flag is on. + + Usage:: + + async def handler( + on: Annotated[bool, Depends(flag_enabled("new_ui"))], + ) -> ...: + if on: ... + """ + + def _dep(request: Request) -> bool: + return is_flag_enabled(request, name) + + return _dep + + +def require_flag(name: str) -> Callable[[Request], None]: + """FastAPI dep factory — raises 404 when the flag is off. + + Gate an entire endpoint behind a flag:: + + @router.post( + "/bulk", + dependencies=[Depends(require_flag("products.bulk_import"))], + ) + async def bulk_import(...): ... + """ + + def _dep(request: Request) -> None: + if not is_flag_enabled(request, name): + raise HTTPException(status_code=404, detail="Feature not available") + + return _dep diff --git a/framework/core/tests/test_feature_flags.py b/framework/core/tests/test_feature_flags.py index aa8b8269..fe2e717d 100644 --- a/framework/core/tests/test_feature_flags.py +++ b/framework/core/tests/test_feature_flags.py @@ -2,7 +2,18 @@ from __future__ import annotations -from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry +from types import SimpleNamespace +from typing import Annotated + +import httpx +from fastapi import Depends, FastAPI +from simple_module_core.feature_flags import ( + FeatureFlagDefinition, + FeatureFlagRegistry, + flag_enabled, + is_flag_enabled, + require_flag, +) class TestFeatureFlagRegistry: @@ -86,3 +97,100 @@ async def test_inspectors_return_none_when_unset(self): reg.add(FeatureFlagDefinition(name="beta_ui")) assert reg.system_override("beta_ui") is None assert reg.tenant_override("beta_ui", "acme") is None + + +def _app_with_flag(registry: FeatureFlagRegistry, tenant_id: str | None = None) -> FastAPI: + """Minimal FastAPI app wired with a registry on app.state.sm + optional tenant. + + Consumers in the real app read the registry off ``app.state.sm.feature_flags`` + and tenant off ``request.state.tenant_id``; this stubs just enough state for + the helpers to exercise that exact lookup path. + """ + app = FastAPI() + app.state.sm = SimpleNamespace(feature_flags=registry) + + @app.middleware("http") + async def _set_tenant(request, call_next): + request.state.tenant_id = tenant_id + return await call_next(request) + + @app.get("/check") + async def check( + on: Annotated[bool, Depends(flag_enabled("beta_ui"))], + ) -> dict[str, bool]: + return {"on": on} + + @app.get("/gated", dependencies=[Depends(require_flag("beta_ui"))]) + async def gated() -> dict[str, bool]: + return {"reached": True} + + return app + + +class TestConsumerHelpers: + async def test_flag_enabled_dep_yields_bool_for_request_tenant(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=False)) + reg.set_override("beta_ui", True, tenant_id="acme") + + app = _app_with_flag(reg, tenant_id="acme") + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + assert (await c.get("/check")).json() == {"on": True} + + async def test_flag_enabled_dep_falls_back_to_system_without_tenant(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=False)) + reg.set_override("beta_ui", True) # system on + + app = _app_with_flag(reg, tenant_id=None) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + assert (await c.get("/check")).json() == {"on": True} + + async def test_require_flag_404s_when_off(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=False)) + + app = _app_with_flag(reg) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + resp = await c.get("/gated") + assert resp.status_code == 404 + + async def test_require_flag_passes_when_on(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=True)) + + app = _app_with_flag(reg) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + resp = await c.get("/gated") + assert resp.status_code == 200 + assert resp.json() == {"reached": True} + + async def test_require_flag_respects_tenant_override(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=True)) + reg.set_override("beta_ui", False, tenant_id="acme") # acme opts out + + app = _app_with_flag(reg, tenant_id="acme") + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + assert (await c.get("/gated")).status_code == 404 + + async def test_is_flag_enabled_reads_tenant_from_request_state(self): + """Unit-level: the helper reads ``app.state.sm.feature_flags`` and + ``request.state.tenant_id`` with no FastAPI dep machinery.""" + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=False)) + reg.set_override("beta_ui", True, tenant_id="acme") + + request = SimpleNamespace( + app=SimpleNamespace(state=SimpleNamespace(sm=SimpleNamespace(feature_flags=reg))), + state=SimpleNamespace(tenant_id="acme"), + ) + assert is_flag_enabled(request, "beta_ui") is True # type: ignore[arg-type] + + request.state.tenant_id = "other" + assert is_flag_enabled(request, "beta_ui") is False # type: ignore[arg-type] From c1134de7dbacc693bed1d144852ef476fe19ea3b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 10:06:49 +0000 Subject: [PATCH 6/6] Accept FeatureFlagDefinition in helpers + add @feature_flag decorator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flag checks no longer force consumers to retype the registered flag name. All four helpers — is_flag_enabled, flag_enabled, require_flag, and the new feature_flag decorator — accept either a FeatureFlagDefinition constant (preferred) or the raw string name. The new @feature_flag(FLAG) decorator is an attribute-style alternative to dependencies=[Depends(require_flag(...))]; apply it directly to an endpoint handler that declares a request: Request parameter. The decorator validates the signature at import time so a misconfigured handler fails fast instead of 500-ing at request time. https://claude.ai/code/session_01AKxpW8EjmzJHSfrsQWyoqn --- docs/module-authoring.md | 61 ++++--- framework/core/simple_module_core/__init__.py | 2 + .../core/simple_module_core/feature_flags.py | 84 ++++++++-- .../tests/test_feature_flags_decorator.py | 150 ++++++++++++++++++ 4 files changed, 267 insertions(+), 30 deletions(-) create mode 100644 framework/core/tests/test_feature_flags_decorator.py diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 05ad47a1..b25e7092 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -96,51 +96,74 @@ version, raising `FrameworkVersionError` with the offending modules named. ## Feature flags -Declare flags in `register_feature_flags` and gate behaviour with the -helpers from `simple_module_core`. All three are tenant-aware: they read +Declare flags as module-level constants so every consumer imports the +same object instead of retyping the string name, then register them in +`register_feature_flags`. All the helpers are tenant-aware: they read `request.state.tenant_id` (populated by `TenantMiddleware`) and resolve -tenant override > system override > definition default. +tenant override > system override > definition default. They accept +either a `FeatureFlagDefinition` (preferred) or the raw name. + +```python +# my_module/constants.py +from simple_module_core import FeatureFlagDefinition + +FLAG_BULK_IMPORT = FeatureFlagDefinition( + name="my_module.bulk_import", + description="Enable CSV bulk import", + default_enabled=False, +) +``` ```python # module.py -from simple_module_core import FeatureFlagDefinition, FeatureFlagRegistry +from simple_module_core import FeatureFlagRegistry, ModuleBase + +from my_module.constants import FLAG_BULK_IMPORT class MyModule(ModuleBase): def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: - registry.add(FeatureFlagDefinition( - name="my_module.bulk_import", - description="Enable CSV bulk import", - default_enabled=False, - )) + registry.add(FLAG_BULK_IMPORT) ``` ```python -# endpoints/api.py — three ways to consume +# endpoints/api.py — four ways to consume, all accept the constant directly from typing import Annotated from fastapi import APIRouter, Depends, Request -from simple_module_core import flag_enabled, is_flag_enabled, require_flag +from simple_module_core import feature_flag, flag_enabled, is_flag_enabled, require_flag + +from my_module.constants import FLAG_BULK_IMPORT router = APIRouter() -# 1. Gate a whole endpoint — 404 when off -@router.post("/bulk", dependencies=[Depends(require_flag("my_module.bulk_import"))]) -async def bulk_import(...): ... +# 1. Attribute-style decorator — 404 when off +@router.post("/bulk") +@feature_flag(FLAG_BULK_IMPORT) +async def bulk_import(request: Request, payload: BulkPayload): ... + +# 2. FastAPI dependency — 404 when off +@router.post("/bulk-alt", dependencies=[Depends(require_flag(FLAG_BULK_IMPORT))]) +async def bulk_import_alt(...): ... -# 2. Inject the value into your handler +# 3. Inject the value into your handler @router.get("/") async def list_items( - bulk_on: Annotated[bool, Depends(flag_enabled("my_module.bulk_import"))], + bulk_on: Annotated[bool, Depends(flag_enabled(FLAG_BULK_IMPORT))], ): if bulk_on: ... -# 3. Check ad-hoc inside any handler that already has Request +# 4. Check ad-hoc inside any handler that already has Request @router.get("/dashboard") async def dashboard(request: Request): - if is_flag_enabled(request, "my_module.bulk_import"): ... + if is_flag_enabled(request, FLAG_BULK_IMPORT): ... ``` +The `@feature_flag(...)` decorator requires the handler to declare a +`request: Request` parameter (FastAPI injects it automatically) so the +gate can read the tenant context; decorating a handler without one +raises `TypeError` at import time. + Outside of an HTTP request (background tasks, CLI), pass the registry and -tenant explicitly: `registry.is_enabled(name, tenant_id=tenant)`. +tenant explicitly: `registry.is_enabled(FLAG_BULK_IMPORT.name, tenant_id=tenant)`. ## Settings diff --git a/framework/core/simple_module_core/__init__.py b/framework/core/simple_module_core/__init__.py index eabd70ae..4c3edc64 100644 --- a/framework/core/simple_module_core/__init__.py +++ b/framework/core/simple_module_core/__init__.py @@ -23,6 +23,7 @@ from simple_module_core.feature_flags import ( FeatureFlagDefinition, FeatureFlagRegistry, + feature_flag, flag_enabled, is_flag_enabled, require_flag, @@ -64,6 +65,7 @@ "ValidationError", "check_framework_compatibility", "discover_modules", + "feature_flag", "flag_enabled", "get_module_package_name", "is_flag_enabled", diff --git a/framework/core/simple_module_core/feature_flags.py b/framework/core/simple_module_core/feature_flags.py index a8dd59b8..0e1a470f 100644 --- a/framework/core/simple_module_core/feature_flags.py +++ b/framework/core/simple_module_core/feature_flags.py @@ -9,14 +9,20 @@ Resolution order: tenant override > system override > definition default. Consumers check a flag inside a FastAPI endpoint via ``is_flag_enabled``, -``flag_enabled``, or ``require_flag`` — all tenant-aware using -``request.state.tenant_id`` set by ``TenantMiddleware``. +``flag_enabled``, ``require_flag``, or the ``@feature_flag`` decorator — +all tenant-aware using ``request.state.tenant_id`` set by +``TenantMiddleware``. Every helper accepts either a +``FeatureFlagDefinition`` (preferred: pass the constant you registered) or +the raw flag name. """ from __future__ import annotations +import functools +import inspect from collections.abc import Callable from dataclasses import dataclass +from typing import Any from fastapi import HTTPException, Request @@ -76,8 +82,12 @@ def all_flags(self) -> list[FeatureFlagDefinition]: return list(self._flags.values()) -def is_flag_enabled(request: Request, name: str) -> bool: - """Return whether ``name`` is enabled for this request's tenant context. +def _flag_name(flag: FeatureFlagDefinition | str) -> str: + return flag.name if isinstance(flag, FeatureFlagDefinition) else flag + + +def is_flag_enabled(request: Request, flag: FeatureFlagDefinition | str) -> bool: + """Return whether ``flag`` is enabled for this request's tenant context. Reads the registry from ``request.app.state.sm.feature_flags`` and the tenant from ``request.state.tenant_id`` (set by ``TenantMiddleware``). @@ -86,40 +96,92 @@ def is_flag_enabled(request: Request, name: str) -> bool: """ registry: FeatureFlagRegistry = request.app.state.sm.feature_flags tenant_id: str | None = getattr(request.state, "tenant_id", None) - return registry.is_enabled(name, tenant_id=tenant_id) + return registry.is_enabled(_flag_name(flag), tenant_id=tenant_id) -def flag_enabled(name: str) -> Callable[[Request], bool]: +def flag_enabled(flag: FeatureFlagDefinition | str) -> Callable[[Request], bool]: """FastAPI dep factory — yields ``True`` when the flag is on. Usage:: async def handler( - on: Annotated[bool, Depends(flag_enabled("new_ui"))], + on: Annotated[bool, Depends(flag_enabled(FLAG_NEW_UI))], ) -> ...: if on: ... """ def _dep(request: Request) -> bool: - return is_flag_enabled(request, name) + return is_flag_enabled(request, flag) return _dep -def require_flag(name: str) -> Callable[[Request], None]: +def require_flag(flag: FeatureFlagDefinition | str) -> Callable[[Request], None]: """FastAPI dep factory — raises 404 when the flag is off. Gate an entire endpoint behind a flag:: @router.post( "/bulk", - dependencies=[Depends(require_flag("products.bulk_import"))], + dependencies=[Depends(require_flag(FLAG_BULK_IMPORT))], ) async def bulk_import(...): ... """ def _dep(request: Request) -> None: - if not is_flag_enabled(request, name): + if not is_flag_enabled(request, flag): raise HTTPException(status_code=404, detail="Feature not available") return _dep + + +def feature_flag( + flag: FeatureFlagDefinition | str, +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator — gates an endpoint behind a flag, 404s when off. + + Attribute-style alternative to ``Depends(require_flag(...))``: apply + directly to the handler. The decorated function must accept a + ``request: Request`` parameter (FastAPI injects it automatically). + + Usage:: + + @router.post("/bulk") + @feature_flag(FLAG_BULK_IMPORT) + async def bulk_import(request: Request, payload: Payload): ... + """ + name = _flag_name(flag) + + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + sig = inspect.signature(fn, eval_str=True) + request_param = next( + (p.name for p in sig.parameters.values() if p.annotation is Request), + None, + ) + if request_param is None: + raise TypeError( + f"@feature_flag({name!r}): {fn.__qualname__} must declare a " + f"'request: Request' parameter for the decorator to read tenant state" + ) + + if inspect.iscoroutinefunction(fn): + + @functools.wraps(fn) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + request = sig.bind(*args, **kwargs).arguments[request_param] + if not is_flag_enabled(request, name): + raise HTTPException(status_code=404, detail="Feature not available") + return await fn(*args, **kwargs) + + return async_wrapper + + @functools.wraps(fn) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + request = sig.bind(*args, **kwargs).arguments[request_param] + if not is_flag_enabled(request, name): + raise HTTPException(status_code=404, detail="Feature not available") + return fn(*args, **kwargs) + + return sync_wrapper + + return decorator diff --git a/framework/core/tests/test_feature_flags_decorator.py b/framework/core/tests/test_feature_flags_decorator.py new file mode 100644 index 00000000..1922808a --- /dev/null +++ b/framework/core/tests/test_feature_flags_decorator.py @@ -0,0 +1,150 @@ +"""Tests for the constant-based helpers and the ``@feature_flag`` decorator. + +The older ``TestConsumerHelpers`` suite in ``test_feature_flags.py`` covers +the FastAPI dep factories with raw string flag names; the cases here +exercise the sibling behaviours: accepting a ``FeatureFlagDefinition`` +directly, and the attribute-style decorator applied to endpoint +functions. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Annotated + +import httpx +import pytest +from fastapi import Depends, FastAPI, Request +from simple_module_core.feature_flags import ( + FeatureFlagDefinition, + FeatureFlagRegistry, + feature_flag, + flag_enabled, + is_flag_enabled, + require_flag, +) + +FLAG_BETA_UI = FeatureFlagDefinition(name="beta_ui", default_enabled=False) + + +def _app_with_registry(registry: FeatureFlagRegistry, tenant_id: str | None = None) -> FastAPI: + """FastAPI app with the registry wired on ``app.state.sm.feature_flags``.""" + app = FastAPI() + app.state.sm = SimpleNamespace(feature_flags=registry) + + @app.middleware("http") + async def _set_tenant(request, call_next): + request.state.tenant_id = tenant_id + return await call_next(request) + + return app + + +class TestDefinitionAcceptance: + """Helpers accept ``FeatureFlagDefinition`` objects so callers pass the + constant they registered instead of duplicating the string name.""" + + async def test_is_flag_enabled_accepts_definition(self): + reg = FeatureFlagRegistry() + reg.add(FLAG_BETA_UI) + reg.set_override(FLAG_BETA_UI.name, True) + + request = SimpleNamespace( + app=SimpleNamespace(state=SimpleNamespace(sm=SimpleNamespace(feature_flags=reg))), + state=SimpleNamespace(tenant_id=None), + ) + assert is_flag_enabled(request, FLAG_BETA_UI) is True # type: ignore[arg-type] + + async def test_flag_enabled_and_require_flag_accept_definition(self): + reg = FeatureFlagRegistry() + reg.add(FLAG_BETA_UI) + reg.set_override(FLAG_BETA_UI.name, True) + + app = _app_with_registry(reg) + + @app.get("/check") + async def check( + on: Annotated[bool, Depends(flag_enabled(FLAG_BETA_UI))], + ) -> dict[str, bool]: + return {"on": on} + + @app.get("/gated", dependencies=[Depends(require_flag(FLAG_BETA_UI))]) + async def gated() -> dict[str, bool]: + return {"reached": True} + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + assert (await c.get("/check")).json() == {"on": True} + assert (await c.get("/gated")).status_code == 200 + + +class TestFeatureFlagDecorator: + """``@feature_flag(FLAG)`` gates an endpoint function directly.""" + + async def test_decorator_404s_when_off(self): + reg = FeatureFlagRegistry() + reg.add(FLAG_BETA_UI) + app = _app_with_registry(reg) + + @app.get("/bulk") + @feature_flag(FLAG_BETA_UI) + async def bulk(request: Request) -> dict[str, bool]: + return {"reached": True} + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + assert (await c.get("/bulk")).status_code == 404 + + async def test_decorator_passes_when_on_and_accepts_raw_name(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=True)) + app = _app_with_registry(reg) + + @app.get("/bulk") + @feature_flag("beta_ui") + async def bulk(request: Request) -> dict[str, bool]: + return {"reached": True} + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + resp = await c.get("/bulk") + assert resp.status_code == 200 + assert resp.json() == {"reached": True} + + async def test_decorator_respects_tenant_override(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=True)) + reg.set_override("beta_ui", False, tenant_id="acme") + app = _app_with_registry(reg, tenant_id="acme") + + @app.get("/bulk") + @feature_flag(FLAG_BETA_UI) + async def bulk(request: Request) -> dict[str, bool]: + return {"reached": True} + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + assert (await c.get("/bulk")).status_code == 404 + + async def test_decorator_rejects_functions_without_request_param(self): + """Defensive: the decorator needs a ``Request`` to read tenant state.""" + + with pytest.raises(TypeError, match="must declare a 'request: Request' parameter"): + + @feature_flag(FLAG_BETA_UI) + async def handler(payload: dict) -> dict: # no request param + return payload + + async def test_decorator_works_on_sync_handler(self): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="beta_ui", default_enabled=True)) + + @feature_flag(FLAG_BETA_UI) + def handler(request: Request) -> str: + return "ok" + + request = SimpleNamespace( + app=SimpleNamespace(state=SimpleNamespace(sm=SimpleNamespace(feature_flags=reg))), + state=SimpleNamespace(tenant_id=None), + ) + assert handler(request) == "ok" # type: ignore[arg-type]