lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
"""Сессии: создание, состояние, история.
|
|
|
|
|
|
|
|
|
|
|
|
`group_id` и `mode` принимаются с первого дня — размечать накопленные сессии
|
|
|
|
|
|
задним числом не надо (docs/arch/CONTRACT.md#http-api).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
import logging
|
|
|
|
|
|
import time
|
|
|
|
|
|
from collections.abc import AsyncIterator
|
|
|
|
|
|
from datetime import UTC, datetime
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from fastapi.responses import FileResponse
|
|
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
|
|
from sqlalchemy import select
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.api.auth import add_audit_entry, audit, audit_required, require
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.config import get_settings
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
from app.db import repo
|
|
|
|
|
|
from app.db.base import get_session
|
2026-09-26 22:48:24 +00:00
|
|
|
|
from app.db.models import Group, Score, Session, Trainee
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from app.domain.events import Exercise, SessionMode, SessionReport
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.domain.roles import Role
|
|
|
|
|
|
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
|
|
|
|
|
|
from app.domain.timers import TimerCode
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from app.scoring.export import to_csv, to_pdf
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.scoring.report import build as build_report
|
|
|
|
|
|
from app.session.checkpoint import load_state
|
2026-09-26 22:48:24 +00:00
|
|
|
|
from app.session.finish import override_score, scoring_scenario
|
2026-09-17 21:24:34 +03:00
|
|
|
|
from app.session.hub import hub
|
2026-09-26 22:48:24 +00:00
|
|
|
|
from app.session.store import ScoreOverridden, apply_score_override
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.voice.recording import recording_path
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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 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]
|
|
|
|
|
|
dds_snapshot: StationSnapshot | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
queue = station.queue_cards if station else []
|
2026-09-26 21:38:09 +00:00
|
|
|
|
card = state.desk.active
|
|
|
|
|
|
status_log = card.status_log if card is not None else []
|
2026-09-26 18:12:27 +03:00
|
|
|
|
managed_services = state.managed_services()
|
|
|
|
|
|
latest_statuses = {
|
2026-09-26 21:38:09 +00:00
|
|
|
|
service: SERVICE_STATUS_LABELS[current(status_log, service)]
|
2026-09-26 18:12:27 +03:00
|
|
|
|
for service in managed_services
|
2026-09-26 21:38:09 +00:00
|
|
|
|
if (status_log or state.exercise is Exercise.DDS)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-09-26 21:38:09 +00:00
|
|
|
|
dds_card_total=len(state.desk.scenarios),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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
|
|
|
|
|
|
and timer.started_at is not None
|
|
|
|
|
|
and not timer.stopped
|
|
|
|
|
|
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
|
2026-09-26 21:38:09 +00:00
|
|
|
|
for card in state.desk.cards.values()
|
2026-09-26 18:12:27 +03:00
|
|
|
|
),
|
|
|
|
|
|
dds_statuses=latest_statuses,
|
|
|
|
|
|
dds_snapshot=station,
|
|
|
|
|
|
))
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
@router.post("", response_model=SessionOut, status_code=201)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
group_created = False
|
2026-09-24 01:10:49 +03:00
|
|
|
|
try:
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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
|
2026-09-24 01:10:49 +03:00
|
|
|
|
except PermissionError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="group_not_found") from exc
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
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,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
owner_login=who.login,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
backend_node_id=get_settings().backend_node_id,
|
|
|
|
|
|
before_commit=audit_creation,
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
)
|
|
|
|
|
|
return _out(session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{session_id}", response_model=SessionOut)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
|
|
|
|
|
who = require(request)
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
session = await repo.get_session(db, session_id)
|
|
|
|
|
|
if session is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="not_your_session")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
return _out(session)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 21:24:34 +03:00
|
|
|
|
class ChecklistItemOut(BaseModel):
|
|
|
|
|
|
id: str
|
|
|
|
|
|
question: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{session_id}/checklist", response_model=list[ChecklistItemOut])
|
2026-09-21 17:40:54 +03:00
|
|
|
|
async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut]:
|
2026-09-17 21:24:34 +03:00
|
|
|
|
"""Чек-лист для самооценки — **только после конца звонка**.
|
|
|
|
|
|
|
|
|
|
|
|
Во время звонка это содержимое подсказок: отдать его значит выдать
|
|
|
|
|
|
в контрольном режиме то, чего там быть не должно. После звонка курсант
|
|
|
|
|
|
по нему отмечает, что, по его мнению, пропустил.
|
|
|
|
|
|
"""
|
2026-09-21 17:40:54 +03:00
|
|
|
|
who = require(request)
|
2026-09-17 21:24:34 +03:00
|
|
|
|
state = hub.get(session_id)
|
|
|
|
|
|
if state is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="not_your_session")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
2026-09-17 21:24:34 +03:00
|
|
|
|
if not state.ended:
|
|
|
|
|
|
raise HTTPException(status_code=409, detail="call_not_ended")
|
2026-09-26 19:02:24 +00:00
|
|
|
|
scenario = scoring_scenario(state)
|
2026-09-17 21:24:34 +03:00
|
|
|
|
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
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
class ScoreOverride(BaseModel):
|
|
|
|
|
|
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
|
|
|
|
|
|
|
2026-09-21 19:56:13 +03:00
|
|
|
|
score_final: float = Field(ge=0, le=100)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
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
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _live(session_id: UUID):
|
|
|
|
|
|
state = hub.get(session_id)
|
|
|
|
|
|
if state is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
2026-09-26 19:02:24 +00:00
|
|
|
|
scenario = scoring_scenario(state)
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
if scenario is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="scenario_not_found")
|
|
|
|
|
|
return state, scenario
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async def _report_data(
|
2026-09-26 18:12:27 +03:00
|
|
|
|
session_id: UUID, request: Request, db: AsyncSession | None,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
) -> SessionReport:
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
|
|
|
|
|
|
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
Обучающийся открывает только свой разбор: ТЗ запрещает доступ к чужим
|
|
|
|
|
|
результатам, а не только к чужим экранам. Проверка по `trainee_id`
|
|
|
|
|
|
занятия, а не по номеру в ссылке.
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
"""
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
who = require(request)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
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:
|
|
|
|
|
|
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
|
|
|
|
|
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="not_your_session")
|
|
|
|
|
|
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")
|
2026-09-26 22:48:24 +00:00
|
|
|
|
if hub.store.persistent and isinstance(db, AsyncSession):
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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):
|
2026-09-26 22:48:24 +00:00
|
|
|
|
# Live state is populated just before the store commit finishes.
|
2026-09-26 18:12:27 +03:00
|
|
|
|
# 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")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return build_report(session_id, state, scenario)
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if db is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
session = await repo.get_session(db, session_id)
|
|
|
|
|
|
if session is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
|
|
|
|
|
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
raise HTTPException(status_code=403, detail="not_your_session")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
|
|
|
|
|
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
|
|
|
|
|
if score is None:
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
raise HTTPException(status_code=409, detail="score_not_ready")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
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(
|
2026-09-26 18:12:27 +03:00
|
|
|
|
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
2026-09-24 01:10:49 +03:00
|
|
|
|
) -> SessionReport:
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
@router.get("/{session_id}/report.csv")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async def report_csv(
|
2026-09-26 18:12:27 +03:00
|
|
|
|
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
2026-09-24 01:10:49 +03:00
|
|
|
|
) -> Response:
|
2026-09-21 17:40:54 +03:00
|
|
|
|
"""Те же права и готовность оценки, что у JSON-разбора."""
|
2026-09-24 01:10:49 +03:00
|
|
|
|
data = await _report_data(session_id, request, db)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
content = to_csv(data)
|
|
|
|
|
|
who = require(request)
|
|
|
|
|
|
await audit_required(who.login, who.role.value, "report.export.csv", str(session_id))
|
2026-09-21 17:40:54 +03:00
|
|
|
|
return Response(
|
2026-09-26 18:12:27 +03:00
|
|
|
|
content=content, media_type="text/csv; charset=utf-8",
|
2026-09-21 17:40:54 +03:00
|
|
|
|
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{session_id}/report.pdf")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async def report_pdf(
|
2026-09-26 18:12:27 +03:00
|
|
|
|
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
2026-09-24 01:10:49 +03:00
|
|
|
|
) -> Response:
|
2026-09-21 17:40:54 +03:00
|
|
|
|
"""Печатный разбор; генерация полностью локальна."""
|
2026-09-24 01:10:49 +03:00
|
|
|
|
data = await _report_data(session_id, request, db)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
try:
|
|
|
|
|
|
content = to_pdf(data)
|
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
2026-09-26 18:12:27 +03:00
|
|
|
|
who = require(request)
|
|
|
|
|
|
await audit_required(who.login, who.role.value, "report.export.pdf", str(session_id))
|
2026-09-21 17:40:54 +03:00
|
|
|
|
return Response(
|
|
|
|
|
|
content=content, media_type="application/pdf",
|
|
|
|
|
|
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.pdf"'},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
@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")
|
|
|
|
|
|
owner_id = state.trainee_id
|
|
|
|
|
|
owner_login = state.owner_login
|
|
|
|
|
|
ended = state.ended
|
|
|
|
|
|
else:
|
|
|
|
|
|
row = await repo.get_session(db, session_id)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
|
|
|
|
|
owner_id = row.trainee_id
|
|
|
|
|
|
owner_login = row.owner_login
|
|
|
|
|
|
ended = row.ended_at is not None
|
|
|
|
|
|
if who.role is Role.TRAINEE and (owner_id is None or owner_id != who.trainee_id):
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="not_your_recording")
|
|
|
|
|
|
if who.role is Role.INSTRUCTOR and owner_login != who.login:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="recording_not_found")
|
|
|
|
|
|
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")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
await audit_required(who.login, who.role.value, "recording.read", str(session_id))
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return FileResponse(
|
|
|
|
|
|
path,
|
|
|
|
|
|
media_type="audio/wav",
|
|
|
|
|
|
filename=f"session-{session_id}-recording.wav",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
@router.patch("/{session_id}/report", response_model=SessionReport)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async def override(
|
|
|
|
|
|
session_id: UUID,
|
|
|
|
|
|
body: ScoreOverride,
|
|
|
|
|
|
request: Request,
|
2026-09-26 22:48:24 +00:00
|
|
|
|
db: AsyncSession | None = Depends(optional_session),
|
2026-09-24 01:10:49 +03:00
|
|
|
|
) -> SessionReport:
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
|
|
|
|
|
|
|
|
|
|
|
|
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
|
2026-09-26 22:48:24 +00:00
|
|
|
|
Правка идёт тем же путём, что команда `score.override` с пульта.
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
"""
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
2026-09-26 22:48:24 +00:00
|
|
|
|
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:
|
|
|
|
|
|
if state.owner_login != who.login:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
|
|
|
|
|
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:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
2026-09-26 22:48:24 +00:00
|
|
|
|
session = await repo.get_session(db, session_id)
|
|
|
|
|
|
if session is None or session.owner_login != who.login:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
|
|
|
|
|
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
|
|
|
|
|
if score is None:
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
raise HTTPException(status_code=409, detail="score_not_ready")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
archived = (score.report or {}).get("full_report")
|
2026-09-26 22:48:24 +00:00
|
|
|
|
if archived is None:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
raise HTTPException(status_code=409, detail="report_not_archived")
|
2026-09-26 22:48:24 +00:00
|
|
|
|
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"])
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
@router.get("", response_model=list[SessionOut])
|
|
|
|
|
|
async def listing(
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
request: Request,
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
trainee: UUID | None = None,
|
|
|
|
|
|
group: UUID | None = None,
|
|
|
|
|
|
mode: SessionMode | None = None,
|
|
|
|
|
|
since: datetime | None = Query(default=None, alias="from"),
|
|
|
|
|
|
limit: int = 100,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
db: AsyncSession | None = Depends(optional_session),
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
) -> list[SessionOut]:
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
who = require(request)
|
|
|
|
|
|
# Обучающийся видит только свою историю, что бы он ни передал в фильтре.
|
|
|
|
|
|
if who.role is Role.TRAINEE:
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if who.trainee_id is None:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
trainee = who.trainee_id
|
2026-09-24 01:10:49 +03:00
|
|
|
|
owner_login = who.login if who.role is Role.INSTRUCTOR else None
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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]
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
rows = await repo.history(
|
|
|
|
|
|
db,
|
|
|
|
|
|
trainee_id=trainee,
|
|
|
|
|
|
group_id=group,
|
|
|
|
|
|
mode=mode.value if mode else None,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
owner_login=owner_login,
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
since=since,
|
|
|
|
|
|
limit=limit,
|
|
|
|
|
|
)
|
|
|
|
|
|
return [_out(row) for row in rows]
|