lct-05: состояние сессии, таймеры по событиям, четыре канала

Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.

Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.

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

Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.

Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
This commit is contained in:
Ivan Gerasimov 2026-09-16 02:07:27 +03:00
commit e7a0798ba7
12 changed files with 1029 additions and 0 deletions

View file

@ -29,6 +29,7 @@ async def create_session(
mode: str,
trainee_id: UUID | None = None,
group_id: UUID | None = None,
session_id: UUID | None = None,
) -> Session:
session = Session(
scenario_id=scenario_id,
@ -37,11 +38,43 @@ async def create_session(
group_id=group_id,
attempt=await next_attempt(db, trainee_id, scenario_id),
)
if session_id is not None:
session.id = session_id
db.add(session)
await db.commit()
return session
async def ensure_session(
db: AsyncSession,
*,
session_id: UUID,
scenario_id: str,
mode: str,
trainee_name: str | None = None,
group_name: str | None = None,
) -> Session:
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
Иначе реплики, подсказки и пометки не к чему привязать: они уходят
в нарушение внешнего ключа, а профиль курсанта остаётся пустым.
"""
existing = await db.get(Session, session_id)
if existing is not None:
return existing
group = await ensure_group(db, group_name) if group_name else None
trainee = await ensure_trainee(db, trainee_name, group) if trainee_name else None
return await create_session(
db,
scenario_id=scenario_id,
mode=mode,
trainee_id=trainee.id if trainee else None,
group_id=group.id if group else None,
session_id=session_id,
)
async def get_session(db: AsyncSession, session_id: UUID) -> Session | None:
return await db.get(Session, session_id)