Files
vector/backend/routers/admin.py
T
Sadmin a1d33a2b28 Реорганизация /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).
2026-09-09 22:41:14 +03:00

171 lines
6.3 KiB
Python

from __future__ import annotations
from io import BytesIO
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']))])
def _parse_bool(value: str | None) -> bool | None:
if value is None:
return None
normalized = value.strip().lower()
if normalized in {'1', 'true', 'yes', 'y', 'да'}:
return True
if normalized in {'0', 'false', 'no', 'n', 'нет'}:
return False
return None
def _extract_docx_text(content: bytes) -> str:
try:
with zipfile.ZipFile(BytesIO(content)) as archive:
xml = archive.read('word/document.xml')
root = ET.fromstring(xml)
except (zipfile.BadZipFile, KeyError, ET.ParseError) as exc:
raise ValueError('Не удалось прочитать .docx: файл повреждён или имеет неверный формат') from exc
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
paragraphs: list[str] = []
for paragraph in root.findall('.//w:body/w:p', ns):
texts = [node.text for node in paragraph.findall('.//w:t', ns) if node.text]
if texts:
paragraphs.append(''.join(texts).strip())
return '\n'.join(paragraphs).strip()
def _extract_kv_pairs(raw_text: str) -> dict[str, str]:
parsed: dict[str, str] = {}
for line in raw_text.splitlines():
if ':' not in line:
continue
key, value = line.split(':', 1)
parsed[key.strip().lower()] = value.strip()
return parsed
def _coerce_preview(raw_text: str) -> dict:
pairs = _extract_kv_pairs(raw_text)
case: dict[str, object] = {}
if 'age' in pairs:
try:
case['age'] = int(pairs['age'])
except ValueError:
pass
if 'gender' in pairs:
case['gender'] = pairs['gender']
if 'status' in pairs:
case['status'] = pairs['status']
if 'found alive' in pairs:
case['found_alive'] = _parse_bool(pairs['found alive'])
if 'found distance km' in pairs:
try:
case['found_distance_km'] = float(pairs['found distance km'])
except ValueError:
pass
if 'found lat' in pairs:
try:
case['found_lat'] = float(pairs['found lat'])
except ValueError:
pass
if 'found lon' in pairs:
try:
case['found_lon'] = float(pairs['found lon'])
except ValueError:
pass
if 'who found' in pairs:
case['who_found'] = pairs['who found']
if 'notes' in pairs:
case['notes'] = pairs['notes']
elif 'note' in pairs:
case['notes'] = pairs['note']
case_id = None
if 'case id' in pairs:
match = re.search(r'\d+', pairs['case id'])
if match:
case_id = match.group(0)
return {'raw_text': raw_text, 'case_id': case_id, 'case': case}
@router.get('/cases', response_model=CaseListResponse)
def admin_cases(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
status: str | None = None,
age_min: int | None = Query(default=None, ge=0),
age_max: int | None = Query(default=None, ge=0),
) -> dict:
cases = db.list_cases()
if status:
cases = [c for c in cases if c.status == status]
if age_min is not None:
cases = [c for c in cases if c.age is not None and c.age >= age_min]
if age_max is not None:
cases = [c for c in cases if c.age is not None and c.age <= age_max]
start = (page - 1) * page_size
end = start + page_size
return {'items': [c.to_detail() for c in cases[start:end]], 'total': len(cases), 'page': page, 'page_size': page_size}
@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()
@router.get('/dashboard', response_model=DashboardResponse)
def admin_dashboard() -> dict:
return db.stats()
@router.post('/parse-doc', response_model=ParseDocResponse)
async def admin_parse_doc(file: UploadFile = File(...)) -> dict:
content = await file.read()
try:
raw_text = _extract_docx_text(content)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
preview = _coerce_preview(raw_text)
return {'filename': file.filename, 'parsed': True, 'preview': preview, 'raw_text': raw_text}