feat: сигналы реестра занятий: очередь, повторные отказы, курсант не на связи
This commit is contained in:
parent
aa6860fc6c
commit
69b483f570
9 changed files with 244 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""Сохраняемая часть без копирования — то, что уходит в снимок."""
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ def full_state() -> SessionState:
|
|||
resolve_comment="передано в другой регион",
|
||||
processed_station_commands=[str(uuid4())],
|
||||
text_revealed_facts={"f_address": "улица Ленина, 14"},
|
||||
consecutive_refusals=2,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
152
backend/tests/test_session_signals.py
Normal file
152
backend/tests/test_session_signals.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Колонка сигналов реестра: очередь, повторные отказы, курсант не на связи."""
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.http import sessions as sessions_http
|
||||
from app.config import get_settings
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
POOL = ["fire-apartment-l2", "t01-1-fire-container"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
async def audit_override(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def optional_session_override():
|
||||
yield None
|
||||
|
||||
monkeypatch.setattr(sessions_http, "audit_required", audit_override)
|
||||
monkeypatch.setitem(
|
||||
app.dependency_overrides, sessions_http.optional_session, optional_session_override
|
||||
)
|
||||
try:
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.store = MemorySessionStore()
|
||||
yield test_client
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=3):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("состояние не обновилось")
|
||||
|
||||
|
||||
def read_until(socket, wanted):
|
||||
received = []
|
||||
for _ in range(20):
|
||||
event = socket.receive_json()
|
||||
received.append(event["type"])
|
||||
if event["type"] == wanted:
|
||||
return event
|
||||
raise AssertionError(f"событие {wanted} не пришло; получены: {received}")
|
||||
|
||||
|
||||
def start_two_card_dds(client):
|
||||
session_id = uuid4()
|
||||
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = context.__enter__()
|
||||
control.send_json({
|
||||
"type": "scenario.start",
|
||||
"scenario_id": POOL[0],
|
||||
"trainee": "Иванов",
|
||||
"mode": "training",
|
||||
"exercise": "dds",
|
||||
"random_scenario_ids": POOL,
|
||||
})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, control
|
||||
|
||||
|
||||
def row_for(client, session_id):
|
||||
rows = client.get("/api/sessions/active").json()
|
||||
return next(item for item in rows if item["session_id"] == str(session_id))
|
||||
|
||||
|
||||
def signal_kinds(row):
|
||||
return {signal["kind"] for signal in row["signals"]}
|
||||
|
||||
|
||||
def test_signal_backlog_when_queue_reaches_threshold(client, monkeypatch):
|
||||
monkeypatch.setenv("SIGNAL_BACKLOG_THRESHOLD", "2")
|
||||
get_settings.cache_clear()
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assert len(state.desk.cards) == 2, "оба билета должны прийти сразу без интервала"
|
||||
row = row_for(client, session_id)
|
||||
assert row["dds_open_cards"] == 2
|
||||
assert "backlog" in signal_kinds(row)
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_signal_refusals_after_two_consecutive_declines(client):
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
first_card, second_card = state.desk.ordered()
|
||||
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
|
||||
read_until(station, "station.state")
|
||||
first_service = state.card_services(first_card)[0]
|
||||
station.send_json({
|
||||
"type": "card.status", "service": first_service, "status": "declined",
|
||||
"comment": "не наш адрес, передано в УК",
|
||||
})
|
||||
read_until(station, "station.state")
|
||||
|
||||
row = row_for(client, session_id)
|
||||
assert "refusals" not in signal_kinds(row), "одного отказа недостаточно для сигнала"
|
||||
|
||||
station.send_json({"type": "card.open", "card_id": str(second_card.card_id)})
|
||||
read_until(station, "station.state")
|
||||
second_service = state.card_services(second_card)[0]
|
||||
station.send_json({
|
||||
"type": "card.status", "service": second_service, "status": "declined",
|
||||
"comment": "не наша территория, передано в ОМВД",
|
||||
})
|
||||
read_until(station, "station.state")
|
||||
|
||||
row = row_for(client, session_id)
|
||||
assert state.consecutive_refusals == 2
|
||||
assert "refusals" in signal_kinds(row)
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_signal_offline_when_station_socket_is_closed_past_the_window(client):
|
||||
session_id, control = start_two_card_dds(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
|
||||
read_until(station, "station.state")
|
||||
row = row_for(client, session_id)
|
||||
assert "offline" not in signal_kinds(row), "сокет подключён — сигнала быть не должно"
|
||||
|
||||
window = get_settings().signal_offline_window_seconds
|
||||
state.socket_last_seen_at = datetime.now(UTC) - timedelta(seconds=window + 1)
|
||||
row = row_for(client, session_id)
|
||||
assert "offline" in signal_kinds(row)
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
|
@ -338,7 +338,7 @@ export function Instructor() {
|
|||
Список ваших активных сессий обновляется автоматически каждые 2 секунды.
|
||||
</p>
|
||||
<div className="dds-table-wrap"><table className="grid">
|
||||
<thead><tr><th>Курсант</th><th>Упражнение · карточка</th><th>Ход</th><th>ДДС</th><th>Задержки</th><th></th></tr></thead>
|
||||
<thead><tr><th>Курсант</th><th>Упражнение · карточка</th><th>Ход</th><th>ДДС</th><th>Задержки</th><th>Сигналы</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(activeSessions.data ?? []).map((item) => (
|
||||
<tr key={item.session_id}>
|
||||
|
|
@ -383,12 +383,17 @@ export function Instructor() {
|
|||
? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}`
|
||||
: "Нормативы не нарушены"}
|
||||
</td>
|
||||
<td className={item.signals.length ? `state-${item.signals[0].severity}` : "state-ok"}>
|
||||
{item.signals.length
|
||||
? item.signals.map((signal) => <span key={signal.kind}>{signal.text}<br /></span>)
|
||||
: "Сигналов нет"}
|
||||
</td>
|
||||
<td><button type="button" onClick={() => openActiveSession(item)}>
|
||||
{sessionId === item.session_id ? "Открыто" : "Наблюдать"}
|
||||
</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{!activeSessions.data?.length && <tr><td colSpan={6}>
|
||||
{!activeSessions.data?.length && <tr><td colSpan={7}>
|
||||
{activeSessions.isLoading ? "Загружаем активные занятия…" : "Активных занятий пока нет."}
|
||||
</td></tr>}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -94,6 +94,12 @@ export interface SessionInfo {
|
|||
end_reason: string | null;
|
||||
}
|
||||
|
||||
export interface Signal {
|
||||
kind: "backlog" | "refusals" | "offline";
|
||||
severity: "warn" | "violated";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ActiveSessionInfo {
|
||||
session_id: string;
|
||||
trainee_name: string | null;
|
||||
|
|
@ -109,6 +115,7 @@ export interface ActiveSessionInfo {
|
|||
dds_work_overdue_cards: number;
|
||||
dds_statuses: Record<string, string>;
|
||||
dds_snapshot: StationSnapshot | null;
|
||||
signals: Signal[];
|
||||
}
|
||||
|
||||
export const useActiveSessions = () =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue