Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались ссылкой с номером занятия, и пускало знание адреса. - Таблицы 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 чистый.
96 lines
4.3 KiB
Python
96 lines
4.3 KiB
Python
"""Сборка приложения. Роутеры подключаются по мере готовности — см. tasks/."""
|
||
|
||
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
|
||
from app.api.http import trainees
|
||
from app.api.ws import call as call_ws
|
||
from app.api.ws import control as control_ws
|
||
from app.api.ws import observe as observe_ws
|
||
from app.api.ws import station as station_ws
|
||
from app.config import get_settings
|
||
from app.db.base import get_sessionmaker
|
||
from app.dialog.runtime import get_embedder
|
||
from app.voice.models import get_voice_models
|
||
from app.scenarios import store
|
||
from app.session.hub import hub
|
||
from app.session.journal import DbJournal
|
||
from app.scenarios.loader import ScenarioError
|
||
|
||
|
||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||
|
||
# uvicorn настраивает только собственные логгеры: без этого INFO из модулей
|
||
# приложения («получено N кадров», замеры задержки голоса) молча теряется,
|
||
# а до лога доходят одни предупреждения. Чужие библиотеки — от WARNING.
|
||
logging.basicConfig(level=logging.WARNING, format="%(levelname)-8s %(name)s: %(message)s")
|
||
logging.getLogger("app").setLevel(logging.INFO)
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
# Библиотека проверяется на старте целиком: сломанный сценарий, найденный
|
||
# посреди занятия, — сценарий, которого не должно случиться.
|
||
try:
|
||
loaded = store.load_from_disk(LIBRARY)
|
||
except ScenarioError as exc:
|
||
raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc
|
||
app.state.scenarios_loaded = len(loaded)
|
||
|
||
# Журнал: всё, что не записано, для оценки не существует.
|
||
hub.journal = DbJournal(get_sessionmaker())
|
||
|
||
# Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия.
|
||
app.state.embeddings_ready = get_embedder() is not None
|
||
|
||
# Модели речи: ~5 секунд на старте стенда вместо паузы на первом звонке.
|
||
app.state.models_ready = get_voice_models() is not None
|
||
yield
|
||
|
||
await hub.shutdown()
|
||
for state in list(hub._sessions.values()):
|
||
if state.voice is not None:
|
||
await state.voice.close()
|
||
|
||
|
||
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)
|
||
app.include_router(trainees.router)
|
||
app.include_router(call_ws.router)
|
||
app.include_router(observe_ws.router)
|
||
app.include_router(control_ws.router)
|
||
app.include_router(station_ws.router)
|
||
|
||
|
||
@app.get("/api/health")
|
||
async def health() -> dict:
|
||
"""Готовность стенда. Фронт показывает экран «модели прогреваются»."""
|
||
settings = get_settings()
|
||
return {
|
||
"status": "ok",
|
||
"models_ready": getattr(app.state, "models_ready", False),
|
||
"scenarios_loaded": getattr(app.state, "scenarios_loaded", 0),
|
||
"embeddings_ready": getattr(app.state, "embeddings_ready", False),
|
||
"offline": settings.offline,
|
||
}
|