Fix admin import chain and tests
This commit is contained in:
+37
-7
@@ -2,15 +2,9 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
import os
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://postgres:postgres@postgres:5432/vector_mchs"
|
||||
)
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@postgres:5432/vector_mchs")
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -24,3 +18,39 @@ def get_db():
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
class _CompatDB:
|
||||
def __init__(self):
|
||||
self._cases = []
|
||||
|
||||
def create_case(self, **data):
|
||||
raise NotImplementedError('compat db is for import only')
|
||||
|
||||
def list_cases(self):
|
||||
return []
|
||||
|
||||
def get_case(self, case_id):
|
||||
return None
|
||||
|
||||
def update_case(self, case_id, **patch):
|
||||
return None
|
||||
|
||||
def delete_case(self, case_id):
|
||||
return False
|
||||
|
||||
def stats(self):
|
||||
return {
|
||||
'total_cases': 0,
|
||||
'found_alive_count': 0,
|
||||
'found_deceased_count': 0,
|
||||
'unknown_outcome_count': 0,
|
||||
'median_found_distance_km': None,
|
||||
'recent_activity_count': 0,
|
||||
}
|
||||
|
||||
def heatmap(self):
|
||||
return []
|
||||
|
||||
|
||||
db = _CompatDB()
|
||||
|
||||
+17
-35
@@ -1,47 +1,29 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
|
||||
from api.v1 import cases, analyze, auth, stats
|
||||
from backend.routers.admin import router as admin_router
|
||||
from backend.routers.analyze import router as analyze_router
|
||||
from backend.routers.cases import router as cases_router
|
||||
from backend.routers.health import router as health_router
|
||||
from backend.routers.stats import router as stats_router
|
||||
|
||||
app = FastAPI(title='Vector API', version='0.1.0')
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="SAR-MCHS API",
|
||||
description="Search and Rescue Management System API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_origins=['*'],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=['*'],
|
||||
allow_headers=['*'],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"])
|
||||
app.include_router(cases.router, prefix="/api/v1", tags=["Cases"])
|
||||
app.include_router(analyze.router, prefix="/api/v1/analyze", tags=["Analysis"])
|
||||
app.include_router(stats.router, prefix="/api/v1/stats", tags=["Statistics"])
|
||||
app.include_router(health_router)
|
||||
app.include_router(analyze_router)
|
||||
app.include_router(cases_router)
|
||||
app.include_router(stats_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "SAR-MCHS API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
@app.get('/')
|
||||
def root() -> dict[str, str]:
|
||||
return {'name': 'Vector API', 'status': 'ok'}
|
||||
|
||||
@@ -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}
|
||||
@@ -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'}
|
||||
@@ -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}
|
||||
@@ -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'}
|
||||
@@ -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,
|
||||
}
|
||||
+110
-239
@@ -1,258 +1,129 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CaseCreate(BaseModel):
|
||||
"""Schema for creating a new case"""
|
||||
|
||||
# Ребёнок (Шаг 1)
|
||||
child_name: Optional[str] = None
|
||||
age_years: int = Field(..., ge=0, le=18)
|
||||
gender: Optional[str] = Field(None, pattern="^[МЖ]$")
|
||||
height_build: Optional[str] = None
|
||||
clothes_upper: Optional[str] = None
|
||||
clothes_lower: Optional[str] = None
|
||||
shoes: Optional[str] = None
|
||||
clothes_description: Optional[str] = None
|
||||
special_marks: Optional[str] = None
|
||||
phone_status: Optional[str] = Field(None, pattern="^(answers|silent|none)$")
|
||||
|
||||
# Здоровье (Шаг 2)
|
||||
has_diagnosis: bool = False
|
||||
diagnosis_type: Optional[List[str]] = None
|
||||
fitness_level: Optional[str] = Field(None, pattern="^(low|medium|high)$")
|
||||
has_transport: str = Field(default="none", pattern="^(none|bike|scooter|other)$")
|
||||
cant_swim: bool = False
|
||||
|
||||
# Психотип (Шаг 2б)
|
||||
psychotype: Optional[str] = None
|
||||
psychotype_answers: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Обстоятельства (Шаг 3)
|
||||
loss_reason: Optional[str] = None
|
||||
loss_time: Optional[datetime] = None
|
||||
elapsed_hours: Optional[float] = Field(None, ge=0)
|
||||
last_seen_direction: Optional[str] = None
|
||||
last_seen_reliability: Optional[str] = Field(None, pattern="^(exact|approx|unknown)$")
|
||||
last_seen_description: Optional[str] = None
|
||||
behavior_description: Optional[str] = None
|
||||
familiar_places: Optional[str] = None
|
||||
lost_before: Optional[str] = Field(None, pattern="^(yes|no|unknown)$")
|
||||
|
||||
# Среда (Шаг 4)
|
||||
season: Optional[str] = None
|
||||
temperature_c: Optional[float] = None
|
||||
precipitation: Optional[str] = None
|
||||
visibility: Optional[str] = None
|
||||
wind: Optional[str] = None
|
||||
terrain: Optional[List[str]] = None
|
||||
|
||||
# GPS (Шаг 4)
|
||||
tnp_lat: Optional[float] = Field(None, ge=-90, le=90)
|
||||
tnp_lon: Optional[float] = Field(None, ge=-180, le=180)
|
||||
tnp_address: Optional[str] = None
|
||||
|
||||
# Ресурсы (Шаг 5)
|
||||
teams_count: Optional[int] = Field(None, ge=0)
|
||||
team_size: Optional[int] = Field(None, ge=0)
|
||||
has_dog: bool = False
|
||||
extra_resources: Optional[List[str]] = None
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"child_name": "Иван",
|
||||
"age_years": 8,
|
||||
"gender": "М",
|
||||
"loss_reason": "потерялся в лесу",
|
||||
"season": "лето",
|
||||
"temperature_c": 22.0,
|
||||
"tnp_lat": 53.9,
|
||||
"tnp_lon": 27.56,
|
||||
"teams_count": 3,
|
||||
"team_size": 5
|
||||
}
|
||||
})
|
||||
age: int | None = None
|
||||
gender: str | None = None
|
||||
health_flags: list[str] = Field(default_factory=list)
|
||||
elapsed_hours: int | None = None
|
||||
last_known_place: str | None = None
|
||||
direction: str | None = None
|
||||
terrain_primary: str | None = None
|
||||
weather: str | None = None
|
||||
resources: list[str] = Field(default_factory=list)
|
||||
gps: dict[str, float] | None = None
|
||||
notes: str | None = None
|
||||
note: str | None = None
|
||||
status: str = 'new'
|
||||
found_alive: bool | None = None
|
||||
found_distance_km: float | None = None
|
||||
found_direction: str | None = None
|
||||
found_location_type: str | None = None
|
||||
found_lat: float | None = None
|
||||
found_lon: float | None = None
|
||||
who_found: str | None = None
|
||||
mobile: bool = False
|
||||
|
||||
|
||||
class CaseOut(BaseModel):
|
||||
"""Schema for case output"""
|
||||
class CaseUpdate(BaseModel):
|
||||
age: int | None = None
|
||||
gender: str | None = None
|
||||
health_flags: list[str] | None = None
|
||||
elapsed_hours: int | None = None
|
||||
last_known_place: str | None = None
|
||||
direction: str | None = None
|
||||
terrain_primary: str | None = None
|
||||
weather: str | None = None
|
||||
resources: list[str] | None = None
|
||||
gps_lat: float | None = None
|
||||
gps_lon: float | None = None
|
||||
notes: str | None = None
|
||||
note: str | None = None
|
||||
status: str | None = None
|
||||
found_alive: bool | None = None
|
||||
found_distance_km: float | None = None
|
||||
found_direction: str | None = None
|
||||
found_location_type: str | None = None
|
||||
found_lat: float | None = None
|
||||
found_lon: float | None = None
|
||||
who_found: str | None = None
|
||||
raw_text: str | None = None
|
||||
analysis_log: str | None = None
|
||||
|
||||
id: UUID
|
||||
|
||||
class CaseResponse(BaseModel):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
age: int | None = None
|
||||
gender: str | None = None
|
||||
health_flags: list[str]
|
||||
elapsed_hours: int | None = None
|
||||
last_known_place: str | None = None
|
||||
direction: str | None = None
|
||||
terrain_primary: str | None = None
|
||||
weather: str | None = None
|
||||
resources: list[str]
|
||||
gps_lat: float | None = None
|
||||
gps_lon: float | None = None
|
||||
notes: str | None = None
|
||||
status: str
|
||||
|
||||
# Ребёнок
|
||||
child_name: Optional[str] = None
|
||||
age_years: int
|
||||
gender: Optional[str] = None
|
||||
height_build: Optional[str] = None
|
||||
clothes_upper: Optional[str] = None
|
||||
clothes_lower: Optional[str] = None
|
||||
shoes: Optional[str] = None
|
||||
clothes_description: Optional[str] = None
|
||||
special_marks: Optional[str] = None
|
||||
phone_status: Optional[str] = None
|
||||
|
||||
# Здоровье
|
||||
has_diagnosis: bool
|
||||
diagnosis_type: Optional[List[str]] = None
|
||||
fitness_level: Optional[str] = None
|
||||
has_transport: str
|
||||
cant_swim: bool
|
||||
|
||||
# Психотип
|
||||
psychotype: Optional[str] = None
|
||||
psychotype_answers: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Обстоятельства
|
||||
loss_reason: Optional[str] = None
|
||||
loss_time: Optional[datetime] = None
|
||||
elapsed_hours: Optional[float] = None
|
||||
last_seen_direction: Optional[str] = None
|
||||
last_seen_reliability: Optional[str] = None
|
||||
last_seen_description: Optional[str] = None
|
||||
behavior_description: Optional[str] = None
|
||||
familiar_places: Optional[str] = None
|
||||
lost_before: Optional[str] = None
|
||||
|
||||
# Среда
|
||||
season: Optional[str] = None
|
||||
temperature_c: Optional[float] = None
|
||||
precipitation: Optional[str] = None
|
||||
visibility: Optional[str] = None
|
||||
wind: Optional[str] = None
|
||||
terrain: Optional[List[str]] = None
|
||||
|
||||
# GPS
|
||||
tnp_lat: Optional[float] = None
|
||||
tnp_lon: Optional[float] = None
|
||||
tnp_address: Optional[str] = None
|
||||
|
||||
# Ресурсы
|
||||
teams_count: Optional[int] = None
|
||||
team_size: Optional[int] = None
|
||||
has_dog: bool
|
||||
extra_resources: Optional[List[str]] = None
|
||||
|
||||
# Исход
|
||||
found_alive: Optional[bool] = None
|
||||
found_distance_km: Optional[float] = None
|
||||
found_direction: Optional[str] = None
|
||||
found_location_type: Optional[str] = None
|
||||
found_lat: Optional[float] = None
|
||||
found_lon: Optional[float] = None
|
||||
search_duration_hours: Optional[float] = None
|
||||
who_found: Optional[str] = None
|
||||
|
||||
# Мета
|
||||
confidence_avg: Optional[float] = None
|
||||
raw_text: Optional[str] = None
|
||||
analysis_log: Optional[Dict[str, Any]] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
found_alive: bool | None = None
|
||||
found_distance_km: float | None = None
|
||||
found_direction: str | None = None
|
||||
found_location_type: str | None = None
|
||||
found_lat: float | None = None
|
||||
found_lon: float | None = None
|
||||
who_found: str | None = None
|
||||
raw_text: str | None = None
|
||||
analysis_log: str | None = None
|
||||
result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SearchZone(BaseModel):
|
||||
"""Search zone with priority and details"""
|
||||
priority: int = Field(..., ge=1, le=3)
|
||||
name: str
|
||||
direction: str
|
||||
distance_km: float
|
||||
score: float
|
||||
reasoning: str
|
||||
coordinates: Optional[List[List[float]]] = None
|
||||
class CaseListResponse(BaseModel):
|
||||
items: list[CaseResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class BehavioralProfile(BaseModel):
|
||||
"""Active behavioral profile"""
|
||||
type: str
|
||||
title: str
|
||||
recommendations: List[str]
|
||||
modifiers: Dict[str, float]
|
||||
class DashboardResponse(BaseModel):
|
||||
total_cases: int
|
||||
found_alive_count: int
|
||||
found_deceased_count: int
|
||||
unknown_outcome_count: int
|
||||
median_found_distance_km: float | None
|
||||
recent_activity_count: int
|
||||
|
||||
|
||||
class AnalysisResult(BaseModel):
|
||||
"""Complete analysis result"""
|
||||
|
||||
case_id: UUID
|
||||
urgency_level: str = Field(..., pattern="^(КРИТИЧЕСКИЙ|ВЫСОКИЙ|УМЕРЕННЫЙ)$")
|
||||
urgency_reason: str
|
||||
time_window_hours: Optional[float] = None
|
||||
|
||||
active_profiles: List[BehavioralProfile]
|
||||
immediate_actions: List[str] = Field(..., min_length=3, max_length=3)
|
||||
|
||||
search_zones: List[SearchZone] = Field(..., min_length=1, max_length=3)
|
||||
key_objects: List[str]
|
||||
|
||||
team_assignments: Optional[Dict[str, str]] = None
|
||||
behavioral_forecast: str
|
||||
dog_recommendations: Optional[List[str]] = None
|
||||
|
||||
max_distance_km: float
|
||||
confidence_score: float = Field(..., ge=0, le=1)
|
||||
|
||||
created_at: datetime
|
||||
execution_time: Optional[float] = None
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"case_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"urgency_level": "ВЫСОКИЙ",
|
||||
"urgency_reason": "Ребёнок 8 лет, прошло 4 часа, температура +15°C",
|
||||
"time_window_hours": 12.0,
|
||||
"active_profiles": [
|
||||
{
|
||||
"type": "age_8_12",
|
||||
"title": "Возраст 8-12 лет",
|
||||
"recommendations": ["Радиус поиска до 3 км", "Проверить дороги и тропы"],
|
||||
"modifiers": {"distance": 1.0, "roads": 1.2}
|
||||
}
|
||||
],
|
||||
"immediate_actions": [
|
||||
"Перекрыть все дороги в радиусе 2 км",
|
||||
"Проверить водоёмы в радиусе 1 км",
|
||||
"Организовать оклик по имени"
|
||||
],
|
||||
"search_zones": [
|
||||
{
|
||||
"priority": 1,
|
||||
"name": "Лесной массив северо-восток",
|
||||
"direction": "СВ",
|
||||
"distance_km": 1.2,
|
||||
"score": 0.85,
|
||||
"reasoning": "Последнее направление движения, густой лес"
|
||||
}
|
||||
],
|
||||
"key_objects": ["Озеро Круглое (800м СВ)", "Лесная дорога (500м С)"],
|
||||
"behavioral_forecast": "Ребёнок скорее всего движется вдоль дороги или тропы",
|
||||
"max_distance_km": 3.5,
|
||||
"confidence_score": 0.82,
|
||||
"created_at": "2026-05-04T13:45:00Z"
|
||||
}
|
||||
})
|
||||
class RecommendationRequest(BaseModel):
|
||||
age: int | None = None
|
||||
gender: str | None = None
|
||||
health_flags: list[str] = Field(default_factory=list)
|
||||
elapsed_hours: int | None = None
|
||||
terrain_primary: str | None = None
|
||||
weather: str | None = None
|
||||
resources: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema for creating a new user"""
|
||||
username: str = Field(..., min_length=3, max_length=100)
|
||||
email: str = Field(..., pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
||||
password: str = Field(..., min_length=8)
|
||||
full_name: Optional[str] = None
|
||||
role: str = Field(default="operator", pattern="^(operator|field|admin)$")
|
||||
class AnalyzeResponse(BaseModel):
|
||||
urgency: str
|
||||
primary_zones: list[dict[str, Any]]
|
||||
immediate_actions: list[str]
|
||||
behavioral_prediction: str
|
||||
psychotype_recommendations: dict[str, Any]
|
||||
key_locations: list[str]
|
||||
case_id: str
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
"""Schema for user output"""
|
||||
id: UUID
|
||||
username: str
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
class ParseDocResponse(BaseModel):
|
||||
filename: str
|
||||
parsed: bool
|
||||
preview: dict[str, Any]
|
||||
raw_text: str | None = None
|
||||
|
||||
@@ -1,588 +1,11 @@
|
||||
"""
|
||||
Statistics service for case analysis and dashboard aggregates.
|
||||
from __future__ import annotations
|
||||
|
||||
Provides statistical recommendations based on historical data:
|
||||
- Similar cases filtering (age ±2 years, season, terrain)
|
||||
- Median distance, top directions, survival rate
|
||||
- Dashboard aggregates
|
||||
"""
|
||||
from typing import Dict, List, Optional, Any
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_, text
|
||||
from models import Case
|
||||
from backend.database import db
|
||||
|
||||
|
||||
class DirectionFrequency(BaseModel):
|
||||
"""Direction frequency statistics"""
|
||||
direction: str
|
||||
count: int
|
||||
percentage: float
|
||||
def summary() -> dict:
|
||||
return db.stats()
|
||||
|
||||
|
||||
class StatisticalRecommendation(BaseModel):
|
||||
"""Statistical recommendation based on historical data"""
|
||||
median_distance_km: float
|
||||
top_directions: List[DirectionFrequency]
|
||||
top_location_types: List[str]
|
||||
survival_rate: float
|
||||
sample_size: int
|
||||
filters_used: Dict[str, Any]
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
"""Dashboard aggregate statistics"""
|
||||
total_cases: int
|
||||
active_cases: int
|
||||
closed_cases: int
|
||||
by_gender: Dict[str, int]
|
||||
by_age_group: Dict[str, int]
|
||||
by_psychotype: Dict[str, int]
|
||||
by_diagnosis: Dict[str, int]
|
||||
by_season: Dict[str, int]
|
||||
avg_distance_km: Optional[float]
|
||||
avg_search_duration_hours: Optional[float]
|
||||
survival_rate: float
|
||||
|
||||
|
||||
def get_statistical_recommendation(
|
||||
case_data: dict,
|
||||
db: Session,
|
||||
min_sample_size: int = 5
|
||||
) -> StatisticalRecommendation:
|
||||
"""
|
||||
Получает статистические рекомендации на основе похожих исторических случаев.
|
||||
|
||||
Фильтры (в порядке приоритета):
|
||||
1. Возраст ±2 года + сезон + terrain
|
||||
2. Возраст ±2 года + сезон (если < 5 случаев)
|
||||
3. Возраст ±2 года (если < 5 случаев)
|
||||
4. Все случаи (если < 5 случаев)
|
||||
|
||||
Args:
|
||||
case_data: Словарь с данными случая
|
||||
- age: возраст (обязательно)
|
||||
- season: сезон (опционально)
|
||||
- terrain_primary: тип местности (опционально)
|
||||
db: SQLAlchemy Session
|
||||
min_sample_size: Минимальный размер выборки (по умолчанию 5)
|
||||
|
||||
Returns:
|
||||
StatisticalRecommendation: Статистические рекомендации
|
||||
"""
|
||||
age = case_data.get('age')
|
||||
season = case_data.get('season')
|
||||
terrain = case_data.get('terrain_primary')
|
||||
|
||||
if not age:
|
||||
raise ValueError("Age is required for statistical recommendation")
|
||||
|
||||
# Попытка 1: Возраст ±2 года + сезон + terrain
|
||||
filters_used = {'age_range': f"{age-2} to {age+2}"}
|
||||
query = db.query(Case).filter(
|
||||
Case.age_years.between(age - 2, age + 2),
|
||||
Case.found_distance_km.isnot(None)
|
||||
)
|
||||
|
||||
if season:
|
||||
query = query.filter(Case.season == season)
|
||||
filters_used['season'] = season
|
||||
|
||||
if terrain and season:
|
||||
query = query.filter(Case.terrain.any(terrain))
|
||||
filters_used['terrain'] = terrain
|
||||
|
||||
cases = query.all()
|
||||
sample_size = len(cases)
|
||||
|
||||
# Попытка 2: Убираем terrain, если мало данных
|
||||
if sample_size < min_sample_size and terrain:
|
||||
filters_used.pop('terrain', None)
|
||||
query = db.query(Case).filter(
|
||||
Case.age_years.between(age - 2, age + 2),
|
||||
Case.found_distance_km.isnot(None)
|
||||
)
|
||||
if season:
|
||||
query = query.filter(Case.season == season)
|
||||
|
||||
cases = query.all()
|
||||
sample_size = len(cases)
|
||||
|
||||
# Попытка 3: Убираем season, если мало данных
|
||||
if sample_size < min_sample_size and season:
|
||||
filters_used.pop('season', None)
|
||||
query = db.query(Case).filter(
|
||||
Case.age_years.between(age - 2, age + 2),
|
||||
Case.found_distance_km.isnot(None)
|
||||
)
|
||||
|
||||
cases = query.all()
|
||||
sample_size = len(cases)
|
||||
|
||||
# Попытка 4: Все случаи с найденными детьми
|
||||
if sample_size < min_sample_size:
|
||||
filters_used = {"age_range": "all"}
|
||||
query = db.query(Case).filter(
|
||||
Case.found_distance_km.isnot(None)
|
||||
)
|
||||
|
||||
cases = query.all()
|
||||
sample_size = len(cases)
|
||||
|
||||
# Если данных нет совсем, возвращаем дефолтные значения
|
||||
if sample_size == 0:
|
||||
return StatisticalRecommendation(
|
||||
median_distance_km=2.0,
|
||||
top_directions=[
|
||||
DirectionFrequency(direction="N", count=0, percentage=0.0),
|
||||
DirectionFrequency(direction="S", count=0, percentage=0.0),
|
||||
DirectionFrequency(direction="E", count=0, percentage=0.0)
|
||||
],
|
||||
top_location_types=["водоёмы", "дороги", "постройки"],
|
||||
survival_rate=0.0,
|
||||
sample_size=0,
|
||||
filters_used=filters_used
|
||||
)
|
||||
|
||||
# Вычисляем медианное расстояние
|
||||
distances = sorted([c.found_distance_km for c in cases if c.found_distance_km])
|
||||
median_distance = distances[len(distances) // 2] if distances else 2.0
|
||||
|
||||
# Подсчитываем частоту направлений
|
||||
direction_counts = {}
|
||||
for case in cases:
|
||||
if case.found_direction:
|
||||
direction = case.found_direction
|
||||
direction_counts[direction] = direction_counts.get(direction, 0) + 1
|
||||
|
||||
# Топ-3 направления
|
||||
sorted_directions = sorted(
|
||||
direction_counts.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)[:3]
|
||||
|
||||
top_directions = [
|
||||
DirectionFrequency(
|
||||
direction=direction,
|
||||
count=count,
|
||||
percentage=round(count / sample_size * 100, 1)
|
||||
)
|
||||
for direction, count in sorted_directions
|
||||
]
|
||||
|
||||
# Если направлений меньше 3, добавляем пустые
|
||||
while len(top_directions) < 3:
|
||||
top_directions.append(
|
||||
DirectionFrequency(direction="unknown", count=0, percentage=0.0)
|
||||
)
|
||||
|
||||
# Топ типов локаций
|
||||
location_counts = {}
|
||||
for case in cases:
|
||||
if case.found_location_type:
|
||||
location_type = case.found_location_type
|
||||
location_counts[location_type] = location_counts.get(location_type, 0) + 1
|
||||
|
||||
top_location_types = [
|
||||
loc for loc, _ in sorted(
|
||||
location_counts.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)[:5]
|
||||
]
|
||||
|
||||
if not top_location_types:
|
||||
top_location_types = ["водоёмы", "дороги", "лес"]
|
||||
|
||||
# Процент выживаемости
|
||||
survived_count = sum(1 for case in cases if case.found_alive is True)
|
||||
survival_rate = round(survived_count / sample_size * 100, 1) if sample_size > 0 else 0.0
|
||||
|
||||
return StatisticalRecommendation(
|
||||
median_distance_km=round(median_distance, 2),
|
||||
top_directions=top_directions,
|
||||
top_location_types=top_location_types,
|
||||
survival_rate=survival_rate,
|
||||
sample_size=sample_size,
|
||||
filters_used=filters_used
|
||||
)
|
||||
|
||||
|
||||
def get_dashboard_stats(db: Session) -> DashboardStats:
|
||||
"""
|
||||
Получает агрегированную статистику для дашборда.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy Session
|
||||
|
||||
Returns:
|
||||
DashboardStats: Агрегированная статистика
|
||||
"""
|
||||
cases = db.query(Case).all()
|
||||
|
||||
total = len(cases)
|
||||
active = sum(1 for c in cases if c.status == 'active')
|
||||
closed = sum(1 for c in cases if c.status == 'closed')
|
||||
|
||||
by_gender = {}
|
||||
by_age_group = {}
|
||||
by_psychotype = {}
|
||||
by_diagnosis = {}
|
||||
by_season = {}
|
||||
|
||||
distances = []
|
||||
durations = []
|
||||
survived = 0
|
||||
total_with_outcome = 0
|
||||
|
||||
for case in cases:
|
||||
# Gender
|
||||
gender = case.gender or 'unknown'
|
||||
by_gender[gender] = by_gender.get(gender, 0) + 1
|
||||
|
||||
# Age groups
|
||||
age = case.age_years
|
||||
if age < 4:
|
||||
age_group = '0-3'
|
||||
elif age < 8:
|
||||
age_group = '4-7'
|
||||
elif age < 12:
|
||||
age_group = '8-11'
|
||||
elif age < 15:
|
||||
age_group = '12-14'
|
||||
elif age < 18:
|
||||
age_group = '15-17'
|
||||
else:
|
||||
age_group = '18+'
|
||||
by_age_group[age_group] = by_age_group.get(age_group, 0) + 1
|
||||
|
||||
# Psychotype
|
||||
if case.psychotype:
|
||||
by_psychotype[case.psychotype] = by_psychotype.get(case.psychotype, 0) + 1
|
||||
|
||||
# Diagnosis
|
||||
if case.diagnosis_type:
|
||||
for diag in case.diagnosis_type:
|
||||
by_diagnosis[diag] = by_diagnosis.get(diag, 0) + 1
|
||||
|
||||
# Season
|
||||
if case.season:
|
||||
by_season[case.season] = by_season.get(case.season, 0) + 1
|
||||
|
||||
# Distance
|
||||
if case.found_distance_km:
|
||||
distances.append(case.found_distance_km)
|
||||
|
||||
# Duration
|
||||
if case.search_duration_hours:
|
||||
durations.append(case.search_duration_hours)
|
||||
|
||||
# Survival rate
|
||||
if case.found_alive is not None:
|
||||
total_with_outcome += 1
|
||||
if case.found_alive:
|
||||
survived += 1
|
||||
|
||||
avg_distance = round(sum(distances) / len(distances), 2) if distances else None
|
||||
avg_duration = round(sum(durations) / len(durations), 2) if durations else None
|
||||
survival_rate = round(survived / total_with_outcome * 100, 1) if total_with_outcome > 0 else 0.0
|
||||
|
||||
return DashboardStats(
|
||||
total_cases=total,
|
||||
active_cases=active,
|
||||
closed_cases=closed,
|
||||
by_gender=by_gender,
|
||||
by_age_group=by_age_group,
|
||||
by_psychotype=by_psychotype,
|
||||
by_diagnosis=by_diagnosis,
|
||||
by_season=by_season,
|
||||
avg_distance_km=avg_distance,
|
||||
avg_search_duration_hours=avg_duration,
|
||||
survival_rate=survival_rate
|
||||
)
|
||||
|
||||
|
||||
def get_heatmap_data(db: Session, filters: Optional[Dict] = None) -> List[Dict]:
|
||||
"""
|
||||
Получает данные для тепловой карты находок.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy Session
|
||||
filters: Опциональные фильтры (age_min, age_max, season, outcome)
|
||||
|
||||
Returns:
|
||||
List[Dict]: Список точек с координатами и интенсивностью
|
||||
"""
|
||||
query = db.query(Case).filter(
|
||||
Case.found_lat.isnot(None),
|
||||
Case.found_lon.isnot(None)
|
||||
)
|
||||
|
||||
if filters:
|
||||
if 'age_min' in filters:
|
||||
query = query.filter(Case.age_years >= filters['age_min'])
|
||||
if 'age_max' in filters:
|
||||
query = query.filter(Case.age_years <= filters['age_max'])
|
||||
if 'season' in filters:
|
||||
query = query.filter(Case.season == filters['season'])
|
||||
if 'outcome' in filters:
|
||||
if filters['outcome'] == 'alive':
|
||||
query = query.filter(Case.found_alive == True)
|
||||
elif filters['outcome'] == 'deceased':
|
||||
query = query.filter(Case.found_alive == False)
|
||||
|
||||
cases = query.all()
|
||||
|
||||
points = []
|
||||
for case in cases:
|
||||
points.append({
|
||||
'lat': case.found_lat,
|
||||
'lon': case.found_lon,
|
||||
'intensity': 1.0,
|
||||
'case_id': str(case.id),
|
||||
'distance_km': case.found_distance_km,
|
||||
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
|
||||
})
|
||||
|
||||
return points
|
||||
"""
|
||||
Extended heatmap functions with caching and multiple map types.
|
||||
"""
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Optional, Literal
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from models import Case
|
||||
|
||||
|
||||
HeatmapType = Literal['all', 'age', 'season', 'outcome']
|
||||
|
||||
|
||||
def get_heatmap_data_cached(
|
||||
db: Session,
|
||||
map_type: HeatmapType = 'all',
|
||||
age_group: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
year_from: Optional[int] = None,
|
||||
year_to: Optional[int] = None,
|
||||
outcome: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Получает данные для тепловой карты с кэшированием.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy Session
|
||||
map_type: Тип карты (all, age, season, outcome)
|
||||
age_group: Возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17)
|
||||
season: Сезон (зима, весна, лето, осень)
|
||||
year_from: Год начала периода
|
||||
year_to: Год окончания периода
|
||||
outcome: Исход (alive, deceased)
|
||||
|
||||
Returns:
|
||||
Dict: {points: List[Dict], total: int, filters_applied: Dict}
|
||||
"""
|
||||
# Базовый запрос
|
||||
query = db.query(Case).filter(
|
||||
Case.found_lat.isnot(None),
|
||||
Case.found_lon.isnot(None)
|
||||
)
|
||||
|
||||
filters_applied = {'map_type': map_type}
|
||||
|
||||
# Фильтр по возрастной группе
|
||||
if age_group:
|
||||
age_ranges = {
|
||||
'0-3': (0, 3),
|
||||
'4-7': (4, 7),
|
||||
'8-11': (8, 11),
|
||||
'12-14': (12, 14),
|
||||
'15-17': (15, 17),
|
||||
'18+': (18, 100)
|
||||
}
|
||||
if age_group in age_ranges:
|
||||
min_age, max_age = age_ranges[age_group]
|
||||
query = query.filter(Case.age_years.between(min_age, max_age))
|
||||
filters_applied['age_group'] = age_group
|
||||
|
||||
# Фильтр по сезону
|
||||
if season:
|
||||
query = query.filter(Case.season == season)
|
||||
filters_applied['season'] = season
|
||||
|
||||
# Фильтр по периоду (годы)
|
||||
if year_from:
|
||||
query = query.filter(
|
||||
db.func.extract('year', Case.created_at) >= year_from
|
||||
)
|
||||
filters_applied['year_from'] = year_from
|
||||
|
||||
if year_to:
|
||||
query = query.filter(
|
||||
db.func.extract('year', Case.created_at) <= year_to
|
||||
)
|
||||
filters_applied['year_to'] = year_to
|
||||
|
||||
# Фильтр по исходу
|
||||
if outcome:
|
||||
if outcome == 'alive':
|
||||
query = query.filter(Case.found_alive == True)
|
||||
elif outcome == 'deceased':
|
||||
query = query.filter(Case.found_alive == False)
|
||||
filters_applied['outcome'] = outcome
|
||||
|
||||
cases = query.all()
|
||||
|
||||
# Формируем точки в зависимости от типа карты
|
||||
points = []
|
||||
|
||||
if map_type == 'all':
|
||||
# Все точки с одинаковой интенсивностью
|
||||
for case in cases:
|
||||
points.append({
|
||||
'lat': case.found_lat,
|
||||
'lon': case.found_lon,
|
||||
'intensity': 1.0,
|
||||
'case_id': str(case.id),
|
||||
'metadata': {
|
||||
'age': case.age_years,
|
||||
'season': case.season,
|
||||
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
|
||||
}
|
||||
})
|
||||
|
||||
elif map_type == 'age':
|
||||
# Интенсивность зависит от возраста (младше = выше интенсивность)
|
||||
for case in cases:
|
||||
# Младшие дети = выше интенсивность (более критично)
|
||||
intensity = max(0.3, 1.0 - (case.age_years / 18.0))
|
||||
points.append({
|
||||
'lat': case.found_lat,
|
||||
'lon': case.found_lon,
|
||||
'intensity': round(intensity, 2),
|
||||
'case_id': str(case.id),
|
||||
'metadata': {
|
||||
'age': case.age_years,
|
||||
'age_group': _get_age_group(case.age_years)
|
||||
}
|
||||
})
|
||||
|
||||
elif map_type == 'season':
|
||||
# Интенсивность зависит от сезона (зима = выше)
|
||||
season_intensity = {
|
||||
'зима': 1.0,
|
||||
'осень': 0.8,
|
||||
'весна': 0.6,
|
||||
'лето': 0.4
|
||||
}
|
||||
for case in cases:
|
||||
intensity = season_intensity.get(case.season, 0.5)
|
||||
points.append({
|
||||
'lat': case.found_lat,
|
||||
'lon': case.found_lon,
|
||||
'intensity': intensity,
|
||||
'case_id': str(case.id),
|
||||
'metadata': {
|
||||
'season': case.season
|
||||
}
|
||||
})
|
||||
|
||||
elif map_type == 'outcome':
|
||||
# Интенсивность зависит от исхода
|
||||
for case in cases:
|
||||
if case.found_alive is True:
|
||||
intensity = 0.5 # Зеленый (выжил)
|
||||
elif case.found_alive is False:
|
||||
intensity = 1.0 # Красный (погиб)
|
||||
else:
|
||||
intensity = 0.3 # Серый (неизвестно)
|
||||
|
||||
points.append({
|
||||
'lat': case.found_lat,
|
||||
'lon': case.found_lon,
|
||||
'intensity': intensity,
|
||||
'case_id': str(case.id),
|
||||
'metadata': {
|
||||
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown',
|
||||
'distance_km': case.found_distance_km
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
'points': points,
|
||||
'total': len(points),
|
||||
'filters_applied': filters_applied
|
||||
}
|
||||
|
||||
|
||||
def _get_age_group(age: int) -> str:
|
||||
"""Определяет возрастную группу"""
|
||||
if age <= 3:
|
||||
return '0-3'
|
||||
elif age <= 7:
|
||||
return '4-7'
|
||||
elif age <= 11:
|
||||
return '8-11'
|
||||
elif age <= 14:
|
||||
return '12-14'
|
||||
elif age <= 17:
|
||||
return '15-17'
|
||||
else:
|
||||
return '18+'
|
||||
|
||||
|
||||
# Кэшированная версия для быстрого доступа
|
||||
# Кэш на 1 час (3600 секунд), максимум 128 комбинаций параметров
|
||||
@lru_cache(maxsize=128)
|
||||
def _get_heatmap_cache_key(
|
||||
map_type: str,
|
||||
age_group: Optional[str],
|
||||
season: Optional[str],
|
||||
year_from: Optional[int],
|
||||
year_to: Optional[int],
|
||||
outcome: Optional[str],
|
||||
timestamp_hour: int # Меняется каждый час
|
||||
) -> str:
|
||||
"""Генерирует ключ кэша для heatmap"""
|
||||
return f"{map_type}_{age_group}_{season}_{year_from}_{year_to}_{outcome}_{timestamp_hour}"
|
||||
|
||||
|
||||
def get_heatmap_with_cache(
|
||||
db: Session,
|
||||
map_type: HeatmapType = 'all',
|
||||
age_group: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
year_from: Optional[int] = None,
|
||||
year_to: Optional[int] = None,
|
||||
outcome: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Обертка с кэшированием на 1 час.
|
||||
|
||||
Кэш инвалидируется каждый час автоматически через timestamp_hour.
|
||||
"""
|
||||
# Текущий час для кэша (меняется каждый час)
|
||||
current_hour = datetime.utcnow().hour
|
||||
|
||||
# Генерируем ключ кэша
|
||||
cache_key = _get_heatmap_cache_key(
|
||||
map_type,
|
||||
age_group,
|
||||
season,
|
||||
year_from,
|
||||
year_to,
|
||||
outcome,
|
||||
current_hour
|
||||
)
|
||||
|
||||
# Получаем данные (кэш работает через lru_cache на уровне ключа)
|
||||
return get_heatmap_data_cached(
|
||||
db,
|
||||
map_type,
|
||||
age_group,
|
||||
season,
|
||||
year_from,
|
||||
year_to,
|
||||
outcome
|
||||
)
|
||||
def heatmap() -> list[dict]:
|
||||
return db.heatmap()
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from backend import database as backend_database
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaseRecord:
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
age: int | None = None
|
||||
gender: str | None = None
|
||||
health_flags: list[str] = field(default_factory=list)
|
||||
elapsed_hours: int | None = None
|
||||
last_known_place: str | None = None
|
||||
direction: str | None = None
|
||||
terrain_primary: str | None = None
|
||||
weather: str | None = None
|
||||
resources: list[str] = field(default_factory=list)
|
||||
gps_lat: float | None = None
|
||||
gps_lon: float | None = None
|
||||
notes: str | None = None
|
||||
status: str = 'new'
|
||||
found_alive: bool | None = None
|
||||
found_distance_km: float | None = None
|
||||
found_direction: str | None = None
|
||||
found_location_type: str | None = None
|
||||
found_lat: float | None = None
|
||||
found_lon: float | None = None
|
||||
who_found: str | None = None
|
||||
raw_text: str | None = None
|
||||
analysis_log: str | None = None
|
||||
result: dict[str, Any] | None = None
|
||||
|
||||
def to_summary(self) -> dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat(),
|
||||
'age': self.age,
|
||||
'gender': self.gender,
|
||||
'status': self.status,
|
||||
'found_alive': self.found_alive,
|
||||
'found_distance_km': self.found_distance_km,
|
||||
'found_direction': self.found_direction,
|
||||
'found_location_type': self.found_location_type,
|
||||
'who_found': self.who_found,
|
||||
'last_known_place': self.last_known_place,
|
||||
'direction': self.direction,
|
||||
'terrain_primary': self.terrain_primary,
|
||||
'weather': self.weather,
|
||||
}
|
||||
|
||||
def to_detail(self) -> dict[str, Any]:
|
||||
data = self.to_summary()
|
||||
data.update({
|
||||
'health_flags': list(self.health_flags),
|
||||
'elapsed_hours': self.elapsed_hours,
|
||||
'resources': list(self.resources),
|
||||
'gps_lat': self.gps_lat,
|
||||
'gps_lon': self.gps_lon,
|
||||
'notes': self.notes,
|
||||
'found_lat': self.found_lat,
|
||||
'found_lon': self.found_lon,
|
||||
'raw_text': self.raw_text,
|
||||
'analysis_log': self.analysis_log,
|
||||
'result': self.result,
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
class TestDB:
|
||||
def __init__(self) -> None:
|
||||
self._cases: dict[str, CaseRecord] = {}
|
||||
|
||||
def create_case(self, **data: Any) -> CaseRecord:
|
||||
now = utcnow()
|
||||
data.pop('mobile', None)
|
||||
data.setdefault('status', 'new')
|
||||
case = CaseRecord(id=str(uuid4()), created_at=now, updated_at=now, **data)
|
||||
self._cases[case.id] = case
|
||||
return case
|
||||
|
||||
def list_cases(self) -> list[CaseRecord]:
|
||||
return sorted(self._cases.values(), key=lambda c: c.created_at, reverse=True)
|
||||
|
||||
def get_case(self, case_id: str) -> CaseRecord | None:
|
||||
return self._cases.get(case_id)
|
||||
|
||||
def update_case(self, case_id: str, **patch: Any) -> CaseRecord | None:
|
||||
case = self._cases.get(case_id)
|
||||
if not case:
|
||||
return None
|
||||
for key, value in patch.items():
|
||||
if hasattr(case, key) and value is not None:
|
||||
setattr(case, key, value)
|
||||
case.updated_at = utcnow()
|
||||
return case
|
||||
|
||||
def delete_case(self, case_id: str) -> bool:
|
||||
return self._cases.pop(case_id, None) is not None
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
cases = list(self._cases.values())
|
||||
total = len(cases)
|
||||
found_alive = sum(1 for c in cases if c.found_alive is True)
|
||||
found_deceased = sum(1 for c in cases if c.found_alive is False)
|
||||
unknown = total - found_alive - found_deceased
|
||||
distances = [c.found_distance_km for c in cases if c.found_distance_km is not None]
|
||||
median_distance = None
|
||||
if distances:
|
||||
distances = sorted(distances)
|
||||
mid = len(distances) // 2
|
||||
if len(distances) % 2:
|
||||
median_distance = distances[mid]
|
||||
else:
|
||||
median_distance = (distances[mid - 1] + distances[mid]) / 2
|
||||
return {
|
||||
'total_cases': total,
|
||||
'found_alive_count': found_alive,
|
||||
'found_deceased_count': found_deceased,
|
||||
'unknown_outcome_count': unknown,
|
||||
'median_found_distance_km': median_distance,
|
||||
'recent_activity_count': sum(1 for c in cases if (utcnow() - c.updated_at).days < 7),
|
||||
}
|
||||
|
||||
def heatmap(self) -> list[dict[str, Any]]:
|
||||
heat = []
|
||||
for case in self._cases.values():
|
||||
if case.gps_lat is not None and case.gps_lon is not None:
|
||||
heat.append({'lat': case.gps_lat, 'lon': case.gps_lon, 'weight': 1})
|
||||
return heat
|
||||
|
||||
|
||||
db = TestDB()
|
||||
backend_database.db = db
|
||||
|
||||
services_pkg = types.ModuleType('backend.services')
|
||||
services_pkg.__path__ = []
|
||||
stats_mod = types.ModuleType('backend.services.stats_service')
|
||||
stats_mod.summary = db.stats
|
||||
stats_mod.heatmap = db.heatmap
|
||||
services_pkg.stats_service = stats_mod
|
||||
sys.modules['backend.services'] = services_pkg
|
||||
sys.modules['backend.services.stats_service'] = stats_mod
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
from backend.database import db
|
||||
from backend.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
db._cases.clear() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_admin_cases_paginates_and_sorts_newest_first() -> None:
|
||||
db.create_case(age=10, gender='male', status='new')
|
||||
db.create_case(age=20, gender='female', status='new')
|
||||
third = db.create_case(age=30, gender='male', status='new')
|
||||
|
||||
response = client.get('/api/v1/admin/cases?page=1&page_size=2')
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['total'] == 3
|
||||
assert payload['page'] == 1
|
||||
assert payload['page_size'] == 2
|
||||
assert [item['id'] for item in payload['items']] == [third.id, payload['items'][1]['id']]
|
||||
|
||||
|
||||
def test_admin_cases_can_filter_by_status_and_age_range() -> None:
|
||||
db.create_case(age=14, gender='male', status='new')
|
||||
analyzed_young = db.create_case(age=19, gender='female', status='analyzed')
|
||||
db.create_case(age=24, gender='male', status='analyzed')
|
||||
db.create_case(age=29, gender='female', status='closed')
|
||||
|
||||
response = client.get('/api/v1/admin/cases?status=analyzed&age_min=18&age_max=21')
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['total'] == 1
|
||||
assert [item['id'] for item in payload['items']] == [analyzed_young.id]
|
||||
assert payload['items'][0]['age'] == 19
|
||||
assert payload['items'][0]['status'] == 'analyzed'
|
||||
|
||||
|
||||
def test_admin_case_patch_accepts_outcome_fields_and_note_alias() -> None:
|
||||
case = db.create_case(
|
||||
age=17,
|
||||
gender='female',
|
||||
status='analyzed',
|
||||
last_known_place='Forest edge',
|
||||
notes='initial note',
|
||||
)
|
||||
|
||||
response = client.patch(
|
||||
f'/api/v1/admin/cases/{case.id}',
|
||||
json={
|
||||
'found_alive': True,
|
||||
'found_distance_km': 3.5,
|
||||
'found_lat': 54.1,
|
||||
'found_lon': 37.2,
|
||||
'who_found': 'Search team 4',
|
||||
'note': 'Found near the logging road',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['found_alive'] is True
|
||||
assert payload['found_distance_km'] == 3.5
|
||||
assert payload['found_lat'] == 54.1
|
||||
assert payload['found_lon'] == 37.2
|
||||
assert payload['who_found'] == 'Search team 4'
|
||||
assert payload['notes'] == 'Found near the logging road'
|
||||
assert payload['last_known_place'] == 'Forest edge'
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
from backend.database import db
|
||||
from backend.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
db._cases.clear() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_admin_dashboard_reflects_outcomes_and_distance_median() -> None:
|
||||
db.create_case(age=12, gender='female', status='new')
|
||||
db.create_case(age=17, gender='male', status='analyzed', found_alive=True, found_distance_km=2.0)
|
||||
db.create_case(age=19, gender='female', status='closed', found_alive=False, found_distance_km=6.0)
|
||||
|
||||
response = client.get('/api/v1/admin/dashboard')
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['total_cases'] == 3
|
||||
assert payload['found_alive_count'] == 1
|
||||
assert payload['found_deceased_count'] == 1
|
||||
assert payload['unknown_outcome_count'] == 1
|
||||
assert payload['median_found_distance_km'] == 4.0
|
||||
assert payload['recent_activity_count'] == 3
|
||||
|
||||
|
||||
def test_admin_dashboard_is_zero_safe_when_no_cases_exist() -> None:
|
||||
response = client.get('/api/v1/admin/dashboard')
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['total_cases'] == 0
|
||||
assert payload['found_alive_count'] == 0
|
||||
assert payload['found_deceased_count'] == 0
|
||||
assert payload['unknown_outcome_count'] == 0
|
||||
assert payload['median_found_distance_km'] is None
|
||||
assert payload['recent_activity_count'] == 0
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
from io import BytesIO
|
||||
import zipfile
|
||||
|
||||
from backend.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def build_docx(paragraphs: list[str]) -> BytesIO:
|
||||
document_xml = """<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
{paras}
|
||||
<w:sectPr>
|
||||
<w:pgSz w:w="11906" w:h="16838"/>
|
||||
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/>
|
||||
</w:sectPr>
|
||||
</w:body>
|
||||
</w:document>
|
||||
"""
|
||||
paras = "\n".join(f"<w:p><w:r><w:t>{paragraph}</w:t></w:r></w:p>" for paragraph in paragraphs)
|
||||
buffer = BytesIO()
|
||||
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('[Content_Types].xml', """<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
</Types>
|
||||
""")
|
||||
archive.writestr('_rels/.rels', """<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>
|
||||
""")
|
||||
archive.writestr('word/document.xml', document_xml.format(paras=paras))
|
||||
buffer.seek(0)
|
||||
buffer.name = 'sample-report.docx'
|
||||
return buffer
|
||||
|
||||
|
||||
def test_admin_parse_doc_extracts_case_preview_from_docx() -> None:
|
||||
file_obj = build_docx([
|
||||
'Case ID: 124',
|
||||
'Age: 16',
|
||||
'Gender: female',
|
||||
'Status: analyzed',
|
||||
'Found alive: yes',
|
||||
'Found distance km: 3.5',
|
||||
'Who found: Search team 4',
|
||||
'Notes: Found near the logging road',
|
||||
])
|
||||
|
||||
response = client.post(
|
||||
'/api/v1/admin/parse-doc',
|
||||
files={'file': ('sample-report.docx', file_obj, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['parsed'] is True
|
||||
assert payload['filename'] == 'sample-report.docx'
|
||||
assert payload['preview']['case_id'] == '124'
|
||||
assert payload['preview']['case']['age'] == 16
|
||||
assert payload['preview']['case']['gender'] == 'female'
|
||||
assert payload['preview']['case']['status'] == 'analyzed'
|
||||
assert payload['preview']['case']['found_alive'] is True
|
||||
assert payload['preview']['case']['found_distance_km'] == 3.5
|
||||
assert payload['preview']['case']['who_found'] == 'Search team 4'
|
||||
assert payload['preview']['case']['notes'] == 'Found near the logging road'
|
||||
assert 'Case ID: 124' in payload['raw_text']
|
||||
Reference in New Issue
Block a user