fix: сигнал очереди считает только карточки без первичного статуса, критичность сигнала через TimerState

This commit is contained in:
GGlamer 2026-09-27 22:20:49 +03:00
commit 43d87c59af
3 changed files with 44 additions and 14 deletions

View file

@ -25,7 +25,7 @@ from app.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport from app.domain.events import Exercise, SessionMode, SessionReport
from app.domain.roles import Role from app.domain.roles import Role
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
from app.domain.timers import TimerCode from app.domain.timers import TimerCode, TimerState
from app.scoring.export import to_csv, to_pdf from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report from app.scoring.report import build as build_report
from app.session.access import can_access from app.session.access import can_access
@ -93,7 +93,7 @@ class Signal(BaseModel):
`severity`; текст обязателен и не заменяется цветом.""" `severity`; текст обязателен и не заменяется цветом."""
kind: Literal["backlog", "refusals", "offline"] kind: Literal["backlog", "refusals", "offline"]
severity: Literal["warn", "violated"] severity: TimerState
text: str text: str
@ -212,14 +212,18 @@ def _signals(
""" """
settings = get_settings() settings = get_settings()
signals: list[Signal] = [] signals: list[Signal] = []
if len(queue) >= settings.signal_backlog_threshold: # Необработанная — без первичного статуса: он останавливает норматив
# DDS_ACK. Принятая или отклонённая карточка висит в очереди до
# «Следующей», но реакции курсанта уже не ждёт.
pending = sum(not card.timer_stopped for card in queue)
if pending >= settings.signal_backlog_threshold:
signals.append(Signal( signals.append(Signal(
kind="backlog", severity="warn", kind="backlog", severity=TimerState.WARN,
text=f"Очередь: {len(queue)} необработанных карточек", text=f"Очередь: {pending} необработанных карточек",
)) ))
if state.consecutive_refusals >= settings.signal_refusals_threshold: if state.consecutive_refusals >= settings.signal_refusals_threshold:
signals.append(Signal( signals.append(Signal(
kind="refusals", severity="warn", kind="refusals", severity=TimerState.WARN,
text=f"Подряд отказов: {state.consecutive_refusals}", text=f"Подряд отказов: {state.consecutive_refusals}",
)) ))
if live: if live:
@ -233,10 +237,10 @@ def _signals(
) )
if not connected and offline_seconds >= settings.signal_offline_window_seconds: if not connected and offline_seconds >= settings.signal_offline_window_seconds:
signals.append(Signal( signals.append(Signal(
kind="offline", severity="violated", kind="offline", severity=TimerState.VIOLATED,
text=f"Курсант не на связи {int(offline_seconds)} с", text=f"Курсант не на связи {int(offline_seconds)} с",
)) ))
return sorted(signals, key=lambda signal: signal.severity != "violated") return sorted(signals, key=lambda signal: signal.severity is not TimerState.VIOLATED)
@router.get("/active", response_model=list[ActiveSessionOut]) @router.get("/active", response_model=list[ActiveSessionOut])

View file

@ -3,17 +3,18 @@
import asyncio import asyncio
import time import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.engine.result import IteratorResult, SimpleResultMetaData
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from app.api.auth import Principal from app.api.auth import Principal
from app.api.http import sessions as sessions_http from app.api.http import sessions as sessions_http
from app.api.ws import session as session_ws from app.api.ws import session as session_ws
from app.config import get_settings from app.config import get_settings
from app.db.models import Session
from app.domain.events import Exercise, SessionMode from app.domain.events import Exercise, SessionMode
from app.domain.roles import Role from app.domain.roles import Role
from app.main import app from app.main import app
@ -156,6 +157,28 @@ def test_signal_backlog_when_queue_reaches_threshold(client, monkeypatch):
control.__exit__(None, None, None) control.__exit__(None, None, None)
def test_declined_card_leaves_backlog(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)
first_card = state.desk.ordered()[0]
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
read_until(station, "station.state")
station.send_json({
"type": "card.status", "service": state.card_services(first_card)[0],
"status": "declined", "comment": "не наш адрес, передано в УК",
})
read_until(station, "station.state")
row = row_for(client, session_id)
assert row["dds_open_cards"] == 2, "отклонённая карточка ждёт «Следующей» на пульте"
assert "backlog" not in signal_kinds(row)
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_signal_refusals_after_two_consecutive_declines(client): def test_signal_refusals_after_two_consecutive_declines(client):
session_id, control = start_two_card_dds(client) session_id, control = start_two_card_dds(client)
try: try:
@ -406,11 +429,12 @@ def test_checkpoint_on_another_node_marks_presence_unknown(client, monkeypatch):
checkpoint_at = datetime.now(UTC) checkpoint_at = datetime.now(UTC)
hub.drop(session_id) hub.drop(session_id)
row = Session(id=session_id, owner_login="dev",
live_state=snapshot, checkpoint_at=checkpoint_at)
class CheckpointDB: class CheckpointDB:
async def scalars(self, _query): async def scalars(self, _query):
row = SimpleNamespace(id=session_id, owner_login="dev", return IteratorResult(SimpleResultMetaData(["Session"]), iter([(row,)])).scalars()
live_state=snapshot, checkpoint_at=checkpoint_at)
return SimpleNamespace(all=lambda: [row])
monkeypatch.setattr(sessions_http, "require", lambda _request, _role: monkeypatch.setattr(sessions_http, "require", lambda _request, _role:
Principal(login="dev", full_name="Преподаватель", Principal(login="dev", full_name="Преподаватель",

View file

@ -3,7 +3,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Level, SessionMode, SessionReport, StationSnapshot } from "@/shared/types/generated"; import type {
Level, SessionMode, SessionReport, StationSnapshot, TimerState,
} from "@/shared/types/generated";
import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs"; import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs";
@ -96,7 +98,7 @@ export interface SessionInfo {
export interface Signal { export interface Signal {
kind: "backlog" | "refusals" | "offline"; kind: "backlog" | "refusals" | "offline";
severity: "warn" | "violated"; severity: TimerState;
text: string; text: string;
} }