402 lines
15 KiB
Python
402 lines
15 KiB
Python
|
|
"""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()
|