feat: АРМ администратора — записи, состояние стенда, аудит, копии (lct-24)

Целый раздел ТЗ, от которого не было ни одной функции.

- Учётные записи: завести, сменить роль и службу, заблокировать, сбросить
  пароль. Обучающемуся заводится и карточка курсанта — на ней висят профиль
  и проверка «это твой разбор».
- Состояние компонентов: база, модели речи, эмбеддинги, провайдер LLM, версия
  классификатора, число живых занятий. Отдельной строкой — секрет сессии:
  значение по умолчанию не ошибка запуска, но на стенде это дыра, и увидеть
  её должен администратор, а не проверяющий на защите.
- Журнал действий с фильтром, только на чтение.
- Резервные копии: кнопка и make backup для cron. pg_dump берётся локальный,
  а если его нет — из контейнера базы; если нет ни того ни другого,
  администратор видит, чего не хватает, а кнопка не молчит.

Границы роли выражены отсутствием маршрутов, а не проверками внутри них:
в разделе нет ни одной точки, трогающей оценки или пишущей в аудит. Два теста
проверяют это перебором маршрутов роутера — так запрет нельзя ослабить,
случайно добавив обработчик.

Найдено по ходу: тесты с базой падали в компании и проходили поодиночке.
Движок SQLAlchemy кэшировался на процесс, а каждый TestClient поднимает свой
событийный цикл — пул привязан к первому. Появился db.base.reset() и фикстура,
дающая движок на тест.

193 теста зелёных (12 новых).
This commit is contained in:
Ivan Gerasimov 2026-09-20 09:11:12 +03:00
commit 641e505c67
13 changed files with 948 additions and 1 deletions

3
.gitignore vendored
View file

@ -5,6 +5,9 @@
# Веса моделей (тянутся make models) # Веса моделей (тянутся make models)
backend/models/ backend/models/
# Резервные копии базы (make backup)
backend/backups/
# Python # Python
__pycache__/ __pycache__/
*.pyc *.pyc

View file

@ -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

View file

View 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),
}

View 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)

View file

@ -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:

View file

@ -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
View 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())

View file

@ -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
View 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"]

View file

@ -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 />) },
]); ]);

View 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>
</>
);
}

View 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"] }),
});
}