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>
This commit is contained in:
251
backend/app/api/endpoints/admin_feature_flags.py
Normal file
251
backend/app/api/endpoints/admin_feature_flags.py
Normal file
@@ -0,0 +1,251 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user