Implement DDS exercise, customer UI and local demo
This commit is contained in:
parent
cec84ffcd0
commit
0526b11f91
46 changed files with 2504 additions and 253 deletions
|
|
@ -15,7 +15,10 @@
|
|||
протокола авторизации в канале нет.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
import weakref
|
||||
from uuid import UUID
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
|
|
@ -31,8 +34,58 @@ from app.domain.roles import Role
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
DEMO_TRAINEE_ID = UUID("00000000-0000-4000-8000-000000000112")
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
# Cookie is signed, but carries a role snapshot. A changed account must not
|
||||
# keep its old privileges for the full 12-hour cookie lifetime. Compose runs
|
||||
# one worker; the process marker also invalidates all cookies after restart.
|
||||
_INSTANCE = secrets.token_urlsafe(32)
|
||||
_generations: dict[str, int] = {}
|
||||
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
|
||||
|
||||
|
||||
async def _close_revoked(ws: WebSocket) -> None:
|
||||
try:
|
||||
await ws.close(code=1008, reason="Учётная запись изменена: войдите снова")
|
||||
except (RuntimeError, OSError):
|
||||
# The peer may already have disconnected; revocation still stands.
|
||||
pass
|
||||
|
||||
|
||||
def invalidate_login(login: str) -> None:
|
||||
"""Revoke previously issued cookies after account/role/password changes."""
|
||||
_generations[login] = _generations.get(login, 0) + 1
|
||||
for ws, loop in list(_active_sockets.get(login, {}).items()):
|
||||
try:
|
||||
if not loop.is_closed():
|
||||
loop.call_soon_threadsafe(lambda socket=ws: asyncio.create_task(_close_revoked(socket)))
|
||||
except RuntimeError:
|
||||
pass # loop closed between the check and scheduling
|
||||
|
||||
|
||||
def _session_principal(session: dict) -> "Principal | None":
|
||||
data = session.get("principal")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
login = data.get("login")
|
||||
if not isinstance(login, str):
|
||||
return None
|
||||
if session.get("auth_instance") != _INSTANCE:
|
||||
return None
|
||||
if session.get("auth_generation") != _generations.get(login, 0):
|
||||
return None
|
||||
try:
|
||||
return Principal.model_validate(data)
|
||||
except Exception: # malformed signed cookie: reject, do not 500
|
||||
log.warning("неверный формат principal в cookie")
|
||||
return None
|
||||
|
||||
|
||||
def _issue_session(request: Request, who: "Principal") -> None:
|
||||
request.session["principal"] = who.model_dump(mode="json")
|
||||
request.session["auth_instance"] = _INSTANCE
|
||||
request.session["auth_generation"] = _generations.get(who.login, 0)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
|
|
@ -50,8 +103,7 @@ def verify_password(password_hash: str, password: str) -> bool:
|
|||
|
||||
|
||||
class Principal(BaseModel):
|
||||
"""Кто действует. Хранится в cookie целиком: ходить в базу за ролью
|
||||
на каждый запрос незачем, а меняется она правкой учётной записи."""
|
||||
"""Кто действует. Cookie stores a snapshot, revoked on account edits."""
|
||||
|
||||
login: str
|
||||
full_name: str
|
||||
|
|
@ -69,9 +121,23 @@ class LoginIn(BaseModel):
|
|||
password: str
|
||||
|
||||
|
||||
def _demo_local(request: Request) -> None:
|
||||
# При ошибочном bind 0.0.0.0 удалённый клиент всё равно не получит cookie.
|
||||
if request.client is None or request.client.host not in {"127.0.0.1", "::1", "localhost", "testclient"}:
|
||||
raise HTTPException(status_code=403, detail="demo_local_only")
|
||||
|
||||
|
||||
def _demo_principal(role: Role) -> Principal:
|
||||
return Principal(
|
||||
login="demo-trainee" if role is Role.TRAINEE else "demo-instructor",
|
||||
full_name="Демо-курсант" if role is Role.TRAINEE else "Демо-преподаватель",
|
||||
role=role,
|
||||
trainee_id=DEMO_TRAINEE_ID if role is Role.TRAINEE else None,
|
||||
)
|
||||
|
||||
|
||||
def current(request: Request) -> Principal | None:
|
||||
data = request.session.get("principal")
|
||||
return Principal.model_validate(data) if data else None
|
||||
return _session_principal(request.session)
|
||||
|
||||
|
||||
def principal_of(websocket: WebSocket) -> Principal | None:
|
||||
|
|
@ -80,8 +146,10 @@ def principal_of(websocket: WebSocket) -> Principal | None:
|
|||
`WebSocket.session` доступен, потому что `SessionMiddleware` стоит до
|
||||
роутера: значит роль известна до входа в цикл приёма сообщений.
|
||||
"""
|
||||
data = websocket.session.get("principal") if "session" in websocket.scope else None
|
||||
return Principal.model_validate(data) if data else None
|
||||
who = _session_principal(websocket.session) if "session" in websocket.scope else None
|
||||
if who is not None:
|
||||
_active_sockets.setdefault(who.login, weakref.WeakKeyDictionary())[websocket] = asyncio.get_running_loop()
|
||||
return who
|
||||
|
||||
|
||||
def require(request: Request, *roles: Role) -> Principal:
|
||||
|
|
@ -99,6 +167,8 @@ async def audit(
|
|||
) -> None:
|
||||
"""Запись в журнал. Аудит не должен ронять действие: если база недоступна,
|
||||
занятие продолжается, а пропуск виден в логе."""
|
||||
if get_settings().demo_no_db:
|
||||
return # в явном demo-режиме запись и долговременный аудит недоступны
|
||||
try:
|
||||
async with get_sessionmaker()() as db:
|
||||
db.add(
|
||||
|
|
@ -113,6 +183,16 @@ async def audit(
|
|||
|
||||
@router.post("/login")
|
||||
async def login(payload: LoginIn, request: Request) -> dict:
|
||||
if get_settings().demo_no_db:
|
||||
_demo_local(request)
|
||||
demo_roles = {"demo-instructor": Role.INSTRUCTOR, "demo-trainee": Role.TRAINEE}
|
||||
role = demo_roles.get(payload.login)
|
||||
if role is None or not secrets.compare_digest(payload.password, "demo"):
|
||||
raise HTTPException(status_code=401, detail="bad_credentials")
|
||||
who = _demo_principal(role)
|
||||
_issue_session(request, who)
|
||||
return who.model_dump(mode="json")
|
||||
|
||||
async with get_sessionmaker()() as db:
|
||||
user = await db.scalar(select(User).where(User.login == payload.login))
|
||||
|
||||
|
|
@ -130,7 +210,7 @@ async def login(payload: LoginIn, request: Request) -> dict:
|
|||
service=user.service,
|
||||
trainee_id=user.trainee_id,
|
||||
)
|
||||
request.session["principal"] = who.model_dump(mode="json")
|
||||
_issue_session(request, who)
|
||||
await audit(who.login, who.role.value, "login")
|
||||
return who.model_dump(mode="json")
|
||||
|
||||
|
|
@ -153,7 +233,7 @@ async def me(request: Request) -> dict:
|
|||
|
||||
|
||||
@router.post("/dev-token")
|
||||
async def dev_token(request: Request) -> dict:
|
||||
async def dev_token(request: Request, role: Role = Role.INSTRUCTOR) -> dict:
|
||||
"""Вход без пароля для наших же инструментов.
|
||||
|
||||
`make lesson` и тесты открывают сокеты напрямую и после включения ролей
|
||||
|
|
@ -161,8 +241,17 @@ async def dev_token(request: Request) -> dict:
|
|||
умолчанию выключенным: в рабочем стенде она отвечает 404, а не 401 —
|
||||
выключенной функции не должно быть видно вовсе.
|
||||
"""
|
||||
if not get_settings().dev_auth_bypass:
|
||||
settings = get_settings()
|
||||
if not settings.dev_auth_bypass:
|
||||
raise HTTPException(status_code=404, detail="not_found")
|
||||
who = Principal(login="dev", full_name="Разработка", role=Role.INSTRUCTOR)
|
||||
request.session["principal"] = who.model_dump(mode="json")
|
||||
if role is not Role.INSTRUCTOR and not settings.demo_no_db:
|
||||
raise HTTPException(status_code=404, detail="not_found")
|
||||
if settings.demo_no_db:
|
||||
_demo_local(request)
|
||||
if role not in {Role.INSTRUCTOR, Role.TRAINEE}:
|
||||
raise HTTPException(status_code=403, detail="demo_role_forbidden")
|
||||
who = _demo_principal(role) if settings.demo_no_db else Principal(
|
||||
login="dev", full_name="Разработка", role=Role.INSTRUCTOR
|
||||
)
|
||||
_issue_session(request, who)
|
||||
return who.model_dump(mode="json")
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -18,9 +19,10 @@ from pydantic import BaseModel, Field
|
|||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.admin import backup as backup_service
|
||||
from app.api.auth import audit, hash_password, require
|
||||
from app.api.auth import audit, hash_password, invalidate_login, require
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
|
||||
|
|
@ -122,6 +124,13 @@ async def patch_user(
|
|||
|
||||
changed: list[str] = []
|
||||
if body.role is not None:
|
||||
if user.login == who.login and body.role is not Role.ADMIN:
|
||||
raise HTTPException(status_code=409, detail="cannot_demote_yourself")
|
||||
if body.role is Role.TRAINEE and user.trainee_id is None:
|
||||
trainee = Trainee(name=user.full_name)
|
||||
db.add(trainee)
|
||||
await db.flush()
|
||||
user.trainee_id = trainee.id
|
||||
user.role = body.role.value
|
||||
changed.append(f"роль {body.role.value}")
|
||||
if body.service is not None:
|
||||
|
|
@ -137,7 +146,10 @@ async def patch_user(
|
|||
user.password_hash = hash_password(body.password)
|
||||
changed.append("пароль сброшен")
|
||||
|
||||
if not changed:
|
||||
return _out(user)
|
||||
await db.commit()
|
||||
invalidate_login(user.login)
|
||||
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
|
||||
return _out(user)
|
||||
|
||||
|
|
@ -162,7 +174,7 @@ async def audit_log(
|
|||
"""Журнал действий. Администратор его читает, но не правит: точки удаления
|
||||
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
|
||||
require(request, Role.ADMIN)
|
||||
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(min(limit, 1000))
|
||||
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000)))
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
if actor:
|
||||
|
|
@ -261,7 +273,19 @@ class BackupOut(BaseModel):
|
|||
@router.get("/backups", response_model=list[BackupOut])
|
||||
async def backups(request: Request) -> list[BackupOut]:
|
||||
require(request, Role.ADMIN)
|
||||
return [BackupOut(**item) for item in backup_service.listing()]
|
||||
return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)]
|
||||
|
||||
|
||||
def _safe_backup_error(exc: backup_service.BackupError) -> str:
|
||||
"""Never echo DATABASE_URL/its password from backup diagnostics to HTTP."""
|
||||
message = str(exc)
|
||||
dsn = get_settings().database_url
|
||||
if dsn:
|
||||
message = message.replace(dsn, "[DATABASE_URL скрыт]")
|
||||
match = re.search(r"://[^:]+:([^@]+)@", dsn)
|
||||
if match and match.group(1):
|
||||
message = message.replace(match.group(1), "[пароль скрыт]")
|
||||
return message
|
||||
|
||||
|
||||
@router.post("/backups", response_model=BackupOut, status_code=201)
|
||||
|
|
@ -270,8 +294,11 @@ async def make_backup(request: Request) -> BackupOut:
|
|||
кнопка нужна перед занятием, расписание — чтобы о нём не вспоминали."""
|
||||
who = require(request, Role.ADMIN)
|
||||
try:
|
||||
created = backup_service.create()
|
||||
# pg_dump may run for two minutes; never block the event loop for it.
|
||||
created = await run_in_threadpool(backup_service.create)
|
||||
except backup_service.BackupError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
detail = _safe_backup_error(exc)
|
||||
await audit(who.login, who.role.value, "backup.failed", detail=detail)
|
||||
raise HTTPException(status_code=503, detail=detail) from exc
|
||||
await audit(who.login, who.role.value, "backup.create", created["name"])
|
||||
return BackupOut(**created)
|
||||
|
|
|
|||
125
backend/app/api/http/groups.py
Normal file
125
backend/app/api/http/groups.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Сводка ошибок и рекомендаций учебной группы для преподавателя."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, Score, Session, Trainee
|
||||
from app.domain.roles import Role
|
||||
from app.scoring.group import ScoredAttempt, summarize
|
||||
|
||||
router = APIRouter(prefix="/api/groups", tags=["groups"])
|
||||
|
||||
|
||||
class GroupOut(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class GroupErrorOut(BaseModel):
|
||||
code: str
|
||||
title: str
|
||||
affected_trainees: int
|
||||
occurrences: int
|
||||
rate_percent: float
|
||||
recommendation: str
|
||||
|
||||
|
||||
class GroupAnalyticsOut(BaseModel):
|
||||
group: GroupOut
|
||||
enrolled_trainees: int
|
||||
active_trainees: int
|
||||
scored_attempts: int
|
||||
average_score: float | None
|
||||
errors: list[GroupErrorOut]
|
||||
|
||||
|
||||
@router.get("", response_model=list[GroupOut])
|
||||
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
groups = await db.scalars(select(Group).order_by(Group.name))
|
||||
return [GroupOut(id=group.id, name=group.name) for group in groups]
|
||||
|
||||
|
||||
@router.post("", response_model=GroupOut, status_code=201)
|
||||
async def create(
|
||||
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> GroupOut:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="group_name_required")
|
||||
group = Group(name=name)
|
||||
db.add(group)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="group_exists") from exc
|
||||
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
|
||||
async def assign_trainee(
|
||||
group_id: UUID, trainee_id: UUID, request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> GroupOut:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
group = await db.get(Group, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="group_not_found")
|
||||
trainee = await db.get(Trainee, trainee_id)
|
||||
if trainee is None:
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found")
|
||||
trainee.group_id = group_id
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
|
||||
async def analytics(
|
||||
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> GroupAnalyticsOut:
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
group = await db.get(Group, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="group_not_found")
|
||||
enrolled = await db.scalar(
|
||||
select(func.count()).select_from(Trainee).where(Trainee.group_id == group_id)
|
||||
)
|
||||
rows = await db.execute(
|
||||
select(Session.trainee_id, Score.score_final, Score.report)
|
||||
.join(Score, Score.session_id == Session.id)
|
||||
.join(Trainee, Trainee.id == Session.trainee_id, isouter=True)
|
||||
.where(
|
||||
or_(
|
||||
Session.group_id == group_id,
|
||||
and_(Session.group_id.is_(None), Trainee.group_id == group_id),
|
||||
),
|
||||
Session.ended_at.is_not(None),
|
||||
)
|
||||
)
|
||||
attempts = [
|
||||
ScoredAttempt(
|
||||
trainee_id=trainee_id,
|
||||
score=score,
|
||||
codes=(report or {}).get("summary", {}).get("codes", {}),
|
||||
)
|
||||
for trainee_id, score, report in rows
|
||||
]
|
||||
return GroupAnalyticsOut(
|
||||
group=GroupOut(id=group.id, name=group.name),
|
||||
**summarize(attempts, int(enrolled or 0)),
|
||||
)
|
||||
|
|
@ -8,7 +8,7 @@ from datetime import datetime
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
|
|
@ -117,9 +117,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut
|
|||
class ScoreOverride(BaseModel):
|
||||
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
|
||||
|
||||
score_final: float
|
||||
score_final: float = Field(ge=0, le=100)
|
||||
comment: str = ""
|
||||
author: str = "преподаватель"
|
||||
|
||||
|
||||
def _live(session_id: UUID):
|
||||
|
|
@ -188,9 +187,13 @@ async def override(session_id: UUID, body: ScoreOverride, request: Request) -> S
|
|||
state.score = {
|
||||
**state.score,
|
||||
"score_final": body.score_final,
|
||||
"overridden_by": body.author,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": body.comment,
|
||||
}
|
||||
if hub.journal:
|
||||
await hub.journal.score_override(
|
||||
session_id, body.score_final, who.login, body.comment
|
||||
)
|
||||
await audit(
|
||||
who.login, who.role.value, "score.override", str(session_id),
|
||||
f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}",
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ from pydantic import BaseModel
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import require
|
||||
from app.api.auth import DEMO_TRAINEE_ID, require
|
||||
from app.config import get_settings
|
||||
from app.domain.roles import Role
|
||||
from app.db.base import get_session
|
||||
from app.db.base import get_session, get_sessionmaker
|
||||
from app.db.models import Group, Score, Session, Trainee
|
||||
|
||||
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
|
||||
|
|
@ -61,14 +62,17 @@ class ProfileOut(BaseModel):
|
|||
|
||||
|
||||
@router.get("", response_model=list[TraineeOut])
|
||||
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
|
||||
async def listing(request: Request) -> 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]
|
||||
if get_settings().demo_no_db:
|
||||
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")]
|
||||
async with get_sessionmaker()() as db:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import math
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
|
@ -44,7 +44,7 @@ from app.api.auth import audit, principal_of
|
|||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.domain.kio import KIO, ResponseStatus
|
||||
from app.session.dds import prepare_card
|
||||
from app.voice.models import get_voice_models
|
||||
from app.voice.pipeline import FILLERS, prefetch
|
||||
|
||||
|
|
@ -84,6 +84,23 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
)
|
||||
return
|
||||
|
||||
scenario_ids = event.scenario_ids or [event.scenario_id]
|
||||
if event.exercise is Exercise.DDS:
|
||||
if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 10:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 10 карточек",
|
||||
))
|
||||
return
|
||||
scenarios = [store.get(scenario_id) for scenario_id in scenario_ids]
|
||||
if any(item is None for item in scenarios):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID, message="В очереди ДДС есть неизвестный сценарий",
|
||||
))
|
||||
return
|
||||
else:
|
||||
scenarios = []
|
||||
|
||||
attempt = 1
|
||||
recorded_trainee_id = event.trainee_id
|
||||
if hub.journal:
|
||||
|
|
@ -114,29 +131,8 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(scenario.id)
|
||||
elif event.exercise is Exercise.DDS:
|
||||
# В ДДС поступает уже оформленная учебная карточка. Содержимое берётся
|
||||
# из утверждённого сценария, а не из действий несуществующего оператора.
|
||||
truth = scenario.ground_truth
|
||||
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
|
||||
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
|
||||
service = truth.dds.value if truth.dds else None
|
||||
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
|
||||
state.kio = KIO(
|
||||
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
|
||||
address=truth.address or address_fact or None,
|
||||
floor=floor.group(1) if floor else None,
|
||||
incident_type=truth.incident_type, incident_code=truth.incident_code,
|
||||
dds=truth.dds, signs=list(scenario.signs),
|
||||
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
|
||||
victims_count=truth.victims,
|
||||
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
|
||||
)
|
||||
if service:
|
||||
state.dispatch(service)
|
||||
else:
|
||||
state.kio.response_status = ResponseStatus.TRANSFERRED
|
||||
state.dispatched_card = state.kio.model_copy(deep=True)
|
||||
state.dispatched_at = now_utc()
|
||||
state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios]
|
||||
prepare_card(state, state.dds_scenarios[0])
|
||||
state.started_at = state.dispatched_at
|
||||
else:
|
||||
state.started_at = now_utc()
|
||||
|
|
@ -151,8 +147,6 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
if models is not None:
|
||||
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
|
||||
state.on_event("call.incoming")
|
||||
elif event.exercise is Exercise.DDS:
|
||||
state.on_event("dds.dispatch")
|
||||
hub.start_ticker(session_id)
|
||||
if who is not None:
|
||||
# Запуск занятия меняет чужой результат — значит попадает в журнал
|
||||
|
|
@ -265,15 +259,39 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
if state is not None:
|
||||
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
|
||||
case "score.override":
|
||||
if event.session_id != session_id:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Оценка относится к другому занятию",
|
||||
))
|
||||
continue
|
||||
state = hub.get(session_id)
|
||||
if state is not None and state.score is not None:
|
||||
try:
|
||||
verdict = float(event.verdict)
|
||||
except ValueError:
|
||||
verdict = float("nan")
|
||||
if not math.isfinite(verdict) or not 0 <= verdict <= 100:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Оценка должна быть числом от 0 до 100",
|
||||
))
|
||||
continue
|
||||
# Автооценка остаётся рядом: видно, что скорректировано и кем.
|
||||
state.score = {
|
||||
**state.score,
|
||||
"score_final": float(event.verdict) if event.verdict.replace(".", "", 1).isdigit() else state.score["score_auto"],
|
||||
"overridden_by": "преподаватель",
|
||||
"score_final": verdict,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": event.comment,
|
||||
}
|
||||
if hub.journal:
|
||||
await hub.journal.score_override(
|
||||
session_id, verdict, who.login, event.comment
|
||||
)
|
||||
await audit(
|
||||
who.login, who.role.value, "score.override", str(session_id),
|
||||
f"{state.score.get('score_auto')} → {verdict}: {event.comment}",
|
||||
)
|
||||
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||
case "director.inject":
|
||||
state = hub.get(session_id)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ from app.domain.roles import Role
|
|||
from app.session.hub import hub
|
||||
from app.session.state import now_utc
|
||||
from app.session.finish import finish
|
||||
from app.session.finish import score_current_dds
|
||||
from app.session.dds import prepare_card
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
|
@ -50,6 +52,18 @@ def _error(session_id: UUID, message: str) -> None:
|
|||
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
|
||||
|
||||
|
||||
async def _finish_dds(session_id: UUID, state) -> None:
|
||||
state.ended_at = now_utc()
|
||||
state.end_reason = CallEndReason.COMPLETE
|
||||
hub.stop_ticker(session_id)
|
||||
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value)
|
||||
await finish(session_id, state)
|
||||
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||
|
||||
|
||||
async def _handle(session_id: UUID, state, event) -> None:
|
||||
if state.ended:
|
||||
_error(session_id, "Занятие уже завершено")
|
||||
|
|
@ -104,6 +118,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
return
|
||||
state.crew_selected = event.crew
|
||||
state.crew_assignments[service] = event.crew
|
||||
state.dds_log.append(("crew.select", now_utc(), event.crew))
|
||||
case "phone.dial":
|
||||
crew = state.crew_selected
|
||||
service = state.crew_service(crew) if crew else None
|
||||
|
|
@ -126,20 +141,37 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
text=REPORT_TEXT[phase], at=now_utc(),
|
||||
)
|
||||
state.phone_reports.append(report)
|
||||
state.dds_log.append(("phone.dial", now_utc(), crew))
|
||||
hub.to_station(session_id, PhoneReport(**report.model_dump()))
|
||||
case "card.reply":
|
||||
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
|
||||
event.card_id != state.dispatched_card.card_id
|
||||
):
|
||||
_error(session_id, "Ответ относится не к текущей карточке")
|
||||
return
|
||||
state.reply_text = event.text
|
||||
state.reply_log.append((now_utc(), event.text))
|
||||
case "card.next":
|
||||
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
|
||||
event.card_id != state.dispatched_card.card_id
|
||||
):
|
||||
_error(session_id, "Следующая карточка недоступна: ID текущей не совпадает")
|
||||
return
|
||||
if any(item.card_id == event.card_id for item in state.dds_completed):
|
||||
_error(session_id, "Эта карточка уже завершена")
|
||||
return
|
||||
state.dds_completed.append(score_current_dds(state))
|
||||
if state.dds_card_index + 1 < len(state.dds_scenarios):
|
||||
state.dds_card_index += 1
|
||||
prepare_card(state, state.dds_scenarios[state.dds_card_index])
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
else:
|
||||
await _finish_dds(session_id, state)
|
||||
case "station.finish":
|
||||
if state.exercise is not Exercise.DDS:
|
||||
_error(session_id, "Операторское занятие завершается после звонка 112")
|
||||
return
|
||||
state.ended_at = now_utc()
|
||||
state.end_reason = CallEndReason.COMPLETE
|
||||
hub.stop_ticker(session_id)
|
||||
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value)
|
||||
await finish(session_id, state)
|
||||
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||
await _finish_dds(session_id, state)
|
||||
case "card.bounce":
|
||||
# Карточка вернулась: в разборе это E6 с конкретной причиной.
|
||||
state.bounced_fields = list(event.missing_fields)
|
||||
|
|
|
|||
Loading…
Reference in a new issue