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