Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны по обработчикам: так видно целиком, что чем запускается, и норматив нельзя потерять по дороге. Опрос стартует на ответе курсанта и останавливается передачей в ДДС — событием, а не таймаутом. Канал наблюдателя без единого обработчика входящих: кадры читаются и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без чтения задача сокета висела бы на очереди до первой отправки, а закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что наблюдатель не может ни изменить карточку, ни завершить занятие. Пульт теперь заводит сессию в журнале: запущенное с него занятие жило только в памяти, и реплики с подсказками уходили в нарушение внешнего ключа — журнал об этом честно сообщал в лог. Тикер таймеров гасится при остановке приложения, иначе задачи переживают выключение и держат событийный цикл. Проверено вживую через uvicorn: монитор, подключённый посреди занятия, получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
62 lines
2.3 KiB
Python
62 lines
2.3 KiB
Python
"""Сборка приложения. Роутеры подключаются по мере готовности — см. tasks/."""
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI
|
|
|
|
from pathlib import Path
|
|
|
|
from app.api.http import scenarios as scenarios_api
|
|
from app.api.http import sessions
|
|
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.config import get_settings
|
|
from app.db.base import get_sessionmaker
|
|
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"
|
|
|
|
|
|
@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())
|
|
|
|
# Прогрев моделей — карточка lct-06.
|
|
app.state.models_ready = False
|
|
yield
|
|
|
|
await hub.shutdown()
|
|
|
|
|
|
app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan)
|
|
app.include_router(sessions.router)
|
|
app.include_router(scenarios_api.router)
|
|
app.include_router(call_ws.router)
|
|
app.include_router(observe_ws.router)
|
|
app.include_router(control_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),
|
|
"offline": settings.offline,
|
|
}
|