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
+37 -7
View File
@@ -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
View File
@@ -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'}
+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,
}
+110 -239
View File
@@ -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
+6 -583
View File
@@ -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()
+162
View File
@@ -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
+73
View File
@@ -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'
+40
View File
@@ -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
+73
View File
@@ -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']