Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -11,10 +11,13 @@
import logging
import re
from datetime import datetime
from datetime import datetime, timedelta, timezone
from uuid import UUID
from xml.etree import ElementTree as ET
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
@ -27,13 +30,68 @@ from app.config import get_settings
from app.db.base import get_session
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
from app.domain import ekp
from app.domain.roles import ROLE_LABELS, Role
from app.domain.roles import ROLE_LABELS, SCREENS, Role
from app.domain.timers import NORMATIVES
from app.dialog.llm import is_loopback_url
from app.monitoring import recent_events, sample_metrics
from app.session.hub import hub
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _configuration_xml() -> bytes:
"""Безопасный переносимый снимок конфигурации без паролей и ключей."""
settings = get_settings()
root = ET.Element("lctConfiguration", {"version": "1"})
ET.SubElement(root, "platform", {
"offline": str(settings.offline).lower(),
"voiceEnabled": str(settings.voice_enabled).lower(),
"secureCookies": str(settings.secure_cookies).lower(),
})
models = ET.SubElement(root, "localModels")
ET.SubElement(models, "dialogue", {"name": settings.llm_model_caller})
ET.SubElement(models, "russianControl", {
"name": settings.llm_model_control,
"grammarEnabled": str(settings.grammar_llm_enabled).lower(),
})
ET.SubElement(models, "speechToText", {
"name": settings.stt_model,
"enabled": str(settings.voice_enabled).lower(),
})
workstations = ET.SubElement(root, "workstations")
for role, screens in SCREENS.items():
workstation = ET.SubElement(workstations, "workstation", {
"role": role.value, "label": ROLE_LABELS[role],
})
for path in screens:
ET.SubElement(workstation, "screen", {"path": path})
timers = ET.SubElement(root, "timerLimits")
for code, normative in NORMATIVES.items():
ET.SubElement(timers, "timer", {
"code": code.value,
"milliseconds": str(settings.limit_ms(code)),
"defaultMilliseconds": str(normative.limit_ms),
})
reference = ekp.reference()
ET.SubElement(root, "ekp", {
"version": reference.version,
"incidents": str(len(reference.incidents)),
})
ET.indent(root, space=" ")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
@router.get("/config.xml")
async def configuration_xml(request: Request) -> Response:
require(request, Role.ADMIN)
return Response(
content=_configuration_xml(),
media_type="application/xml",
headers={"Content-Disposition": 'attachment; filename="lct-workstations.xml"'},
)
class UserOut(BaseModel):
id: UUID
login: str
@ -47,7 +105,7 @@ class UserOut(BaseModel):
class UserCreate(BaseModel):
login: str = Field(min_length=3, max_length=80)
full_name: str = Field(min_length=1, max_length=120)
password: str = Field(min_length=8, description="Короткий пароль не заводится")
password: str = Field(min_length=8, max_length=1024, description="Пароль должен быть от 8 до 1024 символов")
role: Role
service: str | None = None
@ -59,7 +117,7 @@ class UserPatch(BaseModel):
role: Role | None = None
service: str | None = None
blocked: bool | None = None
password: str | None = Field(default=None, min_length=8)
password: str | None = Field(default=None, min_length=8, max_length=1024)
def _out(user: User) -> UserOut:
@ -148,8 +206,9 @@ async def patch_user(
if not changed:
return _out(user)
user.auth_version += 1
await db.commit()
invalidate_login(user.login)
invalidate_login(user.login, user.auth_version)
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
return _out(user)
@ -169,12 +228,18 @@ async def audit_log(
action: str | None = None,
actor: str | None = None,
limit: int = 200,
offset: int = 0,
db: AsyncSession = Depends(get_session),
) -> list[AuditOut]:
"""Журнал действий. Администратор его читает, но не правит: точки удаления
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
require(request, Role.ADMIN)
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000)))
query = (
select(AuditLog)
.order_by(AuditLog.at.desc(), AuditLog.id.desc())
.limit(max(1, min(limit, 1001)))
.offset(max(0, min(offset, 10_000_000)))
)
if action:
query = query.where(AuditLog.action == action)
if actor:
@ -195,6 +260,104 @@ class ServiceState(BaseModel):
detail: str
class RuntimeMetrics(BaseModel):
at: datetime
uptime_seconds: float
cpu_percent: float
load_1m_percent: float | None
cpu_cores: int
rss_bytes: int | None
memory_total_bytes: int | None
memory_available_bytes: int | None
disk_total_bytes: int
disk_free_bytes: int
threads: int
active_sessions: int
restored_sessions: int
completed_sessions_24h: int
class DiagnosticEvent(BaseModel):
at: datetime
level: str
source: str
message: str
class FailureEvent(BaseModel):
at: datetime
actor: str
action: str
object_id: str | None
detail: str
class DiagnosticReport(BaseModel):
generated_at: datetime
metrics: RuntimeMetrics
recent_system_events: list[DiagnosticEvent]
failed_actions_24h: list[FailureEvent]
async def _runtime_metrics(db: AsyncSession) -> RuntimeMetrics:
from app.main import app
raw = sample_metrics()
since = datetime.now(timezone.utc) - timedelta(hours=24)
completed = await db.scalar(
select(func.count()).select_from(SessionRow).where(SessionRow.ended_at >= since)
)
return RuntimeMetrics(
**raw,
active_sessions=sum(not item.ended for item in hub._sessions.values()), # noqa: SLF001
restored_sessions=getattr(app.state, "sessions_restored", 0),
completed_sessions_24h=completed or 0,
)
async def _diagnostic_report(db: AsyncSession) -> DiagnosticReport:
since = datetime.now(timezone.utc) - timedelta(hours=24)
rows = await db.scalars(
select(AuditLog)
.where(AuditLog.at >= since, AuditLog.action.like("%.failed"))
.order_by(AuditLog.at.desc())
.limit(200)
)
return DiagnosticReport(
generated_at=datetime.now(timezone.utc),
metrics=await _runtime_metrics(db),
recent_system_events=[DiagnosticEvent(**item) for item in recent_events(limit=100)],
failed_actions_24h=[
FailureEvent(
at=row.at, actor=row.actor, action=row.action,
object_id=row.object_id, detail=row.detail,
)
for row in rows
],
)
@router.get("/diagnostics", response_model=DiagnosticReport)
async def diagnostics(
request: Request, db: AsyncSession = Depends(get_session)
) -> DiagnosticReport:
"""Live load plus a bounded, redacted incident report for the admin."""
require(request, Role.ADMIN)
return await _diagnostic_report(db)
@router.get("/diagnostics.json")
async def download_diagnostics(
request: Request, db: AsyncSession = Depends(get_session)
) -> JSONResponse:
require(request, Role.ADMIN)
report = await _diagnostic_report(db)
return JSONResponse(
jsonable_encoder(report),
headers={"Content-Disposition": 'attachment; filename="lct-diagnostics.json"'},
)
@router.get("/status", response_model=list[ServiceState])
async def status(request: Request, db: AsyncSession = Depends(get_session)) -> list[ServiceState]:
"""Состояние компонентов стенда — то, что администратор смотрит до занятия,
@ -220,6 +383,21 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
detail="распознавание и синтез готовы" if models_ready else "не загружены: занятие пойдёт без голоса",
)
)
metrics = sample_metrics()
disk_free = metrics["disk_free_bytes"]
disk_total = max(metrics["disk_total_bytes"], 1)
disk_ok = disk_free >= 1024 ** 3 and disk_free / disk_total >= 0.05
load = metrics["load_1m_percent"]
states.append(ServiceState(
name="Нагрузка backend",
ok=disk_ok and (load is None or load < 100),
detail=(
f"CPU процесса {metrics['cpu_percent']:.1f}%; "
+ (f"нагрузка хоста {load:.1f}%; " if load is not None else "")
+ f"RAM процесса {(metrics['rss_bytes'] or 0) / 1024 ** 2:.0f} МБ; "
+ f"свободно на диске {disk_free / 1024 ** 3:.1f} ГБ"
),
))
states.append(
ServiceState(
name="Эмбеддинги",
@ -228,11 +406,20 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
else "нет модели: подсказки идут по порядку чек-листа",
)
)
llm_configured = (
is_loopback_url(
settings.llm_base_url,
allow_docker_host=settings.allow_docker_host_models,
)
if settings.offline or settings.llm_provider == "local"
else bool(settings.llm_api_key and settings.llm_base_url)
)
states.append(
ServiceState(
name="Провайдер LLM",
ok=bool(settings.llm_api_key and settings.llm_base_url),
detail=settings.llm_base_url or "не настроен: звонящий читает офлайн-таблицу",
ok=llm_configured,
detail=(f"локальный адрес разрешён: {settings.llm_base_url}"
if llm_configured else "не настроен: звонящий читает офлайн-таблицу"),
)
)
@ -248,9 +435,39 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
ServiceState(
name="Живых занятий",
ok=True,
detail=str(len(hub._sessions)), # noqa: SLF001 — реестр в памяти процесса
detail=(
f"активно {sum(not item.ended for item in hub._sessions.values())}; "
f"восстановлено после запуска {getattr(app.state, 'sessions_restored', 0)}"
), # noqa: SLF001 — реестр в памяти процесса
)
)
try:
copies = backup_service.listing()
except backup_service.BackupError as exc:
states.append(ServiceState(
name="Резервное копирование",
ok=False,
detail=f"ошибка чтения копий: {exc}",
))
copies = []
if copies:
latest = copies[0]
age_seconds = max(0.0, (datetime.now(timezone.utc) - latest["at"]).total_seconds())
allowed_age = max(60, settings.backup_interval_seconds) + max(
60, settings.backup_retry_seconds
)
states.append(ServiceState(
name="Резервное копирование",
ok=age_seconds <= allowed_age,
detail=(f"последняя копия {latest['name']}, "
f"{age_seconds / 3600:.1f} ч назад; хранится {len(copies)}"),
))
else:
states.append(ServiceState(
name="Резервное копирование",
ok=False,
detail="успешных копий ещё нет",
))
# Секрет сессии по умолчанию — не ошибка запуска, но на стенде это дыра,
# и увидеть её должен администратор, а не проверяющий.
default_secret = settings.session_secret.startswith("dev-secret")
@ -273,7 +490,12 @@ class BackupOut(BaseModel):
@router.get("/backups", response_model=list[BackupOut])
async def backups(request: Request) -> list[BackupOut]:
require(request, Role.ADMIN)
return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)]
try:
copies = await run_in_threadpool(backup_service.listing)
except backup_service.BackupError as exc:
detail = _safe_backup_error(exc)
raise HTTPException(status_code=503, detail=detail) from exc
return [BackupOut(**item) for item in copies]
def _safe_backup_error(exc: backup_service.BackupError) -> str:

View file

@ -10,9 +10,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.db.models import Group, Score, Session, Trainee, User
from app.domain.roles import Role
from app.scoring.group import ScoredAttempt, summarize
from app.scoring.group_insight import InsightInvalid, generate_group_insight
from app.dialog.llm import LlmUnavailable
router = APIRouter(prefix="/api/groups", tags=["groups"])
@ -26,6 +28,10 @@ class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
class GroupOwnerPatch(BaseModel):
owner_login: str | None
class GroupErrorOut(BaseModel):
code: str
title: str
@ -44,62 +50,23 @@ class GroupAnalyticsOut(BaseModel):
errors: list[GroupErrorOut]
@router.get("", response_model=list[GroupOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
require(request, Role.INSTRUCTOR, Role.ADMIN)
groups = await db.scalars(select(Group).order_by(Group.name))
return [GroupOut(id=group.id, name=group.name) for group in groups]
class GroupInsightOut(BaseModel):
summary: str
priorities: list[str]
source: str = "local_qwen"
personal_data_sent: bool = False
@router.post("", response_model=GroupOut, status_code=201)
async def create(
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="group_name_required")
group = Group(name=name)
db.add(group)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="group_exists") from exc
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
return GroupOut(id=group.id, name=group.name)
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
async def assign_trainee(
group_id: UUID, trainee_id: UUID, request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee.group_id = group_id
await db.commit()
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
return GroupOut(id=group.id, name=group.name)
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
async def analytics(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
async def _analytics(
group_id: UUID, db: AsyncSession, *, owner_login: str | None = None
) -> GroupAnalyticsOut:
require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
if group is None or (owner_login is not None and group.owner_login != owner_login):
raise HTTPException(status_code=404, detail="group_not_found")
enrolled = await db.scalar(
select(func.count()).select_from(Trainee).where(Trainee.group_id == group_id)
)
rows = await db.execute(
attempts_query = (
select(Session.trainee_id, Score.score_final, Score.report)
.join(Score, Score.session_id == Session.id)
.join(Trainee, Trainee.id == Session.trainee_id, isouter=True)
@ -111,6 +78,9 @@ async def analytics(
Session.ended_at.is_not(None),
)
)
if owner_login is not None:
attempts_query = attempts_query.where(Session.owner_login == owner_login)
rows = await db.execute(attempts_query)
attempts = [
ScoredAttempt(
trainee_id=trainee_id,
@ -123,3 +93,116 @@ async def analytics(
group=GroupOut(id=group.id, name=group.name),
**summarize(attempts, int(enrolled or 0)),
)
@router.get("", response_model=list[GroupOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
query = select(Group).order_by(Group.name)
if who.role is Role.INSTRUCTOR:
query = query.where(Group.owner_login == who.login)
groups = await db.scalars(query)
return [GroupOut(id=group.id, name=group.name) for group in groups]
@router.post("", response_model=GroupOut, status_code=201)
async def create(
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="group_name_required")
group = Group(name=name, owner_login=who.login if who.role is Role.INSTRUCTOR else None)
db.add(group)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="group_exists") from exc
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
return GroupOut(id=group.id, name=group.name)
@router.patch("/{group_id}/owner", response_model=GroupOut)
async def transfer_ownership(
group_id: UUID,
body: GroupOwnerPatch,
request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
"""Администратор безопасно закрепляет legacy-группу за преподавателем."""
who = require(request, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
if body.owner_login is not None:
user = await db.scalar(select(User).where(User.login == body.owner_login))
if user is None or user.role != Role.INSTRUCTOR.value or user.blocked:
raise HTTPException(status_code=422, detail="active_instructor_required")
previous_owner = group.owner_login
group.owner_login = body.owner_login
await db.commit()
await audit(
who.login,
who.role.value,
"group.transfer",
str(group.id),
f"{previous_owner or 'admin'} -> {body.owner_login or 'admin'}",
)
return GroupOut(id=group.id, name=group.name)
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
async def assign_trainee(
group_id: UUID, trainee_id: UUID, request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None or (who.role is Role.INSTRUCTOR and group.owner_login != who.login):
raise HTTPException(status_code=404, detail="group_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
if (
who.role is Role.INSTRUCTOR
and trainee.group_id is not None
and trainee.group_id != group_id
):
current_group = await db.get(Group, trainee.group_id)
if current_group is None or current_group.owner_login != who.login:
raise HTTPException(status_code=409, detail="trainee_in_other_instructor_group")
trainee.group_id = group_id
await db.commit()
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
return GroupOut(id=group.id, name=group.name)
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
async def analytics(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupAnalyticsOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
return await _analytics(
group_id, db, owner_login=who.login if who.role is Role.INSTRUCTOR else None
)
@router.post("/{group_id}/analytics/insight", response_model=GroupInsightOut)
async def ai_insight(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupInsightOut:
"""Сформировать по запросу преподавателя локальный обезличенный инсайт."""
who = require(request, Role.INSTRUCTOR)
data = await _analytics(group_id, db, owner_login=who.login)
if data.scored_attempts == 0:
raise HTTPException(status_code=409, detail="no_scored_attempts")
try:
insight = await generate_group_insight(data.model_dump(mode="json"))
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except InsightInvalid as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only")
return GroupInsightOut(**insight)

View file

@ -0,0 +1,549 @@
"""Учебные материалы, локальные вложения и назначения курсантам.
Файлы хранятся в PostgreSQL и отдаются только как attachment: учебный контур
не зависит от внешнего файлового сервиса и не исполняет загруженный HTML.
"""
import base64
import binascii
import hashlib
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from typing import Literal
from urllib.parse import quote
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import DEMO_TRAINEE_ID, audit, require
from app.config import get_settings
from app.db.base import get_session
from app.db.models import Group, LearningMaterial, MaterialAssignment, Trainee
from app.domain.events import Exercise, ScenarioStart, SessionMode
from app.domain.roles import Role
from app.scenarios import store
from app.session.hub import hub
router = APIRouter(prefix="/api/materials", tags=["materials"])
MAX_FILE_BYTES = 5 * 1024 * 1024
LEVELS = {"L1", "L2", "L3"}
_DEMO_GUIDE_ID = UUID("00000000-0000-4000-8000-000000000901")
_demo_materials: dict[UUID, LearningMaterial] = {}
_demo_assignments: dict[tuple[UUID, UUID], dict] = {}
def reset_demo_materials() -> None:
"""Демонстрационный справочник воспроизводим после каждого старта."""
_demo_materials.clear()
_demo_assignments.clear()
now = datetime.now(timezone.utc)
_demo_materials[_DEMO_GUIDE_ID] = LearningMaterial(
id=_DEMO_GUIDE_ID,
title="Памятка диспетчера ДДС",
description="Короткий алгоритм работы с готовой карточкой происшествия.",
level="L1",
kind="text",
body=(
"1. Подтвердите получение карточки.\n"
"2. Проверьте зону ответственности и список оповещения.\n"
"3. Назначьте бригаду и передайте адрес, событие и задачу.\n"
"4. Фиксируйте выезд, прибытие, локализацию и завершение работ."
),
scenario_id="t01-1-fire-container",
file_name=None,
media_type=None,
file_data=None,
file_sha256=None,
active=True,
created_by="system",
created_at=now,
updated_at=now,
)
_demo_assignments[(_DEMO_GUIDE_ID, DEMO_TRAINEE_ID)] = {
"assigned_by": "system", "assigned_at": now, "completed_at": None,
}
async def material_session() -> AsyncIterator[AsyncSession | None]:
if get_settings().demo_no_db:
yield None
else:
async for db in get_session():
yield db
class MaterialCreate(BaseModel):
title: str = Field(min_length=3, max_length=200)
description: str = Field(default="", max_length=2000)
level: Literal["L1", "L2", "L3"] = "L1"
kind: Literal["text", "file"] = "text"
body: str = Field(default="", max_length=30_000)
scenario_id: str | None = Field(default=None, max_length=80)
file_name: str | None = Field(default=None, max_length=240)
media_type: str | None = Field(default=None, max_length=120)
content_base64: str | None = None
@model_validator(mode="after")
def valid_content(self):
if self.kind == "text" and not self.body.strip():
raise ValueError("текст материала пуст")
if self.kind == "file" and (not self.file_name or not self.content_base64):
raise ValueError("для файла нужны имя и содержимое")
return self
class MaterialPatch(BaseModel):
title: str | None = Field(default=None, min_length=3, max_length=200)
description: str | None = Field(default=None, max_length=2000)
level: Literal["L1", "L2", "L3"] | None = None
body: str | None = Field(default=None, max_length=30_000)
scenario_id: str | None = Field(default=None, max_length=80)
active: bool | None = None
class MaterialOut(BaseModel):
id: UUID
title: str
description: str
level: str
kind: str
body: str
scenario_id: str | None
file_name: str | None
media_type: str | None
file_size: int
file_sha256: str | None
active: bool
created_by: str
created_at: datetime
assigned_at: datetime | None = None
completed_at: datetime | None = None
assignment_count: int = 0
def _out(
row: LearningMaterial, *, assignment: MaterialAssignment | dict | None = None,
assignment_count: int = 0,
) -> MaterialOut:
if isinstance(assignment, dict):
assigned_at = assignment.get("assigned_at")
completed_at = assignment.get("completed_at")
else:
assigned_at = assignment.assigned_at if assignment else None
completed_at = assignment.completed_at if assignment else None
return MaterialOut(
id=row.id,
title=row.title,
description=row.description,
level=row.level,
kind=row.kind,
body=row.body,
scenario_id=row.scenario_id,
file_name=row.file_name,
media_type=row.media_type,
file_size=len(row.file_data or b""),
file_sha256=row.file_sha256,
active=row.active,
created_by=row.created_by,
created_at=row.created_at,
assigned_at=assigned_at,
completed_at=completed_at,
assignment_count=assignment_count,
)
def _decode_file(payload: MaterialCreate) -> bytes | None:
if payload.kind != "file":
return None
try:
data = base64.b64decode(payload.content_base64 or "", validate=True)
except (binascii.Error, ValueError) as exc:
raise HTTPException(status_code=422, detail="invalid_file_base64") from exc
if not data:
raise HTTPException(status_code=422, detail="empty_file")
if len(data) > MAX_FILE_BYTES:
raise HTTPException(status_code=413, detail="file_too_large_5mb")
return data
def _validate_scenario(scenario_id: str | None) -> None:
if scenario_id and store.get(scenario_id) is None:
raise HTTPException(status_code=422, detail="scenario_not_found")
def _safe_filename(value: str) -> str:
"""Убрать и POSIX-, и Windows-путь; в БД остаётся только имя файла."""
return value.replace("\\", "/").rsplit("/", 1)[-1] or "resource.bin"
@router.get("", response_model=list[MaterialOut])
async def listing(
request: Request,
include_archived: bool = False,
db: AsyncSession | None = Depends(material_session),
) -> list[MaterialOut]:
who = require(request)
if db is None:
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
result = []
for material_id, row in _demo_materials.items():
assignment = _demo_assignments.get((material_id, who.trainee_id))
if assignment and row.active:
result.append(_out(row, assignment=assignment))
return result
return [
_out(row, assignment_count=sum(mid == row.id for mid, _ in _demo_assignments))
for row in _demo_materials.values()
if (include_archived or row.active)
and (who.role is not Role.INSTRUCTOR or row.created_by in {who.login, "system"})
]
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
rows = await db.execute(
select(LearningMaterial, MaterialAssignment)
.join(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id)
.where(
MaterialAssignment.trainee_id == who.trainee_id,
LearningMaterial.active.is_(True),
)
.order_by(MaterialAssignment.assigned_at.desc())
)
return [_out(row, assignment=assignment) for row, assignment in rows]
require(request, Role.INSTRUCTOR, Role.ADMIN)
statement = (
select(LearningMaterial, func.count(MaterialAssignment.id))
.outerjoin(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id)
.group_by(LearningMaterial.id)
.order_by(LearningMaterial.active.desc(), LearningMaterial.updated_at.desc())
)
if who.role is Role.INSTRUCTOR:
statement = statement.where(LearningMaterial.created_by == who.login)
if not include_archived:
statement = statement.where(LearningMaterial.active.is_(True))
rows = await db.execute(statement)
return [_out(row, assignment_count=count) for row, count in rows]
@router.post("", response_model=MaterialOut, status_code=201)
async def create(
payload: MaterialCreate,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
_validate_scenario(payload.scenario_id)
data = _decode_file(payload)
now = datetime.now(timezone.utc)
row = LearningMaterial(
id=uuid4(),
title=payload.title.strip(),
description=payload.description.strip(),
level=payload.level,
kind=payload.kind,
body=payload.body.strip(),
scenario_id=payload.scenario_id,
file_name=_safe_filename(payload.file_name) if payload.file_name else None,
media_type=(payload.media_type or "application/octet-stream") if data else None,
file_data=data,
file_sha256=hashlib.sha256(data).hexdigest() if data else None,
active=True,
created_by=who.login,
created_at=now,
updated_at=now,
)
if db is None:
_demo_materials[row.id] = row
else:
db.add(row)
await db.commit()
await audit(who.login, who.role.value, "material.create", str(row.id), row.title)
return _out(row)
async def _material(db: AsyncSession | None, material_id: UUID) -> LearningMaterial | None:
return _demo_materials.get(material_id) if db is None else await db.get(LearningMaterial, material_id)
def _require_owner(row: LearningMaterial, login: str) -> None:
"""Only the instructor who authored a resource may manage it."""
if row.created_by != login:
raise HTTPException(status_code=404, detail="material_not_found")
@router.patch("/{material_id}", response_model=MaterialOut)
async def update(
material_id: UUID,
payload: MaterialPatch,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
patch = payload.model_dump(exclude_unset=True)
if "scenario_id" in patch:
patch["scenario_id"] = patch["scenario_id"] or None
_validate_scenario(patch["scenario_id"])
if row.kind == "text" and "body" in patch and not (patch["body"] or "").strip():
raise HTTPException(status_code=422, detail="empty_material_body")
for key, value in patch.items():
setattr(row, key, value.strip() if isinstance(value, str) else value)
row.updated_at = datetime.now(timezone.utc)
if db is not None:
await db.commit()
await audit(who.login, who.role.value, "material.update", str(row.id))
return _out(row)
@router.delete("/{material_id}", response_model=MaterialOut)
async def archive(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
row.active = False
row.updated_at = datetime.now(timezone.utc)
if db is not None:
await db.commit()
await audit(who.login, who.role.value, "material.archive", str(row.id))
return _out(row)
@router.put("/{material_id}/assign/{trainee_id}", response_model=MaterialOut)
async def assign(
material_id: UUID,
trainee_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
if db is None:
if trainee_id != DEMO_TRAINEE_ID:
raise HTTPException(status_code=404, detail="trainee_not_found")
assignment = _demo_assignments.setdefault(
(material_id, trainee_id),
{"assigned_by": who.login, "assigned_at": datetime.now(timezone.utc), "completed_at": None},
)
else:
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
if trainee.group_id is not None:
group = await db.get(Group, trainee.group_id)
if group is None or group.owner_login != who.login:
raise HTTPException(status_code=404, detail="trainee_not_found")
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == trainee_id,
))
if assignment is None:
assignment = MaterialAssignment(
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
)
db.add(assignment)
await db.commit()
await db.refresh(assignment)
await audit(who.login, who.role.value, "material.assign", str(row.id), str(trainee_id))
return _out(row, assignment=assignment)
@router.put("/{material_id}/assign-group/{group_id}")
async def assign_group(
material_id: UUID,
group_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
if db is None:
raise HTTPException(status_code=409, detail="groups_unavailable_in_demo")
group = await db.get(Group, group_id)
if group is None or group.owner_login != who.login:
raise HTTPException(status_code=404, detail="group_not_found")
trainee_ids = list(await db.scalars(select(Trainee.id).where(Trainee.group_id == group_id)))
existing = set(await db.scalars(select(MaterialAssignment.trainee_id).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id.in_(trainee_ids),
))) if trainee_ids else set()
for trainee_id in trainee_ids:
if trainee_id not in existing:
db.add(MaterialAssignment(
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
))
await db.commit()
await audit(who.login, who.role.value, "material.assign_group", str(row.id), str(group_id))
return {"material_id": str(row.id), "assigned": len(trainee_ids)}
@router.delete("/{material_id}/assign/{trainee_id}")
async def unassign(
material_id: UUID,
trainee_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
if db is None:
removed = _demo_assignments.pop((material_id, trainee_id), None) is not None
else:
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == trainee_id,
))
if assignment is not None and assignment.assigned_by != who.login:
raise HTTPException(status_code=404, detail="assignment_not_found")
if assignment is not None:
await db.delete(assignment)
await db.commit()
removed = assignment is not None
await audit(who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id))
return {"removed": removed}
@router.post("/{material_id}/complete", response_model=MaterialOut)
async def complete(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.TRAINEE)
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
if db is None:
assignment = _demo_assignments.get((material_id, who.trainee_id))
else:
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == who.trainee_id,
))
if assignment is None:
raise HTTPException(status_code=403, detail="material_not_assigned")
completed_at = datetime.now(timezone.utc)
if isinstance(assignment, dict):
assignment["completed_at"] = completed_at
else:
assignment.completed_at = completed_at
await db.commit()
await audit(who.login, who.role.value, "material.complete", str(material_id))
return _out(row, assignment=assignment)
@router.post("/{material_id}/start")
async def start_assigned_practice(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> dict:
"""Курсант сам выбирает назначенный модуль и запускает текстовую практику."""
who = require(request, Role.TRAINEE)
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
if db is None:
assignment = _demo_assignments.get((material_id, who.trainee_id))
else:
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == who.trainee_id,
))
if assignment is None:
raise HTTPException(status_code=403, detail="material_not_assigned")
if not row.scenario_id:
raise HTTPException(status_code=409, detail="material_has_no_practice")
scenario = store.get(row.scenario_id)
if scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
# Явное назначение преподавателя — и есть разрешение на самостоятельный
# модуль. Оно не открывает курсанту остальные сценарии библиотеки.
from app.api.ws.control import _start
session_id = uuid4()
await _start(session_id, ScenarioStart(
scenario_id=scenario.id,
trainee=who.full_name,
trainee_id=who.trainee_id,
mode=SessionMode.SELF,
exercise=Exercise.CARD,
), who)
if hub.get(session_id) is None:
raise HTTPException(status_code=409, detail="practice_start_failed")
return {
"session_id": str(session_id),
"scenario_id": scenario.id,
"mode": SessionMode.SELF.value,
"exercise": Exercise.CARD.value,
"path": f"/trainee?session={session_id}",
}
@router.get("/{material_id}/download")
async def download(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> Response:
who = require(request)
row = await _material(db, material_id)
if row is None or not row.active or row.kind != "file" or row.file_data is None:
raise HTTPException(status_code=404, detail="file_not_found")
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
if db is None:
allowed = (material_id, who.trainee_id) in _demo_assignments
else:
allowed = await db.scalar(select(MaterialAssignment.id).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == who.trainee_id,
)) is not None
if not allowed:
raise HTTPException(status_code=403, detail="material_not_assigned")
elif who.role is Role.INSTRUCTOR:
_require_owner(row, who.login)
elif who.role not in {Role.INSTRUCTOR, Role.ADMIN}:
raise HTTPException(status_code=403, detail="forbidden")
filename = _safe_filename(row.file_name or "resource.bin")
disposition = f"attachment; filename=resource; filename*=UTF-8''{quote(filename)}"
return Response(
content=row.file_data,
media_type=row.media_type or "application/octet-stream",
headers={
"Content-Disposition": disposition,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'",
},
)

View file

@ -10,6 +10,7 @@
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
"""
from collections.abc import AsyncIterator
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
@ -19,49 +20,126 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.domain import ekp
from app.db.base import get_session
from app.config import get_settings
from app.domain.roles import Role
from app.scenarios import store
from app.scenarios.editor import validate
from app.scenarios.generation import GenerationError, generate, generate_from_description
from app.dialog.llm import LlmUnavailable
from app.scenarios.loader import ScenarioError
from app.session.hub import hub
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
async def scenario_session() -> AsyncIterator[AsyncSession | None]:
"""Только редактор в demo-lite использует временное хранилище без БД."""
if get_settings().demo_no_db:
yield None
else:
async for db in get_session():
yield db
class TemplateDraftIn(BaseModel):
source_id: str = Field(min_length=1)
title: str | None = Field(default=None, min_length=1, max_length=200)
class GenerateDraftIn(BaseModel):
source_id: str = Field(min_length=1)
instruction: str = Field(min_length=10, max_length=1000)
class GenerateFullDraftIn(BaseModel):
source_id: str = Field(min_length=1)
description: str = Field(min_length=20, max_length=1500)
class ReviseDraftIn(BaseModel):
comment: str = Field(min_length=10, max_length=1000)
def _draft_out(row) -> dict:
if row.id.startswith("ai-full-"):
generation = "ai_full"
elif row.id.startswith("ai-"):
generation = "ai_variant"
else:
generation = "template_copy"
return {
"id": row.id,
"status": row.status,
"generation": "template_copy",
"generation": generation,
"body": row.body,
}
@router.post("/drafts/from-template", status_code=201)
async def create_template_draft(
body: TemplateDraftIn, request: Request, db: AsyncSession = Depends(get_session)
body: TemplateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
who = require(request, Role.INSTRUCTOR)
source = store.get(body.source_id)
if source is None:
raise HTTPException(status_code=404, detail="published_source_not_found")
row = await store.create_draft(db, source=source, title=body.title)
row = await store.create_draft(db, source=source, title=body.title, owner_login=who.login)
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
return _draft_out(row)
@router.post("/drafts/generate", status_code=201)
async def create_ai_draft(
body: GenerateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
who = require(request, Role.INSTRUCTOR)
source = store.get(body.source_id)
if source is None:
raise HTTPException(status_code=404, detail="published_source_not_found")
try:
proposal = await generate(source, body.instruction.strip(), require_fact_change=False)
row = await store.create_draft(db, source=source, proposal=proposal, owner_login=who.login)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except GenerationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
f"source:{source.id}")
return _draft_out(row)
@router.post("/drafts/generate-from-description", status_code=201)
async def create_full_ai_draft(
body: GenerateFullDraftIn, request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
"""Новый сюжет и мягкий эталон внутри выбранного класса ЕКП."""
who = require(request, Role.INSTRUCTOR)
source = store.get(body.source_id)
if source is None:
raise HTTPException(status_code=404, detail="published_source_not_found")
try:
proposal = await generate_from_description(source, body.description.strip())
row = await store.create_draft(
db, source=source, full_proposal=proposal, owner_login=who.login
)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except GenerationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
f"class_source:{source.id}")
return _draft_out(row)
@router.get("/drafts/{scenario_id}")
async def read_draft(
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
return _draft_out(row)
@ -72,10 +150,10 @@ async def patch_draft(
scenario_id: str,
body: dict[str, Any],
request: Request,
db: AsyncSession = Depends(get_session),
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@ -86,12 +164,36 @@ async def patch_draft(
return _draft_out(row)
@router.post("/drafts/{scenario_id}/revise")
async def revise_ai_draft(
scenario_id: str,
body: ReviseDraftIn,
request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
source = validate(row.body)
proposal = await generate(source, body.comment.strip(), require_fact_change=False)
row = await store.revise_draft(db, row, proposal)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except (GenerationError, ScenarioError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
body.comment.strip()[:500])
return _draft_out(row)
@router.post("/drafts/{scenario_id}/validate")
async def validate_draft(
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@ -107,10 +209,10 @@ async def validate_draft(
@router.post("/drafts/{scenario_id}/approve")
async def approve_draft(
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@ -122,7 +224,15 @@ async def approve_draft(
@router.get("")
async def listing() -> list[dict]:
async def listing(
request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> list[dict]:
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
owned_ids = (
await store.owned_scenario_ids(db, who.login)
if who is not None and who.role is Role.INSTRUCTOR
else set()
)
return [
{
"id": scenario.id,
@ -131,19 +241,57 @@ async def listing() -> list[dict]:
"level": scenario.level.value,
"topics": scenario.topics,
"modes": scenario.modes,
# Преподаватель должен видеть не только название карточки, но и
# зафиксированный путь классификатора. ИИ меняет сюжет внутри
# этого пути, а не незаметно подменяет код происшествия.
"signs": scenario.signs,
"incident_code": scenario.ground_truth.incident_code,
"dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None,
"ticket": scenario.ticket,
"position": scenario.position,
"ekp_group": (ekp.incident(scenario.ground_truth.incident_code).group
if scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code) else None),
"can_manage": scenario.id in owned_ids,
}
for scenario in store.all_scenarios()
]
@router.delete("/{scenario_id}")
async def archive_scenario(
scenario_id: str, request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
"""Мягкое удаление: история занятий остаётся целой, сценарий можно вернуть."""
who = require(request, Role.INSTRUCTOR)
if hub.has_active_scenario(scenario_id):
raise HTTPException(status_code=409, detail="scenario_is_used_by_active_session")
scenario = await store.archive(db, scenario_id, owner_login=who.login)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
return {"id": scenario_id, "status": "archived", "title": scenario.title}
@router.post("/{scenario_id}/restore")
async def restore_scenario(
scenario_id: str, request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
scenario = await store.restore_archived(db, scenario_id, owner_login=who.login)
if scenario is None:
raise HTTPException(status_code=404, detail="archived_scenario_not_found")
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
return {"id": scenario_id, "status": "published", "title": scenario.title}
@router.get("/{scenario_id}")
async def read(scenario_id: str) -> dict:
async def read(scenario_id: str, request: Request) -> dict:
# Training content is local but not public: anonymous clients must not be
# able to enumerate cards or inspect even the trainee-safe scenario body.
require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
scenario = store.get(scenario_id)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")

View file

@ -8,18 +8,23 @@ from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
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 audit, require
from app.config import get_settings
from app.db import repo
from app.db.base import get_session
from app.db.models import AuditLog, Score
from app.domain.events import Exercise, SessionMode, SessionReport
from app.scenarios import store
from app.scoring.report import build as build_report
from app.scoring.export import to_csv, to_pdf
from app.domain.roles import Role
from app.session.hub import hub
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@ -59,8 +64,11 @@ def _out(session) -> SessionOut:
@router.post("", response_model=SessionOut, status_code=201)
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
require(request, Role.INSTRUCTOR)
group = await repo.ensure_group(db, body.group) if body.group else None
who = require(request, Role.INSTRUCTOR)
try:
group = await repo.ensure_group(db, body.group, owner_login=who.login) if body.group else None
except PermissionError as exc:
raise HTTPException(status_code=404, detail="group_not_found") from exc
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
session = await repo.create_session(
db,
@ -68,6 +76,14 @@ async def create(body: SessionCreate, request: Request, db: AsyncSession = Depen
mode=body.mode.value,
trainee_id=trainee.id if trainee else None,
group_id=group.id if group else None,
owner_login=who.login,
)
await audit(
who.login,
who.role.value,
"session.create",
str(session.id),
f"scenario={session.scenario_id}; mode={session.mode}; attempt={session.attempt}",
)
return _out(session)
@ -80,6 +96,8 @@ async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(ge
raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
return _out(session)
@ -102,6 +120,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut
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.INSTRUCTOR and state.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
if not state.ended:
raise HTTPException(status_code=409, detail="call_not_ended")
scenario = state.scenario or store.get(state.scenario_id)
@ -118,7 +138,15 @@ class ScoreOverride(BaseModel):
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
score_final: float = Field(ge=0, le=100)
comment: str = ""
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):
@ -131,8 +159,9 @@ def _live(session_id: UUID):
return state, scenario
@router.get("/{session_id}/report", response_model=SessionReport)
async def report(session_id: UUID, request: Request) -> SessionReport:
async def _report_data(
session_id: UUID, request: Request, db: AsyncSession,
) -> SessionReport:
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
Обучающийся открывает только свой разбор: ТЗ запрещает доступ к чужим
@ -140,20 +169,62 @@ async def report(session_id: UUID, request: Request) -> SessionReport:
занятия, а не по номеру в ссылке.
"""
who = require(request)
state, scenario = _live(session_id)
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
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")
return build_report(session_id, state, scenario)
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:
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:
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:
raise HTTPException(status_code=409, detail="score_not_ready")
return build_report(session_id, state, scenario)
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 = Depends(get_session),
) -> SessionReport:
return await _report_data(session_id, request, db)
@router.get("/{session_id}/report.csv")
async def report_csv(session_id: UUID, request: Request) -> Response:
async def report_csv(
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
) -> Response:
"""Те же права и готовность оценки, что у JSON-разбора."""
data = await report(session_id, request)
data = await _report_data(session_id, request, db)
return Response(
content=to_csv(data), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
@ -161,9 +232,11 @@ async def report_csv(session_id: UUID, request: Request) -> Response:
@router.get("/{session_id}/report.pdf")
async def report_pdf(session_id: UUID, request: Request) -> Response:
async def report_pdf(
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
) -> Response:
"""Печатный разбор; генерация полностью локальна."""
data = await report(session_id, request)
data = await _report_data(session_id, request, db)
try:
content = to_pdf(data)
except RuntimeError as exc:
@ -174,31 +247,120 @@ async def report_pdf(session_id: UUID, request: Request) -> Response:
)
@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")
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) -> SessionReport:
async def override(
session_id: UUID,
body: ScoreOverride,
request: Request,
db: AsyncSession = Depends(get_session),
) -> SessionReport:
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
"""
who = require(request, Role.INSTRUCTOR)
state, scenario = _live(session_id)
if state.score is None:
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
if session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
state = hub.get(session_id)
live_ready = state is not None and state.score is not None
scenario = (state.scenario or store.get(state.scenario_id)) if live_ready else None
if live_ready and scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
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")
state.score = {
**state.score,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
}
if hub.journal:
await hub.journal.score_override(
session_id, body.score_final, who.login, body.comment
)
await audit(
who.login, who.role.value, "score.override", str(session_id),
f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}",
)
return build_report(session_id, state, scenario)
archived = (score.report or {}).get("full_report")
if not live_ready and archived is None:
raise HTTPException(status_code=409, detail="report_not_archived")
# Persist score and audit entry in the same request transaction: the API
# must not report success if either durable record failed to commit.
score.score_final = body.score_final
score.overridden_by = who.login
score.override_comment = body.comment
report_payload = dict(score.report or {})
if archived is not None:
archived_payload = dict(archived)
archived_payload.update({
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
})
report_payload["full_report"] = archived_payload
score.report = report_payload
db.add(AuditLog(
actor=who.login,
role=who.role.value,
action="score.override",
object_id=str(session_id),
detail=f"{score.score_auto} → {body.score_final}: {body.comment}"[:2000],
))
await db.commit()
if live_ready:
assert state is not None and state.score is not None and scenario is not None
state.score = {
**state.score,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
}
state.score["full_report"] = {
**state.score.get("full_report", {}),
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
}
result = build_report(session_id, state, scenario)
else:
# Historical/archived sessions no longer have a live object in the hub.
# Keep score correction available from the instructor's report history.
result = SessionReport.model_validate(archived).model_copy(update={
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
})
return result
@router.get("", response_model=list[SessionOut])
@ -217,11 +379,13 @@ async def listing(
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
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,
)

View file

@ -8,24 +8,77 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import DEMO_TRAINEE_ID, require
from app.config import get_settings
from app.domain.roles import Role
from app.db.base import get_session, get_sessionmaker
from app.db.models import Group, Score, Session, Trainee
from app.db.models import Group, Score, Session, Trainee, User
from app.scoring.export import certificate_pdf
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
@router.get("/{trainee_id}/certificate.pdf")
async def certificate(
trainee_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> Response:
who = require(request)
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
raise HTTPException(status_code=403, detail="not_your_certificate")
if who.role is Role.INSTRUCTOR:
owns_trainee = await db.scalar(
select(Session.id)
.where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
.limit(1)
)
if owns_trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
group = await db.get(Group, trainee.group_id) if trainee.group_id else None
if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
group = None
scores_query = (
select(func.count(Score.id), func.avg(Score.score_final), func.max(Score.created_at))
.join(Session, Session.id == Score.session_id)
.where(Session.trainee_id == trainee_id)
)
if who.role is Role.INSTRUCTOR:
scores_query = scores_query.where(Session.owner_login == who.login)
result = await db.execute(scores_query)
attempts, average_score, completed_at = result.one()
if not attempts:
raise HTTPException(status_code=409, detail="no_scored_attempts")
try:
content = certificate_pdf(
trainee_name=trainee.name,
trainee_id=trainee.id,
group_name=group.name if group else None,
attempts=int(attempts),
average_score=float(average_score),
issued_at=completed_at.date().isoformat(),
)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return Response(
content=content,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="trainee-{trainee_id}-certificate.pdf"'},
)
class TraineeOut(BaseModel):
id: UUID
name: str
group: str | None = None
service: str | None = None
class AttemptOut(BaseModel):
@ -40,6 +93,7 @@ class AttemptOut(BaseModel):
facts_required: int | None = None
hints: int | None = None
codes: dict[str, int] = {}
recording_available: bool = False
class DeltaOut(BaseModel):
@ -65,14 +119,39 @@ class ProfileOut(BaseModel):
async def listing(request: Request) -> list[TraineeOut]:
"""Список курсантов — преподавателю и администратору: обучающемуся он
не нужен, а чужие фамилии из него видны."""
require(request, Role.INSTRUCTOR, Role.ADMIN)
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
if get_settings().demo_no_db:
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")]
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант", service="Служба 101")]
async with get_sessionmaker()() as db:
rows = await db.execute(
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
query = (
select(Trainee, Group.name, Group.owner_login, User.service)
.join(Group, Group.id == Trainee.group_id, isouter=True)
.join(User, User.trainee_id == Trainee.id, isouter=True)
)
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
if who.role is Role.INSTRUCTOR:
has_owned_attempt = exists(
select(Session.id).where(
Session.trainee_id == Trainee.id,
Session.owner_login == who.login,
)
)
query = query.where(
or_(
Trainee.group_id.is_(None),
Group.owner_login == who.login,
has_owned_attempt,
)
)
rows = await db.execute(query)
return [
TraineeOut(
id=trainee.id,
name=trainee.name,
group=(group if who.role is not Role.INSTRUCTOR or group_owner == who.login else None),
service=service,
)
for trainee, group, group_owner, service in rows
]
@router.get("/{trainee_id}/profile", response_model=ProfileOut)
@ -82,17 +161,30 @@ async def profile(
who = require(request)
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
raise HTTPException(status_code=403, detail="not_your_profile")
if who.role is Role.INSTRUCTOR:
owns_trainee = await db.scalar(
select(Session.id)
.where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
.limit(1)
)
if owns_trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
group = await db.get(Group, trainee.group_id) if trainee.group_id else None
if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
group = None
rows = await db.execute(
attempts_query = (
select(Session, Score)
.join(Score, Score.session_id == Session.id, isouter=True)
.where(Session.trainee_id == trainee_id)
.order_by(Session.created_at)
)
if who.role is Role.INSTRUCTOR:
attempts_query = attempts_query.where(Session.owner_login == who.login)
rows = await db.execute(attempts_query)
attempts: list[AttemptOut] = []
competency_sums: dict[str, list[float]] = {}
for session, score in rows:
@ -110,6 +202,7 @@ async def profile(
facts_required=summary.get("facts_required"),
hints=summary.get("hints"),
codes=summary.get("codes", {}),
recording_available=recording_path(session.id).is_file(),
)
)
for item in (score.report or {}).get("competencies", []) if score else []: