Files
resolutionflow/backend/app/api/endpoints/admin_feature_flags.py
Michael Chihlas b570f8415f feat: implement full admin panel with dashboard, user management, and platform settings
Adds complete super_admin panel with 9 pages and account owner categories page.
Backend includes 5 new DB tables, ~25 API endpoints, settings manager with
in-memory cache, and 29 integration tests. Frontend includes reusable admin
components (DataTable, Pagination, ActionMenu, etc.) with code-split lazy loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 06:05:59 -05:00

252 lines
9.7 KiB
Python

from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.audit import log_audit
from app.models.user import User
from app.models.account import Account
from app.models.feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
from app.schemas.admin import (
FeatureFlagCreate, FeatureFlagUpdate, FeatureFlagResponse, PlanDefaultEntry,
PlanDefaultUpdate,
AccountFeatureOverrideCreate, AccountFeatureOverrideResponse,
)
from app.api.deps import require_admin
router = APIRouter(prefix="/admin/feature-flags", tags=["admin-feature-flags"])
@router.get("", response_model=list[FeatureFlagResponse])
async def list_feature_flags(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all feature flags with plan defaults."""
result = await db.execute(select(FeatureFlag).order_by(FeatureFlag.display_name))
flags = result.scalars().all()
responses = []
for flag in flags:
# Get plan defaults for this flag
defaults_result = await db.execute(
select(PlanFeatureDefault).where(PlanFeatureDefault.flag_id == flag.id)
)
defaults = defaults_result.scalars().all()
responses.append(FeatureFlagResponse(
id=flag.id,
flag_key=flag.flag_key,
display_name=flag.display_name,
description=flag.description,
plan_defaults=[PlanDefaultEntry(plan=d.plan, enabled=d.enabled) for d in defaults],
created_at=flag.created_at,
))
return responses
@router.post("", response_model=FeatureFlagResponse, status_code=status.HTTP_201_CREATED)
async def create_feature_flag(
data: FeatureFlagCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a new feature flag."""
# Check uniqueness
existing = await db.execute(select(FeatureFlag).where(FeatureFlag.flag_key == data.flag_key))
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Flag key already exists")
flag = FeatureFlag(flag_key=data.flag_key, display_name=data.display_name, description=data.description)
db.add(flag)
await log_audit(db, current_user.id, "feature_flag.create", "feature_flag", details={"flag_key": data.flag_key})
await db.commit()
await db.refresh(flag)
return FeatureFlagResponse(
id=flag.id, flag_key=flag.flag_key, display_name=flag.display_name,
description=flag.description, plan_defaults=[], created_at=flag.created_at,
)
@router.put("/plan-defaults")
async def update_plan_default(
data: PlanDefaultUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a plan feature default (upsert)."""
result = await db.execute(
select(PlanFeatureDefault).where(
PlanFeatureDefault.plan == data.plan,
PlanFeatureDefault.flag_id == data.flag_id,
)
)
existing = result.scalar_one_or_none()
if existing:
existing.enabled = data.enabled
else:
new_default = PlanFeatureDefault(plan=data.plan, flag_id=data.flag_id, enabled=data.enabled)
db.add(new_default)
await log_audit(db, current_user.id, "plan_default.update", "feature_flag", data.flag_id,
{"plan": data.plan, "enabled": data.enabled})
await db.commit()
return {"ok": True}
@router.put("/{flag_id}", response_model=FeatureFlagResponse)
async def update_feature_flag(
flag_id: UUID,
data: FeatureFlagUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a feature flag."""
result = await db.execute(select(FeatureFlag).where(FeatureFlag.id == flag_id))
flag = result.scalar_one_or_none()
if not flag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature flag not found")
if data.display_name is not None:
flag.display_name = data.display_name
if data.description is not None:
flag.description = data.description
await log_audit(db, current_user.id, "feature_flag.update", "feature_flag", flag.id)
await db.commit()
await db.refresh(flag)
defaults_result = await db.execute(select(PlanFeatureDefault).where(PlanFeatureDefault.flag_id == flag.id))
defaults = defaults_result.scalars().all()
return FeatureFlagResponse(
id=flag.id, flag_key=flag.flag_key, display_name=flag.display_name,
description=flag.description,
plan_defaults=[PlanDefaultEntry(plan=d.plan, enabled=d.enabled) for d in defaults],
created_at=flag.created_at,
)
@router.delete("/{flag_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_feature_flag(
flag_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Delete a feature flag (cascades to defaults and overrides)."""
result = await db.execute(select(FeatureFlag).where(FeatureFlag.id == flag_id))
flag = result.scalar_one_or_none()
if not flag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature flag not found")
await log_audit(db, current_user.id, "feature_flag.delete", "feature_flag", flag.id,
{"flag_key": flag.flag_key})
await db.delete(flag)
await db.commit()
# --- Account Feature Overrides ---
@router.get("/account-overrides", response_model=list[AccountFeatureOverrideResponse])
async def list_account_feature_overrides(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all account feature overrides."""
query = (
select(
AccountFeatureOverride,
Account.display_code.label("account_display_code"),
FeatureFlag.flag_key.label("flag_key"),
FeatureFlag.display_name.label("flag_display_name"),
)
.outerjoin(Account, AccountFeatureOverride.account_id == Account.id)
.outerjoin(FeatureFlag, AccountFeatureOverride.flag_id == FeatureFlag.id)
.order_by(AccountFeatureOverride.created_at.desc())
)
result = await db.execute(query)
rows = result.all()
return [
AccountFeatureOverrideResponse(
id=row.AccountFeatureOverride.id,
account_id=row.AccountFeatureOverride.account_id,
account_display_code=row.account_display_code,
flag_id=row.AccountFeatureOverride.flag_id,
flag_key=row.flag_key,
flag_display_name=row.flag_display_name,
enabled=row.AccountFeatureOverride.enabled,
note=row.AccountFeatureOverride.note,
created_at=row.AccountFeatureOverride.created_at,
)
for row in rows
]
@router.post("/account-overrides", response_model=AccountFeatureOverrideResponse, status_code=status.HTTP_201_CREATED)
async def create_account_feature_override(
data: AccountFeatureOverrideCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create an account feature override."""
# Look up account
result = await db.execute(select(Account).where(Account.display_code == data.account_display_code))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
# Look up flag
result = await db.execute(select(FeatureFlag).where(FeatureFlag.id == data.flag_id))
flag = result.scalar_one_or_none()
if not flag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature flag not found")
# Check for existing
existing = await db.execute(
select(AccountFeatureOverride).where(
AccountFeatureOverride.account_id == account.id,
AccountFeatureOverride.flag_id == data.flag_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Override already exists")
override = AccountFeatureOverride(
account_id=account.id, flag_id=data.flag_id, enabled=data.enabled,
note=data.note, created_by_id=current_user.id,
)
db.add(override)
await log_audit(db, current_user.id, "feature_override.create", "account", account.id,
{"flag_key": flag.flag_key, "enabled": data.enabled})
await db.commit()
await db.refresh(override)
return AccountFeatureOverrideResponse(
id=override.id, account_id=override.account_id, account_display_code=account.display_code,
flag_id=override.flag_id, flag_key=flag.flag_key, flag_display_name=flag.display_name,
enabled=override.enabled, note=override.note, created_at=override.created_at,
)
@router.delete("/account-overrides/{override_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_account_feature_override(
override_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Delete an account feature override."""
result = await db.execute(select(AccountFeatureOverride).where(AccountFeatureOverride.id == override_id))
override = result.scalar_one_or_none()
if not override:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Override not found")
await log_audit(db, current_user.id, "feature_override.delete", "account", override.account_id)
await db.delete(override)
await db.commit()