Complete DDS training workflow and delivery package

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

View file

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