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:
102
backend/alembic/versions/026_add_admin_panel_tables.py
Normal file
102
backend/alembic/versions/026_add_admin_panel_tables.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
154
backend/app/api/endpoints/admin_audit.py
Normal file
154
backend/app/api/endpoints/admin_audit.py
Normal 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"},
|
||||
)
|
||||
127
backend/app/api/endpoints/admin_categories.py
Normal file
127
backend/app/api/endpoints/admin_categories.py
Normal 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()
|
||||
82
backend/app/api/endpoints/admin_dashboard.py
Normal file
82
backend/app/api/endpoints/admin_dashboard.py
Normal 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
|
||||
]
|
||||
251
backend/app/api/endpoints/admin_feature_flags.py
Normal file
251
backend/app/api/endpoints/admin_feature_flags.py
Normal 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()
|
||||
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()
|
||||
40
backend/app/api/endpoints/admin_settings.py
Normal file
40
backend/app/api/endpoints/admin_settings.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
96
backend/app/core/settings_manager.py
Normal file
96
backend/app/core/settings_manager.py
Normal 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"
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
42
backend/app/models/account_limit_override.py
Normal file
42
backend/app/models/account_limit_override.py
Normal 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")
|
||||
84
backend/app/models/feature_flag.py
Normal file
84
backend/app/models/feature_flag.py
Normal 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")
|
||||
32
backend/app/models/platform_setting.py
Normal file
32
backend/app/models/platform_setting.py
Normal 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")
|
||||
208
backend/app/schemas/admin.py
Normal file
208
backend/app/schemas/admin.py
Normal 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")
|
||||
76
backend/tests/test_admin_audit_logs.py
Normal file
76
backend/tests/test_admin_audit_logs.py
Normal 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
|
||||
95
backend/tests/test_admin_categories_global.py
Normal file
95
backend/tests/test_admin_categories_global.py
Normal 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
|
||||
35
backend/tests/test_admin_dashboard.py
Normal file
35
backend/tests/test_admin_dashboard.py
Normal 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
|
||||
142
backend/tests/test_admin_feature_flags.py
Normal file
142
backend/tests/test_admin_feature_flags.py
Normal 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
|
||||
58
backend/tests/test_admin_plan_limits.py
Normal file
58
backend/tests/test_admin_plan_limits.py
Normal 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
|
||||
43
backend/tests/test_admin_settings.py
Normal file
43
backend/tests/test_admin_settings.py
Normal 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
|
||||
Reference in New Issue
Block a user