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