Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -9,23 +9,29 @@
|
|||
но с проверкой».
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import quote, quote_plus
|
||||
from uuid import UUID
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.admin import backup as backup_service
|
||||
from app.api.auth import audit, hash_password, invalidate_login, require
|
||||
from app.api.auth import (
|
||||
add_audit_entry, audit, audit_required, hash_password, invalidate_login, require,
|
||||
)
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
|
||||
|
|
@ -97,6 +103,7 @@ class UserOut(BaseModel):
|
|||
login: str
|
||||
full_name: str
|
||||
role: Role
|
||||
auth_provider: str
|
||||
service: str | None
|
||||
blocked: bool
|
||||
created_at: datetime
|
||||
|
|
@ -126,6 +133,7 @@ def _out(user: User) -> UserOut:
|
|||
login=user.login,
|
||||
full_name=user.full_name,
|
||||
role=Role(user.role),
|
||||
auth_provider=user.auth_provider,
|
||||
service=user.service,
|
||||
blocked=user.blocked,
|
||||
created_at=user.created_at,
|
||||
|
|
@ -161,13 +169,14 @@ async def create_user(
|
|||
user.trainee_id = trainee.id
|
||||
|
||||
db.add(user)
|
||||
add_audit_entry(db, who.login, who.role.value, "user.create", body.login,
|
||||
ROLE_LABELS[body.role])
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="login_taken") from exc
|
||||
|
||||
await audit(who.login, who.role.value, "user.create", body.login, ROLE_LABELS[body.role])
|
||||
return _out(user)
|
||||
|
||||
|
||||
|
|
@ -180,6 +189,11 @@ async def patch_user(
|
|||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="user_not_found")
|
||||
|
||||
if user.auth_provider == "ldap" and any(
|
||||
value is not None for value in (body.role, body.service, body.password)
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="directory_managed_account")
|
||||
|
||||
changed: list[str] = []
|
||||
if body.role is not None:
|
||||
if user.login == who.login and body.role is not Role.ADMIN:
|
||||
|
|
@ -207,9 +221,10 @@ async def patch_user(
|
|||
if not changed:
|
||||
return _out(user)
|
||||
user.auth_version += 1
|
||||
add_audit_entry(db, who.login, who.role.value, "user.update", user.login,
|
||||
", ".join(changed))
|
||||
await db.commit()
|
||||
invalidate_login(user.login, user.auth_version)
|
||||
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
|
||||
return _out(user)
|
||||
|
||||
|
||||
|
|
@ -222,6 +237,25 @@ class AuditOut(BaseModel):
|
|||
detail: str
|
||||
|
||||
|
||||
def _csv_value(value: object) -> str:
|
||||
"""Prevent spreadsheet formula execution in user-controlled audit fields."""
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value)
|
||||
probe = text.lstrip(" \t\r\n\ufeff\u200b")
|
||||
if probe.startswith(("=", "+", "-", "@")) or text.startswith(("\t", "\r", "\n")):
|
||||
return "'" + text
|
||||
return text
|
||||
|
||||
|
||||
def _csv_row(values: tuple[object, ...]) -> str:
|
||||
output = io.StringIO(newline="")
|
||||
csv.writer(output, lineterminator="\r\n").writerow(
|
||||
[_csv_value(value) for value in values]
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
@router.get("/audit", response_model=list[AuditOut])
|
||||
async def audit_log(
|
||||
request: Request,
|
||||
|
|
@ -254,6 +288,37 @@ async def audit_log(
|
|||
]
|
||||
|
||||
|
||||
@router.get("/audit.csv")
|
||||
async def audit_csv(
|
||||
request: Request,
|
||||
action: str | None = None,
|
||||
actor: str | None = None,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> StreamingResponse:
|
||||
"""Stream the complete filtered security log for offline review/archive."""
|
||||
require(request, Role.ADMIN)
|
||||
query = select(AuditLog).order_by(AuditLog.at.asc(), AuditLog.id.asc())
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
if actor:
|
||||
query = query.where(AuditLog.actor == actor)
|
||||
|
||||
async def rows():
|
||||
yield "\ufeff" + _csv_row(("Когда UTC", "Пользователь", "Роль", "Действие", "Объект", "Подробности"))
|
||||
result = await db.stream_scalars(query)
|
||||
async for row in result:
|
||||
yield _csv_row((
|
||||
row.at.isoformat(), row.actor, row.role, row.action,
|
||||
row.object_id, row.detail,
|
||||
))
|
||||
|
||||
return StreamingResponse(
|
||||
rows(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="lct-audit.csv"'},
|
||||
)
|
||||
|
||||
|
||||
class ServiceState(BaseModel):
|
||||
name: str
|
||||
ok: bool
|
||||
|
|
@ -504,9 +569,25 @@ def _safe_backup_error(exc: backup_service.BackupError) -> str:
|
|||
dsn = get_settings().database_url
|
||||
if dsn:
|
||||
message = message.replace(dsn, "[DATABASE_URL скрыт]")
|
||||
match = re.search(r"://[^:]+:([^@]+)@", dsn)
|
||||
if match and match.group(1):
|
||||
message = message.replace(match.group(1), "[пароль скрыт]")
|
||||
try:
|
||||
password = make_url(dsn).password
|
||||
except Exception: # malformed DSN is handled by backup setup separately
|
||||
password = None
|
||||
if password:
|
||||
# Driver errors may echo the DSN either as configured (percent
|
||||
# encoded) or after the URL parser decoded credentials. Redact all
|
||||
# common representations; checking only the raw password misses
|
||||
# secrets containing @, :, spaces, or other escaped characters.
|
||||
encoded = {quote(password, safe=""), quote_plus(password, safe="")}
|
||||
variants = {
|
||||
password,
|
||||
*encoded,
|
||||
*(re.sub(r"%[0-9A-F]{2}", lambda match: match.group(0).lower(), item)
|
||||
for item in encoded),
|
||||
}
|
||||
for secret in sorted(variants, key=len, reverse=True):
|
||||
if secret:
|
||||
message = message.replace(secret, "[пароль скрыт]")
|
||||
return message
|
||||
|
||||
|
||||
|
|
@ -515,12 +596,21 @@ async def make_backup(request: Request) -> BackupOut:
|
|||
"""Копия прямо сейчас. Расписание — отдельно, в `scripts/backup.py`:
|
||||
кнопка нужна перед занятием, расписание — чтобы о нём не вспоминали."""
|
||||
who = require(request, Role.ADMIN)
|
||||
# Record intent before the irreversible filesystem operation. If the DB
|
||||
# audit store fails after pg_dump finishes, the attempt is still visible.
|
||||
await audit_required(who.login, who.role.value, "backup.create.requested")
|
||||
try:
|
||||
# pg_dump may run for two minutes; never block the event loop for it.
|
||||
created = await run_in_threadpool(backup_service.create)
|
||||
except backup_service.BackupError as exc:
|
||||
detail = _safe_backup_error(exc)
|
||||
# The durable requested event above preserves the attempt even if the
|
||||
# outcome write also fails. Keep the concrete storage error visible to
|
||||
# the operator instead of replacing it with an audit-store error.
|
||||
await audit(who.login, who.role.value, "backup.failed", detail=detail)
|
||||
raise HTTPException(status_code=503, detail=detail) from exc
|
||||
await audit(who.login, who.role.value, "backup.create", created["name"])
|
||||
# A completed backup must not be reported as successful when its security
|
||||
# audit could not be persisted. The file remains visible in the backup list
|
||||
# so an administrator can reconcile it after the audit store recovers.
|
||||
await audit_required(who.login, who.role.value, "backup.create", created["name"])
|
||||
return BackupOut(**created)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Сводка ошибок и рекомендаций учебной группы для преподавателя."""
|
||||
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -8,7 +8,7 @@ from sqlalchemy import and_, func, or_, select
|
|||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
from app.api.auth import add_audit_entry, audit_required, require
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, Score, Session, Trainee, User
|
||||
from app.domain.roles import Role
|
||||
|
|
@ -113,14 +113,16 @@ async def create(
|
|||
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)
|
||||
group = Group(
|
||||
id=uuid4(), name=name, owner_login=who.login if who.role is Role.INSTRUCTOR else None
|
||||
)
|
||||
db.add(group)
|
||||
add_audit_entry(db, who.login, who.role.value, "group.create", str(group.id), group.name)
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -142,14 +144,11 @@ async def transfer_ownership(
|
|||
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),
|
||||
add_audit_entry(
|
||||
db, who.login, who.role.value, "group.transfer", str(group.id),
|
||||
f"{previous_owner or 'admin'} -> {body.owner_login or 'admin'}",
|
||||
)
|
||||
await db.commit()
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
|
|
@ -174,8 +173,8 @@ async def assign_trainee(
|
|||
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
|
||||
add_audit_entry(db, who.login, who.role.value, "group.assign", str(group.id), str(trainee_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)
|
||||
|
||||
|
||||
|
|
@ -204,5 +203,7 @@ async def ai_insight(
|
|||
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")
|
||||
await audit_required(
|
||||
who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only"
|
||||
)
|
||||
return GroupInsightOut(**insight)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ 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.api.auth import DEMO_TRAINEE_ID, add_audit_entry, audit, require
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, LearningMaterial, MaterialAssignment, Trainee
|
||||
|
|
@ -264,8 +264,8 @@ async def create(
|
|||
_demo_materials[row.id] = row
|
||||
else:
|
||||
db.add(row)
|
||||
add_audit_entry(db, who.login, who.role.value, "material.create", str(row.id), row.title)
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.create", str(row.id), row.title)
|
||||
return _out(row)
|
||||
|
||||
|
||||
|
|
@ -301,8 +301,8 @@ async def update(
|
|||
setattr(row, key, value.strip() if isinstance(value, str) else value)
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
if db is not None:
|
||||
add_audit_entry(db, who.login, who.role.value, "material.update", str(row.id))
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.update", str(row.id))
|
||||
return _out(row)
|
||||
|
||||
|
||||
|
|
@ -320,8 +320,8 @@ async def archive(
|
|||
row.active = False
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
if db is not None:
|
||||
add_audit_entry(db, who.login, who.role.value, "material.archive", str(row.id))
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.archive", str(row.id))
|
||||
return _out(row)
|
||||
|
||||
|
||||
|
|
@ -361,9 +361,13 @@ async def assign(
|
|||
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))
|
||||
add_audit_entry(
|
||||
db, who.login, who.role.value, "material.assign", str(row.id), str(trainee_id)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(assignment)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "material.assign", str(row.id), str(trainee_id))
|
||||
return _out(row, assignment=assignment)
|
||||
|
||||
|
||||
|
|
@ -394,8 +398,10 @@ async def assign_group(
|
|||
db.add(MaterialAssignment(
|
||||
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
|
||||
))
|
||||
add_audit_entry(
|
||||
db, who.login, who.role.value, "material.assign_group", str(row.id), str(group_id)
|
||||
)
|
||||
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)}
|
||||
|
||||
|
||||
|
|
@ -422,9 +428,13 @@ async def unassign(
|
|||
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))
|
||||
add_audit_entry(
|
||||
db, who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id)
|
||||
)
|
||||
await db.commit()
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id))
|
||||
return {"removed": removed}
|
||||
|
||||
|
||||
|
|
@ -454,8 +464,10 @@ async def complete(
|
|||
assignment["completed_at"] = completed_at
|
||||
else:
|
||||
assignment.completed_at = completed_at
|
||||
add_audit_entry(db, who.login, who.role.value, "material.complete", str(material_id))
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.complete", str(material_id))
|
||||
if isinstance(assignment, dict):
|
||||
await audit(who.login, who.role.value, "material.complete", str(material_id))
|
||||
return _out(row, assignment=assignment)
|
||||
|
||||
|
||||
|
|
|
|||
402
backend/app/api/http/scenario_submissions.py
Normal file
402
backend/app/api/http/scenario_submissions.py
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
"""Student-authored case outlines and instructor moderation."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import Principal, add_audit_entry, audit, require
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, ScenarioSubmission, Trainee
|
||||
from app.db.models import Scenario as ScenarioRow
|
||||
from app.domain.classifiers import IncidentType, Level
|
||||
from app.domain.kio import KIO, derive_incident
|
||||
from app.domain.roles import Role
|
||||
from app.scenarios import store
|
||||
from app.scenarios.editor import validate
|
||||
from app.scenarios.loader import ScenarioError
|
||||
|
||||
router = APIRouter(prefix="/api/scenario-submissions", tags=["scenario submissions"])
|
||||
_demo_submissions: dict[UUID, dict] = {}
|
||||
|
||||
|
||||
def _card_address(card: KIO) -> str:
|
||||
explicit = (card.address or "").strip()
|
||||
fallback = " ".join(filter(None, (card.street, card.building))).strip()
|
||||
return explicit or fallback
|
||||
|
||||
|
||||
async def submission_session() -> AsyncIterator[AsyncSession | None]:
|
||||
if get_settings().demo_no_db:
|
||||
yield None
|
||||
else:
|
||||
async for db in get_session():
|
||||
yield db
|
||||
|
||||
|
||||
class SubmissionIn(BaseModel):
|
||||
title: str = Field(min_length=3, max_length=200)
|
||||
level: Level
|
||||
kio: KIO
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def normalize_title(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if len(normalized) < 3:
|
||||
raise ValueError("title must contain at least three non-space characters")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_kio(self):
|
||||
card = derive_incident(self.kio)
|
||||
if card.incident_type is None or len((card.description or "").strip()) < 20:
|
||||
raise ValueError("KIO needs incident type and a meaningful description")
|
||||
if not _card_address(card):
|
||||
raise ValueError("KIO needs a usable address")
|
||||
if card.incident_group is None or not card.signs:
|
||||
raise ValueError("KIO needs a classifier group and signs")
|
||||
if not card.notify:
|
||||
raise ValueError("KIO needs at least one derived DDS recipient")
|
||||
data = card.model_dump()
|
||||
data.update(
|
||||
{
|
||||
"card_id": uuid4(),
|
||||
"registered_at": None,
|
||||
"response_status": "registered",
|
||||
"caller_number": None,
|
||||
"incident_code": None,
|
||||
"notify": [],
|
||||
"dispatch_order_at": None,
|
||||
"arrival_at": None,
|
||||
}
|
||||
)
|
||||
object.__setattr__(self, "kio", derive_incident(KIO.model_validate(data)))
|
||||
return self
|
||||
|
||||
|
||||
class ReviewIn(BaseModel):
|
||||
decision: Literal["approve", "reject"]
|
||||
comment: str = Field(default="", max_length=1000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def rejection_needs_reason(self):
|
||||
if self.decision == "reject" and not self.comment.strip():
|
||||
raise ValueError("comment is required when rejecting a proposal")
|
||||
return self
|
||||
|
||||
|
||||
def _out(row, author_name: str | None = None) -> dict:
|
||||
def get(name, default=None):
|
||||
if isinstance(row, dict):
|
||||
return row.get(name, default)
|
||||
return getattr(row, name, default)
|
||||
|
||||
return {
|
||||
"id": str(get("id")),
|
||||
"author_name": author_name or get("author_name", "Курсант"),
|
||||
"title": get("title"),
|
||||
"incident_type": get("incident_type"),
|
||||
"level": get("level"),
|
||||
"description": get("description"),
|
||||
"address": get("address", ""),
|
||||
"victims": get("victims"),
|
||||
"kio": get("kio"),
|
||||
"status": get("status"),
|
||||
"review_comment": get("review_comment", ""),
|
||||
"scenario_id": get("scenario_id"),
|
||||
"created_at": get("created_at"),
|
||||
"reviewed_at": get("reviewed_at"),
|
||||
}
|
||||
|
||||
|
||||
def _scenario_for(row) -> object:
|
||||
sid = f"student-{row['id'].hex if isinstance(row, dict) else row.id.hex}"
|
||||
title = row["title"] if isinstance(row, dict) else row.title
|
||||
level = row["level"] if isinstance(row, dict) else row.level
|
||||
kio_data = row.get("kio") if isinstance(row, dict) else row.kio
|
||||
if kio_data:
|
||||
card = derive_incident(KIO.model_validate(kio_data))
|
||||
if card.incident_type is None:
|
||||
raise ScenarioError("КИО не содержит тип происшествия")
|
||||
address = _card_address(card) or None
|
||||
facts = [{"id": "event", "value": card.description or title}]
|
||||
if address:
|
||||
facts.append({"id": "address", "value": address})
|
||||
caller = "; ".join(
|
||||
filter(None, (card.caller_name, card.caller_contact, card.phone_on_scene))
|
||||
)
|
||||
if caller:
|
||||
facts.append({"id": "f_caller", "value": caller})
|
||||
raw = {
|
||||
"id": sid,
|
||||
"title": title.strip(),
|
||||
"type": card.incident_type.value,
|
||||
"level": level,
|
||||
"topics": ["student-created", "moderated-kio"],
|
||||
"modes": ["training", "exam"],
|
||||
"persona": {"base": "Утверждённая преподавателем учебная карточка КИО."},
|
||||
"first_line": card.description or title,
|
||||
"signs": card.signs,
|
||||
"facts": facts,
|
||||
"checklist": [
|
||||
{"id": "q_event", "question": "Что произошло?", "fact": "event"}
|
||||
],
|
||||
"required_fields": ["address", "description"],
|
||||
"outcome": "card",
|
||||
"dds_decision": {"expected": "accept"},
|
||||
"ground_truth": {
|
||||
**({"address": address} if address else {}),
|
||||
**(
|
||||
{"victims": card.victims_count}
|
||||
if card.victims_count is not None
|
||||
else {}
|
||||
),
|
||||
},
|
||||
"student_card": card.model_dump(mode="json"),
|
||||
}
|
||||
return validate(raw)
|
||||
|
||||
incident_type = row["incident_type"] if isinstance(row, dict) else row.incident_type
|
||||
description = row["description"] if isinstance(row, dict) else row.description
|
||||
address = row.get("address", "") if isinstance(row, dict) else row.address
|
||||
victims = row.get("victims") if isinstance(row, dict) else row.victims
|
||||
facts = [{"id": "event", "value": description.strip()}]
|
||||
if address and address.strip():
|
||||
facts.append({"id": "address", "value": address.strip()})
|
||||
raw = {
|
||||
"id": sid,
|
||||
"title": title.strip(),
|
||||
"type": incident_type,
|
||||
"level": level,
|
||||
"topics": ["student-created"],
|
||||
"modes": ["training", "exam"],
|
||||
"persona": {
|
||||
"base": "Авторский учебный сюжет курсанта, проверенный преподавателем."
|
||||
},
|
||||
"first_line": description.strip(),
|
||||
"facts": facts,
|
||||
"checklist": [{"id": "q_event", "question": "Что произошло?", "fact": "event"}],
|
||||
"outcome": "card",
|
||||
"ground_truth": {
|
||||
**({"address": address.strip()} if address and address.strip() else {}),
|
||||
**({"victims": victims} if victims is not None else {}),
|
||||
},
|
||||
}
|
||||
return validate(raw)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_submission(
|
||||
body: SubmissionIn,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(submission_session),
|
||||
) -> dict:
|
||||
who: Principal = require(request, Role.TRAINEE)
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
now = datetime.now(UTC)
|
||||
card = body.kio
|
||||
incident_type = card.incident_type
|
||||
description = card.description or ""
|
||||
address = _card_address(card)
|
||||
victims = card.victims_count
|
||||
if db is None:
|
||||
row = {
|
||||
"id": uuid4(),
|
||||
"author_trainee_id": who.trainee_id,
|
||||
"author_name": who.full_name,
|
||||
"group_id": None,
|
||||
"title": body.title,
|
||||
"level": body.level.value,
|
||||
"kio": card.model_dump(mode="json"),
|
||||
"incident_type": incident_type.value,
|
||||
"description": description,
|
||||
"address": address,
|
||||
"victims": victims,
|
||||
"status": "pending",
|
||||
"review_comment": "",
|
||||
"reviewed_by": None,
|
||||
"scenario_id": None,
|
||||
"created_at": now,
|
||||
"reviewed_at": None,
|
||||
}
|
||||
_demo_submissions[row["id"]] = row
|
||||
else:
|
||||
trainee = await db.get(Trainee, who.trainee_id)
|
||||
if trainee is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
if trainee.group_id is None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="trainee_group_required_for_review"
|
||||
)
|
||||
group = await db.get(Group, trainee.group_id)
|
||||
if group is None or group.owner_login is None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="instructor_group_required_for_review"
|
||||
)
|
||||
row = ScenarioSubmission(
|
||||
id=uuid4(),
|
||||
author_trainee_id=trainee.id,
|
||||
group_id=trainee.group_id,
|
||||
title=body.title.strip(),
|
||||
incident_type=incident_type.value,
|
||||
level=body.level.value,
|
||||
description=description,
|
||||
address=address,
|
||||
victims=victims,
|
||||
kio=card.model_dump(mode="json"),
|
||||
)
|
||||
db.add(row)
|
||||
add_audit_entry(
|
||||
db, who.login, who.role.value, "scenario.submission.create", str(row.id)
|
||||
)
|
||||
await db.commit()
|
||||
if isinstance(row, dict):
|
||||
await audit(who.login, who.role.value, "scenario.submission.create", str(row["id"]))
|
||||
return _out(row, who.full_name)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_submissions(
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(submission_session),
|
||||
) -> list[dict]:
|
||||
who: Principal = require(request, Role.TRAINEE, Role.INSTRUCTOR, Role.ADMIN)
|
||||
if db is None:
|
||||
if who.role is Role.TRAINEE:
|
||||
rows = [
|
||||
row
|
||||
for row in _demo_submissions.values()
|
||||
if row["author_trainee_id"] == who.trainee_id
|
||||
]
|
||||
else:
|
||||
rows = list(_demo_submissions.values())
|
||||
rows.sort(key=lambda item: item["created_at"], reverse=True)
|
||||
return [_out(row) for row in rows]
|
||||
|
||||
query = select(ScenarioSubmission, Trainee.name).join(
|
||||
Trainee, Trainee.id == ScenarioSubmission.author_trainee_id
|
||||
)
|
||||
if who.role is Role.TRAINEE:
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
query = query.where(ScenarioSubmission.author_trainee_id == who.trainee_id)
|
||||
elif who.role is Role.INSTRUCTOR:
|
||||
owned_groups = select(Group.id).where(Group.owner_login == who.login)
|
||||
query = query.where(ScenarioSubmission.group_id.in_(owned_groups))
|
||||
rows = (
|
||||
await db.execute(query.order_by(ScenarioSubmission.created_at.desc()))
|
||||
).all()
|
||||
return [_out(row, name) for row, name in rows]
|
||||
|
||||
|
||||
async def _reviewable(
|
||||
db: AsyncSession, submission_id: UUID, who: Principal
|
||||
) -> ScenarioSubmission | None:
|
||||
# Serialize concurrent teacher decisions. Under PostgreSQL READ COMMITTED,
|
||||
# a second reviewer waits and then observes the committed non-pending status,
|
||||
# instead of racing to publish the same scenario twice.
|
||||
query = (
|
||||
select(ScenarioSubmission)
|
||||
.where(ScenarioSubmission.id == submission_id)
|
||||
.with_for_update()
|
||||
)
|
||||
if who.role is Role.INSTRUCTOR:
|
||||
owned_groups = select(Group.id).where(Group.owner_login == who.login)
|
||||
query = query.where(ScenarioSubmission.group_id.in_(owned_groups))
|
||||
return await db.scalar(query)
|
||||
|
||||
|
||||
@router.post("/{submission_id}/review")
|
||||
async def review_submission(
|
||||
submission_id: UUID,
|
||||
body: ReviewIn,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(submission_session),
|
||||
) -> dict:
|
||||
who: Principal = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
if db is None:
|
||||
row = _demo_submissions.get(submission_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="submission_not_found")
|
||||
if row["status"] != "pending":
|
||||
raise HTTPException(status_code=409, detail="submission_already_reviewed")
|
||||
else:
|
||||
row = await _reviewable(db, submission_id, who)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="submission_not_found")
|
||||
if row.status != "pending":
|
||||
raise HTTPException(status_code=409, detail="submission_already_reviewed")
|
||||
|
||||
scenario = None
|
||||
if body.decision == "approve":
|
||||
try:
|
||||
scenario = _scenario_for(row)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"scenario_invalid: {exc}"
|
||||
) from exc
|
||||
|
||||
now = datetime.now(UTC)
|
||||
if isinstance(row, dict):
|
||||
row["status"] = "approved" if scenario else "rejected"
|
||||
row["review_comment"] = body.comment.strip()
|
||||
row["reviewed_by"] = who.login
|
||||
row["reviewed_at"] = now
|
||||
if scenario is not None:
|
||||
row["scenario_id"] = scenario.id
|
||||
else:
|
||||
row.status = "approved" if scenario else "rejected"
|
||||
row.review_comment = body.comment.strip()
|
||||
row.reviewed_by = who.login
|
||||
row.reviewed_at = now
|
||||
if scenario is not None:
|
||||
db.add(
|
||||
ScenarioRow(
|
||||
id=scenario.id,
|
||||
title=scenario.title,
|
||||
incident_type=scenario.type.value,
|
||||
level=scenario.level.value,
|
||||
topics=scenario.topics,
|
||||
modes=scenario.modes,
|
||||
status="published",
|
||||
owner_login=who.login,
|
||||
body=scenario.model_dump(mode="json"),
|
||||
)
|
||||
)
|
||||
row.scenario_id = scenario.id
|
||||
add_audit_entry(
|
||||
db,
|
||||
who.login,
|
||||
who.role.value,
|
||||
f"scenario.submission.{body.decision}",
|
||||
str(submission_id),
|
||||
f"comment_chars={len(body.comment.strip())}" if body.comment else "",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if scenario is not None:
|
||||
store.register_owned_scenario(scenario, who.login)
|
||||
if isinstance(row, dict):
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
f"scenario.submission.{body.decision}",
|
||||
str(submission_id),
|
||||
f"comment_chars={len(body.comment.strip())}" if body.comment else "",
|
||||
)
|
||||
result = _out(row)
|
||||
if scenario is not None:
|
||||
result["scenario_id"] = scenario.id
|
||||
return result
|
||||
|
||||
|
||||
def reset_demo_submissions() -> None:
|
||||
_demo_submissions.clear()
|
||||
|
|
@ -1,37 +1,58 @@
|
|||
"""Библиотека сценариев по HTTP.
|
||||
|
||||
`GET /api/scenarios/{id}` **не отдаёт** `facts` и `ground_truth`: иначе курсант
|
||||
откроет DevTools и прочитает адрес до того, как его спросит.
|
||||
|
||||
`checklist` скрыт по той же причине и даже более веской: чек-лист — это
|
||||
содержимое подсказок. Отдать его целиком значит выдать в контрольном режиме
|
||||
то, чего там не должно быть вовсе, и обойти выдачу по одному пункту
|
||||
(docs/product/MODES.md#подсказка-по-запросу). Подсказки идут только событием
|
||||
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
|
||||
Курсантский каталог и карточка отдают только заголовок, сложность и доступные
|
||||
режимы: классификатор, факты, личность звонящего и чек-лист не должны быть
|
||||
доступны заранее через DevTools. Инструктор и администратор получают редакторскую
|
||||
карточку. Подсказки в сессии выдаются по одному пункту через `hint.shown`,
|
||||
эталонные вопросы — только в разборе (docs/product/MODES.md#подсказка-по-запросу).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
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.api.auth import add_audit_entry, audit, require
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, Trainee
|
||||
from app.dialog.llm import LlmUnavailable
|
||||
from app.domain import ekp
|
||||
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.generation import (
|
||||
GenerationError,
|
||||
generate,
|
||||
generate_from_description,
|
||||
)
|
||||
from app.scenarios.loader import ScenarioError
|
||||
from app.scoring.grammar import assess
|
||||
from app.session.hub import hub
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
||||
|
||||
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
|
||||
|
||||
async def _hidden_scenario_ids(db: AsyncSession | None, who) -> set[str]:
|
||||
"""Scenario drafts are private to their instructor and that instructor's class."""
|
||||
if who.role is Role.ADMIN:
|
||||
return set()
|
||||
owner_login = who.login
|
||||
if who.role is Role.TRAINEE:
|
||||
if db is None or who.trainee_id is None:
|
||||
owner_login = ""
|
||||
else:
|
||||
owner_login = await db.scalar(
|
||||
select(Group.owner_login)
|
||||
.join(Trainee, Trainee.group_id == Group.id)
|
||||
.where(Trainee.id == who.trainee_id)
|
||||
) or ""
|
||||
return await store.scenario_ids_owned_by_other(db, owner_login)
|
||||
|
||||
|
||||
async def scenario_session() -> AsyncIterator[AsyncSession | None]:
|
||||
|
|
@ -77,6 +98,25 @@ def _draft_out(row) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _draft_grammar_hash(scenario) -> str:
|
||||
"""Stable fingerprint of the caller dialogue fields covered by grammar QA."""
|
||||
payload = {
|
||||
"first_line": scenario.first_line,
|
||||
"facts": [
|
||||
{"id": fact.id, "value": fact.value, "refined": fact.refined}
|
||||
for fact in scenario.facts
|
||||
],
|
||||
}
|
||||
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _audit_before_commit(actor: str, role: str, action: str, detail: str = ""):
|
||||
return lambda transaction, row: add_audit_entry(
|
||||
transaction, actor, role, action, str(row.id), detail
|
||||
)
|
||||
|
||||
|
||||
@router.post("/drafts/from-template", status_code=201)
|
||||
async def create_template_draft(
|
||||
body: TemplateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
|
|
@ -85,8 +125,17 @@ async def create_template_draft(
|
|||
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, owner_login=who.login)
|
||||
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
||||
row = await store.create_draft(
|
||||
db,
|
||||
source=source,
|
||||
title=body.title,
|
||||
owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.create", f"template:{source.id}"
|
||||
),
|
||||
)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -100,13 +149,19 @@ async def create_ai_draft(
|
|||
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)
|
||||
row = await store.create_draft(
|
||||
db, source=source, proposal=proposal, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.ai_generate", f"source:{source.id}",
|
||||
),
|
||||
)
|
||||
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}")
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
|
||||
f"source:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -123,14 +178,19 @@ async def create_full_ai_draft(
|
|||
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
|
||||
db, source=source, full_proposal=proposal, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.ai_generate_full",
|
||||
f"class_source:{source.id}",
|
||||
),
|
||||
)
|
||||
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}")
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
|
||||
f"class_source:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -157,10 +217,16 @@ async def patch_draft(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
row = await store.update_draft(db, row, body)
|
||||
row = await store.update_draft(
|
||||
db, row, body,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.update"
|
||||
),
|
||||
)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -178,13 +244,22 @@ async def revise_ai_draft(
|
|||
try:
|
||||
source = validate(row.body)
|
||||
proposal = await generate(source, body.comment.strip(), require_fact_change=False)
|
||||
row = await store.revise_draft(db, row, proposal)
|
||||
row = await store.revise_draft(
|
||||
db, row, proposal,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.ai_revise",
|
||||
f"instruction_chars={len(body.comment.strip())}",
|
||||
),
|
||||
)
|
||||
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])
|
||||
# Editorial instructions can contain names, addresses, or other sensitive
|
||||
# details. Keep only non-content metadata in the durable admin audit log.
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
|
||||
f"instruction_chars={len(body.comment.strip())}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -207,6 +282,45 @@ async def validate_draft(
|
|||
}
|
||||
|
||||
|
||||
@router.post("/drafts/{scenario_id}/grammar-check")
|
||||
async def check_draft_grammar(
|
||||
scenario_id: str, 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:
|
||||
scenario = validate(row.body)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"сначала исправьте структуру: {exc}") from exc
|
||||
|
||||
fields = [("first_line", scenario.first_line)]
|
||||
for fact in scenario.facts:
|
||||
fields.append((f"facts.{fact.id}.value", fact.value))
|
||||
if fact.refined:
|
||||
fields.append((f"facts.{fact.id}.refined", fact.refined))
|
||||
checks = []
|
||||
for field, value in fields:
|
||||
result = await assess(value)
|
||||
checks.append({
|
||||
"field": field,
|
||||
"passed": result.passed,
|
||||
"errors": list(result.errors),
|
||||
"source": result.source,
|
||||
})
|
||||
passed = all(item["passed"] for item in checks)
|
||||
row.grammar_check_hash = _draft_grammar_hash(scenario) if passed else None
|
||||
if db is not None:
|
||||
await db.commit()
|
||||
return {"valid": passed, "checks": checks}
|
||||
|
||||
|
||||
@router.post("/drafts/{scenario_id}/approve")
|
||||
async def approve_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
|
|
@ -216,10 +330,23 @@ async def approve_draft(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
scenario = await store.approve_draft(db, row)
|
||||
current = validate(row.body)
|
||||
if (row.manual_edit_pending
|
||||
and row.grammar_check_hash != _draft_grammar_hash(current)):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="после ручных правок требуется успешная проверка грамматики",
|
||||
)
|
||||
scenario = await store.approve_draft(
|
||||
db, row,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.approve"
|
||||
),
|
||||
)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
|
||||
return {"id": scenario.id, "status": "published", "title": scenario.title}
|
||||
|
||||
|
||||
|
|
@ -228,15 +355,36 @@ async def listing(
|
|||
request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> list[dict]:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
if db is not None:
|
||||
# Published student scenarios may have been approved on a peer backend.
|
||||
# Refresh this process-local catalog from the shared authoritative DB.
|
||||
await store.restore_published(db)
|
||||
owned_ids = (
|
||||
await store.owned_scenario_ids(db, who.login)
|
||||
if who is not None and who.role is Role.INSTRUCTOR
|
||||
else set()
|
||||
)
|
||||
return [
|
||||
{
|
||||
hidden_ids = await _hidden_scenario_ids(db, who)
|
||||
result = []
|
||||
for scenario in store.all_scenarios():
|
||||
if scenario.id in hidden_ids:
|
||||
continue
|
||||
if who.role is Role.TRAINEE:
|
||||
# A trainee may select a scenario for self-practice, but the catalog
|
||||
# must not reveal dispatch codes, answer hints, or instructor-only metadata.
|
||||
if "self" not in scenario.modes:
|
||||
continue
|
||||
result.append({
|
||||
"id": scenario.id,
|
||||
"title": scenario.title,
|
||||
"level": scenario.level.value,
|
||||
"modes": scenario.modes,
|
||||
})
|
||||
continue
|
||||
result.append({
|
||||
"id": scenario.id,
|
||||
"title": scenario.title,
|
||||
"outcome": scenario.outcome.value,
|
||||
"type": scenario.type.value,
|
||||
"level": scenario.level.value,
|
||||
"topics": scenario.topics,
|
||||
|
|
@ -253,9 +401,9 @@ async def listing(
|
|||
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()
|
||||
]
|
||||
"source": "trainee" if "student-created" in scenario.topics else "system",
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}")
|
||||
|
|
@ -267,10 +415,16 @@ async def archive_scenario(
|
|||
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)
|
||||
scenario = await store.archive(
|
||||
db, scenario_id, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.archive"
|
||||
),
|
||||
)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
|
||||
return {"id": scenario_id, "status": "archived", "title": scenario.title}
|
||||
|
||||
|
||||
|
|
@ -280,23 +434,42 @@ async def restore_scenario(
|
|||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
scenario = await store.restore_archived(db, scenario_id, owner_login=who.login)
|
||||
scenario = await store.restore_archived(
|
||||
db, scenario_id, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.restore"
|
||||
),
|
||||
)
|
||||
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)
|
||||
if db is None:
|
||||
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, request: Request) -> dict:
|
||||
async def read(
|
||||
scenario_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> 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)
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
if scenario_id in await _hidden_scenario_ids(db, who):
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
if db is not None:
|
||||
await store.restore_published(db)
|
||||
scenario = store.get(scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
payload = scenario.model_dump(mode="json")
|
||||
for key in HIDDEN_FROM_TRAINEE:
|
||||
payload.pop(key, None)
|
||||
payload["required_fields"] = scenario.required_fields
|
||||
return payload
|
||||
if who.role is Role.TRAINEE:
|
||||
if "self" not in scenario.modes:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
return {
|
||||
"id": scenario.id,
|
||||
"title": scenario.title,
|
||||
"level": scenario.level.value,
|
||||
"modes": scenario.modes,
|
||||
}
|
||||
return scenario.model_dump(mode="json")
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
задним числом не надо (docs/arch/CONTRACT.md#http-api).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
|
|
@ -13,20 +16,32 @@ 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.api.auth import add_audit_entry, audit, audit_required, require
|
||||
from app.config import get_settings
|
||||
from app.db import repo
|
||||
from app.db.base import get_session
|
||||
from app.db.models import AuditLog, Score
|
||||
from app.db.models import AuditLog, Group, Score, Session, Trainee
|
||||
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.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
from app.scoring.export import to_csv, to_pdf
|
||||
from app.scoring.report import build as build_report
|
||||
from app.session.checkpoint import load_state
|
||||
from app.session.hub import hub
|
||||
from app.voice.recording import recording_path
|
||||
|
||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def optional_session() -> AsyncIterator[AsyncSession | None]:
|
||||
if get_settings().demo_no_db:
|
||||
yield None
|
||||
else:
|
||||
async for db in get_session():
|
||||
yield db
|
||||
|
||||
|
||||
class SessionCreate(BaseModel):
|
||||
|
|
@ -48,6 +63,43 @@ class SessionOut(BaseModel):
|
|||
end_reason: str | None = None
|
||||
|
||||
|
||||
class DdsHistoryOut(BaseModel):
|
||||
"""Одна завершённая карточка из отчёта занятия; только в границах владельца."""
|
||||
|
||||
session_id: UUID
|
||||
ended_at: datetime
|
||||
card_id: UUID
|
||||
scenario_id: str
|
||||
score_auto: float
|
||||
score_final: float
|
||||
reply_text: str = ""
|
||||
title: str | None = None
|
||||
address: str | None = None
|
||||
description: str | None = None
|
||||
incident_type: str | None = None
|
||||
victims_count: int | None = None
|
||||
received_at: datetime | None = None
|
||||
managed_service: str | None = None
|
||||
recipient_services: list[str] = []
|
||||
|
||||
|
||||
class 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
|
||||
|
||||
|
||||
def _out(session) -> SessionOut:
|
||||
return SessionOut(
|
||||
session_id=session.id,
|
||||
|
|
@ -62,14 +114,204 @@ def _out(session) -> SessionOut:
|
|||
)
|
||||
|
||||
|
||||
@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 []
|
||||
managed_services = state.managed_services()
|
||||
latest_statuses = {
|
||||
service: SERVICE_STATUS_LABELS[current(state.status_log, service)]
|
||||
for service in managed_services
|
||||
if (state.status_log or state.exercise is Exercise.DDS)
|
||||
}
|
||||
result.append(ActiveSessionOut(
|
||||
session_id=state.session_id,
|
||||
trainee_name=state.trainee_name,
|
||||
scenario_id=state.scenario_id,
|
||||
scenario_title=state.scenario_title,
|
||||
mode=state.mode,
|
||||
exercise=state.exercise,
|
||||
started_at=state.started_at,
|
||||
elapsed_seconds=elapsed,
|
||||
dds_card_total=len(state.dds_scenarios),
|
||||
dds_open_cards=len(queue),
|
||||
dds_overdue_cards=sum(
|
||||
not card.timer_stopped and card.elapsed_ms > card.limit_ms for card in queue
|
||||
),
|
||||
dds_work_overdue_cards=sum(
|
||||
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None
|
||||
and timer.started_at is not None
|
||||
and not timer.stopped
|
||||
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
|
||||
for card in state.dds_live_cards
|
||||
),
|
||||
dds_statuses=latest_statuses,
|
||||
dds_snapshot=station,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", response_model=SessionOut, status_code=201)
|
||||
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
group_created = False
|
||||
try:
|
||||
group = await repo.ensure_group(db, body.group, owner_login=who.login) if body.group else None
|
||||
if body.group:
|
||||
group = await db.scalar(select(Group).where(Group.name == body.group))
|
||||
group_created = group is None
|
||||
group = await repo.ensure_group(
|
||||
db, body.group, owner_login=who.login, commit=False
|
||||
)
|
||||
else:
|
||||
group = None
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=404, detail="group_not_found") from exc
|
||||
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
|
||||
trainee_created = False
|
||||
if body.trainee:
|
||||
trainee = await db.scalar(select(Trainee).where(Trainee.name == body.trainee))
|
||||
trainee_created = trainee is None
|
||||
try:
|
||||
trainee = await repo.ensure_trainee(
|
||||
db, body.trainee, group, owner_login=who.login, commit=False
|
||||
)
|
||||
except PermissionError as exc:
|
||||
# A group created earlier in this same request must not be left
|
||||
# behind when the selected learner is outside this instructor's scope.
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found") from exc
|
||||
else:
|
||||
trainee = None
|
||||
|
||||
def audit_creation(transaction, row):
|
||||
if group_created and group is not None:
|
||||
add_audit_entry(
|
||||
transaction, who.login, who.role.value,
|
||||
"group.create", str(group.id), group.name,
|
||||
)
|
||||
if trainee_created and trainee is not None:
|
||||
add_audit_entry(
|
||||
transaction, who.login, who.role.value,
|
||||
"trainee.profile.create", str(trainee.id),
|
||||
)
|
||||
add_audit_entry(
|
||||
transaction,
|
||||
who.login,
|
||||
who.role.value,
|
||||
"session.create",
|
||||
str(row.id),
|
||||
f"scenario={row.scenario_id}; mode={row.mode}; attempt={row.attempt}",
|
||||
)
|
||||
|
||||
session = await repo.create_session(
|
||||
db,
|
||||
scenario_id=body.scenario_id,
|
||||
|
|
@ -77,13 +319,8 @@ async def create(body: SessionCreate, request: Request, db: AsyncSession = Depen
|
|||
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}",
|
||||
backend_node_id=get_settings().backend_node_id,
|
||||
before_commit=audit_creation,
|
||||
)
|
||||
return _out(session)
|
||||
|
||||
|
|
@ -160,7 +397,7 @@ def _live(session_id: UUID):
|
|||
|
||||
|
||||
async def _report_data(
|
||||
session_id: UUID, request: Request, db: AsyncSession,
|
||||
session_id: UUID, request: Request, db: AsyncSession | None,
|
||||
) -> SessionReport:
|
||||
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
|
||||
|
||||
|
|
@ -185,8 +422,21 @@ async def _report_data(
|
|||
raise HTTPException(status_code=409, detail="self_assessment_required")
|
||||
if state.score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
if hub.journal is not None and isinstance(db, AsyncSession):
|
||||
persisted_session = await db.scalar(
|
||||
select(Session.id).where(Session.id == session_id)
|
||||
)
|
||||
if (persisted_session is not None and await db.scalar(
|
||||
select(Score.session_id).where(Score.session_id == session_id)
|
||||
) is None):
|
||||
# Live state is populated just before the journal transaction commits.
|
||||
# Do not expose a report that looks ready but cannot yet be corrected
|
||||
# or retrieved after restart.
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
return build_report(session_id, state, scenario)
|
||||
|
||||
if db is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
session = await repo.get_session(db, session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
|
|
@ -214,26 +464,32 @@ async def _report_data(
|
|||
|
||||
@router.get("/{session_id}/report", response_model=SessionReport)
|
||||
async def report(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
||||
) -> SessionReport:
|
||||
return await _report_data(session_id, request, db)
|
||||
data = await _report_data(session_id, request, db)
|
||||
who = require(request)
|
||||
await audit_required(who.login, who.role.value, "report.read", str(session_id))
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/{session_id}/report.csv")
|
||||
async def report_csv(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
||||
) -> Response:
|
||||
"""Те же права и готовность оценки, что у JSON-разбора."""
|
||||
data = await _report_data(session_id, request, db)
|
||||
content = to_csv(data)
|
||||
who = require(request)
|
||||
await audit_required(who.login, who.role.value, "report.export.csv", str(session_id))
|
||||
return Response(
|
||||
content=to_csv(data), media_type="text/csv; charset=utf-8",
|
||||
content=content, media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{session_id}/report.pdf")
|
||||
async def report_pdf(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
||||
) -> Response:
|
||||
"""Печатный разбор; генерация полностью локальна."""
|
||||
data = await _report_data(session_id, request, db)
|
||||
|
|
@ -241,6 +497,8 @@ async def report_pdf(
|
|||
content = to_pdf(data)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
who = require(request)
|
||||
await audit_required(who.login, who.role.value, "report.export.pdf", str(session_id))
|
||||
return Response(
|
||||
content=content, media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.pdf"'},
|
||||
|
|
@ -274,6 +532,7 @@ async def recording(session_id: UUID, request: Request, db: AsyncSession = Depen
|
|||
path = recording_path(session_id)
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="recording_not_found")
|
||||
await audit_required(who.login, who.role.value, "recording.read", str(session_id))
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="audio/wav",
|
||||
|
|
@ -331,7 +590,11 @@ async def override(
|
|||
role=who.role.value,
|
||||
action="score.override",
|
||||
object_id=str(session_id),
|
||||
detail=f"{score.score_auto} → {body.score_final}: {body.comment}"[:2000],
|
||||
# The actual reason remains attached to the instructor-facing score
|
||||
# report. The durable security audit needs the change and actor, not
|
||||
# a second indefinite copy of free-text that may contain personal data.
|
||||
detail=(f"{score.score_auto} → {body.score_final}; "
|
||||
f"comment_chars={len(body.comment)}"),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
|
@ -371,7 +634,7 @@ async def listing(
|
|||
mode: SessionMode | None = None,
|
||||
since: datetime | None = Query(default=None, alias="from"),
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
db: AsyncSession | None = Depends(optional_session),
|
||||
) -> list[SessionOut]:
|
||||
who = require(request)
|
||||
# Обучающийся видит только свою историю, что бы он ни передал в фильтре.
|
||||
|
|
@ -380,6 +643,30 @@ async def listing(
|
|||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
trainee = who.trainee_id
|
||||
owner_login = who.login if who.role is Role.INSTRUCTOR else None
|
||||
if db is None:
|
||||
# The explicit in-memory demo keeps completed session state in `hub`
|
||||
# until restart. It has no group records, so group-filtered history is
|
||||
# empty rather than silently leaking sessions outside that filter.
|
||||
if group is not None:
|
||||
return []
|
||||
states = hub.history(
|
||||
owner_login=owner_login,
|
||||
trainee_id=trainee,
|
||||
mode=mode.value if mode else None,
|
||||
since=since,
|
||||
limit=limit,
|
||||
)
|
||||
return [SessionOut(
|
||||
session_id=state.session_id,
|
||||
scenario_id=state.scenario_id,
|
||||
mode=state.mode,
|
||||
attempt=state.attempt,
|
||||
trainee_id=state.trainee_id,
|
||||
group_id=None,
|
||||
started_at=state.started_at,
|
||||
ended_at=state.ended_at,
|
||||
end_reason=state.end_reason.value if state.end_reason else None,
|
||||
) for state in states]
|
||||
rows = await repo.history(
|
||||
db,
|
||||
trainee_id=trainee,
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@ from pydantic import BaseModel
|
|||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import DEMO_TRAINEE_ID, require
|
||||
from app.api.auth import DEMO_TRAINEE_ID, audit_required, 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, User
|
||||
from app.domain.taxonomy import ERRORS, ErrorCode
|
||||
from app.scoring.export import certificate_pdf
|
||||
from app.scoring.group import RECOMMENDATIONS
|
||||
from app.voice.recording import recording_path
|
||||
|
||||
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
|
||||
|
|
@ -67,6 +69,9 @@ async def certificate(
|
|||
)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
await audit_required(
|
||||
who.login, who.role.value, "trainee.certificate.export.pdf", str(trainee_id)
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/pdf",
|
||||
|
|
@ -107,14 +112,38 @@ class DeltaOut(BaseModel):
|
|||
facts_got: int | None = None
|
||||
|
||||
|
||||
class RecommendationOut(BaseModel):
|
||||
code: str
|
||||
title: str
|
||||
recommendation: str
|
||||
occurrences: int
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
trainee: TraineeOut
|
||||
attempts: list[AttemptOut]
|
||||
competencies: dict[str, float]
|
||||
deltas: list[DeltaOut]
|
||||
recommendations: list[RecommendationOut]
|
||||
hints_total: int
|
||||
|
||||
|
||||
def _personal_recommendations(codes: dict[str, int]) -> list[RecommendationOut]:
|
||||
"""Следующие упражнения опираются на коды последней оценённой попытки."""
|
||||
recommendations = []
|
||||
for code, count in codes.items():
|
||||
if code not in RECOMMENDATIONS or not isinstance(count, int) or count <= 0:
|
||||
continue
|
||||
error = ERRORS[ErrorCode(code)]
|
||||
recommendations.append(RecommendationOut(
|
||||
code=code,
|
||||
title=error.title,
|
||||
recommendation=RECOMMENDATIONS[code],
|
||||
occurrences=count,
|
||||
))
|
||||
return sorted(recommendations, key=lambda item: (-item.occurrences, item.code))[:5]
|
||||
|
||||
|
||||
@router.get("", response_model=list[TraineeOut])
|
||||
async def listing(request: Request) -> list[TraineeOut]:
|
||||
"""Список курсантов — преподавателю и администратору: обучающемуся он
|
||||
|
|
@ -187,8 +216,11 @@ async def profile(
|
|||
rows = await db.execute(attempts_query)
|
||||
attempts: list[AttemptOut] = []
|
||||
competency_sums: dict[str, list[float]] = {}
|
||||
latest_scored_codes: dict[str, int] = {}
|
||||
for session, score in rows:
|
||||
summary = (score.report or {}).get("summary", {}) if score else {}
|
||||
if score is not None:
|
||||
latest_scored_codes = summary.get("codes", {})
|
||||
attempts.append(
|
||||
AttemptOut(
|
||||
session_id=session.id,
|
||||
|
|
@ -230,13 +262,18 @@ async def profile(
|
|||
)
|
||||
)
|
||||
|
||||
return ProfileOut(
|
||||
result = ProfileOut(
|
||||
trainee=TraineeOut(id=trainee.id, name=trainee.name, group=group.name if group else None),
|
||||
attempts=attempts,
|
||||
competencies=competencies,
|
||||
deltas=deltas,
|
||||
recommendations=_personal_recommendations(latest_scored_codes),
|
||||
hints_total=sum(attempt.hints or 0 for attempt in attempts),
|
||||
)
|
||||
await audit_required(
|
||||
who.login, who.role.value, "trainee.profile.read", str(trainee_id)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _diff(before, after):
|
||||
|
|
|
|||
Loading…
Reference in a new issue