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
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
import os
|
import os
|
||||||
|
|
||||||
DATABASE_URL = os.getenv(
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@postgres:5432/vector_mchs")
|
||||||
"DATABASE_URL",
|
|
||||||
"postgresql://postgres:postgres@postgres:5432/vector_mchs"
|
|
||||||
)
|
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL, echo=False)
|
engine = create_engine(DATABASE_URL, echo=False)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
@@ -24,3 +18,39 @@ def get_db():
|
|||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
Base.metadata.create_all(bind=engine)
|
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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=cors_origins,
|
allow_origins=['*'],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=['*'],
|
||||||
allow_headers=["*"],
|
allow_headers=['*'],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"])
|
app.include_router(health_router)
|
||||||
app.include_router(cases.router, prefix="/api/v1", tags=["Cases"])
|
app.include_router(analyze_router)
|
||||||
app.include_router(analyze.router, prefix="/api/v1/analyze", tags=["Analysis"])
|
app.include_router(cases_router)
|
||||||
app.include_router(stats.router, prefix="/api/v1/stats", tags=["Statistics"])
|
app.include_router(stats_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get('/')
|
||||||
async def root():
|
def root() -> dict[str, str]:
|
||||||
return {
|
return {'name': 'Vector API', 'status': 'ok'}
|
||||||
"message": "SAR-MCHS API",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"docs": "/docs"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health_check():
|
|
||||||
return {"status": "healthy"}
|
|
||||||
|
|||||||
@@ -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 datetime import datetime
|
||||||
from uuid import UUID
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class CaseCreate(BaseModel):
|
class CaseCreate(BaseModel):
|
||||||
"""Schema for creating a new case"""
|
age: int | None = None
|
||||||
|
gender: str | None = None
|
||||||
# Ребёнок (Шаг 1)
|
health_flags: list[str] = Field(default_factory=list)
|
||||||
child_name: Optional[str] = None
|
elapsed_hours: int | None = None
|
||||||
age_years: int = Field(..., ge=0, le=18)
|
last_known_place: str | None = None
|
||||||
gender: Optional[str] = Field(None, pattern="^[МЖ]$")
|
direction: str | None = None
|
||||||
height_build: Optional[str] = None
|
terrain_primary: str | None = None
|
||||||
clothes_upper: Optional[str] = None
|
weather: str | None = None
|
||||||
clothes_lower: Optional[str] = None
|
resources: list[str] = Field(default_factory=list)
|
||||||
shoes: Optional[str] = None
|
gps: dict[str, float] | None = None
|
||||||
clothes_description: Optional[str] = None
|
notes: str | None = None
|
||||||
special_marks: Optional[str] = None
|
note: str | None = None
|
||||||
phone_status: Optional[str] = Field(None, pattern="^(answers|silent|none)$")
|
status: str = 'new'
|
||||||
|
found_alive: bool | None = None
|
||||||
# Здоровье (Шаг 2)
|
found_distance_km: float | None = None
|
||||||
has_diagnosis: bool = False
|
found_direction: str | None = None
|
||||||
diagnosis_type: Optional[List[str]] = None
|
found_location_type: str | None = None
|
||||||
fitness_level: Optional[str] = Field(None, pattern="^(low|medium|high)$")
|
found_lat: float | None = None
|
||||||
has_transport: str = Field(default="none", pattern="^(none|bike|scooter|other)$")
|
found_lon: float | None = None
|
||||||
cant_swim: bool = False
|
who_found: str | None = None
|
||||||
|
mobile: 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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class CaseOut(BaseModel):
|
class CaseUpdate(BaseModel):
|
||||||
"""Schema for case output"""
|
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
|
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
|
status: str
|
||||||
|
found_alive: bool | None = None
|
||||||
# Ребёнок
|
found_distance_km: float | None = None
|
||||||
child_name: Optional[str] = None
|
found_direction: str | None = None
|
||||||
age_years: int
|
found_location_type: str | None = None
|
||||||
gender: Optional[str] = None
|
found_lat: float | None = None
|
||||||
height_build: Optional[str] = None
|
found_lon: float | None = None
|
||||||
clothes_upper: Optional[str] = None
|
who_found: str | None = None
|
||||||
clothes_lower: Optional[str] = None
|
raw_text: str | None = None
|
||||||
shoes: Optional[str] = None
|
analysis_log: str | None = None
|
||||||
clothes_description: Optional[str] = None
|
result: dict[str, Any] | None = 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)
|
|
||||||
|
|
||||||
|
|
||||||
class SearchZone(BaseModel):
|
class CaseListResponse(BaseModel):
|
||||||
"""Search zone with priority and details"""
|
items: list[CaseResponse]
|
||||||
priority: int = Field(..., ge=1, le=3)
|
total: int
|
||||||
name: str
|
page: int
|
||||||
direction: str
|
page_size: int
|
||||||
distance_km: float
|
|
||||||
score: float
|
|
||||||
reasoning: str
|
|
||||||
coordinates: Optional[List[List[float]]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BehavioralProfile(BaseModel):
|
class DashboardResponse(BaseModel):
|
||||||
"""Active behavioral profile"""
|
total_cases: int
|
||||||
type: str
|
found_alive_count: int
|
||||||
title: str
|
found_deceased_count: int
|
||||||
recommendations: List[str]
|
unknown_outcome_count: int
|
||||||
modifiers: Dict[str, float]
|
median_found_distance_km: float | None
|
||||||
|
recent_activity_count: int
|
||||||
|
|
||||||
|
|
||||||
class AnalysisResult(BaseModel):
|
class RecommendationRequest(BaseModel):
|
||||||
"""Complete analysis result"""
|
age: int | None = None
|
||||||
|
gender: str | None = None
|
||||||
case_id: UUID
|
health_flags: list[str] = Field(default_factory=list)
|
||||||
urgency_level: str = Field(..., pattern="^(КРИТИЧЕСКИЙ|ВЫСОКИЙ|УМЕРЕННЫЙ)$")
|
elapsed_hours: int | None = None
|
||||||
urgency_reason: str
|
terrain_primary: str | None = None
|
||||||
time_window_hours: Optional[float] = None
|
weather: str | None = None
|
||||||
|
resources: list[str] = Field(default_factory=list)
|
||||||
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 UserCreate(BaseModel):
|
class AnalyzeResponse(BaseModel):
|
||||||
"""Schema for creating a new user"""
|
urgency: str
|
||||||
username: str = Field(..., min_length=3, max_length=100)
|
primary_zones: list[dict[str, Any]]
|
||||||
email: str = Field(..., pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
immediate_actions: list[str]
|
||||||
password: str = Field(..., min_length=8)
|
behavioral_prediction: str
|
||||||
full_name: Optional[str] = None
|
psychotype_recommendations: dict[str, Any]
|
||||||
role: str = Field(default="operator", pattern="^(operator|field|admin)$")
|
key_locations: list[str]
|
||||||
|
case_id: str
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class ParseDocResponse(BaseModel):
|
||||||
"""Schema for user output"""
|
filename: str
|
||||||
id: UUID
|
parsed: bool
|
||||||
username: str
|
preview: dict[str, Any]
|
||||||
email: str
|
raw_text: str | None = None
|
||||||
full_name: Optional[str] = None
|
|
||||||
role: str
|
|
||||||
is_active: bool
|
|
||||||
created_at: datetime
|
|
||||||
last_login: Optional[datetime] = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|||||||
@@ -1,588 +1,11 @@
|
|||||||
"""
|
from __future__ import annotations
|
||||||
Statistics service for case analysis and dashboard aggregates.
|
|
||||||
|
|
||||||
Provides statistical recommendations based on historical data:
|
from backend.database import db
|
||||||
- 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
|
|
||||||
|
|
||||||
|
|
||||||
class DirectionFrequency(BaseModel):
|
def summary() -> dict:
|
||||||
"""Direction frequency statistics"""
|
return db.stats()
|
||||||
direction: str
|
|
||||||
count: int
|
|
||||||
percentage: float
|
|
||||||
|
|
||||||
|
|
||||||
class StatisticalRecommendation(BaseModel):
|
def heatmap() -> list[dict]:
|
||||||
"""Statistical recommendation based on historical data"""
|
return db.heatmap()
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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']
|
||||||
@@ -1,332 +1,439 @@
|
|||||||
/* Админка — палитра из §5 контекста */
|
|
||||||
.admin-dashboard {
|
.admin-dashboard {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 16px 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 24px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav button:hover {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav button.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-content {
|
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
max-width: 1400px;
|
background: linear-gradient(180deg, #0d1117 0%, #111827 100%);
|
||||||
margin: 0 auto;
|
color: #e6edf3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.admin-hero {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card h3 {
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-change {
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-change.positive {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-change.negative {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cases-table {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-header {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #21262d;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #0d1117;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-header h2 {
|
.admin-kicker {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #ff7b42;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-subtitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
color: #8b949e;
|
||||||
color: var(--text-primary);
|
line-height: 1.5;
|
||||||
font-weight: 600;
|
max-width: 760px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-actions {
|
.admin-hero-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.admin-metrics-grid {
|
||||||
padding: 8px 16px;
|
display: grid;
|
||||||
background: var(--accent);
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
color: white;
|
gap: 12px;
|
||||||
border: none;
|
margin-bottom: 20px;
|
||||||
border-radius: 6px;
|
}
|
||||||
|
|
||||||
|
.admin-metric-card,
|
||||||
|
.admin-panel {
|
||||||
|
border: 1px solid #21262d;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #0d1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metric-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metric-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metric-card strong {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-upload-panel {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-upload-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-upload-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-file-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: min(100%, 320px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-file-picker input {
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #161b22;
|
||||||
|
color: #e6edf3;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-upload-hint {
|
||||||
|
color: #8b949e;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-upload-preview {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-upload-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-upload-grid h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-preview-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-preview-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #21262d;
|
||||||
|
background: #161b22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-preview-list strong {
|
||||||
|
text-transform: lowercase;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-preview-list span {
|
||||||
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-raw-details {
|
||||||
|
border: 1px solid #21262d;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #161b22;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-raw-details summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.admin-raw-text {
|
||||||
background: var(--accent-hover);
|
margin: 12px 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: #c9d1d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.admin-panel-header {
|
||||||
padding: 8px 16px;
|
display: flex;
|
||||||
background: transparent;
|
justify-content: space-between;
|
||||||
color: var(--text-secondary);
|
gap: 12px;
|
||||||
border: 1px solid var(--border);
|
align-items: flex-start;
|
||||||
border-radius: 6px;
|
margin-bottom: 16px;
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.admin-panel-header h2 {
|
||||||
background: var(--bg-primary);
|
margin: 0 0 6px;
|
||||||
color: var(--text-primary);
|
font-size: 20px;
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
.admin-panel-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-filters-grid,
|
||||||
|
.admin-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.7fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-panel,
|
||||||
|
.admin-editor-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
thead {
|
.admin-table th,
|
||||||
background: var(--bg-primary);
|
.admin-table td {
|
||||||
}
|
padding: 12px 10px;
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
thead th {
|
|
||||||
padding: 12px 20px;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
color: #8b949e;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr {
|
.admin-table tbody tr {
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr:hover {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody td {
|
|
||||||
padding: 16px 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.active {
|
|
||||||
background: var(--warning);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.closed {
|
|
||||||
background: var(--success);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.archived {
|
|
||||||
background: var(--text-secondary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Графики */
|
|
||||||
.chart-container {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-container h3 {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Цвета для графиков из палитры админки */
|
|
||||||
.chart-distance {
|
|
||||||
color: var(--chart-distance);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-terrain {
|
|
||||||
color: var(--chart-terrain);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-outcome {
|
|
||||||
color: var(--chart-outcome);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Форма загрузки документов */
|
|
||||||
.upload-zone {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 2px dashed var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 40px;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-zone:hover {
|
.admin-table tbody tr:hover,
|
||||||
border-color: var(--accent);
|
.admin-table tbody tr.is-selected {
|
||||||
background: var(--bg-primary);
|
background: rgba(255, 77, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-zone.dragging {
|
.admin-empty-state {
|
||||||
border-color: var(--accent);
|
padding: 28px 12px;
|
||||||
background: var(--bg-primary);
|
text-align: center;
|
||||||
|
color: #8b949e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-icon {
|
.admin-pagination {
|
||||||
font-size: 48px;
|
display: flex;
|
||||||
color: var(--text-secondary);
|
justify-content: space-between;
|
||||||
margin-bottom: 16px;
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-text {
|
.admin-pagination-summary {
|
||||||
font-size: 16px;
|
color: #8b949e;
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-hint {
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
.admin-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-field-wide {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-field span {
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-field input,
|
||||||
|
.admin-field select,
|
||||||
|
.admin-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #161b22;
|
||||||
|
color: #e6edf3;
|
||||||
|
padding: 12px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-field textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-case-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-case-meta span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-case-meta strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-empty-editor {
|
||||||
|
padding: 28px 12px;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-alert {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-alert-error {
|
||||||
|
background: rgba(248, 81, 73, 0.12);
|
||||||
|
border: 1px solid #f85149;
|
||||||
|
color: #ffb4b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-alert-success {
|
||||||
|
background: rgba(46, 160, 67, 0.12);
|
||||||
|
border: 1px solid #2ea043;
|
||||||
|
color: #b8f7c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-primary-button,
|
||||||
|
.admin-secondary-button {
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-primary-button {
|
||||||
|
background: #ff4d00;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-secondary-button {
|
||||||
|
background: #161b22;
|
||||||
|
border-color: #30363d;
|
||||||
|
color: #e6edf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-secondary-button:disabled,
|
||||||
|
.admin-primary-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge-neutral {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge-success {
|
||||||
|
background: rgba(46, 160, 67, 0.15);
|
||||||
|
border: 1px solid #2ea043;
|
||||||
|
color: #b8f7c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge-danger {
|
||||||
|
background: rgba(248, 81, 73, 0.15);
|
||||||
|
border: 1px solid #f85149;
|
||||||
|
color: #ffb4b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.admin-metrics-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.admin-header {
|
.admin-dashboard {
|
||||||
flex-direction: column;
|
padding: 14px;
|
||||||
gap: 16px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav {
|
.admin-hero {
|
||||||
width: 100%;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav button {
|
.admin-metrics-grid,
|
||||||
width: 100%;
|
.admin-filters-grid,
|
||||||
}
|
.admin-form-grid,
|
||||||
|
.admin-case-meta {
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-header {
|
.admin-pagination {
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-actions {
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-actions button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead th,
|
|
||||||
tbody td {
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,682 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
|
||||||
import CasesList from '../components/admin/CasesList';
|
|
||||||
import CaseDetail from '../components/admin/CaseDetail';
|
|
||||||
import HeatmapView from '../components/admin/HeatmapView';
|
|
||||||
import Statistics from '../components/admin/Statistics';
|
|
||||||
import './AdminDashboard.css';
|
import './AdminDashboard.css';
|
||||||
|
|
||||||
const AdminDashboard = () => {
|
const PAGE_SIZE = 10;
|
||||||
const location = useLocation();
|
|
||||||
|
const OUTCOME_OPTIONS = [
|
||||||
|
{ value: '', label: 'Все' },
|
||||||
|
{ value: 'new', label: 'new' },
|
||||||
|
{ value: 'analyzed', label: 'analyzed' },
|
||||||
|
{ value: 'closed', label: 'closed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString('ru-RU', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ children, tone = 'neutral' }) {
|
||||||
|
return <span className={`admin-badge admin-badge-${tone}`}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminDashboard() {
|
||||||
|
const [cases, setCases] = useState([]);
|
||||||
|
const [dashboard, setDashboard] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dashboardLoading, setDashboardLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [filters, setFilters] = useState({ status: '', ageMin: '', ageMax: '' });
|
||||||
|
const [selectedCaseId, setSelectedCaseId] = useState('');
|
||||||
|
const [editForm, setEditForm] = useState({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState('');
|
||||||
|
const [saveMessage, setSaveMessage] = useState('');
|
||||||
|
const [uploadFile, setUploadFile] = useState(null);
|
||||||
|
const [uploadLoading, setUploadLoading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState('');
|
||||||
|
const [uploadMessage, setUploadMessage] = useState('');
|
||||||
|
const [parsePreview, setParsePreview] = useState(null);
|
||||||
|
const [parseRawText, setParseRawText] = useState('');
|
||||||
|
const [parseCaseId, setParseCaseId] = useState('');
|
||||||
|
const [parseCaseFields, setParseCaseFields] = useState({});
|
||||||
|
|
||||||
|
const selectedCase = useMemo(
|
||||||
|
() => cases.find((item) => item.id === selectedCaseId) || null,
|
||||||
|
[cases, selectedCaseId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function loadCases() {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('page_size', String(PAGE_SIZE));
|
||||||
|
if (filters.status) params.set('status', filters.status);
|
||||||
|
if (filters.ageMin !== '') params.set('age_min', filters.ageMin);
|
||||||
|
if (filters.ageMax !== '') params.set('age_max', filters.ageMax);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/admin/cases?${params.toString()}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Не удалось загрузить список кейсов (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
setCases(payload.items || []);
|
||||||
|
setTotal(payload.total || 0);
|
||||||
|
if ((payload.items || []).length && !selectedCaseId) {
|
||||||
|
setSelectedCaseId(payload.items[0].id);
|
||||||
|
setEditForm(payload.items[0]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
setError(err.message || 'Ошибка загрузки админ-таблицы');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCases();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [filters.ageMax, filters.ageMin, filters.status, page, selectedCaseId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
setDashboardLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/admin/dashboard', { signal: controller.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Не удалось загрузить dashboard (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
setDashboard(payload);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
setDashboard(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDashboardLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDashboard();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCase) return;
|
||||||
|
setEditForm(selectedCase);
|
||||||
|
}, [selectedCase]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
const parsePreviewFields = useMemo(() => {
|
||||||
|
if (!parsePreview) return [];
|
||||||
|
return Object.entries(parsePreview).filter(([key]) => key !== 'case' && key !== 'raw_text');
|
||||||
|
}, [parsePreview]);
|
||||||
|
|
||||||
|
function resetParseState() {
|
||||||
|
setUploadFile(null);
|
||||||
|
setUploadError('');
|
||||||
|
setUploadMessage('');
|
||||||
|
setParsePreview(null);
|
||||||
|
setParseRawText('');
|
||||||
|
setParseCaseId('');
|
||||||
|
setParseCaseFields({});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadFile(event) {
|
||||||
|
setUploadFile(event.target.files?.[0] || null);
|
||||||
|
setUploadError('');
|
||||||
|
setUploadMessage('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadPreview(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!uploadFile) {
|
||||||
|
setUploadError('Выбери .docx файл для предпросмотра');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadLoading(true);
|
||||||
|
setUploadError('');
|
||||||
|
setUploadMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', uploadFile);
|
||||||
|
|
||||||
|
const response = await fetch('/api/v1/admin/parse-doc', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || `Не удалось распарсить документ (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
setParsePreview(payload.preview || {});
|
||||||
|
setParseRawText(payload.raw_text || '');
|
||||||
|
setParseCaseId(payload.preview?.case_id || '');
|
||||||
|
setParseCaseFields(payload.preview?.case || {});
|
||||||
|
setUploadMessage('Документ распознан, можно проверить preview ниже');
|
||||||
|
} catch (err) {
|
||||||
|
setUploadError(err.message || 'Ошибка загрузки файла');
|
||||||
|
} finally {
|
||||||
|
setUploadLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldFromPreview(name, fallback = '') {
|
||||||
|
const value = parseCaseFields?.[name];
|
||||||
|
return value === undefined || value === null || value === '' ? fallback : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportPreview(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!parseCaseFields || Object.keys(parseCaseFields).length === 0) {
|
||||||
|
setUploadError('Сначала загрузи и распарсь .docx файл');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadLoading(true);
|
||||||
|
setUploadError('');
|
||||||
|
setUploadMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
age: parseCaseFields.age ?? null,
|
||||||
|
gender: parseCaseFields.gender ?? null,
|
||||||
|
status: parseCaseFields.status ?? 'new',
|
||||||
|
found_alive: parseCaseFields.found_alive ?? null,
|
||||||
|
found_distance_km: parseCaseFields.found_distance_km ?? null,
|
||||||
|
found_lat: parseCaseFields.found_lat ?? null,
|
||||||
|
found_lon: parseCaseFields.found_lon ?? null,
|
||||||
|
who_found: parseCaseFields.who_found ?? null,
|
||||||
|
note: parseCaseFields.notes ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/v1/cases', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || `Не удалось создать кейс (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await response.json();
|
||||||
|
setUploadMessage(`Кейс создан: ${created.id}`);
|
||||||
|
resetParseState();
|
||||||
|
setSaveMessage('Импорт из .docx завершён');
|
||||||
|
setPage(1);
|
||||||
|
} catch (err) {
|
||||||
|
setUploadError(err.message || 'Ошибка импорта');
|
||||||
|
} finally {
|
||||||
|
setUploadLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilterChange(event) {
|
||||||
|
const { name, value } = event.target;
|
||||||
|
setPage(1);
|
||||||
|
setSelectedCaseId('');
|
||||||
|
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectCase(caseId) {
|
||||||
|
setSelectedCaseId(caseId);
|
||||||
|
const nextCase = cases.find((item) => item.id === caseId);
|
||||||
|
if (nextCase) {
|
||||||
|
setEditForm(nextCase);
|
||||||
|
setSaveMessage('');
|
||||||
|
setSaveError('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEditForm(name, value) {
|
||||||
|
setEditForm((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedCaseId) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError('');
|
||||||
|
setSaveMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
status: editForm.status ?? null,
|
||||||
|
found_alive: editForm.found_alive ?? null,
|
||||||
|
found_distance_km: editForm.found_distance_km === '' ? null : editForm.found_distance_km,
|
||||||
|
found_direction: editForm.found_direction ?? null,
|
||||||
|
found_location_type: editForm.found_location_type ?? null,
|
||||||
|
found_lat: editForm.found_lat === '' ? null : editForm.found_lat,
|
||||||
|
found_lon: editForm.found_lon === '' ? null : editForm.found_lon,
|
||||||
|
who_found: editForm.who_found ?? null,
|
||||||
|
note: editForm.notes ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/admin/cases/${selectedCaseId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || `Не удалось сохранить изменения (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await response.json();
|
||||||
|
setSaveMessage('Изменения сохранены');
|
||||||
|
setCases((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
|
||||||
|
setEditForm(updated);
|
||||||
|
setDashboardLoading(true);
|
||||||
|
try {
|
||||||
|
const dashboardResponse = await fetch('/api/v1/admin/dashboard');
|
||||||
|
if (dashboardResponse.ok) {
|
||||||
|
setDashboard(await dashboardResponse.json());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDashboardLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err.message || 'Ошибка сохранения');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-dashboard">
|
<div className="admin-dashboard">
|
||||||
<aside className="sidebar">
|
<header className="admin-hero">
|
||||||
<div className="sidebar-header">
|
<div>
|
||||||
<h1>SAR МЧС</h1>
|
<p className="admin-kicker">Vector / admin</p>
|
||||||
<p className="subtitle">Система анализа поисков</p>
|
<h1>Панель администрирования</h1>
|
||||||
|
<p className="admin-subtitle">
|
||||||
|
Список кейсов, быстрый просмотр исходов и редактирование административных полей.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-hero-actions">
|
||||||
|
<button type="button" className="admin-secondary-button" onClick={() => setPage(1)}>
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="admin-metrics-grid">
|
||||||
|
<article className="admin-metric-card">
|
||||||
|
<span className="admin-metric-label">Всего кейсов</span>
|
||||||
|
<strong>{dashboardLoading ? '…' : dashboard?.total_cases ?? 0}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="admin-metric-card">
|
||||||
|
<span className="admin-metric-label">Найден живым</span>
|
||||||
|
<strong>{dashboardLoading ? '…' : dashboard?.found_alive_count ?? 0}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="admin-metric-card">
|
||||||
|
<span className="admin-metric-label">Найден погибшим</span>
|
||||||
|
<strong>{dashboardLoading ? '…' : dashboard?.found_deceased_count ?? 0}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="admin-metric-card">
|
||||||
|
<span className="admin-metric-label">Неизвестный исход</span>
|
||||||
|
<strong>{dashboardLoading ? '…' : dashboard?.unknown_outcome_count ?? 0}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="admin-metric-card">
|
||||||
|
<span className="admin-metric-label">Медиана дистанции</span>
|
||||||
|
<strong>
|
||||||
|
{dashboardLoading ? '…' : dashboard?.median_found_distance_km ?? '—'}
|
||||||
|
</strong>
|
||||||
|
</article>
|
||||||
|
<article className="admin-metric-card">
|
||||||
|
<span className="admin-metric-label">Активность 7 дней</span>
|
||||||
|
<strong>{dashboardLoading ? '…' : dashboard?.recent_activity_count ?? 0}</strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-panel admin-filters-panel">
|
||||||
|
<div className="admin-panel-header">
|
||||||
|
<h2>Фильтры</h2>
|
||||||
|
<p>Список кейсов можно сужать по статусу и возрасту.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-filters-grid">
|
||||||
|
<label className="admin-field">
|
||||||
|
<span>Статус</span>
|
||||||
|
<select name="status" value={filters.status} onChange={handleFilterChange}>
|
||||||
|
{OUTCOME_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value || 'all'} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="admin-field">
|
||||||
|
<span>Возраст от</span>
|
||||||
|
<input
|
||||||
|
name="ageMin"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={filters.ageMin}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
placeholder="18"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-field">
|
||||||
|
<span>Возраст до</span>
|
||||||
|
<input
|
||||||
|
name="ageMax"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={filters.ageMax}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
placeholder="25"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-panel admin-upload-panel">
|
||||||
|
<div className="admin-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Импорт отчёта .docx</h2>
|
||||||
|
<p>Загрузите документ, проверьте preview и импортируйте извлечённые поля в новый кейс.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-upload-hint">
|
||||||
|
{parseCaseId ? `Preview case_id: ${parseCaseId}` : 'Ждём файл'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="sidebar-nav">
|
<form className="admin-upload-form" onSubmit={handleUploadPreview}>
|
||||||
<Link
|
<div className="admin-upload-row">
|
||||||
to="/admin/cases"
|
<label className="admin-file-picker">
|
||||||
className={location.pathname.startsWith('/admin/cases') ? 'active' : ''}
|
<span>Выбери .docx</span>
|
||||||
>
|
<input type="file" accept=".docx" onChange={handleUploadFile} />
|
||||||
<span className="icon">📋</span>
|
</label>
|
||||||
Карточки
|
<button type="submit" className="admin-secondary-button" disabled={uploadLoading}>
|
||||||
</Link>
|
{uploadLoading ? 'Распознаю…' : 'Распознать'}
|
||||||
<Link
|
</button>
|
||||||
to="/admin/heatmap"
|
<button type="button" className="admin-primary-button" onClick={handleImportPreview} disabled={uploadLoading}>
|
||||||
className={location.pathname === '/admin/heatmap' ? 'active' : ''}
|
Импортировать в кейс
|
||||||
>
|
</button>
|
||||||
<span className="icon">🗺️</span>
|
|
||||||
Тепловая карта
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/admin/statistics"
|
|
||||||
className={location.pathname === '/admin/statistics' ? 'active' : ''}
|
|
||||||
>
|
|
||||||
<span className="icon">📊</span>
|
|
||||||
Статистика
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
|
||||||
<a href="/mobile" target="_blank" rel="noopener noreferrer" className="mobile-link">
|
|
||||||
<span className="icon">📱</span>
|
|
||||||
Мобильная форма
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{uploadError ? <div className="admin-alert admin-alert-error">{uploadError}</div> : null}
|
||||||
|
{uploadMessage ? <div className="admin-alert admin-alert-success">{uploadMessage}</div> : null}
|
||||||
|
|
||||||
|
{parsePreview ? (
|
||||||
|
<div className="admin-upload-preview">
|
||||||
|
<div className="admin-upload-grid">
|
||||||
|
<div>
|
||||||
|
<h3>Распознанные поля</h3>
|
||||||
|
<ul className="admin-preview-list">
|
||||||
|
{parsePreviewFields.map(([key, value]) => (
|
||||||
|
<li key={key}>
|
||||||
|
<strong>{key}</strong>
|
||||||
|
<span>{typeof value === 'object' ? JSON.stringify(value) : String(value)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Кейс preview</h3>
|
||||||
|
<ul className="admin-preview-list">
|
||||||
|
<li><strong>age</strong><span>{fieldFromPreview('age', '—')}</span></li>
|
||||||
|
<li><strong>gender</strong><span>{fieldFromPreview('gender', '—')}</span></li>
|
||||||
|
<li><strong>status</strong><span>{fieldFromPreview('status', 'new')}</span></li>
|
||||||
|
<li><strong>found_alive</strong><span>{fieldFromPreview('found_alive', '—')}</span></li>
|
||||||
|
<li><strong>found_distance_km</strong><span>{fieldFromPreview('found_distance_km', '—')}</span></li>
|
||||||
|
<li><strong>who_found</strong><span>{fieldFromPreview('who_found', '—')}</span></li>
|
||||||
|
<li><strong>notes</strong><span>{fieldFromPreview('notes', '—')}</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{parseRawText ? (
|
||||||
|
<details className="admin-raw-details">
|
||||||
|
<summary>Показать raw text</summary>
|
||||||
|
<pre className="admin-raw-text">{parseRawText}</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error ? <div className="admin-alert admin-alert-error">{error}</div> : null}
|
||||||
|
|
||||||
|
<section className="admin-layout">
|
||||||
|
<div className="admin-panel admin-table-panel">
|
||||||
|
<div className="admin-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Кейсы</h2>
|
||||||
|
<p>
|
||||||
|
Показано {cases.length} из {total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-pagination-summary">
|
||||||
|
Страница {page} из {totalPages}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-table-wrap">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Возраст</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Исход</th>
|
||||||
|
<th>Дистанция</th>
|
||||||
|
<th>Место</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="7" className="admin-empty-state">
|
||||||
|
Загрузка…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : cases.length ? (
|
||||||
|
cases.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className={item.id === selectedCaseId ? 'is-selected' : ''}
|
||||||
|
onClick={() => handleSelectCase(item.id)}
|
||||||
|
>
|
||||||
|
<td className="admin-mono">{item.id.slice(0, 8)}</td>
|
||||||
|
<td>{formatDate(item.created_at)}</td>
|
||||||
|
<td>{item.age ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<Badge tone={item.status === 'closed' ? 'success' : 'neutral'}>{item.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{item.found_alive === true ? (
|
||||||
|
<Badge tone="success">alive</Badge>
|
||||||
|
) : item.found_alive === false ? (
|
||||||
|
<Badge tone="danger">deceased</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="neutral">unknown</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{item.found_distance_km ?? '—'}</td>
|
||||||
|
<td>{item.last_known_place || item.found_location_type || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="7" className="admin-empty-state">
|
||||||
|
Нет данных по выбранным фильтрам
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-pagination">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-secondary-button"
|
||||||
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-secondary-button"
|
||||||
|
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
Вперёд
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="admin-panel admin-editor-panel">
|
||||||
|
<div className="admin-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Редактирование исхода</h2>
|
||||||
|
<p>{selectedCase ? `Кейс ${selectedCase.id.slice(0, 8)}` : 'Выберите строку в таблице'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedCase ? (
|
||||||
|
<form className="admin-form" onSubmit={handleSave}>
|
||||||
|
<div className="admin-form-grid">
|
||||||
|
<label className="admin-field">
|
||||||
|
<span>Статус</span>
|
||||||
|
<input
|
||||||
|
value={editForm.status ?? ''}
|
||||||
|
onChange={(event) => updateEditForm('status', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-field">
|
||||||
|
<span>Кто нашёл</span>
|
||||||
|
<input
|
||||||
|
value={editForm.who_found ?? ''}
|
||||||
|
onChange={(event) => updateEditForm('who_found', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-field">
|
||||||
|
<span>Найден живым</span>
|
||||||
|
<select
|
||||||
|
value={editForm.found_alive === null || editForm.found_alive === undefined ? '' : String(editForm.found_alive)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
updateEditForm('found_alive', value === '' ? null : value === 'true');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Не указано</option>
|
||||||
|
<option value="true">Да</option>
|
||||||
|
<option value="false">Нет</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="admin-field">
|
||||||
|
<span>Дистанция, км</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={editForm.found_distance_km ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateEditForm('found_distance_km', event.target.value === '' ? '' : Number(event.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-field">
|
||||||
|
<span>Широта</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
value={editForm.found_lat ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateEditForm('found_lat', event.target.value === '' ? '' : Number(event.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-field">
|
||||||
|
<span>Долгота</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
value={editForm.found_lon ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateEditForm('found_lon', event.target.value === '' ? '' : Number(event.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="admin-field admin-field-wide">
|
||||||
|
<span>Тип точки</span>
|
||||||
|
<input
|
||||||
|
value={editForm.found_location_type ?? ''}
|
||||||
|
onChange={(event) => updateEditForm('found_location_type', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="admin-field admin-field-wide">
|
||||||
|
<span>Комментарий / note</span>
|
||||||
|
<textarea
|
||||||
|
rows="4"
|
||||||
|
value={editForm.notes ?? ''}
|
||||||
|
onChange={(event) => updateEditForm('notes', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{saveError ? <div className="admin-alert admin-alert-error">{saveError}</div> : null}
|
||||||
|
{saveMessage ? <div className="admin-alert admin-alert-success">{saveMessage}</div> : null}
|
||||||
|
|
||||||
|
<div className="admin-form-actions">
|
||||||
|
<button type="submit" className="admin-primary-button" disabled={saving}>
|
||||||
|
{saving ? 'Сохраняю…' : 'Сохранить исход'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-case-meta">
|
||||||
|
<div>
|
||||||
|
<span>Последнее обновление</span>
|
||||||
|
<strong>{formatDate(editForm.updated_at)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Исходное место</span>
|
||||||
|
<strong>{editForm.last_known_place || '—'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="admin-empty-editor">
|
||||||
|
<p>Нажми на строку в таблице, чтобы открыть карточку редактирования.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
</section>
|
||||||
<main className="main-content">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<CasesList />} />
|
|
||||||
<Route path="/cases" element={<CasesList />} />
|
|
||||||
<Route path="/cases/:id" element={<CaseDetail />} />
|
|
||||||
<Route path="/heatmap" element={<HeatmapView />} />
|
|
||||||
<Route path="/statistics" element={<Statistics />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default AdminDashboard;
|
export default AdminDashboard;
|
||||||
|
|||||||
Reference in New Issue
Block a user