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

@@ -0,0 +1,102 @@
"""add admin panel tables
Revision ID: 026
Revises: 025
Create Date: 2026-02-08
Creates tables for admin panel:
- account_limit_overrides: Per-account plan limit overrides
- feature_flags: Feature flag definitions
- plan_feature_defaults: Which features each plan gets
- account_feature_overrides: Per-account feature exceptions
- platform_settings: Runtime configuration storage
"""
revision = "026"
down_revision = "025"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade() -> None:
# Account limit overrides
op.create_table(
"account_limit_overrides",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), unique=True, nullable=False),
sa.Column("override_max_trees", sa.Integer(), nullable=True),
sa.Column("override_max_sessions_per_month", sa.Integer(), nullable=True),
sa.Column("override_max_users", sa.Integer(), nullable=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_by_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_account_limit_overrides_account_id", "account_limit_overrides", ["account_id"])
# Feature flags
op.create_table(
"feature_flags",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("flag_key", sa.String(100), unique=True, nullable=False),
sa.Column("display_name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
# Plan feature defaults
op.create_table(
"plan_feature_defaults",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("plan", sa.String(50), sa.ForeignKey("plan_limits.plan"), nullable=False),
sa.Column("flag_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("feature_flags.id", ondelete="CASCADE"), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.UniqueConstraint("plan", "flag_id", name="uq_plan_feature_defaults_plan_flag"),
)
op.create_index("ix_plan_feature_defaults_plan", "plan_feature_defaults", ["plan"])
# Account feature overrides
op.create_table(
"account_feature_overrides",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("flag_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("feature_flags.id", ondelete="CASCADE"), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_by_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.UniqueConstraint("account_id", "flag_id", name="uq_account_feature_overrides_account_flag"),
)
op.create_index("ix_account_feature_overrides_account_id", "account_feature_overrides", ["account_id"])
# Platform settings
op.create_table(
"platform_settings",
sa.Column("setting_key", sa.String(100), primary_key=True),
sa.Column("setting_value", sa.Text(), nullable=True),
sa.Column("data_type", sa.String(20), nullable=False, server_default="string"),
sa.Column("is_sensitive", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("updated_by_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
# Seed default platform settings
op.execute(
"INSERT INTO platform_settings (setting_key, setting_value, data_type) VALUES "
"('maintenance_mode', 'false', 'boolean'), "
"('maintenance_message', 'We''re performing scheduled maintenance. We''ll be back soon!', 'string')"
)
def downgrade() -> None:
op.drop_table("platform_settings")
op.drop_table("account_feature_overrides")
op.drop_table("plan_feature_defaults")
op.drop_table("feature_flags")
op.drop_table("account_limit_overrides")

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)

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
api_router = APIRouter()
@@ -13,6 +14,12 @@ api_router.include_router(folders.router)
api_router.include_router(step_categories.router)
api_router.include_router(steps.router)
api_router.include_router(admin.router)
api_router.include_router(admin_dashboard.router)
api_router.include_router(admin_audit.router)
api_router.include_router(admin_plan_limits.router)
api_router.include_router(admin_feature_flags.router)
api_router.include_router(admin_settings.router)
api_router.include_router(admin_categories.router)
api_router.include_router(accounts.router)
api_router.include_router(webhooks.router)
api_router.include_router(shares.router)

View File

@@ -0,0 +1,96 @@
"""Runtime platform settings with in-memory cache."""
import json
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.platform_setting import PlatformSetting
class SettingsManager:
"""Manage runtime platform settings with in-memory cache (60s TTL)."""
_cache: dict[str, Any] = {}
_cache_time: float = 0
CACHE_TTL = 60
@classmethod
async def get(cls, key: str, db: AsyncSession, default: Any = None) -> Any:
if time.time() - cls._cache_time < cls.CACHE_TTL and key in cls._cache:
return cls._cache[key]
result = await db.execute(
select(PlatformSetting).where(PlatformSetting.setting_key == key)
)
setting = result.scalar_one_or_none()
if not setting:
return default
value = cls._parse_value(setting.setting_value, setting.data_type)
cls._cache[key] = value
cls._cache_time = time.time()
return value
@classmethod
async def set(cls, key: str, value: Any, db: AsyncSession, user_id: uuid.UUID) -> None:
result = await db.execute(
select(PlatformSetting).where(PlatformSetting.setting_key == key)
)
setting = result.scalar_one_or_none()
str_value = json.dumps(value) if isinstance(value, (dict, list)) else str(value).lower() if isinstance(value, bool) else str(value)
if setting:
setting.setting_value = str_value
setting.updated_by_id = user_id
setting.updated_at = datetime.now(timezone.utc)
else:
setting = PlatformSetting(
setting_key=key,
setting_value=str_value,
data_type=cls._infer_type(value),
updated_by_id=user_id,
)
db.add(setting)
# Invalidate cache
cls._cache.pop(key, None)
cls._cache_time = 0
@classmethod
async def get_all(cls, db: AsyncSession, include_sensitive: bool = False) -> dict[str, Any]:
result = await db.execute(select(PlatformSetting))
settings = result.scalars().all()
out = {}
for s in settings:
if s.is_sensitive and not include_sensitive:
out[s.setting_key] = "***"
else:
out[s.setting_key] = cls._parse_value(s.setting_value, s.data_type)
return out
@staticmethod
def _parse_value(value: Optional[str], data_type: str) -> Any:
if value is None:
return None
if data_type == "boolean":
return value.lower() == "true"
if data_type == "integer":
return int(value)
if data_type == "json":
return json.loads(value)
return value
@staticmethod
def _infer_type(value: Any) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int):
return "integer"
if isinstance(value, (dict, list)):
return "json"
return "string"

View File

@@ -16,6 +16,9 @@ from .step_library import StepLibrary, StepRating, StepUsageLog
from .refresh_token import RefreshToken
from .audit_log import AuditLog
from .session_share import SessionShare, SessionShareView
from .account_limit_override import AccountLimitOverride
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
from .platform_setting import PlatformSetting
__all__ = [
"User",
@@ -41,4 +44,9 @@ __all__ = [
"AuditLog",
"SessionShare",
"SessionShareView",
"AccountLimitOverride",
"FeatureFlag",
"PlanFeatureDefault",
"AccountFeatureOverride",
"PlatformSetting",
]

View File

@@ -14,6 +14,7 @@ if TYPE_CHECKING:
from app.models.tag import TreeTag
from app.models.step_category import StepCategory
from app.models.step_library import StepLibrary
from app.models.account_limit_override import AccountLimitOverride
class Account(Base):
@@ -43,3 +44,4 @@ class Account(Base):
tags: Mapped[list["TreeTag"]] = relationship("TreeTag", foreign_keys="[TreeTag.account_id]", back_populates="account")
step_categories: Mapped[list["StepCategory"]] = relationship("StepCategory", foreign_keys="[StepCategory.account_id]", back_populates="account")
step_library: Mapped[list["StepLibrary"]] = relationship("StepLibrary", foreign_keys="[StepLibrary.account_id]", back_populates="account")
limit_override: Mapped[Optional["AccountLimitOverride"]] = relationship("AccountLimitOverride", back_populates="account", uselist=False)

View File

@@ -0,0 +1,42 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import Integer, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.account import Account
from app.models.user import User
class AccountLimitOverride(Base):
__tablename__ = "account_limit_overrides"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
unique=True,
nullable=False
)
override_max_trees: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
override_max_sessions_per_month: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
override_max_users: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=False
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="limit_override")
created_by: Mapped["User"] = relationship("User")

View File

@@ -0,0 +1,84 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class FeatureFlag(Base):
__tablename__ = "feature_flags"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
flag_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
plan_defaults: Mapped[list["PlanFeatureDefault"]] = relationship("PlanFeatureDefault", back_populates="flag", cascade="all, delete-orphan")
account_overrides: Mapped[list["AccountFeatureOverride"]] = relationship("AccountFeatureOverride", back_populates="flag", cascade="all, delete-orphan")
class PlanFeatureDefault(Base):
__tablename__ = "plan_feature_defaults"
__table_args__ = (
UniqueConstraint("plan", "flag_id", name="uq_plan_feature_defaults_plan_flag"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
plan: Mapped[str] = mapped_column(String(50), ForeignKey("plan_limits.plan"), nullable=False)
flag_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("feature_flags.id", ondelete="CASCADE"),
nullable=False
)
enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Relationships
flag: Mapped["FeatureFlag"] = relationship("FeatureFlag", back_populates="plan_defaults")
class AccountFeatureOverride(Base):
__tablename__ = "account_feature_overrides"
__table_args__ = (
UniqueConstraint("account_id", "flag_id", name="uq_account_feature_overrides_account_flag"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False
)
flag_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("feature_flags.id", ondelete="CASCADE"),
nullable=False
)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
flag: Mapped["FeatureFlag"] = relationship("FeatureFlag", back_populates="account_overrides")
created_by: Mapped[Optional["User"]] = relationship("User")

View File

@@ -0,0 +1,32 @@
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
import uuid
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class PlatformSetting(Base):
__tablename__ = "platform_settings"
setting_key: Mapped[str] = mapped_column(String(100), primary_key=True)
setting_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
data_type: Mapped[str] = mapped_column(String(20), nullable=False, default="string")
is_sensitive: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
updated_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
updated_by: Mapped[Optional["User"]] = relationship("User")

View File

@@ -0,0 +1,208 @@
"""Pydantic schemas for admin panel endpoints."""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
# --- Dashboard ---
class DashboardMetrics(BaseModel):
total_users: int
active_subscriptions: int
paid_accounts: int
total_trees: int
class ActivityEntry(BaseModel):
id: UUID
user_email: Optional[str] = None
action: str
resource_type: str
resource_id: Optional[UUID] = None
details: Optional[dict] = None
ip_address: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# --- Audit Logs ---
class AuditLogEntry(BaseModel):
id: UUID
user_id: UUID
user_email: Optional[str] = None
action: str
resource_type: str
resource_id: Optional[UUID] = None
details: Optional[dict] = None
ip_address: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
class AuditLogListResponse(BaseModel):
items: list[AuditLogEntry]
total: int
page: int
per_page: int
# --- Plan Limits ---
class PlanLimitResponse(BaseModel):
plan: str
max_trees: Optional[int] = None
max_sessions_per_month: Optional[int] = None
max_users: Optional[int] = None
custom_branding: bool = False
priority_support: bool = False
export_formats: list = []
class Config:
from_attributes = True
class PlanLimitUpdate(BaseModel):
plan: str
max_trees: Optional[int] = None
max_sessions_per_month: Optional[int] = None
max_users: Optional[int] = None
custom_branding: bool = False
priority_support: bool = False
export_formats: list = Field(default_factory=lambda: ["markdown", "text"])
class AccountOverrideCreate(BaseModel):
account_display_code: str = Field(..., description="Account display code to look up")
override_max_trees: Optional[int] = None
override_max_sessions_per_month: Optional[int] = None
override_max_users: Optional[int] = None
note: Optional[str] = None
class AccountOverrideUpdate(BaseModel):
override_max_trees: Optional[int] = None
override_max_sessions_per_month: Optional[int] = None
override_max_users: Optional[int] = None
note: Optional[str] = None
class AccountOverrideResponse(BaseModel):
id: UUID
account_id: UUID
account_name: Optional[str] = None
account_display_code: Optional[str] = None
override_max_trees: Optional[int] = None
override_max_sessions_per_month: Optional[int] = None
override_max_users: Optional[int] = None
note: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# --- Feature Flags ---
class FeatureFlagCreate(BaseModel):
flag_key: str = Field(..., max_length=100)
display_name: str = Field(..., max_length=255)
description: Optional[str] = None
class FeatureFlagUpdate(BaseModel):
display_name: Optional[str] = None
description: Optional[str] = None
class PlanDefaultEntry(BaseModel):
plan: str
enabled: bool
class FeatureFlagResponse(BaseModel):
id: UUID
flag_key: str
display_name: str
description: Optional[str] = None
plan_defaults: list[PlanDefaultEntry] = []
created_at: datetime
class Config:
from_attributes = True
class PlanDefaultUpdate(BaseModel):
plan: str
flag_id: UUID
enabled: bool
class AccountFeatureOverrideCreate(BaseModel):
account_display_code: str
flag_id: UUID
enabled: bool
note: Optional[str] = None
class AccountFeatureOverrideResponse(BaseModel):
id: UUID
account_id: UUID
account_display_code: Optional[str] = None
flag_id: UUID
flag_key: Optional[str] = None
flag_display_name: Optional[str] = None
enabled: bool
note: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# --- Platform Settings ---
class SettingsResponse(BaseModel):
settings: dict
class SettingsUpdate(BaseModel):
settings: dict = Field(..., description="Key-value pairs to update")
# --- Global Categories ---
class GlobalCategoryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
slug: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
class GlobalCategoryUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=100)
slug: Optional[str] = Field(None, max_length=100)
description: Optional[str] = None
class GlobalCategoryResponse(BaseModel):
id: UUID
name: str
slug: str
description: Optional[str] = None
account_id: Optional[UUID] = None
tree_count: int = 0
class Config:
from_attributes = True
# --- Move User ---
class MoveUserAccount(BaseModel):
display_code: str = Field(..., description="Target account display code")

View File

@@ -0,0 +1,76 @@
"""Integration tests for admin audit log endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminAuditLogs:
@pytest.mark.asyncio
async def test_list_audit_logs(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""List audit logs with pagination."""
# Generate some audit activity first (e.g., admin listing users creates no audit,
# but we can create a tree to generate audit data)
response = await client.get(
"/api/v1/admin/audit-logs", headers=admin_auth_headers
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "per_page" in data
@pytest.mark.asyncio
async def test_filter_audit_logs_by_action(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Filter audit logs by action."""
response = await client.get(
"/api/v1/admin/audit-logs?action=tree.create",
headers=admin_auth_headers,
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_filter_audit_logs_by_resource_type(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Filter audit logs by resource_type."""
response = await client.get(
"/api/v1/admin/audit-logs?resource_type=tree",
headers=admin_auth_headers,
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_filter_audit_logs_by_date_range(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Filter audit logs by date range."""
response = await client.get(
"/api/v1/admin/audit-logs?date_from=2020-01-01T00:00:00Z&date_to=2030-12-31T23:59:59Z",
headers=admin_auth_headers,
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_export_audit_logs_csv(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Export audit logs as CSV."""
response = await client.get(
"/api/v1/admin/audit-logs/export", headers=admin_auth_headers
)
assert response.status_code == 200
assert "text/csv" in response.headers.get("content-type", "")
@pytest.mark.asyncio
async def test_non_admin_cannot_access(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/audit-logs", headers=auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,95 @@
"""Integration tests for admin global categories endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminGlobalCategories:
@pytest.mark.asyncio
async def test_list_global_categories(
self, client: AsyncClient, admin_auth_headers: dict
):
"""List global categories."""
response = await client.get("/api/v1/admin/categories/global", headers=admin_auth_headers)
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_create_global_category(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Create a global category."""
response = await client.post(
"/api/v1/admin/categories/global",
json={"name": "Test Category", "slug": "test-category"},
headers=admin_auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Category"
assert data["slug"] == "test-category"
assert data["account_id"] is None
@pytest.mark.asyncio
async def test_update_global_category(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Update a global category."""
create_resp = await client.post(
"/api/v1/admin/categories/global",
json={"name": "Old Name", "slug": "old-name"},
headers=admin_auth_headers,
)
cat_id = create_resp.json()["id"]
response = await client.put(
f"/api/v1/admin/categories/global/{cat_id}",
json={"name": "New Name"},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["name"] == "New Name"
@pytest.mark.asyncio
async def test_delete_global_category(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Delete (archive) a global category."""
create_resp = await client.post(
"/api/v1/admin/categories/global",
json={"name": "To Delete", "slug": "to-delete"},
headers=admin_auth_headers,
)
cat_id = create_resp.json()["id"]
response = await client.delete(
f"/api/v1/admin/categories/global/{cat_id}",
headers=admin_auth_headers,
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_duplicate_slug_fails(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Duplicate slug returns 409."""
await client.post(
"/api/v1/admin/categories/global",
json={"name": "First", "slug": "dupe-slug"},
headers=admin_auth_headers,
)
response = await client.post(
"/api/v1/admin/categories/global",
json={"name": "Second", "slug": "dupe-slug"},
headers=admin_auth_headers,
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_non_admin_cannot_access(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/categories/global", headers=auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,35 @@
"""Integration tests for admin dashboard endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminDashboard:
@pytest.mark.asyncio
async def test_get_dashboard_metrics(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Super admin can get dashboard metrics."""
response = await client.get("/api/v1/admin/dashboard/metrics", headers=admin_auth_headers)
assert response.status_code == 200
data = response.json()
assert "total_users" in data
assert data["total_users"] >= 2 # admin + test_user
@pytest.mark.asyncio
async def test_get_dashboard_activity(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Super admin can get recent activity."""
response = await client.get("/api/v1/admin/dashboard/activity", headers=admin_auth_headers)
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_non_admin_cannot_access_dashboard(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/dashboard/metrics", headers=auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,142 @@
"""Integration tests for admin feature flag endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminFeatureFlags:
@pytest.mark.asyncio
async def test_create_feature_flag(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Create a feature flag."""
response = await client.post(
"/api/v1/admin/feature-flags",
json={
"flag_key": "test_feature",
"display_name": "Test Feature",
"description": "A test feature flag",
},
headers=admin_auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["flag_key"] == "test_feature"
assert data["display_name"] == "Test Feature"
@pytest.mark.asyncio
async def test_list_feature_flags(
self, client: AsyncClient, admin_auth_headers: dict
):
"""List feature flags."""
# Create a flag first
await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "list_test", "display_name": "List Test"},
headers=admin_auth_headers,
)
response = await client.get("/api/v1/admin/feature-flags", headers=admin_auth_headers)
assert response.status_code == 200
flags = response.json()
assert len(flags) >= 1
@pytest.mark.asyncio
async def test_update_feature_flag(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Update a feature flag."""
create_resp = await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "update_test", "display_name": "Before"},
headers=admin_auth_headers,
)
flag_id = create_resp.json()["id"]
response = await client.put(
f"/api/v1/admin/feature-flags/{flag_id}",
json={"display_name": "After"},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["display_name"] == "After"
@pytest.mark.asyncio
async def test_update_plan_default(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Update a plan feature default."""
create_resp = await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "plan_default_test", "display_name": "Plan Default Test"},
headers=admin_auth_headers,
)
flag_id = create_resp.json()["id"]
response = await client.put(
"/api/v1/admin/feature-flags/plan-defaults",
json={"plan": "free", "flag_id": flag_id, "enabled": True},
headers=admin_auth_headers,
)
assert response.status_code == 200
# Verify in list
list_resp = await client.get("/api/v1/admin/feature-flags", headers=admin_auth_headers)
flag = next(f for f in list_resp.json() if f["id"] == flag_id)
assert any(d["plan"] == "free" and d["enabled"] for d in flag["plan_defaults"])
@pytest.mark.asyncio
async def test_delete_feature_flag_cascades(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Delete a feature flag cascades to plan defaults."""
create_resp = await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "delete_test", "display_name": "Delete Test"},
headers=admin_auth_headers,
)
flag_id = create_resp.json()["id"]
# Add a plan default
await client.put(
"/api/v1/admin/feature-flags/plan-defaults",
json={"plan": "pro", "flag_id": flag_id, "enabled": True},
headers=admin_auth_headers,
)
# Delete the flag
response = await client.delete(
f"/api/v1/admin/feature-flags/{flag_id}",
headers=admin_auth_headers,
)
assert response.status_code == 204
# Verify gone
list_resp = await client.get("/api/v1/admin/feature-flags", headers=admin_auth_headers)
assert not any(f["id"] == flag_id for f in list_resp.json())
@pytest.mark.asyncio
async def test_duplicate_flag_key_fails(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Duplicate flag_key returns 409."""
await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "dupe_test", "display_name": "First"},
headers=admin_auth_headers,
)
response = await client.post(
"/api/v1/admin/feature-flags",
json={"flag_key": "dupe_test", "display_name": "Second"},
headers=admin_auth_headers,
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_non_admin_cannot_access(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/feature-flags", headers=auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,58 @@
"""Integration tests for admin plan limits and account override endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminPlanLimits:
@pytest.mark.asyncio
async def test_list_plan_limits(
self, client: AsyncClient, admin_auth_headers: dict
):
"""List all plan limits."""
response = await client.get("/api/v1/admin/plan-limits", headers=admin_auth_headers)
assert response.status_code == 200
plans = response.json()
assert len(plans) >= 3 # free, pro, team seeded in conftest
plan_names = [p["plan"] for p in plans]
assert "free" in plan_names
@pytest.mark.asyncio
async def test_update_plan_limits(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Update a plan's limits."""
response = await client.put(
"/api/v1/admin/plan-limits",
json={
"plan": "free",
"max_trees": 5,
"max_sessions_per_month": 30,
"max_users": 2,
"custom_branding": False,
"priority_support": False,
"export_formats": ["markdown", "text"],
},
headers=admin_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["max_trees"] == 5
@pytest.mark.asyncio
async def test_list_account_overrides(
self, client: AsyncClient, admin_auth_headers: dict
):
"""List account overrides."""
response = await client.get("/api/v1/admin/account-overrides", headers=admin_auth_headers)
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_non_admin_cannot_access(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/plan-limits", headers=auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,43 @@
"""Integration tests for admin settings endpoints."""
import pytest
from httpx import AsyncClient
class TestAdminSettings:
@pytest.mark.asyncio
async def test_list_settings(
self, client: AsyncClient, admin_auth_headers: dict
):
"""List platform settings (may be empty if not seeded via migration)."""
response = await client.get("/api/v1/admin/settings", headers=admin_auth_headers)
assert response.status_code == 200
data = response.json()
assert "settings" in data
assert isinstance(data["settings"], dict)
@pytest.mark.asyncio
async def test_update_settings(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Update maintenance_mode setting."""
response = await client.put(
"/api/v1/admin/settings",
json={"settings": {"maintenance_mode": "true"}},
headers=admin_auth_headers,
)
assert response.status_code == 200
# Verify change
get_resp = await client.get("/api/v1/admin/settings", headers=admin_auth_headers)
settings = get_resp.json()["settings"]
assert settings["maintenance_mode"] is True or settings["maintenance_mode"] == "true"
@pytest.mark.asyncio
async def test_non_admin_cannot_access(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/settings", headers=auth_headers)
assert response.status_code == 403