lct-hack/backend/app/api/http/sessions.py
kaifarikman 0d58878cbe chore: обновить паузу занятия от main
# Conflicts:
#	backend/app/scoring/dispatcher.py
#	backend/app/session/store.py
2026-09-27 23:30:18 +03:00

872 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сессии: создание, состояние, история.
`group_id` и `mode` принимаются с первого дня — размечать накопленные сессии
задним числом не надо (docs/arch/CONTRACT.md#http-api).
"""
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
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import add_audit_entry, audit, audit_required, require
from app.config import get_settings
from app.db import repo
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.taxonomy import (
ERRORS,
ErrorCode,
Finding,
FindingDecision,
FindingReview,
FindingSource,
)
from app.domain.roles import Role
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
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.review import has_finding
from app.scoring.taxonomy import METRIC_MAP
from app.session.access import can_access
from app.session.checkpoint import load_state
from app.session.finish import change_findings, override_score
from app.session.store import FindingAdded, FindingReviewed
from app.session.timers import now_utc
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
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
log = logging.getLogger(__name__)
async def optional_session() -> AsyncIterator[AsyncSession | None]:
if get_settings().demo_no_db:
yield None
else:
async for db in get_session():
yield db
class SessionCreate(BaseModel):
scenario_id: str
mode: SessionMode
trainee: str | None = None
group: str | None = None
class SessionOut(BaseModel):
session_id: UUID
scenario_id: str
mode: SessionMode
attempt: int
trainee_id: UUID | None = None
group_id: UUID | None = None
started_at: datetime | None = None
ended_at: datetime | None = None
end_reason: str | None = None
class DdsHistoryOut(BaseModel):
"""Одна завершённая карточка из отчёта занятия; только в границах владельца."""
session_id: UUID
ended_at: datetime
card_id: UUID
scenario_id: str
score_auto: float
score_final: float
reply_text: str = ""
title: str | None = None
address: str | None = None
description: str | None = None
incident_type: str | None = None
victims_count: int | None = None
received_at: datetime | None = None
managed_service: str | None = None
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
scenario_id: str
scenario_title: str
mode: SessionMode
exercise: Exercise
started_at: datetime | None
elapsed_seconds: int
dds_card_total: int
dds_open_cards: int
dds_overdue_cards: int
dds_work_overdue_cards: int
dds_statuses: dict[str, str]
paused: bool = False
dds_snapshot: StationSnapshot | None = None
signals: list[Signal] = []
presence_known: bool = True
def _out(session) -> SessionOut:
return SessionOut(
session_id=session.id,
scenario_id=session.scenario_id,
mode=session.mode,
attempt=session.attempt,
trainee_id=session.trainee_id,
group_id=session.group_id,
started_at=session.started_at,
ended_at=session.ended_at,
end_reason=session.end_reason,
)
@router.get("/dds-history", response_model=list[DdsHistoryOut])
async def dds_history(
request: Request,
limit: int = Query(default=200, ge=1, le=500),
db: AsyncSession | None = Depends(optional_session),
) -> list[DdsHistoryOut]:
"""Durable completed-card registry, limited to the current trainee/instructor."""
who = require(request, Role.TRAINEE, Role.INSTRUCTOR)
if db is None:
await audit_required(
who.login, who.role.value, "dds.history.read", detail="cards=0"
)
return []
statement = (
select(Session, Score)
.join(Score, Score.session_id == Session.id)
.where(Session.ended_at.is_not(None))
.order_by(Session.ended_at.desc())
.limit(limit)
)
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
statement = statement.where(Session.trainee_id == who.trainee_id)
else:
statement = statement.where(Session.owner_login == who.login)
rows = (await db.execute(statement)).all()
result: list[DdsHistoryOut] = []
for session, score in rows:
report = score.report or {}
full_report = report.get("full_report") or report
if full_report.get("exercise") != Exercise.DDS.value:
continue
for card in full_report.get("card_results", []):
try:
result.append(DdsHistoryOut(
session_id=session.id,
ended_at=session.ended_at,
card_id=card["card_id"],
scenario_id=card["scenario_id"],
score_auto=card["score_auto"],
score_final=score.score_final,
reply_text=card.get("reply_text", ""),
title=card.get("title"),
address=card.get("address"),
description=card.get("description"),
incident_type=card.get("incident_type"),
victims_count=card.get("victims_count"),
received_at=card.get("received_at"),
managed_service=card.get("managed_service"),
recipient_services=card.get("recipient_services", []),
))
except (KeyError, TypeError, ValueError):
log.warning("Пропущена некорректная карточка ДДС в отчёте сессии %s", session.id)
if len(result) >= limit:
await audit_required(
who.login, who.role.value, "dds.history.read",
detail=f"cards={len(result)}",
)
return result
await audit_required(
who.login, who.role.value, "dds.history.read", detail=f"cards={len(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])
async def active(
request: Request,
db: AsyncSession | None = Depends(optional_session),
) -> list[ActiveSessionOut]:
"""Компактный live-реестр сессий преподавателя; детали остаются в /ws/observe."""
who = require(request, Role.INSTRUCTOR)
now = datetime.now(UTC)
result: list[ActiveSessionOut] = []
states = {
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(
select(Session).where(
Session.owner_login == who.login,
Session.ended_at.is_(None),
Session.live_state.is_not(None),
Session.checkpoint_at.is_not(None),
)
)
).all()
for row in rows:
local = hub.get(row.id)
if local is not None:
if local.owner_login == who.login and not local.ended:
states[row.id] = local
else:
states.pop(row.id, None)
continue
try:
state = load_state(row.live_state, row.checkpoint_at)
except Exception as exc: # noqa: BLE001 — один плохой checkpoint не ломает весь реестр
log.error("Не удалось прочитать checkpoint сессии %s (%s)",
row.id, type(exc).__name__)
continue
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():
paused_ms = state.total_paused_ms
if state.paused and state.paused_at is not None:
paused_ms += max(0, int((now - state.paused_at).total_seconds() * 1000))
elapsed = (max(0, int((now - state.started_at).total_seconds() - paused_ms / 1000))
if state.started_at else 0)
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 []
managed_services = state.managed_services()
latest_statuses = {
service: SERVICE_STATUS_LABELS[current(status_log, service)]
for service in managed_services
if (status_log or state.exercise is Exercise.DDS)
}
result.append(ActiveSessionOut(
session_id=state.session_id,
trainee_name=state.trainee_name,
scenario_id=state.scenario_id,
scenario_title=state.scenario_title,
mode=state.mode,
exercise=state.exercise,
started_at=state.started_at,
elapsed_seconds=elapsed,
dds_card_total=len(state.desk.scenarios),
dds_open_cards=len(queue),
dds_overdue_cards=sum(
not card.timer_stopped and card.elapsed_ms > card.limit_ms for card in queue
),
dds_work_overdue_cards=sum(
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None
# Пауза держит `started_at=None`, хотя таймер уже шёл: судить
# по нему одному спрятало бы уже случившееся нарушение (lct-39).
and (timer.started_at is not None or timer.paused)
and not timer.stopped
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
for card in state.desk.cards.values()
),
dds_statuses=latest_statuses,
paused=state.paused,
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
@router.post("", response_model=SessionOut, status_code=201)
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
who = require(request, Role.INSTRUCTOR)
group_created = False
try:
if body.group:
group = await db.scalar(select(Group).where(Group.name == body.group))
group_created = group is None
group = await repo.ensure_group(
db, body.group, owner_login=who.login, commit=False
)
else:
group = None
except PermissionError as exc:
raise HTTPException(status_code=404, detail="group_not_found") from exc
trainee_created = False
if body.trainee:
trainee = await db.scalar(select(Trainee).where(Trainee.name == body.trainee))
trainee_created = trainee is None
try:
trainee = await repo.ensure_trainee(
db, body.trainee, group, owner_login=who.login, commit=False
)
except PermissionError as exc:
# A group created earlier in this same request must not be left
# behind when the selected learner is outside this instructor's scope.
await db.rollback()
raise HTTPException(status_code=404, detail="trainee_not_found") from exc
else:
trainee = None
def audit_creation(transaction, row):
if group_created and group is not None:
add_audit_entry(
transaction, who.login, who.role.value,
"group.create", str(group.id), group.name,
)
if trainee_created and trainee is not None:
add_audit_entry(
transaction, who.login, who.role.value,
"trainee.profile.create", str(trainee.id),
)
add_audit_entry(
transaction,
who.login,
who.role.value,
"session.create",
str(row.id),
f"scenario={row.scenario_id}; mode={row.mode}; attempt={row.attempt}",
)
session = await repo.create_session(
db,
scenario_id=body.scenario_id,
mode=body.mode.value,
trainee_id=trainee.id if trainee else None,
group_id=group.id if group else None,
owner_login=who.login,
backend_node_id=get_settings().backend_node_id,
before_commit=audit_creation,
)
return _out(session)
def _require_access(who, lesson, what: str = "session") -> None:
"""Отказ по правилу `can_access`: курсанту — 403, чужому преподавателю
занятие «не существует» — 404, владелец не раскрывается."""
if can_access(who, lesson):
return
if who.role is Role.TRAINEE:
raise HTTPException(status_code=403, detail=f"not_your_{what}")
raise HTTPException(status_code=404, detail=f"{what}_not_found")
@router.get("/{session_id}", response_model=SessionOut)
async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
who = require(request)
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, session)
return _out(session)
class ChecklistItemOut(BaseModel):
id: str
question: str
@router.get("/{session_id}/checklist", response_model=list[ChecklistItemOut])
async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut]:
"""Чек-лист для самооценки — **только после конца звонка**.
Во время звонка это содержимое подсказок: отдать его значит выдать
в контрольном режиме то, чего там быть не должно. После звонка курсант
по нему отмечает, что, по его мнению, пропустил.
"""
who = require(request)
state = hub.get(session_id)
if state is None:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, state)
if not state.ended:
raise HTTPException(status_code=409, detail="call_not_ended")
scenario = scoring_scenario(state)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")
return [
ChecklistItemOut(id=item.id, question=item.question)
for item in scenario.checklist
if item.question
]
class ScoreOverride(BaseModel):
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
score_final: float = Field(ge=0, le=100)
comment: str = Field(min_length=1, max_length=2000)
@field_validator("comment")
@classmethod
def comment_must_not_be_blank(cls, comment: str) -> str:
cleaned = comment.strip()
if not cleaned:
raise ValueError("обоснование корректировки обязательно")
return cleaned
def _live(session_id: UUID):
state = hub.get(session_id)
if state is None:
raise HTTPException(status_code=404, detail="session_not_found")
scenario = scoring_scenario(state)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")
return state, scenario
async def _report_data(
session_id: UUID, request: Request, db: AsyncSession | None,
) -> SessionReport:
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
Обучающийся открывает только свой разбор: ТЗ запрещает доступ к чужим
результатам, а не только к чужим экранам. Проверка по `trainee_id`
занятия, а не по номеру в ссылке.
"""
who = require(request)
try:
state, scenario = _live(session_id)
except HTTPException as exc:
if exc.status_code != 404:
raise
state = None
scenario = None
if state is not None and scenario is not None:
_require_access(who, state)
if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
raise HTTPException(status_code=409, detail="self_assessment_required")
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
if hub.store.persistent and isinstance(db, AsyncSession):
persisted_session = await db.scalar(
select(Session.id).where(Session.id == session_id)
)
if (persisted_session is not None and await db.scalar(
select(Score.session_id).where(Score.session_id == session_id)
) is None):
# Live state is populated just before the store commit finishes.
# Do not expose a report that looks ready but cannot yet be corrected
# or retrieved after restart.
raise HTTPException(status_code=409, detail="score_not_ready")
return build_report(session_id, state, scenario)
if db is None:
raise HTTPException(status_code=404, detail="session_not_found")
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, session)
score = await db.scalar(select(Score).where(Score.session_id == session_id))
if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
archived = (score.report or {}).get("full_report")
if archived is None:
raise HTTPException(status_code=409, detail="report_not_archived")
data = SessionReport.model_validate(archived)
if (who.role is Role.TRAINEE and data.reference_questions
and data.self_assessment is None):
raise HTTPException(status_code=409, detail="self_assessment_required")
return data.model_copy(update={
"score_auto": score.score_auto,
"score_final": score.score_final,
"overridden_by": score.overridden_by,
"override_comment": score.override_comment,
})
@router.get("/{session_id}/report", response_model=SessionReport)
async def report(
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
data = await _report_data(session_id, request, db)
who = require(request)
await audit_required(who.login, who.role.value, "report.read", str(session_id))
return data
@router.get("/{session_id}/report.csv")
async def report_csv(
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
) -> Response:
"""Те же права и готовность оценки, что у JSON-разбора."""
data = await _report_data(session_id, request, db)
content = to_csv(data)
who = require(request)
await audit_required(who.login, who.role.value, "report.export.csv", str(session_id))
return Response(
content=content, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
)
@router.get("/{session_id}/report.pdf")
async def report_pdf(
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
) -> Response:
"""Печатный разбор; генерация полностью локальна."""
data = await _report_data(session_id, request, db)
try:
content = to_pdf(data)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
who = require(request)
await audit_required(who.login, who.role.value, "report.export.pdf", str(session_id))
return Response(
content=content, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.pdf"'},
)
@router.get("/{session_id}/recording.wav")
async def recording(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)):
"""Запись учебного звонка: преподавателю либо владельцу занятия."""
who = require(request, Role.INSTRUCTOR, Role.TRAINEE)
if get_settings().demo_no_db:
state = hub.get(session_id)
if state is None:
raise HTTPException(status_code=404, detail="session_not_found")
lesson, ended = state, state.ended
else:
row = await repo.get_session(db, session_id)
if row is None:
raise HTTPException(status_code=404, detail="session_not_found")
lesson, ended = row, row.ended_at is not None
_require_access(who, lesson, "recording")
if not ended:
raise HTTPException(status_code=409, detail="recording_not_ready")
path = recording_path(session_id)
if not path.is_file():
raise HTTPException(status_code=404, detail="recording_not_found")
await audit_required(who.login, who.role.value, "recording.read", str(session_id))
return FileResponse(
path,
media_type="audio/wav",
filename=f"session-{session_id}-recording.wav",
)
@router.patch("/{session_id}/report", response_model=SessionReport)
async def override(
session_id: UUID,
body: ScoreOverride,
request: Request,
db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
Правка идёт тем же путём, что команда `score.override` с пульта.
"""
who = require(request, Role.INSTRUCTOR)
correction = ScoreOverridden(
score_final=body.score_final, author=who.login, role=who.role.value,
comment=body.comment,
)
state = hub.get(session_id)
if state is not None:
_require_access(who, state)
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
scenario = scoring_scenario(state)
if scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
async with hub.operation(session_id):
override_score(state, correction)
return build_report(session_id, state, scenario)
# Занятия нет в памяти узла: правка из истории, по архивному разбору.
if db is None:
raise HTTPException(status_code=404, detail="session_not_found")
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, session)
score = await db.scalar(select(Score).where(Score.session_id == session_id))
if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
archived = (score.report or {}).get("full_report")
if archived is None:
raise HTTPException(status_code=409, detail="report_not_archived")
report = apply_score_override(dict(score.report), correction)
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
await hub.store.commit_archived(session_id, [correction])
return SessionReport.model_validate(report["full_report"])
def _required_text(value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("поле обязательно")
return cleaned
class FindingReviewIn(BaseModel):
"""Решение по отметке: причина обязательна и остаётся в разборе."""
decision: FindingDecision
reason: str = Field(min_length=1, max_length=1000)
_reason = field_validator("reason")(_required_text)
class FindingIn(BaseModel):
"""Отметка преподавателя: код, факт и норма — то же обоснование, что у автоматической."""
code: ErrorCode
fact: str = Field(min_length=1, max_length=1000)
norm: str = Field(min_length=1, max_length=1000)
#: Номер карточки очереди ДДС (с 1); без него отметка относится к занятию.
card: int | None = Field(default=None, ge=1)
#: Ключ идемпотентности с клиента: повтор запроса не добавит отметку второй раз.
client_id: UUID | None = None
_fact = field_validator("fact")(_required_text)
_norm = field_validator("norm")(_required_text)
async def _change_findings(
session_id: UUID, who, db: AsyncSession | None, make,
) -> SessionReport:
"""Общий путь решений по отметкам — тот же, что у правки итога.
`make(who, score)` строит запись изменения по сохранённой оценке или
отвечает HTTP-ошибкой. Роль проверяет маршрут: администратору сюда нельзя,
как и к правке балла.
"""
state = hub.get(session_id)
if state is not None:
_require_access(who, state)
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
scenario = scoring_scenario(state)
if scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
change = make(who, state.score)
async with hub.operation(session_id):
if not _repeated(state.score, change):
change_findings(state, change)
return build_report(session_id, state, scenario)
if db is None:
raise HTTPException(status_code=404, detail="session_not_found")
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, session)
score = await db.scalar(select(Score).where(Score.session_id == session_id))
if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
if (score.report or {}).get("full_report") is None:
raise HTTPException(status_code=409, detail="report_not_archived")
current = dict(score.report)
change = make(who, current)
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
if not _repeated(current, change):
await hub.store.commit_archived(session_id, [change])
# Ответ — по строке после commit: пересчёт шёл под блокировкой и мог учесть
# решение из соседней вкладки, которого не было в прочитанном выше отчёте.
score = await db.scalar(
select(Score).where(Score.session_id == session_id)
.execution_options(populate_existing=True)
)
report = SessionReport.model_validate(score.report["full_report"])
await db.rollback()
return report
def _repeated(report: dict, change: FindingReviewed | FindingAdded) -> bool:
return isinstance(change, FindingAdded) and has_finding(report, change.finding.client_id)
@router.post("/{session_id}/findings/{index}/review", response_model=SessionReport)
async def review_finding(
session_id: UUID,
index: int,
body: FindingReviewIn,
request: Request,
db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
"""Подтвердить или снять отметку; снятая не штрафует свою метрику."""
who = require(request, Role.INSTRUCTOR)
def make(who, score: dict) -> FindingReviewed:
if not 0 <= index < len(score.get("findings", [])):
raise HTTPException(status_code=404, detail="finding_not_found")
return FindingReviewed(index=index, role=who.role.value, review=FindingReview(
decision=body.decision, reason=body.reason, author=who.login, at=now_utc(),
))
return await _change_findings(session_id, who, db, make)
@router.post("/{session_id}/findings", response_model=SessionReport)
async def add_finding(
session_id: UUID,
body: FindingIn,
request: Request,
db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
"""Отметка преподавателя штрафует связанную метрику по той же методике."""
who = require(request, Role.INSTRUCTOR)
def make(who, score: dict) -> FindingAdded:
service = None
prefix = ""
exercise = (score.get("full_report") or {}).get("exercise")
# E1–E6 в связке 112 → ДДС — ошибки приёма вызова: их метрики у занятия,
# а не у карточки очереди. С номером карточки отметка не нашла бы свою
# метрику 112 и штрафовала бы отдельной метрикой преподавателя.
if (body.card is not None and body.code.value.startswith("E")
and exercise != Exercise.DDS.value):
raise HTTPException(status_code=422, detail="call_code_without_card")
if body.card is not None:
cards = score.get("card_results") or []
if body.card > len(cards):
raise HTTPException(status_code=422, detail="card_not_found")
service = cards[body.card - 1].get("managed_service")
prefix = f"Карточка {body.card}: " + (f"{service}: " if service else "")
competency = next((item for code, item in METRIC_MAP.values() if code is body.code), None)
return FindingAdded(role=who.role.value, finding=Finding(
code=body.code,
source=FindingSource.INSTRUCTOR,
summary=f"{prefix}{ERRORS[body.code].title}",
fact=body.fact,
norm=body.norm,
ref="отметка преподавателя на разборе",
competency=competency,
at=now_utc(),
card=body.card,
service=service,
author=who.login,
client_id=body.client_id,
))
return await _change_findings(session_id, who, db, make)
@router.get("", response_model=list[SessionOut])
async def listing(
request: Request,
trainee: UUID | None = None,
group: UUID | None = None,
mode: SessionMode | None = None,
since: datetime | None = Query(default=None, alias="from"),
limit: int = 100,
db: AsyncSession | None = Depends(optional_session),
) -> list[SessionOut]:
who = require(request)
# Обучающийся видит только свою историю, что бы он ни передал в фильтре.
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
trainee = who.trainee_id
owner_login = who.login if who.role is Role.INSTRUCTOR else None
if db is None:
# The explicit in-memory demo keeps completed session state in `hub`
# until restart. It has no group records, so group-filtered history is
# empty rather than silently leaking sessions outside that filter.
if group is not None:
return []
states = hub.history(
owner_login=owner_login,
trainee_id=trainee,
mode=mode.value if mode else None,
since=since,
limit=limit,
)
return [SessionOut(
session_id=state.session_id,
scenario_id=state.scenario_id,
mode=state.mode,
attempt=state.attempt,
trainee_id=state.trainee_id,
group_id=None,
started_at=state.started_at,
ended_at=state.ended_at,
end_reason=state.end_reason.value if state.end_reason else None,
) for state in states]
rows = await repo.history(
db,
trainee_id=trainee,
group_id=group,
mode=mode.value if mode else None,
owner_login=owner_login,
since=since,
limit=limit,
)
return [_out(row) for row in rows]