2026-09-17 21:42:12 +03:00
|
|
|
|
"""Профиль курсанта и прогресс.
|
|
|
|
|
|
|
|
|
|
|
|
«Контроль подготовленности» из ТЗ — не таблица оценок, а инструмент планирования
|
|
|
|
|
|
подготовки: радар компетенций, история попыток и дельта между ними
|
|
|
|
|
|
(docs/product/MODES.md#профиль-курсанта). Считается по журналу занятий.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
2026-09-17 21:42:12 +03:00
|
|
|
|
from pydantic import BaseModel
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from sqlalchemy import exists, func, or_, select
|
2026-09-17 21:42:12 +03:00
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
2026-09-21 19:56:13 +03:00
|
|
|
|
from app.api.auth import DEMO_TRAINEE_ID, require
|
|
|
|
|
|
from app.config import get_settings
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
from app.domain.roles import Role
|
2026-09-21 19:56:13 +03:00
|
|
|
|
from app.db.base import get_session, get_sessionmaker
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.db.models import Group, Score, Session, Trainee, User
|
|
|
|
|
|
from app.scoring.export import certificate_pdf
|
|
|
|
|
|
from app.voice.recording import recording_path
|
2026-09-17 21:42:12 +03:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
@router.get("/{trainee_id}/certificate.pdf")
|
|
|
|
|
|
async def certificate(
|
|
|
|
|
|
trainee_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
|
|
|
|
|
|
) -> Response:
|
|
|
|
|
|
who = require(request)
|
|
|
|
|
|
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="not_your_certificate")
|
|
|
|
|
|
if who.role is Role.INSTRUCTOR:
|
|
|
|
|
|
owns_trainee = await db.scalar(
|
|
|
|
|
|
select(Session.id)
|
|
|
|
|
|
.where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
|
|
|
|
|
|
.limit(1)
|
|
|
|
|
|
)
|
|
|
|
|
|
if owns_trainee is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="trainee_not_found")
|
|
|
|
|
|
trainee = await db.get(Trainee, trainee_id)
|
|
|
|
|
|
if trainee is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="trainee_not_found")
|
|
|
|
|
|
group = await db.get(Group, trainee.group_id) if trainee.group_id else None
|
|
|
|
|
|
if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
|
|
|
|
|
|
group = None
|
|
|
|
|
|
scores_query = (
|
|
|
|
|
|
select(func.count(Score.id), func.avg(Score.score_final), func.max(Score.created_at))
|
|
|
|
|
|
.join(Session, Session.id == Score.session_id)
|
|
|
|
|
|
.where(Session.trainee_id == trainee_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
if who.role is Role.INSTRUCTOR:
|
|
|
|
|
|
scores_query = scores_query.where(Session.owner_login == who.login)
|
|
|
|
|
|
result = await db.execute(scores_query)
|
|
|
|
|
|
attempts, average_score, completed_at = result.one()
|
|
|
|
|
|
if not attempts:
|
|
|
|
|
|
raise HTTPException(status_code=409, detail="no_scored_attempts")
|
|
|
|
|
|
try:
|
|
|
|
|
|
content = certificate_pdf(
|
|
|
|
|
|
trainee_name=trainee.name,
|
|
|
|
|
|
trainee_id=trainee.id,
|
|
|
|
|
|
group_name=group.name if group else None,
|
|
|
|
|
|
attempts=int(attempts),
|
|
|
|
|
|
average_score=float(average_score),
|
|
|
|
|
|
issued_at=completed_at.date().isoformat(),
|
|
|
|
|
|
)
|
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
|
|
|
|
return Response(
|
|
|
|
|
|
content=content,
|
|
|
|
|
|
media_type="application/pdf",
|
|
|
|
|
|
headers={"Content-Disposition": f'attachment; filename="trainee-{trainee_id}-certificate.pdf"'},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 21:42:12 +03:00
|
|
|
|
class TraineeOut(BaseModel):
|
|
|
|
|
|
id: UUID
|
|
|
|
|
|
name: str
|
|
|
|
|
|
group: str | None = None
|
2026-09-24 01:10:49 +03:00
|
|
|
|
service: str | None = None
|
2026-09-17 21:42:12 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AttemptOut(BaseModel):
|
|
|
|
|
|
session_id: UUID
|
|
|
|
|
|
scenario_id: str
|
|
|
|
|
|
mode: str
|
|
|
|
|
|
attempt: int
|
|
|
|
|
|
created_at: datetime
|
|
|
|
|
|
score: float | None = None
|
|
|
|
|
|
interview_ms: int | None = None
|
|
|
|
|
|
facts_got: int | None = None
|
|
|
|
|
|
facts_required: int | None = None
|
|
|
|
|
|
hints: int | None = None
|
|
|
|
|
|
codes: dict[str, int] = {}
|
2026-09-24 01:10:49 +03:00
|
|
|
|
recording_available: bool = False
|
2026-09-17 21:42:12 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DeltaOut(BaseModel):
|
|
|
|
|
|
"""Что изменилось от попытки к попытке — измеримый цикл работы над ошибками."""
|
|
|
|
|
|
|
|
|
|
|
|
scenario_id: str
|
|
|
|
|
|
from_attempt: int
|
|
|
|
|
|
to_attempt: int
|
|
|
|
|
|
score: float | None = None
|
|
|
|
|
|
interview_ms: int | None = None
|
|
|
|
|
|
facts_got: int | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProfileOut(BaseModel):
|
|
|
|
|
|
trainee: TraineeOut
|
|
|
|
|
|
attempts: list[AttemptOut]
|
|
|
|
|
|
competencies: dict[str, float]
|
|
|
|
|
|
deltas: list[DeltaOut]
|
|
|
|
|
|
hints_total: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("", response_model=list[TraineeOut])
|
2026-09-21 19:56:13 +03:00
|
|
|
|
async def listing(request: Request) -> list[TraineeOut]:
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
"""Список курсантов — преподавателю и администратору: обучающемуся он
|
|
|
|
|
|
не нужен, а чужие фамилии из него видны."""
|
2026-09-24 01:10:49 +03:00
|
|
|
|
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
2026-09-21 19:56:13 +03:00
|
|
|
|
if get_settings().demo_no_db:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант", service="Служба 101")]
|
2026-09-21 19:56:13 +03:00
|
|
|
|
async with get_sessionmaker()() as db:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
query = (
|
|
|
|
|
|
select(Trainee, Group.name, Group.owner_login, User.service)
|
|
|
|
|
|
.join(Group, Group.id == Trainee.group_id, isouter=True)
|
|
|
|
|
|
.join(User, User.trainee_id == Trainee.id, isouter=True)
|
2026-09-21 19:56:13 +03:00
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if who.role is Role.INSTRUCTOR:
|
|
|
|
|
|
has_owned_attempt = exists(
|
|
|
|
|
|
select(Session.id).where(
|
|
|
|
|
|
Session.trainee_id == Trainee.id,
|
|
|
|
|
|
Session.owner_login == who.login,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
query = query.where(
|
|
|
|
|
|
or_(
|
|
|
|
|
|
Trainee.group_id.is_(None),
|
|
|
|
|
|
Group.owner_login == who.login,
|
|
|
|
|
|
has_owned_attempt,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
rows = await db.execute(query)
|
|
|
|
|
|
return [
|
|
|
|
|
|
TraineeOut(
|
|
|
|
|
|
id=trainee.id,
|
|
|
|
|
|
name=trainee.name,
|
|
|
|
|
|
group=(group if who.role is not Role.INSTRUCTOR or group_owner == who.login else None),
|
|
|
|
|
|
service=service,
|
|
|
|
|
|
)
|
|
|
|
|
|
for trainee, group, group_owner, service in rows
|
|
|
|
|
|
]
|
2026-09-17 21:42:12 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{trainee_id}/profile", response_model=ProfileOut)
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
async def profile(
|
|
|
|
|
|
trainee_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
|
|
|
|
|
|
) -> ProfileOut:
|
|
|
|
|
|
who = require(request)
|
|
|
|
|
|
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="not_your_profile")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if who.role is Role.INSTRUCTOR:
|
|
|
|
|
|
owns_trainee = await db.scalar(
|
|
|
|
|
|
select(Session.id)
|
|
|
|
|
|
.where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
|
|
|
|
|
|
.limit(1)
|
|
|
|
|
|
)
|
|
|
|
|
|
if owns_trainee is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="trainee_not_found")
|
2026-09-17 21:42:12 +03:00
|
|
|
|
trainee = await db.get(Trainee, trainee_id)
|
|
|
|
|
|
if trainee is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="trainee_not_found")
|
|
|
|
|
|
group = await db.get(Group, trainee.group_id) if trainee.group_id else None
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
|
|
|
|
|
|
group = None
|
2026-09-17 21:42:12 +03:00
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
attempts_query = (
|
2026-09-17 21:42:12 +03:00
|
|
|
|
select(Session, Score)
|
|
|
|
|
|
.join(Score, Score.session_id == Session.id, isouter=True)
|
|
|
|
|
|
.where(Session.trainee_id == trainee_id)
|
|
|
|
|
|
.order_by(Session.created_at)
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if who.role is Role.INSTRUCTOR:
|
|
|
|
|
|
attempts_query = attempts_query.where(Session.owner_login == who.login)
|
|
|
|
|
|
rows = await db.execute(attempts_query)
|
2026-09-17 21:42:12 +03:00
|
|
|
|
attempts: list[AttemptOut] = []
|
|
|
|
|
|
competency_sums: dict[str, list[float]] = {}
|
|
|
|
|
|
for session, score in rows:
|
|
|
|
|
|
summary = (score.report or {}).get("summary", {}) if score else {}
|
|
|
|
|
|
attempts.append(
|
|
|
|
|
|
AttemptOut(
|
|
|
|
|
|
session_id=session.id,
|
|
|
|
|
|
scenario_id=session.scenario_id,
|
|
|
|
|
|
mode=session.mode,
|
|
|
|
|
|
attempt=session.attempt,
|
|
|
|
|
|
created_at=session.created_at,
|
|
|
|
|
|
score=score.score_final if score else None,
|
|
|
|
|
|
interview_ms=summary.get("interview_ms"),
|
|
|
|
|
|
facts_got=summary.get("facts_got"),
|
|
|
|
|
|
facts_required=summary.get("facts_required"),
|
|
|
|
|
|
hints=summary.get("hints"),
|
|
|
|
|
|
codes=summary.get("codes", {}),
|
2026-09-24 01:10:49 +03:00
|
|
|
|
recording_available=recording_path(session.id).is_file(),
|
2026-09-17 21:42:12 +03:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
for item in (score.report or {}).get("competencies", []) if score else []:
|
|
|
|
|
|
competency_sums.setdefault(item["competency"], []).append(item["value"])
|
|
|
|
|
|
|
|
|
|
|
|
# Радар — среднее по попыткам: одна неудачная попытка не определяет курсанта.
|
|
|
|
|
|
competencies = {
|
|
|
|
|
|
name: round(sum(values) / len(values), 3) for name, values in competency_sums.items()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
deltas: list[DeltaOut] = []
|
|
|
|
|
|
by_scenario: dict[str, list[AttemptOut]] = {}
|
|
|
|
|
|
for attempt in attempts:
|
|
|
|
|
|
by_scenario.setdefault(attempt.scenario_id, []).append(attempt)
|
|
|
|
|
|
for scenario_id, items in by_scenario.items():
|
|
|
|
|
|
for previous, current in zip(items, items[1:]):
|
|
|
|
|
|
deltas.append(
|
|
|
|
|
|
DeltaOut(
|
|
|
|
|
|
scenario_id=scenario_id,
|
|
|
|
|
|
from_attempt=previous.attempt,
|
|
|
|
|
|
to_attempt=current.attempt,
|
|
|
|
|
|
score=_diff(previous.score, current.score),
|
|
|
|
|
|
interview_ms=_diff(previous.interview_ms, current.interview_ms),
|
|
|
|
|
|
facts_got=_diff(previous.facts_got, current.facts_got),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return ProfileOut(
|
|
|
|
|
|
trainee=TraineeOut(id=trainee.id, name=trainee.name, group=group.name if group else None),
|
|
|
|
|
|
attempts=attempts,
|
|
|
|
|
|
competencies=competencies,
|
|
|
|
|
|
deltas=deltas,
|
|
|
|
|
|
hints_total=sum(attempt.hints or 0 for attempt in attempts),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _diff(before, after):
|
|
|
|
|
|
if before is None or after is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return round(after - before, 3)
|