"""Admin operations routes.""" from __future__ import annotations import time from datetime import datetime, timezone from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from ..auth_sessions import ( active_session_count, deactivate_managed_user, list_managed_users, update_managed_user, upsert_managed_user, ) from ..config import settings from ..db import acquire, get_pool, healthcheck from ..deps import Principal, Role, require_role from ..engine_client import engine_client from ..runtime_policy import require_runtime_fallback_allowed from ..services.voice import voice_service from ..services import rag router = APIRouter(prefix="/admin", tags=["admin"]) AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))] HealthStatus = Literal["ok", "degraded", "down"] class AdminServiceHealth(BaseModel): key: str name: str status: HealthStatus detail: str metric: str load: float class AdminHealthResponse(BaseModel): status: HealthStatus environment: str engine_mode: str services: list[AdminServiceHealth] class AdminEngineConfigResponse(BaseModel): engine_mode: str engine_url: str model: str updated_by: str | None = None updated_at: float | None = None durable: bool = False source: Literal["database", "runtime_cache", "runtime_default"] = "runtime_default" class AdminEngineConfigPatch(BaseModel): engine_mode: str | None = None engine_url: str | None = None model: str | None = None RoleName = Literal["learner", "teacher", "admin"] class AdminUserResponse(BaseModel): user_id: str email: str display_name: str role: RoleName cohort_ids: list[str] affiliation: str active_sessions: int created_at: float last_seen_at: float source: Literal["database", "server_session_registry"] class AdminUsersResponse(BaseModel): source: Literal["database", "server_session_registry"] durable: bool users: list[AdminUserResponse] class AdminUserPatch(BaseModel): display_name: str | None = Field(default=None, min_length=1, max_length=80) role: RoleName | None = None affiliation: str | None = Field(default=None, max_length=120) cohort_ids: list[str] | None = None class RuntimeHealthMetrics(BaseModel): engine_latency_ms: float | None = None db_pool_size: int = 0 db_pool_idle: int = 0 db_pool_max: int = 0 active_users: int = 0 active_auth_sessions: int = 0 active_sessions: int = 0 ended_sessions: int = 0 pending_reviews: int = 0 def _clamp01(value: float) -> float: return round(max(0.0, min(1.0, value)), 3) def _pool_load(metrics: RuntimeHealthMetrics) -> float: if metrics.db_pool_max <= 0: return 0.0 busy = max(0, metrics.db_pool_size - metrics.db_pool_idle) return _clamp01(busy / metrics.db_pool_max) def _workload_load(count: int, expected_capacity: int) -> float: if expected_capacity <= 0: return 0.0 return _clamp01(count / expected_capacity) async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics: metrics = RuntimeHealthMetrics() try: pool = get_pool() metrics.db_pool_size = int(pool.get_size()) metrics.db_pool_idle = int(pool.get_idle_size()) metrics.db_pool_max = int(pool.get_max_size()) except Exception: pass if not db_ok: return metrics try: async with acquire(role="admin") as conn: row = await conn.fetchrow( """ SELECT (SELECT COUNT(*) FROM app.app_user WHERE is_active) AS active_users, ( SELECT COUNT(*) FROM app.auth_session WHERE revoked_at IS NULL AND expires_at > now() ) AS active_auth_sessions, ( SELECT COUNT(*) FROM app.sessions WHERE ended_at IS NULL ) AS active_sessions, ( SELECT COUNT(*) FROM app.sessions WHERE ended_at IS NOT NULL ) AS ended_sessions, ( SELECT COUNT(*) FROM app.sessions s LEFT JOIN app.session_summary ss ON ss.session_id = s.id WHERE s.ended_at IS NOT NULL AND ss.session_id IS NULL ) AS pending_reviews """ ) if row is not None: metrics.active_users = int(row["active_users"] or 0) metrics.active_auth_sessions = int(row["active_auth_sessions"] or 0) metrics.active_sessions = int(row["active_sessions"] or 0) metrics.ended_sessions = int(row["ended_sessions"] or 0) metrics.pending_reviews = int(row["pending_reviews"] or 0) except Exception: return metrics return metrics class AdminUserCreate(BaseModel): email: str = Field(..., min_length=3, max_length=254) display_name: str = Field(..., min_length=1, max_length=80) role: RoleName = "learner" affiliation: str | None = Field(default=None, max_length=120) cohort_ids: list[str] = Field(default_factory=list) class AdminUserDeleteResponse(BaseModel): ok: bool user_id: str _ENGINE_CONFIG: AdminEngineConfigResponse | None = None ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"} ENGINE_MODE_ALIASES = {"messages_api": "claude_api"} def _normalize_email(value: str) -> str: email = value.strip().lower() if "@" not in email: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid") local, domain = email.rsplit("@", 1) if not local or not domain: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid") allowed = {item.strip().lower().lstrip("@") for item in settings.auth_allowed_email_domains if item.strip()} if domain not in allowed: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed") return email def _default_engine_config() -> AdminEngineConfigResponse: return AdminEngineConfigResponse( engine_mode=settings.engine_mode, engine_url=settings.engine_url, model="gateway-default", durable=False, source="runtime_default", ) def _normalize_engine_mode(value: str) -> str: mode = ENGINE_MODE_ALIASES.get(value.strip(), value.strip()) if mode not in ENGINE_MODES: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"unsupported engine mode {value}", ) return mode def _normalize_engine_url(value: str) -> str: url = value.strip().rstrip("/") if not (url.startswith("http://") or url.startswith("https://")): raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, detail="engine_url must start with http:// or https://", ) return url def _updated_at_ts(value: datetime | None) -> float | None: if value is None: return None if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value.timestamp() def _engine_config_from_row(row) -> AdminEngineConfigResponse: return AdminEngineConfigResponse( engine_mode=_normalize_engine_mode(row["engine_mode"]), engine_url=_normalize_engine_url(row["engine_url"]), model=row["model"], updated_by=row["updated_by"], updated_at=_updated_at_ts(row["updated_at"]), durable=True, source="database", ) async def _current_engine_config() -> AdminEngineConfigResponse: if _ENGINE_CONFIG is not None: return _ENGINE_CONFIG try: pool = get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( """ SELECT engine_mode, engine_url, model, updated_by, updated_at FROM app.admin_engine_config WHERE id = TRUE """ ) if row is not None: return _engine_config_from_row(row) except Exception: require_runtime_fallback_allowed("admin engine config") require_runtime_fallback_allowed("admin engine config") return _default_engine_config() async def apply_engine_config_from_store() -> AdminEngineConfigResponse: """Load admin engine settings and apply them to the live engine client.""" config = await _current_engine_config() await engine_client.configure( base_url=config.engine_url, engine_mode=config.engine_mode, default_model=config.model, ) return config def _overall_status(services: list[AdminServiceHealth]) -> HealthStatus: if any(s.status == "down" for s in services): return "down" if any(s.status == "degraded" for s in services): return "degraded" return "ok" def _engine_unavailable_detail(detail: str) -> str: if detail.lstrip().startswith("{") and '"ok":false' in detail: return "Engine readiness failed" return detail async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse: return AdminUserResponse( user_id=user.user_id, email=user.email, display_name=user.display_name, role=user.role, cohort_ids=user.cohort_ids, affiliation=user.affiliation, active_sessions=await active_session_count(user.user_id), created_at=user.created_at, last_seen_at=user.last_seen_at, source="database" if durable else "server_session_registry", ) @router.get("/health", response_model=AdminHealthResponse) async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse: """Return operational health from live backend checks.""" current_engine = await _current_engine_config() db_ok = await healthcheck() engine_started = time.perf_counter() engine = await engine_client.health_detail() engine_ok = bool(engine.get("ok")) engine_detail = _engine_unavailable_detail( str(engine.get("detail") or "engine readiness unavailable") ) engine_latency_ms = (time.perf_counter() - engine_started) * 1000 voice_ok = voice_service.is_available() metrics = await _runtime_health_metrics(db_ok=db_ok) metrics.engine_latency_ms = engine_latency_ms if engine_ok else None pool_load = _pool_load(metrics) session_load = _workload_load(metrics.active_sessions, 50) review_load = _workload_load(metrics.pending_reviews, 50) runtime_fallback_is_enabled = settings.environment == "dev" db_status: HealthStatus = "ok" if db_ok else ("degraded" if runtime_fallback_is_enabled else "down") db_detail = ( "사용자, 세션, 리뷰 저장" if db_ok else ( "DB 연결 전까지 비영구 개발 런타임 기록 사용" if runtime_fallback_is_enabled else "DB 저장소에 연결할 수 없습니다" ) ) db_metric = ( f"풀 {max(0, metrics.db_pool_size - metrics.db_pool_idle)}/{metrics.db_pool_max}" if db_ok else ("비영구 런타임 기록" if runtime_fallback_is_enabled else "저장소 중단") ) services = [ AdminServiceHealth( key="engine", name="응답 생성", status="ok" if engine_ok else "down", detail="AI 엔진 생성 준비 완료" if engine_ok else engine_detail, metric=f"{engine_latency_ms:.0f}ms" if engine_ok else "로그인/설정 필요", load=_clamp01(engine_latency_ms / 1500) if engine_ok else 0.0, ), AdminServiceHealth( key="db", name="영구 저장소", status=db_status, detail=db_detail, metric=db_metric, load=max(pool_load, session_load) if db_ok else 0.0, ), AdminServiceHealth( key="voice", name="음성 입력", status="ok" if voice_ok else "degraded", detail="음성 입력과 재생", metric="OpenAI 연결" if voice_ok else "설정 필요", load=0.05 if voice_ok else 0.0, ), AdminServiceHealth( key="evaluation", name="리뷰 생성", status="ok" if engine_ok else "degraded", detail="회기 종료 후 피드백 생성", metric=f"대기 {metrics.pending_reviews}건", load=review_load if engine_ok else 0.0, ), AdminServiceHealth( key="kb", name="지식 검색", status="ok" if db_ok else "degraded", detail=f"검색 기준값 {rag.CRAG_TOP1_THRESHOLD}", metric=( f"활성 세션 {metrics.active_sessions}건" if db_ok else "대기 중" ), load=max(pool_load, session_load) if db_ok else 0.0, ), ] return AdminHealthResponse( status=_overall_status(services), environment=settings.environment, engine_mode=current_engine.engine_mode, services=services, ) @router.get("/engine-config", response_model=AdminEngineConfigResponse) async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigResponse: """Return the current admin-managed engine settings.""" return await _current_engine_config() @router.patch("/engine-config", response_model=AdminEngineConfigResponse) async def patch_engine_config( body: AdminEngineConfigPatch, principal: AdminPrincipal, ) -> AdminEngineConfigResponse: """Persist engine settings for administrators.""" global _ENGINE_CONFIG current = await _current_engine_config() next_mode = _normalize_engine_mode(body.engine_mode or current.engine_mode) next_url = _normalize_engine_url(body.engine_url or current.engine_url) next_config = AdminEngineConfigResponse( engine_mode=next_mode, engine_url=next_url, model=(body.model or current.model).strip(), updated_by=principal.email, updated_at=time.time(), durable=False, source="runtime_cache", ) try: pool = get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( """ INSERT INTO app.admin_engine_config ( id, engine_mode, engine_url, model, updated_by, updated_at ) VALUES (TRUE, $1, $2, $3, $4, now()) ON CONFLICT (id) DO UPDATE SET engine_mode = EXCLUDED.engine_mode, engine_url = EXCLUDED.engine_url, model = EXCLUDED.model, updated_by = EXCLUDED.updated_by, updated_at = now() RETURNING engine_mode, engine_url, model, updated_by, updated_at """, next_config.engine_mode, next_config.engine_url, next_config.model, principal.email, ) next_config = _engine_config_from_row(row) except Exception as exc: if settings.environment != "dev": raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="engine config persistence unavailable", ) from exc _ENGINE_CONFIG = next_config await engine_client.configure( base_url=next_config.engine_url, engine_mode=next_config.engine_mode, default_model=next_config.model, ) return next_config @router.get("/users", response_model=AdminUsersResponse) async def list_users(principal: AdminPrincipal) -> AdminUsersResponse: """Return users observed by the server-side auth/session boundary.""" users, durable = await list_managed_users() if not durable: require_runtime_fallback_allowed("admin user list") return AdminUsersResponse( source="database" if durable else "server_session_registry", durable=durable, users=[await _admin_user_response(user, durable=durable) for user in users], ) @router.post("/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED) async def create_user( body: AdminUserCreate, principal: AdminPrincipal, ) -> AdminUserResponse: """Create or reactivate a managed user without requiring that user to log in first.""" user = await upsert_managed_user( email=_normalize_email(body.email), display_name=body.display_name, role=body.role, affiliation=body.affiliation, cohort_ids=body.cohort_ids, reactivate=True, ) users, durable = await list_managed_users() if not durable: require_runtime_fallback_allowed("admin user create") return await _admin_user_response(user, durable=durable) @router.patch("/users/{user_id}", response_model=AdminUserResponse) async def patch_user( user_id: str, body: AdminUserPatch, principal: AdminPrincipal, ) -> AdminUserResponse: """Update a server-known user's role/profile for the current API process.""" next_user = await update_managed_user( user_id, display_name=body.display_name, role=body.role, affiliation=body.affiliation, cohort_ids=body.cohort_ids, ) if next_user is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found") users, durable = await list_managed_users() if not durable: require_runtime_fallback_allowed("admin user update") return await _admin_user_response(next_user, durable=durable) @router.delete("/users/{user_id}", response_model=AdminUserDeleteResponse) async def delete_user( user_id: str, principal: AdminPrincipal, ) -> AdminUserDeleteResponse: """Deactivate a managed user and revoke any active browser sessions.""" if user_id == principal.user_id: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate current admin") if not await deactivate_managed_user(user_id): raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found") return AdminUserDeleteResponse(ok=True, user_id=user_id)