feat: сигналы реестра занятий: очередь, повторные отказы, курсант не на связи

This commit is contained in:
kaifarikman 2026-09-27 17:16:21 +03:00
commit 69b483f570
9 changed files with 244 additions and 3 deletions

View file

@ -8,6 +8,7 @@ import logging
import time
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
@ -23,7 +24,7 @@ from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport
from app.domain.roles import Role
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
from app.domain.timers import TimerCode
from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report
@ -32,6 +33,7 @@ from app.session.checkpoint import load_state
from app.session.finish import override_score
from app.session.score import scoring_scenario
from app.session.hub import hub
from app.session.state import SessionState
from app.session.store import ScoreOverridden, apply_score_override
from app.voice.recording import recording_path
@ -86,6 +88,15 @@ class DdsHistoryOut(BaseModel):
recipient_services: list[str] = []
class Signal(BaseModel):
"""Одна строка колонки сигналов реестра. Цвет несёт смысл только через
`severity`; текст обязателен и не заменяется цветом."""
kind: Literal["backlog", "refusals", "offline"]
severity: Literal["warn", "violated"]
text: str
class ActiveSessionOut(BaseModel):
session_id: UUID
trainee_name: str | None
@ -101,6 +112,7 @@ class ActiveSessionOut(BaseModel):
dds_work_overdue_cards: int
dds_statuses: dict[str, str]
dds_snapshot: StationSnapshot | None = None
signals: list[Signal] = []
def _out(session) -> SessionOut:
@ -184,6 +196,44 @@ async def dds_history(
return result
def _signals(state: SessionState, queue: list[DdsQueueCard], now: datetime) -> list[Signal]:
"""Сигналы реестра: очередь, повторные отказы, курсант не на связи.
Не завязаны на паузу занятия (`session.pause`, если появится) — очередь,
отказы и присутствие сокета не читают таймеры. Присутствие — по наличию
подписчика в хабе; окно нужно, чтобы короткий разрыв соединения не сразу
считался потерей связи курсанта.
"""
settings = get_settings()
signals: list[Signal] = []
if len(queue) >= settings.signal_backlog_threshold:
signals.append(Signal(
kind="backlog", severity="warn",
text=f"Очередь: {len(queue)} необработанных карточек",
))
if state.consecutive_refusals >= settings.signal_refusals_threshold:
signals.append(Signal(
kind="refusals", severity="warn",
text=f"Подряд отказов: {state.consecutive_refusals}",
))
connected = (
hub.station_connected(state.session_id) if state.exercise is Exercise.DDS
else hub.trainee_connected(state.session_id)
)
if connected:
state.socket_last_seen_at = now
offline_seconds = (
(now - state.socket_last_seen_at).total_seconds()
if state.socket_last_seen_at is not None else 0
)
if offline_seconds >= settings.signal_offline_window_seconds:
signals.append(Signal(
kind="offline", severity="violated",
text=f"Курсант не на связи {int(offline_seconds)} с",
))
return signals
@router.get("/active", response_model=list[ActiveSessionOut])
async def active(
request: Request,
@ -262,6 +312,7 @@ async def active(
),
dds_statuses=latest_statuses,
dds_snapshot=station,
signals=_signals(state, queue, now),
))
return result

View file

@ -120,6 +120,11 @@ class Settings(BaseSettings):
def limit_ms(self, code: TimerCode) -> int:
return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms)
# Пороги сигналов реестра живых занятий (пульт преподавателя).
signal_backlog_threshold: int = Field(default=4, ge=1)
signal_refusals_threshold: int = Field(default=2, ge=1)
signal_offline_window_seconds: int = Field(default=45, ge=1)
def validate_deployment_security(self) -> None:
"""Reject known development authentication defaults on a production app."""
if self.app_env != "production":

View file

@ -446,6 +446,7 @@ class DdsDesk(BaseModel):
services[0], ServiceStatus.ACCEPTED, command.comment,
author="диспетчер",
)
session.consecutive_refusals = 0
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
@ -462,6 +463,13 @@ class DdsDesk(BaseModel):
# Первичный статус останавливает норматив 30 секунд.
if command.status in PRIMARY:
card.on_event("card.ack")
# Сигнал реестра "повторные отказы" считает подряд идущие
# отказы, а не сумму за занятие.
session.consecutive_refusals = (
session.consecutive_refusals + 1
if command.status is ServiceStatus.DECLINED
else 0
)
if command.status in {
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
}:

View file

@ -314,6 +314,12 @@ class SessionHub:
def observer_count(self, session_id: UUID) -> int:
return len(self._observers.get(session_id, set()))
def station_connected(self, session_id: UUID) -> bool:
return bool(self._stations.get(session_id))
def trainee_connected(self, session_id: UUID) -> bool:
return bool(self._trainees.get(session_id))
# ── такт таймеров ──
def start_ticker(self, session_id: UUID) -> None:

View file

@ -124,6 +124,9 @@ class PersistedSession(BaseModel):
#: a lost WebSocket acknowledgement cannot apply an operation twice.
processed_station_commands: list[str] = Field(default_factory=list)
text_revealed_facts: dict[str, str] = Field(default_factory=dict)
#: Подряд идущих первичных отказов без принятой карточки между ними —
#: сигнал реестра преподавателя, не влияет на балл.
consecutive_refusals: int = 0
@field_validator("processed_station_commands")
@classmethod
@ -170,6 +173,9 @@ class SessionState(PersistedSession):
voice: object | None = Field(default=None, exclude=True)
recorder: object | None = Field(default=None, exclude=True)
recording_path: str | None = Field(default=None, exclude=True)
#: Когда реестр преподавателя последний раз видел подключённый сокет
#: курсанта (станция ДДС или звонок 112) — для сигнала "не на связи".
socket_last_seen_at: datetime | None = Field(default=None, exclude=True)
def persisted(self) -> PersistedSession:
"""Сохраняемая часть без копирования — то, что уходит в снимок."""