chore: слияние с main, разрешены конфликты импортов в sessions.py
This commit is contained in:
parent
7abbce0ef8
commit
62d1010b79
1 changed files with 68 additions and 4 deletions
|
|
@ -8,6 +8,7 @@ import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
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.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, StationSnapshot, current
|
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
|
||||||
from app.domain.taxonomy import (
|
from app.domain.taxonomy import (
|
||||||
ERRORS,
|
ERRORS,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
|
@ -32,7 +33,7 @@ from app.domain.taxonomy import (
|
||||||
FindingReview,
|
FindingReview,
|
||||||
FindingSource,
|
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.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.scoring.taxonomy import METRIC_MAP
|
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.finish import change_findings, override_score
|
||||||
from app.session.score import scoring_scenario
|
from app.session.score import scoring_scenario
|
||||||
from app.session.hub import hub
|
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 (
|
from app.session.store import (
|
||||||
FindingAdded,
|
FindingAdded,
|
||||||
FindingReviewed,
|
FindingReviewed,
|
||||||
|
|
@ -102,6 +103,15 @@ class DdsHistoryOut(BaseModel):
|
||||||
recipient_services: list[str] = []
|
recipient_services: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class Signal(BaseModel):
|
||||||
|
"""Одна строка колонки сигналов реестра. Цвет несёт смысл только через
|
||||||
|
`severity`; текст обязателен и не заменяется цветом."""
|
||||||
|
|
||||||
|
kind: Literal["backlog", "refusals", "offline"]
|
||||||
|
severity: TimerState
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
class ActiveSessionOut(BaseModel):
|
class ActiveSessionOut(BaseModel):
|
||||||
session_id: UUID
|
session_id: UUID
|
||||||
trainee_name: str | None
|
trainee_name: str | None
|
||||||
|
|
@ -117,6 +127,8 @@ class ActiveSessionOut(BaseModel):
|
||||||
dds_work_overdue_cards: int
|
dds_work_overdue_cards: int
|
||||||
dds_statuses: dict[str, str]
|
dds_statuses: dict[str, str]
|
||||||
dds_snapshot: StationSnapshot | None = None
|
dds_snapshot: StationSnapshot | None = None
|
||||||
|
signals: list[Signal] = []
|
||||||
|
presence_known: bool = True
|
||||||
|
|
||||||
|
|
||||||
def _out(session) -> SessionOut:
|
def _out(session) -> SessionOut:
|
||||||
|
|
@ -200,6 +212,52 @@ async def dds_history(
|
||||||
return result
|
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])
|
@router.get("/active", response_model=list[ActiveSessionOut])
|
||||||
async def active(
|
async def active(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -213,6 +271,9 @@ async def active(
|
||||||
state.session_id: state
|
state.session_id: state
|
||||||
for state in hub.active_sessions(who.login)
|
for state in hub.active_sessions(who.login)
|
||||||
}
|
}
|
||||||
|
#: Снимки без живой записи в хабе этого узла — чужой узел или сессия,
|
||||||
|
#: ожидающая восстановления. Её сокеты нельзя проверить этим процессом.
|
||||||
|
checkpoint_only: set[UUID] = set()
|
||||||
if db is not None:
|
if db is not None:
|
||||||
rows = (
|
rows = (
|
||||||
await db.scalars(
|
await db.scalars(
|
||||||
|
|
@ -241,11 +302,12 @@ async def active(
|
||||||
state.owner_login = row.owner_login
|
state.owner_login = row.owner_login
|
||||||
if not state.ended:
|
if not state.ended:
|
||||||
states[state.session_id] = state
|
states[state.session_id] = state
|
||||||
|
checkpoint_only.add(state.session_id)
|
||||||
|
|
||||||
for state in states.values():
|
for state in states.values():
|
||||||
elapsed = (max(0, int((now - state.started_at).total_seconds()))
|
elapsed = (max(0, int((now - state.started_at).total_seconds()))
|
||||||
if state.started_at else 0)
|
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 []
|
queue = station.queue_cards if station else []
|
||||||
card = state.desk.active
|
card = state.desk.active
|
||||||
status_log = card.status_log if card is not None else []
|
status_log = card.status_log if card is not None else []
|
||||||
|
|
@ -278,6 +340,8 @@ async def active(
|
||||||
),
|
),
|
||||||
dds_statuses=latest_statuses,
|
dds_statuses=latest_statuses,
|
||||||
dds_snapshot=station,
|
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
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue