526 lines
20 KiB
Python
526 lines
20 KiB
Python
"""АРМ администратора: учётные записи, состояние стенда, аудит, копии.
|
||
|
||
ТЗ выделяет администратора отдельной ролью с собственным разделом: управление
|
||
учётными записями, состояние компонентов, резервное копирование не реже раза
|
||
в сутки, журналы и мониторинг нагрузки.
|
||
|
||
Границы роли из ТЗ соблюдаются здесь же: администратор **не** правит оценки
|
||
и не вмешивается в занятия — этих точек в модуле нет вовсе, а не «есть,
|
||
но с проверкой».
|
||
"""
|
||
|
||
import logging
|
||
import re
|
||
from datetime import datetime, timedelta, timezone
|
||
from uuid import UUID
|
||
from xml.etree import ElementTree as ET
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||
from fastapi.encoders import jsonable_encoder
|
||
from fastapi.responses import JSONResponse
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from starlette.concurrency import run_in_threadpool
|
||
|
||
from app.admin import backup as backup_service
|
||
from app.api.auth import audit, hash_password, invalidate_login, require
|
||
from app.config import get_settings
|
||
from app.db.base import get_session
|
||
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
|
||
from app.domain import ekp
|
||
from app.domain.roles import ROLE_LABELS, SCREENS, Role
|
||
from app.domain.timers import NORMATIVES
|
||
from app.dialog.llm import is_loopback_url
|
||
from app.monitoring import recent_events, sample_metrics
|
||
from app.session.hub import hub
|
||
|
||
log = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||
|
||
|
||
def _configuration_xml() -> bytes:
|
||
"""Безопасный переносимый снимок конфигурации без паролей и ключей."""
|
||
settings = get_settings()
|
||
root = ET.Element("lctConfiguration", {"version": "1"})
|
||
ET.SubElement(root, "platform", {
|
||
"offline": str(settings.offline).lower(),
|
||
"voiceEnabled": str(settings.voice_enabled).lower(),
|
||
"secureCookies": str(settings.secure_cookies).lower(),
|
||
})
|
||
models = ET.SubElement(root, "localModels")
|
||
ET.SubElement(models, "dialogue", {"name": settings.llm_model_caller})
|
||
ET.SubElement(models, "russianControl", {
|
||
"name": settings.llm_model_control,
|
||
"grammarEnabled": str(settings.grammar_llm_enabled).lower(),
|
||
})
|
||
ET.SubElement(models, "speechToText", {
|
||
"name": settings.stt_model,
|
||
"enabled": str(settings.voice_enabled).lower(),
|
||
})
|
||
workstations = ET.SubElement(root, "workstations")
|
||
for role, screens in SCREENS.items():
|
||
workstation = ET.SubElement(workstations, "workstation", {
|
||
"role": role.value, "label": ROLE_LABELS[role],
|
||
})
|
||
for path in screens:
|
||
ET.SubElement(workstation, "screen", {"path": path})
|
||
timers = ET.SubElement(root, "timerLimits")
|
||
for code, normative in NORMATIVES.items():
|
||
ET.SubElement(timers, "timer", {
|
||
"code": code.value,
|
||
"milliseconds": str(settings.limit_ms(code)),
|
||
"defaultMilliseconds": str(normative.limit_ms),
|
||
})
|
||
reference = ekp.reference()
|
||
ET.SubElement(root, "ekp", {
|
||
"version": reference.version,
|
||
"incidents": str(len(reference.incidents)),
|
||
})
|
||
ET.indent(root, space=" ")
|
||
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
||
|
||
|
||
@router.get("/config.xml")
|
||
async def configuration_xml(request: Request) -> Response:
|
||
require(request, Role.ADMIN)
|
||
return Response(
|
||
content=_configuration_xml(),
|
||
media_type="application/xml",
|
||
headers={"Content-Disposition": 'attachment; filename="lct-workstations.xml"'},
|
||
)
|
||
|
||
|
||
class UserOut(BaseModel):
|
||
id: UUID
|
||
login: str
|
||
full_name: str
|
||
role: Role
|
||
service: str | None
|
||
blocked: bool
|
||
created_at: datetime
|
||
|
||
|
||
class UserCreate(BaseModel):
|
||
login: str = Field(min_length=3, max_length=80)
|
||
full_name: str = Field(min_length=1, max_length=120)
|
||
password: str = Field(min_length=8, max_length=1024, description="Пароль должен быть от 8 до 1024 символов")
|
||
role: Role
|
||
service: str | None = None
|
||
|
||
|
||
class UserPatch(BaseModel):
|
||
"""Что администратор меняет у существующей записи. Пароль — сбросом,
|
||
а не чтением: старый он не видит и увидеть не может."""
|
||
|
||
role: Role | None = None
|
||
service: str | None = None
|
||
blocked: bool | None = None
|
||
password: str | None = Field(default=None, min_length=8, max_length=1024)
|
||
|
||
|
||
def _out(user: User) -> UserOut:
|
||
return UserOut(
|
||
id=user.id,
|
||
login=user.login,
|
||
full_name=user.full_name,
|
||
role=Role(user.role),
|
||
service=user.service,
|
||
blocked=user.blocked,
|
||
created_at=user.created_at,
|
||
)
|
||
|
||
|
||
@router.get("/users", response_model=list[UserOut])
|
||
async def users(request: Request, db: AsyncSession = Depends(get_session)) -> list[UserOut]:
|
||
require(request, Role.ADMIN)
|
||
rows = await db.scalars(select(User).order_by(User.created_at))
|
||
return [_out(user) for user in rows]
|
||
|
||
|
||
@router.post("/users", response_model=UserOut, status_code=201)
|
||
async def create_user(
|
||
body: UserCreate, request: Request, db: AsyncSession = Depends(get_session)
|
||
) -> UserOut:
|
||
who = require(request, Role.ADMIN)
|
||
|
||
user = User(
|
||
login=body.login,
|
||
full_name=body.full_name,
|
||
password_hash=hash_password(body.password),
|
||
role=body.role.value,
|
||
service=body.service,
|
||
)
|
||
# У обучающегося должна быть карточка курсанта: на ней висят профиль,
|
||
# история и проверка «это твой разбор» (lct-23).
|
||
if body.role is Role.TRAINEE:
|
||
trainee = Trainee(name=body.full_name)
|
||
db.add(trainee)
|
||
await db.flush()
|
||
user.trainee_id = trainee.id
|
||
|
||
db.add(user)
|
||
try:
|
||
await db.commit()
|
||
except IntegrityError as exc:
|
||
await db.rollback()
|
||
raise HTTPException(status_code=409, detail="login_taken") from exc
|
||
|
||
await audit(who.login, who.role.value, "user.create", body.login, ROLE_LABELS[body.role])
|
||
return _out(user)
|
||
|
||
|
||
@router.patch("/users/{user_id}", response_model=UserOut)
|
||
async def patch_user(
|
||
user_id: UUID, body: UserPatch, request: Request, db: AsyncSession = Depends(get_session)
|
||
) -> UserOut:
|
||
who = require(request, Role.ADMIN)
|
||
user = await db.get(User, user_id)
|
||
if user is None:
|
||
raise HTTPException(status_code=404, detail="user_not_found")
|
||
|
||
changed: list[str] = []
|
||
if body.role is not None:
|
||
if user.login == who.login and body.role is not Role.ADMIN:
|
||
raise HTTPException(status_code=409, detail="cannot_demote_yourself")
|
||
if body.role is Role.TRAINEE and user.trainee_id is None:
|
||
trainee = Trainee(name=user.full_name)
|
||
db.add(trainee)
|
||
await db.flush()
|
||
user.trainee_id = trainee.id
|
||
user.role = body.role.value
|
||
changed.append(f"роль {body.role.value}")
|
||
if body.service is not None:
|
||
user.service = body.service
|
||
changed.append(f"служба {body.service}")
|
||
if body.blocked is not None:
|
||
# Блокировка себя оставила бы стенд без администратора до похода в базу.
|
||
if body.blocked and user.login == who.login:
|
||
raise HTTPException(status_code=409, detail="cannot_block_yourself")
|
||
user.blocked = body.blocked
|
||
changed.append("заблокирован" if body.blocked else "разблокирован")
|
||
if body.password is not None:
|
||
user.password_hash = hash_password(body.password)
|
||
changed.append("пароль сброшен")
|
||
|
||
if not changed:
|
||
return _out(user)
|
||
user.auth_version += 1
|
||
await db.commit()
|
||
invalidate_login(user.login, user.auth_version)
|
||
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
|
||
return _out(user)
|
||
|
||
|
||
class AuditOut(BaseModel):
|
||
at: datetime
|
||
actor: str
|
||
role: str
|
||
action: str
|
||
object_id: str | None
|
||
detail: str
|
||
|
||
|
||
@router.get("/audit", response_model=list[AuditOut])
|
||
async def audit_log(
|
||
request: Request,
|
||
action: str | None = None,
|
||
actor: str | None = None,
|
||
limit: int = 200,
|
||
offset: int = 0,
|
||
db: AsyncSession = Depends(get_session),
|
||
) -> list[AuditOut]:
|
||
"""Журнал действий. Администратор его читает, но не правит: точки удаления
|
||
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
|
||
require(request, Role.ADMIN)
|
||
query = (
|
||
select(AuditLog)
|
||
.order_by(AuditLog.at.desc(), AuditLog.id.desc())
|
||
.limit(max(1, min(limit, 1001)))
|
||
.offset(max(0, min(offset, 10_000_000)))
|
||
)
|
||
if action:
|
||
query = query.where(AuditLog.action == action)
|
||
if actor:
|
||
query = query.where(AuditLog.actor == actor)
|
||
rows = await db.scalars(query)
|
||
return [
|
||
AuditOut(
|
||
at=row.at, actor=row.actor, role=row.role,
|
||
action=row.action, object_id=row.object_id, detail=row.detail,
|
||
)
|
||
for row in rows
|
||
]
|
||
|
||
|
||
class ServiceState(BaseModel):
|
||
name: str
|
||
ok: bool
|
||
detail: str
|
||
|
||
|
||
class RuntimeMetrics(BaseModel):
|
||
at: datetime
|
||
uptime_seconds: float
|
||
cpu_percent: float
|
||
load_1m_percent: float | None
|
||
cpu_cores: int
|
||
rss_bytes: int | None
|
||
memory_total_bytes: int | None
|
||
memory_available_bytes: int | None
|
||
disk_total_bytes: int
|
||
disk_free_bytes: int
|
||
threads: int
|
||
active_sessions: int
|
||
restored_sessions: int
|
||
completed_sessions_24h: int
|
||
|
||
|
||
class DiagnosticEvent(BaseModel):
|
||
at: datetime
|
||
level: str
|
||
source: str
|
||
message: str
|
||
|
||
|
||
class FailureEvent(BaseModel):
|
||
at: datetime
|
||
actor: str
|
||
action: str
|
||
object_id: str | None
|
||
detail: str
|
||
|
||
|
||
class DiagnosticReport(BaseModel):
|
||
generated_at: datetime
|
||
metrics: RuntimeMetrics
|
||
recent_system_events: list[DiagnosticEvent]
|
||
failed_actions_24h: list[FailureEvent]
|
||
|
||
|
||
async def _runtime_metrics(db: AsyncSession) -> RuntimeMetrics:
|
||
from app.main import app
|
||
|
||
raw = sample_metrics()
|
||
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||
completed = await db.scalar(
|
||
select(func.count()).select_from(SessionRow).where(SessionRow.ended_at >= since)
|
||
)
|
||
return RuntimeMetrics(
|
||
**raw,
|
||
active_sessions=sum(not item.ended for item in hub._sessions.values()), # noqa: SLF001
|
||
restored_sessions=getattr(app.state, "sessions_restored", 0),
|
||
completed_sessions_24h=completed or 0,
|
||
)
|
||
|
||
|
||
async def _diagnostic_report(db: AsyncSession) -> DiagnosticReport:
|
||
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||
rows = await db.scalars(
|
||
select(AuditLog)
|
||
.where(AuditLog.at >= since, AuditLog.action.like("%.failed"))
|
||
.order_by(AuditLog.at.desc())
|
||
.limit(200)
|
||
)
|
||
return DiagnosticReport(
|
||
generated_at=datetime.now(timezone.utc),
|
||
metrics=await _runtime_metrics(db),
|
||
recent_system_events=[DiagnosticEvent(**item) for item in recent_events(limit=100)],
|
||
failed_actions_24h=[
|
||
FailureEvent(
|
||
at=row.at, actor=row.actor, action=row.action,
|
||
object_id=row.object_id, detail=row.detail,
|
||
)
|
||
for row in rows
|
||
],
|
||
)
|
||
|
||
|
||
@router.get("/diagnostics", response_model=DiagnosticReport)
|
||
async def diagnostics(
|
||
request: Request, db: AsyncSession = Depends(get_session)
|
||
) -> DiagnosticReport:
|
||
"""Live load plus a bounded, redacted incident report for the admin."""
|
||
require(request, Role.ADMIN)
|
||
return await _diagnostic_report(db)
|
||
|
||
|
||
@router.get("/diagnostics.json")
|
||
async def download_diagnostics(
|
||
request: Request, db: AsyncSession = Depends(get_session)
|
||
) -> JSONResponse:
|
||
require(request, Role.ADMIN)
|
||
report = await _diagnostic_report(db)
|
||
return JSONResponse(
|
||
jsonable_encoder(report),
|
||
headers={"Content-Disposition": 'attachment; filename="lct-diagnostics.json"'},
|
||
)
|
||
|
||
|
||
@router.get("/status", response_model=list[ServiceState])
|
||
async def status(request: Request, db: AsyncSession = Depends(get_session)) -> list[ServiceState]:
|
||
"""Состояние компонентов стенда — то, что администратор смотрит до занятия,
|
||
а не после жалобы преподавателя."""
|
||
require(request, Role.ADMIN)
|
||
from app.main import app
|
||
from app.voice.models import get_voice_models
|
||
|
||
settings = get_settings()
|
||
states: list[ServiceState] = []
|
||
|
||
try:
|
||
sessions_total = await db.scalar(select(func.count()).select_from(SessionRow))
|
||
states.append(ServiceState(name="База данных", ok=True, detail=f"занятий в журнале: {sessions_total}"))
|
||
except Exception as exc: # noqa: BLE001
|
||
states.append(ServiceState(name="База данных", ok=False, detail=str(exc)[:200]))
|
||
|
||
models_ready = getattr(app.state, "models_ready", False) or get_voice_models() is not None
|
||
states.append(
|
||
ServiceState(
|
||
name="Модели речи",
|
||
ok=bool(models_ready),
|
||
detail="распознавание и синтез готовы" if models_ready else "не загружены: занятие пойдёт без голоса",
|
||
)
|
||
)
|
||
metrics = sample_metrics()
|
||
disk_free = metrics["disk_free_bytes"]
|
||
disk_total = max(metrics["disk_total_bytes"], 1)
|
||
disk_ok = disk_free >= 1024 ** 3 and disk_free / disk_total >= 0.05
|
||
load = metrics["load_1m_percent"]
|
||
states.append(ServiceState(
|
||
name="Нагрузка backend",
|
||
ok=disk_ok and (load is None or load < 100),
|
||
detail=(
|
||
f"CPU процесса {metrics['cpu_percent']:.1f}%; "
|
||
+ (f"нагрузка хоста {load:.1f}%; " if load is not None else "")
|
||
+ f"RAM процесса {(metrics['rss_bytes'] or 0) / 1024 ** 2:.0f} МБ; "
|
||
+ f"свободно на диске {disk_free / 1024 ** 3:.1f} ГБ"
|
||
),
|
||
))
|
||
states.append(
|
||
ServiceState(
|
||
name="Эмбеддинги",
|
||
ok=bool(getattr(app.state, "embeddings_ready", False)),
|
||
detail="слот-автомат работает" if getattr(app.state, "embeddings_ready", False)
|
||
else "нет модели: подсказки идут по порядку чек-листа",
|
||
)
|
||
)
|
||
llm_configured = (
|
||
is_loopback_url(
|
||
settings.llm_base_url,
|
||
allow_docker_host=settings.allow_docker_host_models,
|
||
)
|
||
if settings.offline or settings.llm_provider == "local"
|
||
else bool(settings.llm_api_key and settings.llm_base_url)
|
||
)
|
||
states.append(
|
||
ServiceState(
|
||
name="Провайдер LLM",
|
||
ok=llm_configured,
|
||
detail=(f"локальный адрес разрешён: {settings.llm_base_url}"
|
||
if llm_configured else "не настроен: звонящий читает офлайн-таблицу"),
|
||
)
|
||
)
|
||
|
||
reference = ekp.reference()
|
||
states.append(
|
||
ServiceState(
|
||
name="Классификатор ЕКП",
|
||
ok=True,
|
||
detail=f"версия {reference.version}, кодов {len(reference.incidents)}",
|
||
)
|
||
)
|
||
states.append(
|
||
ServiceState(
|
||
name="Живых занятий",
|
||
ok=True,
|
||
detail=(
|
||
f"активно {sum(not item.ended for item in hub._sessions.values())}; "
|
||
f"восстановлено после запуска {getattr(app.state, 'sessions_restored', 0)}"
|
||
), # noqa: SLF001 — реестр в памяти процесса
|
||
)
|
||
)
|
||
try:
|
||
copies = backup_service.listing()
|
||
except backup_service.BackupError as exc:
|
||
states.append(ServiceState(
|
||
name="Резервное копирование",
|
||
ok=False,
|
||
detail=f"ошибка чтения копий: {exc}",
|
||
))
|
||
copies = []
|
||
if copies:
|
||
latest = copies[0]
|
||
age_seconds = max(0.0, (datetime.now(timezone.utc) - latest["at"]).total_seconds())
|
||
allowed_age = max(60, settings.backup_interval_seconds) + max(
|
||
60, settings.backup_retry_seconds
|
||
)
|
||
states.append(ServiceState(
|
||
name="Резервное копирование",
|
||
ok=age_seconds <= allowed_age,
|
||
detail=(f"последняя копия {latest['name']}, "
|
||
f"{age_seconds / 3600:.1f} ч назад; хранится {len(copies)}"),
|
||
))
|
||
else:
|
||
states.append(ServiceState(
|
||
name="Резервное копирование",
|
||
ok=False,
|
||
detail="успешных копий ещё нет",
|
||
))
|
||
# Секрет сессии по умолчанию — не ошибка запуска, но на стенде это дыра,
|
||
# и увидеть её должен администратор, а не проверяющий.
|
||
default_secret = settings.session_secret.startswith("dev-secret")
|
||
states.append(
|
||
ServiceState(
|
||
name="Секрет сессии",
|
||
ok=not default_secret,
|
||
detail="заменён" if not default_secret else "стоит значение по умолчанию — поменяйте SESSION_SECRET",
|
||
)
|
||
)
|
||
return states
|
||
|
||
|
||
class BackupOut(BaseModel):
|
||
name: str
|
||
size_bytes: int
|
||
at: datetime
|
||
|
||
|
||
@router.get("/backups", response_model=list[BackupOut])
|
||
async def backups(request: Request) -> list[BackupOut]:
|
||
require(request, Role.ADMIN)
|
||
try:
|
||
copies = await run_in_threadpool(backup_service.listing)
|
||
except backup_service.BackupError as exc:
|
||
detail = _safe_backup_error(exc)
|
||
raise HTTPException(status_code=503, detail=detail) from exc
|
||
return [BackupOut(**item) for item in copies]
|
||
|
||
|
||
def _safe_backup_error(exc: backup_service.BackupError) -> str:
|
||
"""Never echo DATABASE_URL/its password from backup diagnostics to HTTP."""
|
||
message = str(exc)
|
||
dsn = get_settings().database_url
|
||
if dsn:
|
||
message = message.replace(dsn, "[DATABASE_URL скрыт]")
|
||
match = re.search(r"://[^:]+:([^@]+)@", dsn)
|
||
if match and match.group(1):
|
||
message = message.replace(match.group(1), "[пароль скрыт]")
|
||
return message
|
||
|
||
|
||
@router.post("/backups", response_model=BackupOut, status_code=201)
|
||
async def make_backup(request: Request) -> BackupOut:
|
||
"""Копия прямо сейчас. Расписание — отдельно, в `scripts/backup.py`:
|
||
кнопка нужна перед занятием, расписание — чтобы о нём не вспоминали."""
|
||
who = require(request, Role.ADMIN)
|
||
try:
|
||
# pg_dump may run for two minutes; never block the event loop for it.
|
||
created = await run_in_threadpool(backup_service.create)
|
||
except backup_service.BackupError as exc:
|
||
detail = _safe_backup_error(exc)
|
||
await audit(who.login, who.role.value, "backup.failed", detail=detail)
|
||
raise HTTPException(status_code=503, detail=detail) from exc
|
||
await audit(who.login, who.role.value, "backup.create", created["name"])
|
||
return BackupOut(**created)
|