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:
Michael Chihlas
2026-02-08 06:05:59 -05:00
parent 4f57c84d43
commit b570f8415f
50 changed files with 4589 additions and 5 deletions

View File

@@ -7,7 +7,9 @@ from sqlalchemy import select, func
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.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
from app.schemas.admin import MoveUserAccount
from app.api.deps import require_admin
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -167,3 +169,32 @@ async def activate_user(
await db.commit()
await db.refresh(user)
return user
@router.put("/users/{user_id}/move-account", response_model=UserResponse)
async def move_user_account(
user_id: UUID,
data: MoveUserAccount,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Move a user to a different account (super admin only)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
result = await db.execute(select(Account).where(Account.display_code == data.display_code))
target_account = result.scalar_one_or_none()
if not target_account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target account not found")
old_account_id = user.account_id
user.account_id = target_account.id
user.account_role = "engineer" # Reset to engineer on move
await log_audit(db, current_user.id, "user.move_account", "user", user.id,
{"old_account_id": str(old_account_id), "new_account_id": str(target_account.id)})
await db.commit()
await db.refresh(user)
return user

View File

@@ -0,0 +1,154 @@
import csv
import io
from datetime import datetime, timezone
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.models.user import User
from app.models.audit_log import AuditLog
from app.schemas.admin import AuditLogEntry, AuditLogListResponse
from app.api.deps import require_admin
router = APIRouter(prefix="/admin/audit-logs", tags=["admin-audit"])
def _build_audit_query(
action: Optional[str] = None,
resource_type: Optional[str] = None,
user_id: Optional[UUID] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
search: Optional[str] = None,
):
"""Build base query with filters (reused for list and export)."""
query = (
select(
AuditLog.id,
AuditLog.user_id,
AuditLog.action,
AuditLog.resource_type,
AuditLog.resource_id,
AuditLog.details,
AuditLog.ip_address,
AuditLog.created_at,
User.email.label("user_email"),
)
.outerjoin(User, AuditLog.user_id == User.id)
)
if action:
query = query.where(AuditLog.action == action)
if resource_type:
query = query.where(AuditLog.resource_type == resource_type)
if user_id:
query = query.where(AuditLog.user_id == user_id)
if date_from:
query = query.where(AuditLog.created_at >= datetime.fromisoformat(date_from))
if date_to:
query = query.where(AuditLog.created_at <= datetime.fromisoformat(date_to))
if search:
query = query.where(AuditLog.resource_id.cast(str).ilike(f"%{search}%"))
return query
@router.get("", response_model=AuditLogListResponse)
async def list_audit_logs(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
action: Optional[str] = None,
resource_type: Optional[str] = None,
user_id: Optional[UUID] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
search: Optional[str] = None,
):
"""List audit logs with pagination and filters."""
base_query = _build_audit_query(action, resource_type, user_id, date_from, date_to, search)
# Count
count_query = select(func.count()).select_from(AuditLog)
if action:
count_query = count_query.where(AuditLog.action == action)
if resource_type:
count_query = count_query.where(AuditLog.resource_type == resource_type)
if user_id:
count_query = count_query.where(AuditLog.user_id == user_id)
if date_from:
count_query = count_query.where(AuditLog.created_at >= datetime.fromisoformat(date_from))
if date_to:
count_query = count_query.where(AuditLog.created_at <= datetime.fromisoformat(date_to))
total = await db.scalar(count_query) or 0
# Paginated results
query = base_query.order_by(AuditLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
result = await db.execute(query)
rows = result.all()
items = [
AuditLogEntry(
id=row.id,
user_id=row.user_id,
user_email=row.user_email,
action=row.action,
resource_type=row.resource_type,
resource_id=row.resource_id,
details=row.details,
ip_address=row.ip_address,
created_at=row.created_at,
)
for row in rows
]
return AuditLogListResponse(items=items, total=total, page=page, per_page=per_page)
@router.get("/export")
async def export_audit_logs(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
action: Optional[str] = None,
resource_type: Optional[str] = None,
user_id: Optional[UUID] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
):
"""Export audit logs as CSV (10k row limit)."""
query = _build_audit_query(action, resource_type, user_id, date_from, date_to)
query = query.order_by(AuditLog.created_at.desc()).limit(10000)
result = await db.execute(query)
rows = result.all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["timestamp", "user_email", "action", "resource_type", "resource_id", "ip_address", "details"])
for row in rows:
writer.writerow([
row.created_at.isoformat() if row.created_at else "",
row.user_email or "",
row.action,
row.resource_type,
str(row.resource_id) if row.resource_id else "",
row.ip_address or "",
str(row.details) if row.details else "",
])
output.seek(0)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=audit-logs-{today}.csv"},
)

View File

@@ -0,0 +1,127 @@
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, func
from app.core.database import get_db
from app.core.audit import log_audit
from app.models.user import User
from app.models.category import TreeCategory
from app.models.tree import Tree
from app.schemas.admin import GlobalCategoryCreate, GlobalCategoryUpdate, GlobalCategoryResponse
from app.api.deps import require_admin
router = APIRouter(prefix="/admin/categories", tags=["admin-categories"])
@router.get("/global", response_model=list[GlobalCategoryResponse])
async def list_global_categories(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all global categories (account_id IS NULL)."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.account_id.is_(None)).order_by(TreeCategory.name)
)
categories = result.scalars().all()
responses = []
for cat in categories:
tree_count = await db.scalar(
select(func.count()).select_from(Tree).where(
Tree.category_id == cat.id, Tree.deleted_at.is_(None)
)
) or 0
responses.append(GlobalCategoryResponse(
id=cat.id, name=cat.name, slug=cat.slug,
description=cat.description if hasattr(cat, 'description') else None,
account_id=cat.account_id, tree_count=tree_count,
))
return responses
@router.post("/global", response_model=GlobalCategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_global_category(
data: GlobalCategoryCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a global category."""
# Check slug uniqueness for global categories
existing = await db.execute(
select(TreeCategory).where(TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None))
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Global category with this slug already exists")
category = TreeCategory(name=data.name, slug=data.slug, account_id=None)
db.add(category)
await log_audit(db, current_user.id, "global_category.create", "category", details={"name": data.name})
await db.commit()
await db.refresh(category)
return GlobalCategoryResponse(id=category.id, name=category.name, slug=category.slug, account_id=None, tree_count=0)
@router.put("/global/{category_id}", response_model=GlobalCategoryResponse)
async def update_global_category(
category_id: UUID,
data: GlobalCategoryUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a global category."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None))
)
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Global category not found")
if data.name is not None:
category.name = data.name
if data.slug is not None:
# Check slug uniqueness
existing = await db.execute(
select(TreeCategory).where(
TreeCategory.slug == data.slug, TreeCategory.account_id.is_(None), TreeCategory.id != category_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Slug already exists")
category.slug = data.slug
await log_audit(db, current_user.id, "global_category.update", "category", category.id)
await db.commit()
await db.refresh(category)
tree_count = await db.scalar(
select(func.count()).select_from(Tree).where(Tree.category_id == category.id, Tree.deleted_at.is_(None))
) or 0
return GlobalCategoryResponse(
id=category.id, name=category.name, slug=category.slug,
account_id=None, tree_count=tree_count,
)
@router.delete("/global/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_global_category(
category_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Delete (archive) a global category."""
result = await db.execute(
select(TreeCategory).where(TreeCategory.id == category_id, TreeCategory.account_id.is_(None))
)
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Global category not found")
await log_audit(db, current_user.id, "global_category.delete", "category", category.id,
{"name": category.name})
await db.delete(category)
await db.commit()

View File

@@ -0,0 +1,82 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.models.user import User
from app.models.subscription import Subscription
from app.models.tree import Tree
from app.models.audit_log import AuditLog
from app.schemas.admin import DashboardMetrics, ActivityEntry
from app.api.deps import require_admin
router = APIRouter(prefix="/admin/dashboard", tags=["admin-dashboard"])
@router.get("/metrics", response_model=DashboardMetrics)
async def get_dashboard_metrics(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Get platform overview metrics."""
total_users = await db.scalar(select(func.count()).select_from(User)) or 0
active_subs = await db.scalar(
select(func.count()).select_from(Subscription).where(
Subscription.status.in_(["active", "trialing"])
)
) or 0
paid_accounts = await db.scalar(
select(func.count()).select_from(Subscription).where(
Subscription.plan.in_(["pro", "team"])
)
) or 0
total_trees = await db.scalar(
select(func.count()).select_from(Tree).where(Tree.deleted_at.is_(None))
) or 0
return DashboardMetrics(
total_users=total_users,
active_subscriptions=active_subs,
paid_accounts=paid_accounts,
total_trees=total_trees,
)
@router.get("/activity", response_model=list[ActivityEntry])
async def get_dashboard_activity(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Get recent audit log entries for activity feed."""
query = (
select(
AuditLog.id,
AuditLog.action,
AuditLog.resource_type,
AuditLog.resource_id,
AuditLog.details,
AuditLog.ip_address,
AuditLog.created_at,
User.email.label("user_email"),
)
.outerjoin(User, AuditLog.user_id == User.id)
.order_by(AuditLog.created_at.desc())
.limit(10)
)
result = await db.execute(query)
rows = result.all()
return [
ActivityEntry(
id=row.id,
user_email=row.user_email,
action=row.action,
resource_type=row.resource_type,
resource_id=row.resource_id,
details=row.details,
ip_address=row.ip_address,
created_at=row.created_at,
)
for row in rows
]

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

View File

@@ -0,0 +1,198 @@
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.plan_limits import PlanLimits
from app.models.account import Account
from app.models.account_limit_override import AccountLimitOverride
from app.schemas.admin import (
PlanLimitResponse, PlanLimitUpdate,
AccountOverrideCreate, AccountOverrideUpdate, AccountOverrideResponse,
)
from app.api.deps import require_admin
router = APIRouter(prefix="/admin", tags=["admin-plan-limits"])
@router.get("/plan-limits", response_model=list[PlanLimitResponse])
async def list_plan_limits(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all plan limit configurations."""
result = await db.execute(select(PlanLimits))
return result.scalars().all()
@router.put("/plan-limits", response_model=PlanLimitResponse)
async def update_plan_limits(
data: PlanLimitUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a plan's limits."""
result = await db.execute(select(PlanLimits).where(PlanLimits.plan == data.plan))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found")
plan.max_trees = data.max_trees
plan.max_sessions_per_month = data.max_sessions_per_month
plan.max_users = data.max_users
plan.custom_branding = data.custom_branding
plan.priority_support = data.priority_support
plan.export_formats = data.export_formats
await log_audit(db, current_user.id, "plan_limits.update", "plan_limits", details={"plan": data.plan})
await db.commit()
await db.refresh(plan)
return plan
@router.get("/account-overrides", response_model=list[AccountOverrideResponse])
async def list_account_overrides(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all account limit overrides."""
query = (
select(
AccountLimitOverride,
Account.name.label("account_name"),
Account.display_code.label("account_display_code"),
)
.outerjoin(Account, AccountLimitOverride.account_id == Account.id)
.order_by(AccountLimitOverride.created_at.desc())
)
result = await db.execute(query)
rows = result.all()
return [
AccountOverrideResponse(
id=row.AccountLimitOverride.id,
account_id=row.AccountLimitOverride.account_id,
account_name=row.account_name,
account_display_code=row.account_display_code,
override_max_trees=row.AccountLimitOverride.override_max_trees,
override_max_sessions_per_month=row.AccountLimitOverride.override_max_sessions_per_month,
override_max_users=row.AccountLimitOverride.override_max_users,
note=row.AccountLimitOverride.note,
created_at=row.AccountLimitOverride.created_at,
updated_at=row.AccountLimitOverride.updated_at,
)
for row in rows
]
@router.post("/account-overrides", response_model=AccountOverrideResponse, status_code=status.HTTP_201_CREATED)
async def create_account_override(
data: AccountOverrideCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create an account limit override."""
# Look up account by display_code
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")
# Check for existing override
existing = await db.execute(
select(AccountLimitOverride).where(AccountLimitOverride.account_id == account.id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Override already exists for this account")
override = AccountLimitOverride(
account_id=account.id,
override_max_trees=data.override_max_trees,
override_max_sessions_per_month=data.override_max_sessions_per_month,
override_max_users=data.override_max_users,
note=data.note,
created_by_id=current_user.id,
)
db.add(override)
await log_audit(db, current_user.id, "account_override.create", "account", account.id,
{"display_code": data.account_display_code})
await db.commit()
await db.refresh(override)
return AccountOverrideResponse(
id=override.id,
account_id=override.account_id,
account_name=account.name,
account_display_code=account.display_code,
override_max_trees=override.override_max_trees,
override_max_sessions_per_month=override.override_max_sessions_per_month,
override_max_users=override.override_max_users,
note=override.note,
created_at=override.created_at,
updated_at=override.updated_at,
)
@router.put("/account-overrides/{override_id}", response_model=AccountOverrideResponse)
async def update_account_override(
override_id: UUID,
data: AccountOverrideUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update an account limit override."""
result = await db.execute(select(AccountLimitOverride).where(AccountLimitOverride.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")
if data.override_max_trees is not None:
override.override_max_trees = data.override_max_trees
if data.override_max_sessions_per_month is not None:
override.override_max_sessions_per_month = data.override_max_sessions_per_month
if data.override_max_users is not None:
override.override_max_users = data.override_max_users
if data.note is not None:
override.note = data.note
await log_audit(db, current_user.id, "account_override.update", "account", override.account_id)
await db.commit()
await db.refresh(override)
# Fetch account info
acct = await db.execute(select(Account).where(Account.id == override.account_id))
account = acct.scalar_one_or_none()
return AccountOverrideResponse(
id=override.id,
account_id=override.account_id,
account_name=account.name if account else None,
account_display_code=account.display_code if account else None,
override_max_trees=override.override_max_trees,
override_max_sessions_per_month=override.override_max_sessions_per_month,
override_max_users=override.override_max_users,
note=override.note,
created_at=override.created_at,
updated_at=override.updated_at,
)
@router.delete("/account-overrides/{override_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_account_override(
override_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Delete an account limit override."""
result = await db.execute(select(AccountLimitOverride).where(AccountLimitOverride.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, "account_override.delete", "account", override.account_id)
await db.delete(override)
await db.commit()

View File

@@ -0,0 +1,40 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.audit import log_audit
from app.core.settings_manager import SettingsManager
from app.models.user import User
from app.schemas.admin import SettingsResponse, SettingsUpdate
from app.api.deps import require_admin
router = APIRouter(prefix="/admin/settings", tags=["admin-settings"])
@router.get("", response_model=SettingsResponse)
async def list_settings(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all platform settings."""
settings = await SettingsManager.get_all(db, include_sensitive=True)
return SettingsResponse(settings=settings)
@router.put("", response_model=SettingsResponse)
async def update_settings(
data: SettingsUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update platform settings (batch)."""
for key, value in data.settings.items():
await SettingsManager.set(key, value, db, current_user.id)
await log_audit(db, current_user.id, "settings.update", "platform_settings",
details={"keys": list(data.settings.keys())})
await db.commit()
settings = await SettingsManager.get_all(db, include_sensitive=True)
return SettingsResponse(settings=settings)