1339 lines
42 KiB
Python
1339 lines
42 KiB
Python
"""Admin REST routes — department CRUD + skill/KB bindings (U2).
|
|
|
|
This module hosts the *department-scoped* admin endpoints under
|
|
``/api/v1/admin/departments/*``. It is a sibling of the existing
|
|
``admin_router`` in :mod:`agentkit.server.routes.auth` (which hosts
|
|
session-management endpoints). The two routers coexist independently:
|
|
``auth.admin_router`` keeps its session endpoints, this module adds
|
|
department endpoints, and ``app.py`` mounts both under ``/api/v1``.
|
|
|
|
All endpoints here require the ``USER_MANAGE`` permission (admin role),
|
|
enforced by the :func:`_require_admin` dependency. The dependency is
|
|
re-defined locally to avoid a hard dependency on ``routes.auth`` at
|
|
import time (keeps the module self-contained and test-friendly).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import PlainTextResponse
|
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
|
|
|
from agentkit.server.admin.department_service import get_department_service
|
|
from agentkit.server.admin.kb_service import get_kb_service
|
|
from agentkit.server.admin.llm_config_service import (
|
|
LlmConfigService,
|
|
get_llm_config_service,
|
|
)
|
|
from agentkit.server.admin.quota_service import get_quota_service
|
|
from agentkit.server.admin.skill_service import get_skill_service
|
|
from agentkit.server.admin.usage_service import get_usage_service
|
|
from agentkit.server.admin.user_service import get_user_service
|
|
from agentkit.server.auth.dependencies import require_authenticated
|
|
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH, init_auth_db
|
|
from agentkit.server.auth.permissions import Permission, has_permission
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Local helpers (mirror routes.auth — kept local to avoid import cycles)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _require_admin(
|
|
request: Request,
|
|
user: dict[str, Any] = Depends(require_authenticated),
|
|
) -> dict[str, Any]:
|
|
"""Require the ``USER_MANAGE`` permission (admin role)."""
|
|
if not has_permission(user, Permission.USER_MANAGE):
|
|
raise HTTPException(status_code=403, detail="Admin permission required")
|
|
return user
|
|
|
|
|
|
def _resolve_db_path(request: Request) -> Path:
|
|
"""Resolve the auth DB path from ``app.state`` (mirrors routes.auth)."""
|
|
path = getattr(request.app.state, "auth_db_path", None)
|
|
return Path(path) if path else DEFAULT_AUTH_DB_PATH
|
|
|
|
|
|
async def _ensure_db(request: Request) -> Path:
|
|
"""Ensure the auth DB schema is up to date; returns the resolved path.
|
|
|
|
Always calls ``init_auth_db`` (idempotent) to ensure all tables
|
|
exist and pending migrations are applied — even when the DB file
|
|
already exists. This fixes the issue where existing V2 DBs were
|
|
never migrated to V3/V4 because the ``if not db_path.exists()``
|
|
guard skipped ``init_auth_db`` (U5/R8).
|
|
"""
|
|
db_path = _resolve_db_path(request)
|
|
await init_auth_db(db_path)
|
|
return db_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pydantic request bodies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class DepartmentCreateRequest(BaseModel):
|
|
"""Body for ``POST /admin/departments``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
name: str
|
|
description: str = ""
|
|
|
|
|
|
class DepartmentUpdateRequest(BaseModel):
|
|
"""Body for ``PATCH /admin/departments/{id}``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
name: str | None = None
|
|
description: str | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Department CRUD endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@admin_router.post("/departments", status_code=201)
|
|
async def create_department(
|
|
payload: DepartmentCreateRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Create a new department.
|
|
|
|
Returns 201 with the department dict on success, 409 if a department
|
|
with the same name already exists.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
try:
|
|
return await svc.create_department(db_path, payload.name, payload.description)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
|
|
|
|
@admin_router.get("/departments")
|
|
async def list_departments(
|
|
request: Request,
|
|
include_inactive: bool = True,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[dict[str, Any]]:
|
|
"""List all departments."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
return await svc.list_departments(db_path, include_inactive=include_inactive)
|
|
|
|
|
|
@admin_router.get("/departments/{department_id}")
|
|
async def get_department(
|
|
department_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Return a single department by id. 404 if not found."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
dept = await svc.get_department(db_path, department_id)
|
|
if dept is None:
|
|
raise HTTPException(status_code=404, detail="Department not found")
|
|
return dept
|
|
|
|
|
|
@admin_router.patch("/departments/{department_id}")
|
|
async def update_department(
|
|
department_id: str,
|
|
payload: DepartmentUpdateRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Partially update a department.
|
|
|
|
Returns 200 with the updated dict, 404 if not found, 409 on duplicate
|
|
name.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
try:
|
|
return await svc.update_department(
|
|
db_path,
|
|
department_id,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
)
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if "not found" in msg:
|
|
raise HTTPException(status_code=404, detail=msg) from exc
|
|
raise HTTPException(status_code=409, detail=msg) from exc
|
|
|
|
|
|
@admin_router.post("/departments/{department_id}/disable")
|
|
async def disable_department(
|
|
department_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Set ``is_active=False`` on a department."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
try:
|
|
return await svc.set_department_active(db_path, department_id, is_active=False)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
@admin_router.post("/departments/{department_id}/enable")
|
|
async def enable_department(
|
|
department_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Set ``is_active=True`` on a department."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
try:
|
|
return await svc.set_department_active(db_path, department_id, is_active=True)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
@admin_router.delete("/departments/{department_id}")
|
|
async def delete_department(
|
|
department_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Delete a department.
|
|
|
|
Returns 200 ``{deleted: true}`` on success. Returns 400 if the
|
|
department still has users assigned (admin must remove them first).
|
|
Returns 404 if the department does not exist.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
try:
|
|
deleted = await svc.delete_department(db_path, department_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Department not found")
|
|
return {"deleted": True}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Department skill bindings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@admin_router.post(
|
|
"/departments/{department_id}/skills/{skill_name}",
|
|
status_code=201,
|
|
)
|
|
async def bind_skill(
|
|
department_id: str,
|
|
skill_name: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Bind a skill to a department. 409 on duplicate binding."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
try:
|
|
return await svc.bind_skill(db_path, department_id, skill_name)
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if "not found" in msg:
|
|
raise HTTPException(status_code=404, detail=msg) from exc
|
|
raise HTTPException(status_code=409, detail=msg) from exc
|
|
|
|
|
|
@admin_router.delete(
|
|
"/departments/{department_id}/skills/{skill_name}",
|
|
)
|
|
async def unbind_skill(
|
|
department_id: str,
|
|
skill_name: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Remove a skill binding. Idempotent — returns 200 even if no row existed."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
await svc.unbind_skill(db_path, department_id, skill_name)
|
|
return {"unbound": True}
|
|
|
|
|
|
@admin_router.get("/departments/{department_id}/skills")
|
|
async def list_department_skills(
|
|
department_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[str]:
|
|
"""List skill names bound to the department."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
return await svc.list_department_skills(db_path, department_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Department KB bindings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@admin_router.post(
|
|
"/departments/{department_id}/kb/{kb_source_id}",
|
|
status_code=201,
|
|
)
|
|
async def bind_kb(
|
|
department_id: str,
|
|
kb_source_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Bind a KB source to a department. 409 on duplicate binding."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
try:
|
|
return await svc.bind_kb(db_path, department_id, kb_source_id)
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if "not found" in msg:
|
|
raise HTTPException(status_code=404, detail=msg) from exc
|
|
raise HTTPException(status_code=409, detail=msg) from exc
|
|
|
|
|
|
@admin_router.delete(
|
|
"/departments/{department_id}/kb/{kb_source_id}",
|
|
)
|
|
async def unbind_kb(
|
|
department_id: str,
|
|
kb_source_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Remove a KB binding. Idempotent — returns 200 even if no row existed."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
await svc.unbind_kb(db_path, department_id, kb_source_id)
|
|
return {"unbound": True}
|
|
|
|
|
|
@admin_router.get("/departments/{department_id}/kb")
|
|
async def list_department_kbs(
|
|
department_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[str]:
|
|
"""List KB source ids bound to the department."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_department_service()
|
|
return await svc.list_department_kbs(db_path, department_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# User CRUD endpoints (U3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class UserCreateRequest(BaseModel):
|
|
"""Body for ``POST /admin/users``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
username: str
|
|
email: EmailStr
|
|
password: str = Field(min_length=8)
|
|
role: Literal["member", "operator", "admin"] = "member"
|
|
department_ids: list[str] | None = None
|
|
|
|
|
|
class UserUpdateRequest(BaseModel):
|
|
"""Body for ``PATCH /admin/users/{user_id}``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
role: Literal["member", "operator", "admin"] | None = None
|
|
is_active: bool | None = None
|
|
is_terminal_authorized: bool | None = None
|
|
is_server_terminal_authorized: bool | None = None
|
|
|
|
|
|
class ResetPasswordRequest(BaseModel):
|
|
"""Body for ``POST /admin/users/{user_id}/reset-password``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
new_password: str = Field(min_length=8)
|
|
|
|
|
|
@admin_router.post("/users", status_code=201)
|
|
async def create_user(
|
|
payload: UserCreateRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Create a new user.
|
|
|
|
Returns 201 with the user dict (including ``departments``) on
|
|
success. Returns 409 on duplicate username or email, or if any of
|
|
the ``department_ids`` is already assigned. Returns 404 if any of
|
|
the ``department_ids`` does not exist.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_user_service()
|
|
try:
|
|
return await svc.create_user(
|
|
db_path,
|
|
username=payload.username,
|
|
email=payload.email,
|
|
password=payload.password,
|
|
role=payload.role,
|
|
department_ids=payload.department_ids,
|
|
created_by=admin.get("user_id"),
|
|
)
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if "not found" in msg:
|
|
raise HTTPException(status_code=404, detail=msg) from exc
|
|
raise HTTPException(status_code=409, detail=msg) from exc
|
|
|
|
|
|
@admin_router.get("/users")
|
|
async def list_users(
|
|
request: Request,
|
|
department_id: str | None = None,
|
|
include_inactive: bool = True,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[dict[str, Any]]:
|
|
"""List users, optionally filtered by department."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_user_service()
|
|
return await svc.list_users(
|
|
db_path,
|
|
department_id=department_id,
|
|
include_inactive=include_inactive,
|
|
)
|
|
|
|
|
|
@admin_router.get("/users/{user_id}")
|
|
async def get_user(
|
|
user_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Return a single user by id (including departments). 404 if not found."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_user_service()
|
|
user = await svc.get_user(db_path, user_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
return user
|
|
|
|
|
|
@admin_router.patch("/users/{user_id}")
|
|
async def update_user(
|
|
user_id: str,
|
|
payload: UserUpdateRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Partially update a user.
|
|
|
|
Returns 200 with the updated dict, 404 if the user does not exist.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_user_service()
|
|
try:
|
|
return await svc.update_user(
|
|
db_path,
|
|
user_id,
|
|
role=payload.role,
|
|
is_active=payload.is_active,
|
|
is_terminal_authorized=payload.is_terminal_authorized,
|
|
is_server_terminal_authorized=payload.is_server_terminal_authorized,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
@admin_router.delete("/users/{user_id}")
|
|
async def delete_user(
|
|
user_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Soft-delete a user (set ``is_active=0``).
|
|
|
|
Returns 200 ``{deleted: true}`` on success, 404 if the user does
|
|
not exist or is already inactive.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_user_service()
|
|
deleted = await svc.delete_user(db_path, user_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
return {"deleted": True}
|
|
|
|
|
|
@admin_router.post("/users/{user_id}/reset-password")
|
|
async def reset_password(
|
|
user_id: str,
|
|
payload: ResetPasswordRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Reset a user's password and revoke all their sessions.
|
|
|
|
Returns 200 ``{reset: true}`` on success, 404 if the user does not
|
|
exist.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_user_service()
|
|
reset = await svc.reset_password(db_path, user_id, payload.new_password)
|
|
if not reset:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
return {"reset": True}
|
|
|
|
|
|
@admin_router.post(
|
|
"/users/{user_id}/departments/{department_id}",
|
|
status_code=201,
|
|
)
|
|
async def assign_department(
|
|
user_id: str,
|
|
department_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Assign a user to a department.
|
|
|
|
Returns 201 ``{assigned: true}`` on success. Returns 404 if the
|
|
department does not exist. Returns 409 if the user is already
|
|
assigned to this department.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_user_service()
|
|
try:
|
|
await svc.assign_department(db_path, user_id, department_id)
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if "not found" in msg:
|
|
raise HTTPException(status_code=404, detail=msg) from exc
|
|
raise HTTPException(status_code=409, detail=msg) from exc
|
|
return {"assigned": True}
|
|
|
|
|
|
@admin_router.delete("/users/{user_id}/departments/{department_id}")
|
|
async def remove_department(
|
|
user_id: str,
|
|
department_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Remove a user's department assignment.
|
|
|
|
Returns 200 ``{removed: true}`` on success, 404 if the assignment
|
|
does not exist.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_user_service()
|
|
removed = await svc.remove_department(db_path, user_id, department_id)
|
|
if not removed:
|
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
|
return {"removed": True}
|
|
|
|
|
|
@admin_router.get("/users/{user_id}/departments")
|
|
async def list_user_departments(
|
|
user_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[dict[str, Any]]:
|
|
"""List the departments a user is assigned to."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_user_service()
|
|
return await svc.list_user_departments(db_path, user_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LLM config endpoints (U5) — provider CRUD + fallback chains
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_llm_config_service(request: Request) -> LlmConfigService:
|
|
"""Resolve the :class:`LlmConfigService` from ``app.state`` or the
|
|
module singleton.
|
|
|
|
The service needs the YAML config path, which is read from
|
|
``app.state.server_config._config_path`` (same source as the
|
|
existing ``GET/PUT /settings/llm`` endpoints). Falls back to the
|
|
module singleton (which tests can pre-populate via
|
|
:func:`set_llm_config_service`).
|
|
"""
|
|
server_config = getattr(request.app.state, "server_config", None)
|
|
if server_config is not None and getattr(server_config, "_config_path", None):
|
|
try:
|
|
return get_llm_config_service(server_config._config_path)
|
|
except ValueError:
|
|
# Singleton was reset between calls — re-initialize with the
|
|
# resolved path.
|
|
from agentkit.server.admin.llm_config_service import set_llm_config_service
|
|
|
|
svc = LlmConfigService(Path(server_config._config_path))
|
|
set_llm_config_service(svc)
|
|
return svc
|
|
# Fall back to the existing singleton (tests inject it directly).
|
|
return get_llm_config_service()
|
|
|
|
|
|
class ProviderCreateRequest(BaseModel):
|
|
"""Body for ``POST /admin/llm/providers``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
name: str
|
|
type: str = "openai"
|
|
api_key: str
|
|
base_url: str = ""
|
|
models: dict[str, Any] | None = None
|
|
max_tokens: int = 4096
|
|
timeout: float = 60.0
|
|
|
|
|
|
class ProviderUpdateRequest(BaseModel):
|
|
"""Body for ``PATCH /admin/llm/providers/{name}``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
type: str | None = None
|
|
api_key: str | None = None
|
|
base_url: str | None = None
|
|
models: dict[str, Any] | None = None
|
|
max_tokens: int | None = None
|
|
timeout: float | None = None
|
|
|
|
|
|
class ApiKeySetRequest(BaseModel):
|
|
"""Body for ``POST /admin/llm/providers/{name}/api-key``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
api_key: str
|
|
|
|
|
|
class FallbackSetRequest(BaseModel):
|
|
"""Body for ``PUT /admin/llm/fallbacks/{model}``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
chain: list[str]
|
|
|
|
|
|
@admin_router.get("/llm/providers")
|
|
async def list_llm_providers(
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[dict[str, Any]]:
|
|
"""List all LLM providers (API keys masked)."""
|
|
svc = _get_llm_config_service(request)
|
|
return svc.list_providers()
|
|
|
|
|
|
@admin_router.post("/llm/providers", status_code=201)
|
|
async def create_llm_provider(
|
|
payload: ProviderCreateRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Create a new LLM provider.
|
|
|
|
Returns 201 with the provider dict (masked key) on success, 409 if
|
|
a provider with the same name already exists.
|
|
"""
|
|
svc = _get_llm_config_service(request)
|
|
try:
|
|
return svc.create_provider(
|
|
name=payload.name,
|
|
provider_type=payload.type,
|
|
api_key=payload.api_key,
|
|
base_url=payload.base_url,
|
|
models=payload.models,
|
|
max_tokens=payload.max_tokens,
|
|
timeout=payload.timeout,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
|
|
|
|
@admin_router.patch("/llm/providers/{name}")
|
|
async def update_llm_provider(
|
|
name: str,
|
|
payload: ProviderUpdateRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Partially update an LLM provider.
|
|
|
|
Returns 200 with the updated provider dict (masked key), 404 if the
|
|
provider does not exist.
|
|
"""
|
|
svc = _get_llm_config_service(request)
|
|
try:
|
|
return svc.update_provider(
|
|
name=name,
|
|
provider_type=payload.type,
|
|
api_key=payload.api_key,
|
|
base_url=payload.base_url,
|
|
models=payload.models,
|
|
max_tokens=payload.max_tokens,
|
|
timeout=payload.timeout,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
@admin_router.delete("/llm/providers/{name}")
|
|
async def delete_llm_provider(
|
|
name: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Delete an LLM provider.
|
|
|
|
Returns 200 ``{deleted: true}`` on success, 404 if the provider
|
|
does not exist, 400 if the provider is used in a fallback chain.
|
|
"""
|
|
svc = _get_llm_config_service(request)
|
|
try:
|
|
deleted = svc.delete_provider(name)
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if "not found" in msg:
|
|
raise HTTPException(status_code=404, detail=msg) from exc
|
|
raise HTTPException(status_code=400, detail=msg) from exc
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Provider not found")
|
|
return {"deleted": True}
|
|
|
|
|
|
@admin_router.post("/llm/providers/{name}/api-key")
|
|
async def set_llm_provider_api_key(
|
|
name: str,
|
|
payload: ApiKeySetRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Set the API key for a provider.
|
|
|
|
The plaintext key is written to ``.env`` (not the YAML file); the
|
|
YAML stores a ``${ENV_VAR}`` reference. Returns 200 with the
|
|
updated provider dict (masked key).
|
|
"""
|
|
svc = _get_llm_config_service(request)
|
|
return svc.set_api_key(name, payload.api_key)
|
|
|
|
|
|
@admin_router.get("/llm/fallbacks")
|
|
async def get_llm_fallbacks(
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, list[str]]:
|
|
"""Return all fallback chains (model → list of provider/model refs)."""
|
|
svc = _get_llm_config_service(request)
|
|
return svc.get_fallbacks()
|
|
|
|
|
|
@admin_router.put("/llm/fallbacks/{model}")
|
|
async def set_llm_fallback(
|
|
model: str,
|
|
payload: FallbackSetRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Set the fallback chain for a model."""
|
|
svc = _get_llm_config_service(request)
|
|
return svc.set_fallback(model, payload.chain)
|
|
|
|
|
|
@admin_router.delete("/llm/fallbacks/{model}")
|
|
async def delete_llm_fallback(
|
|
model: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Delete the fallback chain for a model.
|
|
|
|
Returns 200 ``{deleted: true}`` if a chain was deleted, 404 if no
|
|
chain existed for ``model``.
|
|
"""
|
|
svc = _get_llm_config_service(request)
|
|
deleted = svc.delete_fallback(model)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Fallback chain not found")
|
|
return {"deleted": True}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Department quota endpoints (U5) — per-department LLM quotas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class QuotaSetRequest(BaseModel):
|
|
"""Body for ``PUT /admin/departments/{id}/quotas``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
quota_type: str
|
|
limit_value: int | list[str]
|
|
period: str = "daily"
|
|
|
|
|
|
@admin_router.get("/departments/{department_id}/quotas")
|
|
async def list_department_quotas(
|
|
department_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[dict[str, Any]]:
|
|
"""List all quotas for a department."""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_quota_service()
|
|
return await svc.list_department_quotas(db_path, department_id)
|
|
|
|
|
|
@admin_router.put("/departments/{department_id}/quotas")
|
|
async def set_department_quota(
|
|
department_id: str,
|
|
payload: QuotaSetRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Set (upsert) a quota for a department.
|
|
|
|
Returns 200 with the upserted quota dict. Returns 400 if
|
|
``quota_type`` or ``period`` is invalid, or if ``limit_value``
|
|
doesn't match the quota type (int for token/cost limits, list for
|
|
model_whitelist).
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_quota_service()
|
|
try:
|
|
return await svc.set_quota(
|
|
db_path,
|
|
department_id,
|
|
payload.quota_type,
|
|
payload.limit_value,
|
|
payload.period,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@admin_router.delete("/departments/{department_id}/quotas")
|
|
async def delete_department_quota(
|
|
department_id: str,
|
|
request: Request,
|
|
quota_type: str,
|
|
period: str = "daily",
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Delete a quota for a department.
|
|
|
|
Query params: ``quota_type`` (required), ``period`` (default
|
|
``daily``). Returns 200 ``{deleted: true}`` if a quota was deleted,
|
|
404 if no quota matched, 400 if ``quota_type`` or ``period`` is
|
|
invalid.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_quota_service()
|
|
try:
|
|
deleted = await svc.delete_quota(db_path, department_id, quota_type, period)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Quota not found")
|
|
return {"deleted": True}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Skill management endpoints (U6) — enable/disable + import/reload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_skills_dir(request: Request) -> str:
|
|
"""Resolve the skills directory from ``app.state.server_config``.
|
|
|
|
Mirrors the logic in :func:`agentkit.server.routes.skills._get_skills_dir`
|
|
so admin endpoints install/reload skills into the same directory as
|
|
the existing ``/skills/install`` route.
|
|
"""
|
|
server_config = getattr(request.app.state, "server_config", None)
|
|
if server_config and getattr(server_config, "skill_paths", None):
|
|
first_path = Path(server_config.skill_paths[0])
|
|
if first_path.is_dir():
|
|
return str(first_path)
|
|
# Fallback: configs/skills/ relative to cwd (matches routes.skills).
|
|
import os
|
|
|
|
return os.path.join(os.getcwd(), "configs", "skills")
|
|
|
|
|
|
def _get_skill_registry(request: Request) -> Any:
|
|
"""Return the live :class:`SkillRegistry` from ``app.state``.
|
|
|
|
Raises HTTPException(500) if the registry is missing — admin skill
|
|
endpoints cannot function without it.
|
|
"""
|
|
registry = getattr(request.app.state, "skill_registry", None)
|
|
if registry is None:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Skill registry not initialized on app.state",
|
|
)
|
|
return registry
|
|
|
|
|
|
class SkillImportRequest(BaseModel):
|
|
"""Body for ``POST /admin/skills/import``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
yaml_content: str
|
|
|
|
|
|
class SkillUpdateRequest(BaseModel):
|
|
"""Body for ``PATCH /admin/skills/{name}``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
config: dict[str, Any]
|
|
|
|
|
|
@admin_router.post("/skills/{name}/enable")
|
|
async def enable_skill(
|
|
name: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Enable a previously-disabled skill.
|
|
|
|
Returns 200 ``{enabled: true}`` on success. Idempotent — returns
|
|
200 even if the skill was not disabled.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_skill_service()
|
|
try:
|
|
enabled = await svc.enable_skill(db_path, name)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
return {"enabled": enabled, "skill_name": name}
|
|
|
|
|
|
@admin_router.post("/skills/{name}/disable")
|
|
async def disable_skill(
|
|
name: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Disable a skill (hides it from ``GET /skills``).
|
|
|
|
Returns 200 with the disabled skill state dict.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
svc = get_skill_service()
|
|
try:
|
|
return await svc.disable_skill(db_path, name, disabled_by=admin.get("user_id"))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@admin_router.patch("/skills/{name}")
|
|
async def update_skill(
|
|
name: str,
|
|
payload: SkillUpdateRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Update a skill's YAML config in-place and reload it.
|
|
|
|
Returns 200 with the updated skill info. Returns 404 if the skill
|
|
YAML file does not exist, 400 if the patched config is invalid.
|
|
"""
|
|
skills_dir = _get_skills_dir(request)
|
|
registry = _get_skill_registry(request)
|
|
svc = get_skill_service()
|
|
try:
|
|
return await svc.update_skill_config(
|
|
name,
|
|
payload.config,
|
|
skills_dir,
|
|
registry,
|
|
)
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if "not found" in msg:
|
|
raise HTTPException(status_code=404, detail=msg) from exc
|
|
raise HTTPException(status_code=400, detail=msg) from exc
|
|
|
|
|
|
@admin_router.post("/skills/import", status_code=201)
|
|
async def import_skill(
|
|
payload: SkillImportRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Import a skill from YAML content.
|
|
|
|
Writes the YAML to ``{skills_dir}/{name}.yaml`` and registers it
|
|
in the live :class:`SkillRegistry`. Returns 200 with the imported
|
|
skill info. Returns 400 if the YAML is invalid or the skill name
|
|
is invalid.
|
|
"""
|
|
skills_dir = _get_skills_dir(request)
|
|
registry = _get_skill_registry(request)
|
|
svc = get_skill_service()
|
|
try:
|
|
return await svc.import_skill(
|
|
payload.yaml_content,
|
|
skills_dir,
|
|
skill_registry=registry,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@admin_router.post("/skills/{name}/reload")
|
|
async def reload_skill(
|
|
name: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Reload a skill from its YAML file.
|
|
|
|
Returns 200 with the reloaded skill info. Returns 404 if the YAML
|
|
file does not exist, 400 if the skill name is invalid.
|
|
"""
|
|
skills_dir = _get_skills_dir(request)
|
|
registry = _get_skill_registry(request)
|
|
svc = get_skill_service()
|
|
try:
|
|
return await svc.reload_skill(name, registry, skills_dir)
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if "not found" in msg:
|
|
raise HTTPException(status_code=404, detail=msg) from exc
|
|
raise HTTPException(status_code=400, detail=msg) from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# KB management endpoints (U6) — documents + sources
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class KbDocumentUploadRequest(BaseModel):
|
|
"""Body for ``POST /admin/kb/documents``."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
filename: str
|
|
content: str
|
|
source_id: str = ""
|
|
department_id: str | None = None
|
|
|
|
|
|
@admin_router.get("/kb/documents")
|
|
async def list_kb_documents(
|
|
request: Request,
|
|
source_id: str | None = None,
|
|
department_id: str | None = None,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""List KB documents (admin sees all).
|
|
|
|
Query params: ``source_id`` (optional filter), ``department_id``
|
|
(optional filter — when set, only documents bound to that
|
|
department or global documents are returned).
|
|
"""
|
|
svc = get_kb_service()
|
|
# Admin sees everything. If department_id is provided as a query
|
|
# filter, narrow to that department + global docs.
|
|
if department_id is not None:
|
|
dept_ids = [department_id]
|
|
else:
|
|
dept_ids = None # admin: no filtering
|
|
documents = svc.list_documents(department_ids=dept_ids, source_id=source_id)
|
|
return {"documents": documents}
|
|
|
|
|
|
@admin_router.post("/kb/documents", status_code=201)
|
|
async def upload_kb_document(
|
|
payload: KbDocumentUploadRequest,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Upload a KB document with optional department binding.
|
|
|
|
Returns 201 with the uploaded document dict.
|
|
"""
|
|
svc = get_kb_service()
|
|
return svc.upload_document(
|
|
filename=payload.filename,
|
|
content=payload.content.encode("utf-8"),
|
|
source_id=payload.source_id,
|
|
department_id=payload.department_id,
|
|
)
|
|
|
|
|
|
@admin_router.delete("/kb/documents/{document_id}")
|
|
async def delete_kb_document(
|
|
document_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Delete a KB document by id.
|
|
|
|
Returns 200 ``{deleted: true}`` on success, 404 if not found.
|
|
"""
|
|
svc = get_kb_service()
|
|
deleted = svc.delete_document(document_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
return {"deleted": True}
|
|
|
|
|
|
@admin_router.post("/kb/sources/{source_id}/sync")
|
|
async def sync_kb_source(
|
|
source_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Trigger a sync for a KB source.
|
|
|
|
Returns 200 with the sync status. Returns 404 if the source does
|
|
not exist.
|
|
"""
|
|
svc = get_kb_service()
|
|
try:
|
|
return svc.sync_source(source_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
@admin_router.post("/kb/sources/{source_id}/rebuild")
|
|
async def rebuild_kb_source(
|
|
source_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Rebuild the index for a KB source.
|
|
|
|
Returns 200 with the rebuild status. Returns 404 if the source
|
|
does not exist.
|
|
"""
|
|
svc = get_kb_service()
|
|
try:
|
|
return svc.rebuild_index(source_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Usage dashboard endpoints (U7) — usage aggregations + export
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_usage_store(request: Request) -> Any:
|
|
"""Return the live :class:`UsageStore` from ``app.state.llm_gateway``.
|
|
|
|
Raises HTTPException(500) if the gateway or usage store is missing —
|
|
usage endpoints cannot function without it.
|
|
"""
|
|
gateway = getattr(request.app.state, "llm_gateway", None)
|
|
if gateway is None:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="LLM gateway not initialized on app.state",
|
|
)
|
|
try:
|
|
return gateway._usage_tracker.store # type: ignore[attr-defined]
|
|
except AttributeError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Usage store not available on LLM gateway",
|
|
) from exc
|
|
|
|
|
|
def _parse_iso(value: str | None) -> datetime | None:
|
|
"""Parse an ISO 8601 string into a timezone-aware datetime."""
|
|
if value is None or value == "":
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(value)
|
|
except ValueError:
|
|
# Try the trailing-Z form.
|
|
if value.endswith("Z"):
|
|
try:
|
|
from datetime import timezone
|
|
|
|
dt = datetime.fromisoformat(value[:-1]).replace(tzinfo=timezone.utc)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=400, detail=f"Invalid ISO 8601 timestamp: {value!r}"
|
|
)
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Invalid ISO 8601 timestamp: {value!r}")
|
|
return dt
|
|
|
|
|
|
@admin_router.get("/usage/summary")
|
|
async def get_usage_summary(
|
|
request: Request,
|
|
department_id: str | None = None,
|
|
user_id: str | None = None,
|
|
start: str | None = None,
|
|
end: str | None = None,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Return an aggregated usage summary.
|
|
|
|
Query params: ``department_id``, ``user_id``, ``start``, ``end``
|
|
(ISO 8601). Admins see all data; non-admin callers are blocked by
|
|
``_require_admin`` (403).
|
|
"""
|
|
store = _get_usage_store(request)
|
|
svc = get_usage_service()
|
|
return await svc.get_usage_summary(
|
|
store,
|
|
department_id=department_id,
|
|
user_id=user_id,
|
|
start_time=_parse_iso(start),
|
|
end_time=_parse_iso(end),
|
|
)
|
|
|
|
|
|
@admin_router.get("/usage/timeseries")
|
|
async def get_usage_timeseries(
|
|
request: Request,
|
|
department_id: str | None = None,
|
|
user_id: str | None = None,
|
|
start: str | None = None,
|
|
end: str | None = None,
|
|
interval: str = "day",
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[dict[str, Any]]:
|
|
"""Return a time-bucketed usage series.
|
|
|
|
Query params: ``department_id``, ``user_id``, ``start``, ``end``
|
|
(ISO 8601), ``interval`` (``day`` or ``hour``, default ``day``).
|
|
"""
|
|
if interval not in ("day", "hour"):
|
|
raise HTTPException(status_code=400, detail="interval must be 'day' or 'hour'")
|
|
store = _get_usage_store(request)
|
|
svc = get_usage_service()
|
|
return await svc.get_usage_timeseries(
|
|
store,
|
|
department_id=department_id,
|
|
user_id=user_id,
|
|
start_time=_parse_iso(start),
|
|
end_time=_parse_iso(end),
|
|
interval=interval,
|
|
)
|
|
|
|
|
|
@admin_router.get("/usage/by-model")
|
|
async def get_usage_by_model(
|
|
request: Request,
|
|
department_id: str | None = None,
|
|
user_id: str | None = None,
|
|
start: str | None = None,
|
|
end: str | None = None,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[dict[str, Any]]:
|
|
"""Return a per-model usage breakdown."""
|
|
store = _get_usage_store(request)
|
|
svc = get_usage_service()
|
|
return await svc.get_usage_by_model(
|
|
store,
|
|
department_id=department_id,
|
|
user_id=user_id,
|
|
start_time=_parse_iso(start),
|
|
end_time=_parse_iso(end),
|
|
)
|
|
|
|
|
|
@admin_router.get("/usage/top-users")
|
|
async def get_top_users(
|
|
request: Request,
|
|
department_id: str | None = None,
|
|
user_id: str | None = None,
|
|
start: str | None = None,
|
|
end: str | None = None,
|
|
limit: int = 10,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[dict[str, Any]]:
|
|
"""Return the top-N users by total token usage.
|
|
|
|
Query params: ``department_id``, ``user_id``, ``start``, ``end``,
|
|
``limit`` (default 10, max 100).
|
|
"""
|
|
if limit < 1:
|
|
limit = 1
|
|
if limit > 100:
|
|
limit = 100
|
|
store = _get_usage_store(request)
|
|
svc = get_usage_service()
|
|
return await svc.get_top_users(
|
|
store,
|
|
department_id=department_id,
|
|
user_id=user_id,
|
|
start_time=_parse_iso(start),
|
|
end_time=_parse_iso(end),
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
@admin_router.get("/usage/export")
|
|
async def export_usage(
|
|
request: Request,
|
|
department_id: str | None = None,
|
|
user_id: str | None = None,
|
|
start: str | None = None,
|
|
end: str | None = None,
|
|
format: str = "csv",
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> Any:
|
|
"""Export raw usage records as CSV or JSON.
|
|
|
|
Query params: ``department_id``, ``user_id``, ``start``, ``end``,
|
|
``format`` (``csv`` or ``json``, default ``csv``).
|
|
|
|
Returns ``text/csv`` for CSV or ``application/json`` for JSON.
|
|
"""
|
|
if format not in ("csv", "json"):
|
|
raise HTTPException(status_code=400, detail="format must be 'csv' or 'json'")
|
|
store = _get_usage_store(request)
|
|
svc = get_usage_service()
|
|
body = await svc.export_usage(
|
|
store,
|
|
department_id=department_id,
|
|
user_id=user_id,
|
|
start_time=_parse_iso(start),
|
|
end_time=_parse_iso(end),
|
|
format=format,
|
|
)
|
|
if format == "csv":
|
|
return PlainTextResponse(content=body, media_type="text/csv")
|
|
return PlainTextResponse(content=body, media_type="application/json")
|