Реорганизация /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:
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ def get_case(case_id: str) -> dict:
|
||||
@router.patch('/{case_id}', response_model=CaseResponse)
|
||||
def patch_case(case_id: str, payload: CaseUpdate) -> dict:
|
||||
data = payload.model_dump(exclude_none=True)
|
||||
# default_factory-списки затирали 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
|
||||
|
||||
@@ -85,7 +85,13 @@ def list_operations(
|
||||
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)}
|
||||
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) -> dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user