2026-06-19 11:28:04 +03:00
|
|
|
"""Admin panel routes: settings, LLM logs, users."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Any, Dict, List
|
2026-06-19 16:31:45 +03:00
|
|
|
from uuid import UUID
|
2026-06-19 11:28:04 +03:00
|
|
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
|
|
|
|
|
|
|
|
|
from app.core.settings_service import EDITABLE_SETTING_KEYS, get_all_settings, update_settings
|
|
|
|
|
from app.db import get_db_dep
|
|
|
|
|
from app.deps import require_admin
|
|
|
|
|
from app.models import LlmCallLog, Setting, User
|
|
|
|
|
from app.schemas import LlmLogOut, SettingsOut, SettingsUpdate
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mask_secrets(values: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
"""Mask sensitive api_key fields in outbound responses."""
|
|
|
|
|
for k in ("llm.api_key", "embedding.api_key"):
|
|
|
|
|
v = values.get(k)
|
|
|
|
|
if isinstance(v, str) and v:
|
|
|
|
|
values[k] = v[:4] + "***" + v[-4:] if len(v) > 8 else "***"
|
|
|
|
|
# Never expose admin setup token via this endpoint
|
|
|
|
|
values.pop("admin.setup_token", None)
|
|
|
|
|
return values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/settings", response_model=SettingsOut)
|
|
|
|
|
async def get_settings_endpoint(
|
|
|
|
|
db: AsyncSession = Depends(get_db_dep),
|
|
|
|
|
_: User = Depends(require_admin),
|
|
|
|
|
):
|
|
|
|
|
values = await get_all_settings(db)
|
|
|
|
|
values = _mask_secrets(values)
|
|
|
|
|
return SettingsOut(values=values, editable_keys=sorted(EDITABLE_SETTING_KEYS.keys()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/settings", response_model=SettingsOut)
|
|
|
|
|
async def update_settings_endpoint(
|
|
|
|
|
payload: SettingsUpdate,
|
|
|
|
|
db: AsyncSession = Depends(get_db_dep),
|
|
|
|
|
_: User = Depends(require_admin),
|
|
|
|
|
):
|
|
|
|
|
# Strip masked api_key fields unless the user typed a new value
|
|
|
|
|
cleaned: Dict[str, Any] = {}
|
|
|
|
|
for k, v in (payload.values or {}).items():
|
|
|
|
|
if k in ("llm.api_key", "embedding.api_key") and isinstance(v, str) and "***" in v:
|
|
|
|
|
continue
|
|
|
|
|
cleaned[k] = v
|
|
|
|
|
new_values = await update_settings(db, cleaned)
|
|
|
|
|
# If embedding settings changed, drop the cached RAG client so the next
|
|
|
|
|
# get_rag() call rebuilds it (and reconfigures Qdrant collections if dim changed).
|
|
|
|
|
if any(k.startswith("embedding.") for k in cleaned):
|
|
|
|
|
from app.core.rag import reset_rag
|
|
|
|
|
await reset_rag()
|
|
|
|
|
new_values = _mask_secrets(new_values)
|
|
|
|
|
return SettingsOut(values=new_values, editable_keys=sorted(EDITABLE_SETTING_KEYS.keys()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/embeddings/test")
|
|
|
|
|
async def test_embeddings_endpoint(
|
|
|
|
|
payload: Dict[str, Any] = Body(default={}),
|
|
|
|
|
db: AsyncSession = Depends(get_db_dep),
|
|
|
|
|
_: User = Depends(require_admin),
|
|
|
|
|
):
|
|
|
|
|
"""Probe the currently configured embeddings endpoint.
|
|
|
|
|
|
|
|
|
|
Accepts an optional `overrides` dict with embedding.* keys (e.g. to test
|
|
|
|
|
a new endpoint before saving). Returns: ok, provider, base_url, model,
|
|
|
|
|
dim, sample_norm (or error).
|
|
|
|
|
"""
|
|
|
|
|
from app.core.rag import probe_embeddings
|
|
|
|
|
settings_map = await get_all_settings(db)
|
|
|
|
|
# Apply ad-hoc overrides (without saving) so the admin can try before save
|
|
|
|
|
overrides = (payload or {}).get("overrides") or {}
|
|
|
|
|
for k, v in overrides.items():
|
|
|
|
|
if k in EDITABLE_SETTING_KEYS:
|
|
|
|
|
settings_map[k] = v
|
|
|
|
|
return await probe_embeddings(settings_map)
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 17:10:17 +03:00
|
|
|
@router.post("/llm/test")
|
|
|
|
|
async def test_llm_endpoint(
|
|
|
|
|
payload: Dict[str, Any] = Body(default={}),
|
|
|
|
|
db: AsyncSession = Depends(get_db_dep),
|
|
|
|
|
_: User = Depends(require_admin),
|
|
|
|
|
):
|
|
|
|
|
"""Probe the currently configured LLM endpoint from inside the backend container.
|
|
|
|
|
|
|
|
|
|
Accepts an optional `overrides` dict with llm.* keys (e.g. to test a new
|
|
|
|
|
endpoint before saving). Returns: ok, base_url, model, http_status,
|
|
|
|
|
latency_ms, response_preview (or error + error_type).
|
|
|
|
|
|
|
|
|
|
This is the diagnostic tool to use when the LLM call fails with
|
|
|
|
|
`ConnectError: All connection attempts failed` — it tells you whether
|
|
|
|
|
the backend container can actually reach the LLM URL.
|
|
|
|
|
"""
|
|
|
|
|
import time
|
|
|
|
|
import httpx
|
|
|
|
|
import socket
|
|
|
|
|
|
|
|
|
|
settings_map = await get_all_settings(db)
|
|
|
|
|
overrides = (payload or {}).get("overrides") or {}
|
|
|
|
|
for k, v in overrides.items():
|
|
|
|
|
if k in EDITABLE_SETTING_KEYS:
|
|
|
|
|
settings_map[k] = v
|
|
|
|
|
|
|
|
|
|
base_url = str(settings_map.get("llm.base_url", "")).rstrip("/")
|
|
|
|
|
model = str(settings_map.get("llm.model", "local-model"))
|
|
|
|
|
api_key = str(settings_map.get("llm.api_key", "dummy"))
|
|
|
|
|
timeout_s = float(settings_map.get("llm.request_timeout", 30) or 30)
|
|
|
|
|
|
|
|
|
|
result: Dict[str, Any] = {
|
|
|
|
|
"base_url": base_url,
|
|
|
|
|
"model": model,
|
|
|
|
|
"ok": False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# === Stage 1: DNS / TCP connect (without TLS) ===
|
|
|
|
|
try:
|
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
parsed = urlparse(base_url)
|
|
|
|
|
host = parsed.hostname or ""
|
|
|
|
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
|
|
|
if not host:
|
|
|
|
|
result["error"] = "invalid_base_url: no host"
|
|
|
|
|
result["error_type"] = "ConfigError"
|
|
|
|
|
return result
|
|
|
|
|
# Try to resolve + connect TCP
|
|
|
|
|
addrs = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
|
|
|
result["dns_resolved"] = True
|
|
|
|
|
result["resolved_addrs"] = [a[4][0] for a in addrs[:3]]
|
|
|
|
|
# Try to actually open a TCP connection
|
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
|
|
|
sock.settimeout(5.0)
|
|
|
|
|
try:
|
|
|
|
|
sock.connect((host, port))
|
|
|
|
|
result["tcp_connect_ok"] = True
|
|
|
|
|
finally:
|
|
|
|
|
sock.close()
|
|
|
|
|
except socket.gaierror as e:
|
|
|
|
|
result["dns_resolved"] = False
|
|
|
|
|
result["error"] = f"DNS resolution failed for {host}: {e}"
|
|
|
|
|
result["error_type"] = "DNSError"
|
|
|
|
|
return result
|
|
|
|
|
except (socket.timeout, ConnectionRefusedError, OSError) as e:
|
|
|
|
|
result["tcp_connect_ok"] = False
|
|
|
|
|
result["error"] = f"TCP connect to {host}:{port} failed: {type(e).__name__}: {e}"
|
|
|
|
|
result["error_type"] = type(e).__name__
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
# === Stage 2: HTTP request to /v1/models (lightweight probe) ===
|
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
|
if api_key and api_key != "dummy":
|
|
|
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
|
|
|
|
|
|
|
|
started = time.monotonic()
|
|
|
|
|
try:
|
|
|
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=timeout_s, write=10.0, pool=5.0)) as client:
|
|
|
|
|
# First try /models (lightweight, exists on every OpenAI-compatible server)
|
|
|
|
|
models_url = f"{base_url}/models"
|
|
|
|
|
try:
|
|
|
|
|
resp = await client.get(models_url, headers=headers)
|
|
|
|
|
result["models_endpoint_status"] = resp.status_code
|
|
|
|
|
if resp.status_code == 200:
|
|
|
|
|
data = resp.json()
|
|
|
|
|
model_ids = []
|
|
|
|
|
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
|
|
|
|
model_ids = [m.get("id", "?") for m in data["data"][:10]]
|
|
|
|
|
result["available_models"] = model_ids
|
|
|
|
|
except Exception as e:
|
|
|
|
|
result["models_endpoint_error"] = f"{type(e).__name__}: {e}"
|
|
|
|
|
|
|
|
|
|
# Now try the actual chat completions endpoint with a minimal payload
|
|
|
|
|
chat_url = f"{base_url}/chat/completions"
|
|
|
|
|
chat_payload = {
|
|
|
|
|
"model": model,
|
|
|
|
|
"messages": [{"role": "user", "content": "Reply with the single word: ok"}],
|
|
|
|
|
"max_tokens": 10,
|
|
|
|
|
"temperature": 0.1,
|
|
|
|
|
"stream": False,
|
|
|
|
|
}
|
|
|
|
|
resp = await client.post(chat_url, json=chat_payload, headers=headers)
|
|
|
|
|
result["chat_endpoint_status"] = resp.status_code
|
|
|
|
|
result["latency_ms"] = int((time.monotonic() - started) * 1000)
|
|
|
|
|
if resp.status_code >= 400:
|
|
|
|
|
result["error"] = f"HTTP {resp.status_code}: {resp.text[:500]}"
|
|
|
|
|
result["error_type"] = "HTTPError"
|
|
|
|
|
return result
|
|
|
|
|
data = resp.json()
|
|
|
|
|
choice = (data.get("choices") or [{}])[0]
|
|
|
|
|
msg = choice.get("message", {})
|
|
|
|
|
result["ok"] = True
|
|
|
|
|
result["response_preview"] = (msg.get("content") or "")[:200]
|
|
|
|
|
result["usage"] = data.get("usage", {})
|
|
|
|
|
return result
|
|
|
|
|
except httpx.ConnectError as e:
|
|
|
|
|
cause = getattr(e, "__cause__", None) or getattr(e, "__context__", None)
|
|
|
|
|
result["error"] = f"ConnectError: {e}"
|
|
|
|
|
if cause:
|
|
|
|
|
result["error"] += f" (cause: {cause})"
|
|
|
|
|
result["error_type"] = "ConnectError"
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
|
|
|
|
result["error"] = f"{type(e).__name__}: {e}"
|
|
|
|
|
result["error_type"] = type(e).__name__
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 17:32:21 +03:00
|
|
|
@router.post("/llm/test-tools")
|
|
|
|
|
async def test_llm_tools_endpoint(
|
|
|
|
|
payload: Dict[str, Any] = Body(default={}),
|
|
|
|
|
db: AsyncSession = Depends(get_db_dep),
|
|
|
|
|
_: User = Depends(require_admin),
|
|
|
|
|
):
|
|
|
|
|
"""Probe whether the configured LLM endpoint supports OpenAI-style tool calls.
|
|
|
|
|
|
|
|
|
|
Sends a minimal chat completion request WITH a `tools` array containing one
|
|
|
|
|
simple function (`get_time`). Returns:
|
|
|
|
|
- ok: bool — did the model produce ANY well-formed response?
|
|
|
|
|
- tool_calls_returned: bool — did the model emit at least one tool_call?
|
|
|
|
|
- tool_call_name: str|null — the function name the model called (if any)
|
|
|
|
|
- tool_call_args: dict|null — the parsed arguments (if any)
|
|
|
|
|
- text: str — the model's text response (if any)
|
|
|
|
|
- http_status: int — HTTP status of the chat-completions call
|
|
|
|
|
- latency_ms: int
|
|
|
|
|
- raw_tool_calls: list — the raw tool_calls array from the response
|
|
|
|
|
- error: str|null — error message if the request failed
|
|
|
|
|
- error_type: str|null
|
|
|
|
|
|
|
|
|
|
Use this to verify the model actually supports function-calling before
|
|
|
|
|
relying on it for world-builder / orchestrator / step-writer flows.
|
|
|
|
|
"""
|
|
|
|
|
import time
|
|
|
|
|
import httpx
|
|
|
|
|
import json as _json
|
|
|
|
|
|
|
|
|
|
settings_map = await get_all_settings(db)
|
|
|
|
|
overrides = (payload or {}).get("overrides") or {}
|
|
|
|
|
for k, v in overrides.items():
|
|
|
|
|
if k in EDITABLE_SETTING_KEYS:
|
|
|
|
|
settings_map[k] = v
|
|
|
|
|
|
|
|
|
|
base_url = str(settings_map.get("llm.base_url", "")).rstrip("/")
|
|
|
|
|
model = str(settings_map.get("llm.model", "local-model"))
|
|
|
|
|
api_key = str(settings_map.get("llm.api_key", "dummy"))
|
|
|
|
|
timeout_s = float(settings_map.get("llm.request_timeout", 30) or 30)
|
|
|
|
|
|
|
|
|
|
result: Dict[str, Any] = {
|
|
|
|
|
"base_url": base_url,
|
|
|
|
|
"model": model,
|
|
|
|
|
"ok": False,
|
|
|
|
|
"tool_calls_returned": False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
|
if api_key and api_key != "dummy":
|
|
|
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
|
|
|
|
|
|
|
|
# Minimal tool definition — the model should call this.
|
|
|
|
|
tools = [
|
|
|
|
|
{
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": {
|
|
|
|
|
"name": "get_current_time",
|
|
|
|
|
"description": "Returns the current time. Call this when the user asks for the time.",
|
|
|
|
|
"parameters": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {
|
|
|
|
|
"timezone": {
|
|
|
|
|
"type": "string",
|
|
|
|
|
"description": "Optional timezone, e.g. 'UTC' or 'Europe/Moscow'",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"required": [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
chat_url = f"{base_url}/chat/completions"
|
|
|
|
|
chat_payload = {
|
|
|
|
|
"model": model,
|
|
|
|
|
"messages": [
|
|
|
|
|
{"role": "system", "content": "You are a helpful assistant. When the user asks for the time, you MUST call the get_current_time tool."},
|
|
|
|
|
{"role": "user", "content": "What time is it now? Use the get_current_time tool to find out."},
|
|
|
|
|
],
|
|
|
|
|
"tools": tools,
|
|
|
|
|
"tool_choice": "auto",
|
|
|
|
|
"max_tokens": 200,
|
|
|
|
|
"temperature": 0.0,
|
|
|
|
|
"stream": False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
started = time.monotonic()
|
|
|
|
|
try:
|
|
|
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=timeout_s, write=10.0, pool=5.0)) as client:
|
|
|
|
|
resp = await client.post(chat_url, json=chat_payload, headers=headers)
|
|
|
|
|
result["http_status"] = resp.status_code
|
|
|
|
|
result["latency_ms"] = int((time.monotonic() - started) * 1000)
|
|
|
|
|
if resp.status_code >= 400:
|
|
|
|
|
result["error"] = f"HTTP {resp.status_code}: {resp.text[:500]}"
|
|
|
|
|
result["error_type"] = "HTTPError"
|
|
|
|
|
return result
|
|
|
|
|
data = resp.json()
|
|
|
|
|
except httpx.ConnectError as e:
|
|
|
|
|
cause = getattr(e, "__cause__", None) or getattr(e, "__context__", None)
|
|
|
|
|
result["error"] = f"ConnectError: {e}"
|
|
|
|
|
if cause:
|
|
|
|
|
result["error"] += f" (cause: {cause})"
|
|
|
|
|
result["error_type"] = "ConnectError"
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
|
|
|
|
result["error"] = f"{type(e).__name__}: {e}"
|
|
|
|
|
result["error_type"] = type(e).__name__
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
choice = (data.get("choices") or [{}])[0]
|
|
|
|
|
msg = choice.get("message", {})
|
|
|
|
|
text = msg.get("content") or ""
|
|
|
|
|
tool_calls = msg.get("tool_calls") or []
|
|
|
|
|
result["text"] = text[:500]
|
|
|
|
|
result["raw_tool_calls"] = tool_calls
|
|
|
|
|
if tool_calls:
|
|
|
|
|
result["tool_calls_returned"] = True
|
|
|
|
|
first = tool_calls[0]
|
|
|
|
|
fn = first.get("function", {}) if isinstance(first, dict) else {}
|
|
|
|
|
result["tool_call_name"] = fn.get("name")
|
|
|
|
|
args_str = fn.get("arguments", "{}")
|
|
|
|
|
try:
|
|
|
|
|
result["tool_call_args"] = _json.loads(args_str) if args_str else {}
|
|
|
|
|
except _json.JSONDecodeError:
|
|
|
|
|
result["tool_call_args"] = {"_raw": args_str}
|
|
|
|
|
result["ok"] = True
|
|
|
|
|
result["usage"] = data.get("usage", {})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
result["error"] = f"response_parse_failed: {type(e).__name__}: {e}"
|
|
|
|
|
result["error_type"] = type(e).__name__
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 11:28:04 +03:00
|
|
|
@router.get("/llm-logs", response_model=List[LlmLogOut])
|
|
|
|
|
async def list_llm_logs(
|
|
|
|
|
limit: int = 50,
|
|
|
|
|
offset: int = 0,
|
|
|
|
|
db: AsyncSession = Depends(get_db_dep),
|
|
|
|
|
_: User = Depends(require_admin),
|
|
|
|
|
):
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(LlmCallLog).order_by(LlmCallLog.created_at.desc()).limit(min(limit, 200)).offset(offset)
|
|
|
|
|
)
|
|
|
|
|
return result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/llm-logs/{log_id}")
|
|
|
|
|
async def get_llm_log(
|
|
|
|
|
log_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db_dep),
|
|
|
|
|
_: User = Depends(require_admin),
|
|
|
|
|
):
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
result = await db.execute(select(LlmCallLog).where(LlmCallLog.id == UUID(log_id)))
|
|
|
|
|
log = result.scalars().first()
|
|
|
|
|
if not log:
|
|
|
|
|
raise HTTPException(status_code=404, detail="log_not_found")
|
|
|
|
|
return {
|
|
|
|
|
"id": str(log.id),
|
|
|
|
|
"purpose": log.purpose,
|
|
|
|
|
"model": log.model,
|
|
|
|
|
"base_url": log.base_url,
|
|
|
|
|
"prompt_messages": log.prompt_messages,
|
|
|
|
|
"tools": log.tools,
|
|
|
|
|
"response_text": log.response_text,
|
|
|
|
|
"tool_calls": log.tool_calls,
|
|
|
|
|
"prompt_tokens": log.prompt_tokens,
|
|
|
|
|
"completion_tokens": log.completion_tokens,
|
|
|
|
|
"total_tokens": log.total_tokens,
|
|
|
|
|
"latency_ms": log.latency_ms,
|
|
|
|
|
"error": log.error,
|
|
|
|
|
"created_at": log.created_at.isoformat() if log.created_at else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/users")
|
|
|
|
|
async def list_users(
|
|
|
|
|
db: AsyncSession = Depends(get_db_dep),
|
|
|
|
|
_: User = Depends(require_admin),
|
|
|
|
|
):
|
|
|
|
|
result = await db.execute(select(User).order_by(User.created_at.desc()))
|
|
|
|
|
users = result.scalars().all()
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"id": str(u.id),
|
|
|
|
|
"email": u.email,
|
|
|
|
|
"username": u.username,
|
|
|
|
|
"is_admin": u.is_admin,
|
|
|
|
|
"is_active": u.is_active,
|
|
|
|
|
"created_at": u.created_at.isoformat() if u.created_at else None,
|
|
|
|
|
}
|
|
|
|
|
for u in users
|
|
|
|
|
]
|
2026-06-19 16:31:45 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/users/{user_id}/set-active")
|
|
|
|
|
async def set_user_active(
|
|
|
|
|
user_id: UUID,
|
|
|
|
|
payload: Dict[str, Any] = Body(default={}),
|
|
|
|
|
db: AsyncSession = Depends(get_db_dep),
|
|
|
|
|
admin: User = Depends(require_admin),
|
|
|
|
|
):
|
|
|
|
|
"""Activate or ban a user. Banned users cannot log in (see auth.login).
|
|
|
|
|
|
|
|
|
|
Body: `{"is_active": true|false}`. Admins cannot ban themselves.
|
|
|
|
|
"""
|
|
|
|
|
is_active = bool(payload.get("is_active"))
|
|
|
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
|
|
|
user = result.scalars().first()
|
|
|
|
|
if not user:
|
|
|
|
|
raise HTTPException(status_code=404, detail="user_not_found")
|
|
|
|
|
if user.id == admin.id and not is_active:
|
|
|
|
|
raise HTTPException(status_code=400, detail="cannot_ban_self")
|
|
|
|
|
user.is_active = is_active
|
|
|
|
|
await db.commit()
|
|
|
|
|
return {
|
|
|
|
|
"id": str(user.id),
|
|
|
|
|
"email": user.email,
|
|
|
|
|
"username": user.username,
|
|
|
|
|
"is_admin": user.is_admin,
|
|
|
|
|
"is_active": user.is_active,
|
|
|
|
|
}
|