Implement DDS exercise, customer UI and local demo

This commit is contained in:
andreysk0304 2026-09-21 19:56:13 +03:00
commit 0526b11f91
46 changed files with 2504 additions and 253 deletions

View file

@ -15,7 +15,10 @@
протокола авторизации в канале нет.
"""
import asyncio
import logging
import secrets
import weakref
from uuid import UUID
from argon2 import PasswordHasher
@ -31,8 +34,58 @@ from app.domain.roles import Role
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
DEMO_TRAINEE_ID = UUID("00000000-0000-4000-8000-000000000112")
_hasher = PasswordHasher()
# Cookie is signed, but carries a role snapshot. A changed account must not
# keep its old privileges for the full 12-hour cookie lifetime. Compose runs
# one worker; the process marker also invalidates all cookies after restart.
_INSTANCE = secrets.token_urlsafe(32)
_generations: dict[str, int] = {}
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
async def _close_revoked(ws: WebSocket) -> None:
try:
await ws.close(code=1008, reason="Учётная запись изменена: войдите снова")
except (RuntimeError, OSError):
# The peer may already have disconnected; revocation still stands.
pass
def invalidate_login(login: str) -> None:
"""Revoke previously issued cookies after account/role/password changes."""
_generations[login] = _generations.get(login, 0) + 1
for ws, loop in list(_active_sockets.get(login, {}).items()):
try:
if not loop.is_closed():
loop.call_soon_threadsafe(lambda socket=ws: asyncio.create_task(_close_revoked(socket)))
except RuntimeError:
pass # loop closed between the check and scheduling
def _session_principal(session: dict) -> "Principal | None":
data = session.get("principal")
if not isinstance(data, dict):
return None
login = data.get("login")
if not isinstance(login, str):
return None
if session.get("auth_instance") != _INSTANCE:
return None
if session.get("auth_generation") != _generations.get(login, 0):
return None
try:
return Principal.model_validate(data)
except Exception: # malformed signed cookie: reject, do not 500
log.warning("неверный формат principal в cookie")
return None
def _issue_session(request: Request, who: "Principal") -> None:
request.session["principal"] = who.model_dump(mode="json")
request.session["auth_instance"] = _INSTANCE
request.session["auth_generation"] = _generations.get(who.login, 0)
def hash_password(password: str) -> str:
@ -50,8 +103,7 @@ def verify_password(password_hash: str, password: str) -> bool:
class Principal(BaseModel):
"""Кто действует. Хранится в cookie целиком: ходить в базу за ролью
на каждый запрос незачем, а меняется она правкой учётной записи."""
"""Кто действует. Cookie stores a snapshot, revoked on account edits."""
login: str
full_name: str
@ -69,9 +121,23 @@ class LoginIn(BaseModel):
password: str
def _demo_local(request: Request) -> None:
# При ошибочном bind 0.0.0.0 удалённый клиент всё равно не получит cookie.
if request.client is None or request.client.host not in {"127.0.0.1", "::1", "localhost", "testclient"}:
raise HTTPException(status_code=403, detail="demo_local_only")
def _demo_principal(role: Role) -> Principal:
return Principal(
login="demo-trainee" if role is Role.TRAINEE else "demo-instructor",
full_name="Демо-курсант" if role is Role.TRAINEE else "Демо-преподаватель",
role=role,
trainee_id=DEMO_TRAINEE_ID if role is Role.TRAINEE else None,
)
def current(request: Request) -> Principal | None:
data = request.session.get("principal")
return Principal.model_validate(data) if data else None
return _session_principal(request.session)
def principal_of(websocket: WebSocket) -> Principal | None:
@ -80,8 +146,10 @@ def principal_of(websocket: WebSocket) -> Principal | None:
`WebSocket.session` доступен, потому что `SessionMiddleware` стоит до
роутера: значит роль известна до входа в цикл приёма сообщений.
"""
data = websocket.session.get("principal") if "session" in websocket.scope else None
return Principal.model_validate(data) if data else None
who = _session_principal(websocket.session) if "session" in websocket.scope else None
if who is not None:
_active_sockets.setdefault(who.login, weakref.WeakKeyDictionary())[websocket] = asyncio.get_running_loop()
return who
def require(request: Request, *roles: Role) -> Principal:
@ -99,6 +167,8 @@ async def audit(
) -> None:
"""Запись в журнал. Аудит не должен ронять действие: если база недоступна,
занятие продолжается, а пропуск виден в логе."""
if get_settings().demo_no_db:
return # в явном demo-режиме запись и долговременный аудит недоступны
try:
async with get_sessionmaker()() as db:
db.add(
@ -113,6 +183,16 @@ async def audit(
@router.post("/login")
async def login(payload: LoginIn, request: Request) -> dict:
if get_settings().demo_no_db:
_demo_local(request)
demo_roles = {"demo-instructor": Role.INSTRUCTOR, "demo-trainee": Role.TRAINEE}
role = demo_roles.get(payload.login)
if role is None or not secrets.compare_digest(payload.password, "demo"):
raise HTTPException(status_code=401, detail="bad_credentials")
who = _demo_principal(role)
_issue_session(request, who)
return who.model_dump(mode="json")
async with get_sessionmaker()() as db:
user = await db.scalar(select(User).where(User.login == payload.login))
@ -130,7 +210,7 @@ async def login(payload: LoginIn, request: Request) -> dict:
service=user.service,
trainee_id=user.trainee_id,
)
request.session["principal"] = who.model_dump(mode="json")
_issue_session(request, who)
await audit(who.login, who.role.value, "login")
return who.model_dump(mode="json")
@ -153,7 +233,7 @@ async def me(request: Request) -> dict:
@router.post("/dev-token")
async def dev_token(request: Request) -> dict:
async def dev_token(request: Request, role: Role = Role.INSTRUCTOR) -> dict:
"""Вход без пароля для наших же инструментов.
`make lesson` и тесты открывают сокеты напрямую и после включения ролей
@ -161,8 +241,17 @@ async def dev_token(request: Request) -> dict:
умолчанию выключенным: в рабочем стенде она отвечает 404, а не 401 —
выключенной функции не должно быть видно вовсе.
"""
if not get_settings().dev_auth_bypass:
settings = get_settings()
if not 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")
if role is not Role.INSTRUCTOR and not settings.demo_no_db:
raise HTTPException(status_code=404, detail="not_found")
if settings.demo_no_db:
_demo_local(request)
if role not in {Role.INSTRUCTOR, Role.TRAINEE}:
raise HTTPException(status_code=403, detail="demo_role_forbidden")
who = _demo_principal(role) if settings.demo_no_db else Principal(
login="dev", full_name="Разработка", role=Role.INSTRUCTOR
)
_issue_session(request, who)
return who.model_dump(mode="json")

View file

@ -10,6 +10,7 @@
"""
import logging
import re
from datetime import datetime
from uuid import UUID
@ -18,9 +19,10 @@ from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.concurrency import run_in_threadpool
from app.admin import backup as backup_service
from app.api.auth import audit, hash_password, require
from app.api.auth import audit, hash_password, invalidate_login, 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
@ -122,6 +124,13 @@ async def patch_user(
changed: list[str] = []
if body.role is not None:
if user.login == who.login and body.role is not Role.ADMIN:
raise HTTPException(status_code=409, detail="cannot_demote_yourself")
if body.role is Role.TRAINEE and user.trainee_id is None:
trainee = Trainee(name=user.full_name)
db.add(trainee)
await db.flush()
user.trainee_id = trainee.id
user.role = body.role.value
changed.append(f"роль {body.role.value}")
if body.service is not None:
@ -137,7 +146,10 @@ async def patch_user(
user.password_hash = hash_password(body.password)
changed.append("пароль сброшен")
if not changed:
return _out(user)
await db.commit()
invalidate_login(user.login)
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
return _out(user)
@ -162,7 +174,7 @@ async def audit_log(
"""Журнал действий. Администратор его читает, но не правит: точки удаления
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
require(request, Role.ADMIN)
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(min(limit, 1000))
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000)))
if action:
query = query.where(AuditLog.action == action)
if actor:
@ -261,7 +273,19 @@ class BackupOut(BaseModel):
@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()]
return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)]
def _safe_backup_error(exc: backup_service.BackupError) -> str:
"""Never echo DATABASE_URL/its password from backup diagnostics to HTTP."""
message = str(exc)
dsn = get_settings().database_url
if dsn:
message = message.replace(dsn, "[DATABASE_URL скрыт]")
match = re.search(r"://[^:]+:([^@]+)@", dsn)
if match and match.group(1):
message = message.replace(match.group(1), "[пароль скрыт]")
return message
@router.post("/backups", response_model=BackupOut, status_code=201)
@ -270,8 +294,11 @@ async def make_backup(request: Request) -> BackupOut:
кнопка нужна перед занятием, расписание — чтобы о нём не вспоминали."""
who = require(request, Role.ADMIN)
try:
created = backup_service.create()
# pg_dump may run for two minutes; never block the event loop for it.
created = await run_in_threadpool(backup_service.create)
except backup_service.BackupError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
detail = _safe_backup_error(exc)
await audit(who.login, who.role.value, "backup.failed", detail=detail)
raise HTTPException(status_code=503, detail=detail) from exc
await audit(who.login, who.role.value, "backup.create", created["name"])
return BackupOut(**created)

View file

@ -0,0 +1,125 @@
"""Сводка ошибок и рекомендаций учебной группы для преподавателя."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.domain.roles import Role
from app.scoring.group import ScoredAttempt, summarize
router = APIRouter(prefix="/api/groups", tags=["groups"])
class GroupOut(BaseModel):
id: UUID
name: str
class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
class GroupErrorOut(BaseModel):
code: str
title: str
affected_trainees: int
occurrences: int
rate_percent: float
recommendation: str
class GroupAnalyticsOut(BaseModel):
group: GroupOut
enrolled_trainees: int
active_trainees: int
scored_attempts: int
average_score: float | None
errors: list[GroupErrorOut]
@router.get("", response_model=list[GroupOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
require(request, Role.INSTRUCTOR, Role.ADMIN)
groups = await db.scalars(select(Group).order_by(Group.name))
return [GroupOut(id=group.id, name=group.name) for group in groups]
@router.post("", response_model=GroupOut, status_code=201)
async def create(
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="group_name_required")
group = Group(name=name)
db.add(group)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="group_exists") from exc
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
return GroupOut(id=group.id, name=group.name)
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
async def assign_trainee(
group_id: UUID, trainee_id: UUID, request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee.group_id = group_id
await db.commit()
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
return GroupOut(id=group.id, name=group.name)
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
async def analytics(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupAnalyticsOut:
require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
enrolled = await db.scalar(
select(func.count()).select_from(Trainee).where(Trainee.group_id == group_id)
)
rows = await db.execute(
select(Session.trainee_id, Score.score_final, Score.report)
.join(Score, Score.session_id == Session.id)
.join(Trainee, Trainee.id == Session.trainee_id, isouter=True)
.where(
or_(
Session.group_id == group_id,
and_(Session.group_id.is_(None), Trainee.group_id == group_id),
),
Session.ended_at.is_not(None),
)
)
attempts = [
ScoredAttempt(
trainee_id=trainee_id,
score=score,
codes=(report or {}).get("summary", {}).get("codes", {}),
)
for trainee_id, score, report in rows
]
return GroupAnalyticsOut(
group=GroupOut(id=group.id, name=group.name),
**summarize(attempts, int(enrolled or 0)),
)

View file

@ -8,7 +8,7 @@ from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
@ -117,9 +117,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut
class ScoreOverride(BaseModel):
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
score_final: float
score_final: float = Field(ge=0, le=100)
comment: str = ""
author: str = "преподаватель"
def _live(session_id: UUID):
@ -188,9 +187,13 @@ async def override(session_id: UUID, body: ScoreOverride, request: Request) -> S
state.score = {
**state.score,
"score_final": body.score_final,
"overridden_by": body.author,
"overridden_by": who.login,
"override_comment": body.comment,
}
if hub.journal:
await hub.journal.score_override(
session_id, body.score_final, who.login, 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}",

View file

@ -13,9 +13,10 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import require
from app.api.auth import DEMO_TRAINEE_ID, require
from app.config import get_settings
from app.domain.roles import Role
from app.db.base import get_session
from app.db.base import get_session, get_sessionmaker
from app.db.models import Group, Score, Session, Trainee
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
@ -61,14 +62,17 @@ class ProfileOut(BaseModel):
@router.get("", response_model=list[TraineeOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
async def listing(request: Request) -> 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)
)
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
if get_settings().demo_no_db:
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")]
async with get_sessionmaker()() as db:
rows = await db.execute(
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
)
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
@router.get("/{trainee_id}/profile", response_model=ProfileOut)

View file

@ -9,7 +9,7 @@
"""
import logging
import re
import math
from uuid import UUID, uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@ -44,7 +44,7 @@ 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.domain.kio import KIO, ResponseStatus
from app.session.dds import prepare_card
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
@ -84,6 +84,23 @@ async def _start(session_id: UUID, event, who=None) -> None:
)
return
scenario_ids = event.scenario_ids or [event.scenario_id]
if event.exercise is Exercise.DDS:
if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 10:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 10 карточек",
))
return
scenarios = [store.get(scenario_id) for scenario_id in scenario_ids]
if any(item is None for item in scenarios):
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID, message="В очереди ДДС есть неизвестный сценарий",
))
return
else:
scenarios = []
attempt = 1
recorded_trainee_id = event.trainee_id
if hub.journal:
@ -114,29 +131,8 @@ async def _start(session_id: UUID, event, who=None) -> None:
state.persona = PersonaState(state.scenario.persona)
state.caller = build_caller(scenario.id)
elif event.exercise is Exercise.DDS:
# В ДДС поступает уже оформленная учебная карточка. Содержимое берётся
# из утверждённого сценария, а не из действий несуществующего оператора.
truth = scenario.ground_truth
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
service = truth.dds.value if truth.dds else None
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
state.kio = KIO(
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
address=truth.address or address_fact or None,
floor=floor.group(1) if floor else None,
incident_type=truth.incident_type, incident_code=truth.incident_code,
dds=truth.dds, signs=list(scenario.signs),
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
victims_count=truth.victims,
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
)
if service:
state.dispatch(service)
else:
state.kio.response_status = ResponseStatus.TRANSFERRED
state.dispatched_card = state.kio.model_copy(deep=True)
state.dispatched_at = now_utc()
state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios]
prepare_card(state, state.dds_scenarios[0])
state.started_at = state.dispatched_at
else:
state.started_at = now_utc()
@ -151,8 +147,6 @@ async def _start(session_id: UUID, event, who=None) -> None:
if models is not None:
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
state.on_event("call.incoming")
elif event.exercise is Exercise.DDS:
state.on_event("dds.dispatch")
hub.start_ticker(session_id)
if who is not None:
# Запуск занятия меняет чужой результат — значит попадает в журнал
@ -265,15 +259,39 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
if state is not None:
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
case "score.override":
if event.session_id != session_id:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Оценка относится к другому занятию",
))
continue
state = hub.get(session_id)
if state is not None and state.score is not None:
try:
verdict = float(event.verdict)
except ValueError:
verdict = float("nan")
if not math.isfinite(verdict) or not 0 <= verdict <= 100:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Оценка должна быть числом от 0 до 100",
))
continue
# Автооценка остаётся рядом: видно, что скорректировано и кем.
state.score = {
**state.score,
"score_final": float(event.verdict) if event.verdict.replace(".", "", 1).isdigit() else state.score["score_auto"],
"overridden_by": "преподаватель",
"score_final": verdict,
"overridden_by": who.login,
"override_comment": event.comment,
}
if hub.journal:
await hub.journal.score_override(
session_id, verdict, who.login, event.comment
)
await audit(
who.login, who.role.value, "score.override", str(session_id),
f"{state.score.get('score_auto')} → {verdict}: {event.comment}",
)
hub.to_observers(session_id, ScoreReady(session_id=session_id))
case "director.inject":
state = hub.get(session_id)

View file

@ -25,6 +25,8 @@ from app.domain.roles import Role
from app.session.hub import hub
from app.session.state import now_utc
from app.session.finish import finish
from app.session.finish import score_current_dds
from app.session.dds import prepare_card
log = logging.getLogger(__name__)
router = APIRouter()
@ -50,6 +52,18 @@ def _error(session_id: UUID, message: str) -> None:
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
async def _finish_dds(session_id: UUID, state) -> None:
state.ended_at = now_utc()
state.end_reason = CallEndReason.COMPLETE
hub.stop_ticker(session_id)
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value)
await finish(session_id, state)
hub.to_station(session_id, ScoreReady(session_id=session_id))
async def _handle(session_id: UUID, state, event) -> None:
if state.ended:
_error(session_id, "Занятие уже завершено")
@ -104,6 +118,7 @@ async def _handle(session_id: UUID, state, event) -> None:
return
state.crew_selected = event.crew
state.crew_assignments[service] = event.crew
state.dds_log.append(("crew.select", now_utc(), event.crew))
case "phone.dial":
crew = state.crew_selected
service = state.crew_service(crew) if crew else None
@ -126,20 +141,37 @@ async def _handle(session_id: UUID, state, event) -> None:
text=REPORT_TEXT[phase], at=now_utc(),
)
state.phone_reports.append(report)
state.dds_log.append(("phone.dial", now_utc(), crew))
hub.to_station(session_id, PhoneReport(**report.model_dump()))
case "card.reply":
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id
):
_error(session_id, "Ответ относится не к текущей карточке")
return
state.reply_text = event.text
state.reply_log.append((now_utc(), event.text))
case "card.next":
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id
):
_error(session_id, "Следующая карточка недоступна: ID текущей не совпадает")
return
if any(item.card_id == event.card_id for item in state.dds_completed):
_error(session_id, "Эта карточка уже завершена")
return
state.dds_completed.append(score_current_dds(state))
if state.dds_card_index + 1 < len(state.dds_scenarios):
state.dds_card_index += 1
prepare_card(state, state.dds_scenarios[state.dds_card_index])
hub.to_station(session_id, state.card_received_event())
else:
await _finish_dds(session_id, state)
case "station.finish":
if state.exercise is not Exercise.DDS:
_error(session_id, "Операторское занятие завершается после звонка 112")
return
state.ended_at = now_utc()
state.end_reason = CallEndReason.COMPLETE
hub.stop_ticker(session_id)
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value)
await finish(session_id, state)
hub.to_station(session_id, ScoreReady(session_id=session_id))
await _finish_dds(session_id, state)
case "card.bounce":
# Карточка вернулась: в разборе это E6 с конкретной причиной.
state.bounced_fields = list(event.missing_fields)