d5a270f3af
- 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
356 lines
14 KiB
Python
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) |