Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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)

View 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()

View file

@ -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")

View file

@ -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,

View file

@ -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):