lct-hack/backend/app/api/http/trainees.py

148 lines
5.5 KiB
Python
Raw Normal View History

"""Профиль курсанта и прогресс.
«Контроль подготовленности» из ТЗ — не таблица оценок, а инструмент планирования
подготовки: радар компетенций, история попыток и дельта между ними
(docs/product/MODES.md#профиль-курсанта). Считается по журналу занятий.
"""
from datetime import datetime
from uuid import UUID
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 fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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.api.auth import require
from app.domain.roles import Role
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
class TraineeOut(BaseModel):
id: UUID
name: str
group: str | None = None
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] = {}
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])
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 listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
"""Список курсантов — преподавателю и администратору: обучающемуся он
не нужен, а чужие фамилии из него видны."""
require(request, Role.INSTRUCTOR, Role.ADMIN)
rows = await db.execute(
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
)
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
@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")
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
rows = await db.execute(
select(Session, Score)
.join(Score, Score.session_id == Session.id, isouter=True)
.where(Session.trainee_id == trainee_id)
.order_by(Session.created_at)
)
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", {}),
)
)
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)