Files
vector/backend/routers/operations.py
T
Sadmin 90213e0491 E4 (B21): отправка зон в КОНТУР по операции (контракт B20)
- contour_operation_id в PATCH /operations/{id} (mapping на операцию КОНТУРа).
- backend/routers/contour.py: POST /{id}/send-to-contour — последний анализ
  (search_models → legacy analysis_log) → SearchZones[] GeoJSON-сектора
  направлений (±22.5°, probability по приоритету) → POST {CONTOUR_API_URL}/
  api/v1/vector/zones?operation_id=... (Bearer CONTOUR_TOKEN). Все шаги
  аудируются (contour_send_ok/failed). GET /{id}/areas-checked — прокси
  проверенных квадратов (inbound к B17).
- env: CONTOUR_API_URL (дефолт CT130:8000), CONTOUR_TOKEN (в secrets).
- case_id-фильтр в GET /operations.
- UI: панель «КОНТУР» на странице анализа: ввод/сохранение UUID операции
  КОНТУРа, кнопка «Отправить зоны в КОНТУР» со статусом результата.
- Тесты E4 (4): build B20-payload (полигон замкнут, probability 0..1,
  operation_id в query, Bearer), недостающий contour_id 400, отказ КОНТУРа
  → 502 + аудит, геометрия сектора.
226 passed, 5 skipped.
2026-09-09 21:39:18 +03:00

235 lines
9.1 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.database import get_db
from backend.models import SearchOperation, User
from backend.routers.auth import get_current_user, require_permission
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
return {'items': [_operation_dto(o, users_by_id) for o in ops], 'total': len(ops)}
def _operation_dto(op: SearchOperation, users_by_id: dict | None = None) -> dict[str, Any]:
u = users_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,
'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,
}
@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)