1954 lines
67 KiB
Python
1954 lines
67 KiB
Python
"""Admin operations routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
from typing import Annotated, Literal
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ..auth_types import AccountStatus, RoleName
|
|
from ..auth_sessions import (
|
|
ManagedUserPatch,
|
|
ManagedUserUpsertInput,
|
|
active_session_count,
|
|
deactivate_managed_user,
|
|
get_managed_user,
|
|
has_admin_access,
|
|
is_super_admin_email,
|
|
list_managed_users,
|
|
update_managed_user,
|
|
upsert_managed_user,
|
|
)
|
|
from ..config import settings
|
|
from ..contracts.engine_gateway import ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL
|
|
from ..db import acquire, get_pool, healthcheck
|
|
from ..deps import Principal, require_admin_access
|
|
from ..engine_client import engine_client
|
|
from ..runtime_policy import require_runtime_fallback_allowed
|
|
from ..services.voice import voice_service
|
|
from ..services import evaluator, notifications, rag
|
|
from ..store import store
|
|
|
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
|
|
AdminPrincipal = Annotated[Principal, Depends(require_admin_access())]
|
|
HealthStatus = Literal["ok", "degraded", "down"]
|
|
UsageBudgetStatus = Literal["disabled", "ok", "warn", "exceeded"]
|
|
TicketCategory = Literal[
|
|
"account_access",
|
|
"session_review",
|
|
"voice_browser",
|
|
"content_scenario",
|
|
"safety",
|
|
"other",
|
|
]
|
|
TicketPriority = Literal["low", "normal", "high", "urgent"]
|
|
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
|
|
NotificationDeliveryStatus = Literal["queued", "sending", "sent", "failed", "skipped"]
|
|
|
|
|
|
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 AdminUsageBreakdown(BaseModel):
|
|
provider: str
|
|
model: str
|
|
turns: int
|
|
tokens_in: int
|
|
tokens_out: int
|
|
cost_usd: float
|
|
|
|
|
|
class AdminUsageDailyCost(BaseModel):
|
|
day: str
|
|
turns: int
|
|
tokens_in: int
|
|
tokens_out: int
|
|
cost_usd: float
|
|
|
|
|
|
class AdminUsageBudget(BaseModel):
|
|
limit_usd: float
|
|
used_ratio: float
|
|
remaining_usd: float | None
|
|
status: UsageBudgetStatus
|
|
|
|
|
|
class AdminUsageEvaluatorCache(BaseModel):
|
|
enabled: bool
|
|
entries: int
|
|
hits: int
|
|
misses: int
|
|
stores: int
|
|
evictions: int
|
|
requests: int
|
|
hit_rate: float
|
|
|
|
|
|
class AdminUsageResponse(BaseModel):
|
|
source: Literal["database", "server_session_registry"]
|
|
durable: bool
|
|
window_days: int
|
|
generated_at: float
|
|
total_turns: int
|
|
metered_turns: int
|
|
tokens_in: int
|
|
tokens_out: int
|
|
cost_usd: float
|
|
budget: AdminUsageBudget
|
|
evaluator_cache: AdminUsageEvaluatorCache
|
|
by_provider: list[AdminUsageBreakdown]
|
|
daily_cost: list[AdminUsageDailyCost] = Field(default_factory=list)
|
|
|
|
|
|
class AdminHealthEvent(BaseModel):
|
|
event_id: int
|
|
observed_at: float
|
|
overall_status: HealthStatus
|
|
service_key: str
|
|
service_name: str
|
|
service_status: HealthStatus
|
|
detail: str
|
|
metric: str
|
|
load: float
|
|
|
|
|
|
class AdminUptimeServiceSummary(BaseModel):
|
|
service_key: str
|
|
service_name: str
|
|
samples: int
|
|
ok_samples: int
|
|
degraded_samples: int
|
|
down_samples: int
|
|
latest_status: HealthStatus
|
|
latest_observed_at: float | None = None
|
|
|
|
|
|
class AdminUptimeResponse(BaseModel):
|
|
source: Literal["database", "unavailable"]
|
|
durable: bool
|
|
window_hours: int
|
|
generated_at: float
|
|
sample_count: int
|
|
ok_ratio: float
|
|
degraded_events: int
|
|
down_events: int
|
|
last_down_at: float | None = None
|
|
services: list[AdminUptimeServiceSummary]
|
|
events: list[AdminHealthEvent]
|
|
|
|
|
|
class AdminTicketReporter(BaseModel):
|
|
user_id: str | None = None
|
|
email: str
|
|
display_name: str
|
|
role: str
|
|
|
|
|
|
class AdminSupportTicketResponse(BaseModel):
|
|
ticket_id: str
|
|
reporter: AdminTicketReporter
|
|
category: TicketCategory
|
|
priority: TicketPriority
|
|
status: TicketStatus
|
|
subject: str
|
|
body: str
|
|
source_path: str
|
|
fingerprint: str
|
|
parent_ticket_id: str | None = None
|
|
duplicate_count: int = 0
|
|
duplicate_parent_candidate_id: str | None = None
|
|
child_ticket_count: int = 0
|
|
assigned_group: str
|
|
resolution_note: str
|
|
created_at: float
|
|
updated_at: float
|
|
resolved_at: float | None = None
|
|
event_count: int = 0
|
|
last_event_at: float | None = None
|
|
|
|
|
|
class AdminTicketSummary(BaseModel):
|
|
total: int
|
|
open_count: int
|
|
high_priority_count: int
|
|
stale_count: int
|
|
by_status: dict[str, int]
|
|
by_category: dict[str, int]
|
|
by_priority: dict[str, int]
|
|
|
|
|
|
class AdminTicketsResponse(BaseModel):
|
|
source: Literal["database", "unavailable"]
|
|
durable: bool
|
|
generated_at: float
|
|
tickets: list[AdminSupportTicketResponse]
|
|
summary: AdminTicketSummary
|
|
|
|
|
|
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
|
|
|
|
|
|
class AdminTicketPatch(BaseModel):
|
|
status: TicketStatus | None = None
|
|
priority: TicketPriority | None = None
|
|
assigned_group: str | None = Field(default=None, max_length=120)
|
|
resolution_note: str | None = Field(default=None, max_length=2000)
|
|
parent_ticket_id: str | None = Field(default=None, max_length=36)
|
|
|
|
|
|
class AdminUserResponse(BaseModel):
|
|
user_id: str
|
|
email: str
|
|
display_name: str
|
|
role: RoleName
|
|
admin_access: bool
|
|
super_admin: bool = False
|
|
account_status: AccountStatus
|
|
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
|
|
admin_access: bool | None = None
|
|
account_status: AccountStatus | None = None
|
|
affiliation: str | None = Field(default=None, max_length=120)
|
|
cohort_ids: list[str] | None = None
|
|
|
|
|
|
class AdminNotificationDeliveryResponse(BaseModel):
|
|
id: str
|
|
event_id: str
|
|
kind: str
|
|
recipient_email: str
|
|
recipient_name: str
|
|
recipient_role: str
|
|
subject: str
|
|
status: NotificationDeliveryStatus
|
|
attempts: int
|
|
last_error: str | None = None
|
|
provider_message_id: str | None = None
|
|
created_at: float | None = None
|
|
updated_at: float | None = None
|
|
sent_at: float | None = None
|
|
|
|
|
|
class AdminNotificationsResponse(BaseModel):
|
|
provider: str
|
|
smtp_configured: bool
|
|
queued: int
|
|
failed: int
|
|
sent: int
|
|
skipped: int
|
|
deliveries: list[AdminNotificationDeliveryResponse]
|
|
|
|
|
|
class AdminNotificationProcessResponse(BaseModel):
|
|
processed: int
|
|
sent: int
|
|
failed: int
|
|
skipped: int
|
|
|
|
|
|
class AdminNotificationTestResponse(AdminNotificationProcessResponse):
|
|
provider: str
|
|
smtp_configured: bool
|
|
recipients: int
|
|
|
|
|
|
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)
|
|
|
|
|
|
def _decimal_to_float(value: object) -> float:
|
|
if value is None:
|
|
return 0.0
|
|
if isinstance(value, Decimal):
|
|
return float(value)
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
def _safe_usage_int(value: object) -> int:
|
|
try:
|
|
return int(value or 0)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _usage_budget(cost_usd: float) -> AdminUsageBudget:
|
|
limit = max(0.0, float(settings.admin_usage_budget_usd or 0.0))
|
|
if limit <= 0:
|
|
return AdminUsageBudget(
|
|
limit_usd=0.0,
|
|
used_ratio=0.0,
|
|
remaining_usd=None,
|
|
status="disabled",
|
|
)
|
|
used_ratio = max(0.0, cost_usd / limit)
|
|
status_value: UsageBudgetStatus = "ok"
|
|
if used_ratio >= 1.0:
|
|
status_value = "exceeded"
|
|
elif used_ratio >= 0.8:
|
|
status_value = "warn"
|
|
return AdminUsageBudget(
|
|
limit_usd=round(limit, 6),
|
|
used_ratio=round(used_ratio, 4),
|
|
remaining_usd=round(max(0.0, limit - cost_usd), 6),
|
|
status=status_value,
|
|
)
|
|
|
|
|
|
def _usage_evaluator_cache() -> AdminUsageEvaluatorCache:
|
|
stats = evaluator.evaluator_semantic_cache_stats()
|
|
hits = _safe_usage_int(stats.get("hits", 0))
|
|
misses = _safe_usage_int(stats.get("misses", 0))
|
|
requests = hits + misses
|
|
hit_rate = round(hits / requests, 4) if requests else 0.0
|
|
enabled = (
|
|
bool(settings.evaluator_semantic_cache_enabled)
|
|
and settings.evaluator_semantic_cache_ttl_seconds > 0
|
|
and settings.evaluator_semantic_cache_max_entries > 0
|
|
)
|
|
return AdminUsageEvaluatorCache(
|
|
enabled=enabled,
|
|
entries=_safe_usage_int(stats.get("entries", 0)),
|
|
hits=hits,
|
|
misses=misses,
|
|
stores=_safe_usage_int(stats.get("stores", 0)),
|
|
evictions=_safe_usage_int(stats.get("evictions", 0)),
|
|
requests=requests,
|
|
hit_rate=hit_rate,
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
async def _usage_from_database(window_days: int) -> AdminUsageResponse:
|
|
async with acquire(role="admin") as conn:
|
|
total_row = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE speaker = 'client') AS total_turns,
|
|
COUNT(*) FILTER (
|
|
WHERE speaker = 'client'
|
|
AND (
|
|
llm_provider IS NOT NULL OR model IS NOT NULL
|
|
OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
|
|
OR cost_usd IS NOT NULL
|
|
)
|
|
) AS metered_turns,
|
|
COALESCE(SUM(tokens_in) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_in,
|
|
COALESCE(SUM(tokens_out) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_out,
|
|
COALESCE(SUM(cost_usd) FILTER (WHERE speaker = 'client'), 0)::numeric AS cost_usd
|
|
FROM app.turns
|
|
WHERE created_at >= now() - ($1::int * interval '1 day')
|
|
""",
|
|
window_days,
|
|
)
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
COALESCE(llm_provider, 'unknown') AS provider,
|
|
COALESCE(model, 'unknown') AS model,
|
|
COUNT(*) AS turns,
|
|
COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in,
|
|
COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out,
|
|
COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
|
|
FROM app.turns
|
|
WHERE created_at >= now() - ($1::int * interval '1 day')
|
|
AND speaker = 'client'
|
|
AND (
|
|
llm_provider IS NOT NULL OR model IS NOT NULL
|
|
OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
|
|
OR cost_usd IS NOT NULL
|
|
)
|
|
GROUP BY 1, 2
|
|
ORDER BY
|
|
cost_usd DESC,
|
|
COALESCE(SUM(tokens_in), 0) + COALESCE(SUM(tokens_out), 0) DESC,
|
|
turns DESC
|
|
LIMIT 12
|
|
""",
|
|
window_days,
|
|
)
|
|
daily_rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
to_char(date_trunc('day', created_at), 'YYYY-MM-DD') AS day,
|
|
COUNT(*) AS turns,
|
|
COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in,
|
|
COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out,
|
|
COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
|
|
FROM app.turns
|
|
WHERE created_at >= now() - ($1::int * interval '1 day')
|
|
AND speaker = 'client'
|
|
AND (
|
|
llm_provider IS NOT NULL OR model IS NOT NULL
|
|
OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
|
|
OR cost_usd IS NOT NULL
|
|
)
|
|
GROUP BY 1
|
|
ORDER BY 1
|
|
""",
|
|
window_days,
|
|
)
|
|
|
|
total_cost = round(_decimal_to_float(total_row["cost_usd"] if total_row else 0), 6)
|
|
return AdminUsageResponse(
|
|
source="database",
|
|
durable=True,
|
|
window_days=window_days,
|
|
generated_at=time.time(),
|
|
total_turns=_safe_usage_int(total_row["total_turns"] if total_row else 0),
|
|
metered_turns=_safe_usage_int(total_row["metered_turns"] if total_row else 0),
|
|
tokens_in=_safe_usage_int(total_row["tokens_in"] if total_row else 0),
|
|
tokens_out=_safe_usage_int(total_row["tokens_out"] if total_row else 0),
|
|
cost_usd=total_cost,
|
|
budget=_usage_budget(total_cost),
|
|
evaluator_cache=_usage_evaluator_cache(),
|
|
by_provider=[
|
|
AdminUsageBreakdown(
|
|
provider=str(row["provider"] or "unknown"),
|
|
model=str(row["model"] or "unknown"),
|
|
turns=_safe_usage_int(row["turns"]),
|
|
tokens_in=_safe_usage_int(row["tokens_in"]),
|
|
tokens_out=_safe_usage_int(row["tokens_out"]),
|
|
cost_usd=round(_decimal_to_float(row["cost_usd"]), 6),
|
|
)
|
|
for row in rows
|
|
],
|
|
daily_cost=[
|
|
AdminUsageDailyCost(
|
|
day=str(row["day"]),
|
|
turns=_safe_usage_int(row["turns"]),
|
|
tokens_in=_safe_usage_int(row["tokens_in"]),
|
|
tokens_out=_safe_usage_int(row["tokens_out"]),
|
|
cost_usd=round(_decimal_to_float(row["cost_usd"]), 6),
|
|
)
|
|
for row in daily_rows
|
|
],
|
|
)
|
|
|
|
|
|
async def _record_health_events(
|
|
*,
|
|
principal: Principal | None,
|
|
overall_status: HealthStatus,
|
|
environment: str,
|
|
engine_mode: str,
|
|
services: list[AdminServiceHealth],
|
|
) -> int:
|
|
if not services:
|
|
return 0
|
|
captured_by = principal.user_id if principal is not None else None
|
|
try:
|
|
async with acquire(role="admin", user_id=captured_by) as conn:
|
|
await conn.executemany(
|
|
"""
|
|
INSERT INTO app.admin_health_event (
|
|
overall_status,
|
|
environment,
|
|
engine_mode,
|
|
service_key,
|
|
service_name,
|
|
service_status,
|
|
detail,
|
|
metric,
|
|
load,
|
|
captured_by
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::uuid)
|
|
""",
|
|
[
|
|
(
|
|
overall_status,
|
|
environment,
|
|
engine_mode,
|
|
service.key,
|
|
service.name,
|
|
service.status,
|
|
service.detail,
|
|
service.metric,
|
|
service.load,
|
|
captured_by,
|
|
)
|
|
for service in services
|
|
],
|
|
)
|
|
return len(services)
|
|
except Exception:
|
|
# 헬스 화면 자체가 장애 확인 경로라, 이력 적재 실패가 응답을 막으면 안 된다.
|
|
return 0
|
|
|
|
|
|
async def _uptime_from_database(window_hours: int) -> AdminUptimeResponse:
|
|
async with acquire(role="admin") as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
id,
|
|
observed_at,
|
|
overall_status,
|
|
service_key,
|
|
service_name,
|
|
service_status,
|
|
detail,
|
|
metric,
|
|
load
|
|
FROM app.admin_health_event
|
|
WHERE observed_at >= now() - ($1::int * interval '1 hour')
|
|
ORDER BY observed_at DESC, id DESC
|
|
LIMIT 240
|
|
""",
|
|
window_hours,
|
|
)
|
|
rollup_rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
r.service_key,
|
|
r.service_name,
|
|
r.sample_count,
|
|
r.ok_samples,
|
|
r.degraded_samples,
|
|
r.down_samples,
|
|
r.latest_status,
|
|
r.last_observed_at,
|
|
r.last_down_at
|
|
FROM app.admin_health_daily_rollup AS r
|
|
WHERE r.rollup_date >= (now() - ($1::int * interval '1 hour'))::date
|
|
AND r.rollup_date < current_date
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM app.admin_health_event AS e
|
|
WHERE e.observed_at::date = r.rollup_date
|
|
AND e.environment = r.environment
|
|
AND e.engine_mode = r.engine_mode
|
|
AND e.service_key = r.service_key
|
|
)
|
|
ORDER BY r.last_observed_at DESC, r.service_key
|
|
LIMIT 1000
|
|
""",
|
|
window_hours,
|
|
)
|
|
|
|
events = [_health_event_from_row(row) for row in rows]
|
|
service_buckets: dict[str, AdminUptimeServiceSummary] = {}
|
|
|
|
def ensure_bucket(
|
|
*,
|
|
service_key: str,
|
|
service_name: str,
|
|
latest_status: HealthStatus,
|
|
latest_observed_at: float | None,
|
|
) -> AdminUptimeServiceSummary:
|
|
current = service_buckets.get(service_key)
|
|
if current is None:
|
|
current = AdminUptimeServiceSummary(
|
|
service_key=service_key,
|
|
service_name=service_name,
|
|
samples=0,
|
|
ok_samples=0,
|
|
degraded_samples=0,
|
|
down_samples=0,
|
|
latest_status=latest_status,
|
|
latest_observed_at=latest_observed_at,
|
|
)
|
|
service_buckets[service_key] = current
|
|
return current
|
|
if latest_observed_at is not None and (
|
|
current.latest_observed_at is None
|
|
or latest_observed_at > current.latest_observed_at
|
|
):
|
|
current.latest_status = latest_status
|
|
current.latest_observed_at = latest_observed_at
|
|
return current
|
|
|
|
for event in events:
|
|
current = ensure_bucket(
|
|
service_key=event.service_key,
|
|
service_name=event.service_name,
|
|
latest_status=event.service_status,
|
|
latest_observed_at=event.observed_at,
|
|
)
|
|
current.samples += 1
|
|
if event.service_status == "ok":
|
|
current.ok_samples += 1
|
|
elif event.service_status == "degraded":
|
|
current.degraded_samples += 1
|
|
else:
|
|
current.down_samples += 1
|
|
|
|
rollup_last_down: list[float] = []
|
|
for row in rollup_rows:
|
|
latest_observed_at = _row_ts(row["last_observed_at"])
|
|
current = ensure_bucket(
|
|
service_key=str(row["service_key"]),
|
|
service_name=str(row["service_name"]),
|
|
latest_status=row["latest_status"],
|
|
latest_observed_at=latest_observed_at,
|
|
)
|
|
current.samples += int(row["sample_count"] or 0)
|
|
current.ok_samples += int(row["ok_samples"] or 0)
|
|
current.degraded_samples += int(row["degraded_samples"] or 0)
|
|
current.down_samples += int(row["down_samples"] or 0)
|
|
last_down = _row_ts(row["last_down_at"])
|
|
if last_down is not None:
|
|
rollup_last_down.append(last_down)
|
|
|
|
ok_samples = sum(1 for event in events if event.service_status == "ok") + sum(
|
|
int(row["ok_samples"] or 0) for row in rollup_rows
|
|
)
|
|
sample_count = len(events) + sum(int(row["sample_count"] or 0) for row in rollup_rows)
|
|
down_times = [event.observed_at for event in events if event.service_status == "down"]
|
|
down_times.extend(rollup_last_down)
|
|
return AdminUptimeResponse(
|
|
source="database",
|
|
durable=True,
|
|
window_hours=window_hours,
|
|
generated_at=time.time(),
|
|
sample_count=sample_count,
|
|
ok_ratio=round(ok_samples / sample_count, 4) if sample_count else 0.0,
|
|
degraded_events=sum(1 for event in events if event.service_status == "degraded")
|
|
+ sum(int(row["degraded_samples"] or 0) for row in rollup_rows),
|
|
down_events=sum(1 for event in events if event.service_status == "down")
|
|
+ sum(int(row["down_samples"] or 0) for row in rollup_rows),
|
|
last_down_at=max(down_times) if down_times else None,
|
|
services=sorted(
|
|
service_buckets.values(),
|
|
key=lambda item: (item.latest_status != "down", item.latest_status != "degraded", item.service_key),
|
|
),
|
|
events=events,
|
|
)
|
|
|
|
|
|
async def _tickets_from_database(
|
|
*,
|
|
ticket_status: TicketStatus | None,
|
|
category: TicketCategory | None = None,
|
|
priority: TicketPriority | None = None,
|
|
assigned_group: str | None = None,
|
|
source_path: str | None = None,
|
|
stale_only: bool = False,
|
|
search: str = "",
|
|
window_days: int,
|
|
) -> AdminTicketsResponse:
|
|
assigned_group_filter = (assigned_group or "").strip()
|
|
source_path_filter = (source_path or "").strip()
|
|
search_filter = search.strip().lower()
|
|
async with acquire(role="admin") as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
t.id,
|
|
t.reporter_id,
|
|
t.reporter_email,
|
|
t.reporter_name,
|
|
t.reporter_role,
|
|
t.category,
|
|
t.priority,
|
|
t.status,
|
|
t.subject,
|
|
t.body,
|
|
t.source_path,
|
|
t.fingerprint,
|
|
t.parent_ticket_id,
|
|
t.assigned_group,
|
|
t.resolution_note,
|
|
t.created_at,
|
|
t.updated_at,
|
|
t.resolved_at,
|
|
COALESCE(dup.duplicate_count, 0) AS duplicate_count,
|
|
dup.parent_candidate_id AS duplicate_parent_candidate_id,
|
|
COALESCE(child.child_ticket_count, 0) AS child_ticket_count,
|
|
COALESCE(ev.event_count, 0) AS event_count,
|
|
ev.last_event_at
|
|
FROM app.support_ticket AS t
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
count(*) FILTER (WHERE id <> t.id)::int AS duplicate_count,
|
|
(array_agg(id::text ORDER BY created_at ASC, id ASC))[1] AS parent_candidate_id
|
|
FROM app.support_ticket
|
|
WHERE fingerprint <> ''
|
|
AND fingerprint = t.fingerprint
|
|
) AS dup ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT count(*)::int AS child_ticket_count
|
|
FROM app.support_ticket
|
|
WHERE parent_ticket_id = t.id
|
|
) AS child ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
|
|
FROM audit.audit_log
|
|
WHERE action = 'support_ticket_update'
|
|
AND target_kind = 'support_ticket'
|
|
AND target_id = t.id::text
|
|
) AS ev ON TRUE
|
|
WHERE ($1::text IS NULL OR status = $1)
|
|
AND ($2::text IS NULL OR category = $2)
|
|
AND ($3::text IS NULL OR priority = $3)
|
|
AND ($4::text = '' OR assigned_group = $4)
|
|
AND ($5::text = '' OR source_path = $5)
|
|
AND (
|
|
NOT $6::bool
|
|
OR (
|
|
status NOT IN ('resolved', 'closed')
|
|
AND updated_at < now() - interval '1 day'
|
|
)
|
|
)
|
|
AND (
|
|
$7::text = ''
|
|
OR lower(subject) LIKE '%' || $7 || '%'
|
|
OR lower(body) LIKE '%' || $7 || '%'
|
|
OR lower(source_path) LIKE '%' || $7 || '%'
|
|
OR lower(reporter_email) LIKE '%' || $7 || '%'
|
|
)
|
|
AND created_at >= now() - ($8::int * interval '1 day')
|
|
ORDER BY
|
|
CASE
|
|
WHEN status = 'open' THEN 0
|
|
WHEN status = 'triaged' THEN 1
|
|
WHEN status = 'in_progress' THEN 2
|
|
WHEN status = 'resolved' THEN 3
|
|
ELSE 4
|
|
END,
|
|
CASE
|
|
WHEN priority = 'urgent' THEN 0
|
|
WHEN priority = 'high' THEN 1
|
|
WHEN priority = 'normal' THEN 2
|
|
ELSE 3
|
|
END,
|
|
updated_at DESC
|
|
LIMIT 120
|
|
""",
|
|
ticket_status,
|
|
category,
|
|
priority,
|
|
assigned_group_filter,
|
|
source_path_filter,
|
|
stale_only,
|
|
search_filter,
|
|
window_days,
|
|
)
|
|
tickets = [_ticket_from_row(row) for row in rows]
|
|
return AdminTicketsResponse(
|
|
source="database",
|
|
durable=True,
|
|
generated_at=time.time(),
|
|
tickets=tickets,
|
|
summary=_ticket_summary(tickets),
|
|
)
|
|
|
|
|
|
def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
|
|
window_start = time.time() - (window_days * 86400)
|
|
total_turns = 0
|
|
metered_turns = 0
|
|
tokens_in = 0
|
|
tokens_out = 0
|
|
cost_usd = 0.0
|
|
buckets: dict[tuple[str, str], dict[str, int | float]] = {}
|
|
daily_buckets: dict[str, dict[str, int | float]] = {}
|
|
|
|
for sess in store.list():
|
|
for turn in getattr(sess, "turns", []) or []:
|
|
if getattr(turn, "speaker", "") != "client":
|
|
continue
|
|
created_at = float(getattr(turn, "created_at", 0.0) or 0.0)
|
|
if created_at < window_start:
|
|
continue
|
|
total_turns += 1
|
|
provider = str(getattr(turn, "llm_provider", None) or "unknown")
|
|
model = str(getattr(turn, "model", None) or "unknown")
|
|
turn_tokens_in = _safe_usage_int(getattr(turn, "tokens_in", 0))
|
|
turn_tokens_out = _safe_usage_int(getattr(turn, "tokens_out", 0))
|
|
turn_cost = _decimal_to_float(getattr(turn, "cost_usd", 0.0))
|
|
is_metered = (
|
|
provider != "unknown"
|
|
or model != "unknown"
|
|
or turn_tokens_in > 0
|
|
or turn_tokens_out > 0
|
|
or turn_cost > 0
|
|
)
|
|
if not is_metered:
|
|
continue
|
|
metered_turns += 1
|
|
tokens_in += turn_tokens_in
|
|
tokens_out += turn_tokens_out
|
|
cost_usd += turn_cost
|
|
key = (provider, model)
|
|
bucket = buckets.setdefault(
|
|
key,
|
|
{"turns": 0, "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0},
|
|
)
|
|
bucket["turns"] = int(bucket["turns"]) + 1
|
|
bucket["tokens_in"] = int(bucket["tokens_in"]) + turn_tokens_in
|
|
bucket["tokens_out"] = int(bucket["tokens_out"]) + turn_tokens_out
|
|
bucket["cost_usd"] = float(bucket["cost_usd"]) + turn_cost
|
|
day = datetime.fromtimestamp(created_at, timezone.utc).strftime("%Y-%m-%d")
|
|
daily_bucket = daily_buckets.setdefault(
|
|
day,
|
|
{"turns": 0, "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0},
|
|
)
|
|
daily_bucket["turns"] = int(daily_bucket["turns"]) + 1
|
|
daily_bucket["tokens_in"] = int(daily_bucket["tokens_in"]) + turn_tokens_in
|
|
daily_bucket["tokens_out"] = int(daily_bucket["tokens_out"]) + turn_tokens_out
|
|
daily_bucket["cost_usd"] = float(daily_bucket["cost_usd"]) + turn_cost
|
|
|
|
by_provider = [
|
|
AdminUsageBreakdown(
|
|
provider=provider,
|
|
model=model,
|
|
turns=int(values["turns"]),
|
|
tokens_in=int(values["tokens_in"]),
|
|
tokens_out=int(values["tokens_out"]),
|
|
cost_usd=round(float(values["cost_usd"]), 6),
|
|
)
|
|
for (provider, model), values in sorted(
|
|
buckets.items(),
|
|
key=lambda item: (
|
|
-float(item[1]["cost_usd"]),
|
|
-(int(item[1]["tokens_in"]) + int(item[1]["tokens_out"])),
|
|
-int(item[1]["turns"]),
|
|
),
|
|
)[:12]
|
|
]
|
|
|
|
total_cost = round(cost_usd, 6)
|
|
return AdminUsageResponse(
|
|
source="server_session_registry",
|
|
durable=False,
|
|
window_days=window_days,
|
|
generated_at=time.time(),
|
|
total_turns=total_turns,
|
|
metered_turns=metered_turns,
|
|
tokens_in=tokens_in,
|
|
tokens_out=tokens_out,
|
|
cost_usd=total_cost,
|
|
budget=_usage_budget(total_cost),
|
|
evaluator_cache=_usage_evaluator_cache(),
|
|
by_provider=by_provider,
|
|
daily_cost=[
|
|
AdminUsageDailyCost(
|
|
day=day,
|
|
turns=int(values["turns"]),
|
|
tokens_in=int(values["tokens_in"]),
|
|
tokens_out=int(values["tokens_out"]),
|
|
cost_usd=round(float(values["cost_usd"]), 6),
|
|
)
|
|
for day, values in sorted(daily_buckets.items())
|
|
],
|
|
)
|
|
|
|
|
|
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"
|
|
admin_access: bool = False
|
|
account_status: AccountStatus = "approved"
|
|
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")
|
|
return email
|
|
|
|
|
|
def _default_engine_config() -> AdminEngineConfigResponse:
|
|
return AdminEngineConfigResponse(
|
|
engine_mode=settings.engine_mode,
|
|
engine_url=settings.engine_url,
|
|
model=ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL,
|
|
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 _row_ts(value: object) -> float | None:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return _updated_at_ts(value)
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _row_value(row, key: str, default=None):
|
|
try:
|
|
return row[key]
|
|
except (IndexError, KeyError, TypeError):
|
|
return default
|
|
|
|
|
|
def _notification_delivery_response(row) -> AdminNotificationDeliveryResponse:
|
|
return AdminNotificationDeliveryResponse(
|
|
id=str(row["id"]),
|
|
event_id=str(row["event_id"]),
|
|
kind=str(row["kind"]),
|
|
recipient_email=str(row["recipient_email"]),
|
|
recipient_name=str(row["recipient_name"] or ""),
|
|
recipient_role=str(row["recipient_role"] or ""),
|
|
subject=str(row["subject"] or ""),
|
|
status=row["status"],
|
|
attempts=int(row["attempts"] or 0),
|
|
last_error=str(row["last_error"] or "") or None,
|
|
provider_message_id=str(row["provider_message_id"] or "") or None,
|
|
created_at=_row_ts(row["created_at"]),
|
|
updated_at=_row_ts(row["updated_at"]),
|
|
sent_at=_row_ts(row["sent_at"]),
|
|
)
|
|
|
|
|
|
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",
|
|
)
|
|
|
|
|
|
def _health_event_from_row(row) -> AdminHealthEvent:
|
|
return AdminHealthEvent(
|
|
event_id=int(row["id"]),
|
|
observed_at=_row_ts(row["observed_at"]) or 0.0,
|
|
overall_status=row["overall_status"],
|
|
service_key=row["service_key"],
|
|
service_name=row["service_name"],
|
|
service_status=row["service_status"],
|
|
detail=row["detail"],
|
|
metric=row["metric"],
|
|
load=_clamp01(float(row["load"] or 0.0)),
|
|
)
|
|
|
|
|
|
def _ticket_from_row(row) -> AdminSupportTicketResponse:
|
|
return AdminSupportTicketResponse(
|
|
ticket_id=str(row["id"]),
|
|
reporter=AdminTicketReporter(
|
|
user_id=str(row["reporter_id"]) if row["reporter_id"] else None,
|
|
email=row["reporter_email"],
|
|
display_name=row["reporter_name"],
|
|
role=row["reporter_role"],
|
|
),
|
|
category=row["category"],
|
|
priority=row["priority"],
|
|
status=row["status"],
|
|
subject=row["subject"],
|
|
body=row["body"],
|
|
source_path=row["source_path"],
|
|
fingerprint=_row_value(row, "fingerprint", "") or "",
|
|
parent_ticket_id=(
|
|
str(_row_value(row, "parent_ticket_id"))
|
|
if _row_value(row, "parent_ticket_id") is not None
|
|
else None
|
|
),
|
|
duplicate_count=int(_row_value(row, "duplicate_count", 0) or 0),
|
|
duplicate_parent_candidate_id=(
|
|
str(_row_value(row, "duplicate_parent_candidate_id"))
|
|
if _row_value(row, "duplicate_parent_candidate_id") is not None
|
|
else None
|
|
),
|
|
child_ticket_count=int(_row_value(row, "child_ticket_count", 0) or 0),
|
|
assigned_group=row["assigned_group"],
|
|
resolution_note=row["resolution_note"],
|
|
created_at=_row_ts(row["created_at"]) or 0.0,
|
|
updated_at=_row_ts(row["updated_at"]) or 0.0,
|
|
resolved_at=_row_ts(row["resolved_at"]),
|
|
event_count=int(_row_value(row, "event_count", 0) or 0),
|
|
last_event_at=_row_ts(_row_value(row, "last_event_at")),
|
|
)
|
|
|
|
|
|
def _empty_ticket_summary() -> AdminTicketSummary:
|
|
return AdminTicketSummary(
|
|
total=0,
|
|
open_count=0,
|
|
high_priority_count=0,
|
|
stale_count=0,
|
|
by_status={},
|
|
by_category={},
|
|
by_priority={},
|
|
)
|
|
|
|
|
|
def _ticket_summary(tickets: list[AdminSupportTicketResponse]) -> AdminTicketSummary:
|
|
now = time.time()
|
|
by_status: dict[str, int] = {}
|
|
by_category: dict[str, int] = {}
|
|
by_priority: dict[str, int] = {}
|
|
active_tickets = [ticket for ticket in tickets if ticket.status not in {"resolved", "closed"}]
|
|
for ticket in tickets:
|
|
by_status[ticket.status] = by_status.get(ticket.status, 0) + 1
|
|
by_category[ticket.category] = by_category.get(ticket.category, 0) + 1
|
|
by_priority[ticket.priority] = by_priority.get(ticket.priority, 0) + 1
|
|
return AdminTicketSummary(
|
|
total=len(tickets),
|
|
open_count=len(active_tickets),
|
|
high_priority_count=sum(
|
|
1 for ticket in active_tickets if ticket.priority in {"high", "urgent"}
|
|
),
|
|
stale_count=sum(
|
|
1
|
|
for ticket in active_tickets
|
|
if now - ticket.updated_at > 86400
|
|
),
|
|
by_status=by_status,
|
|
by_category=by_category,
|
|
by_priority=by_priority,
|
|
)
|
|
|
|
|
|
def _unavailable_uptime(window_hours: int) -> AdminUptimeResponse:
|
|
return AdminUptimeResponse(
|
|
source="unavailable",
|
|
durable=False,
|
|
window_hours=window_hours,
|
|
generated_at=time.time(),
|
|
sample_count=0,
|
|
ok_ratio=0.0,
|
|
degraded_events=0,
|
|
down_events=0,
|
|
last_down_at=None,
|
|
services=[],
|
|
events=[],
|
|
)
|
|
|
|
|
|
def _unavailable_tickets() -> AdminTicketsResponse:
|
|
return AdminTicketsResponse(
|
|
source="unavailable",
|
|
durable=False,
|
|
generated_at=time.time(),
|
|
tickets=[],
|
|
summary=_empty_ticket_summary(),
|
|
)
|
|
|
|
|
|
def _normalize_parent_ticket_id(value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
stripped = value.strip()
|
|
if not stripped:
|
|
return None
|
|
try:
|
|
return str(UUID(stripped))
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="invalid parent ticket id",
|
|
) from exc
|
|
|
|
|
|
def _ticket_change_detail(old_row, new_row) -> dict[str, object]:
|
|
changed_fields: list[str] = []
|
|
detail: dict[str, object] = {"changed_fields": changed_fields}
|
|
for field in ("status", "priority", "assigned_group", "parent_ticket_id"):
|
|
before = _row_value(old_row, field, "")
|
|
after = _row_value(new_row, field, "")
|
|
if before is not None:
|
|
before = str(before)
|
|
if after is not None:
|
|
after = str(after)
|
|
if before != after:
|
|
changed_fields.append(field)
|
|
detail[field] = {"from": before, "to": after}
|
|
old_note = (_row_value(old_row, "resolution_note", "") or "").strip()
|
|
new_note = (_row_value(new_row, "resolution_note", "") or "").strip()
|
|
if old_note != new_note:
|
|
changed_fields.append("resolution_note")
|
|
detail["resolution_note"] = {
|
|
"from_present": bool(old_note),
|
|
"to_present": bool(new_note),
|
|
}
|
|
detail["category"] = _row_value(new_row, "category", "")
|
|
detail["source_path"] = _row_value(new_row, "source_path", "")
|
|
return detail
|
|
|
|
|
|
async def _record_ticket_update_audit(
|
|
conn,
|
|
*,
|
|
principal: Principal,
|
|
ticket_id: str,
|
|
detail: dict[str, object],
|
|
) -> None:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO audit.audit_log (
|
|
actor_uid, action, target_kind, target_id, detail
|
|
)
|
|
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
|
|
""",
|
|
principal.user_id,
|
|
"support_ticket_update",
|
|
"support_ticket",
|
|
ticket_id,
|
|
detail,
|
|
)
|
|
|
|
|
|
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)
|
|
return _default_engine_config()
|
|
except Exception:
|
|
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
|
|
|
|
|
|
def _requires_super_admin_for_privilege_change(
|
|
*,
|
|
current_role: RoleName | None,
|
|
next_role: RoleName | None,
|
|
current_admin_access: bool,
|
|
next_admin_access: bool | None,
|
|
) -> bool:
|
|
if next_admin_access is not None and next_admin_access != current_admin_access:
|
|
return True
|
|
if next_role == "admin" and current_role != "admin":
|
|
return True
|
|
if current_role == "admin" and next_role is not None and next_role != "admin":
|
|
return True
|
|
return False
|
|
|
|
|
|
def _assert_super_admin(principal: Principal) -> None:
|
|
if not principal.super_admin:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="super admin required")
|
|
|
|
|
|
def _assert_super_admin_target_mutable(email: str, admin_access: bool | None) -> None:
|
|
if is_super_admin_email(email) and admin_access is False:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot revoke super admin access")
|
|
|
|
|
|
async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse:
|
|
effective_admin_access = has_admin_access(user.email, user.role, user.admin_access)
|
|
return AdminUserResponse(
|
|
user_id=user.user_id,
|
|
email=user.email,
|
|
display_name=user.display_name,
|
|
role=user.role,
|
|
admin_access=effective_admin_access,
|
|
super_admin=is_super_admin_email(user.email),
|
|
account_status=user.account_status,
|
|
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",
|
|
)
|
|
|
|
|
|
async def _build_admin_health_response() -> AdminHealthResponse:
|
|
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,
|
|
),
|
|
]
|
|
|
|
response = AdminHealthResponse(
|
|
status=_overall_status(services),
|
|
environment=settings.environment,
|
|
engine_mode=current_engine.engine_mode,
|
|
services=services,
|
|
)
|
|
return response
|
|
|
|
|
|
async def record_admin_health_sample(
|
|
*, principal: Principal | None = None
|
|
) -> tuple[AdminHealthResponse, int]:
|
|
"""Collect and persist one synthetic/admin health sample."""
|
|
response = await _build_admin_health_response()
|
|
recorded_count = await _record_health_events(
|
|
principal=principal,
|
|
overall_status=response.status,
|
|
environment=response.environment,
|
|
engine_mode=response.engine_mode,
|
|
services=response.services,
|
|
)
|
|
return response, recorded_count
|
|
|
|
|
|
@router.get("/health", response_model=AdminHealthResponse)
|
|
async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse:
|
|
"""Return operational health from live backend checks."""
|
|
response, _ = await record_admin_health_sample(principal=principal)
|
|
return response
|
|
|
|
|
|
@router.get("/usage", response_model=AdminUsageResponse)
|
|
async def admin_usage(
|
|
principal: AdminPrincipal,
|
|
window_days: Annotated[int, Query(ge=1, le=90)] = 7,
|
|
) -> AdminUsageResponse:
|
|
"""Return AI token/cost usage from persisted turns or dev fallback state."""
|
|
try:
|
|
return await _usage_from_database(window_days)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("admin usage")
|
|
return _usage_from_runtime_store(window_days)
|
|
|
|
|
|
@router.get("/uptime", response_model=AdminUptimeResponse)
|
|
async def admin_uptime(
|
|
principal: AdminPrincipal,
|
|
window_hours: Annotated[int, Query(ge=1, le=720)] = 24,
|
|
) -> AdminUptimeResponse:
|
|
"""Return persisted health check samples captured by the admin console."""
|
|
try:
|
|
return await _uptime_from_database(window_hours)
|
|
except Exception:
|
|
return _unavailable_uptime(window_hours)
|
|
|
|
|
|
@router.get("/tickets", response_model=AdminTicketsResponse)
|
|
async def list_tickets(
|
|
principal: AdminPrincipal,
|
|
ticket_status: Annotated[TicketStatus | None, Query(alias="status")] = None,
|
|
category: TicketCategory | None = None,
|
|
priority: TicketPriority | None = None,
|
|
assigned_group: Annotated[str | None, Query(max_length=120)] = None,
|
|
source_path: Annotated[str | None, Query(max_length=300)] = None,
|
|
stale_only: bool = False,
|
|
search: Annotated[str, Query(max_length=120)] = "",
|
|
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
|
|
) -> AdminTicketsResponse:
|
|
"""Return user-submitted operational tickets without synthetic fallback rows."""
|
|
try:
|
|
return await _tickets_from_database(
|
|
ticket_status=ticket_status,
|
|
category=category,
|
|
priority=priority,
|
|
assigned_group=assigned_group,
|
|
source_path=source_path,
|
|
stale_only=stale_only,
|
|
search=search,
|
|
window_days=window_days,
|
|
)
|
|
except Exception:
|
|
return _unavailable_tickets()
|
|
|
|
|
|
@router.get("/notifications", response_model=AdminNotificationsResponse)
|
|
async def list_notifications(
|
|
principal: AdminPrincipal,
|
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
) -> AdminNotificationsResponse:
|
|
"""Return recent operational email delivery state."""
|
|
try:
|
|
get_pool()
|
|
async with acquire(role="admin") as conn:
|
|
status_rows = await conn.fetch(
|
|
"""
|
|
SELECT status, COUNT(*) AS count
|
|
FROM app.notification_delivery
|
|
GROUP BY status
|
|
"""
|
|
)
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
d.id,
|
|
d.event_id,
|
|
e.kind,
|
|
d.recipient_email,
|
|
d.recipient_name,
|
|
d.recipient_role,
|
|
d.subject,
|
|
d.status,
|
|
d.attempts,
|
|
d.last_error,
|
|
d.provider_message_id,
|
|
d.created_at,
|
|
d.updated_at,
|
|
d.sent_at
|
|
FROM app.notification_delivery d
|
|
JOIN app.notification_event e ON e.id = d.event_id
|
|
ORDER BY d.created_at DESC
|
|
LIMIT $1
|
|
""",
|
|
limit,
|
|
)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("admin notifications")
|
|
status_rows = []
|
|
rows = []
|
|
counts = {str(row["status"]): int(row["count"] or 0) for row in status_rows}
|
|
return AdminNotificationsResponse(
|
|
provider=settings.notification_email_provider,
|
|
smtp_configured=bool(settings.smtp_host and settings.smtp_from_email),
|
|
queued=counts.get("queued", 0),
|
|
failed=counts.get("failed", 0),
|
|
sent=counts.get("sent", 0),
|
|
skipped=counts.get("skipped", 0),
|
|
deliveries=[_notification_delivery_response(row) for row in rows],
|
|
)
|
|
|
|
|
|
@router.post("/notifications/process", response_model=AdminNotificationProcessResponse)
|
|
async def process_notifications(
|
|
principal: AdminPrincipal,
|
|
limit: Annotated[int, Query(ge=1, le=100)] = 25,
|
|
) -> AdminNotificationProcessResponse:
|
|
"""Drain queued email notifications once from the admin console/API."""
|
|
try:
|
|
result = await notifications.process_queued_email_notifications(limit=limit)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("admin notification processing")
|
|
result = {"processed": 0, "sent": 0, "failed": 0, "skipped": 0}
|
|
return AdminNotificationProcessResponse(**result)
|
|
|
|
|
|
@router.post("/notifications/test", response_model=AdminNotificationTestResponse)
|
|
async def send_test_notification(
|
|
principal: AdminPrincipal,
|
|
) -> AdminNotificationTestResponse:
|
|
"""Queue and process one explicit admin email test."""
|
|
recipient_count = 0
|
|
try:
|
|
recipient_count = await notifications.enqueue_admin_test_email(
|
|
actor_user_id=principal.user_id,
|
|
actor_email=principal.email,
|
|
)
|
|
result = await notifications.process_queued_email_notifications(
|
|
limit=max(1, min(100, recipient_count or 1))
|
|
)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("admin notification test")
|
|
result = {"processed": 0, "sent": 0, "failed": 0, "skipped": 0}
|
|
return AdminNotificationTestResponse(
|
|
provider=settings.notification_email_provider,
|
|
smtp_configured=bool(settings.smtp_host and settings.smtp_from_email),
|
|
recipients=recipient_count,
|
|
**result,
|
|
)
|
|
|
|
|
|
@router.patch("/tickets/{ticket_id}", response_model=AdminSupportTicketResponse)
|
|
async def patch_ticket(
|
|
ticket_id: str,
|
|
body: AdminTicketPatch,
|
|
principal: AdminPrincipal,
|
|
) -> AdminSupportTicketResponse:
|
|
"""Update ticket triage state for administrators."""
|
|
try:
|
|
async with acquire(role="admin", user_id=principal.user_id) as conn:
|
|
old_row = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
id,
|
|
category,
|
|
priority,
|
|
status,
|
|
source_path,
|
|
assigned_group,
|
|
resolution_note,
|
|
parent_ticket_id
|
|
FROM app.support_ticket
|
|
WHERE id = $1::uuid
|
|
""",
|
|
ticket_id,
|
|
)
|
|
if old_row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
|
|
parent_specified = "parent_ticket_id" in body.model_fields_set
|
|
next_parent_id = (
|
|
_normalize_parent_ticket_id(body.parent_ticket_id)
|
|
if parent_specified
|
|
else None
|
|
)
|
|
if parent_specified and next_parent_id is not None:
|
|
if next_parent_id == str(UUID(ticket_id)):
|
|
raise HTTPException(
|
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="ticket cannot be its own parent",
|
|
)
|
|
parent_check = await conn.fetchrow(
|
|
"""
|
|
WITH RECURSIVE ancestors AS (
|
|
SELECT id, parent_ticket_id
|
|
FROM app.support_ticket
|
|
WHERE id = $1::uuid
|
|
UNION ALL
|
|
SELECT t.id, t.parent_ticket_id
|
|
FROM app.support_ticket AS t
|
|
JOIN ancestors AS a ON t.id = a.parent_ticket_id
|
|
WHERE a.parent_ticket_id IS NOT NULL
|
|
)
|
|
SELECT
|
|
EXISTS (SELECT 1 FROM ancestors) AS parent_exists,
|
|
EXISTS (SELECT 1 FROM ancestors WHERE id = $2::uuid) AS creates_cycle
|
|
""",
|
|
next_parent_id,
|
|
ticket_id,
|
|
)
|
|
if not parent_check or not parent_check["parent_exists"]:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="parent ticket not found")
|
|
if parent_check["creates_cycle"]:
|
|
raise HTTPException(
|
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="ticket parent would create a cycle",
|
|
)
|
|
row = await conn.fetchrow(
|
|
"""
|
|
UPDATE app.support_ticket SET
|
|
status = COALESCE($2, status),
|
|
priority = COALESCE($3, priority),
|
|
assigned_group = COALESCE($4, assigned_group),
|
|
resolution_note = COALESCE($5, resolution_note),
|
|
parent_ticket_id = CASE
|
|
WHEN $6::bool THEN $7::uuid
|
|
ELSE parent_ticket_id
|
|
END,
|
|
resolved_at = CASE
|
|
WHEN COALESCE($2, status) IN ('resolved', 'closed')
|
|
THEN COALESCE(resolved_at, now())
|
|
WHEN $2 IS NOT NULL
|
|
THEN NULL
|
|
ELSE resolved_at
|
|
END,
|
|
updated_at = now(),
|
|
last_activity_at = now()
|
|
WHERE id = $1::uuid
|
|
RETURNING
|
|
id,
|
|
reporter_id,
|
|
reporter_email,
|
|
reporter_name,
|
|
reporter_role,
|
|
category,
|
|
priority,
|
|
status,
|
|
subject,
|
|
body,
|
|
source_path,
|
|
fingerprint,
|
|
parent_ticket_id,
|
|
assigned_group,
|
|
resolution_note,
|
|
created_at,
|
|
updated_at,
|
|
resolved_at
|
|
""",
|
|
ticket_id,
|
|
body.status,
|
|
body.priority,
|
|
body.assigned_group.strip() if body.assigned_group is not None else None,
|
|
body.resolution_note.strip() if body.resolution_note is not None else None,
|
|
parent_specified,
|
|
next_parent_id,
|
|
)
|
|
if row is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
|
|
detail = _ticket_change_detail(old_row, row)
|
|
if detail["changed_fields"]:
|
|
await _record_ticket_update_audit(
|
|
conn,
|
|
principal=principal,
|
|
ticket_id=ticket_id,
|
|
detail=detail,
|
|
)
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
t.id,
|
|
t.reporter_id,
|
|
t.reporter_email,
|
|
t.reporter_name,
|
|
t.reporter_role,
|
|
t.category,
|
|
t.priority,
|
|
t.status,
|
|
t.subject,
|
|
t.body,
|
|
t.source_path,
|
|
t.fingerprint,
|
|
t.parent_ticket_id,
|
|
t.assigned_group,
|
|
t.resolution_note,
|
|
t.created_at,
|
|
t.updated_at,
|
|
t.resolved_at,
|
|
COALESCE(dup.duplicate_count, 0) AS duplicate_count,
|
|
dup.parent_candidate_id AS duplicate_parent_candidate_id,
|
|
COALESCE(child.child_ticket_count, 0) AS child_ticket_count,
|
|
COALESCE(ev.event_count, 0) AS event_count,
|
|
ev.last_event_at
|
|
FROM app.support_ticket AS t
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
count(*) FILTER (WHERE id <> t.id)::int AS duplicate_count,
|
|
(array_agg(id::text ORDER BY created_at ASC, id ASC))[1] AS parent_candidate_id
|
|
FROM app.support_ticket
|
|
WHERE fingerprint <> ''
|
|
AND fingerprint = t.fingerprint
|
|
) AS dup ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT count(*)::int AS child_ticket_count
|
|
FROM app.support_ticket
|
|
WHERE parent_ticket_id = t.id
|
|
) AS child ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
|
|
FROM audit.audit_log
|
|
WHERE action = 'support_ticket_update'
|
|
AND target_kind = 'support_ticket'
|
|
AND target_id = t.id::text
|
|
) AS ev ON TRUE
|
|
WHERE t.id = $1::uuid
|
|
""",
|
|
ticket_id,
|
|
)
|
|
except Exception as exc:
|
|
if isinstance(exc, HTTPException):
|
|
raise
|
|
raise HTTPException(
|
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="ticket persistence unavailable",
|
|
) from exc
|
|
return _ticket_from_row(row)
|
|
|
|
|
|
@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."""
|
|
if body.role == "admin" or body.admin_access:
|
|
_assert_super_admin(principal)
|
|
user = await upsert_managed_user(
|
|
ManagedUserUpsertInput(
|
|
email=_normalize_email(body.email),
|
|
display_name=body.display_name,
|
|
role=body.role,
|
|
admin_access=body.admin_access,
|
|
account_status=body.account_status,
|
|
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."""
|
|
current = await get_managed_user(user_id)
|
|
if current is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
|
if _requires_super_admin_for_privilege_change(
|
|
current_role=current.role,
|
|
next_role=body.role,
|
|
current_admin_access=current.admin_access,
|
|
next_admin_access=body.admin_access,
|
|
):
|
|
_assert_super_admin(principal)
|
|
_assert_super_admin_target_mutable(current.email, body.admin_access)
|
|
next_user = await update_managed_user(
|
|
user_id,
|
|
ManagedUserPatch(
|
|
display_name=body.display_name,
|
|
role=body.role,
|
|
admin_access=body.admin_access,
|
|
account_status=body.account_status,
|
|
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")
|
|
current = await get_managed_user(user_id)
|
|
if current is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
|
if is_super_admin_email(current.email):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate super 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)
|