Fix admin import chain and tests

This commit is contained in:
root
2026-06-06 20:59:23 +00:00
parent fcc22063ed
commit 3548733019
15 changed files with 1818 additions and 1193 deletions
+137
View File
@@ -0,0 +1,137 @@
from io import BytesIO
import re
import xml.etree.ElementTree as ET
import zipfile
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from backend.database import db
from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, ParseDocResponse, DashboardResponse
router = APIRouter(prefix='/api/v1/admin', tags=['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}
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
@router.post('')
def analyze_stub() -> dict:
return {'status': 'ok'}
+59
View File
@@ -0,0 +1,59 @@
from fastapi import APIRouter, HTTPException, Query
from backend.database import db
from backend.schemas import CaseCreate, CaseListResponse, CaseResponse, CaseUpdate
router = APIRouter(prefix='/api/v1/cases', tags=['cases'])
@router.post('', response_model=CaseResponse)
def create_case(payload: CaseCreate) -> dict:
data = payload.model_dump(exclude_none=True)
gps = data.pop('gps', None) or {}
note = data.pop('note', None)
if note is not None and 'notes' not in data:
data['notes'] = note
if gps:
data['gps_lat'] = gps.get('lat')
data['gps_lon'] = gps.get('lon')
case = db.create_case(**data)
if data.get('status'):
db.update_case(case.id, status=data['status'])
return case.to_detail()
@router.get('', response_model=CaseListResponse)
def list_cases(page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100)) -> dict:
cases = db.list_cases()
start = (page - 1) * page_size
end = start + page_size
items = [c.to_detail() for c in cases[start:end]]
return {'items': items, 'total': len(cases), 'page': page, 'page_size': page_size}
@router.get('/{case_id}', response_model=CaseResponse)
def get_case(case_id: str) -> dict:
case = db.get_case(case_id)
if not case:
raise HTTPException(status_code=404, detail='Case not found')
return case.to_detail()
@router.patch('/{case_id}', response_model=CaseResponse)
def patch_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.delete('/{case_id}')
def delete_case(case_id: str) -> dict[str, bool]:
deleted = db.delete_case(case_id)
if not deleted:
raise HTTPException(status_code=404, detail='Case not found')
return {'deleted': True}
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(prefix='/api/v1/health', tags=['health'])
@router.get('')
def health() -> dict[str, str]:
return {'status': 'ok'}
+35
View File
@@ -0,0 +1,35 @@
from fastapi import APIRouter
from backend.database import db
from backend.schemas import DashboardResponse, RecommendationRequest
router = APIRouter(prefix='/api/v1/stats', tags=['stats'])
@router.get('/summary', response_model=DashboardResponse)
def stats_summary() -> dict:
return db.stats()
@router.get('/heatmap')
def stats_heatmap() -> dict:
return {'items': db.heatmap()}
@router.post('/recommendation')
def stats_recommendation(payload: RecommendationRequest) -> dict:
score = 0
if payload.age is not None and payload.age < 12:
score += 20
if payload.elapsed_hours is not None and payload.elapsed_hours >= 12:
score += 20
if payload.terrain_primary in {'лес', 'болото', 'вода'}:
score += 15
if payload.weather in {'дождь', 'туман', 'снег', 'ночь'}:
score += 15
if len(payload.health_flags) >= 2:
score += 15
return {
'recommendation': 'Высокий приоритет на прочёс и дрон' if score >= 40 else 'Стандартный приоритет поиска',
'score': score,
}