feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались ссылкой с номером занятия, и пускало знание адреса. - Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная cookie; роль на сокетах читается из той же cookie в момент рукопожатия, отдельного протокола авторизации в канале нет. - Разграничение: control — преподавателю, observe — преподавателю и админу, call и station — обучающемуся и преподавателю. Отказ приходит событием error с кодом forbidden. - Обучающийся не видит чужого: история подменяет фильтр на его собственный идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ к чужим результатам, а не только к чужим экранам. - Администратору закрыта правка оценок — ТЗ запрещает это прямо. - make users заводит по записи на роль и печатает случайные пароли один раз: зашитый в репозиторий admin/admin пережил бы сдачу. - Экран входа и проверка роли на каждом маршруте фронта. Наши инструменты не сломались: make lesson и тесты входят через dev-token за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной функции не должно быть видно вовсе. У тестов появился conftest.py. Role уехала в домен и в generated.ts через EventCatalog.principal: иначе фронт переписывал бы список ролей руками. 181 тест зелёный (14 новых), make typecheck чистый.
This commit is contained in:
parent
4c2bbfd021
commit
d55c8bc8cd
30 changed files with 984 additions and 21 deletions
168
backend/app/api/auth.py
Normal file
168
backend/app/api/auth.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Вход, роли и аудит.
|
||||
|
||||
ТЗ называет три роли поимённо — администратор, преподаватель, обучающийся —
|
||||
и требует разграничения доступа, аудита действий и хранения журналов
|
||||
безопасности (docs/spec/GAP.md#1-аутентификации-нет-вообще). До этой карточки
|
||||
входа не было вовсе: экраны открывались ссылкой с номером занятия.
|
||||
|
||||
Решения, принятые заранее и не переоткрываемые (tasks/lct-23-auth-roles.md):
|
||||
|
||||
* пароль — `argon2`, не bcrypt;
|
||||
* сессия — подписанная cookie через `SessionMiddleware`, не JWT и не таблица
|
||||
сессий: состояние занятия и так живёт в памяти одного воркера, лишний поход
|
||||
в базу на каждый запрос не нужен;
|
||||
* на сокетах роль читается из cookie в момент рукопожатия — отдельного
|
||||
протокола авторизации в канале нет.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.db.models import AuditLog, User
|
||||
from app.domain.roles import Role
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password_hash: str, password: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
except Exception: # noqa: BLE001 — битый хеш не должен пускать в систему
|
||||
log.exception("проверка пароля не удалась")
|
||||
return False
|
||||
|
||||
|
||||
class Principal(BaseModel):
|
||||
"""Кто действует. Хранится в cookie целиком: ходить в базу за ролью
|
||||
на каждый запрос незачем, а меняется она правкой учётной записи."""
|
||||
|
||||
login: str
|
||||
full_name: str
|
||||
role: Role
|
||||
service: str | None = None
|
||||
trainee_id: UUID | None = None
|
||||
|
||||
@property
|
||||
def is_staff(self) -> bool:
|
||||
return self.role in (Role.ADMIN, Role.INSTRUCTOR)
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
login: str
|
||||
password: str
|
||||
|
||||
|
||||
def current(request: Request) -> Principal | None:
|
||||
data = request.session.get("principal")
|
||||
return Principal.model_validate(data) if data else None
|
||||
|
||||
|
||||
def principal_of(websocket: WebSocket) -> Principal | None:
|
||||
"""Роль на сокете — из той же cookie, что и на HTTP.
|
||||
|
||||
`WebSocket.session` доступен, потому что `SessionMiddleware` стоит до
|
||||
роутера: значит роль известна до входа в цикл приёма сообщений.
|
||||
"""
|
||||
data = websocket.session.get("principal") if "session" in websocket.scope else None
|
||||
return Principal.model_validate(data) if data else None
|
||||
|
||||
|
||||
def require(request: Request, *roles: Role) -> Principal:
|
||||
"""Принципал нужной роли или отказ. Единственная точка проверки на HTTP."""
|
||||
who = current(request)
|
||||
if who is None:
|
||||
raise HTTPException(status_code=401, detail="not_authenticated")
|
||||
if roles and who.role not in roles:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return who
|
||||
|
||||
|
||||
async def audit(
|
||||
actor: str, role: str, action: str, object_id: str | None = None, detail: str = ""
|
||||
) -> None:
|
||||
"""Запись в журнал. Аудит не должен ронять действие: если база недоступна,
|
||||
занятие продолжается, а пропуск виден в логе."""
|
||||
try:
|
||||
async with get_sessionmaker()() as db:
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor=actor, role=role, action=action, object_id=object_id, detail=detail[:2000]
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("аудит: запись %s не удалась", action)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(payload: LoginIn, request: Request) -> dict:
|
||||
async with get_sessionmaker()() as db:
|
||||
user = await db.scalar(select(User).where(User.login == payload.login))
|
||||
|
||||
# Одинаковый ответ на неизвестный логин и неверный пароль: иначе форма
|
||||
# входа превращается в список действующих учётных записей.
|
||||
if user is None or not verify_password(user.password_hash, payload.password):
|
||||
raise HTTPException(status_code=401, detail="bad_credentials")
|
||||
if user.blocked:
|
||||
raise HTTPException(status_code=403, detail="blocked")
|
||||
|
||||
who = Principal(
|
||||
login=user.login,
|
||||
full_name=user.full_name,
|
||||
role=Role(user.role),
|
||||
service=user.service,
|
||||
trainee_id=user.trainee_id,
|
||||
)
|
||||
request.session["principal"] = who.model_dump(mode="json")
|
||||
await audit(who.login, who.role.value, "login")
|
||||
return who.model_dump(mode="json")
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request) -> dict:
|
||||
who = current(request)
|
||||
request.session.clear()
|
||||
if who:
|
||||
await audit(who.login, who.role.value, "logout")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(request: Request) -> dict:
|
||||
who = current(request)
|
||||
if who is None:
|
||||
raise HTTPException(status_code=401, detail="not_authenticated")
|
||||
return who.model_dump(mode="json")
|
||||
|
||||
|
||||
@router.post("/dev-token")
|
||||
async def dev_token(request: Request) -> dict:
|
||||
"""Вход без пароля для наших же инструментов.
|
||||
|
||||
`make lesson` и тесты открывают сокеты напрямую и после включения ролей
|
||||
сломались бы все разом. Точка живёт за флагом `dev_auth_bypass`, по
|
||||
умолчанию выключенным: в рабочем стенде она отвечает 404, а не 401 —
|
||||
выключенной функции не должно быть видно вовсе.
|
||||
"""
|
||||
if not get_settings().dev_auth_bypass:
|
||||
raise HTTPException(status_code=404, detail="not_found")
|
||||
who = Principal(login="dev", full_name="Разработка", role=Role.INSTRUCTOR)
|
||||
request.session["principal"] = who.model_dump(mode="json")
|
||||
return who.model_dump(mode="json")
|
||||
|
|
@ -7,15 +7,17 @@
|
|||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
from app.db import repo
|
||||
from app.db.base import get_session
|
||||
from app.domain.events import SessionMode, SessionReport
|
||||
from app.scenarios import store
|
||||
from app.scoring.report import build as build_report
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
|
||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||
|
|
@ -123,22 +125,29 @@ def _live(session_id: UUID):
|
|||
|
||||
|
||||
@router.get("/{session_id}/report", response_model=SessionReport)
|
||||
async def report(session_id: UUID) -> SessionReport:
|
||||
async def report(session_id: UUID, request: Request) -> SessionReport:
|
||||
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
|
||||
|
||||
Оценка курсанту открывается событием `score.ready` после самооценки; здесь
|
||||
прав нет — в прототипе нет входа, и точка доступна всем, у кого есть номер
|
||||
занятия (docs/arch/CONTRACT.md).
|
||||
Обучающийся открывает только свой разбор: ТЗ запрещает доступ к чужим
|
||||
результатам, а не только к чужим экранам. Проверка по `trainee_id`
|
||||
занятия, а не по номеру в ссылке.
|
||||
"""
|
||||
who = require(request)
|
||||
state, scenario = _live(session_id)
|
||||
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
|
||||
raise HTTPException(status_code=403, detail="not_your_session")
|
||||
if state.score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
return build_report(session_id, state, scenario)
|
||||
|
||||
|
||||
@router.patch("/{session_id}/report", response_model=SessionReport)
|
||||
async def override(session_id: UUID, body: ScoreOverride) -> SessionReport:
|
||||
"""Тренажёр готовит материал, преподаватель имеет последнее слово."""
|
||||
async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport:
|
||||
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
|
||||
|
||||
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
|
||||
"""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
state, scenario = _live(session_id)
|
||||
if state.score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
|
|
@ -148,11 +157,16 @@ async def override(session_id: UUID, body: ScoreOverride) -> SessionReport:
|
|||
"overridden_by": body.author,
|
||||
"override_comment": body.comment,
|
||||
}
|
||||
await audit(
|
||||
who.login, who.role.value, "score.override", str(session_id),
|
||||
f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}",
|
||||
)
|
||||
return build_report(session_id, state, scenario)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SessionOut])
|
||||
async def listing(
|
||||
request: Request,
|
||||
trainee: UUID | None = None,
|
||||
group: UUID | None = None,
|
||||
mode: SessionMode | None = None,
|
||||
|
|
@ -160,6 +174,10 @@ async def listing(
|
|||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> list[SessionOut]:
|
||||
who = require(request)
|
||||
# Обучающийся видит только свою историю, что бы он ни передал в фильтре.
|
||||
if who.role is Role.TRAINEE:
|
||||
trainee = who.trainee_id
|
||||
rows = await repo.history(
|
||||
db,
|
||||
trainee_id=trainee,
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@
|
|||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import require
|
||||
from app.domain.roles import Role
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, Score, Session, Trainee
|
||||
|
||||
|
|
@ -59,7 +61,10 @@ class ProfileOut(BaseModel):
|
|||
|
||||
|
||||
@router.get("", response_model=list[TraineeOut])
|
||||
async def listing(db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
|
||||
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
|
||||
"""Список курсантов — преподавателю и администратору: обучающемуся он
|
||||
не нужен, а чужие фамилии из него видны."""
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
rows = await db.execute(
|
||||
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
|
||||
)
|
||||
|
|
@ -67,7 +72,12 @@ async def listing(db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
|
|||
|
||||
|
||||
@router.get("/{trainee_id}/profile", response_model=ProfileOut)
|
||||
async def profile(trainee_id: UUID, db: AsyncSession = Depends(get_session)) -> ProfileOut:
|
||||
async def profile(
|
||||
trainee_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> ProfileOut:
|
||||
who = require(request)
|
||||
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
|
||||
raise HTTPException(status_code=403, detail="not_your_profile")
|
||||
trainee = await db.get(Trainee, trainee_id)
|
||||
if trainee is None:
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found")
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ from app.domain.events import (
|
|||
from app.domain.events import BgStart
|
||||
from app.scenarios import store
|
||||
from app.session.finish import finish, release_score
|
||||
from app.api.auth import principal_of
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.state import now_utc
|
||||
from app.voice.models import get_voice_models
|
||||
|
|
@ -198,10 +200,24 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
|||
await ws.send_text(item.model_dump_json())
|
||||
|
||||
|
||||
async def _reject(ws: WebSocket, message: str) -> None:
|
||||
"""Отказ до входа в цикл: сокет закрывается с объяснением, а не молча."""
|
||||
await ws.send_text(
|
||||
ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json()
|
||||
)
|
||||
await ws.close()
|
||||
|
||||
|
||||
@router.websocket("/ws/call/{session_id}")
|
||||
async def call(ws: WebSocket, session_id: UUID) -> None:
|
||||
await ws.accept()
|
||||
|
||||
# АРМ курсанта. Преподаватель допущен, чтобы показать приём вызова группе.
|
||||
who = principal_of(ws)
|
||||
if who is None or who.role not in (Role.TRAINEE, Role.INSTRUCTOR):
|
||||
await _reject(ws, "Недостаточно прав для этого экрана")
|
||||
return
|
||||
|
||||
state = hub.get(session_id)
|
||||
if state is None:
|
||||
await ws.send_text(
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ from app.dialog.persona import PersonaState
|
|||
from app.dialog.runtime import get_embedder
|
||||
from app.dialog.slots import SlotMachine
|
||||
from app.scenarios import store
|
||||
from app.api.auth import audit, principal_of
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.voice.models import get_voice_models
|
||||
|
|
@ -49,7 +51,7 @@ router = APIRouter()
|
|||
_adapter = TypeAdapter(InstructorToServer)
|
||||
|
||||
|
||||
async def _start(session_id: UUID, event) -> None:
|
||||
async def _start(session_id: UUID, event, who=None) -> None:
|
||||
scenario = store.get(event.scenario_id)
|
||||
if scenario is None:
|
||||
hub.to_observers(
|
||||
|
|
@ -76,6 +78,7 @@ async def _start(session_id: UUID, event) -> None:
|
|||
scenario=scenario.model_copy(deep=True),
|
||||
required_fields=scenario.required_fields,
|
||||
trainee_name=event.trainee,
|
||||
trainee_id=getattr(event, "trainee_id", None),
|
||||
attempt=attempt,
|
||||
)
|
||||
embedder = get_embedder()
|
||||
|
|
@ -92,6 +95,13 @@ async def _start(session_id: UUID, event) -> None:
|
|||
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
|
||||
state.on_event("call.incoming")
|
||||
hub.start_ticker(session_id)
|
||||
if who is not None:
|
||||
# Запуск занятия меняет чужой результат — значит попадает в журнал
|
||||
# аудита (ТЗ, хранение не менее шести месяцев).
|
||||
asyncio.create_task(
|
||||
audit(who.login, who.role.value, "lesson.start", str(session_id),
|
||||
f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}")
|
||||
)
|
||||
|
||||
hub.to_trainee(
|
||||
session_id,
|
||||
|
|
@ -120,9 +130,23 @@ async def _stop(session_id: UUID) -> None:
|
|||
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
|
||||
|
||||
|
||||
async def _reject(ws: WebSocket, message: str) -> None:
|
||||
"""Отказ до входа в цикл: сокет закрывается с объяснением, а не молча."""
|
||||
await ws.send_text(
|
||||
ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json()
|
||||
)
|
||||
await ws.close()
|
||||
|
||||
|
||||
@router.websocket("/ws/control/{session_id}")
|
||||
async def control(ws: WebSocket, session_id: UUID) -> None:
|
||||
await ws.accept()
|
||||
|
||||
# Пульт преподавателя: управление занятием доступно только ему.
|
||||
who = principal_of(ws)
|
||||
if who is None or who.role not in (Role.INSTRUCTOR,):
|
||||
await _reject(ws, "Недостаточно прав для этого экрана")
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
payload = await ws.receive_json()
|
||||
|
|
@ -137,7 +161,7 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
|
||||
match event.type:
|
||||
case "scenario.start":
|
||||
await _start(session_id, event)
|
||||
await _start(session_id, event, who)
|
||||
case "session.stop":
|
||||
await _stop(session_id)
|
||||
case "instructor_note.add":
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ from uuid import UUID
|
|||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.domain.events import ErrorEvent, ErrorKind
|
||||
from app.api.auth import principal_of
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -36,10 +38,24 @@ async def _wait_for_disconnect(ws: WebSocket) -> None:
|
|||
return
|
||||
|
||||
|
||||
async def _reject(ws: WebSocket, message: str) -> None:
|
||||
"""Отказ до входа в цикл: сокет закрывается с объяснением, а не молча."""
|
||||
await ws.send_text(
|
||||
ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json()
|
||||
)
|
||||
await ws.close()
|
||||
|
||||
|
||||
@router.websocket("/ws/observe/{session_id}")
|
||||
async def observe(ws: WebSocket, session_id: UUID) -> None:
|
||||
await ws.accept()
|
||||
|
||||
# Наблюдение за чужим занятием — не для обучающегося.
|
||||
who = principal_of(ws)
|
||||
if who is None or who.role not in (Role.INSTRUCTOR, Role.ADMIN):
|
||||
await _reject(ws, "Недостаточно прав для этого экрана")
|
||||
return
|
||||
|
||||
state = hub.get(session_id)
|
||||
if state is None:
|
||||
await ws.send_text(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ from pydantic import TypeAdapter, ValidationError
|
|||
|
||||
from app.domain.events import ErrorEvent, ErrorKind, StationState, StationToServer
|
||||
from app.domain.statuses import PRIMARY, ServiceStatus, StatusError
|
||||
from app.api.auth import principal_of
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.state import now_utc
|
||||
|
||||
|
|
@ -78,10 +80,24 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
|||
await ws.send_text(event.model_dump_json())
|
||||
|
||||
|
||||
async def _reject(ws: WebSocket, message: str) -> None:
|
||||
"""Отказ до входа в цикл: сокет закрывается с объяснением, а не молча."""
|
||||
await ws.send_text(
|
||||
ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json()
|
||||
)
|
||||
await ws.close()
|
||||
|
||||
|
||||
@router.websocket("/ws/station/{session_id}")
|
||||
async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
||||
await ws.accept()
|
||||
|
||||
# За АРМ ДДС садится обучающийся, преподаватель смотрит и подменяет.
|
||||
who = principal_of(ws)
|
||||
if who is None or who.role not in (Role.INSTRUCTOR, Role.TRAINEE):
|
||||
await _reject(ws, "Недостаточно прав для этого экрана")
|
||||
return
|
||||
|
||||
state = hub.get(session_id)
|
||||
if state is None:
|
||||
await ws.send_text(
|
||||
|
|
|
|||
|
|
@ -42,6 +42,13 @@ class Settings(BaseSettings):
|
|||
)
|
||||
judge_temperature: float = 0.0
|
||||
|
||||
# Вход и роли (lct-23). `session_secret` обязателен на рабочем стенде:
|
||||
# значение по умолчанию годится только для разработки, и администратор
|
||||
# видит в состоянии сервисов, что оно не заменено.
|
||||
session_secret: str = "dev-secret-поменять-на-стенде"
|
||||
#: Вход без пароля для `make lesson` и тестов. На стенде выключен.
|
||||
dev_auth_bypass: bool = False
|
||||
|
||||
# Нормативы: переопределяют значения по умолчанию из domain/timers.py
|
||||
timer_limits_ms: dict[TimerCode, int] = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
"""вход и роли: users, audit_log
|
||||
|
||||
Revision ID: e6e2ab171dd7
|
||||
Revises: ff5de7dfe4ce
|
||||
Create Date: 2026-09-20 08:47:33.911250
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'e6e2ab171dd7'
|
||||
down_revision = 'ff5de7dfe4ce'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('audit_log',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('actor', sa.String(length=80), nullable=False),
|
||||
sa.Column('role', sa.String(length=16), nullable=False),
|
||||
sa.Column('action', sa.String(length=40), nullable=False),
|
||||
sa.Column('object_id', sa.String(length=80), nullable=True),
|
||||
sa.Column('detail', sa.Text(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_audit_at', 'audit_log', ['at'], unique=False)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('login', sa.String(length=80), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=200), nullable=False),
|
||||
sa.Column('full_name', sa.String(length=120), nullable=False),
|
||||
sa.Column('role', sa.String(length=16), nullable=False),
|
||||
sa.Column('service', sa.String(length=120), nullable=True),
|
||||
sa.Column('trainee_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('blocked', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['trainee_id'], ['trainees.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('login')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('users')
|
||||
op.drop_index('ix_audit_at', table_name='audit_log')
|
||||
op.drop_table('audit_log')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -195,6 +195,49 @@ class Score(Base):
|
|||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Учётная запись. Три роли из ТЗ: администратор, преподаватель, обучающийся.
|
||||
|
||||
`service` заполняется только у обучающегося и означает ДДС, в которой он
|
||||
работает: ТЗ требует, чтобы «в ленту попадали только профильные события»,
|
||||
а профильность определяется списком оповещения по ЕКП, больше ничем
|
||||
(docs/spec/DATASET.md). У преподавателя и администратора поле пустое.
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
login: Mapped[str] = mapped_column(String(80), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(200))
|
||||
full_name: Mapped[str] = mapped_column(String(120))
|
||||
role: Mapped[str] = mapped_column(String(16))
|
||||
service: Mapped[str | None] = mapped_column(String(120))
|
||||
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
|
||||
blocked: Mapped[bool] = mapped_column(default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Журнал действий. ТЗ требует хранения не менее шести месяцев, поэтому
|
||||
записи не удаляются вместе с сессией: `object_id` — строка, а не ссылка.
|
||||
|
||||
Пишется то, что меняет чужой результат или состав системы: запуск занятия,
|
||||
оценка, коррекция оценки, правка сценария, вход и выход.
|
||||
"""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
actor: Mapped[str] = mapped_column(String(80))
|
||||
role: Mapped[str] = mapped_column(String(16))
|
||||
action: Mapped[str] = mapped_column(String(40))
|
||||
object_id: Mapped[str | None] = mapped_column(String(80))
|
||||
detail: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
__table_args__ = (Index("ix_audit_at", "at"),)
|
||||
|
||||
|
||||
class LlmCache(Base):
|
||||
"""Кэш ответов LLM по хешу контекста. Работает и онлайн — экономия
|
||||
на повторах, — и как накопитель материала для офлайн-дерева."""
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
|
|||
|
||||
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
|
||||
from app.domain.kio import KIO, Coords
|
||||
from app.domain.roles import Role
|
||||
from app.domain.statuses import ServiceStatus, StationSnapshot
|
||||
from app.domain.taxonomy import Finding
|
||||
from app.domain.timers import TimerSnapshot
|
||||
|
|
@ -79,6 +80,9 @@ class ErrorKind(StrEnum):
|
|||
DIRECTIVE_NEEDS_NETWORK = "directive_needs_network"
|
||||
SCENARIO_INVALID = "scenario_invalid"
|
||||
UNSUPPORTED_EVENT = "unsupported_event"
|
||||
#: Канал открыт не тем, кому он предназначен (lct-23). Фронт по этому коду
|
||||
#: отправляет на вход, а не показывает «занятие не найдено».
|
||||
FORBIDDEN = "forbidden"
|
||||
INTERNAL = "internal"
|
||||
|
||||
|
||||
|
|
@ -585,9 +589,24 @@ class SessionReport(BaseModel):
|
|||
override_comment: str | None = None
|
||||
|
||||
|
||||
class Principal(BaseModel):
|
||||
"""Кто вошёл. В событиях не участвует, но фронт разводит по роли экраны,
|
||||
и список ролей должен приезжать из домена, а не переписываться руками
|
||||
(lct-23). Полная модель — в `app/api/auth.py`.
|
||||
"""
|
||||
|
||||
login: str
|
||||
full_name: str
|
||||
role: Role
|
||||
service: str | None = None
|
||||
trainee_id: UUID | None = None
|
||||
|
||||
|
||||
class EventCatalog(BaseModel):
|
||||
"""Единственное назначение — собрать все союзы в одну JSON Schema для make types."""
|
||||
|
||||
principal: Principal
|
||||
|
||||
server_to_trainee: ServerToTrainee
|
||||
trainee_to_server: TraineeToServer
|
||||
server_to_observer: ServerToObserver
|
||||
|
|
|
|||
29
backend/app/domain/roles.py
Normal file
29
backend/app/domain/roles.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Роли пользователей. Три названы в ТЗ поимённо, четвёртой нет.
|
||||
|
||||
Роль лежит в домене, а не в модуле входа: по ней разводятся и экраны фронта,
|
||||
и каналы сокетов, и она уезжает в TypeScript через `make types`.
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class Role(StrEnum):
|
||||
ADMIN = "admin"
|
||||
INSTRUCTOR = "instructor"
|
||||
TRAINEE = "trainee"
|
||||
|
||||
|
||||
ROLE_LABELS: dict[Role, str] = {
|
||||
Role.ADMIN: "Администратор системы",
|
||||
Role.INSTRUCTOR: "Преподаватель",
|
||||
Role.TRAINEE: "Обучающийся",
|
||||
}
|
||||
|
||||
#: Что кому доступно. Ограничения из ТЗ: преподаватель не меняет системные
|
||||
#: настройки, администратор не вмешивается в оценки, обучающийся не видит
|
||||
#: чужих результатов.
|
||||
SCREENS: dict[Role, tuple[str, ...]] = {
|
||||
Role.ADMIN: ("/admin", "/profile"),
|
||||
Role.INSTRUCTOR: ("/instructor", "/wall", "/profile", "/dds"),
|
||||
Role.TRAINEE: ("/trainee", "/profile"),
|
||||
}
|
||||
|
|
@ -4,9 +4,11 @@ import logging
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.api import auth
|
||||
from app.api.http import ekp as ekp_api
|
||||
from app.api.http import scenarios as scenarios_api
|
||||
from app.api.http import sessions
|
||||
|
|
@ -61,6 +63,16 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
|
||||
app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan)
|
||||
# Сессия ставится до роутеров: роль должна быть известна и на HTTP, и в момент
|
||||
# рукопожатия сокета, иначе проверять её в канале будет нечем (app/api/auth.py).
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=get_settings().session_secret,
|
||||
session_cookie="lct_session",
|
||||
https_only=False, # учебный класс живёт по http во внутренней сети
|
||||
max_age=12 * 60 * 60, # смена занятий, не месяц
|
||||
)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(scenarios_api.router)
|
||||
app.include_router(ekp_api.router)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ class SessionState:
|
|||
mode: SessionMode
|
||||
required_fields: list[str] = field(default_factory=list)
|
||||
trainee_name: str | None = None
|
||||
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
|
||||
#: по учётной записи: по нему разбор закрывается от чужих (lct-23).
|
||||
trainee_id: UUID | None = None
|
||||
attempt: int = 1
|
||||
|
||||
kio: KIO = field(default_factory=KIO)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ dependencies = [
|
|||
# Числа словами перед синтезом: Silero молча выбрасывает цифры, и звонящий
|
||||
# произносит «улица Ленина, дом… квартира…» без номеров.
|
||||
"num2words>=0.5.13",
|
||||
# Вход и роли (lct-23): argon2 — современная рекомендация по хешированию
|
||||
# паролей, itsdangerous нужен SessionMiddleware из starlette.
|
||||
"argon2-cffi>=23.1",
|
||||
"itsdangerous>=2.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
import websockets
|
||||
|
|
@ -19,9 +20,35 @@ BACKEND = "ws://localhost:8000"
|
|||
FRONTEND = "http://localhost:5173"
|
||||
|
||||
|
||||
def dev_cookie() -> str:
|
||||
"""Вход для служебного скрипта.
|
||||
|
||||
Сокеты закрыты ролями (lct-23), а `make lesson` — наш инструмент, а не
|
||||
пользователь. Точка `dev-token` живёт за флагом `DEV_AUTH_BYPASS` и на
|
||||
рабочем стенде отвечает 404 — тогда скрипт честно скажет, что занятие
|
||||
запускается с пульта преподавателя.
|
||||
"""
|
||||
request = urllib.request.Request(
|
||||
f"{BACKEND.replace('ws://', 'http://')}/api/auth/dev-token", method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.headers.get("set-cookie", "").split(";")[0]
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
raise SystemExit(
|
||||
"вход выключен: на стенде занятие запускается с пульта преподавателя.\n"
|
||||
"Для разработки поставь DEV_AUTH_BYPASS=true в backend/.env"
|
||||
) from exc
|
||||
raise
|
||||
|
||||
|
||||
async def main(scenario_id: str, mode: str) -> None:
|
||||
session_id = str(uuid.uuid4())
|
||||
async with websockets.connect(f"{BACKEND}/ws/control/{session_id}") as control:
|
||||
headers = {"Cookie": dev_cookie()}
|
||||
async with websockets.connect(
|
||||
f"{BACKEND}/ws/control/{session_id}", additional_headers=headers
|
||||
) as control:
|
||||
await control.send(json.dumps({
|
||||
"type": "scenario.start",
|
||||
"scenario_id": scenario_id,
|
||||
|
|
|
|||
78
backend/scripts/users.py
Normal file
78
backend/scripts/users.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""make users: завести учётные записи для занятия.
|
||||
|
||||
Без входа система не работает (lct-23), а заводить первого администратора
|
||||
руками в базе — плохой первый шаг для того, кто разворачивает стенд.
|
||||
Скрипт создаёт по одной записи на роль и печатает пароли один раз.
|
||||
|
||||
Пароли случайные: зашитый в репозиторий «admin/admin» переживёт сдачу
|
||||
и уедет в реальный класс.
|
||||
|
||||
make users создать недостающие
|
||||
make users force=1 пересоздать пароли существующих
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from sqlalchemy import select # noqa: E402
|
||||
|
||||
from app.api.auth import hash_password # noqa: E402
|
||||
from app.db.base import get_sessionmaker # noqa: E402
|
||||
from app.db.models import Trainee, User # noqa: E402
|
||||
from app.domain.roles import Role # noqa: E402
|
||||
|
||||
PEOPLE = [
|
||||
("admin", "Администратор стенда", Role.ADMIN, None),
|
||||
("teacher", "Преподаватель", Role.INSTRUCTOR, None),
|
||||
("trainee", "Курсант", Role.TRAINEE, "ДДС района"),
|
||||
]
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
force = bool(os.environ.get("force"))
|
||||
created: list[tuple[str, str]] = []
|
||||
|
||||
async with get_sessionmaker()() as db:
|
||||
for login, full_name, role, service in PEOPLE:
|
||||
user = await db.scalar(select(User).where(User.login == login))
|
||||
if user is not None and not force:
|
||||
print(f" {login}: уже есть")
|
||||
continue
|
||||
|
||||
password = secrets.token_urlsafe(9)
|
||||
if user is None:
|
||||
user = User(login=login, full_name=full_name, role=role.value, service=service)
|
||||
db.add(user)
|
||||
user.password_hash = hash_password(password)
|
||||
user.blocked = False
|
||||
|
||||
# У обучающегося должна быть карточка курсанта: по ней собираются
|
||||
# профиль и история, и по ней же разбор закрывается от чужих.
|
||||
if role is Role.TRAINEE and user.trainee_id is None:
|
||||
trainee = Trainee(name=full_name)
|
||||
db.add(trainee)
|
||||
await db.flush()
|
||||
user.trainee_id = trainee.id
|
||||
|
||||
created.append((login, password))
|
||||
await db.commit()
|
||||
|
||||
if not created:
|
||||
print("все учётные записи на месте (пересоздать: make users force=1)")
|
||||
return 0
|
||||
|
||||
print("\nЗаписано. Пароли показываются один раз:\n")
|
||||
for login, password in created:
|
||||
print(f" {login:10} {password}")
|
||||
print("\nСменить пароль можно только пересозданием: экран смены пароля — lct-24.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
31
backend/tests/conftest.py
Normal file
31
backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Общая настройка тестов.
|
||||
|
||||
Голосовой контур выключен: он грузит модели около десяти секунд, а проверяется
|
||||
отдельно в `test_voice_pipeline.py`. Вход включён как на стенде, но тесты
|
||||
логинятся через `dev-token` — так же, как `make lesson` (tasks/lct-23-auth-roles.md).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DEV_AUTH_BYPASS", "true")
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from app.config import get_settings # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _dev_auth():
|
||||
"""Флаг обхода читается один раз при создании настроек."""
|
||||
get_settings.cache_clear()
|
||||
settings = get_settings()
|
||||
assert settings.dev_auth_bypass, "тесты должны ходить через dev-token"
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def as_instructor(client):
|
||||
"""Клиент, вошедший преподавателем. Cookie живёт в самом TestClient."""
|
||||
response = client.post("/api/auth/dev-token")
|
||||
assert response.status_code == 200, response.text
|
||||
return client
|
||||
128
backend/tests/test_auth.py
Normal file
128
backend/tests/test_auth.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Вход, роли и аудит.
|
||||
|
||||
Пункты приёмки lct-23: курсант не открывает пульт даже по прямой ссылке,
|
||||
администратор не правит оценки, чужой разбор закрыт, пароли не хранятся
|
||||
в открытом виде.
|
||||
|
||||
Тесты, которым нужна база, пропускаются, когда Postgres не поднят: остальные
|
||||
проверяют разграничение, для которого база не нужна.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.auth import hash_password, verify_password
|
||||
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
|
||||
|
||||
|
||||
# ── пароли ──
|
||||
|
||||
|
||||
def test_password_is_hashed_not_stored():
|
||||
stored = hash_password("правильный-пароль")
|
||||
assert "правильный-пароль" not in stored
|
||||
assert stored.startswith("$argon2")
|
||||
|
||||
|
||||
def test_password_verifies():
|
||||
stored = hash_password("правильный-пароль")
|
||||
assert verify_password(stored, "правильный-пароль")
|
||||
assert not verify_password(stored, "другой")
|
||||
|
||||
|
||||
def test_broken_hash_does_not_let_anyone_in():
|
||||
"""Битая запись в базе не должна открывать вход."""
|
||||
assert not verify_password("не хеш вовсе", "что угодно")
|
||||
|
||||
|
||||
# ── вход ──
|
||||
|
||||
|
||||
def test_unknown_login_and_wrong_password_look_the_same(client):
|
||||
"""Иначе форма входа превращается в список действующих учётных записей."""
|
||||
first = client.post("/api/auth/login", json={"login": "нет-такого", "password": "x"})
|
||||
assert first.status_code == 401
|
||||
assert first.json()["detail"] == "bad_credentials"
|
||||
|
||||
|
||||
def test_me_requires_authentication(client):
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_dev_token_gives_an_instructor(client):
|
||||
response = client.post("/api/auth/dev-token")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["role"] == "instructor"
|
||||
assert client.get("/api/auth/me").json()["role"] == "instructor"
|
||||
|
||||
|
||||
def test_logout_clears_the_session(client):
|
||||
client.post("/api/auth/dev-token")
|
||||
client.post("/api/auth/logout")
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
# ── разграничение ──
|
||||
|
||||
|
||||
def test_anonymous_cannot_open_any_socket(client):
|
||||
"""Номер занятия перестал быть пропуском: раньше по ссылке пускало знание
|
||||
адреса, теперь — роль."""
|
||||
session_id = uuid4()
|
||||
for path in (f"/ws/control/{session_id}", f"/ws/call/{session_id}",
|
||||
f"/ws/observe/{session_id}", f"/ws/station/{session_id}"):
|
||||
with client.websocket_connect(path) as socket:
|
||||
message = socket.receive_json()
|
||||
assert message["type"] == "error"
|
||||
assert message["code"] == "forbidden", path
|
||||
|
||||
|
||||
def test_anonymous_cannot_read_history(client):
|
||||
assert client.get("/api/sessions").status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_cannot_read_trainees(client):
|
||||
assert client.get("/api/trainees").status_code == 401
|
||||
|
||||
|
||||
def test_classifier_is_open_to_everyone(client):
|
||||
"""Справочник ЕКП прятать не от кого: оператор видит тот же список
|
||||
на боевом АРМ."""
|
||||
assert client.get("/api/ekp/groups").status_code == 200
|
||||
|
||||
|
||||
def test_health_stays_open(client):
|
||||
"""Экран «стенд прогревается» показывается до входа."""
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
def test_instructor_can_open_control(client):
|
||||
"""На `control` сервер не шлёт ничего — это канал только на запись
|
||||
(docs/arch/CONTRACT.md). Признак того, что он открыт, — поднявшееся
|
||||
занятие, а не ответное сообщение."""
|
||||
import time
|
||||
|
||||
client.post("/api/auth/dev-token")
|
||||
session_id = uuid4()
|
||||
with client.websocket_connect(f"/ws/control/{session_id}") as socket:
|
||||
socket.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training"})
|
||||
deadline = time.monotonic() + 3
|
||||
while time.monotonic() < deadline and hub.get(session_id) is None:
|
||||
time.sleep(0.02)
|
||||
assert hub.get(session_id) is not None, "занятие не поднялось: канал отвергнут"
|
||||
|
||||
|
||||
def test_roles_are_three_and_named_in_the_spec():
|
||||
assert {role.value for role in Role} == {"admin", "instructor", "trainee"}
|
||||
|
|
@ -76,7 +76,10 @@ def test_session_modes_are_the_three_from_docs():
|
|||
|
||||
|
||||
def test_event_catalog_covers_every_channel():
|
||||
"""`principal` каналом не является: он в каталоге ради того, чтобы список
|
||||
ролей приезжал на фронт из домена, а не переписывался руками (lct-23)."""
|
||||
assert set(EventCatalog.model_fields) == {
|
||||
"principal",
|
||||
"server_to_trainee",
|
||||
"trainee_to_server",
|
||||
"server_to_observer",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ def client():
|
|||
except OSError as exc:
|
||||
pytest.skip(f"Postgres недоступен ({exc}) — подними `make dev`")
|
||||
with TestClient(app) as test_client:
|
||||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||
# как `make lesson`, — через dev-token за флагом.
|
||||
test_client.post("/api/auth/dev-token")
|
||||
yield test_client
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ from app.session.hub import hub
|
|||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||
# как `make lesson`, — через dev-token за флагом.
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ from app.session.hub import hub
|
|||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||
# как `make lesson`, — через dev-token за флагом.
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None # тесты не пишут в БД: проверяется поведение каналов
|
||||
yield test_client
|
||||
|
||||
|
|
|
|||
83
backend/uv.lock
generated
83
backend/uv.lock
generated
|
|
@ -51,6 +51,43 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi-bindings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi-bindings"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/b9/97f0370f99611b14efd384918613dd5cbda75f28d9bb1b677aacfeaa17df/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:19b562b1de4b9052ef1214a2821c44b6e6f22945daa102c32ae4eff929d8b6d8", size = 23055, upload-time = "2026-08-20T07:33:19.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/70/7eb3fe7bf00103cbbb569c51aef150661f22b734a782673a600ff0f52309/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d525938467d52c923a890153c99087c9d5a937d1f6b585dbdba34ec82e397a", size = 24869, upload-time = "2026-08-20T07:33:20.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/4b/9d5919c6cb1f15df7406af0f99b048bd93936f112e3e8f4c8077bc2a9110/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b0bcac4d490a237e18cf91f57352920c29f77f2fa39efd0813fb81298bf17ba", size = 24216, upload-time = "2026-08-20T07:33:21.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/34/32109943bace7729233cc4ee78530baa306d8cc3c6501a64ba8cb3b58129/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e", size = 23584, upload-time = "2026-08-20T07:33:22.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncpg"
|
||||
version = "0.31.0"
|
||||
|
|
@ -76,6 +113,30 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
|
|
@ -266,6 +327,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
|
|
@ -284,9 +354,11 @@ version = "0.1.0"
|
|||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "num2words" },
|
||||
{ name = "numpy" },
|
||||
{ name = "onnxruntime" },
|
||||
|
|
@ -314,9 +386,11 @@ voice = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.13" },
|
||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||
{ name = "asyncpg", specifier = ">=0.29" },
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "itsdangerous", specifier = ">=2.1" },
|
||||
{ name = "num2words", specifier = ">=0.5.13" },
|
||||
{ name = "numpy", specifier = ">=1.26" },
|
||||
{ name = "onnx-asr", marker = "extra == 'voice'", specifier = ">=0.6" },
|
||||
|
|
@ -485,6 +559,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/39/ca/c47f91d3cab175b01fd8c4f0d80fdf8613be876cc616e66ad281a59c5ddf/protobuf-7.36.1-py3-none-any.whl", hash = "sha256:7d951e46b3f963d6c264c367c437921de9d5aedd9c3f9612b9077736b4e3ad5c", size = 179813, upload-time = "2026-08-31T22:40:03.54Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.5"
|
||||
|
|
|
|||
Loading…
Reference in a new issue