"""Admin panel routes: settings, LLM logs, users.""" from __future__ import annotations from typing import Any, Dict, List from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from fastapi import APIRouter, Body, Depends, HTTPException from app.core.settings_service import EDITABLE_SETTING_KEYS, get_all_settings, update_settings from app.db import get_db_dep from app.deps import require_admin from app.models import LlmCallLog, Setting, User from app.schemas import LlmLogOut, SettingsOut, SettingsUpdate router = APIRouter(prefix="/api/admin", tags=["admin"]) def _mask_secrets(values: Dict[str, Any]) -> Dict[str, Any]: """Mask sensitive api_key fields in outbound responses.""" for k in ("llm.api_key", "embedding.api_key"): v = values.get(k) if isinstance(v, str) and v: values[k] = v[:4] + "***" + v[-4:] if len(v) > 8 else "***" # Never expose admin setup token via this endpoint values.pop("admin.setup_token", None) return values @router.get("/settings", response_model=SettingsOut) async def get_settings_endpoint( db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): values = await get_all_settings(db) values = _mask_secrets(values) return SettingsOut(values=values, editable_keys=sorted(EDITABLE_SETTING_KEYS.keys())) @router.put("/settings", response_model=SettingsOut) async def update_settings_endpoint( payload: SettingsUpdate, db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): # Strip masked api_key fields unless the user typed a new value cleaned: Dict[str, Any] = {} for k, v in (payload.values or {}).items(): if k in ("llm.api_key", "embedding.api_key") and isinstance(v, str) and "***" in v: continue cleaned[k] = v new_values = await update_settings(db, cleaned) # If embedding settings changed, drop the cached RAG client so the next # get_rag() call rebuilds it (and reconfigures Qdrant collections if dim changed). if any(k.startswith("embedding.") for k in cleaned): from app.core.rag import reset_rag await reset_rag() new_values = _mask_secrets(new_values) return SettingsOut(values=new_values, editable_keys=sorted(EDITABLE_SETTING_KEYS.keys())) @router.post("/embeddings/test") async def test_embeddings_endpoint( payload: Dict[str, Any] = Body(default={}), db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): """Probe the currently configured embeddings endpoint. Accepts an optional `overrides` dict with embedding.* keys (e.g. to test a new endpoint before saving). Returns: ok, provider, base_url, model, dim, sample_norm (or error). """ from app.core.rag import probe_embeddings settings_map = await get_all_settings(db) # Apply ad-hoc overrides (without saving) so the admin can try before save overrides = (payload or {}).get("overrides") or {} for k, v in overrides.items(): if k in EDITABLE_SETTING_KEYS: settings_map[k] = v return await probe_embeddings(settings_map) @router.get("/llm-logs", response_model=List[LlmLogOut]) async def list_llm_logs( limit: int = 50, offset: int = 0, db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): result = await db.execute( select(LlmCallLog).order_by(LlmCallLog.created_at.desc()).limit(min(limit, 200)).offset(offset) ) return result.scalars().all() @router.get("/llm-logs/{log_id}") async def get_llm_log( log_id: str, db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): from uuid import UUID result = await db.execute(select(LlmCallLog).where(LlmCallLog.id == UUID(log_id))) log = result.scalars().first() if not log: raise HTTPException(status_code=404, detail="log_not_found") return { "id": str(log.id), "purpose": log.purpose, "model": log.model, "base_url": log.base_url, "prompt_messages": log.prompt_messages, "tools": log.tools, "response_text": log.response_text, "tool_calls": log.tool_calls, "prompt_tokens": log.prompt_tokens, "completion_tokens": log.completion_tokens, "total_tokens": log.total_tokens, "latency_ms": log.latency_ms, "error": log.error, "created_at": log.created_at.isoformat() if log.created_at else None, } @router.get("/users") async def list_users( db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): result = await db.execute(select(User).order_by(User.created_at.desc())) users = result.scalars().all() return [ { "id": str(u.id), "email": u.email, "username": u.username, "is_admin": u.is_admin, "is_active": u.is_active, "created_at": u.created_at.isoformat() if u.created_at else None, } for u in users ] @router.post("/users/{user_id}/set-active") async def set_user_active( user_id: UUID, payload: Dict[str, Any] = Body(default={}), db: AsyncSession = Depends(get_db_dep), admin: User = Depends(require_admin), ): """Activate or ban a user. Banned users cannot log in (see auth.login). Body: `{"is_active": true|false}`. Admins cannot ban themselves. """ is_active = bool(payload.get("is_active")) result = await db.execute(select(User).where(User.id == user_id)) user = result.scalars().first() if not user: raise HTTPException(status_code=404, detail="user_not_found") if user.id == admin.id and not is_active: raise HTTPException(status_code=400, detail="cannot_ban_self") user.is_active = is_active await db.commit() return { "id": str(user.id), "email": user.email, "username": user.username, "is_admin": user.is_admin, "is_active": user.is_active, }