Files
resolutionflow/backend/app/api/endpoints/admin_plan_limits.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

199 lines
7.8 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.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()