Files
vector/backend/routers/operations.py
T
Sadmin d5a270f3af B22: прикладное логирование vector.* + администрирование в доках (по образцу РВС)
- backend/logging_setup.py: setup_logging() (stdout INFO + файл /tmp/vector/vector.log,
  ротация 5×2 МБ, VectorLOGLEVEL/VectorLOGFILE/VectorLOGDIR), get_logger('vector.*')
- события: vector.analyze (матрасчёт с длительностью), vector.auth (login_ok/failed/
  lockout), vector.audit (каждая запись), vector.contour (ok/failed/rejected),
  vector.operations (operation_complete), vector.geocode (успех/timeout/ошибка)
- существующие логгеры переведены в namespace vector.* (geo, geo.osm, rules, water, admin)
- README §13а: таблица наблюдаемых событий + правила (без ПДн/секретов)
- SETUP_GUIDE: §15 RPO/RTO + backup≠restore, §16 grep по vector.*, §17 диагностика
  по цепочкам (БД, 500, fallback-зоны, вход), §17а чек-лист передачи проекта
- DEPLOY.md: вместо устаревшего дубля — ссылка на актуальные доки + шпаргалка стенда
- тесты: 239 passed
2026-09-25 09:38:39 +03:00

356 lines
14 KiB
Python

"""B21/E3: операции мультипоиска.
Скоуп: unit_id операции ∈ {своё подразделение + подчинённые} (РЦУ РЧС — все).
Статусная машина: planned → active ⇄ paused → completed → archived.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from backend.audit import audit_log, visible_unit_ids
from backend.logging_setup import get_logger
from backend.database import get_db
from backend.models import Case, SearchOperation, User
from backend.routers.auth import get_current_user, require_permission
oplog = get_logger('operations')
router = APIRouter(prefix='/api/v1/operations', tags=['operations'])
ALLOWED_TRANSITIONS = {
'planned': {'active', 'archived'},
'active': {'paused', 'completed', 'archived'},
'paused': {'active', 'completed', 'archived'},
'completed': {'archived'},
'archived': set(),
}
class OperationCreate(BaseModel):
title: str
case_id: str
unit_id: Optional[str] = None
class OperationUpdate(BaseModel):
title: Optional[str] = None
status: Optional[str] = None
unit_id: Optional[str] = None
contour_operation_id: Optional[str] = None # UUID операции в КОНТУРе
def _op_scope(db: Session, user: User) -> list | None:
"""UUID-объекты юнитов из скоупа, либо None (без фильтра)."""
scope = visible_unit_ids(db, user)
if scope is None:
return None
import uuid
return [uuid.UUID(h) if isinstance(h, str) else h for h in scope]
def _get_operation_checked(db: Session, user: User, operation_id: str) -> SearchOperation:
import uuid as uuid_mod
op = db.get(SearchOperation, uuid_mod.UUID(operation_id)) if operation_id else None
if not op:
raise HTTPException(status_code=404, detail='Операция не найдена')
scope = _op_scope(db, user)
if scope is not None and (not op.unit_id or op.unit_id not in scope):
raise HTTPException(status_code=403, detail='Операция вне вашего скоупа')
return op
@router.get('')
def list_operations(
status_filter: Optional[str] = Query(default=None, alias='status'),
case_id: Optional[str] = Query(default=None),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
scope = _op_scope(db, current_user)
query = db.query(SearchOperation)
if scope is not None:
query = query.filter(SearchOperation.unit_id.in_(scope))
if status_filter:
query = query.filter(SearchOperation.status == status_filter)
if case_id:
import uuid as uuid_mod
query = query.filter(SearchOperation.case_id == uuid_mod.UUID(case_id))
ops = query.order_by(SearchOperation.updated_at.desc()).all()
from backend.models import User as UserModel
users_by_id = {}
if ops:
creator_ids = {o.created_by for o in ops if o.created_by}
if creator_ids:
for u in db.query(UserModel).filter(UserModel.id.in_(creator_ids)).all():
users_by_id[str(u.id)] = u.full_name or u.username
units_by_id = {}
unit_ids = {o.unit_id for o in ops if o.unit_id}
if unit_ids:
from backend.models import MchsUnit
for un in db.query(MchsUnit).filter(MchsUnit.id.in_(unit_ids)).all():
units_by_id[str(un.id)] = un.name
return {'items': [_operation_dto(o, users_by_id, units_by_id) for o in ops], 'total': len(ops)}
def _operation_dto(op: SearchOperation, users_by_id: dict | None = None,
units_by_id: dict | None = None) -> dict[str, Any]:
u = users_by_id or {}
units = units_by_id or {}
return {
'id': str(op.id),
'title': op.title,
'case_id': str(op.case_id),
'status': op.status,
'unit_id': str(op.unit_id) if op.unit_id else None,
'unit_name': units.get(str(op.unit_id)) if op.unit_id else None,
'contour_operation_id': str(op.contour_operation_id) if op.contour_operation_id else None,
'created_by_name': u.get(str(op.created_by)) if op.created_by else None,
'created_at': op.created_at.isoformat() if op.created_at else None,
'updated_at': op.updated_at.isoformat() if op.updated_at else None,
'closed_at': op.closed_at.isoformat() if op.closed_at else None,
}
class OperationComplete(BaseModel):
found_alive: bool
found_distance_km: Optional[float] = None
found_direction: Optional[str] = None
found_location_type: Optional[str] = None
found_lat: Optional[float] = None
found_lon: Optional[float] = None
search_duration_hours: Optional[float] = None
who_found: Optional[str] = None
notes: Optional[str] = None
@router.post('/{operation_id}/complete')
def complete_operation(
operation_id: str,
payload: OperationComplete,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Завершение поиска: исход → кейс (архив + прогон модели) → операция completed."""
op = _get_operation_checked(db, current_user, operation_id)
if op.status == 'completed':
raise HTTPException(status_code=400, detail='Операция уже завершена')
if op.status == 'archived':
raise HTTPException(status_code=400, detail='Операция в архиве — завершение недоступно')
case = db.query(Case).filter(Case.id == op.case_id).first()
if not case:
raise HTTPException(status_code=404, detail='Карточка операции не найдена')
# Данные кейса → вход матмодели (детерминированный прогон, как /analyze)
data = {
'age_years': case.age_years,
'gender': case.gender or 'м',
'diagnosis_type': case.diagnosis_type or [],
'has_transport': case.has_transport or 'none',
'cant_swim': bool(case.cant_swim),
'fitness_level': case.fitness_level,
'elapsed_hours': case.elapsed_hours or 0.0,
'loss_time': case.loss_time,
'terrain': case.terrain or [],
'weather': None, 'season': case.season, 'temperature_c': case.temperature_c,
'tnp_lat': case.tnp_lat, 'tnp_lon': case.tnp_lon, 'tnp_address': case.tnp_address,
'psychotype': case.psychotype, 'psychotype_answers': case.psychotype_answers,
'teams_count': case.teams_count, 'team_size': case.team_size,
'has_dog': bool(case.has_dog),
'found_alive': payload.found_alive,
'found_distance_km': payload.found_distance_km,
'found_direction': payload.found_direction,
'found_location_type': payload.found_location_type,
'found_lat': payload.found_lat, 'found_lon': payload.found_lon,
'search_duration_hours': payload.search_duration_hours,
'who_found': payload.who_found,
'notes': payload.notes,
'source': 'операция ВЕКТОР',
}
from backend.routers.closed_cases import _run_model as _run_model_func, _accuracy_note as _accuracy_note_func
from backend.schemas import ClosedCaseCreate
cc = ClosedCaseCreate(**{k: v for k, v in data.items() if k in ClosedCaseCreate.model_fields})
prediction = _run_model_func(cc.model_dump())
accuracy = _accuracy_note_func(prediction, cc)
# 1. Кейс → closed + фактические данные + маркер архивной записи
case.status = 'closed'
case.found_alive = payload.found_alive
case.found_distance_km = payload.found_distance_km
case.found_direction = payload.found_direction
case.found_location_type = payload.found_location_type
case.found_lat = payload.found_lat
case.found_lon = payload.found_lon
case.search_duration_hours = payload.search_duration_hours
case.who_found = payload.who_found
log = dict(case.analysis_log or {})
log.update({
'closed_case': True,
'model_prediction': prediction,
'accuracy_note': accuracy,
'source': 'операция ВЕКТОР',
'notes': payload.notes,
})
case.analysis_log = log
# 2. Операция → completed (через статусную машину, closed_at автоматически)
op.status = 'completed'
op.closed_at = datetime.now(timezone.utc)
db.commit()
oplog.info('operation_complete op=%s case=%s исход=%s юзер=%s',
op.id, case.id, 'найден живым' if payload.found_alive else 'найден погибшим',
getattr(current_user, 'username', '—'))
audit_log(db, current_user, 'operation_complete',
object_type='search_operation', object_id=str(op.id),
details={'case_id': str(case.id), 'found_alive': payload.found_alive})
audit_log(db, current_user, 'case_closed', object_type='case', object_id=str(case.id),
details={'operation_id': str(op.id), 'found_alive': payload.found_alive})
return {
'operation_id': str(op.id),
'status': op.status,
'closed_at': op.closed_at.isoformat() if op.closed_at else None,
'case_id': str(case.id),
'model_prediction': prediction,
'accuracy_note': accuracy,
}
@router.get('/summary')
def operations_summary(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Счётчики для дашборда: активных / всего / завершённых за 24 ч."""
scope = _op_scope(db, current_user)
query = db.query(SearchOperation)
if scope is not None:
query = query.filter(SearchOperation.unit_id.in_(scope))
total = query.count()
active = query.filter(SearchOperation.status == 'active').count()
day_ago = datetime.now(timezone.utc) - __import__('datetime').timedelta(hours=24)
completed_24h = query.filter(
SearchOperation.status == 'completed',
SearchOperation.closed_at >= day_ago,
).count()
return {'active': active, 'total': total, 'completed_24h': completed_24h}
@router.get('/{operation_id}')
def get_operation(
operation_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
op = _get_operation_checked(db, current_user, operation_id)
return _operation_dto(op)
@router.post('', status_code=201)
def create_operation(
payload: OperationCreate,
request: Request,
current_user: User = Depends(require_permission('create')),
db: Session = Depends(get_db),
) -> dict[str, Any]:
import uuid as uuid_mod
from backend.models import Case
case_uuid = uuid_mod.UUID(payload.case_id)
case = db.get(Case, case_uuid)
if not case:
raise HTTPException(status_code=404, detail='Карточка не найдена')
existing = db.query(SearchOperation).filter(SearchOperation.case_id == case_uuid).first()
if existing:
raise HTTPException(status_code=400, detail='Операция для этой карточки уже существует')
unit_id = payload.unit_id
if unit_id:
from backend.audit import can_access_unit
if not can_access_unit(db, current_user, unit_id):
raise HTTPException(status_code=403, detail='Подразделение вне вашего скоупа')
else:
# По умолчанию — подразделение создателя
unit_id = getattr(current_user, 'unit_id', None)
unit_id = str(unit_id) if unit_id else None
op = SearchOperation(
title=payload.title,
case_id=case_uuid,
status='active',
unit_id=uuid_mod.UUID(unit_id) if unit_id else None,
created_by=current_user.id,
)
db.add(op)
db.commit()
db.refresh(op)
from backend.audit import audit_log
audit_log(db, current_user, 'operation_create', object_type='operation',
object_id=str(op.id), request=request,
details={'title': op.title, 'case_id': payload.case_id})
return _operation_dto(op)
@router.patch('/{operation_id}')
def update_operation(
operation_id: str,
payload: OperationUpdate,
request: Request,
current_user: User = Depends(require_permission('update')),
db: Session = Depends(get_db),
) -> dict[str, Any]:
import uuid as uuid_mod
op = _get_operation_checked(db, current_user, operation_id)
changes: dict[str, tuple] = {}
if payload.title is not None and payload.title != op.title:
changes['title'] = (op.title, payload.title)
op.title = payload.title
if payload.unit_id is not None:
from backend.audit import can_access_unit, _uuid_hex
if not can_access_unit(db, current_user, payload.unit_id):
raise HTTPException(status_code=403, detail='Подразделение вне вашего скоупа')
new_unit = uuid_mod.UUID(payload.unit_id)
if new_unit != op.unit_id:
changes['unit'] = (str(op.unit_id) if op.unit_id else None, payload.unit_id)
op.unit_id = new_unit
if payload.contour_operation_id is not None:
new_contour = uuid_mod.UUID(payload.contour_operation_id)
if new_contour != op.contour_operation_id:
changes['contour_operation_id'] = (str(op.contour_operation_id) if op.contour_operation_id else None,
payload.contour_operation_id)
op.contour_operation_id = new_contour
if payload.status is not None and payload.status != op.status:
allowed = ALLOWED_TRANSITIONS.get(op.status, set())
if payload.status not in allowed:
raise HTTPException(
status_code=400,
detail=f'Недопустимый переход {op.status} → {payload.status}. '
f'Разрешено: {", ".join(sorted(allowed)) or "нет"}',
)
changes['status'] = (op.status, payload.status)
op.status = payload.status
if payload.status in ('completed', 'archived'):
op.closed_at = datetime.now(timezone.utc)
if not changes:
return _operation_dto(op)
op.updated_at = datetime.now(timezone.utc)
db.commit()
from backend.audit import audit_log
audit_log(db, current_user, 'operation_update', object_type='operation',
object_id=str(op.id), changes=changes, request=request)
return _operation_dto(op)