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()