"""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 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'), 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) 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.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)