Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -19,7 +19,9 @@ import asyncio
|
|||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import weakref
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
|
|
@ -27,10 +29,13 @@ from argon2.exceptions import VerifyMismatchError
|
|||
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.db.models import AuditLog, User
|
||||
from app.db.models import AuditLog, Trainee, User
|
||||
from app.directory import DirectoryDenied, DirectoryIdentity, DirectoryUnavailable
|
||||
from app.domain.roles import Role
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -46,20 +51,46 @@ _INSTANCE = hashlib.sha256(
|
|||
).hexdigest()
|
||||
_generations: dict[str, int] = {}
|
||||
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
|
||||
AUTH_GENERATION_SYNC_SECONDS = 1.0
|
||||
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
|
||||
_generations_synced_at: float | None = None
|
||||
|
||||
|
||||
async def _close_revoked(ws: WebSocket) -> None:
|
||||
try:
|
||||
await ws.close(code=1008, reason="Учётная запись изменена: войдите снова")
|
||||
except (RuntimeError, OSError):
|
||||
except (RuntimeError, OSError, WebSocketDisconnect):
|
||||
# The peer may already have disconnected; revocation still stands.
|
||||
pass
|
||||
|
||||
|
||||
async def _close_auth_state_unavailable(ws: WebSocket) -> None:
|
||||
try:
|
||||
await ws.close(code=1013, reason="Состояние доступа временно недоступно")
|
||||
except (RuntimeError, OSError, WebSocketDisconnect):
|
||||
pass
|
||||
|
||||
|
||||
def _close_unverified_sockets() -> None:
|
||||
for sockets in list(_active_sockets.values()):
|
||||
for ws, loop in list(sockets.items()):
|
||||
try:
|
||||
if not loop.is_closed():
|
||||
loop.call_soon_threadsafe(
|
||||
lambda socket=ws: asyncio.create_task(
|
||||
_close_auth_state_unavailable(socket)
|
||||
)
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
def prime_generations(values: dict[str, int]) -> None:
|
||||
"""Загрузить версии полномочий из БД при старте нового процесса."""
|
||||
global _generations_synced_at
|
||||
_generations.clear()
|
||||
_generations.update(values)
|
||||
_generations_synced_at = time.monotonic()
|
||||
|
||||
|
||||
async def load_generations() -> None:
|
||||
|
|
@ -68,6 +99,106 @@ async def load_generations() -> None:
|
|||
prime_generations({login: version for login, version in rows})
|
||||
|
||||
|
||||
async def sync_generations() -> None:
|
||||
"""Refresh shared account epochs and close sockets revoked on peer nodes."""
|
||||
async with get_sessionmaker()() as db:
|
||||
rows = (await db.execute(select(User.login, User.auth_version))).all()
|
||||
current = {login: version for login, version in rows}
|
||||
for login, version in current.items():
|
||||
previous = _generations.get(login)
|
||||
if previous is None:
|
||||
_generations[login] = version
|
||||
elif previous != version:
|
||||
invalidate_login(login, version)
|
||||
# Account deletion is not exposed by the application. Still close active
|
||||
# sockets if an operator removes one directly from the shared directory DB.
|
||||
# The local-only dev-token principal is synthetic, never stored in users;
|
||||
# a DB watcher must not revoke its in-memory generation during test/demo
|
||||
# flows that deliberately exercise account invalidation.
|
||||
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
|
||||
for login in _generations.keys() - current.keys() - synthetic:
|
||||
invalidate_login(login)
|
||||
_generations.pop(login, None)
|
||||
global _generations_synced_at
|
||||
_generations_synced_at = time.monotonic()
|
||||
|
||||
|
||||
async def watch_generations() -> None:
|
||||
"""Poll PostgreSQL once per node so remote logout/role changes close WS."""
|
||||
while True:
|
||||
try:
|
||||
async with asyncio.timeout(AUTH_GENERATION_MAX_AGE_SECONDS):
|
||||
await sync_generations()
|
||||
except Exception as exc: # noqa: BLE001 — retry; authenticated requests fail closed
|
||||
log.error("не удалось синхронизировать версии полномочий (%s)",
|
||||
type(exc).__name__)
|
||||
if (
|
||||
_generations_synced_at is None
|
||||
or time.monotonic() - _generations_synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
|
||||
):
|
||||
_close_unverified_sockets()
|
||||
await asyncio.sleep(AUTH_GENERATION_SYNC_SECONDS)
|
||||
|
||||
|
||||
class AuthVersionMiddleware:
|
||||
"""Check signed-cookie epochs against the fresh, DB-synchronized node cache."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] not in {"http", "websocket"}:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
if scope["type"] == "http" and scope.get("path") in {
|
||||
"/api/health", "/api/auth/logout",
|
||||
}:
|
||||
# Liveness must remain observable and logout must always be able to
|
||||
# clear the browser cookie even while PostgreSQL is unreachable.
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
settings = get_settings()
|
||||
session = scope.get("session")
|
||||
data = session.get("principal") if isinstance(session, dict) else None
|
||||
login = data.get("login") if isinstance(data, dict) else None
|
||||
if (
|
||||
settings.demo_no_db
|
||||
or not isinstance(login, str)
|
||||
or login == "dev" and settings.dev_auth_bypass
|
||||
or session.get("auth_instance") != _INSTANCE
|
||||
):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
synced_at = _generations_synced_at
|
||||
if (
|
||||
synced_at is None
|
||||
or time.monotonic() - synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
|
||||
):
|
||||
if scope["type"] == "websocket":
|
||||
await send({"type": "websocket.close", "code": 1013})
|
||||
else:
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 503,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
})
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": b'{"detail":"auth_state_unavailable"}',
|
||||
})
|
||||
return
|
||||
|
||||
cookie_version = session.get("auth_generation")
|
||||
version = _generations.get(login)
|
||||
if version is None or cookie_version != version:
|
||||
if session is not None:
|
||||
session.clear()
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def invalidate_login(login: str, version: int | None = None) -> None:
|
||||
"""Revoke previously issued cookies after account/role/password changes."""
|
||||
_generations[login] = version if version is not None else _generations.get(login, 0) + 1
|
||||
|
|
@ -112,8 +243,9 @@ def verify_password(password_hash: str, password: str) -> bool:
|
|||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
except Exception: # noqa: BLE001 — битый хеш не должен пускать в систему
|
||||
log.exception("проверка пароля не удалась")
|
||||
except Exception as exc: # noqa: BLE001 — битый хеш не должен пускать в систему
|
||||
# Do not echo malformed stored hash material in diagnostic tracebacks.
|
||||
log.error("проверка пароля не удалась (%s)", type(exc).__name__)
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -167,6 +299,48 @@ def principal_of(websocket: WebSocket) -> Principal | None:
|
|||
return who
|
||||
|
||||
|
||||
def websocket_origin_allowed(websocket: WebSocket) -> bool:
|
||||
"""Reject browser cross-site WebSocket handshakes (CSWSH).
|
||||
|
||||
Non-browser clients may omit Origin. Browser Origins must exactly match
|
||||
the external host and scheme; the bundled reverse proxies forward the
|
||||
original Host and scheme explicitly for this check.
|
||||
"""
|
||||
origin = websocket.headers.get("origin")
|
||||
if origin is None:
|
||||
return True
|
||||
try:
|
||||
parsed_origin = urlsplit(origin)
|
||||
host = websocket.headers.get("x-forwarded-host") or websocket.headers.get("host")
|
||||
scheme = (
|
||||
websocket.headers.get("x-forwarded-proto")
|
||||
or {"ws": "http", "wss": "https"}.get(websocket.scope.get("scheme", ""), "")
|
||||
).casefold()
|
||||
if not host or scheme not in {"http", "https"}:
|
||||
return False
|
||||
expected = urlsplit(f"{scheme}://{host}")
|
||||
if parsed_origin.scheme.casefold() != scheme:
|
||||
return False
|
||||
if (
|
||||
parsed_origin.username
|
||||
or parsed_origin.password
|
||||
or not parsed_origin.hostname
|
||||
or not expected.hostname
|
||||
):
|
||||
return False
|
||||
origin_port = parsed_origin.port or (443 if scheme == "https" else 80)
|
||||
expected_port = expected.port or (443 if scheme == "https" else 80)
|
||||
return (
|
||||
parsed_origin.hostname.casefold() == expected.hostname.casefold()
|
||||
and origin_port == expected_port
|
||||
and parsed_origin.path in {"", "/"}
|
||||
and not parsed_origin.query
|
||||
and not parsed_origin.fragment
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def require(request: Request, *roles: Role) -> Principal:
|
||||
"""Принципал нужной роли или отказ. Единственная точка проверки на HTTP."""
|
||||
who = current(request)
|
||||
|
|
@ -179,21 +353,134 @@ def require(request: Request, *roles: Role) -> Principal:
|
|||
|
||||
async def audit(
|
||||
actor: str, role: str, action: str, object_id: str | None = None, detail: str = ""
|
||||
) -> None:
|
||||
"""Запись в журнал. Аудит не должен ронять действие: если база недоступна,
|
||||
занятие продолжается, а пропуск виден в логе."""
|
||||
) -> bool:
|
||||
"""Best-effort audit write for actions that cannot be rolled back."""
|
||||
if get_settings().demo_no_db:
|
||||
return # в явном demo-режиме запись и долговременный аудит недоступны
|
||||
return True # явный demo-режим не обещает долговременное хранение
|
||||
try:
|
||||
async with get_sessionmaker()() as db:
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor=actor, role=role, action=action, object_id=object_id, detail=detail[:2000]
|
||||
)
|
||||
)
|
||||
add_audit_entry(db, actor, role, action, object_id, detail)
|
||||
await db.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("аудит: запись %s не удалась", action)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# SQL traces can include audit detail and user-provided text.
|
||||
log.error("аудит: запись %s не удалась (%s)", action, type(exc).__name__)
|
||||
return False
|
||||
|
||||
|
||||
async def audit_required(
|
||||
actor: str, role: str, action: str, object_id: str | None = None, detail: str = ""
|
||||
) -> None:
|
||||
"""Fail closed for authentication decisions that must be auditable."""
|
||||
written = await audit(actor, role, action, object_id, detail)
|
||||
# `is False` preserves simple third-party/test audit hooks that return None.
|
||||
if written is False:
|
||||
raise HTTPException(status_code=503, detail="audit_unavailable")
|
||||
|
||||
|
||||
def add_audit_entry(
|
||||
db, actor: str, role: str, action: str, object_id: str | None = None, detail: str = ""
|
||||
) -> None:
|
||||
"""Добавить audit row к текущей транзакции, не коммитя отдельно.
|
||||
|
||||
Для административных операций, где изменение без audit trail недопустимо,
|
||||
вызывающий код коммитит предметную запись и журнал одним commit.
|
||||
"""
|
||||
db.add(AuditLog(
|
||||
actor=actor,
|
||||
role=role,
|
||||
action=action,
|
||||
object_id=object_id,
|
||||
detail=detail[:2000],
|
||||
))
|
||||
|
||||
|
||||
async def _directory_account(identity: DirectoryIdentity) -> User:
|
||||
"""Just-in-time provision and sync one explicitly group-mapped account."""
|
||||
async with get_sessionmaker()() as db:
|
||||
by_login = await db.scalar(select(User).where(User.login == identity.login))
|
||||
by_subject = await db.scalar(
|
||||
select(User).where(User.directory_subject == identity.subject)
|
||||
)
|
||||
if by_login is not None and by_login.auth_provider != "ldap":
|
||||
raise DirectoryDenied("directory login conflicts with a local account")
|
||||
if by_login is not None and by_subject is not None and by_login.id != by_subject.id:
|
||||
raise DirectoryDenied("directory identity conflicts with an existing account")
|
||||
user = by_subject or by_login
|
||||
if user is not None and user.blocked:
|
||||
# Let the login endpoint record the blocked attempt with the same
|
||||
# audit path used for local accounts. Do not sync any account fields.
|
||||
return user
|
||||
if user is not None and user.directory_subject not in {None, identity.subject}:
|
||||
raise DirectoryDenied("directory login is bound to another identity")
|
||||
|
||||
if user is None:
|
||||
trainee_id = None
|
||||
if identity.role is Role.TRAINEE:
|
||||
trainee = Trainee(name=identity.full_name)
|
||||
db.add(trainee)
|
||||
await db.flush()
|
||||
trainee_id = trainee.id
|
||||
user = User(
|
||||
login=identity.login,
|
||||
password_hash=hash_password(secrets.token_urlsafe(48)),
|
||||
full_name=identity.full_name,
|
||||
role=identity.role.value,
|
||||
service=identity.service,
|
||||
trainee_id=trainee_id,
|
||||
auth_provider="ldap",
|
||||
directory_subject=identity.subject,
|
||||
auth_version=0,
|
||||
blocked=False,
|
||||
)
|
||||
db.add(user)
|
||||
add_audit_entry(
|
||||
db,
|
||||
"system",
|
||||
"system",
|
||||
"user.provision.ldap",
|
||||
identity.login,
|
||||
f"role={identity.role.value}; service_assigned={identity.service is not None}",
|
||||
)
|
||||
else:
|
||||
if user.role != identity.role.value and identity.role is Role.TRAINEE and user.trainee_id is None:
|
||||
trainee = Trainee(name=identity.full_name)
|
||||
db.add(trainee)
|
||||
await db.flush()
|
||||
user.trainee_id = trainee.id
|
||||
changed = (
|
||||
user.full_name != identity.full_name
|
||||
or user.role != identity.role.value
|
||||
or user.service != identity.service
|
||||
or user.directory_subject != identity.subject
|
||||
)
|
||||
user.full_name = identity.full_name
|
||||
user.role = identity.role.value
|
||||
user.service = identity.service
|
||||
user.directory_subject = identity.subject
|
||||
if user.trainee_id is not None:
|
||||
trainee = await db.get(Trainee, user.trainee_id)
|
||||
if trainee is not None:
|
||||
trainee.name = identity.full_name
|
||||
if changed:
|
||||
user.auth_version += 1
|
||||
add_audit_entry(
|
||||
db,
|
||||
"system",
|
||||
"system",
|
||||
"user.sync.ldap",
|
||||
identity.login,
|
||||
f"role={user.role}; service_assigned={user.service is not None}",
|
||||
)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
# Concurrent first login or duplicate directory subject is denied;
|
||||
# the caller can retry after the account mapping is unambiguous.
|
||||
raise DirectoryDenied("directory account provisioning conflict") from exc
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
|
|
@ -211,19 +498,35 @@ async def login(payload: LoginIn, request: Request) -> dict:
|
|||
async with get_sessionmaker()() as db:
|
||||
user = await db.scalar(select(User).where(User.login == payload.login))
|
||||
|
||||
# Одинаковый ответ на неизвестный логин и неверный пароль: иначе форма
|
||||
# входа превращается в список действующих учётных записей.
|
||||
if user is None or not verify_password(user.password_hash, payload.password):
|
||||
settings = get_settings()
|
||||
if user is None or user.auth_provider == "ldap":
|
||||
if not settings.ldap_enabled:
|
||||
raise HTTPException(status_code=401, detail="bad_credentials")
|
||||
from app.directory import authenticate
|
||||
|
||||
try:
|
||||
identity = await authenticate(payload.login, payload.password)
|
||||
if identity is None:
|
||||
raise DirectoryDenied("unknown directory account")
|
||||
user = await _directory_account(identity)
|
||||
except DirectoryDenied as exc:
|
||||
await audit_required(payload.login[:80], "unknown", "login.failed")
|
||||
raise HTTPException(status_code=401, detail="bad_credentials") from exc
|
||||
except DirectoryUnavailable as exc:
|
||||
log.error("local directory unavailable: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="directory_unavailable") from exc
|
||||
elif not verify_password(user.password_hash, payload.password):
|
||||
# Не записываем пароль, IP либо факт существования учётной записи.
|
||||
# Логин нужен администратору для расследования перебора; ограничиваем
|
||||
# длину до размера поля AuditLog.actor.
|
||||
await audit(payload.login[:80], "unknown", "login.failed")
|
||||
await audit_required(payload.login[:80], "unknown", "login.failed")
|
||||
raise HTTPException(status_code=401, detail="bad_credentials")
|
||||
if user.blocked:
|
||||
await audit(user.login, user.role, "login.blocked")
|
||||
await audit_required(user.login, user.role, "login.blocked")
|
||||
raise HTTPException(status_code=403, detail="blocked")
|
||||
|
||||
_generations[user.login] = user.auth_version
|
||||
if _generations.get(user.login) != user.auth_version:
|
||||
invalidate_login(user.login, user.auth_version)
|
||||
|
||||
who = Principal(
|
||||
login=user.login,
|
||||
|
|
@ -232,17 +535,41 @@ async def login(payload: LoginIn, request: Request) -> dict:
|
|||
service=user.service,
|
||||
trainee_id=user.trainee_id,
|
||||
)
|
||||
await audit_required(who.login, who.role.value, "login")
|
||||
_issue_session(request, who)
|
||||
await audit(who.login, who.role.value, "login")
|
||||
return who.model_dump(mode="json")
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request) -> dict:
|
||||
who = current(request)
|
||||
if who is None:
|
||||
request.session.clear()
|
||||
return {"ok": True}
|
||||
|
||||
# Drop the browser cookie even if durable revocation is unavailable.
|
||||
request.session.clear()
|
||||
if who:
|
||||
if get_settings().demo_no_db:
|
||||
invalidate_login(who.login)
|
||||
await audit(who.login, who.role.value, "logout")
|
||||
return {"ok": True}
|
||||
|
||||
try:
|
||||
async with get_sessionmaker()() as db:
|
||||
user = await db.scalar(select(User).where(User.login == who.login).with_for_update())
|
||||
if user is not None:
|
||||
user.auth_version += 1
|
||||
version = user.auth_version
|
||||
else:
|
||||
version = _generations.get(who.login, 0) + 1
|
||||
add_audit_entry(db, who.login, who.role.value, "logout")
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001 — fail closed for revocation/audit
|
||||
log.error("выход: отзыв cookie и аудит не удалось сохранить (%s)",
|
||||
type(exc).__name__)
|
||||
invalidate_login(who.login)
|
||||
raise HTTPException(status_code=503, detail="audit_unavailable") from exc
|
||||
invalidate_login(who.login, version)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,23 +9,29 @@
|
|||
но с проверкой».
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import quote, quote_plus
|
||||
from uuid import UUID
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.engine import make_url
|
||||
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, invalidate_login, require
|
||||
from app.api.auth import (
|
||||
add_audit_entry, audit, audit_required, 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
|
||||
|
|
@ -97,6 +103,7 @@ class UserOut(BaseModel):
|
|||
login: str
|
||||
full_name: str
|
||||
role: Role
|
||||
auth_provider: str
|
||||
service: str | None
|
||||
blocked: bool
|
||||
created_at: datetime
|
||||
|
|
@ -126,6 +133,7 @@ def _out(user: User) -> UserOut:
|
|||
login=user.login,
|
||||
full_name=user.full_name,
|
||||
role=Role(user.role),
|
||||
auth_provider=user.auth_provider,
|
||||
service=user.service,
|
||||
blocked=user.blocked,
|
||||
created_at=user.created_at,
|
||||
|
|
@ -161,13 +169,14 @@ async def create_user(
|
|||
user.trainee_id = trainee.id
|
||||
|
||||
db.add(user)
|
||||
add_audit_entry(db, who.login, who.role.value, "user.create", body.login,
|
||||
ROLE_LABELS[body.role])
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="login_taken") from exc
|
||||
|
||||
await audit(who.login, who.role.value, "user.create", body.login, ROLE_LABELS[body.role])
|
||||
return _out(user)
|
||||
|
||||
|
||||
|
|
@ -180,6 +189,11 @@ async def patch_user(
|
|||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="user_not_found")
|
||||
|
||||
if user.auth_provider == "ldap" and any(
|
||||
value is not None for value in (body.role, body.service, body.password)
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="directory_managed_account")
|
||||
|
||||
changed: list[str] = []
|
||||
if body.role is not None:
|
||||
if user.login == who.login and body.role is not Role.ADMIN:
|
||||
|
|
@ -207,9 +221,10 @@ async def patch_user(
|
|||
if not changed:
|
||||
return _out(user)
|
||||
user.auth_version += 1
|
||||
add_audit_entry(db, who.login, who.role.value, "user.update", user.login,
|
||||
", ".join(changed))
|
||||
await db.commit()
|
||||
invalidate_login(user.login, user.auth_version)
|
||||
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
|
||||
return _out(user)
|
||||
|
||||
|
||||
|
|
@ -222,6 +237,25 @@ class AuditOut(BaseModel):
|
|||
detail: str
|
||||
|
||||
|
||||
def _csv_value(value: object) -> str:
|
||||
"""Prevent spreadsheet formula execution in user-controlled audit fields."""
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value)
|
||||
probe = text.lstrip(" \t\r\n\ufeff\u200b")
|
||||
if probe.startswith(("=", "+", "-", "@")) or text.startswith(("\t", "\r", "\n")):
|
||||
return "'" + text
|
||||
return text
|
||||
|
||||
|
||||
def _csv_row(values: tuple[object, ...]) -> str:
|
||||
output = io.StringIO(newline="")
|
||||
csv.writer(output, lineterminator="\r\n").writerow(
|
||||
[_csv_value(value) for value in values]
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
@router.get("/audit", response_model=list[AuditOut])
|
||||
async def audit_log(
|
||||
request: Request,
|
||||
|
|
@ -254,6 +288,37 @@ async def audit_log(
|
|||
]
|
||||
|
||||
|
||||
@router.get("/audit.csv")
|
||||
async def audit_csv(
|
||||
request: Request,
|
||||
action: str | None = None,
|
||||
actor: str | None = None,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> StreamingResponse:
|
||||
"""Stream the complete filtered security log for offline review/archive."""
|
||||
require(request, Role.ADMIN)
|
||||
query = select(AuditLog).order_by(AuditLog.at.asc(), AuditLog.id.asc())
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
if actor:
|
||||
query = query.where(AuditLog.actor == actor)
|
||||
|
||||
async def rows():
|
||||
yield "\ufeff" + _csv_row(("Когда UTC", "Пользователь", "Роль", "Действие", "Объект", "Подробности"))
|
||||
result = await db.stream_scalars(query)
|
||||
async for row in result:
|
||||
yield _csv_row((
|
||||
row.at.isoformat(), row.actor, row.role, row.action,
|
||||
row.object_id, row.detail,
|
||||
))
|
||||
|
||||
return StreamingResponse(
|
||||
rows(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="lct-audit.csv"'},
|
||||
)
|
||||
|
||||
|
||||
class ServiceState(BaseModel):
|
||||
name: str
|
||||
ok: bool
|
||||
|
|
@ -504,9 +569,25 @@ def _safe_backup_error(exc: backup_service.BackupError) -> str:
|
|||
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), "[пароль скрыт]")
|
||||
try:
|
||||
password = make_url(dsn).password
|
||||
except Exception: # malformed DSN is handled by backup setup separately
|
||||
password = None
|
||||
if password:
|
||||
# Driver errors may echo the DSN either as configured (percent
|
||||
# encoded) or after the URL parser decoded credentials. Redact all
|
||||
# common representations; checking only the raw password misses
|
||||
# secrets containing @, :, spaces, or other escaped characters.
|
||||
encoded = {quote(password, safe=""), quote_plus(password, safe="")}
|
||||
variants = {
|
||||
password,
|
||||
*encoded,
|
||||
*(re.sub(r"%[0-9A-F]{2}", lambda match: match.group(0).lower(), item)
|
||||
for item in encoded),
|
||||
}
|
||||
for secret in sorted(variants, key=len, reverse=True):
|
||||
if secret:
|
||||
message = message.replace(secret, "[пароль скрыт]")
|
||||
return message
|
||||
|
||||
|
||||
|
|
@ -515,12 +596,21 @@ async def make_backup(request: Request) -> BackupOut:
|
|||
"""Копия прямо сейчас. Расписание — отдельно, в `scripts/backup.py`:
|
||||
кнопка нужна перед занятием, расписание — чтобы о нём не вспоминали."""
|
||||
who = require(request, Role.ADMIN)
|
||||
# Record intent before the irreversible filesystem operation. If the DB
|
||||
# audit store fails after pg_dump finishes, the attempt is still visible.
|
||||
await audit_required(who.login, who.role.value, "backup.create.requested")
|
||||
try:
|
||||
# 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:
|
||||
detail = _safe_backup_error(exc)
|
||||
# The durable requested event above preserves the attempt even if the
|
||||
# outcome write also fails. Keep the concrete storage error visible to
|
||||
# the operator instead of replacing it with an audit-store error.
|
||||
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"])
|
||||
# A completed backup must not be reported as successful when its security
|
||||
# audit could not be persisted. The file remains visible in the backup list
|
||||
# so an administrator can reconcile it after the audit store recovers.
|
||||
await audit_required(who.login, who.role.value, "backup.create", created["name"])
|
||||
return BackupOut(**created)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Сводка ошибок и рекомендаций учебной группы для преподавателя."""
|
||||
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -8,7 +8,7 @@ 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.api.auth import add_audit_entry, audit_required, require
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, Score, Session, Trainee, User
|
||||
from app.domain.roles import Role
|
||||
|
|
@ -113,14 +113,16 @@ async def create(
|
|||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="group_name_required")
|
||||
group = Group(name=name, owner_login=who.login if who.role is Role.INSTRUCTOR else None)
|
||||
group = Group(
|
||||
id=uuid4(), name=name, owner_login=who.login if who.role is Role.INSTRUCTOR else None
|
||||
)
|
||||
db.add(group)
|
||||
add_audit_entry(db, who.login, who.role.value, "group.create", str(group.id), group.name)
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -142,14 +144,11 @@ async def transfer_ownership(
|
|||
raise HTTPException(status_code=422, detail="active_instructor_required")
|
||||
previous_owner = group.owner_login
|
||||
group.owner_login = body.owner_login
|
||||
await db.commit()
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
"group.transfer",
|
||||
str(group.id),
|
||||
add_audit_entry(
|
||||
db, who.login, who.role.value, "group.transfer", str(group.id),
|
||||
f"{previous_owner or 'admin'} -> {body.owner_login or 'admin'}",
|
||||
)
|
||||
await db.commit()
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
|
|
@ -174,8 +173,8 @@ async def assign_trainee(
|
|||
if current_group is None or current_group.owner_login != who.login:
|
||||
raise HTTPException(status_code=409, detail="trainee_in_other_instructor_group")
|
||||
trainee.group_id = group_id
|
||||
add_audit_entry(db, who.login, who.role.value, "group.assign", str(group.id), str(trainee_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)
|
||||
|
||||
|
||||
|
|
@ -204,5 +203,7 @@ async def ai_insight(
|
|||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except InsightInvalid as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only")
|
||||
await audit_required(
|
||||
who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only"
|
||||
)
|
||||
return GroupInsightOut(**insight)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ 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, audit, require
|
||||
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
|
||||
|
|
@ -264,8 +264,8 @@ async def create(
|
|||
_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()
|
||||
await audit(who.login, who.role.value, "material.create", str(row.id), row.title)
|
||||
return _out(row)
|
||||
|
||||
|
||||
|
|
@ -301,8 +301,8 @@ async def update(
|
|||
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()
|
||||
await audit(who.login, who.role.value, "material.update", str(row.id))
|
||||
return _out(row)
|
||||
|
||||
|
||||
|
|
@ -320,8 +320,8 @@ async def archive(
|
|||
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()
|
||||
await audit(who.login, who.role.value, "material.archive", str(row.id))
|
||||
return _out(row)
|
||||
|
||||
|
||||
|
|
@ -361,9 +361,13 @@ async def assign(
|
|||
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
|
||||
)
|
||||
db.add(assignment)
|
||||
await db.commit()
|
||||
await db.refresh(assignment)
|
||||
await audit(who.login, who.role.value, "material.assign", str(row.id), str(trainee_id))
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -394,8 +398,10 @@ async def assign_group(
|
|||
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()
|
||||
await audit(who.login, who.role.value, "material.assign_group", str(row.id), str(group_id))
|
||||
return {"material_id": str(row.id), "assigned": len(trainee_ids)}
|
||||
|
||||
|
||||
|
|
@ -422,9 +428,13 @@ async def unassign(
|
|||
raise HTTPException(status_code=404, detail="assignment_not_found")
|
||||
if assignment is not None:
|
||||
await db.delete(assignment)
|
||||
await db.commit()
|
||||
removed = assignment is not None
|
||||
await audit(who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id))
|
||||
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}
|
||||
|
||||
|
||||
|
|
@ -454,8 +464,10 @@ async def complete(
|
|||
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()
|
||||
await audit(who.login, who.role.value, "material.complete", str(material_id))
|
||||
if isinstance(assignment, dict):
|
||||
await audit(who.login, who.role.value, "material.complete", str(material_id))
|
||||
return _out(row, assignment=assignment)
|
||||
|
||||
|
||||
|
|
|
|||
402
backend/app/api/http/scenario_submissions.py
Normal file
402
backend/app/api/http/scenario_submissions.py
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
"""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()
|
||||
|
|
@ -1,37 +1,58 @@
|
|||
"""Библиотека сценариев по HTTP.
|
||||
|
||||
`GET /api/scenarios/{id}` **не отдаёт** `facts` и `ground_truth`: иначе курсант
|
||||
откроет DevTools и прочитает адрес до того, как его спросит.
|
||||
|
||||
`checklist` скрыт по той же причине и даже более веской: чек-лист — это
|
||||
содержимое подсказок. Отдать его целиком значит выдать в контрольном режиме
|
||||
то, чего там не должно быть вовсе, и обойти выдачу по одному пункту
|
||||
(docs/product/MODES.md#подсказка-по-запросу). Подсказки идут только событием
|
||||
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
|
||||
Курсантский каталог и карточка отдают только заголовок, сложность и доступные
|
||||
режимы: классификатор, факты, личность звонящего и чек-лист не должны быть
|
||||
доступны заранее через DevTools. Инструктор и администратор получают редакторскую
|
||||
карточку. Подсказки в сессии выдаются по одному пункту через `hint.shown`,
|
||||
эталонные вопросы — только в разборе (docs/product/MODES.md#подсказка-по-запросу).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
from app.domain import ekp
|
||||
from app.db.base import get_session
|
||||
from app.api.auth import add_audit_entry, audit, require
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, Trainee
|
||||
from app.dialog.llm import LlmUnavailable
|
||||
from app.domain import ekp
|
||||
from app.domain.roles import Role
|
||||
from app.scenarios import store
|
||||
from app.scenarios.editor import validate
|
||||
from app.scenarios.generation import GenerationError, generate, generate_from_description
|
||||
from app.dialog.llm import LlmUnavailable
|
||||
from app.scenarios.generation import (
|
||||
GenerationError,
|
||||
generate,
|
||||
generate_from_description,
|
||||
)
|
||||
from app.scenarios.loader import ScenarioError
|
||||
from app.scoring.grammar import assess
|
||||
from app.session.hub import hub
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
||||
|
||||
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
|
||||
|
||||
async def _hidden_scenario_ids(db: AsyncSession | None, who) -> set[str]:
|
||||
"""Scenario drafts are private to their instructor and that instructor's class."""
|
||||
if who.role is Role.ADMIN:
|
||||
return set()
|
||||
owner_login = who.login
|
||||
if who.role is Role.TRAINEE:
|
||||
if db is None or who.trainee_id is None:
|
||||
owner_login = ""
|
||||
else:
|
||||
owner_login = await db.scalar(
|
||||
select(Group.owner_login)
|
||||
.join(Trainee, Trainee.group_id == Group.id)
|
||||
.where(Trainee.id == who.trainee_id)
|
||||
) or ""
|
||||
return await store.scenario_ids_owned_by_other(db, owner_login)
|
||||
|
||||
|
||||
async def scenario_session() -> AsyncIterator[AsyncSession | None]:
|
||||
|
|
@ -77,6 +98,25 @@ def _draft_out(row) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _draft_grammar_hash(scenario) -> str:
|
||||
"""Stable fingerprint of the caller dialogue fields covered by grammar QA."""
|
||||
payload = {
|
||||
"first_line": scenario.first_line,
|
||||
"facts": [
|
||||
{"id": fact.id, "value": fact.value, "refined": fact.refined}
|
||||
for fact in scenario.facts
|
||||
],
|
||||
}
|
||||
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _audit_before_commit(actor: str, role: str, action: str, detail: str = ""):
|
||||
return lambda transaction, row: add_audit_entry(
|
||||
transaction, actor, role, action, str(row.id), detail
|
||||
)
|
||||
|
||||
|
||||
@router.post("/drafts/from-template", status_code=201)
|
||||
async def create_template_draft(
|
||||
body: TemplateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
|
|
@ -85,8 +125,17 @@ async def create_template_draft(
|
|||
source = store.get(body.source_id)
|
||||
if source is None:
|
||||
raise HTTPException(status_code=404, detail="published_source_not_found")
|
||||
row = await store.create_draft(db, source=source, title=body.title, owner_login=who.login)
|
||||
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
||||
row = await store.create_draft(
|
||||
db,
|
||||
source=source,
|
||||
title=body.title,
|
||||
owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.create", f"template:{source.id}"
|
||||
),
|
||||
)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -100,13 +149,19 @@ async def create_ai_draft(
|
|||
raise HTTPException(status_code=404, detail="published_source_not_found")
|
||||
try:
|
||||
proposal = await generate(source, body.instruction.strip(), require_fact_change=False)
|
||||
row = await store.create_draft(db, source=source, proposal=proposal, owner_login=who.login)
|
||||
row = await store.create_draft(
|
||||
db, source=source, proposal=proposal, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.ai_generate", f"source:{source.id}",
|
||||
),
|
||||
)
|
||||
except LlmUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except GenerationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
|
||||
f"source:{source.id}")
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
|
||||
f"source:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -123,14 +178,19 @@ async def create_full_ai_draft(
|
|||
try:
|
||||
proposal = await generate_from_description(source, body.description.strip())
|
||||
row = await store.create_draft(
|
||||
db, source=source, full_proposal=proposal, owner_login=who.login
|
||||
db, source=source, full_proposal=proposal, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.ai_generate_full",
|
||||
f"class_source:{source.id}",
|
||||
),
|
||||
)
|
||||
except LlmUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except GenerationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
|
||||
f"class_source:{source.id}")
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
|
||||
f"class_source:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -157,10 +217,16 @@ async def patch_draft(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
row = await store.update_draft(db, row, body)
|
||||
row = await store.update_draft(
|
||||
db, row, body,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.update"
|
||||
),
|
||||
)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -178,13 +244,22 @@ async def revise_ai_draft(
|
|||
try:
|
||||
source = validate(row.body)
|
||||
proposal = await generate(source, body.comment.strip(), require_fact_change=False)
|
||||
row = await store.revise_draft(db, row, proposal)
|
||||
row = await store.revise_draft(
|
||||
db, row, proposal,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.ai_revise",
|
||||
f"instruction_chars={len(body.comment.strip())}",
|
||||
),
|
||||
)
|
||||
except LlmUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except (GenerationError, ScenarioError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
|
||||
body.comment.strip()[:500])
|
||||
# Editorial instructions can contain names, addresses, or other sensitive
|
||||
# details. Keep only non-content metadata in the durable admin audit log.
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
|
||||
f"instruction_chars={len(body.comment.strip())}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -207,6 +282,45 @@ async def validate_draft(
|
|||
}
|
||||
|
||||
|
||||
@router.post("/drafts/{scenario_id}/grammar-check")
|
||||
async def check_draft_grammar(
|
||||
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> dict:
|
||||
"""Явная языковая проверка после ручного редактирования сценария.
|
||||
|
||||
Это только диагностический результат: проверяются реплика звонящего и
|
||||
текстовые значения фактов, но содержимое не исправляется и не публикуется.
|
||||
"""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id, owner_login=who.login)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
scenario = validate(row.body)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"сначала исправьте структуру: {exc}") from exc
|
||||
|
||||
fields = [("first_line", scenario.first_line)]
|
||||
for fact in scenario.facts:
|
||||
fields.append((f"facts.{fact.id}.value", fact.value))
|
||||
if fact.refined:
|
||||
fields.append((f"facts.{fact.id}.refined", fact.refined))
|
||||
checks = []
|
||||
for field, value in fields:
|
||||
result = await assess(value)
|
||||
checks.append({
|
||||
"field": field,
|
||||
"passed": result.passed,
|
||||
"errors": list(result.errors),
|
||||
"source": result.source,
|
||||
})
|
||||
passed = all(item["passed"] for item in checks)
|
||||
row.grammar_check_hash = _draft_grammar_hash(scenario) if passed else None
|
||||
if db is not None:
|
||||
await db.commit()
|
||||
return {"valid": passed, "checks": checks}
|
||||
|
||||
|
||||
@router.post("/drafts/{scenario_id}/approve")
|
||||
async def approve_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
|
|
@ -216,10 +330,23 @@ async def approve_draft(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
scenario = await store.approve_draft(db, row)
|
||||
current = validate(row.body)
|
||||
if (row.manual_edit_pending
|
||||
and row.grammar_check_hash != _draft_grammar_hash(current)):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="после ручных правок требуется успешная проверка грамматики",
|
||||
)
|
||||
scenario = await store.approve_draft(
|
||||
db, row,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.approve"
|
||||
),
|
||||
)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
|
||||
return {"id": scenario.id, "status": "published", "title": scenario.title}
|
||||
|
||||
|
||||
|
|
@ -228,15 +355,36 @@ async def listing(
|
|||
request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> list[dict]:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
if db is not None:
|
||||
# Published student scenarios may have been approved on a peer backend.
|
||||
# Refresh this process-local catalog from the shared authoritative DB.
|
||||
await store.restore_published(db)
|
||||
owned_ids = (
|
||||
await store.owned_scenario_ids(db, who.login)
|
||||
if who is not None and who.role is Role.INSTRUCTOR
|
||||
else set()
|
||||
)
|
||||
return [
|
||||
{
|
||||
hidden_ids = await _hidden_scenario_ids(db, who)
|
||||
result = []
|
||||
for scenario in store.all_scenarios():
|
||||
if scenario.id in hidden_ids:
|
||||
continue
|
||||
if who.role is Role.TRAINEE:
|
||||
# A trainee may select a scenario for self-practice, but the catalog
|
||||
# must not reveal dispatch codes, answer hints, or instructor-only metadata.
|
||||
if "self" not in scenario.modes:
|
||||
continue
|
||||
result.append({
|
||||
"id": scenario.id,
|
||||
"title": scenario.title,
|
||||
"level": scenario.level.value,
|
||||
"modes": scenario.modes,
|
||||
})
|
||||
continue
|
||||
result.append({
|
||||
"id": scenario.id,
|
||||
"title": scenario.title,
|
||||
"outcome": scenario.outcome.value,
|
||||
"type": scenario.type.value,
|
||||
"level": scenario.level.value,
|
||||
"topics": scenario.topics,
|
||||
|
|
@ -253,9 +401,9 @@ async def listing(
|
|||
if scenario.ground_truth.incident_code
|
||||
and ekp.incident(scenario.ground_truth.incident_code) else None),
|
||||
"can_manage": scenario.id in owned_ids,
|
||||
}
|
||||
for scenario in store.all_scenarios()
|
||||
]
|
||||
"source": "trainee" if "student-created" in scenario.topics else "system",
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}")
|
||||
|
|
@ -267,10 +415,16 @@ async def archive_scenario(
|
|||
who = require(request, Role.INSTRUCTOR)
|
||||
if hub.has_active_scenario(scenario_id):
|
||||
raise HTTPException(status_code=409, detail="scenario_is_used_by_active_session")
|
||||
scenario = await store.archive(db, scenario_id, owner_login=who.login)
|
||||
scenario = await store.archive(
|
||||
db, scenario_id, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.archive"
|
||||
),
|
||||
)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
|
||||
return {"id": scenario_id, "status": "archived", "title": scenario.title}
|
||||
|
||||
|
||||
|
|
@ -280,23 +434,42 @@ async def restore_scenario(
|
|||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
scenario = await store.restore_archived(db, scenario_id, owner_login=who.login)
|
||||
scenario = await store.restore_archived(
|
||||
db, scenario_id, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.restore"
|
||||
),
|
||||
)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="archived_scenario_not_found")
|
||||
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
|
||||
return {"id": scenario_id, "status": "published", "title": scenario.title}
|
||||
|
||||
|
||||
@router.get("/{scenario_id}")
|
||||
async def read(scenario_id: str, request: Request) -> dict:
|
||||
async def read(
|
||||
scenario_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> dict:
|
||||
# Training content is local but not public: anonymous clients must not be
|
||||
# able to enumerate cards or inspect even the trainee-safe scenario body.
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
if scenario_id in await _hidden_scenario_ids(db, who):
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
if db is not None:
|
||||
await store.restore_published(db)
|
||||
scenario = store.get(scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
payload = scenario.model_dump(mode="json")
|
||||
for key in HIDDEN_FROM_TRAINEE:
|
||||
payload.pop(key, None)
|
||||
payload["required_fields"] = scenario.required_fields
|
||||
return payload
|
||||
if who.role is Role.TRAINEE:
|
||||
if "self" not in scenario.modes:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
return {
|
||||
"id": scenario.id,
|
||||
"title": scenario.title,
|
||||
"level": scenario.level.value,
|
||||
"modes": scenario.modes,
|
||||
}
|
||||
return scenario.model_dump(mode="json")
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
задним числом не надо (docs/arch/CONTRACT.md#http-api).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
|
|
@ -13,20 +16,32 @@ from pydantic import BaseModel, Field, field_validator
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
from app.api.auth import add_audit_entry, audit, audit_required, require
|
||||
from app.config import get_settings
|
||||
from app.db import repo
|
||||
from app.db.base import get_session
|
||||
from app.db.models import AuditLog, Score
|
||||
from app.db.models import AuditLog, Group, Score, Session, Trainee
|
||||
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||
from app.scenarios import store
|
||||
from app.scoring.report import build as build_report
|
||||
from app.scoring.export import to_csv, to_pdf
|
||||
from app.domain.roles import Role
|
||||
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
from app.scoring.export import to_csv, to_pdf
|
||||
from app.scoring.report import build as build_report
|
||||
from app.session.checkpoint import load_state
|
||||
from app.session.hub import hub
|
||||
from app.voice.recording import recording_path
|
||||
|
||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def optional_session() -> AsyncIterator[AsyncSession | None]:
|
||||
if get_settings().demo_no_db:
|
||||
yield None
|
||||
else:
|
||||
async for db in get_session():
|
||||
yield db
|
||||
|
||||
|
||||
class SessionCreate(BaseModel):
|
||||
|
|
@ -48,6 +63,43 @@ class SessionOut(BaseModel):
|
|||
end_reason: str | None = None
|
||||
|
||||
|
||||
class DdsHistoryOut(BaseModel):
|
||||
"""Одна завершённая карточка из отчёта занятия; только в границах владельца."""
|
||||
|
||||
session_id: UUID
|
||||
ended_at: datetime
|
||||
card_id: UUID
|
||||
scenario_id: str
|
||||
score_auto: float
|
||||
score_final: float
|
||||
reply_text: str = ""
|
||||
title: str | None = None
|
||||
address: str | None = None
|
||||
description: str | None = None
|
||||
incident_type: str | None = None
|
||||
victims_count: int | None = None
|
||||
received_at: datetime | None = None
|
||||
managed_service: str | None = None
|
||||
recipient_services: list[str] = []
|
||||
|
||||
|
||||
class ActiveSessionOut(BaseModel):
|
||||
session_id: UUID
|
||||
trainee_name: str | None
|
||||
scenario_id: str
|
||||
scenario_title: str
|
||||
mode: SessionMode
|
||||
exercise: Exercise
|
||||
started_at: datetime | None
|
||||
elapsed_seconds: int
|
||||
dds_card_total: int
|
||||
dds_open_cards: int
|
||||
dds_overdue_cards: int
|
||||
dds_work_overdue_cards: int
|
||||
dds_statuses: dict[str, str]
|
||||
dds_snapshot: StationSnapshot | None = None
|
||||
|
||||
|
||||
def _out(session) -> SessionOut:
|
||||
return SessionOut(
|
||||
session_id=session.id,
|
||||
|
|
@ -62,14 +114,204 @@ def _out(session) -> SessionOut:
|
|||
)
|
||||
|
||||
|
||||
@router.get("/dds-history", response_model=list[DdsHistoryOut])
|
||||
async def dds_history(
|
||||
request: Request,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
db: AsyncSession | None = Depends(optional_session),
|
||||
) -> list[DdsHistoryOut]:
|
||||
"""Durable completed-card registry, limited to the current trainee/instructor."""
|
||||
who = require(request, Role.TRAINEE, Role.INSTRUCTOR)
|
||||
if db is None:
|
||||
await audit_required(
|
||||
who.login, who.role.value, "dds.history.read", detail="cards=0"
|
||||
)
|
||||
return []
|
||||
statement = (
|
||||
select(Session, Score)
|
||||
.join(Score, Score.session_id == Session.id)
|
||||
.where(Session.ended_at.is_not(None))
|
||||
.order_by(Session.ended_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if who.role is Role.TRAINEE:
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
statement = statement.where(Session.trainee_id == who.trainee_id)
|
||||
else:
|
||||
statement = statement.where(Session.owner_login == who.login)
|
||||
|
||||
rows = (await db.execute(statement)).all()
|
||||
result: list[DdsHistoryOut] = []
|
||||
for session, score in rows:
|
||||
report = score.report or {}
|
||||
full_report = report.get("full_report") or report
|
||||
if full_report.get("exercise") != Exercise.DDS.value:
|
||||
continue
|
||||
for card in full_report.get("card_results", []):
|
||||
try:
|
||||
result.append(DdsHistoryOut(
|
||||
session_id=session.id,
|
||||
ended_at=session.ended_at,
|
||||
card_id=card["card_id"],
|
||||
scenario_id=card["scenario_id"],
|
||||
score_auto=card["score_auto"],
|
||||
score_final=score.score_final,
|
||||
reply_text=card.get("reply_text", ""),
|
||||
title=card.get("title"),
|
||||
address=card.get("address"),
|
||||
description=card.get("description"),
|
||||
incident_type=card.get("incident_type"),
|
||||
victims_count=card.get("victims_count"),
|
||||
received_at=card.get("received_at"),
|
||||
managed_service=card.get("managed_service"),
|
||||
recipient_services=card.get("recipient_services", []),
|
||||
))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
log.warning("Пропущена некорректная карточка ДДС в отчёте сессии %s", session.id)
|
||||
if len(result) >= limit:
|
||||
await audit_required(
|
||||
who.login, who.role.value, "dds.history.read",
|
||||
detail=f"cards={len(result)}",
|
||||
)
|
||||
return result
|
||||
await audit_required(
|
||||
who.login, who.role.value, "dds.history.read", detail=f"cards={len(result)}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/active", response_model=list[ActiveSessionOut])
|
||||
async def active(
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(optional_session),
|
||||
) -> list[ActiveSessionOut]:
|
||||
"""Компактный live-реестр сессий преподавателя; детали остаются в /ws/observe."""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
now = datetime.now(UTC)
|
||||
result: list[ActiveSessionOut] = []
|
||||
states = {
|
||||
state.session_id: state
|
||||
for state in hub.active_sessions(who.login)
|
||||
}
|
||||
if db is not None:
|
||||
rows = (
|
||||
await db.scalars(
|
||||
select(Session).where(
|
||||
Session.owner_login == who.login,
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
for row in rows:
|
||||
local = hub.get(row.id)
|
||||
if local is not None:
|
||||
if local.owner_login == who.login and not local.ended:
|
||||
states[row.id] = local
|
||||
else:
|
||||
states.pop(row.id, None)
|
||||
continue
|
||||
try:
|
||||
state = load_state(row.live_state, row.checkpoint_at)
|
||||
except Exception as exc: # noqa: BLE001 — один плохой checkpoint не ломает весь реестр
|
||||
log.error("Не удалось прочитать checkpoint сессии %s (%s)",
|
||||
row.id, type(exc).__name__)
|
||||
continue
|
||||
state.owner_login = row.owner_login
|
||||
if not state.ended:
|
||||
states[state.session_id] = state
|
||||
|
||||
for state in states.values():
|
||||
elapsed = (max(0, int((now - state.started_at).total_seconds()))
|
||||
if state.started_at else 0)
|
||||
station = state.station_snapshot() if state.exercise is Exercise.DDS else None
|
||||
queue = station.queue_cards if station else []
|
||||
managed_services = state.managed_services()
|
||||
latest_statuses = {
|
||||
service: SERVICE_STATUS_LABELS[current(state.status_log, service)]
|
||||
for service in managed_services
|
||||
if (state.status_log or state.exercise is Exercise.DDS)
|
||||
}
|
||||
result.append(ActiveSessionOut(
|
||||
session_id=state.session_id,
|
||||
trainee_name=state.trainee_name,
|
||||
scenario_id=state.scenario_id,
|
||||
scenario_title=state.scenario_title,
|
||||
mode=state.mode,
|
||||
exercise=state.exercise,
|
||||
started_at=state.started_at,
|
||||
elapsed_seconds=elapsed,
|
||||
dds_card_total=len(state.dds_scenarios),
|
||||
dds_open_cards=len(queue),
|
||||
dds_overdue_cards=sum(
|
||||
not card.timer_stopped and card.elapsed_ms > card.limit_ms for card in queue
|
||||
),
|
||||
dds_work_overdue_cards=sum(
|
||||
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None
|
||||
and timer.started_at is not None
|
||||
and not timer.stopped
|
||||
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
|
||||
for card in state.dds_live_cards
|
||||
),
|
||||
dds_statuses=latest_statuses,
|
||||
dds_snapshot=station,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", response_model=SessionOut, status_code=201)
|
||||
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
group_created = False
|
||||
try:
|
||||
group = await repo.ensure_group(db, body.group, owner_login=who.login) if body.group else None
|
||||
if body.group:
|
||||
group = await db.scalar(select(Group).where(Group.name == body.group))
|
||||
group_created = group is None
|
||||
group = await repo.ensure_group(
|
||||
db, body.group, owner_login=who.login, commit=False
|
||||
)
|
||||
else:
|
||||
group = None
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=404, detail="group_not_found") from exc
|
||||
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
|
||||
trainee_created = False
|
||||
if body.trainee:
|
||||
trainee = await db.scalar(select(Trainee).where(Trainee.name == body.trainee))
|
||||
trainee_created = trainee is None
|
||||
try:
|
||||
trainee = await repo.ensure_trainee(
|
||||
db, body.trainee, group, owner_login=who.login, commit=False
|
||||
)
|
||||
except PermissionError as exc:
|
||||
# A group created earlier in this same request must not be left
|
||||
# behind when the selected learner is outside this instructor's scope.
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found") from exc
|
||||
else:
|
||||
trainee = None
|
||||
|
||||
def audit_creation(transaction, row):
|
||||
if group_created and group is not None:
|
||||
add_audit_entry(
|
||||
transaction, who.login, who.role.value,
|
||||
"group.create", str(group.id), group.name,
|
||||
)
|
||||
if trainee_created and trainee is not None:
|
||||
add_audit_entry(
|
||||
transaction, who.login, who.role.value,
|
||||
"trainee.profile.create", str(trainee.id),
|
||||
)
|
||||
add_audit_entry(
|
||||
transaction,
|
||||
who.login,
|
||||
who.role.value,
|
||||
"session.create",
|
||||
str(row.id),
|
||||
f"scenario={row.scenario_id}; mode={row.mode}; attempt={row.attempt}",
|
||||
)
|
||||
|
||||
session = await repo.create_session(
|
||||
db,
|
||||
scenario_id=body.scenario_id,
|
||||
|
|
@ -77,13 +319,8 @@ async def create(body: SessionCreate, request: Request, db: AsyncSession = Depen
|
|||
trainee_id=trainee.id if trainee else None,
|
||||
group_id=group.id if group else None,
|
||||
owner_login=who.login,
|
||||
)
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
"session.create",
|
||||
str(session.id),
|
||||
f"scenario={session.scenario_id}; mode={session.mode}; attempt={session.attempt}",
|
||||
backend_node_id=get_settings().backend_node_id,
|
||||
before_commit=audit_creation,
|
||||
)
|
||||
return _out(session)
|
||||
|
||||
|
|
@ -160,7 +397,7 @@ def _live(session_id: UUID):
|
|||
|
||||
|
||||
async def _report_data(
|
||||
session_id: UUID, request: Request, db: AsyncSession,
|
||||
session_id: UUID, request: Request, db: AsyncSession | None,
|
||||
) -> SessionReport:
|
||||
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
|
||||
|
||||
|
|
@ -185,8 +422,21 @@ async def _report_data(
|
|||
raise HTTPException(status_code=409, detail="self_assessment_required")
|
||||
if state.score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
if hub.journal is not None and isinstance(db, AsyncSession):
|
||||
persisted_session = await db.scalar(
|
||||
select(Session.id).where(Session.id == session_id)
|
||||
)
|
||||
if (persisted_session is not None and await db.scalar(
|
||||
select(Score.session_id).where(Score.session_id == session_id)
|
||||
) is None):
|
||||
# Live state is populated just before the journal transaction commits.
|
||||
# Do not expose a report that looks ready but cannot yet be corrected
|
||||
# or retrieved after restart.
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
return build_report(session_id, state, scenario)
|
||||
|
||||
if db is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
session = await repo.get_session(db, session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
|
|
@ -214,26 +464,32 @@ async def _report_data(
|
|||
|
||||
@router.get("/{session_id}/report", response_model=SessionReport)
|
||||
async def report(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
||||
) -> SessionReport:
|
||||
return await _report_data(session_id, request, db)
|
||||
data = await _report_data(session_id, request, db)
|
||||
who = require(request)
|
||||
await audit_required(who.login, who.role.value, "report.read", str(session_id))
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/{session_id}/report.csv")
|
||||
async def report_csv(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
||||
) -> Response:
|
||||
"""Те же права и готовность оценки, что у JSON-разбора."""
|
||||
data = await _report_data(session_id, request, db)
|
||||
content = to_csv(data)
|
||||
who = require(request)
|
||||
await audit_required(who.login, who.role.value, "report.export.csv", str(session_id))
|
||||
return Response(
|
||||
content=to_csv(data), media_type="text/csv; charset=utf-8",
|
||||
content=content, media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{session_id}/report.pdf")
|
||||
async def report_pdf(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
||||
) -> Response:
|
||||
"""Печатный разбор; генерация полностью локальна."""
|
||||
data = await _report_data(session_id, request, db)
|
||||
|
|
@ -241,6 +497,8 @@ async def report_pdf(
|
|||
content = to_pdf(data)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
who = require(request)
|
||||
await audit_required(who.login, who.role.value, "report.export.pdf", str(session_id))
|
||||
return Response(
|
||||
content=content, media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.pdf"'},
|
||||
|
|
@ -274,6 +532,7 @@ async def recording(session_id: UUID, request: Request, db: AsyncSession = Depen
|
|||
path = recording_path(session_id)
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="recording_not_found")
|
||||
await audit_required(who.login, who.role.value, "recording.read", str(session_id))
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="audio/wav",
|
||||
|
|
@ -331,7 +590,11 @@ async def override(
|
|||
role=who.role.value,
|
||||
action="score.override",
|
||||
object_id=str(session_id),
|
||||
detail=f"{score.score_auto} → {body.score_final}: {body.comment}"[:2000],
|
||||
# The actual reason remains attached to the instructor-facing score
|
||||
# report. The durable security audit needs the change and actor, not
|
||||
# a second indefinite copy of free-text that may contain personal data.
|
||||
detail=(f"{score.score_auto} → {body.score_final}; "
|
||||
f"comment_chars={len(body.comment)}"),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
|
@ -371,7 +634,7 @@ async def listing(
|
|||
mode: SessionMode | None = None,
|
||||
since: datetime | None = Query(default=None, alias="from"),
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
db: AsyncSession | None = Depends(optional_session),
|
||||
) -> list[SessionOut]:
|
||||
who = require(request)
|
||||
# Обучающийся видит только свою историю, что бы он ни передал в фильтре.
|
||||
|
|
@ -380,6 +643,30 @@ async def listing(
|
|||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
trainee = who.trainee_id
|
||||
owner_login = who.login if who.role is Role.INSTRUCTOR else None
|
||||
if db is None:
|
||||
# The explicit in-memory demo keeps completed session state in `hub`
|
||||
# until restart. It has no group records, so group-filtered history is
|
||||
# empty rather than silently leaking sessions outside that filter.
|
||||
if group is not None:
|
||||
return []
|
||||
states = hub.history(
|
||||
owner_login=owner_login,
|
||||
trainee_id=trainee,
|
||||
mode=mode.value if mode else None,
|
||||
since=since,
|
||||
limit=limit,
|
||||
)
|
||||
return [SessionOut(
|
||||
session_id=state.session_id,
|
||||
scenario_id=state.scenario_id,
|
||||
mode=state.mode,
|
||||
attempt=state.attempt,
|
||||
trainee_id=state.trainee_id,
|
||||
group_id=None,
|
||||
started_at=state.started_at,
|
||||
ended_at=state.ended_at,
|
||||
end_reason=state.end_reason.value if state.end_reason else None,
|
||||
) for state in states]
|
||||
rows = await repo.history(
|
||||
db,
|
||||
trainee_id=trainee,
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@ from pydantic import BaseModel
|
|||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import DEMO_TRAINEE_ID, require
|
||||
from app.api.auth import DEMO_TRAINEE_ID, audit_required, require
|
||||
from app.config import get_settings
|
||||
from app.domain.roles import Role
|
||||
from app.db.base import get_session, get_sessionmaker
|
||||
from app.db.models import Group, Score, Session, Trainee, User
|
||||
from app.domain.taxonomy import ERRORS, ErrorCode
|
||||
from app.scoring.export import certificate_pdf
|
||||
from app.scoring.group import RECOMMENDATIONS
|
||||
from app.voice.recording import recording_path
|
||||
|
||||
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
|
||||
|
|
@ -67,6 +69,9 @@ async def certificate(
|
|||
)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
await audit_required(
|
||||
who.login, who.role.value, "trainee.certificate.export.pdf", str(trainee_id)
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/pdf",
|
||||
|
|
@ -107,14 +112,38 @@ class DeltaOut(BaseModel):
|
|||
facts_got: int | None = None
|
||||
|
||||
|
||||
class RecommendationOut(BaseModel):
|
||||
code: str
|
||||
title: str
|
||||
recommendation: str
|
||||
occurrences: int
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
trainee: TraineeOut
|
||||
attempts: list[AttemptOut]
|
||||
competencies: dict[str, float]
|
||||
deltas: list[DeltaOut]
|
||||
recommendations: list[RecommendationOut]
|
||||
hints_total: int
|
||||
|
||||
|
||||
def _personal_recommendations(codes: dict[str, int]) -> list[RecommendationOut]:
|
||||
"""Следующие упражнения опираются на коды последней оценённой попытки."""
|
||||
recommendations = []
|
||||
for code, count in codes.items():
|
||||
if code not in RECOMMENDATIONS or not isinstance(count, int) or count <= 0:
|
||||
continue
|
||||
error = ERRORS[ErrorCode(code)]
|
||||
recommendations.append(RecommendationOut(
|
||||
code=code,
|
||||
title=error.title,
|
||||
recommendation=RECOMMENDATIONS[code],
|
||||
occurrences=count,
|
||||
))
|
||||
return sorted(recommendations, key=lambda item: (-item.occurrences, item.code))[:5]
|
||||
|
||||
|
||||
@router.get("", response_model=list[TraineeOut])
|
||||
async def listing(request: Request) -> list[TraineeOut]:
|
||||
"""Список курсантов — преподавателю и администратору: обучающемуся он
|
||||
|
|
@ -187,8 +216,11 @@ async def profile(
|
|||
rows = await db.execute(attempts_query)
|
||||
attempts: list[AttemptOut] = []
|
||||
competency_sums: dict[str, list[float]] = {}
|
||||
latest_scored_codes: dict[str, int] = {}
|
||||
for session, score in rows:
|
||||
summary = (score.report or {}).get("summary", {}) if score else {}
|
||||
if score is not None:
|
||||
latest_scored_codes = summary.get("codes", {})
|
||||
attempts.append(
|
||||
AttemptOut(
|
||||
session_id=session.id,
|
||||
|
|
@ -230,13 +262,18 @@ async def profile(
|
|||
)
|
||||
)
|
||||
|
||||
return ProfileOut(
|
||||
result = ProfileOut(
|
||||
trainee=TraineeOut(id=trainee.id, name=trainee.name, group=group.name if group else None),
|
||||
attempts=attempts,
|
||||
competencies=competencies,
|
||||
deltas=deltas,
|
||||
recommendations=_personal_recommendations(latest_scored_codes),
|
||||
hints_total=sum(attempt.hints or 0 for attempt in attempts),
|
||||
)
|
||||
await audit_required(
|
||||
who.login, who.role.value, "trainee.profile.read", str(trainee_id)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _diff(before, after):
|
||||
|
|
|
|||
|
|
@ -7,38 +7,46 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from uuid import UUID
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.api.auth import principal_of, websocket_origin_allowed
|
||||
from app.domain.events import (
|
||||
StationState,
|
||||
CallIncoming,
|
||||
BgStart,
|
||||
CallEnded,
|
||||
CallEndReason,
|
||||
CallIncoming,
|
||||
CallStarted,
|
||||
CallerUtterance,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
Exercise,
|
||||
HintShown,
|
||||
KioState,
|
||||
KioPatchOut,
|
||||
KioState,
|
||||
PatchSource,
|
||||
ScoreReady,
|
||||
SessionEnded,
|
||||
SessionMode,
|
||||
StationState,
|
||||
TimerTick,
|
||||
TextTurnAccepted,
|
||||
Speaker,
|
||||
TranscriptAppend,
|
||||
TraineeToServer,
|
||||
)
|
||||
from app.domain.events import BgStart
|
||||
from app.scenarios import store
|
||||
from app.session.finish import finish, refresh_archived_report, release_score
|
||||
from app.api.auth import principal_of
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.state import now_utc
|
||||
from app.domain.kio import ResponseStatus
|
||||
from app.dialog.slots import TurnResult
|
||||
from app.domain.roles import Role
|
||||
from app.scenarios import store
|
||||
from app.session.dds import prepare_handoff_queue
|
||||
from app.session.finish import finish, refresh_archived_report, release_score
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
from app.session.state import now_utc
|
||||
from app.voice.models import TTS_RATE, get_voice_models
|
||||
from app.voice.pipeline import VoiceSession
|
||||
from app.voice.recording import start_recording
|
||||
|
|
@ -53,6 +61,89 @@ FRAMES_PER_LOG = 250 # раз в пять секунд звука
|
|||
_adapter = TypeAdapter(TraineeToServer)
|
||||
|
||||
|
||||
class _TextSlotView:
|
||||
"""Grounded facts for the text exercise when the optional embedder is absent."""
|
||||
def __init__(self, state):
|
||||
self.scenario = state.scenario
|
||||
self.state = state
|
||||
|
||||
def revealed_facts(self):
|
||||
return [SimpleNamespace(id=fact.id, value=self.state.text_revealed_facts[fact.id])
|
||||
for fact in self.scenario.facts if fact.id in self.state.text_revealed_facts]
|
||||
|
||||
|
||||
def _text_turn(state, text: str):
|
||||
"""Match typed questions to approved checklist prompts; never let the model
|
||||
decide which hidden scenario fact becomes available."""
|
||||
turn = None
|
||||
if state.slots is not None:
|
||||
turn = state.slots.hear(text)
|
||||
for fact in state.slots.revealed_facts():
|
||||
state.text_revealed_facts[fact.id] = fact.value
|
||||
if turn.refined:
|
||||
return turn
|
||||
|
||||
# The lexical offline matcher misses natural follow-ups such as “а точнее,
|
||||
# ближайший дом?”. Once the caller has disclosed a fact with a refinement,
|
||||
# allow an explicit request for precision to reveal only that refined value.
|
||||
# This remains a deterministic slot rule: the model never chooses the fact.
|
||||
normalized = text.casefold().replace("ё", "е")
|
||||
asks_for_precision = bool(re.search(
|
||||
r"\b(точн\w*|конкретн\w*|ближ\w*|номер\w*|уточн\w*)\b", normalized
|
||||
))
|
||||
if asks_for_precision:
|
||||
for fact in state.scenario.facts:
|
||||
if (fact.id in state.text_revealed_facts and fact.refine_on and fact.refined):
|
||||
state.text_revealed_facts[fact.id] = fact.refined
|
||||
if state.slots is not None:
|
||||
if fact.id not in state.slots.refined:
|
||||
state.slots.refined.append(fact.id)
|
||||
if fact.id not in state.slots.revealed:
|
||||
state.slots.revealed.append(fact.id)
|
||||
if fact.refine_on not in state.slots.asked:
|
||||
state.slots.asked.append(fact.refine_on)
|
||||
return TurnResult(text=text, matched=[fact.refine_on], refined=[fact.id])
|
||||
|
||||
if turn is not None and turn.matched:
|
||||
return turn
|
||||
|
||||
words = set(re.findall(r"[а-яё]{3,}", text.casefold().replace("ё", "е")))
|
||||
stop = {"что", "как", "где", "когда", "сколько", "есть", "это", "или", "вас", "вам", "пожалуйста"}
|
||||
words -= stop
|
||||
best = None
|
||||
best_score = 0.0
|
||||
for item in state.scenario.checklist:
|
||||
if not item.question:
|
||||
continue
|
||||
for phrase in [item.question, *item.examples]:
|
||||
prompt_words = set(re.findall(r"[а-яё]{3,}", phrase.casefold().replace("ё", "е"))) - stop
|
||||
score = len(words & prompt_words) / max(1, len(prompt_words))
|
||||
if score > best_score:
|
||||
best, best_score = item, score
|
||||
turn = TurnResult(text=text)
|
||||
if best is None or best_score < 0.25:
|
||||
return turn
|
||||
turn.matched.append(best.id)
|
||||
fact_ids = [fact.id for fact in state.scenario.facts
|
||||
if fact.reveal_on and fact.reveal_on.question == best.id]
|
||||
if best.fact and best.fact not in fact_ids:
|
||||
fact_ids.append(best.fact)
|
||||
for fact in state.scenario.facts:
|
||||
if fact.refine_on == best.id and fact.refined:
|
||||
state.text_revealed_facts[fact.id] = fact.refined
|
||||
turn.refined.append(fact.id)
|
||||
for fact_id in fact_ids:
|
||||
fact = next((item for item in state.scenario.facts if item.id == fact_id), None)
|
||||
if fact is None:
|
||||
continue
|
||||
if fact_id in state.text_revealed_facts:
|
||||
turn.repeated.append(fact_id)
|
||||
else:
|
||||
state.text_revealed_facts[fact_id] = fact.value
|
||||
turn.revealed.append(fact_id)
|
||||
return turn
|
||||
|
||||
|
||||
def _on_audio(session_id: UUID, state, frame: bytes) -> None:
|
||||
"""Приём аудиокадра: в голосовой контур, а без него — только счёт."""
|
||||
if len(frame) != FRAME_BYTES:
|
||||
|
|
@ -96,8 +187,8 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
|
||||
))
|
||||
return
|
||||
if state.exercise is Exercise.DDS or (
|
||||
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit"}
|
||||
if (event.type == "text.turn" and state.exercise is not Exercise.CARD) or state.exercise is Exercise.DDS or (
|
||||
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit", "text.turn"}
|
||||
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT, message="Действие недоступно в этом упражнении",
|
||||
|
|
@ -110,13 +201,53 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
))
|
||||
return
|
||||
match event.type:
|
||||
case "text.turn":
|
||||
if state.caller is None or state.persona is None or state.scenario is None:
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.MODELS_WARMING_UP,
|
||||
message="Текстовый диалог пока не готов. Обновите занятие или заполните карточку по вводной.",
|
||||
))
|
||||
return
|
||||
turn = _text_turn(state, event.text)
|
||||
operator_entry = state.append(Speaker.OPERATOR, event.text)
|
||||
accepted = TextTurnAccepted(text=event.text, at=operator_entry.at)
|
||||
hub.to_trainee(session_id, accepted)
|
||||
hub.to_observers(session_id, TranscriptAppend(entry=operator_entry))
|
||||
if hub.journal:
|
||||
await hub.journal.utterance(session_id, operator_entry)
|
||||
try:
|
||||
slots = state.slots if state.slots is not None else _TextSlotView(state)
|
||||
line = await state.caller.reply(turn, state.persona, slots)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# The model/provider exception can contain the prompt and incident facts.
|
||||
log.error("text dialogue failed for session %s (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.",
|
||||
))
|
||||
return
|
||||
caller_entry = state.append(Speaker.CALLER, line.text, line.mood)
|
||||
hub.to_trainee(session_id, CallerUtterance(
|
||||
utterance_id=uuid4(), text=line.text,
|
||||
at=caller_entry.at, mood=line.mood, source=line.source,
|
||||
))
|
||||
hub.to_observers(session_id, TranscriptAppend(entry=caller_entry))
|
||||
if hub.journal:
|
||||
await hub.journal.utterance(session_id, caller_entry)
|
||||
case "card.submit":
|
||||
state.on_event("card.submit")
|
||||
state.kio.registered_at = state.started_at or now_utc()
|
||||
state.kio.response_status = ResponseStatus.TRANSFERRED
|
||||
state.dispatched_card = state.kio.model_copy(deep=True)
|
||||
state.dispatched_at = now_utc()
|
||||
if state.handoff_to_dds:
|
||||
state.on_event("dds.dispatch")
|
||||
prepare_handoff_queue(
|
||||
state,
|
||||
state.pending_dds_scenarios,
|
||||
arrival_interval_seconds=state.dds_arrival_interval_seconds,
|
||||
max_waiting=state.dds_max_waiting,
|
||||
)
|
||||
state.pending_dds_scenarios = []
|
||||
else:
|
||||
state.ended_at = state.dispatched_at
|
||||
state.end_reason = CallEndReason.COMPLETE
|
||||
|
|
@ -135,15 +266,17 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
)
|
||||
await finish(session_id, state)
|
||||
case "call.answer":
|
||||
state.on_event("call.answer")
|
||||
state.started_at = now_utc()
|
||||
first_answer = state.started_at is None
|
||||
if first_answer:
|
||||
state.on_event("call.answer")
|
||||
state.started_at = now_utc()
|
||||
if hub.journal:
|
||||
await hub.journal.session_started(session_id, state.started_at)
|
||||
hub.to_trainee(session_id, CallStarted(started_at=state.started_at))
|
||||
hub.to_observers(session_id, state.snapshot())
|
||||
if hub.journal:
|
||||
await hub.journal.session_started(session_id, state.started_at)
|
||||
if state.recorder is None:
|
||||
state.recorder = start_recording(session_id)
|
||||
_start_voice(session_id, state)
|
||||
_start_voice(session_id, state, initial_statement=first_answer)
|
||||
|
||||
case "kio.patch":
|
||||
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
|
||||
|
|
@ -219,12 +352,16 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
state.on_event("callback.dial")
|
||||
|
||||
case "self_assessment.submit":
|
||||
if hub.journal and not await hub.journal.self_assessment(
|
||||
session_id, event.missed, event.comment, now_utc()
|
||||
):
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось сохранить самооценку и аудит; итог пока не выдан.",
|
||||
))
|
||||
return
|
||||
state.self_assessed = True
|
||||
state.self_assessment = {"missed": event.missed, "comment": event.comment}
|
||||
if hub.journal:
|
||||
await hub.journal.self_assessment(
|
||||
session_id, event.missed, event.comment, now_utc()
|
||||
)
|
||||
await refresh_archived_report(session_id, state)
|
||||
# Оценка могла быть готова раньше самооценки — теперь её можно отдать.
|
||||
await release_score(session_id, state)
|
||||
|
|
@ -246,7 +383,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
await hub.checkpoint(session_id)
|
||||
|
||||
|
||||
def _start_voice(session_id: UUID, state) -> None:
|
||||
def _start_voice(session_id: UUID, state, *, initial_statement: bool = True) -> None:
|
||||
"""Голос включается, когда курсант снял трубку: звонящий сразу кричит первую реплику."""
|
||||
models = get_voice_models()
|
||||
scenario = store.get(state.scenario_id)
|
||||
|
|
@ -269,7 +406,8 @@ def _start_voice(session_id: UUID, state) -> None:
|
|||
if scenario.background:
|
||||
event = BgStart(loop=scenario.background.loop, gain_db=scenario.background.gain_db)
|
||||
hub.broadcast(session_id, event)
|
||||
state.voice.speak(scenario.first_line, state.persona.mood)
|
||||
if initial_statement:
|
||||
state.voice.speak(scenario.first_line, state.persona.mood)
|
||||
|
||||
|
||||
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
||||
|
|
@ -280,6 +418,10 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
|||
await ws.send_bytes(item)
|
||||
else:
|
||||
await ws.send_text(item.model_dump_json())
|
||||
if (isinstance(item, ErrorEvent) and item.code is ErrorKind.INTERNAL
|
||||
and item.message == LEASE_FENCED_MESSAGE):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
|
||||
|
||||
async def _reject(ws: WebSocket, message: str) -> None:
|
||||
|
|
@ -292,6 +434,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
|
|||
|
||||
@router.websocket("/ws/call/{session_id}")
|
||||
async def call(ws: WebSocket, session_id: UUID) -> None:
|
||||
if not websocket_origin_allowed(ws):
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
# АРМ курсанта. Преподаватель допущен, чтобы показать приём вызова группе.
|
||||
|
|
@ -309,6 +457,14 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
)
|
||||
await ws.close()
|
||||
return
|
||||
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
|
||||
await ws.send_text(
|
||||
ErrorEvent(
|
||||
code=ErrorKind.SESSION_NOT_FOUND, message="Занятие ещё не запущено преподавателем"
|
||||
).model_dump_json()
|
||||
)
|
||||
await ws.close()
|
||||
return
|
||||
if who.role is Role.TRAINEE and (
|
||||
state.trainee_id is None or state.trainee_id != who.trainee_id
|
||||
):
|
||||
|
|
@ -343,6 +499,12 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
if state.score is not None and state.self_assessed:
|
||||
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
||||
hub.to_trainee(session_id, TimerTick(timers=state.timers.snapshot()))
|
||||
if state.started_at is not None and not state.ended and state.voice is None:
|
||||
# Rebuild non-serializable audio services after backend recovery;
|
||||
# the audio journal rehydrates the existing recording timeline.
|
||||
if state.recorder is None:
|
||||
state.recorder = start_recording(session_id)
|
||||
_start_voice(session_id, state, initial_statement=False)
|
||||
writer = asyncio.create_task(_pump(ws, queue))
|
||||
try:
|
||||
while True:
|
||||
|
|
@ -376,7 +538,8 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
|
||||
)
|
||||
continue
|
||||
await _handle(session_id, state, event)
|
||||
async with hub.durable_transition(session_id):
|
||||
await _handle(session_id, state, event)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -11,18 +11,23 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import secrets
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.api.auth import audit, principal_of
|
||||
from app.api.auth import audit, principal_of, websocket_origin_allowed
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.db.repo import SessionNodeConflict
|
||||
from app.dialog.director import apply as apply_directive
|
||||
from app.dialog.director import mood_of
|
||||
from app.dialog.factory import build_caller
|
||||
from app.dialog.persona import PersonaState
|
||||
from app.dialog.runtime import get_embedder
|
||||
from app.dialog.slots import SlotMachine
|
||||
from app.domain.classifiers import Outcome
|
||||
from app.domain.events import (
|
||||
CallEnded,
|
||||
CallEndReason,
|
||||
|
|
@ -43,7 +48,7 @@ from app.domain.roles import Role
|
|||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
from app.session.dds import prepare_queue
|
||||
from app.session.hub import hub
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.voice.models import get_voice_models
|
||||
from app.voice.pipeline import FILLERS, prefetch
|
||||
|
|
@ -54,21 +59,16 @@ router = APIRouter()
|
|||
_adapter = TypeAdapter(InstructorToServer)
|
||||
|
||||
|
||||
def card_briefing(state: SessionState) -> CardBriefing:
|
||||
"""Учебная текстовая вводная — исходные реплики, а не эталон карточки.
|
||||
def _dds_ineligible_scenarios(scenarios):
|
||||
"""Консультация и передача региона не являются готовыми карточками ДДС."""
|
||||
return [scenario for scenario in scenarios if scenario.outcome is not Outcome.CARD]
|
||||
|
||||
В отсутствие диалога факты раскрываются сразу. Если факт уточняется,
|
||||
показываем и уточнение: иначе правильно заполнить карточку невозможно.
|
||||
"""
|
||||
|
||||
def card_briefing(state: SessionState) -> CardBriefing:
|
||||
"""Первую реплику показывает курсант; факты раскрываются только в ответах."""
|
||||
scenario = state.scenario
|
||||
lines = ["Учебная текстовая вводная: сведения заявителя приведены ниже.",
|
||||
scenario.first_line]
|
||||
for fact in scenario.facts:
|
||||
lines.append(f"• {fact.value}")
|
||||
if fact.refined:
|
||||
lines.append(f" Уточнено: {fact.refined}")
|
||||
return CardBriefing(
|
||||
scenario_id=scenario.id, mode=state.mode, text="\n".join(lines),
|
||||
scenario_id=scenario.id, mode=state.mode, text=scenario.first_line,
|
||||
required_fields=([field for field in state.required_fields if field != "dds"]
|
||||
if scenario.ground_truth.incident_code else list(state.required_fields)),
|
||||
card=state.kio,
|
||||
|
|
@ -83,40 +83,151 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
message="Передача в ДДС доступна только для текстовой карточки 112",
|
||||
))
|
||||
return
|
||||
scenario = store.get(event.scenario_id)
|
||||
scenario_ids = list(dict.fromkeys([
|
||||
event.scenario_id, *(event.scenario_ids or []), *(event.random_scenario_ids or []),
|
||||
]))
|
||||
if get_settings().demo_no_db or hub.journal is None:
|
||||
catalog = {
|
||||
scenario_id: scenario for scenario_id in scenario_ids
|
||||
if (scenario := store.get(scenario_id)) is not None
|
||||
}
|
||||
hidden_scenario_ids = (
|
||||
await store.scenario_ids_owned_by_other(None, who.login)
|
||||
if who is not None else set()
|
||||
)
|
||||
else:
|
||||
try:
|
||||
async with get_sessionmaker()() as db:
|
||||
catalog, hidden_scenario_ids = await store.published_catalog(
|
||||
db, scenario_ids, who.login if who is not None else None,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Avoid serializing scenario facts or SQL bind values into application logs.
|
||||
log.error("не удалось разрешить сценарий из общей библиотеки (%s)", type(exc).__name__)
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось проверить сценарий в общей библиотеке; запуск отменён.",
|
||||
))
|
||||
return
|
||||
scenario = catalog.get(event.scenario_id)
|
||||
if scenario is None:
|
||||
hub.to_observers(
|
||||
session_id,
|
||||
ErrorEvent(code=ErrorKind.SCENARIO_INVALID, message=f"Нет сценария {event.scenario_id}"),
|
||||
)
|
||||
return
|
||||
if scenario.id in hidden_scenario_ids:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Сценарий не найден или недоступен этому преподавателю",
|
||||
))
|
||||
return
|
||||
|
||||
if event.random_scenario_ids:
|
||||
if len(event.random_scenario_ids) > 96:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Случайный отбор ограничен 96 карточками",
|
||||
))
|
||||
return
|
||||
pool_ids = list(dict.fromkeys(event.random_scenario_ids))
|
||||
pool = [catalog.get(scenario_id) for scenario_id in pool_ids]
|
||||
if any(item is None for item in pool):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="В случайном отборе есть неизвестный сценарий",
|
||||
))
|
||||
return
|
||||
if hidden_scenario_ids.intersection(pool_ids):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Сценарий не найден или недоступен этому преподавателю",
|
||||
))
|
||||
return
|
||||
ineligible_pool = _dds_ineligible_scenarios(pool)
|
||||
if ineligible_pool:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Случайный отбор должен содержать только готовые карточки ДДС",
|
||||
))
|
||||
return
|
||||
scenario = secrets.choice(pool)
|
||||
|
||||
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) > 96:
|
||||
if event.exercise is Exercise.DDS or event.handoff_to_dds:
|
||||
if (not scenario_ids or scenario_ids[0] != event.scenario_id
|
||||
or len(scenario_ids) > 96):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 96 карточек",
|
||||
))
|
||||
return
|
||||
scenarios = [store.get(scenario_id) for scenario_id in scenario_ids]
|
||||
if event.random_scenario_ids:
|
||||
extra_ids = list(dict.fromkeys(
|
||||
item for item in scenario_ids[1:] if item != scenario.id
|
||||
))
|
||||
extras = [catalog.get(item) for item in extra_ids]
|
||||
remaining_random = [item for item in pool if item.id != scenario.id
|
||||
and item.id not in extra_ids]
|
||||
randomized_tail = secrets.SystemRandom().sample(
|
||||
remaining_random, k=len(remaining_random)
|
||||
)
|
||||
scenarios = [scenario, *extras, *randomized_tail]
|
||||
else:
|
||||
scenarios = [catalog.get(scenario_id) for scenario_id in scenario_ids]
|
||||
if len(scenarios) > 96:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Очередь ДДС не может содержать более 96 карточек",
|
||||
))
|
||||
return
|
||||
if any(item is None for item in scenarios):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID, message="В очереди ДДС есть неизвестный сценарий",
|
||||
))
|
||||
return
|
||||
if any(item.id in hidden_scenario_ids for item in scenarios):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Сценарий не найден или недоступен этому преподавателю",
|
||||
))
|
||||
return
|
||||
ineligible = _dds_ineligible_scenarios(scenarios)
|
||||
if ineligible:
|
||||
titles = ", ".join(item.title for item in ineligible)
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message=("В очередь ДДС можно добавить только готовые карточки с исходом "
|
||||
f"«карточка и передача в ДДС». Исключите: {titles}"),
|
||||
))
|
||||
return
|
||||
else:
|
||||
scenarios = []
|
||||
|
||||
attempt = 1
|
||||
recorded_trainee_id = event.trainee_id
|
||||
recorded_service = None
|
||||
fencing_epoch = 0
|
||||
if hub.journal:
|
||||
try:
|
||||
attempt, recorded_trainee_id, recorded_service = await hub.journal.start_lesson(
|
||||
persisted = await hub.journal.start_lesson(
|
||||
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id,
|
||||
owner_login=who.login if who is not None else None,
|
||||
backend_node_id=get_settings().backend_node_id,
|
||||
)
|
||||
if persisted is None:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось записать занятие и аудит; запуск отменён.",
|
||||
))
|
||||
return
|
||||
attempt, recorded_trainee_id, recorded_service, fencing_epoch = persisted
|
||||
except SessionNodeConflict:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Сессия закреплена за другим backend-узлом; проверьте маршрутизацию proxy",
|
||||
))
|
||||
return
|
||||
except PermissionError:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
|
|
@ -134,6 +245,7 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
level=scenario.level.value,
|
||||
mode=event.mode,
|
||||
owner_login=who.login if who is not None else None,
|
||||
backend_fencing_epoch=fencing_epoch,
|
||||
exercise=event.exercise,
|
||||
handoff_to_dds=event.handoff_to_dds,
|
||||
scenario=scenario.model_copy(deep=True),
|
||||
|
|
@ -145,6 +257,8 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
criteria=event.criteria,
|
||||
)
|
||||
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
|
||||
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000
|
||||
state.timers.limits[TimerCode.DDS_WORK] = event.criteria.dds_card_work_time_limit_seconds * 1000
|
||||
if event.exercise is Exercise.CALL:
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
|
|
@ -165,6 +279,19 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
state.started_at = state.dispatched_at
|
||||
else:
|
||||
state.started_at = now_utc()
|
||||
if event.exercise is Exercise.CARD:
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
state.slots = SlotMachine(state.scenario, embedder)
|
||||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(
|
||||
scenario.id, use_pregenerated=scenario.tree.pregenerated,
|
||||
)
|
||||
state.on_event("card.start")
|
||||
if event.handoff_to_dds:
|
||||
state.pending_dds_scenarios = [item.model_copy(deep=True) for item in scenarios[1:]]
|
||||
state.dds_arrival_interval_seconds = event.dds_arrival_interval_seconds
|
||||
state.dds_max_waiting = event.dds_max_waiting
|
||||
hub.register(state)
|
||||
if event.exercise is not Exercise.CALL and hub.journal and state.started_at is not None:
|
||||
await hub.journal.session_started(session_id, state.started_at)
|
||||
|
|
@ -178,19 +305,6 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
state.on_event("call.incoming")
|
||||
await hub.checkpoint(session_id)
|
||||
hub.start_ticker(session_id)
|
||||
if who is not None:
|
||||
# Запуск занятия меняет чужой результат — значит попадает в журнал
|
||||
# аудита (ТЗ, хранение не менее шести месяцев).
|
||||
# Сохраняем до продолжения сценария, чтобы завершение процесса не
|
||||
# потеряло событие. ФИО курсанта в долгоживущий журнал не дублируем.
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
"lesson.start",
|
||||
str(session_id),
|
||||
f"{scenario.id}, режим {event.mode.value}",
|
||||
)
|
||||
|
||||
if event.exercise is Exercise.CALL:
|
||||
hub.to_trainee(
|
||||
session_id,
|
||||
|
|
@ -217,6 +331,8 @@ async def _stop(session_id: UUID) -> None:
|
|||
if state is None or state.ended:
|
||||
return
|
||||
state.ended_at = now_utc()
|
||||
if state.exercise is Exercise.CARD and state.dispatched_card is None:
|
||||
state.on_event("card.end")
|
||||
state.end_reason = CallEndReason.INSTRUCTOR
|
||||
if state.voice is not None:
|
||||
await state.voice.close()
|
||||
|
|
@ -248,6 +364,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
|
|||
|
||||
@router.websocket("/ws/control/{session_id}")
|
||||
async def control(ws: WebSocket, session_id: UUID) -> None:
|
||||
if not websocket_origin_allowed(ws):
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
# Пульт преподавателя: управление занятием доступно только ему.
|
||||
|
|
@ -255,9 +377,19 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
if who is None or who.role not in (Role.INSTRUCTOR,):
|
||||
await _reject(ws, "Недостаточно прав для этого экрана")
|
||||
return
|
||||
event_stream = hub.begin_event_stream(session_id)
|
||||
try:
|
||||
while True:
|
||||
payload = await ws.receive_json()
|
||||
try:
|
||||
payload = await asyncio.wait_for(ws.receive_json(), timeout=1)
|
||||
except TimeoutError:
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.send_text(ErrorEvent(
|
||||
code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE
|
||||
).model_dump_json())
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
continue
|
||||
try:
|
||||
event = _adapter.validate_python(payload)
|
||||
except ValidationError:
|
||||
|
|
@ -331,6 +463,23 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
message="Оценка должна быть числом от 0 до 100",
|
||||
))
|
||||
continue
|
||||
if hub.journal is not None:
|
||||
saved = await hub.journal.score_override(
|
||||
session_id, verdict, who.login, event.comment
|
||||
)
|
||||
if not saved:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось сохранить оценку и запись аудита; изменение отменено",
|
||||
))
|
||||
continue
|
||||
else:
|
||||
# Explicit in-memory demo mode has no Score table.
|
||||
await audit(
|
||||
who.login, who.role.value, "score.override", str(session_id),
|
||||
f"{state.score.get('score_auto')} → {verdict}; "
|
||||
f"comment_chars={len(event.comment)}",
|
||||
)
|
||||
# Автооценка остаётся рядом: видно, что скорректировано и кем.
|
||||
state.score = {
|
||||
**state.score,
|
||||
|
|
@ -338,14 +487,6 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
"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)
|
||||
|
|
@ -384,6 +525,20 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
message=f"{event.type} ещё не реализовано",
|
||||
),
|
||||
)
|
||||
await hub.checkpoint(session_id)
|
||||
try:
|
||||
await hub.checkpoint(session_id)
|
||||
except Exception:
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.send_text(ErrorEvent(
|
||||
code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE
|
||||
).model_dump_json())
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
raise
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
finally:
|
||||
await hub.end_event_stream(event_stream)
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ from uuid import UUID
|
|||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.domain.events import ErrorEvent, ErrorKind
|
||||
from app.api.auth import principal_of
|
||||
from app.api.auth import principal_of, websocket_origin_allowed
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -27,6 +27,10 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
|||
while True:
|
||||
event = await queue.get()
|
||||
await ws.send_text(event.model_dump_json())
|
||||
if (isinstance(event, ErrorEvent) and event.code is ErrorKind.INTERNAL
|
||||
and event.message == LEASE_FENCED_MESSAGE):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
|
||||
|
||||
async def _wait_for_disconnect(ws: WebSocket) -> None:
|
||||
|
|
@ -48,6 +52,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
|
|||
|
||||
@router.websocket("/ws/observe/{session_id}")
|
||||
async def observe(ws: WebSocket, session_id: UUID) -> None:
|
||||
if not websocket_origin_allowed(ws):
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
# Наблюдение за чужим занятием — не для обучающегося.
|
||||
|
|
@ -64,6 +74,16 @@ async def observe(ws: WebSocket, session_id: UUID) -> None:
|
|||
await ws.close()
|
||||
return
|
||||
|
||||
# Live state is process-local, so authorize against the owner snapshot on
|
||||
# the state itself. Instructors may observe only their own sessions;
|
||||
# administrators retain the cross-owner diagnostic view.
|
||||
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
|
||||
await ws.send_text(
|
||||
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
|
||||
)
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
# Снимок при подключении обязателен: монитор в классе включают посреди
|
||||
# занятия, и он должен показать текущее состояние, а не ждать событий.
|
||||
await ws.send_text(state.snapshot().model_dump_json())
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ from uuid import UUID
|
|||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.api.auth import principal_of
|
||||
from app.api.auth import principal_of, websocket_origin_allowed
|
||||
from app.domain.events import (
|
||||
CallEndReason,
|
||||
CommandAck,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
Exercise,
|
||||
|
|
@ -40,10 +41,12 @@ from app.domain.statuses import (
|
|||
StatusError,
|
||||
current,
|
||||
)
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scoring.address import address_matches
|
||||
from app.scoring.grammar import assess
|
||||
from app.session.dds import deliver_due_cards
|
||||
from app.session.finish import finish, score_current_dds
|
||||
from app.session.hub import hub
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
from app.session.state import now_utc
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -51,6 +54,13 @@ router = APIRouter()
|
|||
|
||||
_adapter = TypeAdapter(StationToServer)
|
||||
|
||||
|
||||
def _start_dds_work_timer(state) -> None:
|
||||
"""Start the three-minute work clock once, when the card is opened."""
|
||||
timer = state.timers.timers.get(TimerCode.DDS_WORK)
|
||||
if timer is None or timer.started_at is None:
|
||||
state.on_event("dds.open")
|
||||
|
||||
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
|
||||
REQUIRED_STATUS = {
|
||||
"dispatched": ServiceStatus.ACCEPTED,
|
||||
|
|
@ -82,14 +92,7 @@ def _line(session_id: UUID, state, speaker: str, text: str) -> None:
|
|||
|
||||
def _address_matches(expected: str | None, supplied: str) -> bool:
|
||||
"""Не даём сообщить бригаде другой номер дома/другую улицу."""
|
||||
if not expected:
|
||||
return bool(supplied.strip())
|
||||
numbers = re.findall(r"\d+", expected)
|
||||
spoken_numbers = re.findall(r"\d+", supplied)
|
||||
words = re.findall(r"[а-яё]{4,}", expected.casefold())
|
||||
spoken_words = re.findall(r"[а-яё]{4,}", supplied.casefold())
|
||||
return (all(number in spoken_numbers for number in numbers)
|
||||
and any(word[:4] == spoken[:4] for word in words for spoken in spoken_words))
|
||||
return address_matches(expected, supplied)
|
||||
|
||||
|
||||
def _incident_matches(state, supplied: str) -> bool:
|
||||
|
|
@ -134,6 +137,11 @@ def _finish_phone_call(session_id: UUID, state) -> None:
|
|||
async def _finish_dds(session_id: UUID, state) -> None:
|
||||
state.ended_at = now_utc()
|
||||
state.end_reason = CallEndReason.COMPLETE
|
||||
state.capture_active_dds()
|
||||
for card in state.dds_live_cards:
|
||||
timer = card.timers.timers.get(TimerCode.DDS_WORK)
|
||||
if timer is not None and timer.started_at is not None:
|
||||
card.timers.on_event("dds.finish")
|
||||
hub.stop_ticker(session_id)
|
||||
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
|
|
@ -152,18 +160,27 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
# Подтверждение приёма — это статус «Принята» у главной службы.
|
||||
# Кнопка осталась ради живой цепочки 112 → ДДС (lct-20), где
|
||||
# диспетчер один и выбирать службу не из чего.
|
||||
if not event.comment.strip():
|
||||
_error(session_id, "Для подтверждения приёма добавьте комментарий с основанием")
|
||||
return
|
||||
if any(action == "card.ack" for action, _at, _detail in state.dds_log):
|
||||
return
|
||||
state.on_event("card.ack")
|
||||
state.dds_log.append(("card.ack", now_utc(), None))
|
||||
services = state.managed_services()
|
||||
if services:
|
||||
_start_dds_work_timer(state)
|
||||
try:
|
||||
state.set_service_status(services[0], ServiceStatus.ACCEPTED)
|
||||
state.set_service_status(
|
||||
services[0], ServiceStatus.ACCEPTED, event.comment, author="диспетчер"
|
||||
)
|
||||
except StatusError:
|
||||
pass # статус уже стоит: повторное нажатие ничего не меняет
|
||||
case "card.status":
|
||||
if event.service not in state.managed_services():
|
||||
_error(session_id, "Можно менять статусы только своей ДДС")
|
||||
return
|
||||
_start_dds_work_timer(state)
|
||||
try:
|
||||
state.set_service_status(
|
||||
event.service, event.status, event.comment, author="диспетчер"
|
||||
|
|
@ -177,6 +194,10 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
# Первичный статус останавливает норматив 30 секунд.
|
||||
if event.status in PRIMARY:
|
||||
state.on_event("card.ack")
|
||||
if event.status in {
|
||||
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
|
||||
}:
|
||||
state.on_event("dds.complete")
|
||||
case "crew.select":
|
||||
if event.crew not in state.crew_options():
|
||||
_error(session_id, "Выберите бригаду из списка доступных")
|
||||
|
|
@ -191,6 +212,8 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
if state.phone_pending is not None:
|
||||
_error(session_id, "Завершите текущий разговор перед сменой бригады")
|
||||
return
|
||||
if state.crew_selected == event.crew and assigned == event.crew:
|
||||
return
|
||||
state.crew_selected = event.crew
|
||||
state.crew_assignments[service] = event.crew
|
||||
state.dds_log.append(("crew.select", now_utc(), event.crew))
|
||||
|
|
@ -261,13 +284,20 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
):
|
||||
_error(session_id, "Ответ относится не к текущей карточке")
|
||||
return
|
||||
# A browser may lose the acknowledgement after the server has
|
||||
# committed this replace-style value. Reconnect retries are safe:
|
||||
# don't create another journal row (or rerun grammar assessment)
|
||||
# when the current card already contains exactly this text.
|
||||
if state.reply_text == event.text:
|
||||
return
|
||||
state.reply_text = event.text
|
||||
state.reply_grammar = await assess(event.text)
|
||||
state.reply_log.append((now_utc(), event.text))
|
||||
case "card.open":
|
||||
if state.exercise is not Exercise.DDS or not state.activate_dds_card(event.card_id):
|
||||
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.activate_dds_card(event.card_id):
|
||||
_error(session_id, "Карточка отсутствует в текущей очереди")
|
||||
return
|
||||
_start_dds_work_timer(state)
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
# CardReceived carries the contents, while StationState carries
|
||||
# the status journal and current queue. Send both on every switch
|
||||
|
|
@ -275,7 +305,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
# card's status snapshot until the next periodic tick.
|
||||
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||
case "card.next":
|
||||
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
|
||||
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or (
|
||||
event.card_id != state.dispatched_card.card_id
|
||||
):
|
||||
_error(session_id, "Следующая карточка недоступна: ID текущей не совпадает")
|
||||
|
|
@ -323,8 +353,18 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
)
|
||||
return
|
||||
case "zone.decision":
|
||||
previous = next(
|
||||
(detail for action, _at, detail in reversed(state.dds_log)
|
||||
if action == "zone.decision"),
|
||||
None,
|
||||
)
|
||||
decision = "в зоне" if event.in_zone else "не в зоне"
|
||||
if previous is not None:
|
||||
if previous != decision:
|
||||
_error(session_id, "Решение по зоне уже записано для этой карточки")
|
||||
return
|
||||
state.on_event("zone.decision")
|
||||
state.dds_log.append(("zone.decision", now_utc(), "в зоне" if event.in_zone else "не в зоне"))
|
||||
state.dds_log.append(("zone.decision", now_utc(), decision))
|
||||
case "crew.dispatched":
|
||||
state.kio = state.kio.model_copy(update={"dispatch_order_at": event.at})
|
||||
state.dds_log.append(("crew.dispatched", now_utc(), None))
|
||||
|
|
@ -342,6 +382,10 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
|||
while True:
|
||||
event = await queue.get()
|
||||
await ws.send_text(event.model_dump_json())
|
||||
if (isinstance(event, ErrorEvent) and event.code is ErrorKind.INTERNAL
|
||||
and event.message == LEASE_FENCED_MESSAGE):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
|
||||
|
||||
async def _reject(ws: WebSocket, message: str) -> None:
|
||||
|
|
@ -354,6 +398,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
|
|||
|
||||
@router.websocket("/ws/station/{session_id}")
|
||||
async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
||||
if not websocket_origin_allowed(ws):
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
# За АРМ ДДС садится обучающийся, преподаватель смотрит и подменяет.
|
||||
|
|
@ -369,6 +419,12 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
|||
)
|
||||
await ws.close()
|
||||
return
|
||||
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
|
||||
await ws.send_text(
|
||||
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
|
||||
)
|
||||
await ws.close()
|
||||
return
|
||||
if who.role is Role.TRAINEE and (
|
||||
state.trainee_id is None or state.trainee_id != who.trainee_id
|
||||
):
|
||||
|
|
@ -393,8 +449,47 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
|||
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
|
||||
)
|
||||
continue
|
||||
await _handle(session_id, state, event)
|
||||
raw_command_id = payload.get("_command_id") if isinstance(payload, dict) else None
|
||||
try:
|
||||
command_id = UUID(raw_command_id) if raw_command_id is not None else None
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
hub.to_station(
|
||||
session_id,
|
||||
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Некорректный идентификатор команды"),
|
||||
)
|
||||
continue
|
||||
if command_id is not None and str(command_id) in state.processed_station_commands:
|
||||
# The checkpoint already proves this exact command committed.
|
||||
# Re-ack it without rerunning its business transition.
|
||||
hub.to_station(session_id, CommandAck(command_id=command_id))
|
||||
continue
|
||||
async with hub.durable_transition(session_id):
|
||||
await _handle(session_id, state, event)
|
||||
if command_id is not None:
|
||||
state.processed_station_commands.append(str(command_id))
|
||||
del state.processed_station_commands[:-512]
|
||||
# Commit the state+dedupe ID before acknowledging. The
|
||||
# transition context can have already flushed other
|
||||
# events; an explicit checkpoint here makes the
|
||||
# command/ACK boundary independent of that batch state.
|
||||
await hub.checkpoint(session_id)
|
||||
# The hub stages non-error events until the checkpoint
|
||||
# transaction has committed, including this ack.
|
||||
hub.to_station(session_id, CommandAck(command_id=command_id))
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except Exception: # noqa: BLE001 — failed durable transition may fence the owner
|
||||
if not state.lease_fenced:
|
||||
raise
|
||||
log.info(
|
||||
"закрытие станционного WebSocket после fencing занятия %s",
|
||||
session_id,
|
||||
)
|
||||
# `hub.checkpoint` broadcasts a structured fence event before
|
||||
# propagating the failed database write. Let the sender deliver
|
||||
# that event and close with 1012 instead of an opaque 1006.
|
||||
await asyncio.gather(sender, return_exceptions=True)
|
||||
return
|
||||
finally:
|
||||
sender.cancel()
|
||||
|
|
|
|||
Loading…
Reference in a new issue