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:
198
backend/app/api/endpoints/admin_plan_limits.py
Normal file
198
backend/app/api/endpoints/admin_plan_limits.py
Normal 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()
|
||||
Reference in New Issue
Block a user