Реорганизация /admin: операции, честные метрики, синхронизация закрытия

- Вкладка «Операции»: список search_operations (статус/подразделение/обновлена,
  фильтр, переход в анализ кейса), unit_name в DTO.
- Метрики: + активные/завершённые операции (stats из search_operations);
  «Активность 7 дней» теперь честная (created_at >= now-7d, была = total).
- Синхронизация: закрытие кейса через /admin PATCH → активная операция
  автоматически completed + closed_at (аудит в логе).
- ФИКС legacy-бага PATCH /cases: default_factory-списки затирали
  terrain/diagnosis_type пустыми массивами — выкидываются пустые списки.
- Редактор исхода: «Кто нашёл» → select WHO_FOUND_TYPES.
- Кнопка «Пользователи · Аудит» → /users.
- Тесты: автозакрытие операции, метрики (232 passed).
This commit is contained in:
2026-09-09 22:41:14 +03:00
parent 4fe60b0772
commit a1d33a2b28
8 changed files with 320 additions and 6 deletions
+25
View File
@@ -5,12 +5,17 @@ import re
import xml.etree.ElementTree as ET
import zipfile
from datetime import datetime, timezone
import logging
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from backend.database import db
from backend.routers.auth import require_roles
from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse, ParseDocResponse
log = logging.getLogger('vector.admin')
router = APIRouter(prefix='/api/v1/admin', tags=['admin'], dependencies=[Depends(require_roles(['admin']))])
@@ -120,12 +125,32 @@ def admin_cases(
@router.patch('/cases/{case_id}', response_model=CaseResponse)
def admin_update_case(case_id: str, payload: CaseUpdate) -> dict:
data = payload.model_dump(exclude_none=True)
# default_factory-списки ([]) при exclude_none всё равно попадают в data и
# ЗАТИРАЮТ реальные terrain/diagnosis_type — выкидываем пустые списки:
data = {k: v for k, v in data.items() if not (isinstance(v, list) and not v)}
note = data.pop('note', None)
if note is not None and 'notes' not in data:
data['notes'] = note
case = db.update_case(case_id, **data)
if not case:
raise HTTPException(status_code=404, detail='Case not found')
# B21-синхронизация: кейс закрыт вручную → его операция завершается автоматически
if data.get('status') == 'closed':
import uuid as _uuid
from backend.models import SearchOperation as _Op
from backend.database import SessionLocal as _SL
case_uuid = _uuid.UUID(str(case.id)) if isinstance(case.id, str) else case.id
with _SL() as session:
ops = session.query(_Op).filter(_Op.case_id == case_uuid,
_Op.status.in_(['planned', 'active', 'paused'])).all()
for op in ops:
op.status = 'completed'
op.closed_at = datetime.now(timezone.utc)
session.commit()
closed_ops = len(ops)
if closed_ops:
log.info('admin closed case %s → %d операция(ий) завершены автоматически', case_id, closed_ops)
return case.to_detail()