feat: АРМ администратора — записи, состояние стенда, аудит, копии (lct-24)
Целый раздел ТЗ, от которого не было ни одной функции. - Учётные записи: завести, сменить роль и службу, заблокировать, сбросить пароль. Обучающемуся заводится и карточка курсанта — на ней висят профиль и проверка «это твой разбор». - Состояние компонентов: база, модели речи, эмбеддинги, провайдер LLM, версия классификатора, число живых занятий. Отдельной строкой — секрет сессии: значение по умолчанию не ошибка запуска, но на стенде это дыра, и увидеть её должен администратор, а не проверяющий на защите. - Журнал действий с фильтром, только на чтение. - Резервные копии: кнопка и make backup для cron. pg_dump берётся локальный, а если его нет — из контейнера базы; если нет ни того ни другого, администратор видит, чего не хватает, а кнопка не молчит. Границы роли выражены отсутствием маршрутов, а не проверками внутри них: в разделе нет ни одной точки, трогающей оценки или пишущей в аудит. Два теста проверяют это перебором маршрутов роутера — так запрет нельзя ослабить, случайно добавив обработчик. Найдено по ходу: тесты с базой падали в компании и проходили поодиночке. Движок SQLAlchemy кэшировался на процесс, а каждый TestClient поднимает свой событийный цикл — пул привязан к первому. Появился db.base.reset() и фикстура, дающая движок на тест. 193 теста зелёных (12 новых).
This commit is contained in:
parent
d55c8bc8cd
commit
641e505c67
13 changed files with 948 additions and 1 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,6 +5,9 @@
|
||||||
# Веса моделей (тянутся make models)
|
# Веса моделей (тянутся make models)
|
||||||
backend/models/
|
backend/models/
|
||||||
|
|
||||||
|
# Резервные копии базы (make backup)
|
||||||
|
backend/backups/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
|
||||||
5
Makefile
5
Makefile
|
|
@ -41,6 +41,9 @@ typecheck: ## Проверить фронтенд компилятором
|
||||||
models: ## Скачать модели в backend/models/: эмбеддинги, GigaAM, Silero VAD; Silero TTS — вручную
|
models: ## Скачать модели в backend/models/: эмбеддинги, GigaAM, Silero VAD; Silero TTS — вручную
|
||||||
cd backend && $(UV) run python scripts/models.py
|
cd backend && $(UV) run python scripts/models.py
|
||||||
|
|
||||||
|
backup: ## Резервная копия базы в backend/backups/ (для cron: раз в сутки)
|
||||||
|
cd backend && $(UV) run python scripts/backup.py
|
||||||
|
|
||||||
users: ## Завести учётные записи ролей и напечатать пароли: make users force=1
|
users: ## Завести учётные записи ролей и напечатать пароли: make users force=1
|
||||||
cd backend && $(UV) run python scripts/users.py
|
cd backend && $(UV) run python scripts/users.py
|
||||||
|
|
||||||
|
|
@ -74,4 +77,4 @@ pregen: ## Построить таблицу реплик звонящего д
|
||||||
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
|
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
|
||||||
./scripts/demo.sh
|
./scripts/demo.sh
|
||||||
|
|
||||||
.PHONY: help dev down back front types users test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo
|
.PHONY: help dev down back front types users backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo
|
||||||
|
|
|
||||||
0
backend/app/admin/__init__.py
Normal file
0
backend/app/admin/__init__.py
Normal file
93
backend/app/admin/backup.py
Normal file
93
backend/app/admin/backup.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""Резервные копии базы.
|
||||||
|
|
||||||
|
ТЗ требует копирования не реже раза в сутки. Копия — это `pg_dump`, и брать
|
||||||
|
его неоткуда, кроме самой базы: на машине с нативным бэкендом клиента Postgres
|
||||||
|
может не быть вовсе. Поэтому сначала пробуем локальный `pg_dump`, потом —
|
||||||
|
тот, что лежит в контейнере базы, и только если нет ни того ни другого,
|
||||||
|
честно говорим, чего не хватает.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DIR = Path(__file__).resolve().parents[2] / "backups"
|
||||||
|
#: Контейнер базы в docker compose проекта.
|
||||||
|
CONTAINER = os.environ.get("POSTGRES_CONTAINER", "lct-hack-postgres-1")
|
||||||
|
TIMEOUT_S = 120
|
||||||
|
|
||||||
|
|
||||||
|
class BackupError(RuntimeError):
|
||||||
|
"""Копию снять нечем или не удалось. Сообщение показывается администратору."""
|
||||||
|
|
||||||
|
|
||||||
|
def _dsn() -> tuple[str, str, str, str, str]:
|
||||||
|
"""Разобрать DATABASE_URL на части для pg_dump."""
|
||||||
|
url = get_settings().database_url
|
||||||
|
match = re.match(r".*://([^:]+):([^@]+)@([^:/]+):(\d+)/(\w+)", url)
|
||||||
|
if not match:
|
||||||
|
raise BackupError(f"не разобрать DATABASE_URL: {url}")
|
||||||
|
return match.groups() # user, password, host, port, database
|
||||||
|
|
||||||
|
|
||||||
|
def listing() -> list[dict]:
|
||||||
|
"""Готовые копии, новые первыми."""
|
||||||
|
if not DIR.exists():
|
||||||
|
return []
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"name": path.name,
|
||||||
|
"size_bytes": path.stat().st_size,
|
||||||
|
"at": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc),
|
||||||
|
}
|
||||||
|
for path in DIR.glob("*.sql")
|
||||||
|
]
|
||||||
|
return sorted(items, key=lambda item: item["at"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def create() -> dict:
|
||||||
|
"""Снять копию. Возвращает описание файла, как в `listing()`."""
|
||||||
|
user, password, host, port, database = _dsn()
|
||||||
|
DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
target = DIR / f"lct-{stamp}.sql"
|
||||||
|
|
||||||
|
if shutil.which("pg_dump"):
|
||||||
|
command = ["pg_dump", "-h", host, "-p", port, "-U", user, "-d", database]
|
||||||
|
env = {**os.environ, "PGPASSWORD": password}
|
||||||
|
elif shutil.which("docker"):
|
||||||
|
# В контейнере база доступна как localhost, а не по хосту из DSN.
|
||||||
|
command = ["docker", "exec", "-e", f"PGPASSWORD={password}", CONTAINER,
|
||||||
|
"pg_dump", "-U", user, "-d", database]
|
||||||
|
env = dict(os.environ)
|
||||||
|
else:
|
||||||
|
raise BackupError(
|
||||||
|
"нечем снять копию: нет ни pg_dump, ни docker. "
|
||||||
|
"Поставьте postgresql-client или запускайте базу в контейнере проекта"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, capture_output=True, timeout=TIMEOUT_S, env=env)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise BackupError(f"pg_dump не уложился в {TIMEOUT_S} с") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise BackupError(f"не запустился pg_dump: {exc}") from exc
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise BackupError(result.stderr.decode(errors="replace")[:300] or "pg_dump завершился с ошибкой")
|
||||||
|
|
||||||
|
target.write_bytes(result.stdout)
|
||||||
|
log.info("резервная копия: %s, %d КБ", target.name, target.stat().st_size // 1024)
|
||||||
|
return {
|
||||||
|
"name": target.name,
|
||||||
|
"size_bytes": target.stat().st_size,
|
||||||
|
"at": datetime.fromtimestamp(target.stat().st_mtime, tz=timezone.utc),
|
||||||
|
}
|
||||||
277
backend/app/api/http/admin.py
Normal file
277
backend/app/api/http/admin.py
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
"""АРМ администратора: учётные записи, состояние стенда, аудит, копии.
|
||||||
|
|
||||||
|
ТЗ выделяет администратора отдельной ролью с собственным разделом: управление
|
||||||
|
учётными записями, состояние компонентов, резервное копирование не реже раза
|
||||||
|
в сутки, журналы и мониторинг нагрузки.
|
||||||
|
|
||||||
|
Границы роли из ТЗ соблюдаются здесь же: администратор **не** правит оценки
|
||||||
|
и не вмешивается в занятия — этих точек в модуле нет вовсе, а не «есть,
|
||||||
|
но с проверкой».
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.admin import backup as backup_service
|
||||||
|
from app.api.auth import audit, hash_password, 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, Role
|
||||||
|
from app.session.hub import hub
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
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, description="Короткий пароль не заводится")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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("пароль сброшен")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
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,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[AuditOut]:
|
||||||
|
"""Журнал действий. Администратор его читает, но не правит: точки удаления
|
||||||
|
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
|
||||||
|
require(request, Role.ADMIN)
|
||||||
|
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(min(limit, 1000))
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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 "не загружены: занятие пойдёт без голоса",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
states.append(
|
||||||
|
ServiceState(
|
||||||
|
name="Эмбеддинги",
|
||||||
|
ok=bool(getattr(app.state, "embeddings_ready", False)),
|
||||||
|
detail="слот-автомат работает" if getattr(app.state, "embeddings_ready", False)
|
||||||
|
else "нет модели: подсказки идут по порядку чек-листа",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
states.append(
|
||||||
|
ServiceState(
|
||||||
|
name="Провайдер LLM",
|
||||||
|
ok=bool(settings.llm_api_key and settings.llm_base_url),
|
||||||
|
detail=settings.llm_base_url or "не настроен: звонящий читает офлайн-таблицу",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
reference = ekp.reference()
|
||||||
|
states.append(
|
||||||
|
ServiceState(
|
||||||
|
name="Классификатор ЕКП",
|
||||||
|
ok=True,
|
||||||
|
detail=f"версия {reference.version}, кодов {len(reference.incidents)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
states.append(
|
||||||
|
ServiceState(
|
||||||
|
name="Живых занятий",
|
||||||
|
ok=True,
|
||||||
|
detail=str(len(hub._sessions)), # noqa: SLF001 — реестр в памяти процесса
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Секрет сессии по умолчанию — не ошибка запуска, но на стенде это дыра,
|
||||||
|
# и увидеть её должен администратор, а не проверяющий.
|
||||||
|
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)
|
||||||
|
return [BackupOut(**item) for item in backup_service.listing()]
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
created = backup_service.create()
|
||||||
|
except backup_service.BackupError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
await audit(who.login, who.role.value, "backup.create", created["name"])
|
||||||
|
return BackupOut(**created)
|
||||||
|
|
@ -30,6 +30,19 @@ def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||||
return _sessionmaker
|
return _sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
def reset() -> None:
|
||||||
|
"""Забыть движок и фабрику.
|
||||||
|
|
||||||
|
Нужно тестам: каждый `TestClient` поднимает свой событийный цикл, а пул
|
||||||
|
соединений привязан к тому, в котором создан. Переиспользование даёт
|
||||||
|
«Future attached to a different loop» — причём не сразу, а на втором
|
||||||
|
обращении к базе, и выглядит это как случайно падающий тест.
|
||||||
|
"""
|
||||||
|
global _engine, _sessionmaker
|
||||||
|
_engine = None
|
||||||
|
_sessionmaker = None
|
||||||
|
|
||||||
|
|
||||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||||
"""Зависимость FastAPI: сессия на запрос, коммит явный."""
|
"""Зависимость FastAPI: сессия на запрос, коммит явный."""
|
||||||
async with get_sessionmaker()() as session:
|
async with get_sessionmaker()() as session:
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.api import auth
|
from app.api import auth
|
||||||
|
from app.api.http import admin as admin_api
|
||||||
from app.api.http import ekp as ekp_api
|
from app.api.http import ekp as ekp_api
|
||||||
from app.api.http import scenarios as scenarios_api
|
from app.api.http import scenarios as scenarios_api
|
||||||
from app.api.http import sessions
|
from app.api.http import sessions
|
||||||
|
|
@ -76,6 +77,7 @@ app.include_router(auth.router)
|
||||||
app.include_router(sessions.router)
|
app.include_router(sessions.router)
|
||||||
app.include_router(scenarios_api.router)
|
app.include_router(scenarios_api.router)
|
||||||
app.include_router(ekp_api.router)
|
app.include_router(ekp_api.router)
|
||||||
|
app.include_router(admin_api.router)
|
||||||
app.include_router(trainees.router)
|
app.include_router(trainees.router)
|
||||||
app.include_router(call_ws.router)
|
app.include_router(call_ws.router)
|
||||||
app.include_router(observe_ws.router)
|
app.include_router(observe_ws.router)
|
||||||
|
|
|
||||||
42
backend/scripts/backup.py
Normal file
42
backend/scripts/backup.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""make backup: снять резервную копию базы.
|
||||||
|
|
||||||
|
Та же операция, что кнопка на АРМ администратора, — но вызываемая из cron:
|
||||||
|
ТЗ требует копирования не реже раза в сутки, а полагаться на то, что кто-то
|
||||||
|
нажмёт кнопку, нельзя.
|
||||||
|
|
||||||
|
make backup снять копию
|
||||||
|
0 3 * * * cd … && make backup в crontab демо-машины
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from app.admin.backup import BackupError, create, listing # noqa: E402
|
||||||
|
|
||||||
|
#: Сколько копий держать. Место на демо-машине не бесконечно, а копия за
|
||||||
|
#: позапрошлую неделю не нужна никому.
|
||||||
|
KEEP = 14
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
created = create()
|
||||||
|
except BackupError as exc:
|
||||||
|
print(f"копия не снята: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"{created['name']}: {created['size_bytes'] // 1024} КБ")
|
||||||
|
|
||||||
|
old = listing()[KEEP:]
|
||||||
|
for item in old:
|
||||||
|
(ROOT / "backups" / item["name"]).unlink(missing_ok=True)
|
||||||
|
if old:
|
||||||
|
print(f"удалено старых копий: {len(old)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -23,6 +23,22 @@ def _dev_auth():
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fresh_engine():
|
||||||
|
"""Движок базы — на тест, а не на процесс.
|
||||||
|
|
||||||
|
Пул соединений привязан к событийному циклу, в котором создан, а каждый
|
||||||
|
`TestClient` поднимает свой. Общий движок даёт «Future attached to
|
||||||
|
a different loop» на втором обращении к базе — тест падает не там, где
|
||||||
|
ошибка, и только в компании других тестов.
|
||||||
|
"""
|
||||||
|
from app.db import base
|
||||||
|
|
||||||
|
base.reset()
|
||||||
|
yield
|
||||||
|
base.reset()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def as_instructor(client):
|
def as_instructor(client):
|
||||||
"""Клиент, вошедший преподавателем. Cookie живёт в самом TestClient."""
|
"""Клиент, вошедший преподавателем. Cookie живёт в самом TestClient."""
|
||||||
|
|
|
||||||
205
backend/tests/test_admin.py
Normal file
205
backend/tests/test_admin.py
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
"""АРМ администратора: учётные записи, состояние стенда, аудит, копии.
|
||||||
|
|
||||||
|
Пункты приёмки lct-24. Главное здесь — не функции, а границы роли: ТЗ прямо
|
||||||
|
запрещает администратору вмешиваться в оценки, и проверяется именно это.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.auth import Principal
|
||||||
|
from app.domain.roles import Role
|
||||||
|
from app.main import app
|
||||||
|
from app.session.hub import hub
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
hub.journal = None
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def as_admin(client, monkeypatch):
|
||||||
|
"""Вход администратором. `dev-token` даёт преподавателя, поэтому роль
|
||||||
|
подменяется на уровне проверки: заводить запись в базе ради прав не нужно."""
|
||||||
|
client.post("/api/auth/dev-token")
|
||||||
|
|
||||||
|
import app.api.auth as auth_module
|
||||||
|
|
||||||
|
def admin_principal(request):
|
||||||
|
return Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_module, "current", admin_principal)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def as_instructor(client):
|
||||||
|
client.post("/api/auth/dev-token")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def db_alive(client) -> bool:
|
||||||
|
"""Часть точек без Postgres работать не может, и это не повод падать:
|
||||||
|
на машине разработчика база может быть не поднята."""
|
||||||
|
return client.get("/api/health").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── границы роли ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_instructor_cannot_open_admin(as_instructor):
|
||||||
|
"""Преподаватель не управляет учётными записями: ТЗ ограничивает его
|
||||||
|
в административных функциях прямо."""
|
||||||
|
assert as_instructor.get("/api/admin/users").status_code == 403
|
||||||
|
assert as_instructor.get("/api/admin/audit").status_code == 403
|
||||||
|
assert as_instructor.get("/api/admin/status").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_cannot_open_admin(client):
|
||||||
|
assert client.get("/api/admin/users").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_has_no_way_to_change_a_score():
|
||||||
|
"""Запрет выражен отсутствием точки, а не проверкой внутри неё: в модуле
|
||||||
|
администратора нет ни одного маршрута, трогающего оценки."""
|
||||||
|
from app.api.http import admin
|
||||||
|
|
||||||
|
paths = {route.path for route in admin.router.routes}
|
||||||
|
assert not any("report" in path or "score" in path for path in paths)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_write_to_the_audit_log():
|
||||||
|
"""ТЗ требует хранения журнала, а не управления им."""
|
||||||
|
from app.api.http import admin
|
||||||
|
|
||||||
|
methods = {
|
||||||
|
(route.path, method)
|
||||||
|
for route in admin.router.routes
|
||||||
|
for method in getattr(route, "methods", set())
|
||||||
|
}
|
||||||
|
assert not [
|
||||||
|
path for path, method in methods if "audit" in path and method in {"POST", "PATCH", "DELETE"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── учётные записи ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_creates_a_trainee_with_a_trainee_card(as_admin):
|
||||||
|
"""У обучающегося должна появиться карточка курсанта: на ней висят
|
||||||
|
профиль, история и проверка «это твой разбор» (lct-23)."""
|
||||||
|
if not db_alive(as_admin):
|
||||||
|
pytest.skip("нет базы")
|
||||||
|
|
||||||
|
login = f"курсант-{uuid.uuid4().hex[:8]}"
|
||||||
|
response = as_admin.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"login": login,
|
||||||
|
"full_name": "Петров Пётр",
|
||||||
|
"password": "длинный-пароль",
|
||||||
|
"role": "trainee",
|
||||||
|
"service": "ДДС района",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["role"] == "trainee"
|
||||||
|
assert body["service"] == "ДДС района"
|
||||||
|
|
||||||
|
listing = as_admin.get("/api/admin/users").json()
|
||||||
|
assert any(user["login"] == login for user in listing)
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_login_is_refused(as_admin):
|
||||||
|
if not db_alive(as_admin):
|
||||||
|
pytest.skip("нет базы")
|
||||||
|
|
||||||
|
login = f"двойник-{uuid.uuid4().hex[:8]}"
|
||||||
|
payload = {
|
||||||
|
"login": login, "full_name": "Первый", "password": "длинный-пароль", "role": "instructor",
|
||||||
|
}
|
||||||
|
assert as_admin.post("/api/admin/users", json=payload).status_code == 201
|
||||||
|
second = as_admin.post("/api/admin/users", json=payload)
|
||||||
|
assert second.status_code == 409
|
||||||
|
assert second.json()["detail"] == "login_taken"
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_password_is_refused(as_admin):
|
||||||
|
response = as_admin.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"login": "короткий", "full_name": "Кто-то", "password": "123", "role": "instructor"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_block_himself(as_admin):
|
||||||
|
"""Иначе стенд остаётся без администратора до похода в базу руками."""
|
||||||
|
if not db_alive(as_admin):
|
||||||
|
pytest.skip("нет базы")
|
||||||
|
|
||||||
|
created = as_admin.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"login": "admin", "full_name": "Администратор",
|
||||||
|
"password": "длинный-пароль", "role": "admin",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
user_id = created.json()["id"] if created.status_code == 201 else next(
|
||||||
|
user["id"] for user in as_admin.get("/api/admin/users").json() if user["login"] == "admin"
|
||||||
|
)
|
||||||
|
response = as_admin.patch(f"/api/admin/users/{user_id}", json={"blocked": True})
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["detail"] == "cannot_block_yourself"
|
||||||
|
|
||||||
|
|
||||||
|
# ── состояние стенда ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_names_every_component(as_admin):
|
||||||
|
if not db_alive(as_admin):
|
||||||
|
pytest.skip("нет базы")
|
||||||
|
|
||||||
|
names = {item["name"] for item in as_admin.get("/api/admin/status").json()}
|
||||||
|
assert {"База данных", "Модели речи", "Эмбеддинги", "Провайдер LLM",
|
||||||
|
"Классификатор ЕКП", "Секрет сессии"} <= names
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_session_secret_is_reported_as_a_problem(as_admin):
|
||||||
|
"""На стенде это дыра, и увидеть её должен администратор, а не проверяющий."""
|
||||||
|
if not db_alive(as_admin):
|
||||||
|
pytest.skip("нет базы")
|
||||||
|
|
||||||
|
secret = next(
|
||||||
|
item for item in as_admin.get("/api/admin/status").json() if item["name"] == "Секрет сессии"
|
||||||
|
)
|
||||||
|
assert secret["ok"] is False, "значение по умолчанию не помечено как проблема"
|
||||||
|
|
||||||
|
|
||||||
|
# ── резервные копии ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_listing_is_open_to_admin(as_admin):
|
||||||
|
assert as_admin.get("/api/admin/backups").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
|
||||||
|
"""Кнопка не должна молча ничего не делать: если снять копию нечем,
|
||||||
|
администратор видит, чего именно не хватает."""
|
||||||
|
from app.admin import backup as backup_service
|
||||||
|
|
||||||
|
def broken():
|
||||||
|
raise backup_service.BackupError("нет ни pg_dump, ни docker")
|
||||||
|
|
||||||
|
monkeypatch.setattr(backup_service, "create", broken)
|
||||||
|
response = as_admin.post("/api/admin/backups")
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert "pg_dump" in response.json()["detail"]
|
||||||
|
|
@ -3,6 +3,7 @@ import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||||
import { Call } from "@/pages/trainee/Call";
|
import { Call } from "@/pages/trainee/Call";
|
||||||
import { Instructor } from "@/pages/instructor/Instructor";
|
import { Instructor } from "@/pages/instructor/Instructor";
|
||||||
import { Dds } from "@/pages/dds/Dds";
|
import { Dds } from "@/pages/dds/Dds";
|
||||||
|
import { Admin } from "@/pages/admin/Admin";
|
||||||
import { Profile } from "@/pages/profile/Profile";
|
import { Profile } from "@/pages/profile/Profile";
|
||||||
import { Wall } from "@/pages/wall/Wall";
|
import { Wall } from "@/pages/wall/Wall";
|
||||||
import { RequireAuth } from "@/features/auth/Login";
|
import { RequireAuth } from "@/features/auth/Login";
|
||||||
|
|
@ -23,4 +24,5 @@ export const router = createBrowserRouter([
|
||||||
{ path: "/wall", element: guarded("/wall", <Wall />) },
|
{ path: "/wall", element: guarded("/wall", <Wall />) },
|
||||||
{ path: "/profile", element: guarded("/profile", <Profile />) },
|
{ path: "/profile", element: guarded("/profile", <Profile />) },
|
||||||
{ path: "/dds", element: guarded("/dds", <Dds />) },
|
{ path: "/dds", element: guarded("/dds", <Dds />) },
|
||||||
|
{ path: "/admin", element: guarded("/admin", <Admin />) },
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
211
frontend/src/pages/admin/Admin.tsx
Normal file
211
frontend/src/pages/admin/Admin.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
// АРМ администратора: учётные записи, состояние стенда, журнал аудита, копии.
|
||||||
|
//
|
||||||
|
// Того, чего администратору нельзя по ТЗ, здесь нет вовсе — ни правки оценок,
|
||||||
|
// ни вмешательства в занятие. Запрет выражен отсутствием кнопки, а не
|
||||||
|
// disabled-атрибутом (lct-24).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useLogout, useMe } from "@/features/auth/useAuth";
|
||||||
|
import type { Role } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useAudit, useBackups, useCreateBackup, useCreateUser, usePatchUser, useStatus, useUsers,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<Role, string> = {
|
||||||
|
admin: "Администратор",
|
||||||
|
instructor: "Преподаватель",
|
||||||
|
trainee: "Обучающийся",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Admin() {
|
||||||
|
const me = useMe();
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page">
|
||||||
|
<h1>АРМ администратора</h1>
|
||||||
|
<p>
|
||||||
|
{me.data?.full_name}{" "}
|
||||||
|
<button type="button" onClick={() => logout.mutate()}>Выйти</button>
|
||||||
|
</p>
|
||||||
|
<Status />
|
||||||
|
<Users />
|
||||||
|
<Backups />
|
||||||
|
<Audit />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Status() {
|
||||||
|
const status = useStatus();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Состояние стенда</h2>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{(status.data ?? []).map((item) => (
|
||||||
|
<tr key={item.name}>
|
||||||
|
<th>{item.name}</th>
|
||||||
|
<td className={item.ok ? "state-ok" : "state-violated"}>{item.detail}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Users() {
|
||||||
|
const users = useUsers();
|
||||||
|
const create = useCreateUser();
|
||||||
|
const patch = usePatchUser();
|
||||||
|
const [form, setForm] = useState({ login: "", full_name: "", password: "", role: "trainee" as Role, service: "" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Учётные записи</h2>
|
||||||
|
<table className="grid">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Логин</th><th>ФИО</th><th>Роль</th><th>Служба</th><th>Состояние</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(users.data ?? []).map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<th>{user.login}</th>
|
||||||
|
<td>{user.full_name}</td>
|
||||||
|
<td>{ROLE_LABELS[user.role]}</td>
|
||||||
|
<td>{user.service ?? "—"}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patch.mutate({ id: user.id, body: { blocked: !user.blocked } })}
|
||||||
|
>
|
||||||
|
{user.blocked ? "Разблокировать" : "Заблокировать"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Завести запись</h3>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
create.mutate(
|
||||||
|
{ ...form, service: form.service || null },
|
||||||
|
{ onSuccess: () => setForm({ ...form, login: "", full_name: "", password: "" }) },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Логин</th>
|
||||||
|
<td><input value={form.login} onChange={(e) => setForm({ ...form, login: e.target.value })} /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>ФИО</th>
|
||||||
|
<td><input value={form.full_name} onChange={(e) => setForm({ ...form, full_name: e.target.value })} /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Пароль</th>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
/>{" "}
|
||||||
|
не короче восьми знаков
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Роль</th>
|
||||||
|
<td>
|
||||||
|
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value as Role })}>
|
||||||
|
{Object.entries(ROLE_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/* Служба осмысленна только у обучающегося: по ней в его ленту
|
||||||
|
попадают профильные события (docs/spec/DATASET.md). */}
|
||||||
|
{form.role === "trainee" && (
|
||||||
|
<tr>
|
||||||
|
<th>Служба ДДС</th>
|
||||||
|
<td><input value={form.service} onChange={(e) => setForm({ ...form, service: e.target.value })} /></td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<button type="submit" disabled={!form.login || !form.full_name || form.password.length < 8}>
|
||||||
|
Завести
|
||||||
|
</button>
|
||||||
|
{create.isError && <span className="warn"> Логин уже занят</span>}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Backups() {
|
||||||
|
const backups = useBackups();
|
||||||
|
const create = useCreateBackup();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Резервные копии</h2>
|
||||||
|
<p>
|
||||||
|
<button type="button" disabled={create.isPending} onClick={() => create.mutate()}>
|
||||||
|
Снять копию сейчас
|
||||||
|
</button>
|
||||||
|
{create.isError && <span className="warn"> {String(create.error)}</span>}
|
||||||
|
</p>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{(backups.data ?? []).map((item) => (
|
||||||
|
<tr key={item.name}>
|
||||||
|
<th>{item.name}</th>
|
||||||
|
<td>{Math.round(item.size_bytes / 1024)} КБ</td>
|
||||||
|
<td>{new Date(item.at).toLocaleString("ru-RU")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{(backups.data ?? []).length === 0 && <p>Копий пока нет.</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Audit() {
|
||||||
|
const [action, setAction] = useState("");
|
||||||
|
const audit = useAudit(action);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Журнал действий</h2>
|
||||||
|
<p>
|
||||||
|
Фильтр по действию:{" "}
|
||||||
|
<input value={action} placeholder="lesson.start" onChange={(e) => setAction(e.target.value)} />
|
||||||
|
</p>
|
||||||
|
<table className="grid">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Когда</th><th>Кто</th><th>Действие</th><th>Над чем</th><th>Подробности</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(audit.data ?? []).map((row, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<th>{new Date(row.at).toLocaleString("ru-RU")}</th>
|
||||||
|
<td>{row.actor}</td>
|
||||||
|
<td>{row.action}</td>
|
||||||
|
<td>{row.object_id ?? "—"}</td>
|
||||||
|
<td>{row.detail}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
frontend/src/pages/admin/api.ts
Normal file
80
frontend/src/pages/admin/api.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
// Запросы АРМ администратора. Типы описаны руками, как и остальной HTTP:
|
||||||
|
// в generated.ts едут только события сокетов (docs/arch/CONTRACT.md).
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { Role } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
login: string;
|
||||||
|
full_name: string;
|
||||||
|
role: Role;
|
||||||
|
service: string | null;
|
||||||
|
blocked: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceState { name: string; ok: boolean; detail: string }
|
||||||
|
export interface AuditRow {
|
||||||
|
at: string; actor: string; role: string; action: string; object_id: string | null; detail: string;
|
||||||
|
}
|
||||||
|
export interface Backup { name: string; size_bytes: number; at: string }
|
||||||
|
|
||||||
|
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail ?? String(response.status));
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUsers = () =>
|
||||||
|
useQuery({ queryKey: ["admin-users"], queryFn: () => call<AdminUser[]>("/api/admin/users") });
|
||||||
|
|
||||||
|
export const useStatus = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["admin-status"],
|
||||||
|
queryFn: () => call<ServiceState[]>("/api/admin/status"),
|
||||||
|
// Состояние стенда смотрят перед занятием и во время: пусть обновляется само.
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useAudit = (action: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["admin-audit", action],
|
||||||
|
queryFn: () => call<AuditRow[]>(`/api/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ""}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useBackups = () =>
|
||||||
|
useQuery({ queryKey: ["admin-backups"], queryFn: () => call<Backup[]>("/api/admin/backups") });
|
||||||
|
|
||||||
|
export function useCreateUser() {
|
||||||
|
const cache = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: Omit<AdminUser, "id" | "blocked" | "created_at"> & { password: string }) =>
|
||||||
|
call<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
onSuccess: () => cache.invalidateQueries({ queryKey: ["admin-users"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchUser() {
|
||||||
|
const cache = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||||||
|
call<AdminUser>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
onSuccess: () => cache.invalidateQueries({ queryKey: ["admin-users"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateBackup() {
|
||||||
|
const cache = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => call<Backup>("/api/admin/backups", { method: "POST" }),
|
||||||
|
onSuccess: () => cache.invalidateQueries({ queryKey: ["admin-backups"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue