"""Учебные материалы, локальные вложения и назначения курсантам. Файлы хранятся в PostgreSQL и отдаются только как attachment: учебный контур не зависит от внешнего файлового сервиса и не исполняет загруженный HTML. """ import base64 import binascii import hashlib from collections.abc import AsyncIterator from datetime import datetime, timezone from typing import Literal from urllib.parse import quote from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException, Request, Response 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, 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 from app.domain.events import Exercise, ScenarioStart, SessionMode from app.domain.roles import Role from app.scenarios import store from app.session.hub import hub router = APIRouter(prefix="/api/materials", tags=["materials"]) MAX_FILE_BYTES = 5 * 1024 * 1024 LEVELS = {"L1", "L2", "L3"} _DEMO_GUIDE_ID = UUID("00000000-0000-4000-8000-000000000901") _demo_materials: dict[UUID, LearningMaterial] = {} _demo_assignments: dict[tuple[UUID, UUID], dict] = {} def reset_demo_materials() -> None: """Демонстрационный справочник воспроизводим после каждого старта.""" _demo_materials.clear() _demo_assignments.clear() now = datetime.now(timezone.utc) _demo_materials[_DEMO_GUIDE_ID] = LearningMaterial( id=_DEMO_GUIDE_ID, title="Памятка диспетчера ДДС", description="Короткий алгоритм работы с готовой карточкой происшествия.", level="L1", kind="text", body=( "1. Подтвердите получение карточки.\n" "2. Проверьте зону ответственности и список оповещения.\n" "3. Назначьте бригаду и передайте адрес, событие и задачу.\n" "4. Фиксируйте выезд, прибытие, локализацию и завершение работ." ), scenario_id="t01-1-fire-container", file_name=None, media_type=None, file_data=None, file_sha256=None, active=True, created_by="system", created_at=now, updated_at=now, ) _demo_assignments[(_DEMO_GUIDE_ID, DEMO_TRAINEE_ID)] = { "assigned_by": "system", "assigned_at": now, "completed_at": None, } async def material_session() -> AsyncIterator[AsyncSession | None]: if get_settings().demo_no_db: yield None else: async for db in get_session(): yield db class MaterialCreate(BaseModel): title: str = Field(min_length=3, max_length=200) description: str = Field(default="", max_length=2000) level: Literal["L1", "L2", "L3"] = "L1" kind: Literal["text", "file"] = "text" body: str = Field(default="", max_length=30_000) scenario_id: str | None = Field(default=None, max_length=80) file_name: str | None = Field(default=None, max_length=240) media_type: str | None = Field(default=None, max_length=120) content_base64: str | None = None @model_validator(mode="after") def valid_content(self): if self.kind == "text" and not self.body.strip(): raise ValueError("текст материала пуст") if self.kind == "file" and (not self.file_name or not self.content_base64): raise ValueError("для файла нужны имя и содержимое") return self class MaterialPatch(BaseModel): title: str | None = Field(default=None, min_length=3, max_length=200) description: str | None = Field(default=None, max_length=2000) level: Literal["L1", "L2", "L3"] | None = None body: str | None = Field(default=None, max_length=30_000) scenario_id: str | None = Field(default=None, max_length=80) active: bool | None = None class MaterialOut(BaseModel): id: UUID title: str description: str level: str kind: str body: str scenario_id: str | None file_name: str | None media_type: str | None file_size: int file_sha256: str | None active: bool created_by: str created_at: datetime assigned_at: datetime | None = None completed_at: datetime | None = None assignment_count: int = 0 def _out( row: LearningMaterial, *, assignment: MaterialAssignment | dict | None = None, assignment_count: int = 0, ) -> MaterialOut: if isinstance(assignment, dict): assigned_at = assignment.get("assigned_at") completed_at = assignment.get("completed_at") else: assigned_at = assignment.assigned_at if assignment else None completed_at = assignment.completed_at if assignment else None return MaterialOut( id=row.id, title=row.title, description=row.description, level=row.level, kind=row.kind, body=row.body, scenario_id=row.scenario_id, file_name=row.file_name, media_type=row.media_type, file_size=len(row.file_data or b""), file_sha256=row.file_sha256, active=row.active, created_by=row.created_by, created_at=row.created_at, assigned_at=assigned_at, completed_at=completed_at, assignment_count=assignment_count, ) def _decode_file(payload: MaterialCreate) -> bytes | None: if payload.kind != "file": return None try: data = base64.b64decode(payload.content_base64 or "", validate=True) except (binascii.Error, ValueError) as exc: raise HTTPException(status_code=422, detail="invalid_file_base64") from exc if not data: raise HTTPException(status_code=422, detail="empty_file") if len(data) > MAX_FILE_BYTES: raise HTTPException(status_code=413, detail="file_too_large_5mb") return data def _validate_scenario(scenario_id: str | None) -> None: if scenario_id and store.get(scenario_id) is None: raise HTTPException(status_code=422, detail="scenario_not_found") def _safe_filename(value: str) -> str: """Убрать и POSIX-, и Windows-путь; в БД остаётся только имя файла.""" return value.replace("\\", "/").rsplit("/", 1)[-1] or "resource.bin" @router.get("", response_model=list[MaterialOut]) async def listing( request: Request, include_archived: bool = False, db: AsyncSession | None = Depends(material_session), ) -> list[MaterialOut]: who = require(request) if db is None: if who.role is Role.TRAINEE: if who.trainee_id is None: raise HTTPException(status_code=403, detail="trainee_profile_required") result = [] for material_id, row in _demo_materials.items(): assignment = _demo_assignments.get((material_id, who.trainee_id)) if assignment and row.active: result.append(_out(row, assignment=assignment)) return result return [ _out(row, assignment_count=sum(mid == row.id for mid, _ in _demo_assignments)) for row in _demo_materials.values() if (include_archived or row.active) and (who.role is not Role.INSTRUCTOR or row.created_by in {who.login, "system"}) ] if who.role is Role.TRAINEE: if who.trainee_id is None: raise HTTPException(status_code=403, detail="trainee_profile_required") rows = await db.execute( select(LearningMaterial, MaterialAssignment) .join(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id) .where( MaterialAssignment.trainee_id == who.trainee_id, LearningMaterial.active.is_(True), ) .order_by(MaterialAssignment.assigned_at.desc()) ) return [_out(row, assignment=assignment) for row, assignment in rows] require(request, Role.INSTRUCTOR, Role.ADMIN) statement = ( select(LearningMaterial, func.count(MaterialAssignment.id)) .outerjoin(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id) .group_by(LearningMaterial.id) .order_by(LearningMaterial.active.desc(), LearningMaterial.updated_at.desc()) ) if who.role is Role.INSTRUCTOR: statement = statement.where(LearningMaterial.created_by == who.login) if not include_archived: statement = statement.where(LearningMaterial.active.is_(True)) rows = await db.execute(statement) return [_out(row, assignment_count=count) for row, count in rows] @router.post("", response_model=MaterialOut, status_code=201) async def create( payload: MaterialCreate, request: Request, db: AsyncSession | None = Depends(material_session), ) -> MaterialOut: who = require(request, Role.INSTRUCTOR) _validate_scenario(payload.scenario_id) data = _decode_file(payload) now = datetime.now(timezone.utc) row = LearningMaterial( id=uuid4(), title=payload.title.strip(), description=payload.description.strip(), level=payload.level, kind=payload.kind, body=payload.body.strip(), scenario_id=payload.scenario_id, file_name=_safe_filename(payload.file_name) if payload.file_name else None, media_type=(payload.media_type or "application/octet-stream") if data else None, file_data=data, file_sha256=hashlib.sha256(data).hexdigest() if data else None, active=True, created_by=who.login, created_at=now, updated_at=now, ) if db is None: _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() return _out(row) async def _material(db: AsyncSession | None, material_id: UUID) -> LearningMaterial | None: return _demo_materials.get(material_id) if db is None else await db.get(LearningMaterial, material_id) def _require_owner(row: LearningMaterial, login: str) -> None: """Only the instructor who authored a resource may manage it.""" if row.created_by != login: raise HTTPException(status_code=404, detail="material_not_found") @router.patch("/{material_id}", response_model=MaterialOut) async def update( material_id: UUID, payload: MaterialPatch, request: Request, db: AsyncSession | None = Depends(material_session), ) -> MaterialOut: who = require(request, Role.INSTRUCTOR) row = await _material(db, material_id) if row is None: raise HTTPException(status_code=404, detail="material_not_found") _require_owner(row, who.login) patch = payload.model_dump(exclude_unset=True) if "scenario_id" in patch: patch["scenario_id"] = patch["scenario_id"] or None _validate_scenario(patch["scenario_id"]) if row.kind == "text" and "body" in patch and not (patch["body"] or "").strip(): raise HTTPException(status_code=422, detail="empty_material_body") for key, value in patch.items(): 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() return _out(row) @router.delete("/{material_id}", response_model=MaterialOut) async def archive( material_id: UUID, request: Request, db: AsyncSession | None = Depends(material_session), ) -> MaterialOut: who = require(request, Role.INSTRUCTOR) row = await _material(db, material_id) if row is None: raise HTTPException(status_code=404, detail="material_not_found") _require_owner(row, who.login) 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() return _out(row) @router.put("/{material_id}/assign/{trainee_id}", response_model=MaterialOut) async def assign( material_id: UUID, trainee_id: UUID, request: Request, db: AsyncSession | None = Depends(material_session), ) -> MaterialOut: who = require(request, Role.INSTRUCTOR) row = await _material(db, material_id) if row is None or not row.active: raise HTTPException(status_code=404, detail="material_not_found") _require_owner(row, who.login) if db is None: if trainee_id != DEMO_TRAINEE_ID: raise HTTPException(status_code=404, detail="trainee_not_found") assignment = _demo_assignments.setdefault( (material_id, trainee_id), {"assigned_by": who.login, "assigned_at": datetime.now(timezone.utc), "completed_at": None}, ) else: trainee = await db.get(Trainee, trainee_id) if trainee is None: raise HTTPException(status_code=404, detail="trainee_not_found") if trainee.group_id is not None: group = await db.get(Group, trainee.group_id) if group is None or group.owner_login != who.login: raise HTTPException(status_code=404, detail="trainee_not_found") assignment = await db.scalar(select(MaterialAssignment).where( MaterialAssignment.material_id == material_id, MaterialAssignment.trainee_id == trainee_id, )) if assignment is None: assignment = MaterialAssignment( material_id=material_id, trainee_id=trainee_id, assigned_by=who.login ) db.add(assignment) 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) @router.put("/{material_id}/assign-group/{group_id}") async def assign_group( material_id: UUID, group_id: UUID, request: Request, db: AsyncSession | None = Depends(material_session), ) -> dict: who = require(request, Role.INSTRUCTOR) row = await _material(db, material_id) if row is None or not row.active: raise HTTPException(status_code=404, detail="material_not_found") _require_owner(row, who.login) if db is None: raise HTTPException(status_code=409, detail="groups_unavailable_in_demo") group = await db.get(Group, group_id) if group is None or group.owner_login != who.login: raise HTTPException(status_code=404, detail="group_not_found") trainee_ids = list(await db.scalars(select(Trainee.id).where(Trainee.group_id == group_id))) existing = set(await db.scalars(select(MaterialAssignment.trainee_id).where( MaterialAssignment.material_id == material_id, MaterialAssignment.trainee_id.in_(trainee_ids), ))) if trainee_ids else set() for trainee_id in trainee_ids: if trainee_id not in existing: 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() return {"material_id": str(row.id), "assigned": len(trainee_ids)} @router.delete("/{material_id}/assign/{trainee_id}") async def unassign( material_id: UUID, trainee_id: UUID, request: Request, db: AsyncSession | None = Depends(material_session), ) -> dict: who = require(request, Role.INSTRUCTOR) row = await _material(db, material_id) if row is None: raise HTTPException(status_code=404, detail="material_not_found") _require_owner(row, who.login) if db is None: removed = _demo_assignments.pop((material_id, trainee_id), None) is not None else: assignment = await db.scalar(select(MaterialAssignment).where( MaterialAssignment.material_id == material_id, MaterialAssignment.trainee_id == trainee_id, )) if assignment is not None and assignment.assigned_by != who.login: raise HTTPException(status_code=404, detail="assignment_not_found") if assignment is not None: await db.delete(assignment) removed = assignment is not None 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} @router.post("/{material_id}/complete", response_model=MaterialOut) async def complete( material_id: UUID, request: Request, db: AsyncSession | None = Depends(material_session), ) -> MaterialOut: who = require(request, Role.TRAINEE) if who.trainee_id is None: raise HTTPException(status_code=403, detail="trainee_profile_required") row = await _material(db, material_id) if row is None or not row.active: raise HTTPException(status_code=404, detail="material_not_found") if db is None: assignment = _demo_assignments.get((material_id, who.trainee_id)) else: assignment = await db.scalar(select(MaterialAssignment).where( MaterialAssignment.material_id == material_id, MaterialAssignment.trainee_id == who.trainee_id, )) if assignment is None: raise HTTPException(status_code=403, detail="material_not_assigned") completed_at = datetime.now(timezone.utc) if isinstance(assignment, dict): 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() if isinstance(assignment, dict): await audit(who.login, who.role.value, "material.complete", str(material_id)) return _out(row, assignment=assignment) @router.post("/{material_id}/start") async def start_assigned_practice( material_id: UUID, request: Request, db: AsyncSession | None = Depends(material_session), ) -> dict: """Курсант сам выбирает назначенный модуль и запускает текстовую практику.""" who = require(request, Role.TRAINEE) if who.trainee_id is None: raise HTTPException(status_code=403, detail="trainee_profile_required") row = await _material(db, material_id) if row is None or not row.active: raise HTTPException(status_code=404, detail="material_not_found") if db is None: assignment = _demo_assignments.get((material_id, who.trainee_id)) else: assignment = await db.scalar(select(MaterialAssignment).where( MaterialAssignment.material_id == material_id, MaterialAssignment.trainee_id == who.trainee_id, )) if assignment is None: raise HTTPException(status_code=403, detail="material_not_assigned") if not row.scenario_id: raise HTTPException(status_code=409, detail="material_has_no_practice") scenario = store.get(row.scenario_id) if scenario is None: raise HTTPException(status_code=409, detail="scenario_not_found") # Явное назначение преподавателя — и есть разрешение на самостоятельный # модуль. Оно не открывает курсанту остальные сценарии библиотеки. from app.api.ws.control import _start session_id = uuid4() await _start(session_id, ScenarioStart( scenario_id=scenario.id, trainee=who.full_name, trainee_id=who.trainee_id, mode=SessionMode.SELF, exercise=Exercise.CARD, ), who) if hub.get(session_id) is None: raise HTTPException(status_code=409, detail="practice_start_failed") return { "session_id": str(session_id), "scenario_id": scenario.id, "mode": SessionMode.SELF.value, "exercise": Exercise.CARD.value, "path": f"/trainee?session={session_id}", } @router.get("/{material_id}/download") async def download( material_id: UUID, request: Request, db: AsyncSession | None = Depends(material_session), ) -> Response: who = require(request) row = await _material(db, material_id) if row is None or not row.active or row.kind != "file" or row.file_data is None: raise HTTPException(status_code=404, detail="file_not_found") if who.role is Role.TRAINEE: if who.trainee_id is None: raise HTTPException(status_code=403, detail="trainee_profile_required") if db is None: allowed = (material_id, who.trainee_id) in _demo_assignments else: allowed = await db.scalar(select(MaterialAssignment.id).where( MaterialAssignment.material_id == material_id, MaterialAssignment.trainee_id == who.trainee_id, )) is not None if not allowed: raise HTTPException(status_code=403, detail="material_not_assigned") elif who.role is Role.INSTRUCTOR: _require_owner(row, who.login) elif who.role not in {Role.INSTRUCTOR, Role.ADMIN}: raise HTTPException(status_code=403, detail="forbidden") filename = _safe_filename(row.file_name or "resource.bin") disposition = f"attachment; filename=resource; filename*=UTF-8''{quote(filename)}" return Response( content=row.file_data, media_type=row.media_type or "application/octet-stream", headers={ "Content-Disposition": disposition, "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'none'", }, )