""" routes_admin_surface.py ─────────────────────── Admin Control Plane API Roles: Only 'admin' or 'superadmin' may access these endpoints. Endpoints: GET /admin-surface/health — system health overview GET /admin-surface/queues — queue depth snapshot GET /admin-surface/installs — surface session / install overview POST /admin-surface/actions — submit an admin action GET /admin-surface/actions — list admin action history GET /admin-surface/actions/{id} — get a specific action GET /admin-surface/logs — recent audit event log GET /admin-surface/templates — template catalog summary (admin view) POST /admin-surface/templates/{id}/publish — publish a template POST /admin-surface/templates/{id}/archive — archive a template GET /admin-surface/synthetic-jobs — list synthetic generation jobs POST /admin-surface/synthetic-jobs/{id}/cancel — cancel a synthetic job """ from __future__ import annotations import json import logging import uuid from datetime import datetime, timezone from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel, Field from backend.auth.dependencies import get_current_user logger = logging.getLogger("velocity.admin_surface") router = APIRouter() # ── RBAC guard ──────────────────────────────────────────────────────────────── ADMIN_ROLES = {"admin", "superadmin", "ADMIN", "SUPERADMIN"} def require_admin(user=Depends(get_current_user)): normalized_role = user.role.upper() if normalized_role not in {"ADMIN", "SUPERADMIN"}: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required.", ) return user # ── Helpers ─────────────────────────────────────────────────────────────────── def _pool(request: Request): pool = request.app.state.db_pool if pool is None: raise HTTPException(status_code=503, detail="Database unavailable.") return pool # ── Pydantic Models ─────────────────────────────────────────────────────────── VALID_ACTION_TYPES = { "user_create", "user_deactivate", "user_role_change", "tenant_config_update", "inventory_batch_approve", "inventory_batch_reject", "template_publish", "template_archive", "synthetic_job_trigger", "synthetic_job_cancel", "system_health_check", "queue_drain", "debug_event_export", "install_register", "install_deregister", } class AdminActionRequest(BaseModel): action_type: str target_type: str target_id: str payload: dict = Field(default_factory=dict) idempotency_key: Optional[str] = None # ── System Health ───────────────────────────────────────────────────────────── @router.get("/health", summary="System health overview") async def get_health( request: Request, admin=Depends(require_admin), ): """ Returns an aggregated health snapshot covering DB pool, queue depths, and basic surface session counts. """ pool = _pool(request) async with pool.acquire() as conn: # DB round-trip latency import time t0 = time.monotonic() await conn.fetchval("SELECT 1") db_latency_ms = round((time.monotonic() - t0) * 1000, 2) # Pending jobs pending_transcriptions = await conn.fetchval( "SELECT COUNT(*) FROM edge_transcription_jobs WHERE status='pending'" ) pending_synthetic_jobs = await conn.fetchval( "SELECT COUNT(*) FROM oracle_synthetic_generation_jobs WHERE status IN ('pending','running')" ) pending_admin_actions = await conn.fetchval( "SELECT COUNT(*) FROM admin_action_events WHERE status='pending'" ) pending_inventory_batches = await conn.fetchval( "SELECT COUNT(*) FROM inventory_import_batches WHERE status IN ('pending','validating','processing')" ) # Active surface sessions (last 30 min) active_sessions = await conn.fetchval( "SELECT COUNT(*) FROM surface_sessions WHERE last_active_at > NOW() - INTERVAL '30 minutes'" ) # Surface breakdown surface_breakdown = await conn.fetch( """ SELECT surface_type, COUNT(*) as count FROM surface_sessions WHERE last_active_at > NOW() - INTERVAL '30 minutes' GROUP BY surface_type """ ) return { "status": "ok", "timestamp": datetime.now(timezone.utc).isoformat(), "database": { "connected": True, "latency_ms": db_latency_ms, }, "queues": { "pending_transcriptions": pending_transcriptions, "pending_synthetic_jobs": pending_synthetic_jobs, "pending_admin_actions": pending_admin_actions, "pending_inventory_batches": pending_inventory_batches, }, "active_sessions": { "total": active_sessions, "by_surface": {r["surface_type"]: r["count"] for r in surface_breakdown}, }, } # ── Queue Visibility ────────────────────────────────────────────────────────── @router.get("/queues", summary="Queue depth snapshot") async def get_queues( request: Request, admin=Depends(require_admin), ): pool = _pool(request) async with pool.acquire() as conn: transcription_queue = await conn.fetch( """ SELECT status, COUNT(*) as count FROM edge_transcription_jobs GROUP BY status ORDER BY status """ ) synthetic_queue = await conn.fetch( """ SELECT status, COUNT(*) as count FROM oracle_synthetic_generation_jobs GROUP BY status ORDER BY status """ ) inventory_queue = await conn.fetch( """ SELECT status, COUNT(*) as count FROM inventory_import_batches GROUP BY status ORDER BY status """ ) admin_queue = await conn.fetch( """ SELECT status, COUNT(*) as count FROM admin_action_events GROUP BY status ORDER BY status """ ) return { "transcription_jobs": {r["status"]: r["count"] for r in transcription_queue}, "synthetic_jobs": {r["status"]: r["count"] for r in synthetic_queue}, "inventory_batches": {r["status"]: r["count"] for r in inventory_queue}, "admin_actions": {r["status"]: r["count"] for r in admin_queue}, "timestamp": datetime.now(timezone.utc).isoformat(), } # ── Install / Surface Overview ──────────────────────────────────────────────── @router.get("/installs", summary="Surface session and install overview") async def get_installs( request: Request, admin=Depends(require_admin), ): pool = _pool(request) async with pool.acquire() as conn: rows = await conn.fetch( """ SELECT surface_type, app_version, COUNT(*) as session_count, MAX(last_active_at) as last_seen FROM surface_sessions GROUP BY surface_type, app_version ORDER BY surface_type, app_version """ ) return { "installs": [dict(r) for r in rows], "timestamp": datetime.now(timezone.utc).isoformat(), } # ── Admin Actions ───────────────────────────────────────────────────────────── @router.post("/actions", status_code=status.HTTP_201_CREATED, summary="Submit an admin action") async def submit_action( request: Request, body: AdminActionRequest, admin=Depends(require_admin), ): """ Submit a bounded admin action. All actions are persisted with full audit trail. Supported action_types are enumerated in VALID_ACTION_TYPES. Actions are not auto-executed — they transition to 'pending' and must be processed by the appropriate backend job or confirmed by a second admin. (This prevents destructive mass-actions from running unreviewed.) """ if body.action_type not in VALID_ACTION_TYPES: raise HTTPException(400, f"Invalid action_type. Valid: {sorted(VALID_ACTION_TYPES)}") action_id = body.idempotency_key or str(uuid.uuid4()) pool = _pool(request) async with pool.acquire() as conn: try: row = await conn.fetchrow( """ INSERT INTO admin_action_events ( tenant_id, action_id, action_type, target_type, target_id, requested_by, payload ) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb) RETURNING action_event_id, status, created_at """, admin.role, action_id, body.action_type, body.target_type, body.target_id, admin.user_id, json.dumps(body.payload), ) except Exception as exc: if "unique" in str(exc).lower(): raise HTTPException(409, "Action with this idempotency key already exists") raise logger.info( "Admin action submitted: %s by %s → %s/%s", body.action_type, admin.user_id, body.target_type, body.target_id, ) return { "action_event_id": str(row["action_event_id"]), "action_id": action_id, "status": row["status"], "created_at": str(row["created_at"]), } @router.get("/actions", summary="List admin action history") async def list_actions( request: Request, action_type: Optional[str] = Query(None), status_filter: Optional[str] = Query(None, alias="status"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), admin=Depends(require_admin), ): pool = _pool(request) where = "WHERE tenant_id = $1" params: list[Any] = [admin.role] idx = 2 if action_type: where += f" AND action_type = ${idx}"; params.append(action_type); idx += 1 if status_filter: where += f" AND status = ${idx}"; params.append(status_filter); idx += 1 async with pool.acquire() as conn: rows = await conn.fetch( f""" SELECT action_event_id, action_id, action_type, target_type, target_id, requested_by, status, result_message, executed_at, created_at FROM admin_action_events {where} ORDER BY created_at DESC LIMIT ${idx} OFFSET ${idx+1} """, *params, limit, offset, ) total = await conn.fetchval( f"SELECT COUNT(*) FROM admin_action_events {where}", *params, ) return {"total": total, "limit": limit, "offset": offset, "actions": [dict(r) for r in rows]} @router.get("/actions/{action_event_id}", summary="Get a specific admin action") async def get_action( action_event_id: str, request: Request, admin=Depends(require_admin), ): pool = _pool(request) async with pool.acquire() as conn: row = await conn.fetchrow( "SELECT * FROM admin_action_events WHERE action_event_id=$1 AND tenant_id=$2", action_event_id, admin.role, ) if not row: raise HTTPException(404, "Admin action not found") return dict(row) # ── Audit Log ───────────────────────────────────────────────────────────────── @router.get("/logs", summary="Recent Oracle audit events") async def get_audit_logs( request: Request, entity_type: Optional[str] = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), admin=Depends(require_admin), ): pool = _pool(request) async with pool.acquire() as conn: if entity_type: rows = await conn.fetch( """ SELECT audit_event_id, entity_type, entity_id, action, actor_id, actor_type, correlation_id, details, created_at FROM oracle_audit_events WHERE tenant_id=$1 AND entity_type=$2 ORDER BY created_at DESC LIMIT $3 OFFSET $4 """, admin.role, entity_type, limit, offset, ) else: rows = await conn.fetch( """ SELECT audit_event_id, entity_type, entity_id, action, actor_id, actor_type, correlation_id, details, created_at FROM oracle_audit_events WHERE tenant_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 """, admin.role, limit, offset, ) return {"logs": [dict(r) for r in rows]} # ── Template Administration ─────────────────────────────────────────────────── @router.get("/templates", summary="Template catalog admin view") async def get_templates_admin( request: Request, status_filter: Optional[str] = Query(None, alias="status"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), admin=Depends(require_admin), ): pool = _pool(request) where = "WHERE tenant_id = $1" params: list[Any] = [admin.role] idx = 2 if status_filter: where += f" AND status = ${idx}"; params.append(status_filter); idx += 1 async with pool.acquire() as conn: rows = await conn.fetch( f""" SELECT t.template_id, t.name, t.category, t.status, t.origin, t.version, t.use_count, t.chapter_id, t.subchapter_id, ch.name as chapter_name, sub.name as subchapter_name, t.created_at, t.updated_at FROM oracle_component_templates t LEFT JOIN oracle_template_chapters ch ON ch.chapter_id = t.chapter_id LEFT JOIN oracle_template_subchapters sub ON sub.subchapter_id = t.subchapter_id {where} ORDER BY t.updated_at DESC LIMIT ${idx} OFFSET ${idx+1} """, *params, limit, offset, ) total = await conn.fetchval( f"SELECT COUNT(*) FROM oracle_component_templates {where}", *params, ) return {"total": total, "limit": limit, "offset": offset, "templates": [dict(r) for r in rows]} @router.post("/templates/{template_id}/publish", summary="Publish a template") async def publish_template( template_id: str, request: Request, admin=Depends(require_admin), ): pool = _pool(request) async with pool.acquire() as conn: result = await conn.execute( """ UPDATE oracle_component_templates SET status='catalog_active', updated_at=NOW() WHERE template_id=$1 AND tenant_id=$2 """, template_id, admin.role, ) if result == "UPDATE 0": raise HTTPException(404, "Template not found") logger.info("Template %s published by admin %s", template_id, admin.user_id) return {"status": "published"} @router.post("/templates/{template_id}/archive", summary="Archive a template") async def archive_template( template_id: str, request: Request, admin=Depends(require_admin), ): pool = _pool(request) async with pool.acquire() as conn: result = await conn.execute( """ UPDATE oracle_component_templates SET status='archived', updated_at=NOW() WHERE template_id=$1 AND tenant_id=$2 """, template_id, admin.role, ) if result == "UPDATE 0": raise HTTPException(404, "Template not found") logger.info("Template %s archived by admin %s", template_id, admin.user_id) return {"status": "archived"} # ── Template Chapter Admin ──────────────────────────────────────────────────── @router.get("/template-chapters", summary="List template chapters (admin view)") async def list_chapters_admin( request: Request, admin=Depends(require_admin), ): pool = _pool(request) async with pool.acquire() as conn: rows = await conn.fetch( """ SELECT ch.chapter_id, ch.name, ch.description, ch.sort_order, ch.is_active, COUNT(sub.subchapter_id) as subchapter_count FROM oracle_template_chapters ch LEFT JOIN oracle_template_subchapters sub ON sub.chapter_id = ch.chapter_id WHERE ch.tenant_id=$1 GROUP BY ch.chapter_id ORDER BY ch.sort_order ASC """, admin.role, ) return {"chapters": [dict(r) for r in rows]} # ── Synthetic Jobs Admin ────────────────────────────────────────────────────── @router.get("/synthetic-jobs", summary="List synthetic generation jobs") async def list_synthetic_jobs( request: Request, status_filter: Optional[str] = Query(None, alias="status"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), admin=Depends(require_admin), ): pool = _pool(request) async with pool.acquire() as conn: if status_filter: rows = await conn.fetch( """ SELECT job_id, template_id, model, status, requested_count, accepted_count, created_by, started_at, completed_at, created_at FROM oracle_synthetic_generation_jobs WHERE tenant_id=$1 AND status=$2 ORDER BY created_at DESC LIMIT $3 OFFSET $4 """, admin.role, status_filter, limit, offset, ) else: rows = await conn.fetch( """ SELECT job_id, template_id, model, status, requested_count, accepted_count, created_by, started_at, completed_at, created_at FROM oracle_synthetic_generation_jobs WHERE tenant_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 """, admin.role, limit, offset, ) return {"jobs": [dict(r) for r in rows]} @router.post("/synthetic-jobs/{job_id}/cancel", summary="Cancel a synthetic generation job") async def cancel_synthetic_job( job_id: str, request: Request, admin=Depends(require_admin), ): pool = _pool(request) async with pool.acquire() as conn: result = await conn.execute( """ UPDATE oracle_synthetic_generation_jobs SET status='cancelled', updated_at=NOW() WHERE job_id=$1 AND tenant_id=$2 AND status IN ('pending','running') """, job_id, admin.role, ) if result == "UPDATE 0": raise HTTPException(404, "Job not found or already in terminal state") return {"status": "cancelled"}