90213e0491
- 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.
235 lines
9.1 KiB
Python
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) |