From 641e505c67387bb004a222405185ded59dbf3951 Mon Sep 17 00:00:00 2001 From: Ivan Gerasimov Date: Sun, 20 Sep 2026 09:11:12 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=90=D0=A0=D0=9C=20=D0=B0=D0=B4=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20=E2=80=94=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8,=20?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B4=D0=B0,=20=D0=B0=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D1=82,=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8=20(lct-24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Целый раздел ТЗ, от которого не было ни одной функции. - Учётные записи: завести, сменить роль и службу, заблокировать, сбросить пароль. Обучающемуся заводится и карточка курсанта — на ней висят профиль и проверка «это твой разбор». - Состояние компонентов: база, модели речи, эмбеддинги, провайдер LLM, версия классификатора, число живых занятий. Отдельной строкой — секрет сессии: значение по умолчанию не ошибка запуска, но на стенде это дыра, и увидеть её должен администратор, а не проверяющий на защите. - Журнал действий с фильтром, только на чтение. - Резервные копии: кнопка и make backup для cron. pg_dump берётся локальный, а если его нет — из контейнера базы; если нет ни того ни другого, администратор видит, чего не хватает, а кнопка не молчит. Границы роли выражены отсутствием маршрутов, а не проверками внутри них: в разделе нет ни одной точки, трогающей оценки или пишущей в аудит. Два теста проверяют это перебором маршрутов роутера — так запрет нельзя ослабить, случайно добавив обработчик. Найдено по ходу: тесты с базой падали в компании и проходили поодиночке. Движок SQLAlchemy кэшировался на процесс, а каждый TestClient поднимает свой событийный цикл — пул привязан к первому. Появился db.base.reset() и фикстура, дающая движок на тест. 193 теста зелёных (12 новых). --- .gitignore | 3 + Makefile | 5 +- backend/app/admin/__init__.py | 0 backend/app/admin/backup.py | 93 ++++++++++ backend/app/api/http/admin.py | 277 +++++++++++++++++++++++++++++ backend/app/db/base.py | 13 ++ backend/app/main.py | 2 + backend/scripts/backup.py | 42 +++++ backend/tests/conftest.py | 16 ++ backend/tests/test_admin.py | 205 +++++++++++++++++++++ frontend/src/app/router.tsx | 2 + frontend/src/pages/admin/Admin.tsx | 211 ++++++++++++++++++++++ frontend/src/pages/admin/api.ts | 80 +++++++++ 13 files changed, 948 insertions(+), 1 deletion(-) create mode 100644 backend/app/admin/__init__.py create mode 100644 backend/app/admin/backup.py create mode 100644 backend/app/api/http/admin.py create mode 100644 backend/scripts/backup.py create mode 100644 backend/tests/test_admin.py create mode 100644 frontend/src/pages/admin/Admin.tsx create mode 100644 frontend/src/pages/admin/api.ts diff --git a/.gitignore b/.gitignore index 0578d15..919f7a7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ # Веса моделей (тянутся make models) backend/models/ +# Резервные копии базы (make backup) +backend/backups/ + # Python __pycache__/ *.pyc diff --git a/Makefile b/Makefile index d762462..52e298a 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,9 @@ typecheck: ## Проверить фронтенд компилятором models: ## Скачать модели в backend/models/: эмбеддинги, GigaAM, Silero VAD; Silero TTS — вручную 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 cd backend && $(UV) run python scripts/users.py @@ -74,4 +77,4 @@ pregen: ## Построить таблицу реплик звонящего д demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт ./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 diff --git a/backend/app/admin/__init__.py b/backend/app/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/admin/backup.py b/backend/app/admin/backup.py new file mode 100644 index 0000000..c1c5c8f --- /dev/null +++ b/backend/app/admin/backup.py @@ -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), + } diff --git a/backend/app/api/http/admin.py b/backend/app/api/http/admin.py new file mode 100644 index 0000000..069185f --- /dev/null +++ b/backend/app/api/http/admin.py @@ -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) diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 96d8264..6c5d3ad 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -30,6 +30,19 @@ def get_sessionmaker() -> async_sessionmaker[AsyncSession]: 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]: """Зависимость FastAPI: сессия на запрос, коммит явный.""" async with get_sessionmaker()() as session: diff --git a/backend/app/main.py b/backend/app/main.py index 4edb3d4..74971d9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from starlette.middleware.sessions import SessionMiddleware from pathlib import Path 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 scenarios as scenarios_api from app.api.http import sessions @@ -76,6 +77,7 @@ app.include_router(auth.router) app.include_router(sessions.router) app.include_router(scenarios_api.router) app.include_router(ekp_api.router) +app.include_router(admin_api.router) app.include_router(trainees.router) app.include_router(call_ws.router) app.include_router(observe_ws.router) diff --git a/backend/scripts/backup.py b/backend/scripts/backup.py new file mode 100644 index 0000000..6ec7511 --- /dev/null +++ b/backend/scripts/backup.py @@ -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()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5029097..fd7c85b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,6 +23,22 @@ def _dev_auth(): 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 def as_instructor(client): """Клиент, вошедший преподавателем. Cookie живёт в самом TestClient.""" diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 0000000..2116953 --- /dev/null +++ b/backend/tests/test_admin.py @@ -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"] diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index a26e5ba..207e952 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -3,6 +3,7 @@ import { createBrowserRouter, Navigate } from "react-router-dom"; import { Call } from "@/pages/trainee/Call"; import { Instructor } from "@/pages/instructor/Instructor"; import { Dds } from "@/pages/dds/Dds"; +import { Admin } from "@/pages/admin/Admin"; import { Profile } from "@/pages/profile/Profile"; import { Wall } from "@/pages/wall/Wall"; import { RequireAuth } from "@/features/auth/Login"; @@ -23,4 +24,5 @@ export const router = createBrowserRouter([ { path: "/wall", element: guarded("/wall", ) }, { path: "/profile", element: guarded("/profile", ) }, { path: "/dds", element: guarded("/dds", ) }, + { path: "/admin", element: guarded("/admin", ) }, ]); diff --git a/frontend/src/pages/admin/Admin.tsx b/frontend/src/pages/admin/Admin.tsx new file mode 100644 index 0000000..9d6ad69 --- /dev/null +++ b/frontend/src/pages/admin/Admin.tsx @@ -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 = { + admin: "Администратор", + instructor: "Преподаватель", + trainee: "Обучающийся", +}; + +export function Admin() { + const me = useMe(); + const logout = useLogout(); + + return ( +
+

АРМ администратора

+

+ {me.data?.full_name}{" "} + +

+ + + + +
+ ); +} + +function Status() { + const status = useStatus(); + return ( + <> +

Состояние стенда

+ + + {(status.data ?? []).map((item) => ( + + + + + ))} + +
{item.name}{item.detail}
+ + ); +} + +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 ( + <> +

Учётные записи

+ + + + + + {(users.data ?? []).map((user) => ( + + + + + + + + ))} + +
ЛогинФИОРольСлужбаСостояние
{user.login}{user.full_name}{ROLE_LABELS[user.role]}{user.service ?? "—"} + +
+ +

Завести запись

+
{ + event.preventDefault(); + create.mutate( + { ...form, service: form.service || null }, + { onSuccess: () => setForm({ ...form, login: "", full_name: "", password: "" }) }, + ); + }} + > + + + + + + + + + + + + + + + + + + + {/* Служба осмысленна только у обучающегося: по ней в его ленту + попадают профильные события (docs/spec/DATASET.md). */} + {form.role === "trainee" && ( + + + + + )} + +
Логин setForm({ ...form, login: e.target.value })} />
ФИО setForm({ ...form, full_name: e.target.value })} />
Пароль + setForm({ ...form, password: e.target.value })} + />{" "} + не короче восьми знаков +
Роль + +
Служба ДДС setForm({ ...form, service: e.target.value })} />
+

+ + {create.isError && Логин уже занят} +

+
+ + ); +} + +function Backups() { + const backups = useBackups(); + const create = useCreateBackup(); + return ( + <> +

Резервные копии

+

+ + {create.isError && {String(create.error)}} +

+ + + {(backups.data ?? []).map((item) => ( + + + + + + ))} + +
{item.name}{Math.round(item.size_bytes / 1024)} КБ{new Date(item.at).toLocaleString("ru-RU")}
+ {(backups.data ?? []).length === 0 &&

Копий пока нет.

} + + ); +} + +function Audit() { + const [action, setAction] = useState(""); + const audit = useAudit(action); + return ( + <> +

Журнал действий

+

+ Фильтр по действию:{" "} + setAction(e.target.value)} /> +

+ + + + + + {(audit.data ?? []).map((row, index) => ( + + + + + + + + ))} + +
КогдаКтоДействиеНад чемПодробности
{new Date(row.at).toLocaleString("ru-RU")}{row.actor}{row.action}{row.object_id ?? "—"}{row.detail}
+ + ); +} diff --git a/frontend/src/pages/admin/api.ts b/frontend/src/pages/admin/api.ts new file mode 100644 index 0000000..dcab8f0 --- /dev/null +++ b/frontend/src/pages/admin/api.ts @@ -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(path: string, init?: RequestInit): Promise { + 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; +} + +export const useUsers = () => + useQuery({ queryKey: ["admin-users"], queryFn: () => call("/api/admin/users") }); + +export const useStatus = () => + useQuery({ + queryKey: ["admin-status"], + queryFn: () => call("/api/admin/status"), + // Состояние стенда смотрят перед занятием и во время: пусть обновляется само. + refetchInterval: 10_000, + }); + +export const useAudit = (action: string) => + useQuery({ + queryKey: ["admin-audit", action], + queryFn: () => call(`/api/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ""}`), + }); + +export const useBackups = () => + useQuery({ queryKey: ["admin-backups"], queryFn: () => call("/api/admin/backups") }); + +export function useCreateUser() { + const cache = useQueryClient(); + return useMutation({ + mutationFn: (body: Omit & { password: string }) => + call("/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 }) => + call(`/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("/api/admin/backups", { method: "POST" }), + onSuccess: () => cache.invalidateQueries({ queryKey: ["admin-backups"] }), + }); +}