From 62d1010b79d5d6153c877c9edecee92ae13c08e7 Mon Sep 17 00:00:00 2001 From: GGlamer <52128225+Gamer201760@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:15:04 +0300 Subject: [PATCH] =?UTF-8?q?chore:=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=20main,=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B2=20sessions.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/http/sessions.py | 72 ++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index 6379d08..0832ccf 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -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.taxonomy import ( ERRORS, ErrorCode, @@ -32,7 +33,7 @@ from app.domain.taxonomy import ( FindingReview, FindingSource, ) -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.report import build as build_report from app.scoring.taxonomy import METRIC_MAP @@ -41,7 +42,7 @@ from app.session.checkpoint import load_state from app.session.finish import change_findings, override_score from app.session.score import scoring_scenario from app.session.hub import hub -from app.session.state import now_utc +from app.session.state import SessionState, now_utc from app.session.store import ( FindingAdded, FindingReviewed, @@ -102,6 +103,15 @@ class DdsHistoryOut(BaseModel): recipient_services: list[str] = [] +class Signal(BaseModel): + """Одна строка колонки сигналов реестра. Цвет несёт смысл только через + `severity`; текст обязателен и не заменяется цветом.""" + + kind: Literal["backlog", "refusals", "offline"] + severity: TimerState + text: str + + class ActiveSessionOut(BaseModel): session_id: UUID trainee_name: str | None @@ -117,6 +127,8 @@ class ActiveSessionOut(BaseModel): dds_work_overdue_cards: int dds_statuses: dict[str, str] dds_snapshot: StationSnapshot | None = None + signals: list[Signal] = [] + presence_known: bool = True def _out(session) -> SessionOut: @@ -200,6 +212,52 @@ async def dds_history( return result +def _signals( + state: SessionState, queue: list[DdsQueueCard], now: datetime, *, live: bool +) -> list[Signal]: + """Сигналы реестра: очередь, повторные отказы, курсант не на связи. + + Не завязаны на паузу занятия (`session.pause`, если появится) — очередь, + отказы и присутствие сокета не читают таймеры. Присутствие — по наличию + подписчика в хабе этого узла; окно нужно, чтобы короткий разрыв + соединения не сразу считался потерей связи курсанта. + + `live=False` — снимок чужого узла: его сокеты здесь не видны, поэтому + присутствие неизвестно. Чтение реестра не меняет состояние занятия. + """ + settings = get_settings() + signals: list[Signal] = [] + # Необработанная — без первичного статуса: он останавливает норматив + # DDS_ACK. Принятая или отклонённая карточка висит в очереди до + # «Следующей», но реакции курсанта уже не ждёт. + pending = sum(not card.timer_stopped for card in queue) + if pending >= settings.signal_backlog_threshold: + signals.append(Signal( + kind="backlog", severity=TimerState.WARN, + text=f"Очередь: {pending} необработанных карточек", + )) + if state.consecutive_refusals >= settings.signal_refusals_threshold: + signals.append(Signal( + kind="refusals", severity=TimerState.WARN, + text=f"Подряд отказов: {state.consecutive_refusals}", + )) + if live: + connected = ( + hub.station_connected(state.session_id) if state.dds_phase + else hub.trainee_connected(state.session_id) + ) + offline_seconds = ( + (now - (state.socket_last_seen_at or state.started_at)).total_seconds() + if (state.socket_last_seen_at or state.started_at) is not None else 0 + ) + if not connected and offline_seconds >= settings.signal_offline_window_seconds: + signals.append(Signal( + kind="offline", severity=TimerState.VIOLATED, + text=f"Курсант не на связи {int(offline_seconds)} с", + )) + return sorted(signals, key=lambda signal: signal.severity is not TimerState.VIOLATED) + + @router.get("/active", response_model=list[ActiveSessionOut]) async def active( request: Request, @@ -213,6 +271,9 @@ async def active( state.session_id: state for state in hub.active_sessions(who.login) } + #: Снимки без живой записи в хабе этого узла — чужой узел или сессия, + #: ожидающая восстановления. Её сокеты нельзя проверить этим процессом. + checkpoint_only: set[UUID] = set() if db is not None: rows = ( await db.scalars( @@ -241,11 +302,12 @@ async def active( state.owner_login = row.owner_login if not state.ended: states[state.session_id] = state + checkpoint_only.add(state.session_id) for state in states.values(): elapsed = (max(0, int((now - state.started_at).total_seconds())) if state.started_at else 0) - station = state.station_snapshot() if state.exercise is Exercise.DDS else None + station = state.station_snapshot() if state.dds_phase else None queue = station.queue_cards if station else [] card = state.desk.active status_log = card.status_log if card is not None else [] @@ -278,6 +340,8 @@ async def active( ), dds_statuses=latest_statuses, dds_snapshot=station, + signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only), + presence_known=state.session_id not in checkpoint_only, )) return result