from __future__ import annotations from io import BytesIO import re import xml.etree.ElementTree as ET import zipfile 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 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: with zipfile.ZipFile(BytesIO(content)) as archive: xml = archive.read('word/document.xml') root = ET.fromstring(xml) 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) 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') 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() raw_text = _extract_docx_text(content) preview = _coerce_preview(raw_text) return {'filename': file.filename, 'parsed': True, 'preview': preview, 'raw_text': raw_text}