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}