Compare commits
96 Commits
5490f3c5f2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ef18f8e28f | |||
| cdfbf30232 | |||
| 31757f18b3 | |||
| 05d6b3ce68 | |||
| a1de5794e2 | |||
| 50a261e05e | |||
| 1fdcc04af6 | |||
| 9b6659a85a | |||
| c6be70ce36 | |||
| 2222b7ad5b | |||
| d5a270f3af | |||
| 1513bad538 | |||
| 1fe3b37d1a | |||
| c44bc46667 | |||
| 9115a0e7af | |||
| a7f8879034 | |||
| 0b5c70da73 | |||
| d65e171788 | |||
| 57f0f62d42 | |||
| b635131195 | |||
| a1d33a2b28 | |||
| 4fe60b0772 | |||
| 7d0a0e9a50 | |||
| 4d941b7310 | |||
| cbdce9625b | |||
| 6e9dfe8a6f | |||
| 91fe8358ac | |||
| 611ca95d3a | |||
| 5a86755e90 | |||
| af98ec94e2 | |||
| bd9e93df05 | |||
| d2860118d8 | |||
| 39b2e296d1 | |||
| cc2f597bf2 | |||
| 90213e0491 | |||
| ab3eebe274 | |||
| 5c3b8a1424 | |||
| 808ef93a52 | |||
| e694ad1a05 | |||
| 5a2c9a0688 | |||
| aefd33949c | |||
| 628a1d4536 | |||
| 15e7e27cb8 | |||
| 3a986b0b7c | |||
| 5794cb74a9 | |||
| c1bb7d5249 | |||
| b636389ee1 | |||
| 2aa45e8932 | |||
| 61308ed444 | |||
| ea19ed0d8f | |||
| c7fce52693 | |||
| 59f97c850e | |||
| 1b2c189ace | |||
| ee1265c259 | |||
| 333b827ba7 | |||
| e9c51bcfb2 | |||
| fa466fc5ed | |||
| 6ec9536203 | |||
| b29f964189 | |||
| 3d96c1f630 | |||
| d4f2cb4f20 | |||
| d762fd104c | |||
| 3360f7a8ac | |||
| 8e99ea0290 | |||
| d8723582c9 | |||
| 11015a7883 | |||
| 5bdb345e33 | |||
| c368482d1c | |||
| 5464ce370a | |||
| 10f00e2d2e | |||
| 0320cad063 | |||
| 32af8975f9 | |||
| 4531ac50b9 | |||
| a15b1f42e9 | |||
| f20080305d | |||
| 98e8e58023 | |||
| 2d264a5779 | |||
| 1119793145 | |||
| 3e38aae2be | |||
| d6e6277d85 | |||
| bef603415f | |||
| 25d852035a | |||
| 3e2692f8a5 | |||
| d0f309cdf6 | |||
| bef93d159c | |||
| c2ad5c1915 | |||
| 83e75e7c24 | |||
| b30ce86fa9 | |||
| 3a0d2b0c2d | |||
| 8b3a2cbf7e | |||
| 76278f5fe1 | |||
| 5a631f6553 | |||
| 0b033c402d | |||
| cd65116a2a | |||
| 0de5beeb64 | |||
| 6ca59a04d0 |
+5
-5
@@ -1,11 +1,11 @@
|
||||
# Anthropic API Key for Claude integration
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
# Database connection string
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/sar_mchs
|
||||
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/vector_mchs
|
||||
|
||||
# JWT secret for authentication (generate a secure random string)
|
||||
JWT_SECRET=your_jwt_secret_here_change_in_production
|
||||
|
||||
# CORS origins (comma-separated)
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8000
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# Примечание: внешний ИИ не используется (решение Виктора: ПД-данные
|
||||
# не покидают контур). Все расчёты локальные и детерминированные.
|
||||
@@ -4,3 +4,5 @@ __pycache__/
|
||||
.env
|
||||
|
||||
samples/
|
||||
/frontend/build/
|
||||
.venv-test/
|
||||
|
||||
+5
-5
@@ -10,7 +10,7 @@
|
||||
2. Geo service - построение зон поиска (8 направлений)
|
||||
3. Scoring service - оценка и ранжирование зон
|
||||
4. Psychotype service - определение психотипа (если есть данные)
|
||||
5. Claude service - интеллектуальный анализ (опционально)
|
||||
5. Rules analysis - детерминированные правила (urgency, зоны, действия; без LLM)
|
||||
6. Merge results - объединение результатов
|
||||
|
||||
**Request:**
|
||||
@@ -56,7 +56,7 @@
|
||||
"behavioral_prediction": "Умеренное движение",
|
||||
"summary": "Случай классифицирован как средняя срочность",
|
||||
"execution_time_ms": 21197.7,
|
||||
"services_used": ["distance", "geo", "psychotype", "claude"]
|
||||
"services_used": ["distance", "geo", "psychotype", "rules"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -80,13 +80,13 @@
|
||||
| Geo | ✅ Работает | Построение зон (21 сек) |
|
||||
| Scoring | ⚠️ Fallback | NoneType division error |
|
||||
| Psychotype | ✅ Работает | Определение + модификаторы |
|
||||
| Claude | ⚠️ Fallback | 403 Forbidden (нет API ключа) |
|
||||
| Rules | ✅ | локальный расчёт |
|
||||
|
||||
## Fallback механизмы
|
||||
|
||||
Если сервис недоступен, система использует fallback:
|
||||
- **Geo/Scoring fail** → создаются базовые 8 секторов
|
||||
- **Claude fail** → базовые рекомендации из scoring service
|
||||
- Правила всегда доступны: LLM в пайплайне нет (ПД не покидают контур)
|
||||
- **Psychotype fail** → анализ без модификаторов психотипа
|
||||
|
||||
## Тестирование
|
||||
@@ -100,7 +100,7 @@
|
||||
## Известные проблемы
|
||||
|
||||
1. **Scoring service**: Ошибка деления на None - нужно добавить проверки на None в scoring_service.py
|
||||
2. **Claude API**: Требуется настройка ANTHROPIC_API_KEY в .env
|
||||
2. **LLM не используется**: API-ключи не нужны, всё локально
|
||||
3. **Geo service**: Возвращает пустые зоны (возможно, проблема с Overpass API или кешем)
|
||||
|
||||
## Дата создания
|
||||
|
||||
@@ -1,117 +1,36 @@
|
||||
# Инструкция по развертыванию SAR-MCHS на LXC 108
|
||||
# Развёртывание ВЕКТОРа
|
||||
|
||||
## Текущее состояние
|
||||
**Актуальная документация:**
|
||||
|
||||
Проект развернут на LXC 108 (Lab) по адресу: **192.168.0.108**
|
||||
- **[SETUP_GUIDE.md](SETUP_GUIDE.md)** — полное руководство: требования, Docker, .env,
|
||||
миграции/сиды, OSM-геослой, пользователи, КОНТУР, HTTPS, обновление, бэкапы,
|
||||
мониторинг, диагностика, чек-листы (развёртывание §18, передача проекта §17а).
|
||||
- **[README.md](README.md)** — описание системы: архитектура, API, БД, RBAC,
|
||||
аудит, прикладное логирование (§13а).
|
||||
|
||||
## Доступ к сервисам
|
||||
## Текущее состояние (тестовый стенд)
|
||||
|
||||
- **Backend API**: http://192.168.0.108:8000
|
||||
- Swagger документация: http://192.168.0.108:8000/docs
|
||||
- ReDoc: http://192.168.0.108:8000/redoc
|
||||
Проект развёрнут на LXC 108 (Lab): **192.168.0.99**, путь `/root/vector`, docker compose.
|
||||
|
||||
- **Frontend**: http://192.168.0.108:3000
|
||||
| Сервис | Адрес |
|
||||
|--------|-------|
|
||||
| Backend API + Swagger | http://192.168.0.99:8000 (/docs) |
|
||||
| Frontend | http://192.168.0.99:3000 |
|
||||
| Adminer | http://192.168.0.99:8080 (postgres:postgres, БД vector_mchs) |
|
||||
| PostgreSQL | 192.168.0.99:5432 |
|
||||
|
||||
- **Adminer** (управление БД): http://192.168.0.108:8080
|
||||
- Система: PostgreSQL
|
||||
- Сервер: postgres
|
||||
- Пользователь: postgres
|
||||
- Пароль: postgres
|
||||
- База данных: sar_mchs
|
||||
## Управление (шпаргалка)
|
||||
|
||||
- **PostgreSQL**: 192.168.0.108:5432
|
||||
|
||||
## Управление проектом
|
||||
|
||||
### Запуск проекта
|
||||
```bash
|
||||
cd /root/sar-mchs
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Остановка проекта
|
||||
```bash
|
||||
cd /root/sar-mchs
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Перезапуск с пересборкой
|
||||
```bash
|
||||
cd /root/sar-mchs
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Просмотр логов
|
||||
```bash
|
||||
# Все сервисы
|
||||
docker compose logs -f
|
||||
|
||||
# Конкретный сервис
|
||||
cd /root/vector
|
||||
docker compose up -d # запуск (после правки .env — пересоздание, restart env не перечитывает!)
|
||||
docker compose ps # статус
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
docker compose logs -f postgres
|
||||
docker compose down # остановка
|
||||
docker compose up -d --build # пересборка
|
||||
```
|
||||
|
||||
### Статус сервисов
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
Типовой цикл обновления: push в Gitea → `git pull` → `docker restart vector-backend vector-frontend`
|
||||
(после правки миграций — `alembic upgrade head` в контейнере, после правки env — `up -d backend`).
|
||||
|
||||
## Настройка
|
||||
|
||||
Переменные окружения находятся в файле `/root/sar-mchs/.env`
|
||||
|
||||
Важные переменные:
|
||||
- `ANTHROPIC_API_KEY` - API ключ для Claude (требуется для функции анализа)
|
||||
- `JWT_SECRET` - секрет для JWT токенов
|
||||
- `DATABASE_URL` - строка подключения к PostgreSQL
|
||||
- `CORS_ORIGINS` - разрешенные origins для CORS
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
/root/sar-mchs/
|
||||
├── backend/ # FastAPI backend
|
||||
│ ├── api/v1/ # API endpoints
|
||||
│ ├── main.py # Точка входа
|
||||
│ ├── requirements.txt
|
||||
│ └── Dockerfile
|
||||
├── frontend/ # React frontend
|
||||
│ ├── src/
|
||||
│ ├── public/
|
||||
│ ├── package.json
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml # Конфигурация Docker Compose
|
||||
├── .env # Переменные окружения
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Подключение к LXC 108
|
||||
|
||||
Из Proxmox хоста:
|
||||
```bash
|
||||
pct exec 108 -- bash
|
||||
```
|
||||
|
||||
Через SSH с вашей машины:
|
||||
```bash
|
||||
ssh root@192.168.0.65 "pct exec 108 -- bash"
|
||||
```
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. Добавить ANTHROPIC_API_KEY в .env файл
|
||||
2. Настроить базу данных (миграции Alembic)
|
||||
3. Создать модели SQLAlchemy
|
||||
4. Реализовать аутентификацию с JWT
|
||||
5. Интегрировать Claude API для анализа документов
|
||||
6. Разработать React компоненты для frontend
|
||||
|
||||
## Проверка работоспособности
|
||||
|
||||
Все сервисы запущены и доступны:
|
||||
- ✅ PostgreSQL 16 (порт 5432)
|
||||
- ✅ Adminer (порт 8080)
|
||||
- ✅ Backend API (порт 8000)
|
||||
- ✅ Frontend (порт 3000)
|
||||
Подключение к LXC: `ssh root@192.168.0.65 "pct exec 108 -- bash"` (pve-node1).
|
||||
@@ -1,5 +1,8 @@
|
||||
# Исправления ошибок - Итоговый отчет
|
||||
|
||||
> Исторический документ (2026-05-05). Упоминания Claude Service относятся
|
||||
> к удалённому с тех пор LLM-слою; текущий анализ — rules_analysis (локально).
|
||||
|
||||
## Дата: 2026-05-05
|
||||
|
||||
### Исправленные проблемы:
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
# ВЕКТОР МЧС — Система определения приоритетных направлений поиска
|
||||
|
||||
**Программное средство поддержки принятия решений при поисково-спасательных работах (ПСР) на открытой местности МЧС Республики Беларусь.**
|
||||
|
||||
ВЕКТОР рассчитывает приоритетные направления поиска пропавшего человека по матмодели: на основании данных о потерявшемся (возраст, диагнозы, физподготовка, транспорт), обстоятельствах потери (время, причина, направление), среде (рельеф, погода, сезон) и точке последней пропажи (ТНП) система строит концентрическую зону вероятного нахождения и подсвечивает приоритетные направления обследования с учётом рельефа из OSM-данных.
|
||||
|
||||
> **Версия документа:** соответствует состоянию репозитория на 25 сентября 2026 г.
|
||||
> **Репозиторий:** [`Sadmin/vector`](http://192.168.0.106:3000/Sadmin/vector)
|
||||
> **Развёртывание:** см. [SETUP_GUIDE.md](SETUP_GUIDE.md) — пошаговый гайд по развёртыванию на серверах МЧС.
|
||||
|
||||
---
|
||||
|
||||
## Оглавление
|
||||
|
||||
1. [Назначение и контекст](#1-назначение-и-контекст)
|
||||
2. [Ключевая политика: полностью закрытый контур](#2-ключевая-политика-полностью-закрытый-контур)
|
||||
3. [Архитектура](#3-архитектура)
|
||||
4. [Стек технологий](#4-стек-технологий)
|
||||
5. [Структура репозитория](#5-структура-репозитория)
|
||||
6. [База данных](#6-база-данных)
|
||||
7. [Математическая модель расчёта](#7-математическая-модель-расчёта)
|
||||
8. [OSM-геослой (PostGIS)](#7-osm-геослой-postgis)
|
||||
9. [Бэкенд (FastAPI) — структура и API](#8-бэкенд-fastapi--структура-и-api)
|
||||
10. [Фронтенд (React)](#9-фронтенд-react)
|
||||
11. [Аутентификация, роли и подразделения (RBAC)](#10-аутентификация-роли-и-подразделения-rbac)
|
||||
12. [Мультипоиск и операции](#11-мультипоиск-и-операции)
|
||||
13. [Интеграция с КОНТУР (отправка зон)](#12-интеграция-с-контуром-отправка-зон)
|
||||
14. [Аудит](#13-аудит)
|
||||
15. [Тестирование](#14-тестирование)
|
||||
16. [Справочники](#14-справочники)
|
||||
17. [Что готово и что в планах](#15-что-готово-и-что-в-планах)
|
||||
18. [Контакты и сопровождение](#16-контакты-и-сопровождение)
|
||||
|
||||
---
|
||||
|
||||
## 1. Назначение и контекст
|
||||
|
||||
### Что делает ВЕКТОР
|
||||
|
||||
ВЕКТОР — рабочий инструмент РЦУ (расчётно-центрального управления) МЧС Республики Беларусь при организации поиска пропавших людей:
|
||||
|
||||
- **Карточка поиска** — структурированный ввод данных: субъект (возраст, пол, диагнозы — РАС, эпилепсия, СДВГ, ЗПР, психотип), обстоятельства (причина потери, время, направление движения, надёжность свидетельства), среда (сезон, погода, температура, рельеф), ресурсы (группы, кинолог, транспорт)
|
||||
- **Матрасчёт** — детерминированный расчёт: максимальный радиус по методике ПСР (КМЧ × коэффициенты), приоритетные направления (сектора 45° с весами), прогноз поведения по психотипу, срочность и критические действия
|
||||
- **Карта зон** — Leaflet: концентрические круги возможной зоны (0.5R / 0.75R / R) + подсветка приоритетных направлений; слои воды/болот полигонами и ж/д линий из локальной OSM
|
||||
- **Рекомендации** — тактические рекомендации по профилям случая (проверить водоёмы, обследовать вдоль дорог и т.д.)
|
||||
- **Мультипоиск** — несколько одновременных поисков, каждое ОУМЧС/Г(Р)ОЧС работает со своими операциями
|
||||
- **Интеграция с КОНТУР** — отправка зон в полевую систему координации (ATAK + Meshtastic)
|
||||
- **Архив завершённых поисков** — факт vs прогноз (дистанция, направление, место находки) для калибровки зонального скоринга
|
||||
- **Администрирование** — пользователи по иерархии МЧС, журнал аудита, статистика
|
||||
|
||||
### Методическая основа
|
||||
|
||||
Расчёт радиуса основан на статистических таблицах ПСР (поиск и спасение, экстремум-методика): базовая скорость смещения по среднему лесу (НормС) умножается на коэффициенты возраста, диагноза, рельефа, погоды, времени суток, транспорта. Скоринг зон — взвешенная сумма факторов рельефа (лес, вода, дороги, НП) по секторам вокруг ТНП.
|
||||
|
||||
### Домен
|
||||
|
||||
МЧС осуществляет поиски **только в природных экосистемах** — не в городах. Город учтён в коэффициентах рельефа, но приоритетные зоны строятся для природной среды.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ключевая политика: полностью закрытый контур
|
||||
|
||||
**Все данные о поисках (включая ПДн детей) не покидают контур организации.**
|
||||
|
||||
- **ИИ/LLM удалён из проекта полностью** (решение руководства): анализ детерминированный, `backend/services/claude_service.py` и любые облачные API исключены. В `docker-compose` и `.env` НЕТ никаких API-ключей внешних сервисов
|
||||
- **Расчёт локальный**: вся математика в `services/*.py` (Python), выполняется на сервере за миллисекунды
|
||||
- **Карты — из локальной OSM**: дамп Geofabrik (беларусь-latest.osm.pbf, ~333 МБ) импортирован в PostGIS внутри организации; бэкенд не ходит в Overpass/интернет (fallback на Overpass остаётся только на случай пустых OSM-таблиц)
|
||||
- **Фронтенд** тянет OSM-тайлы и иконки Leaflet с CDN — для полностью offline-контура требуется локальный tile-сервер (в планах)
|
||||
|
||||
Тест-страж: `backend/tests/test_rules_analysis.py::TestNoLLM` — падает, если в код возвращается внешний ИИ.
|
||||
|
||||
---
|
||||
|
||||
## 3. Архитектура
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ Браузер пользователя │
|
||||
│ (Chrome / Firefox / Edge — любая ОС) │
|
||||
└──────────────────────┬────────────────────────────┘
|
||||
│ HTTP
|
||||
┌──────────────────────▼────────────────────────────┐
|
||||
│ vector-frontend (React 18, CRA, порт 3000) │
|
||||
│ Форма карточки · карта Leaflet · дашборд · админка│
|
||||
└──────────────────────┬────────────────────────────┘
|
||||
│ REST JSON :8000
|
||||
┌──────────────────────▼────────────────────────────┐
|
||||
│ vector-backend (FastAPI, порт 8000) │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ Роутеры: auth, cases, analyze, operations, │ │
|
||||
│ │ admin, admin_users, closed_cases, contour, │ │
|
||||
│ │ water, geocode, stats, health │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ Services (математика, без БД/HTTP): │ │
|
||||
│ │ search_engine (модель), distance_service │ │
|
||||
│ │ (радиус), scoring_service (сектора), │ │
|
||||
│ │ rules_analysis (правила), psychotype, │ │
|
||||
│ │ geo_service → osm_local (PostGIS) │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
└──────────────────────┬────────────────────────────┘
|
||||
│ SQLAlchemy / psycopg2
|
||||
┌──────────────────────▼────────────────────────────┐
|
||||
│ vector-postgres (PostgreSQL 16 + PostGIS 3.6) │
|
||||
│ БД vector_mchs: кейсы, операции, юниты, аудит, │
|
||||
│ OSM (planet_osm_*: 1 млн дорог, 192 тыс. лесов) │
|
||||
└───────────────────────────────────────────────────┘
|
||||
│ (опционально, outbound)
|
||||
┌──────────────────────▼────────────────────────────┐
|
||||
│ КОНТУР (CT130, полевая координация ATAK) │
|
||||
│ POST /api/v1/vector/zones — приоритетные зоны │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Принципы архитектуры
|
||||
|
||||
- **Чистая математика**: `services/*.py` не знают ни о БД, ни об HTTP — только входные словари на выходе модели. Тонкие роутеры-обёртки в `backend/routers/`
|
||||
- **Контракт /analyze**: `zone.forest_pct` — доля 0..1 (не проценты!), дистанции — км (в PostGIS SRID 3857 раздувание мерка: real = mercator × cos(lat))
|
||||
- **Анализ воспроизводим**: тот же вход — тот же выход; прогон модели сохраняется в `analysis_log` кейса и сравнивается с фактом в архиве
|
||||
|
||||
---
|
||||
|
||||
## 4. Стек технологий
|
||||
|
||||
| Слой | Технология | Версия | Назначение |
|
||||
|------|-----------|--------|-----------|
|
||||
| Бэкенд | FastAPI | 0.115 | REST API |
|
||||
| ORM | SQLAlchemy 2.0 + psycopg2 | | БД + миграции Alembic 1.14 |
|
||||
| БД | PostgreSQL 16 + PostGIS 3.6 | | Данные + гео (PostGIS) |
|
||||
| OSM | osm2pgsql 2.1 | | Импорт дамп OSM → planet_osm_* |
|
||||
| Auth | python-jose + passlib | | JWT + bcrypt, server-side сессии |
|
||||
| Фронт | React 18 (CRA) + react-leaflet | | SPA |
|
||||
| Карта | Leaflet 1.9 + OSM-тайлы | | Отображение зон |
|
||||
| Контейнеры | Docker Compose | | 4 сервиса |
|
||||
|
||||
Полный список зависимостей — `backend/requirements.txt` и `frontend/package.json`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Структура репозитория
|
||||
|
||||
```
|
||||
vector/
|
||||
├── backend/ # FastAPI-приложение
|
||||
│ ├── main.py # Сборка приложения (12 роутеров)
|
||||
│ ├── models.py # 22 таблицы (SQLAlchemy, +password_history)
|
||||
│ ├── schemas.py # Pydantic-схемы
|
||||
│ ├── database.py # Репозиторий кейсов + статистика
|
||||
│ ├── audit.py # Журнал аудита + гео-скоуп
|
||||
│ ├── password_policy.py # B23: исполняемая политика паролей
|
||||
│ ├── purge_logs.py # B23: ретенция журналов (DELETE, cron)
|
||||
│ ├── rbac.py # Роли/права/политика безопасности
|
||||
│ ├── alembic/ # Миграции (006…010)
|
||||
│ ├── seed_users.py # Сид первых пользователей
|
||||
│ ├── seed_units.py # Сид подразделений (РЦУ РЧС → ОУМЧС → Г(Р)ОЧС, 142 юнита)
|
||||
│ ├── routers/ # 12 модулей API (см. раздел 8)
|
||||
│ ├── services/ # Адаптеры расчёта
|
||||
│ └── tests/ # 263 теста (pytest)
|
||||
├── services/ # Математика (чистые функции)
|
||||
│ ├── search_engine.py # SearchInput/SearchModel — ядро расчёта
|
||||
│ ├── distance_service.py # Максимальный радиус (КМЧ × коэффициенты)
|
||||
│ ├── scoring_service.py # Взвешенный скоринг секторов
|
||||
│ ├── rules_analysis.py # Правила: срочность, действия, поведение
|
||||
│ ├── psychotype_service.py # Психотипирование (опрос)
|
||||
│ ├── geo_service.py # Геоданные секторов (лес/вода/дороги/НП)
|
||||
│ ├── osm_local.py # Локальный PostGIS-провайдер OSM
|
||||
│ └── recommendation_service.py
|
||||
├── frontend/ # React SPA
|
||||
│ └── src/
|
||||
│ ├── App.js # Роутинг (/dashboard, /desktop, /users, /admin)
|
||||
│ ├── constants.js # Единые справочники (место находки, кто нашёл, румбы)
|
||||
│ ├── api/client.js # apiFetch/apiJson — Bearer + 401-перехват
|
||||
│ ├── components/ # CaseForm (5 шагов), SearchMap, ProtectedRoute
|
||||
│ └── pages/ # Dashboard, DesktopSARPage, analysis/, AdminDashboard,
|
||||
│ # UsersAdmin, ClosedCases, Login
|
||||
├── scripts/ # Развёртывание и живые проверки
|
||||
│ ├── b16-import.sh # Импорт OSM-дампа в PostGIS
|
||||
│ ├── e2e-*.sh # e2e-сценарии
|
||||
│ ├── eN-live-check.sh # Живые проверки этапов E1–E4
|
||||
│ └── prod-stamp-backfill.sql
|
||||
├── docker-compose.yml # 4 сервиса: postgres, adminer, backend, frontend
|
||||
├── .env.example # Шаблон окружения (без внешних ключей)
|
||||
├── SETUP_GUIDE.md # Развёртывание на серверах МЧС
|
||||
├── vector_tasks.md # Живой план работ (B1…B21+)
|
||||
└── README.md # Этот документ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. База данных
|
||||
|
||||
**PostgreSQL 16 + PostGIS 3.6**, БД `vector_mchs`. Схема управления — Alembic (`backend/alembic/versions/`), актуальная ревизия **010_b23_policy**.
|
||||
|
||||
### Основные таблицы
|
||||
|
||||
| Таблица | Назначение |
|
||||
|---------|-----------|
|
||||
| `cases` | Карточки поисков: субъект, обстоятельства, среда, ТНП, исход |
|
||||
| `analysis_log` | JSONB-лог прогонов анализа (аудит расчётов) |
|
||||
| `search_models` | Слой 4: сохранённые матмодели по кейсам (GeoJSON-зоны) |
|
||||
| `search_teams`, `field_observations`, `areas_checked`, `found_events` | Оперативные слои (состав групп, наблюдения, проверенные квадраты, находки) |
|
||||
| `reference_priors` | Слой 1: справочные приоритеты |
|
||||
| `users`, `mchs_units` | Пользователи; иерархия РЦУ РЧС → 6 ОУМЧС → 135 Г(Р)ОЧС |
|
||||
| `password_history` | История паролей (bcrypt-хэши; проверка переиспользования, B23) |
|
||||
| `roles`, `permissions`, `role_permissions`, `user_roles` | RBAC: 4 роли, 9 прав, матрица связей |
|
||||
| `sessions`, `auth_events` | Server-side сессии (sha256-токены), журнал входов |
|
||||
| `security_settings` | Политика безопасности (10 параметров, **исполняется кодом с B23**) |
|
||||
| `search_operations` | Поисковые операции (статусная машина, привязка к кейсу и КОНТУРу) |
|
||||
| `audit_events`, `audit_changes` | Неизменяемый журнал аудита, field-level изменения (ретенция 90 дней) |
|
||||
| `planet_osm_*` | Импорт OSM Беларуси (osm2pgsql, SRID 4326/3857): дороги, леса, вода, НП |
|
||||
|
||||
### Политика безопасности (`security_settings`, меняется в админке)
|
||||
|
||||
| Параметр | Значение | Исполнение (B23) |
|
||||
|----------|----------|------------------|
|
||||
| Минимальная длина пароля | 8 | ✅ при смене/сбросе пароля |
|
||||
| Сложность пароля | требуются буквы+цифры | ✅ при смене/сбросе пароля |
|
||||
| Срок действия пароля | 90 дней | ✅ `enforce_expiry` на каждом запросе → `must_change_password` |
|
||||
| История паролей | 5 (нельзя переиспользовать) | ✅ таблица `password_history` (5 последних старых хэшей + текущий) |
|
||||
| Блокировка после неудачных входов | 5 (на 15 минут) | ✅ (с B21) |
|
||||
| Тайм-аут сессии | 24 ч | ✅ проверка `expires_at` при каждом запросе |
|
||||
| Тайм-аут неактивности | 60 мин | ⚠️ `last_seen_at` пишется, но не проверяется |
|
||||
| Макс. параллельных сессий | 3 (старые отзываются) | ✅ (с B21) |
|
||||
| Ретенция журнала аудита | 90 дней | ✅ реальный DELETE: cron `python -m backend.purge_logs` (03:10) или POST `/admin/users/maintenance/purge-logs` |
|
||||
|
||||
Семантика истории: `password_history_count = N` хранит N последних *старых* хэшей;
|
||||
вместе с текущим паролем защищено N+1 последних значений.
|
||||
|
||||
---
|
||||
|
||||
## 7. Математическая модель расчёта
|
||||
|
||||
Ядро — `services/search_engine.py`: `SearchInput` → `SearchModel` (чистая функция, без БД/HTTP).
|
||||
|
||||
### Состав расчёта
|
||||
|
||||
1. **Максимальный радиус** (`distance_service.py`): НормС (скорость смещения по среднему лесу, км/ч) × коэффициенты возраста / диагноза / рельефа / погоды / времени суток / транспорта, с учётом усталости. Контрольные точки: подросток 14 л, 3 ч, лес = 5.1 км; велосипедист 8 л, 2 ч = 10.8 км
|
||||
2. **Коэффициенты рельефа** (`terrain_map`): лес/поле/луг/город/неизвестно = 1.0; густой лес = 0.5; болото = 0.4; горы/овраги = 0.6; дорога/тропа = 0.8
|
||||
3. **Сектора направлений** (`scoring_service.py`): 8 секторов по 45° вокруг ТНП; каждый сектор оценивается по составу рельефа из локальной OSM (лес — доля 0..1, вода/НП — расстояние в км), приоритетные подсвечиваются на карте
|
||||
4. **Психотип** (`psychotype_service.py`): опрос из формы → доминантный / гармоничный / тревожный / интроверт → модификаторы поведения и рекомендаций
|
||||
5. **Правила** (`rules_analysis.py`): срочность (critical/high/elevated/normal), критические действия, прогноз поведения по профилям (не_умеет_плавать ×3.0 к водоёмам и т.п.), тактические рекомендации («проверить водоёмы», не «перекрыть»)
|
||||
6. **Профили, не покрытые моделью** (ДЦП, слабое зрение/слух) — честно помечаются `unmodeled_profiles`, не искажают расчёт
|
||||
|
||||
**Коэффициенты меняются только по явному решению руководства** — молча таблицы не трогать.
|
||||
|
||||
---
|
||||
|
||||
## 7. OSM-геослой (PostGIS)
|
||||
|
||||
- Импорт: дамп [Geofabrik](https://download.geofabrik.de/europe/belarus-latest.osm.pbf) (~333 МБ) → `scripts/b16-import.sh` (osm2pgsql --slim, ~4,5 мин, RAM 2 ГБ)
|
||||
- Содержимое: 1 010 507 дорог, 192 374 леса, 81 772 воды, 2 731 НП
|
||||
- Провайдер `services/osm_local.py` читает `planet_osm_*`; `geo_service.py` переключается на Overpass только если таблицы пусты
|
||||
- Полный `/analyze` на локальных данных: ~10 мс (см. `scripts/e2e-b16-timing.sh`)
|
||||
- Обновление дампа — по решению (раз в квартал / заморозить)
|
||||
|
||||
---
|
||||
|
||||
## 8. Бэкенд (FastAPI) — структура и API
|
||||
|
||||
`backend/main.py` собирает 12 роутеров. Авторизация — Bearer JWT (form-urlencoded логин, OAuth2PasswordRequestForm).
|
||||
|
||||
| Префикс | Модуль | Назначение |
|
||||
|---------|--------|-----------|
|
||||
| `/api/v1/health` | health.py | Liveness |
|
||||
| `/api/v1/auth` | auth.py | Логин/логаут/me/смена пароля, блокировки |
|
||||
| `/api/v1/cases` | cases.py | CRUD карточек поисков |
|
||||
| `/api/v1/analyze` | analyze.py | Матрасчёт зон (по payload или case_id) |
|
||||
| `/api/v1/operations` | operations.py | Операции мультипоиска (CRUD, статусная машина, /summary), завершение поиска |
|
||||
| `/api/v1/operations/{id}/send-to-contour` | contour.py | Отправка зон в КОНТУР; GET areas-checked (прокси) |
|
||||
| `/api/v1/admin` | admin.py | Список/редактирование кейсов, дашборд-метрики |
|
||||
| `/api/v1/admin/users` | admin_users.py | Пользователи (создание/роли/юниты/блокировка/сброс пароля) + журнал аудита |
|
||||
| `/api/v1/closed-cases` | closed_cases.py | Архив завершённых поисков (ручной ввод + прогон модели) |
|
||||
| `/api/v1/water/{case_id}` | water.py | Вода/болота (полигоны), ж/д (линии) вокруг ТНП |
|
||||
| `/api/v1/geocode` | geocode.py | Прокси Nominatim (формат «пункт, район, адрес») |
|
||||
| `/api/v1/stats` | stats.py | Тепловая карта, агрегаты |
|
||||
|
||||
Swagger: `http://<сервер>:8000/docs`.
|
||||
|
||||
**Синхронизация закрытия**: кейс закрывается через ЛЮБОЙ из путей (`PATCH /admin/cases` или `POST /operations/{id}/complete`) — активная операция кейса автоматически завершается (`completed` + `closed_at`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Фронтенд (React)
|
||||
|
||||
SPA на react-scripts (CRA). Роутинг:
|
||||
|
||||
| Путь | Страница |
|
||||
|------|----------|
|
||||
| `/dashboard` | Главная: «Активные поиски» (счётчики, карточки операций, «+ Новый поиск», «Пользователи») |
|
||||
| `/desktop` | Рабочее пространство: форма карточки (5 шагов) + карта + результат анализа |
|
||||
| `/analysis/:case_id` | Результат анализа: карта зон, КОНТУР-панель, «Завершить поиск» |
|
||||
| `/users` | Админка пользователей + вкладка «Аудит» |
|
||||
| `/admin` | Администрирование: Кейсы / Операции / Завершённые поиски |
|
||||
|
||||
Компоненты:
|
||||
- **CaseForm** — 5 шагов: субъект → обстоятельства → среда → координаты (ТНП + геокодирование) → ресурсы; после сохранения автоматически создаётся поисковая операция
|
||||
- **SearchMap** — концентрические круги (0.5R/0.75R/R) + подсветка приоритетных направлений 45°-секторами (палитра ЦОУ: #ff4d00 / #ff9f1a / #ffd166)
|
||||
- **Dashboard / UsersAdmin / AdminDashboard** — VCL-density, тёмная тема (#0d1117 / #161b22 / #30363d / #e6edf3)
|
||||
|
||||
Единые справочники исхода — `frontend/src/constants.js` (тип места находки, кто нашёл, 8 румбов) — используются во всех точках ввода исхода.
|
||||
|
||||
---
|
||||
|
||||
## 10. Аутентификация, роли и подразделения (RBAC)
|
||||
|
||||
### Иерархия подразделений
|
||||
|
||||
РЦУ РЧС (республиканский) → 6 областных ОУМЧС → 135 Г(Р)ОЧС/ПАСО. Справочник — идемпотентный сид `backend/seed_units.py` (источник: справочник подразделений РВС).
|
||||
|
||||
### Роли
|
||||
|
||||
| Роль | Права | Скоуп видимости |
|
||||
|------|-------|----------------|
|
||||
| **admin** (РЦУ РЧС) | Все 9 прав | Все операции и пользователи республики |
|
||||
| **coordinator** (ОУМЧС) | view/create/update/export/manage_users/view_audit | Своё ОУМЧС + подчинённые Г(Р)ОЧС |
|
||||
| **operator** (Г(Р)ОЧС) | view/create/update | Свои поиски |
|
||||
| **observer** | view | Только просмотр |
|
||||
|
||||
- Управление пользователями (`manage_users`): РЦУ РЧС — все; ОУМЧС — только своё поддерево (кросс-областные операции отбиваются 403 на бэкенде)
|
||||
- Созданный пользователь обязан сменить пароль при первом входе (`must_change_password`)
|
||||
- Сессии server-side: JWT несёт идентификатор, в БД — sha256-хэш; сброс пароля отзывает все сессии
|
||||
|
||||
---
|
||||
|
||||
## 11. Мультипоиск и операции
|
||||
|
||||
`search_operations` — надстройка над кейсами для одновременной работы с несколькими поисками:
|
||||
|
||||
- Одна операция на карточку (case_id unique)
|
||||
- **Статусная машина**: planned → active ⇄ paused → completed → archived (недопустимые переходы → 400)
|
||||
- Авто-создание операции при сохранении карточки; дашборд показывает активные
|
||||
- `POST /operations/{id}/complete` — завершение поиска: исход (найден живым/погибшим, дистанция, направление, кто нашёл) → кейс в архив + детерминированный прогон матмодели → сравнение «прогноз vs факт» (accuracy_note)
|
||||
- `GET /operations/summary` — счётчики для дашборда (активные / всего / завершено за 24 ч)
|
||||
|
||||
---
|
||||
|
||||
## 11а. Полевые данные КОНТУРа (B17/E5)
|
||||
|
||||
Двусторонний обмен с КОНТУРом по операции:
|
||||
|
||||
- **Outbound** (E4): `POST /operations/{id}/send-to-contour` — зоны расчёта.
|
||||
- **Inbound** (B17): `POST /operations/{id}/field-data/sync` — тянет из КОНТУРа
|
||||
`GET {CONTOUR_API_URL}/api/v1/vector/areas-checked?operation_id=<contour_operation_id>`
|
||||
и делает идемпотентный upsert в `areas_checked` (dedupe по `contour_area_id`,
|
||||
хранит geom GeoJSON / checked_at / result clear|partial / coverage_pct / callsign цепи,
|
||||
`source=contour`). Каждая синхронизация аудируется (`field_data_sync`).
|
||||
- `GET /operations/{id}/areas-checked` — после sync читает локальную БД (`source: db`),
|
||||
до первой синхронизации прозрачно проксирует КОНТУР (`source: contour`).
|
||||
- Фронт: кнопка «⟳ Синхронизировать полевые данные» в КОНТУР-панели страницы анализа;
|
||||
проверенные квадраты рисуются на карте **поверх зон** — зелёный = прочесано (≥99.5%),
|
||||
жёлтый пунктир = частично; в попапе покрытие %, время проверки, callsign.
|
||||
|
||||
Статусы КОНТУРа: `clear` (покрытие ≥99.5%) / `partial`. Нахождение субъекта на стороне
|
||||
КОНТУРа статусом не передаётся — находка фиксируется завершением поиска в ВЕКТОРе.
|
||||
|
||||
---
|
||||
|
||||
## 12. Интеграция с КОНТУР (отправка зон)
|
||||
|
||||
КОНТУР — система полевой координации (ATAK + Meshtastic). ВЕКТОР отдаёт приоритетные зоны (outbound):
|
||||
|
||||
1. В операции задаётся `contour_operation_id` (UUID операции в КОНТУРе)
|
||||
2. `POST /api/v1/operations/{id}/send-to-contour` строит `SearchZones[]` (GeoJSON-полигоны секторов ±22.5°, `zone_id = vec:<НАПР>:<дист>m`, probability по приоритету) и отправляет на `{CONTOUR_API_URL}/api/v1/vector/zones?operation_id=…` с `Bearer CONTOUR_TOKEN`
|
||||
3. Каждый шаг аудируется (`contour_send_ok` / `contour_send_failed`); ошибка КОНТУРа → 502
|
||||
4. `GET /api/v1/operations/{id}/areas-checked` — прокси проверенных полевых квадратов (inbound, B17)
|
||||
|
||||
Переменные окружения: `CONTOUR_API_URL` (по умолчанию `http://192.168.0.130:8000`), `CONTOUR_TOKEN`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Аудит
|
||||
|
||||
Неизменяемый журнал (append-only):
|
||||
|
||||
- `audit_events`: кто, что, когда, IP, user-agent, тип события (login/lockout/user_*/operation_*/contour_send_*/case_closed)
|
||||
- `audit_changes`: field-level `старое → новое` по каждому изменённому полю
|
||||
- Ретенция 90 дней (фильтруется при чтении из `security_settings`)
|
||||
- Доступ: право `view_audit` — вкладка «Аудит» в админке `/users` с фильтром по типу и пагинацией
|
||||
|
||||
---
|
||||
|
||||
## 13а. Прикладное логирование (B22)
|
||||
|
||||
Единая настройка — `backend/logging_setup.py`, инициализируется один раз при старте
|
||||
(`backend.main::lifespan → setup_logging()`). Все модульные логгеры проекта живут
|
||||
в пространстве имён `vector.*` и пишут в:
|
||||
|
||||
- **stdout** контейнера — уровень INFO (меняется env `VectorLOGLEVEL=debug`);
|
||||
- **файл** `/tmp/vector/vector.log` внутри контейнера — ротация 5×2 МБ (отключается `VectorLOGFILE=0`, каталог `VectorLOGDIR`).
|
||||
|
||||
Формат строки: `время LEVEL vector.<модуль>: сообщение`. Пороги для файла — INFO и выше.
|
||||
|
||||
### Наблюдаемые события
|
||||
|
||||
| Логгер | Событие | Что означает |
|
||||
|--------|---------|--------------|
|
||||
| `vector.app` | `Vector API стартует` / `остановлен` | Жизненный цикл процесса |
|
||||
| `vector.analyze` | `анализ case=… радиус=… срочность=… зон=… время=…мс` | Каждый матрасчёт с длительностью |
|
||||
| `vector.auth` | `login_ok` / `login_failed` / `lockout` | Входы и блокировки (user, счётчик, IP) |
|
||||
| `vector.audit` | `audit <тип> user=… obj=… ip=…` | Каждая запись в audit_events |
|
||||
| `vector.contour` | `contour_send_ok` / `contour_send_failed` / `contour_rejected` | Отправка зон в КОНТУР |
|
||||
| `vector.operations` | `operation_complete op=… исход=…` | Завершение поиска (найден живым/погибшим) |
|
||||
| `vector.admin` | `admin closed case …` | Синхронизация закрытия кейса с операциями |
|
||||
| `vector.geocode` | `geocode q=… результатов=…` / `geocode timeout` | Прокси Nominatim |
|
||||
| `vector.geo` / `vector.geo.osm` | Overpass недоступен, OSM PostGIS недоступен | Состояние геослоя (fallback, breaker) |
|
||||
| `vector.rules` | `Geo/scoring service error` | Отказ расчёта зон → fallback-зоны |
|
||||
| `vector.water` | `geo layer cache write failed` | Ошибки кэша геослоя |
|
||||
|
||||
### Правила
|
||||
|
||||
- ПДн и секреты в логи не пишутся: никаких паролей, JWT, содержимого карточек (только id и агрегаты).
|
||||
- Аудит-события дублируются в БД (`audit_events`) — лог это операционный след, БД — юридический.
|
||||
- Уровень по умолчанию INFO; WARNING/ERROR — для неисправностей. Не логировать на INFO каждый HTTP-запрос (это делает uvicorn access-log).
|
||||
|
||||
---
|
||||
|
||||
## 15. Что готово и что в планах
|
||||
|
||||
### Реализовано
|
||||
|
||||
- Форма карточки поиска (5 шагов), справочники, автоподсчёт «прошло времени»
|
||||
- Детерминированный матрасчёт: радиус, сектора, поведение, срочность, рекомендации — без ИИ
|
||||
- Локальный OSM-геослой в PostGIS (Беларусь), /analyze ~10 мс, без внешних вызовов
|
||||
- Карта: концентрические круги + подсветка направлений (дизайн согласованный)
|
||||
- РБАК: иерархия РЦУ РЧС → ОУМЧС → Г(Р)ОЧС (142 юнита), 4 роли / 9 прав, блокировки, сессии
|
||||
- Мультипоиск: операции, статусная машина, дашборд «Активные поиски»
|
||||
- B17/E5: ингестия полевых данных КОНТУРа (проверенные квадраты на карте операции)
|
||||
- Завершение поиска с внесением исхода, архив завершённых с «прогноз vs факт»
|
||||
- Аудит 90 дней с field-level деталями
|
||||
- Прикладное логирование `vector.*`: события анализа, входов, КОНТУРа, аудита (README §13а)
|
||||
- Интеграция с КОНТУР: отправка зон (контракт B20)
|
||||
- Журнал аудита пользователей: входы, изменения, отправки
|
||||
- 263 автотеста (pytest), 5 skipped
|
||||
- B23: исполняемая политика паролей (срок/сложность/история), ретенция журналов реальным DELETE + cron
|
||||
|
||||
### В планах
|
||||
|
||||
- **E5/B17 расширение** — треки групп (tracks/live) как field_observations; полевой статус «найден»
|
||||
- **Авто-sync полевых данных** — периодический (сейчас кнопка вручную)
|
||||
- **Локальный tile-сервер** — для полностью offline-фронта (сейчас тайлы OSM с CDN в браузере)
|
||||
- **Калибровка settlement_score** — вкладка населённых пунктов в скоринге секторов (открытый методический вопрос)
|
||||
- **Калибровка зонального скоринга** по накопленному архиву «прогноз vs факт»
|
||||
|
||||
---
|
||||
|
||||
## 16. Контакты и сопровождение
|
||||
|
||||
- Развёртывание и эксплуатация: см. [SETUP_GUIDE.md](SETUP_GUIDE.md)
|
||||
- План работ и статус этапов: `vector_tasks.md`
|
||||
- API-документация: Swagger `http://<сервер>:8000/docs`
|
||||
- Репозиторий: [`Sadmin/vector`](http://192.168.0.106:3000/Sadmin/vector)
|
||||
|
||||
Смежные системы МЧС: **РВС** («Регистрация входящих сообщений», [`Sadmin/rvs-web`](http://192.168.0.106:3000/Sadmin/rvs-web)), **КОНТУР** (полевая координация).
|
||||
+45
-159
@@ -2,182 +2,68 @@
|
||||
|
||||
## Обзор
|
||||
|
||||
Созданы два основных сервиса для анализа поисково-спасательных операций:
|
||||
Анализ поисково-спасательных операций — **полностью локальный и
|
||||
детерминированный** (внешний ИИ убран: ПД-данные детей не покидают контур).
|
||||
|
||||
### 1. Claude Service (`backend/services/claude_service.py`)
|
||||
### Состав
|
||||
|
||||
**Функция:** `analyze_case(case_data: dict) -> AnalysisResult`
|
||||
| Модуль | Назначение |
|
||||
|---|---|
|
||||
| `services/search_engine.py` | Чистый движок: SearchInput → build_search_model → SearchModel (B14) |
|
||||
| `services/rules_analysis.py` | Правила: urgency, зоны, прогноз поведения, действия (замена LLM) |
|
||||
| `services/geo_service.py` | Зоны через Overpass (8 направлений × 4 дистанции) |
|
||||
| `services/scoring_service.py` | WeightedScorer, поведенческие профили (§8) |
|
||||
| `services/distance_service.py` | Формула Экстремум: макс. дистанция + коэффициенты |
|
||||
| `services/psychotype_service.py` | Детекция психотипа, модификаторы полос |
|
||||
| `services/recommendation_service.py` | Стат-рекомендации по истории (B3) |
|
||||
|
||||
Использует Claude API (модель `claude-sonnet-4-20250514`) для интеллектуального анализа случаев пропажи людей.
|
||||
### Поток анализа
|
||||
|
||||
**Входные данные (case_data):**
|
||||
- `age` - возраст пропавшего
|
||||
- `gender` - пол
|
||||
- `terrain` - тип местности
|
||||
- `weather` - погодные условия
|
||||
- `time_missing` - время пропажи
|
||||
- `last_location` - последнее известное местоположение
|
||||
- `circumstances` - особые обстоятельства
|
||||
- `physical_condition` - физическое состояние
|
||||
- `experience` - опыт нахождения на природе
|
||||
|
||||
**Возвращаемые данные (AnalysisResult):**
|
||||
```python
|
||||
{
|
||||
"urgency": "критическая|высокая|средняя|низкая",
|
||||
"primary_zones": [
|
||||
{
|
||||
"priority": 1,
|
||||
"name": "Зона А",
|
||||
"direction": "север",
|
||||
"distance": 2.5,
|
||||
"reason": "Обоснование выбора"
|
||||
}
|
||||
],
|
||||
"search_radius_km": 5.0,
|
||||
"key_locations": ["водоемы", "дороги", "постройки"],
|
||||
"team_assignments": {
|
||||
"команда_1": "зона А",
|
||||
"команда_2": "зона Б"
|
||||
},
|
||||
"behavioral_prediction": "Прогноз поведения",
|
||||
"immediate_actions": ["Действие 1", "Действие 2"],
|
||||
"summary": "Краткое резюме"
|
||||
}
|
||||
```
|
||||
analyze_case (роутер, тонкая обёртка)
|
||||
└─ build_search_model(SearchInput, analyzer=rules_analyze)
|
||||
├─ derive_profiles / derive_time_of_day (деривации)
|
||||
├─ detect_psychotype (если ответы есть)
|
||||
├─ calculate_max_distance (формула Экстремум)
|
||||
├─ rules_analyze (urgency/зоны/действия по правилам)
|
||||
│ └─ build_search_zones + rank_zones (если есть координаты)
|
||||
└─ WeightedScorer (веса, профили)
|
||||
```
|
||||
|
||||
### 2. Stats Service (`backend/services/stats_service.py`)
|
||||
## API
|
||||
|
||||
**Функция:** `get_statistical_recommendation(case_data: dict, db_session: AsyncSession) -> StatsResult`
|
||||
|
||||
Анализирует исторические данные из базы для получения статистических рекомендаций.
|
||||
|
||||
**Входные данные:**
|
||||
- `age` - возраст (±2 года для фильтрации)
|
||||
- `season` - сезон (winter, spring, summer, autumn)
|
||||
- `terrain_primary` - основной тип местности
|
||||
|
||||
**Логика фильтрации:**
|
||||
1. Первая попытка: возраст ±2 года + сезон + terrain
|
||||
2. Если sample_size < 5: возраст ±2 года + сезон (без terrain)
|
||||
3. Если данных нет: возвращает дефолтные значения
|
||||
|
||||
**Возвращаемые данные (StatsResult):**
|
||||
```python
|
||||
{
|
||||
"median_distance_km": 2.5,
|
||||
"top_directions": [
|
||||
{
|
||||
"direction": "север",
|
||||
"count": 15,
|
||||
"percentage": 35.0
|
||||
}
|
||||
],
|
||||
"top_location_types": ["водоемы", "дороги", "лесные тропы"],
|
||||
"survival_rate": 87.5,
|
||||
"sample_size": 43
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST `/api/v1/analyze/text`
|
||||
|
||||
Анализ случая с помощью Claude AI.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"age": 65,
|
||||
"gender": "мужской",
|
||||
"terrain": "лес",
|
||||
"weather": "дождь",
|
||||
"time_missing": "6 часов",
|
||||
"last_location": "лесная тропа у озера",
|
||||
"circumstances": "вышел за грибами",
|
||||
"physical_condition": "хорошее",
|
||||
"experience": "опытный грибник"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `AnalysisResult`
|
||||
### POST `/api/v1/analyze`
|
||||
Анализ случая (поле `case_id` опционально — без него анализ «на лету»).
|
||||
|
||||
### POST `/api/v1/analyze/combined`
|
||||
Синоним `/analyze` (оставлен для совместимости).
|
||||
|
||||
Комбинированный анализ: Claude AI + статистические данные.
|
||||
### GET `/api/v1/analyze/{case_id}`
|
||||
Последний сохранённый результат анализа кейса.
|
||||
|
||||
**Request:** Тот же формат, что и `/text`
|
||||
|
||||
**Response:**
|
||||
Ответ `/analyze` (сокращённо):
|
||||
```json
|
||||
{
|
||||
"claude_analysis": { /* AnalysisResult */ },
|
||||
"statistical_data": { /* StatsResult */ }
|
||||
"max_distance_km": 5.4,
|
||||
"coefficients": {"base_speed": 1.2, "terrain": 0.5, "urgency": 5.0, ...},
|
||||
"urgency": "высокая",
|
||||
"primary_zones": [{"priority": 1, "name": "Зона N 0.5км", ...}],
|
||||
"search_radius_km": 5.0,
|
||||
"key_locations": ["водоёмы", ...],
|
||||
"behavioral_prediction": "...",
|
||||
"immediate_actions": ["..."],
|
||||
"active_profiles": [{"name": "велосипед", ...}],
|
||||
"fallback_used": true
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/v1/analyze/document`
|
||||
|
||||
Анализ загруженного документа (пока поддерживается только .txt).
|
||||
|
||||
**Request:** multipart/form-data с файлом
|
||||
|
||||
**Response:** `AnalysisResult`
|
||||
|
||||
## Настройка
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
В файле `.env` должен быть установлен:
|
||||
```
|
||||
ANTHROPIC_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
### База данных
|
||||
|
||||
Для работы `stats_service` требуется таблица `cases` со следующими полями:
|
||||
- `age` (integer)
|
||||
- `season` (string)
|
||||
- `terrain_primary` (string)
|
||||
- `found_distance_km` (float)
|
||||
- `found_direction` (string)
|
||||
- `found_location_type` (string)
|
||||
- `outcome` (string: 'alive', 'found_alive', etc.)
|
||||
`fallback_used: true` означает «локальный расчёт» (единственный режим).
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Тест Claude Service
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.0.99:8000/api/v1/analyze/text \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"age": 65,
|
||||
"gender": "мужской",
|
||||
"terrain": "лес",
|
||||
"weather": "дождь",
|
||||
"time_missing": "6 часов",
|
||||
"last_location": "лесная тропа у озера"
|
||||
}'
|
||||
curl -X POST http://192.168.0.99:8000/api/v1/analyze \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"age": 8, "gender": "м", "terrain": ["лес"], "elapsed_hours": 2.0,
|
||||
"has_transport": "bike", "season": "лето"}'
|
||||
# max_distance_km == 5.4 (регресс B14)
|
||||
```
|
||||
|
||||
### Тест Combined Analysis
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.0.99:8000/api/v1/analyze/combined \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"age": 65,
|
||||
"season": "autumn",
|
||||
"terrain_primary": "forest"
|
||||
}'
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Настроить подключение к базе данных
|
||||
- [ ] Добавить миграции Alembic для таблицы cases
|
||||
- [ ] Реализовать извлечение текста из .docx и .pdf
|
||||
- [ ] Добавить кэширование результатов анализа
|
||||
- [ ] Добавить rate limiting для API запросов
|
||||
- [ ] Добавить логирование запросов и ответов
|
||||
|
||||
+570
@@ -0,0 +1,570 @@
|
||||
# ВЕКТОР МЧС — Гайд по развёртыванию на серверах МЧС
|
||||
|
||||
**Пошаговое руководство для IT-специалистов МЧС Республики Беларусь по развёртыванию системы определения приоритетных направлений поиска (ВЕКТОР) на серверах организации.**
|
||||
|
||||
> **Версия документа:** 1.1 от 25 сентября 2026 г.
|
||||
> **Репозиторий:** [`Sadmin/vector`](http://192.168.0.106:3000/Sadmin/vector)
|
||||
|
||||
---
|
||||
|
||||
## Оглавление
|
||||
|
||||
1. [Требования к серверу](#1-требования-к-серверу)
|
||||
2. [Архитектура развёртывания](#2-архитектура-развёртывания)
|
||||
3. [Подготовка сервера](#3-подготовка-сервера)
|
||||
4. [Установка Docker и Docker Compose](#4-установка-docker-и-docker-compose)
|
||||
5. [Получение кода проекта](#5-получение-кода-проекта)
|
||||
6. [Конфигурация окружения (.env)](#6-конфигурация-окружения-env)
|
||||
7. [Запуск сервисов](#7-запуск-сервисов)
|
||||
8. [Инициализация базы данных (миграции, сиды)](#8-инициализация-базы-данных-миграции-сиды)
|
||||
9. [Загрузка OSM-геослоя (PostGIS)](#9-загрузка-osm-геослоя-postgis)
|
||||
10. [Проверка работоспособности](#10-проверка-работоспособности)
|
||||
11. [Создание пользователей и справочника подразделений](#11-создание-пользователей-и-справочника-подразделений)
|
||||
12. [Настройка интеграции с КОНТУР](#12-настройка-интеграции-с-контуром)
|
||||
13. [Обратный прокси и HTTPS](#13-обратный-прокси-и-https)
|
||||
14. [Обновление проекта](#14-обновление-проекта)
|
||||
15. [Резервное копирование (RPO/RTO)](#15-резервное-копирование)
|
||||
16. [Мониторинг и логи](#16-мониторинг-и-логи)
|
||||
17. [Диагностика проблем](#17-диагностика-проблем)
|
||||
17а. [Чек-лист передачи проекта](#17а-чек-лист-передачи-проекта)
|
||||
18. [Чек-лист развёртывания](#18-чек-лист-развёртывания)
|
||||
|
||||
---
|
||||
|
||||
## 1. Требования к серверу
|
||||
|
||||
### Минимальные требования
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| ОС | Debian 12/13, Ubuntu 22.04+ (любая с Docker) |
|
||||
| CPU | 2 vCPU |
|
||||
| RAM | 4 ГБ (2 ГБ свободно для импорта OSM — см. раздел 9) |
|
||||
| Диск | 20 ГБ + 1 ГБ под дамп OSM |
|
||||
| Сеть | Доступ браузеров к серверу по HTTP(S); для первичной загрузки дампа OSM — доступ в интернет (одноразово) |
|
||||
|
||||
### Требования безопасности
|
||||
|
||||
- Сервер в закрытом контуре организации; **данные ПДн не должны покидать контур** — не настраивать проксирование на внешние сервисы
|
||||
- Смена всех паролей по умолчанию (см. раздел 6)
|
||||
- Для полностью offline-сценария без интернета: скачать дамп OSM заранее (раздел 9) и учесть, что фронт тянет OSM-тайлы с CDN — для абсолютного offline нужен локальный tile-сервер (в планах)
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура развёртывания
|
||||
|
||||
Docker Compose поднимает 4 сервиса:
|
||||
|
||||
| Контейнер | Образ | Порт | Назначение |
|
||||
|-----------|-------|------|-----------|
|
||||
| `vector-postgres` | postgres:16 (+PostGIS 3.6 в контейнере) | 5432 | БД `vector_mchs`, данные + OSM |
|
||||
| `vector-backend` | сборка backend/Dockerfile | 8000 | FastAPI (uvicorn --reload) |
|
||||
| `vector-frontend` | сборка frontend/Dockerfile | 3000 | React SPA (CRA dev-сервер) |
|
||||
| `vector-adminer` | adminer | 8080 | Веб-администрирование БД |
|
||||
|
||||
Код backend/services и frontend смонтирован volume'ами — правки подхватываются без пересборки (dev-режим).
|
||||
|
||||
---
|
||||
|
||||
## 3. Подготовка сервера
|
||||
|
||||
```bash
|
||||
# 1. Обновление системы
|
||||
apt update && apt upgrade -y
|
||||
|
||||
# 2. Базовые утилиты
|
||||
apt install -y curl git ca-certificates gnupg
|
||||
|
||||
# 3. (опционально) NTP-синхронизация — важна для корректных меток времени аудита
|
||||
apt install -y systemd-timesyncd
|
||||
timedatectl set-ntp true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Установка Docker и Docker Compose
|
||||
|
||||
```bash
|
||||
# Официальный скрипт (или репозиторий docker-ce — по политике организации)
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable --now docker
|
||||
|
||||
docker --version # 24+
|
||||
docker compose version # v2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Получение кода проекта
|
||||
|
||||
```bash
|
||||
mkdir -p /root/vector && cd /root/vector
|
||||
git clone http://<gitea-host>:3000/Sadmin/vector.git .
|
||||
```
|
||||
|
||||
Ключевые ветки: `master` — рабочая.
|
||||
|
||||
---
|
||||
|
||||
## 6. Конфигурация окружения (.env)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Обязательные переменные:
|
||||
|
||||
```ini
|
||||
# Подключение к БД (внутри compose — хост postgres)
|
||||
DATABASE_URL=postgresql://postgres:<СМЕНИТЬ_ПАРОЛЬ>@postgres:5432/vector_mchs
|
||||
|
||||
# Секрет JWT — сгенерировать: openssl rand -hex 32
|
||||
JWT_SECRET=<СЛУЧАЙНАЯ_СТРОКА>
|
||||
|
||||
# CORS — адрес(а) фронтенда
|
||||
CORS_ORIGINS=http://<адрес-сервера>:3000
|
||||
|
||||
# Интеграция с КОНТУР (раздел 12; можно отложить)
|
||||
CONTOUR_API_URL=http://<kontur-host>:8000
|
||||
CONTOUR_TOKEN=<токен из КОНТУРа>
|
||||
```
|
||||
|
||||
> **НЕ добавлять** никаких ключей внешних ИИ/API — по политике закрытого контура их в проекте нет и быть не должно (тест-страж `TestNoLLM`).
|
||||
|
||||
Пароль БД должен совпадать с `POSTGRES_PASSWORD` в `docker-compose.yml` (сервис postgres).
|
||||
|
||||
---
|
||||
|
||||
## 7. Запуск сервисов
|
||||
|
||||
```bash
|
||||
cd /root/vector
|
||||
docker compose up -d
|
||||
docker compose ps # все 4 контейнера Up/healthy
|
||||
```
|
||||
|
||||
Первый старт backend создаёт таблицы через `init_db` (create_all). Порядок применения Alembic-миграций — раздел 8.
|
||||
|
||||
---
|
||||
|
||||
## 8. Инициализация базы данных (миграции, сиды)
|
||||
|
||||
### 8.1 Миграции Alembic
|
||||
|
||||
```bash
|
||||
docker exec vector-backend sh -c 'cd /app/backend && alembic upgrade head'
|
||||
docker exec vector-backend sh -c 'cd /app/backend && alembic current' # 010_b23_policy
|
||||
```
|
||||
|
||||
Миграции идемпотентны на чистой БД. **Если** БД уже была инициализирована через create_all и `alembic upgrade` падает `DuplicateTable` — применить стемп-скрипт:
|
||||
|
||||
```bash
|
||||
docker exec -i vector-postgres psql -U postgres -d vector_mchs < scripts/prod-stamp-backfill.sql
|
||||
```
|
||||
|
||||
### 8.2 Сид первого администратора
|
||||
|
||||
```bash
|
||||
docker exec vector-backend sh -c 'cd /app && python backend/seed_users.py'
|
||||
```
|
||||
|
||||
Создаётся стартовый администратор (логин `admin`, пароль по умолчанию — сменить при первом входе через форму). Скрипт пропускается, если пользователи уже есть.
|
||||
|
||||
### 8.3 Сид подразделений (142 юнита)
|
||||
|
||||
```bash
|
||||
docker exec vector-backend sh -c 'cd /app && python backend/seed_units.py'
|
||||
```
|
||||
|
||||
Создаёт РЦУ РЧС → 6 ОУМЧС → 135 Г(Р)ОЧС/ПАСО (справочник идемпотентный: существующие названия не дублируются). Без него админка пользователей бесполезна (пустые селекты подразделений).
|
||||
|
||||
---
|
||||
|
||||
## 9. Загрузка OSM-геослоя (PostGIS)
|
||||
|
||||
Бэкенд рассчитывает рельеф секторов из локальной OSM. На чистой БД геосервис автоматически fallback'ится на публичный Overpass (медленно, требует интернет) — **для продуктива импортируйте дамп**.
|
||||
|
||||
### 9.1 Установка PostGIS в контейнер БД
|
||||
|
||||
```bash
|
||||
docker exec -u root vector-postgres apt update
|
||||
docker exec -u root vector-postgres apt install -y postgresql-16-postgis-3 osm2pgsql
|
||||
docker exec vector-postgres psql -U postgres -d vector_mchs -c 'CREATE EXTENSION IF NOT EXISTS postgis;'
|
||||
```
|
||||
|
||||
> Установка пакетом в живой контенер переживает перезапуск контейнера (данные в volume), но слетает при ПЕРЕСОЗДАНИИ контейнера — тогда повторить команды.
|
||||
|
||||
### 9.2 Импорт дампа OSM
|
||||
|
||||
```bash
|
||||
# Скачивание (одноразово, ~333 МБ; можно перенести на сервер любым носителем)
|
||||
wget https://download.geofabrik.de/europe/belarus-latest.osm.pbf -O /tmp/belarus-latest.osm.pbf
|
||||
|
||||
# Импорт (~4,5 мин, RAM 2 ГБ, node cache 800)
|
||||
sh scripts/b16-import.sh
|
||||
```
|
||||
|
||||
Скрипт сам: удаляет старые planet_osm_* таблицы, копирует дамп в контейнер, запускает osm2pgsql (--slim, SRID по умолчанию), выводит список таблиц.
|
||||
|
||||
### 9.3 Проверка
|
||||
|
||||
```bash
|
||||
docker exec vector-postgres psql -U postgres -d vector_mchs -t -c "
|
||||
SELECT 'roads', count(*) FROM planet_osm_roads
|
||||
UNION ALL SELECT 'forests', count(*) FROM planet_osm_polygon WHERE landuse='forest';"
|
||||
```
|
||||
|
||||
Ожидается ~1 млн дорог, ~190 тыс. лесов.
|
||||
|
||||
---
|
||||
|
||||
## 10. Проверка работоспособности
|
||||
|
||||
```bash
|
||||
# 1. Health бэкенда
|
||||
curl -s http://localhost:8000/api/v1/health
|
||||
|
||||
# 2. Логин (ТОЛЬКО form-urlencoded!)
|
||||
TOKEN=$(curl -s -X POST http://localhost:8000/api/v1/auth/login \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'username=admin&password=<пароль>' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
|
||||
|
||||
# 3. Профиль (9 прав у admin)
|
||||
curl -s http://localhost:8000/api/v1/auth/me -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 4. Фронт
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/ # 200
|
||||
|
||||
# 5. Полный расчёт (e2e-скрипты в scripts/e2e-*.sh — см. примеры вызовов)
|
||||
```
|
||||
|
||||
В браузере: `http://<адрес>:3000` → дашборд «Активные поиски».
|
||||
|
||||
---
|
||||
|
||||
## 11. Создание пользователей и справочника подразделений
|
||||
|
||||
Пользователи создаются через UI: **Дашборд → Пользователи → «+ Новый пользователь»** (доступ: роль admin РЦУ РЧС или coordinator ОУМЧС в рамках своего поддерева).
|
||||
|
||||
- При создании задаётся временный пароль — пользователь обязан сменить его при первом входе
|
||||
- Роли: РЦУ РЧС / ОУМЧС / Г(Р)ОЧС / Наблюдатель (описание прав — README.md, раздел RBAC)
|
||||
- Инлайн-операции в таблице: смена роли/подразделения, отключение/включение, сброс пароля (отзывает все сессии)
|
||||
- Все изменения пишутся в журнал аудита (вкладка «Аудит», ретенция 90 дней)
|
||||
|
||||
Иерархию подразделений менять: правкой `backend/seed_units.py` (идемпотентно, kind ∈ rcu/oblast/gor_rayon — ограничение БД `ck_mchs_units_kind`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Настройка интеграции с КОНТУР
|
||||
|
||||
КОНТУР — система полевой координации (ATAK + Meshtastic) на отдельном сервере. ВЕКТОР отправляет в неё приоритетные зоны.
|
||||
|
||||
1. В `.env`: `CONTOUR_API_URL` (адрес API КОНТУРа) и `CONTOUR_TOKEN` (выдаётся на стороне КОНТУРа)
|
||||
2. `docker compose up -d backend` — **пересоздать** контейнер (restart не перечитывает env!)
|
||||
3. Проверка: операция → «Отправить зоны в КОНТУР» на странице анализа; при недоступности КОНТУРа бэкенд вернёт понятную ошибку 502 и запишет `contour_send_failed` в аудит
|
||||
|
||||
Входящие данные (треки групп, проверенные квадраты) — этап B17, в разработке.
|
||||
|
||||
---
|
||||
|
||||
## 13. Обратный прокси и HTTPS
|
||||
|
||||
Для доступа пользователей по HTTPS поставьте nginx/traefik перед фронтендом и API:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name vector.mchs.example;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/vector.crt;
|
||||
ssl_certificate_key /etc/ssl/private/vector.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
В `CORS_ORIGINS` укажите итоговый https-адрес. В закрытом контуре допустима работа по HTTP без прокси (порты 3000/8000 напрямую).
|
||||
|
||||
---
|
||||
|
||||
## 14. Обновление проекта
|
||||
|
||||
```bash
|
||||
cd /root/vector
|
||||
git pull origin master
|
||||
|
||||
# Если менялись миграции:
|
||||
docker exec vector-backend sh -c 'cd /app/backend && alembic upgrade head'
|
||||
|
||||
# Если менялись env-переменные (.env / compose environment) — ПЕРЕСОЗДАТЬ контейнер:
|
||||
docker compose up -d backend # restart НЕ перечитывает env!
|
||||
|
||||
# Обычное обновление кода:
|
||||
docker restart vector-backend vector-frontend
|
||||
```
|
||||
|
||||
Схема обновления, применяемая при разработке: правки коммитятся в Gitea → на сервере `git pull` → `docker restart`. После рестарта frontend пользователи перелогиниваются.
|
||||
|
||||
---
|
||||
|
||||
## 15. Резервное копирование
|
||||
|
||||
```bash
|
||||
# Полный дамп БД (данные + OSM-таблицы)
|
||||
docker exec vector-postgres pg_dump -U postgres -d vector_mchs -F c \
|
||||
-f /tmp/vector_mchs_$(date +%F).dump
|
||||
docker cp vector-postgres:/tmp/vector_mchs_$(date +%F).dump /root/backups/
|
||||
|
||||
# Только прикладные данные (без тяжёлых planet_osm_* — их восстанавливают импортом дампа):
|
||||
docker exec vector-postgres pg_dump -U postgres -d vector_mchs \
|
||||
--exclude-table='planet_osm_*' -F c -f /tmp/vector_app_$(date +%F).dump
|
||||
```
|
||||
|
||||
Рекомендация: ежедневный cron прикладного дампа (он небольшой) + еженедельный полный. Хранить копии вне сервера.
|
||||
|
||||
### Cron (рекомендуемый на сервере)
|
||||
|
||||
```cron
|
||||
# 03:10 — ретенция журналов (удаление audit_events/auth_events старше audit_retention_days)
|
||||
10 3 * * * docker exec vector-backend python -m backend.purge_logs >> /var/log/vector-purge.log 2>&1
|
||||
# 03:00 — прикладной дамп (planet_osm_* исключены; OSM восстанавливается b16-import.sh)
|
||||
0 3 * * * /root/vector/scripts/backup-app.sh >> /var/log/vector-backup.log 2>&1
|
||||
```
|
||||
|
||||
`backup-app.sh` (в репо, `scripts/`) делает: дамп прикладных таблиц → выгрузка в
|
||||
`/root/backups/` → архив `.env` + `docker-compose.yml` (chmod 600) → проверка
|
||||
оглавления через pg_restore → чистка старше 14 дней.
|
||||
|
||||
Проверка разового прогона: `docker exec vector-backend python -m backend.purge_logs`
|
||||
→ `purge: {'audit_events': N, 'auth_events': N}` (0 — норма).
|
||||
|
||||
**Грабля pg_wrapper PG18** (реальный прогон на CT108, postgres:16): `pg_dump`/`pg_restore`
|
||||
без пути — это клиент PG18. `pg_dump 18` создаёт архив версии 1.16, который `pg_restore 16`
|
||||
не читает (`unsupported version`), и пишет `SET transaction_timeout`, отсутствующий в
|
||||
PG16-сервере. Оба скрипта используют только `/usr/lib/postgresql/16/bin/pg_*`.
|
||||
|
||||
**Docker volume — не backup.** Если сервер умер вместе с диском, volume умер вместе с ним. Что подлежит резервному копированию:
|
||||
|
||||
```text
|
||||
БД (прикладные таблицы + OSM)
|
||||
+ .env (JWT_SECRET, пароль БД, CONTOUR_TOKEN)
|
||||
+ конфигурация compose
|
||||
```
|
||||
|
||||
`backup-app.sh` покрывает БД и конфигурацию. **Копии должны уходить с сервера** —
|
||||
иначе гибель LXC/диска убивает и бэкапы. Варианты (по возрастанию надёжности):
|
||||
|
||||
1. rsync на второй Proxmox-узел по SSH (минимум для пилота):
|
||||
`rsync -az -e ssh /root/backups/ <второй-узел>:/root/backups/vector/` — добавить в backup-app.sh
|
||||
2. Syncthing/NAS (в инфраструктуре Виктора NAS уже есть)
|
||||
3. Для боевого контура МЧС — сетевой шарой организации
|
||||
|
||||
**Backup ≠ восстановление.** Копия проверяется периодическим restore на отдельном стенде:
|
||||
файл читается → БД восстанавливается → backend подключается → расчёт выполняется. Непроверенный restore — это не стратегия, а файл с именем «backup».
|
||||
|
||||
### Восстановление
|
||||
|
||||
```bash
|
||||
/root/vector/scripts/restore-app.sh /root/backups/vector_app_<дата>.dump
|
||||
```
|
||||
|
||||
Скрипт: `--clean --if-exists` (пересоздание объектов из дампа) + TOC без
|
||||
EXTENSION postgis (иначе дропает postgis и падает на зависимостях planet_osm_*).
|
||||
OSM-таблицы не трогает (их в дампе нет). Проверено на CT108: restore → counts
|
||||
(cases/users/audit/password_history) совпали → login ok → analyze 200.
|
||||
|
||||
### RPO и RTO
|
||||
|
||||
| Величина | Вопрос | Рекомендация для пилота |
|
||||
|----------|--------|------------------------|
|
||||
| **RPO** | Сколько данных допустимо потерять | Ежедневный дамп → RPO ≤ 24 ч. Потеря последних суток карточек; активная операция восстановляется из бумажного журнала смены |
|
||||
| **RTO** | Сколько времени допустимо восстанавливать | Развернуть compose + restore дампа на новом LXC ≈ 30–60 мин (без импорта OSM — прикладной дамп + повторный импорт по необходимости; расчёт временно fallback'ится на Overpass) |
|
||||
|
||||
---
|
||||
|
||||
## 16. Мониторинг и логи
|
||||
|
||||
```bash
|
||||
docker compose logs -f # все сервисы
|
||||
docker compose logs -f backend # API + прикладные логи vector.*
|
||||
docker compose logs -f frontend # сборка CRA / ошибки компиляции
|
||||
docker stats # потребление контейнеров
|
||||
tail -f /tmp/vector/vector.log # файл-лог внутри контейнера (ротация 5×2 МБ)
|
||||
```
|
||||
|
||||
Прикладные события (B22, `backend/logging_setup.py`) — префикс `vector.<модуль>`:
|
||||
|
||||
```bash
|
||||
# Все матрасчёты с длительностью
|
||||
docker compose logs backend | grep 'vector.analyze'
|
||||
|
||||
# Входы и блокировки
|
||||
docker compose logs backend | grep -E 'vector\.auth'
|
||||
|
||||
# Отправки в КОНТУР (ok/failed/rejected)
|
||||
docker compose logs backend | grep 'vector.contour'
|
||||
|
||||
# Аудит-записи (операционный след; юридический — в БД audit_events)
|
||||
docker compose logs backend | grep 'vector.audit'
|
||||
```
|
||||
|
||||
Ключевые события: `анализ case=… радиус=… время=…мс` (каждый расчёт),
|
||||
`login_ok/login_failed/lockout` (безопасность), `contour_send_ok/failed` (связь с КОНТУРом),
|
||||
`operation_complete op=… исход=…` (завершение поиска), `admin closed case …` (синхронизация закрытия),
|
||||
`Overpass … недоступен` / `OSM PostGIS недоступен` (геослой ушёл в fallback).
|
||||
|
||||
Уровень детализации: `VectorLOGLEVEL=debug` в `.env` + `docker compose up -d backend` (restart не перечитывает env). Файл-лог отключается `VectorLOGFILE=0`.
|
||||
|
||||
---
|
||||
|
||||
## 17. Диагностика проблем
|
||||
|
||||
### Быстрые проверки
|
||||
|
||||
```bash
|
||||
docker compose ps # 4 контейнера Up/healthy
|
||||
curl -s http://localhost:8000/api/v1/health # {"status":"ok"}
|
||||
df -h && free -h # диск/память
|
||||
```
|
||||
|
||||
### Типовые симптомы
|
||||
|
||||
| Симптом | Причина | Решение |
|
||||
|---------|---------|---------|
|
||||
| Логин даёт 422 | JSON вместо form-urlencoded | Логин строго `application/x-www-form-urlencoded` |
|
||||
| «Session expired or revoked» сразу после логина | Несовпадение sha256-токена | Убедиться, что БД и backend на одной БД; пересоздать сессии |
|
||||
| Селекты подразделений пусты | Не выполнен сид юнитов | `python backend/seed_units.py` |
|
||||
| `alembic upgrade` → DuplicateTable | create_all создал таблицы раньше миграций | `scripts/prod-stamp-backfill.sql`, затем upgrade |
|
||||
| Зоны «всегда вверх-вправо» | Расчёт по case_id терял tnp_lat/lon | Исправлено; при доработках сверять имена полей карточки с входом движка |
|
||||
| Расчёт падает TypeError float×None | Payload содержит None-поля | SearchInput.to_case_data() выбрасывает None — регресс-тест есть |
|
||||
| Правка .env не применилась | restart не перечитывает env | `docker compose up -d backend` (пересоздание) |
|
||||
| Импорт OSM OOM | Мало RAM | Параметры --cache 800 --number-processes 2 уже в скрипте; закрыть лишние сервисы |
|
||||
| КОНТУР недоступен при отправке зон | Нет связи/токена | curl до CONTOUR_API_URL; проверить CONTOUR_TOKEN; после правки env — up -d |
|
||||
| Тайлы карты не грузятся | Нет интернета у браузера | Для полного offline — локальный tile-сервер (в планах) |
|
||||
|
||||
### Диагностика по цепочке (начинать с места проблемы, не с перезапусков)
|
||||
|
||||
**Смена пароля даёт 400 «Пароль должен содержать буквы и цифры» / «совпадает с ранее использованным»** — это B23-политика (`security_settings`), не ошибка: с 25.09.2026 требования сложности и истории исполняются. Пароль, возвращённый ранее, отвергается, пока он в последних `password_history_count` записях.
|
||||
|
||||
**Бэкенд не подключается к БД** — цепочка `backend → docker network → postgres:5432 → authentication → vector_mchs`:
|
||||
|
||||
```bash
|
||||
docker compose logs backend | grep -iE 'error|failed|refused'
|
||||
docker exec vector-backend python -c "from services.osm_local import osm_available; print(osm_available())"
|
||||
docker exec vector-postgres pg_isready -U postgres
|
||||
```
|
||||
|
||||
Проверить: `DATABASE_URL` (хост `postgres`, пароль совпадает с POSTGRES_PASSWORD), общую compose-сеть, состояние БД.
|
||||
|
||||
**500 Internal Server Error** — «500» это результат, а не диагноз. Сначала traceback:
|
||||
|
||||
```bash
|
||||
docker compose logs backend --tail 100
|
||||
```
|
||||
|
||||
Затем определить слой: FastAPI / SQLAlchemy / БД / данные / конфигурация. Прикладной контекст ищется по `vector.*` в логах.
|
||||
|
||||
**Расчёт отдал fallback-зоны («Ближняя зона N / Средняя E»)** — геослой не ответил:
|
||||
|
||||
```bash
|
||||
docker compose logs backend | grep -E 'vector\.(rules|geo)'
|
||||
```
|
||||
|
||||
`Geo/scoring service error` → смотреть причину выше по стеку (PostGIS? Overpass breaker?). При работе на локальном OSM — проверить таблицы:
|
||||
|
||||
```bash
|
||||
docker exec vector-postgres psql -U postgres -d vector_mchs -t -c \
|
||||
"SELECT count(*) FROM planet_osm_line;"
|
||||
```
|
||||
|
||||
**Пользователь не входит** — цепочка `login → bcrypt → lockout → session → JWT`:
|
||||
|
||||
```bash
|
||||
docker compose logs backend | grep 'vector.auth'
|
||||
docker exec vector-postgres psql -U postgres -d vector_mchs -c \
|
||||
"SELECT username, is_active, status, failed_login_count, locked_until FROM users WHERE username='<логин>';"
|
||||
```
|
||||
|
||||
401 после смены JWT_SECRET — все сессии инвалидны, ожидаемо: перелогин. 403 при валидном входе — проверить роль/юнит пользователя (скоуп).
|
||||
|
||||
---
|
||||
|
||||
## 17а. Чек-лист передачи проекта (по образцу РВС)
|
||||
|
||||
### Код
|
||||
|
||||
- [ ] код в Git (Gitea, `Sadmin/vector`), история коммитов осмысленная
|
||||
- [ ] production-конфигурация без паролей в репо (`.env.example` вместо `.env`)
|
||||
- [ ] README актуален (архитектура, API, БД, логирование §13а)
|
||||
- [ ] SETUP_GUIDE актуален (развертывание, диагностика)
|
||||
- [ ] тесты зелёные: `pytest` (239 passed)
|
||||
|
||||
### База
|
||||
|
||||
- [ ] известен production-сервер PostgreSQL и БД `vector_mchs`
|
||||
- [ ] схема актуальна: `alembic current` = `009_e3_operations`
|
||||
- [ ] понятны таблицы OSM (planet_osm_*) и способ их восстановления (дамп + b16-import.sh)
|
||||
- [ ] сиды (users, units) воспроизводимы
|
||||
|
||||
### Docker
|
||||
|
||||
- [ ] понятны compose-файлы, volumes, сети
|
||||
- [ ] понятно, что можно пересоздавать (backend/frontend/adminer), а что содержит данные (postgres volume)
|
||||
- [ ] известен порядок обновления (§14) и отката
|
||||
|
||||
### Security
|
||||
|
||||
- [ ] `JWT_SECRET` не дефолтный; пароль БД сменён
|
||||
- [ ] пароль admin сменён; `must_change_password` для созданных пользователей работает
|
||||
- [ ] `.env` защищён (chmod 600, не в репо)
|
||||
- [ ] внешних ИИ/API-ключей нет (политика закрытого контура, тест `TestNoLLM`)
|
||||
- [ ] Adminer и порты БД не торчат наружу без необходимости
|
||||
|
||||
### Логи и аудит
|
||||
|
||||
- [ ] прикладные логи `vector.*` доступны (`docker compose logs backend`, файл в контейнере)
|
||||
- [ ] аудит 90 дней читается (вкладка «Аудит», право `view_audit`)
|
||||
- [ ] ретенция **исполняется**: cron purge-логов установлен, `python -m backend.purge_logs` даёт счётчики
|
||||
|
||||
### Backup
|
||||
|
||||
- [ ] cron прикладного дампа настроен (`backup-app.sh`, 03:00), копии вне сервера
|
||||
- [ ] первый дамп лежит в `/root/backups/` и проверен `pg_restore --list`
|
||||
- [ ] restore проверен на стенде (`restore-app.sh`), counts совпали
|
||||
- [ ] копии выгружаются вне сервера (rsync на второй узел / NAS) — см. §15
|
||||
- [ ] RPO/RTO осознаны и приняты (§15)
|
||||
|
||||
---
|
||||
|
||||
## 18. Чек-лист развёртывания
|
||||
|
||||
- [ ] Docker + Compose установлены
|
||||
- [ ] Код получен (`/root/vector`), `.env` создан из `.env.example`
|
||||
- [ ] `JWT_SECRET` заменён на случайный; пароль БД сменён (compose + DATABASE_URL)
|
||||
- [ ] `docker compose up -d` — 4 контейнера Up
|
||||
- [ ] `alembic current` = `010_b23_policy`
|
||||
- [ ] cron бэкапа И cron purge-логов настроены (`crontab -l`), первый дамп лежит в /root/backups
|
||||
- [ ] `seed_users.py` выполнен, пароль админа сменён при первом входе
|
||||
- [ ] `seed_units.py` выполнен (142 подразделения в селектах)
|
||||
- [ ] PostGIS установлен в контейнере БД, extension создан
|
||||
- [ ] Дамп OSM импортирован (`b16-import.sh`), таблицы заполнены
|
||||
- [ ] Логин через UI работает, смена пароля обязательна
|
||||
- [ ] Пробный поиск: карточка → анализ → зоны на карте, радиус соответствует контрольным точкам
|
||||
- [ ] Создан тестовый пользователь ОУМЧС — скоуп видимости проверен
|
||||
- [ ] Завершение поиска: исход внесён, кейс в архиве, «прогноз vs факт» отображается
|
||||
- [ ] (опционально) КОНТУР: env-переменные, пересоздание backend, тестовая отправка зон
|
||||
- [ ] (опционально) HTTPS/обратный прокси настроен, CORS обновлён
|
||||
- [ ] Cron резервного копирования настроен, копия выгружена вне сервера
|
||||
- [ ] Внешние ИИ/API-ключи отсутствуют в `.env` и `docker-compose.yml` (политика закрытого контура)
|
||||
|
||||
---
|
||||
|
||||
*Сопровождение: репозиторий [`Sadmin/vector`](http://192.168.0.106:3000/Sadmin/vector), план работ — `vector_tasks.md`, описание системы — [README.md](README.md).*
|
||||
@@ -8,6 +8,7 @@ COPY backend/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend ./backend
|
||||
COPY services ./services
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Alembic для ВЕКТОРа. Запуск:
|
||||
# локально: DATABASE_URL=... .venv-test/bin/alembic -c backend/alembic.ini upgrade head
|
||||
# на стенде: docker exec vector-backend sh -c 'cd /app/backend && alembic upgrade head'
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = ..
|
||||
# URL берётся из env DATABASE_URL (env.py); здесь — дефолт для стенда (docker-compose)
|
||||
sqlalchemy.url = postgresql://postgres:postgres@postgres:5432/vector_mchs
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
"""Alembic scripts package for ВЕКТОР."""
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Alembic environment для ВЕКТОРа.
|
||||
|
||||
URL: env DATABASE_URL > alembic.ini sqlalchemy.url.
|
||||
Метаданные моделей: backend.models (Base).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# backend/ и корень репо в sys.path, чтобы import backend.models работал
|
||||
# при любом CWD (alembic -c backend/alembic.ini из корня или из backend/)
|
||||
_BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
for _p in (_BACKEND_DIR, os.path.dirname(_BACKEND_DIR)):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# URL: env побеждает ini
|
||||
db_url = os.getenv('DATABASE_URL')
|
||||
if db_url:
|
||||
config.set_main_option('sqlalchemy.url', db_url)
|
||||
|
||||
from backend.models import Base # noqa: E402 (после set_main_option)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Запуск без БД (--sql): выгрузить SQL в скрипт."""
|
||||
url = config.get_main_option('sqlalchemy.url')
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={'paramstyle': 'named'},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Обычный запуск: подключиться и накатить."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> ${upgraded if upgraded else "pass"}:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> ${downgrades if downgrades else "pass"}:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,161 @@
|
||||
"""B15: слоистая модель данных — search_models (слой 4, версионирование) +
|
||||
оперативные таблицы (слой 3) + reference_priors (слой 1, пустой справочник).
|
||||
|
||||
Бэкфилл: существующие результаты анализа из cases.analysis_log (формат
|
||||
analyze_case: dict с primary_zones и т.д.) → search_models v1. Строки
|
||||
analysis_log НЕ трогаем (другой формат — pipeline-журнал legacy-анализов).
|
||||
Только JSONB-объекты; массивы/скаляры (legacy-формат) пропускаются.
|
||||
|
||||
Revision ID: 006_b15_layers
|
||||
Revises: None (первая alembic-миграция; схема до этого накатывалась
|
||||
SQL-скриптами migrations/001–005 и Base.metadata.create_all)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql as pg
|
||||
|
||||
revision = '006_b15_layers'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- Слой 4: версионированные поисковые модели ----------
|
||||
op.create_table(
|
||||
'search_models',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('case_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('version', sa.Integer(), nullable=False),
|
||||
sa.Column('input_snapshot', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
|
||||
sa.Column('model_json', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('case_id', 'version', name='uq_search_models_case_version'),
|
||||
)
|
||||
op.create_index('idx_search_models_case_id', 'search_models', ['case_id'])
|
||||
|
||||
# ---------- Слой 3: оперативные данные (схема сейчас, ингестия — B17) ----------
|
||||
op.create_table(
|
||||
'search_teams',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('case_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('members', sa.Integer()),
|
||||
sa.Column('role', sa.String(50)),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_index('idx_search_teams_case_id', 'search_teams', ['case_id'])
|
||||
|
||||
op.create_table(
|
||||
'field_observations',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('case_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('team_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('search_teams.id', ondelete='SET NULL')),
|
||||
sa.Column('obs_type', sa.String(20), nullable=False),
|
||||
sa.Column('geom', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
|
||||
sa.Column('observed_at', sa.DateTime(timezone=True)),
|
||||
sa.Column('confidence', sa.Float()),
|
||||
sa.Column('description', sa.Text()),
|
||||
sa.Column('raw', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
sa.CheckConstraint(
|
||||
"obs_type IN ('position','observation','track','clue','witness')",
|
||||
name='ck_field_observations_obs_type',
|
||||
),
|
||||
)
|
||||
op.create_index('idx_field_observations_case_id', 'field_observations', ['case_id'])
|
||||
op.create_index('idx_field_observations_type', 'field_observations', ['obs_type'])
|
||||
|
||||
op.create_table(
|
||||
'areas_checked',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('case_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('team_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('search_teams.id', ondelete='SET NULL')),
|
||||
sa.Column('geom', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
|
||||
sa.Column('checked_at', sa.DateTime(timezone=True)),
|
||||
sa.Column('result', sa.String(20), server_default='partial'),
|
||||
sa.Column('coverage_pct', sa.Float()),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
sa.CheckConstraint("result IN ('clear','found','partial')",
|
||||
name='ck_areas_checked_result'),
|
||||
)
|
||||
op.create_index('idx_areas_checked_case_id', 'areas_checked', ['case_id'])
|
||||
|
||||
op.create_table(
|
||||
'found_events',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('case_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('geom', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
|
||||
sa.Column('occurred_at', sa.DateTime(timezone=True)),
|
||||
sa.Column('condition', sa.String(50)),
|
||||
sa.Column('team_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('search_teams.id', ondelete='SET NULL')),
|
||||
sa.Column('description', sa.Text()),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_index('idx_found_events_case_id', 'found_events', ['case_id'])
|
||||
|
||||
# ---------- Слой 1: справочник априорных (пустой, B12 заблокирован) ----------
|
||||
op.create_table(
|
||||
'reference_priors',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('source', sa.String(100), nullable=False),
|
||||
sa.Column('category', sa.String(100), nullable=False),
|
||||
sa.Column('value', sa.Float(), nullable=False),
|
||||
sa.Column('units', sa.String(50)),
|
||||
sa.Column('provenance', sa.Text()),
|
||||
sa.Column('version', sa.Integer(), nullable=False, server_default='1'),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
)
|
||||
|
||||
# ---------- Бэкфилл: cases.analysis_log → search_models v1 ----------
|
||||
conn = op.get_bind()
|
||||
rows = conn.execute(sa.text(
|
||||
"SELECT id, analysis_log FROM cases "
|
||||
"WHERE analysis_log IS NOT NULL "
|
||||
"AND jsonb_typeof(analysis_log) = 'object' "
|
||||
"AND analysis_log ? 'primary_zones'"
|
||||
)).fetchall()
|
||||
for case_id, log in rows:
|
||||
# Идемпотентность: если v1 для кейса уже есть — не создаём дубль
|
||||
exists = conn.execute(
|
||||
sa.text("SELECT 1 FROM search_models WHERE case_id = :cid AND version = 1"),
|
||||
{'cid': str(case_id)},
|
||||
).first()
|
||||
if exists:
|
||||
continue
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO search_models (case_id, version, input_snapshot, model_json) "
|
||||
"VALUES (:cid, 1, NULL, CAST(:model AS jsonb))"
|
||||
),
|
||||
{'cid': str(case_id), 'model': json.dumps(log, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Бэкфилл-данные (search_models v1) удаляются вместе с таблицей:
|
||||
# cases.analysis_log не модифицировался, потерь нет.
|
||||
op.drop_table('reference_priors')
|
||||
op.drop_table('found_events')
|
||||
op.drop_table('areas_checked')
|
||||
op.drop_table('field_observations')
|
||||
op.drop_table('search_teams')
|
||||
op.drop_table('search_models')
|
||||
@@ -0,0 +1,167 @@
|
||||
"""E1 B21: RBAC-ядро — подразделения МЧС, роли/права, сессии, auth-журнал.
|
||||
|
||||
Revision ID: 007_e1_rbac
|
||||
Revises: 006_b15_layers
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql as pg
|
||||
|
||||
revision = '007_e1_rbac'
|
||||
down_revision = '006_b15_layers'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- Подразделения ----------
|
||||
op.create_table(
|
||||
'mchs_units',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('name', sa.String(255), nullable=False, unique=True),
|
||||
sa.Column('region', sa.String(100)),
|
||||
sa.Column('kind', sa.String(20), nullable=False),
|
||||
sa.Column('parent_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('mchs_units.id', ondelete='SET NULL')),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
sa.CheckConstraint("kind IN ('rcu','oblast','gor_rayon')", name='ck_mchs_units_kind'),
|
||||
)
|
||||
|
||||
# users: RBAC-колонки
|
||||
op.add_column('users', sa.Column('unit_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('mchs_units.id', ondelete='SET NULL')))
|
||||
op.add_column('users', sa.Column('position', sa.String(255)))
|
||||
op.add_column('users', sa.Column('status', sa.String(20), server_default='active'))
|
||||
op.add_column('users', sa.Column('failed_login_count', sa.Integer(), server_default='0'))
|
||||
op.add_column('users', sa.Column('locked_until', sa.DateTime(timezone=True)))
|
||||
op.add_column('users', sa.Column('must_change_password', sa.Boolean(), server_default=sa.text('false')))
|
||||
op.add_column('users', sa.Column('created_by', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('users.id', ondelete='SET NULL')))
|
||||
op.add_column('users', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('NOW()')))
|
||||
|
||||
# ---------- Роли / права ----------
|
||||
op.create_table(
|
||||
'roles',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('name', sa.String(50), nullable=False, unique=True),
|
||||
sa.Column('description', sa.String(255)),
|
||||
sa.Column('is_system', sa.Boolean(), server_default=sa.text('false')),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_table(
|
||||
'permissions',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('code', sa.String(50), nullable=False, unique=True),
|
||||
sa.Column('description', sa.String(255)),
|
||||
)
|
||||
op.create_table(
|
||||
'role_permissions',
|
||||
sa.Column('role_id', pg.UUID(as_uuid=True), sa.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('permission_id', pg.UUID(as_uuid=True), sa.ForeignKey('permissions.id', ondelete='CASCADE'), primary_key=True),
|
||||
)
|
||||
op.create_table(
|
||||
'user_roles',
|
||||
sa.Column('user_id', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('role_id', pg.UUID(as_uuid=True), sa.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('assigned_by', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL')),
|
||||
sa.Column('assigned_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
)
|
||||
|
||||
# ---------- Сессии / журнал / настройки ----------
|
||||
op.create_table(
|
||||
'sessions',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('token_hash', sa.String(64), nullable=False, unique=True),
|
||||
sa.Column('ip_address', sa.String(45)),
|
||||
sa.Column('user_agent', sa.String(255)),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_seen_at', sa.DateTime(timezone=True)),
|
||||
sa.Column('is_active', sa.Boolean(), server_default=sa.text('true')),
|
||||
)
|
||||
op.create_index('idx_sessions_user_id', 'sessions', ['user_id'])
|
||||
op.create_table(
|
||||
'auth_events',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL')),
|
||||
sa.Column('username', sa.String(100)),
|
||||
sa.Column('event_type', sa.String(30), nullable=False),
|
||||
sa.Column('ip_address', sa.String(64)),
|
||||
sa.Column('user_agent', sa.String(255)),
|
||||
sa.Column('details', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_index('idx_auth_events_created', 'auth_events', ['created_at'])
|
||||
op.create_table(
|
||||
'security_settings',
|
||||
sa.Column('key', sa.String(50), primary_key=True),
|
||||
sa.Column('value', sa.String(255), nullable=False),
|
||||
)
|
||||
|
||||
# ---------- Сид: роли, права, матрица, настройки ----------
|
||||
conn = op.get_bind()
|
||||
roles = [('admin', 'РЦУ РЧС: все права, все операции'),
|
||||
('coordinator', 'ОУМЧС: поиски своей области, управление Г(Р)ОЧС'),
|
||||
('operator', 'Г(Р)ОЧС: свои поиски (create/update)'),
|
||||
('observer', 'Только просмотр')]
|
||||
perms = [('view', 'Просмотр'), ('create', 'Создание'), ('update', 'Изменение'),
|
||||
('delete', 'Удаление'), ('export', 'Экспорт'),
|
||||
('manage_users', 'Управление пользователями'),
|
||||
('manage_roles', 'Управление ролями'),
|
||||
('view_audit', 'Просмотр аудита'),
|
||||
('manage_security', 'Управление настройками безопасности')]
|
||||
matrix = {
|
||||
'admin': tuple(p[0] for p in perms),
|
||||
'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'),
|
||||
'operator': ('view', 'create', 'update'),
|
||||
'observer': ('view',),
|
||||
}
|
||||
for name, desc in roles:
|
||||
conn.execute(sa.text(
|
||||
"INSERT INTO roles (name, description, is_system) VALUES (:n, :d, true) "
|
||||
"ON CONFLICT (name) DO NOTHING"), {'n': name, 'd': desc})
|
||||
for code, desc in perms:
|
||||
conn.execute(sa.text(
|
||||
"INSERT INTO permissions (code, description) VALUES (:c, :d) "
|
||||
"ON CONFLICT (code) DO NOTHING"), {'c': code, 'd': desc})
|
||||
for role_name, codes in matrix.items():
|
||||
for code in codes:
|
||||
conn.execute(sa.text(
|
||||
"INSERT INTO role_permissions (role_id, permission_id) "
|
||||
"SELECT r.id, p.id FROM roles r, permissions p "
|
||||
"WHERE r.name = :rn AND p.code = :pc "
|
||||
"ON CONFLICT DO NOTHING"), {'rn': role_name, 'pc': code})
|
||||
for k, v in {
|
||||
'password_min_length': '8', 'password_require_complexity': 'true',
|
||||
'password_expiry_days': '90', 'password_history_count': '5',
|
||||
'max_failed_logins': '5', 'lockout_duration_minutes': '15',
|
||||
'session_timeout_minutes': '1440', 'session_idle_timeout_minutes': '60',
|
||||
'max_concurrent_sessions': '3', 'audit_retention_days': '90',
|
||||
}.items():
|
||||
conn.execute(sa.text(
|
||||
"INSERT INTO security_settings (key, value) VALUES (:k, :v) "
|
||||
"ON CONFLICT (key) DO NOTHING"), {'k': k, 'v': v})
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('security_settings')
|
||||
op.drop_index('idx_auth_events_created', table_name='auth_events')
|
||||
op.drop_table('auth_events')
|
||||
op.drop_index('idx_sessions_user_id', table_name='sessions')
|
||||
op.drop_table('sessions')
|
||||
op.drop_table('user_roles')
|
||||
op.drop_table('role_permissions')
|
||||
op.drop_table('permissions')
|
||||
op.drop_table('roles')
|
||||
for col in ('updated_at', 'created_by', 'must_change_password', 'locked_until',
|
||||
'failed_login_count', 'status', 'position', 'unit_id'):
|
||||
op.drop_column('users', col)
|
||||
op.drop_table('mchs_units')
|
||||
@@ -0,0 +1,51 @@
|
||||
"""E2 B21: аудит (audit_events + audit_changes, immutable, 90 дней).
|
||||
|
||||
Revision ID: 008_e2_audit
|
||||
Revises: 007_e1_rbac
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql as pg
|
||||
|
||||
revision = '008_e2_audit'
|
||||
down_revision = '007_e1_rbac'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'audit_events',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL')),
|
||||
sa.Column('username', sa.String(100)),
|
||||
sa.Column('event_type', sa.String(40), nullable=False),
|
||||
sa.Column('object_type', sa.String(40)),
|
||||
sa.Column('object_id', sa.String(64)),
|
||||
sa.Column('ip_address', sa.String(64)),
|
||||
sa.Column('user_agent', sa.String(255)),
|
||||
sa.Column('details', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_index('idx_audit_events_created', 'audit_events', ['created_at'])
|
||||
op.create_table(
|
||||
'audit_changes',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('event_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('audit_events.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('field_name', sa.String(100), nullable=False),
|
||||
sa.Column('old_value', sa.Text()),
|
||||
sa.Column('new_value', sa.Text()),
|
||||
)
|
||||
op.create_index('idx_audit_changes_event', 'audit_changes', ['event_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_audit_changes_event', table_name='audit_changes')
|
||||
op.drop_table('audit_changes')
|
||||
op.drop_index('idx_audit_events_created', table_name='audit_events')
|
||||
op.drop_table('audit_events')
|
||||
@@ -0,0 +1,47 @@
|
||||
"""B21/E3: поисковая операция (мультипоиск).
|
||||
|
||||
Revision ID: 009_e3_operations
|
||||
Revises: 008_e2_audit
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql as pg
|
||||
|
||||
revision = '009_e3_operations'
|
||||
down_revision = '008_e2_audit'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'search_operations',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('case_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False, unique=True),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='active'),
|
||||
sa.Column('unit_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('mchs_units.id', ondelete='SET NULL')),
|
||||
sa.Column('contour_operation_id', pg.UUID(as_uuid=True)),
|
||||
sa.Column('created_by', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('users.id', ondelete='SET NULL')),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
sa.Column('closed_at', sa.DateTime(timezone=True)),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('planned','active','paused','completed','archived')",
|
||||
name='ck_search_operations_status',
|
||||
),
|
||||
)
|
||||
op.create_index('idx_search_operations_status', 'search_operations', ['status'])
|
||||
op.create_index('idx_search_operations_unit', 'search_operations', ['unit_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_search_operations_unit', table_name='search_operations')
|
||||
op.drop_index('idx_search_operations_status', table_name='search_operations')
|
||||
op.drop_table('search_operations')
|
||||
@@ -0,0 +1,42 @@
|
||||
"""B23: исполнение политики паролей + реальная ретенция журналов.
|
||||
|
||||
Revises: 009_e3_operations
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql as pg
|
||||
|
||||
revision = '010_b23_policy'
|
||||
down_revision = '009_e3_operations'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Парольная политика: когда пароль был сменён в последний раз
|
||||
op.add_column(
|
||||
'users',
|
||||
sa.Column('password_changed_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('NOW()'), nullable=True),
|
||||
)
|
||||
# История паролей (проверка переиспользования, password_history_count).
|
||||
# id — INTEGER monotonic (обрезка «последние N» детерминирована; created_at
|
||||
# в SQLite точен до секунды, UUID-порядок случайный — см. модели).
|
||||
op.create_table(
|
||||
'password_history',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('public_id', pg.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('hashed_password', sa.String(255), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_index('idx_password_history_user', 'password_history', ['user_id', 'created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_password_history_user', table_name='password_history')
|
||||
op.drop_table('password_history')
|
||||
op.drop_column('users', 'password_changed_at')
|
||||
@@ -0,0 +1,37 @@
|
||||
"""B17/E5: ингестия полевых данных КОНТУРа (колонки areas_checked).
|
||||
|
||||
Revises: 010_b23_policy
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql as pg
|
||||
|
||||
revision = '011_b17_field_data'
|
||||
down_revision = '010_b23_policy'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('areas_checked',
|
||||
sa.Column('contour_area_id', sa.String(64), nullable=True))
|
||||
op.add_column('areas_checked',
|
||||
sa.Column('team_label', sa.String(100), nullable=True))
|
||||
op.add_column('areas_checked',
|
||||
sa.Column('source', sa.String(20),
|
||||
server_default='manual', nullable=False))
|
||||
op.create_index('idx_areas_checked_contour_area',
|
||||
'areas_checked', ['contour_area_id'])
|
||||
op.create_check_constraint(
|
||||
'ck_areas_checked_source', 'areas_checked',
|
||||
"source IN ('manual','contour')")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('ck_areas_checked_source', 'areas_checked', type_='check')
|
||||
op.drop_index('idx_areas_checked_contour_area', table_name='areas_checked')
|
||||
op.drop_column('areas_checked', 'source')
|
||||
op.drop_column('areas_checked', 'team_label')
|
||||
op.drop_column('areas_checked', 'contour_area_id')
|
||||
@@ -0,0 +1 @@
|
||||
"""Alembic migration package for ВЕКТОР."""
|
||||
@@ -1,347 +0,0 @@
|
||||
"""
|
||||
Analysis API endpoints for full case analysis pipeline.
|
||||
|
||||
Pipeline: distance → geo → scoring → psychotype → claude → merged result
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
from database import get_db
|
||||
from models import Case, AnalysisLog, User
|
||||
from api.v1.auth import get_current_user
|
||||
|
||||
# Import services
|
||||
from services.distance_service import calculate_max_distance
|
||||
from services.geo_service import build_search_zones
|
||||
from services.scoring_service import WeightedScorer
|
||||
from services.psychotype_service import (
|
||||
detect_psychotype,
|
||||
get_psychotype_modifiers,
|
||||
get_search_recommendations
|
||||
)
|
||||
from services.claude_service import analyze_case as claude_analyze
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AnalysisRequest(BaseModel):
|
||||
"""Request for full case analysis"""
|
||||
case_id: UUID = Field(..., description="ID случая для анализа")
|
||||
|
||||
|
||||
class ZoneResult(BaseModel):
|
||||
"""Search zone with score and recommendations"""
|
||||
priority: int
|
||||
name: str
|
||||
direction: str
|
||||
distance_km: float
|
||||
score: float
|
||||
reasoning: str
|
||||
forest_pct: Optional[float] = None
|
||||
road_density: Optional[float] = None
|
||||
water_distance_km: Optional[float] = None
|
||||
|
||||
|
||||
class AnalysisResponse(BaseModel):
|
||||
"""Full analysis result"""
|
||||
case_id: UUID
|
||||
analyzed_at: datetime
|
||||
|
||||
# Distance calculation
|
||||
max_distance_km: float
|
||||
|
||||
# Psychotype (if available)
|
||||
psychotype: Optional[str] = None
|
||||
psychotype_modifiers: Optional[Dict[str, Any]] = None
|
||||
psychotype_recommendations: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Zones
|
||||
zones: List[ZoneResult]
|
||||
|
||||
# Claude analysis (if available)
|
||||
urgency: Optional[str] = None
|
||||
key_locations: Optional[List[str]] = None
|
||||
immediate_actions: Optional[List[str]] = None
|
||||
behavioral_prediction: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
|
||||
# Meta
|
||||
execution_time_ms: float
|
||||
services_used: List[str]
|
||||
|
||||
|
||||
class SavedAnalysisResponse(BaseModel):
|
||||
"""Saved analysis result from database"""
|
||||
case_id: UUID
|
||||
analysis_log: Dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@router.post("/analyze", response_model=AnalysisResponse, status_code=200)
|
||||
async def analyze_full_case(
|
||||
request: AnalysisRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Запустить полный анализ случая.
|
||||
|
||||
Пайплайн:
|
||||
1. Distance service - расчет максимальной дистанции
|
||||
2. Geo service - построение зон поиска
|
||||
3. Scoring service - оценка и ранжирование зон
|
||||
4. Psychotype service - определение психотипа (если есть данные)
|
||||
5. Claude service - интеллектуальный анализ (опционально)
|
||||
6. Merge results - объединение результатов
|
||||
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
start_time = time.time()
|
||||
services_used = []
|
||||
|
||||
# 1. Получить случай из БД
|
||||
case = db.query(Case).filter(Case.id == request.case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {request.case_id} not found")
|
||||
|
||||
# Подготовить данные для анализа
|
||||
case_data = {
|
||||
'age': case.age_years,
|
||||
'gender': case.gender,
|
||||
'elapsed_hours': case.elapsed_hours or 1.0,
|
||||
'terrain_primary': case.terrain[0] if case.terrain else 'лес',
|
||||
'season': case.season or 'лето',
|
||||
'temperature_c': case.temperature_c or 20.0,
|
||||
'has_transport': case.has_transport,
|
||||
'has_diagnosis': case.has_diagnosis,
|
||||
'diagnosis_type': case.diagnosis_type or [],
|
||||
'tnp_lat': case.tnp_lat,
|
||||
'tnp_lon': case.tnp_lon,
|
||||
}
|
||||
|
||||
# 2. Distance service - расчет максимальной дистанции
|
||||
try:
|
||||
max_distance = calculate_max_distance(case_data)
|
||||
services_used.append('distance')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Distance calculation failed: {str(e)}")
|
||||
|
||||
# 3. Geo service - построение зон поиска (если есть координаты)
|
||||
zones_data = []
|
||||
if case.tnp_lat and case.tnp_lon:
|
||||
try:
|
||||
zones_data = await build_search_zones(
|
||||
lat=case.tnp_lat,
|
||||
lon=case.tnp_lon,
|
||||
case_data=case_data,
|
||||
max_distance_km=max_distance
|
||||
)
|
||||
services_used.append('geo')
|
||||
except Exception as e:
|
||||
# Geo service опционален, продолжаем без него
|
||||
print(f"Geo service failed: {e}")
|
||||
|
||||
# 4. Scoring service - оценка и ранжирование зон
|
||||
scored_zones = []
|
||||
if zones_data:
|
||||
try:
|
||||
scorer = WeightedScorer()
|
||||
for zone in zones_data:
|
||||
zone_dict = zone.model_dump() if hasattr(zone, 'model_dump') else zone
|
||||
score = scorer.score_zone(zone_dict, case_data, max_distance_km=max_distance)
|
||||
zone_dict['score'] = score
|
||||
zone_dict['reasoning'] = f"Оценка на основе {len(case_data)} факторов"
|
||||
scored_zones.append(zone_dict)
|
||||
|
||||
# Сортировать по score
|
||||
scored_zones.sort(key=lambda z: z.get('score', 0), reverse=True)
|
||||
services_used.append('scoring')
|
||||
except Exception as e:
|
||||
print(f"Scoring service failed: {e}")
|
||||
scored_zones = _create_fallback_zones(max_distance)
|
||||
else:
|
||||
# Если geo не работает, создаем базовые зоны
|
||||
scored_zones = _create_fallback_zones(max_distance)
|
||||
|
||||
# 5. Psychotype service - определение психотипа
|
||||
psychotype = None
|
||||
psychotype_modifiers = None
|
||||
psychotype_recommendations = None
|
||||
|
||||
if case.psychotype_answers:
|
||||
try:
|
||||
psychotype = detect_psychotype(case.psychotype_answers)
|
||||
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
||||
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||
services_used.append('psychotype')
|
||||
|
||||
# Применить модификаторы психотипа к зонам
|
||||
scored_zones = _apply_psychotype_modifiers(scored_zones, psychotype_modifiers)
|
||||
except Exception as e:
|
||||
print(f"Psychotype service failed: {e}")
|
||||
|
||||
# 6. Claude service - интеллектуальный анализ (опционально)
|
||||
urgency = None
|
||||
key_locations = None
|
||||
immediate_actions = None
|
||||
behavioral_prediction = None
|
||||
summary = None
|
||||
|
||||
try:
|
||||
claude_result = await claude_analyze(case_data)
|
||||
urgency = claude_result.urgency
|
||||
key_locations = claude_result.key_locations
|
||||
immediate_actions = claude_result.immediate_actions
|
||||
behavioral_prediction = claude_result.behavioral_prediction
|
||||
summary = claude_result.summary
|
||||
services_used.append('claude')
|
||||
except Exception as e:
|
||||
# Claude опционален, продолжаем без него
|
||||
print(f"Claude service failed: {e}")
|
||||
|
||||
# 7. Формируем результат
|
||||
zones_result = [
|
||||
ZoneResult(
|
||||
priority=i + 1,
|
||||
name=zone.get('name', f"Зона {zone.get('direction', 'N')}"),
|
||||
direction=zone.get('direction', 'N'),
|
||||
distance_km=zone.get('distance_km', 0),
|
||||
score=zone.get('score', 0),
|
||||
reasoning=zone.get('reasoning', 'Автоматическая оценка'),
|
||||
forest_pct=zone.get('forest_pct'),
|
||||
road_density=zone.get('road_density'),
|
||||
water_distance_km=zone.get('water_distance_km')
|
||||
)
|
||||
for i, zone in enumerate(scored_zones[:10]) # Топ-10 зон
|
||||
]
|
||||
|
||||
execution_time = (time.time() - start_time) * 1000
|
||||
|
||||
# 8. Сохранить результат в analysis_log
|
||||
analysis_result = {
|
||||
'max_distance_km': max_distance,
|
||||
'psychotype': psychotype,
|
||||
'psychotype_modifiers': psychotype_modifiers,
|
||||
'zones': [z.model_dump() for z in zones_result],
|
||||
'urgency': urgency,
|
||||
'key_locations': key_locations,
|
||||
'immediate_actions': immediate_actions,
|
||||
'summary': summary,
|
||||
'services_used': services_used,
|
||||
'execution_time_ms': execution_time
|
||||
}
|
||||
|
||||
# Обновить case.analysis_log
|
||||
case.analysis_log = analysis_result
|
||||
db.commit()
|
||||
|
||||
# Создать запись в AnalysisLog
|
||||
log_entry = AnalysisLog(
|
||||
case_id=case.id,
|
||||
user_id=current_user.id,
|
||||
analysis_type='full_pipeline',
|
||||
input_data={'case_id': str(case.id)},
|
||||
output_data=analysis_result,
|
||||
execution_time=execution_time / 1000,
|
||||
status='success'
|
||||
)
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
|
||||
return AnalysisResponse(
|
||||
case_id=case.id,
|
||||
analyzed_at=datetime.utcnow(),
|
||||
max_distance_km=max_distance,
|
||||
psychotype=psychotype,
|
||||
psychotype_modifiers=psychotype_modifiers,
|
||||
psychotype_recommendations=psychotype_recommendations,
|
||||
zones=zones_result,
|
||||
urgency=urgency,
|
||||
key_locations=key_locations,
|
||||
immediate_actions=immediate_actions,
|
||||
behavioral_prediction=behavioral_prediction,
|
||||
summary=summary,
|
||||
execution_time_ms=execution_time,
|
||||
services_used=services_used
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analyze/{case_id}", response_model=SavedAnalysisResponse)
|
||||
async def get_saved_analysis(
|
||||
case_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Получить сохранённый результат анализа.
|
||||
|
||||
Возвращает последний analysis_log из таблицы cases.
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
|
||||
if not case.analysis_log:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No analysis found for case {case_id}. Run POST /analyze first."
|
||||
)
|
||||
|
||||
return SavedAnalysisResponse(
|
||||
case_id=case.id,
|
||||
analysis_log=case.analysis_log,
|
||||
created_at=case.created_at
|
||||
)
|
||||
|
||||
|
||||
def _create_fallback_zones(max_distance: float) -> List[Dict[str, Any]]:
|
||||
"""Create basic zones when geo/scoring services fail"""
|
||||
directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||
zones = []
|
||||
|
||||
for i, direction in enumerate(directions):
|
||||
zones.append({
|
||||
'direction': direction,
|
||||
'distance_km': max_distance * 0.8,
|
||||
'score': 100 - (i * 10),
|
||||
'name': f"Сектор {direction}",
|
||||
'reasoning': 'Базовая оценка (сервисы недоступны)'
|
||||
})
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
def _apply_psychotype_modifiers(zones: List[Dict], modifiers: Dict) -> List[Dict]:
|
||||
"""Apply psychotype modifiers to zone scores"""
|
||||
if not modifiers:
|
||||
return zones
|
||||
|
||||
# Применяем модификаторы зон из психотипа
|
||||
for zone in zones:
|
||||
distance = zone.get('distance_km', 0)
|
||||
|
||||
# Определяем зону дистанции
|
||||
if distance < 0.5:
|
||||
modifier = modifiers.get('zone_0_500', 1.0)
|
||||
elif distance < 1.5:
|
||||
modifier = modifiers.get('zone_500_1500', 1.0)
|
||||
elif distance < 2.5:
|
||||
modifier = modifiers.get('zone_1500_2500', 1.0)
|
||||
else:
|
||||
modifier = modifiers.get('zone_2500plus', 1.0)
|
||||
|
||||
# Применяем модификатор к score
|
||||
zone['score'] = zone.get('score', 0) * modifier
|
||||
zone['reasoning'] += f" (психотип: ×{modifier:.1f})"
|
||||
|
||||
# Пересортировать по score
|
||||
zones.sort(key=lambda z: z.get('score', 0), reverse=True)
|
||||
|
||||
return zones
|
||||
@@ -1,23 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AnalysisRequest(BaseModel):
|
||||
age: int
|
||||
gender: str
|
||||
terrain: str
|
||||
|
||||
|
||||
class AnalysisResult(BaseModel):
|
||||
recommendation: str
|
||||
estimated_radius_km: float
|
||||
|
||||
|
||||
@router.post("/text", response_model=AnalysisResult)
|
||||
async def analyze_text(request: AnalysisRequest):
|
||||
return AnalysisResult(
|
||||
recommendation="Placeholder analysis",
|
||||
estimated_radius_km=2.5
|
||||
)
|
||||
@@ -1,196 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from jose import JWTError, jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
import bcrypt
|
||||
|
||||
from database import get_db
|
||||
from models import User
|
||||
from schemas import UserOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# JWT настройки
|
||||
SECRET_KEY = os.getenv("JWT_SECRET", "change-me-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Проверка пароля через bcrypt напрямую"""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Хеширование пароля через bcrypt напрямую"""
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
"""Создание JWT токена"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str):
|
||||
"""Аутентификация пользователя"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return False
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""Получение текущего пользователя из JWT токена"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username, role=payload.get("role"))
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.username == token_data.username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Проверка активности пользователя"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
||||
|
||||
|
||||
def require_role(allowed_roles: list[str]):
|
||||
"""Dependency для проверки роли пользователя"""
|
||||
async def role_checker(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied. Required roles: {', '.join(allowed_roles)}"
|
||||
)
|
||||
return current_user
|
||||
return role_checker
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Аутентификация и получение JWT токена.
|
||||
|
||||
Используйте username и password для получения access_token.
|
||||
Токен действителен 24 часа.
|
||||
|
||||
Тестовые пользователи:
|
||||
- operator / pass123
|
||||
- field / pass123
|
||||
- admin / pass123
|
||||
"""
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Обновляем last_login
|
||||
user.last_login = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username, "role": user.role},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def read_users_me(current_user: User = Depends(get_current_active_user)):
|
||||
"""
|
||||
Получить информацию о текущем пользователе.
|
||||
|
||||
Требуется валидный JWT токен в заголовке Authorization: Bearer <token>
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def register_user(
|
||||
username: str,
|
||||
email: str,
|
||||
password: str,
|
||||
full_name: Optional[str] = None,
|
||||
role: str = "operator",
|
||||
current_user: User = Depends(require_role(["admin"])),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Регистрация нового пользователя (только для admin).
|
||||
|
||||
Доступные роли: operator, field, admin
|
||||
"""
|
||||
# Проверка существования пользователя
|
||||
if db.query(User).filter(User.username == username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
if db.query(User).filter(User.email == email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Создание пользователя
|
||||
hashed_password = get_password_hash(password)
|
||||
db_user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
hashed_password=hashed_password,
|
||||
full_name=full_name,
|
||||
role=role
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
@@ -1,212 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from database import get_db
|
||||
from models import Case, User
|
||||
from schemas import CaseCreate, CaseOut
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Импортируем auth dependencies
|
||||
import sys
|
||||
sys.path.append('/app/api/v1')
|
||||
from auth import get_current_user, require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CaseUpdate(BaseModel):
|
||||
"""Schema for updating case fields"""
|
||||
# Ребёнок
|
||||
child_name: Optional[str] = None
|
||||
age_years: Optional[int] = None
|
||||
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: Optional[bool] = None
|
||||
diagnosis_type: Optional[List[str]] = None
|
||||
fitness_level: Optional[str] = None
|
||||
has_transport: Optional[str] = None
|
||||
cant_swim: Optional[bool] = None
|
||||
|
||||
# Психотип
|
||||
psychotype: Optional[str] = None
|
||||
psychotype_answers: Optional[dict] = None
|
||||
|
||||
# Обстоятельства
|
||||
loss_reason: Optional[str] = None
|
||||
loss_time: Optional[str] = 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: Optional[bool] = None
|
||||
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
|
||||
|
||||
# Статус
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class CaseListResponse(BaseModel):
|
||||
"""Response for list endpoint with pagination"""
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
cases: List[CaseOut]
|
||||
|
||||
|
||||
@router.post("/cases", response_model=CaseOut, status_code=201)
|
||||
async def create_case(
|
||||
case_data: CaseCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Создать новый случай поиска.
|
||||
|
||||
Принимает все поля из формы опроса (5 шагов).
|
||||
Публичный эндпоинт - не требует аутентификации.
|
||||
"""
|
||||
db_case = Case(**case_data.model_dump(exclude_unset=True))
|
||||
db.add(db_case)
|
||||
db.commit()
|
||||
db.refresh(db_case)
|
||||
return db_case
|
||||
|
||||
|
||||
@router.get("/cases", response_model=CaseListResponse)
|
||||
async def list_cases(
|
||||
skip: int = Query(0, ge=0, description="Количество пропускаемых записей"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Максимум записей на страницу"),
|
||||
status: Optional[str] = Query(None, description="Фильтр по статусу: active/closed/archived"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Получить список случаев с пагинацией и фильтрацией.
|
||||
|
||||
- **skip**: смещение (для пагинации)
|
||||
- **limit**: количество записей (макс 100)
|
||||
- **status**: фильтр по статусу (active/closed/archived)
|
||||
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
query = db.query(Case)
|
||||
|
||||
if status:
|
||||
query = query.filter(Case.status == status)
|
||||
|
||||
total = query.count()
|
||||
cases = query.order_by(desc(Case.created_at)).offset(skip).limit(limit).all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"cases": cases
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}", response_model=CaseOut)
|
||||
async def get_case(
|
||||
case_id: UUID,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получить случай по ID.
|
||||
|
||||
Возвращает все поля случая включая исход (если заполнен).
|
||||
Публичный эндпоинт - не требует аутентификации.
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
return case
|
||||
|
||||
|
||||
@router.patch("/cases/{case_id}", response_model=CaseOut)
|
||||
async def update_case(
|
||||
case_id: UUID,
|
||||
case_update: CaseUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Обновить случай (частичное обновление).
|
||||
|
||||
Используется для:
|
||||
- Корректировки данных опроса
|
||||
- Внесения исхода поиска (found_alive, found_distance_km и т.д.)
|
||||
- Изменения статуса (active → closed)
|
||||
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
|
||||
update_data = case_update.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(case, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(case)
|
||||
return case
|
||||
|
||||
|
||||
@router.delete("/cases/{case_id}", status_code=204)
|
||||
async def delete_case(
|
||||
case_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
"""
|
||||
Удалить случай (только для admin).
|
||||
|
||||
В продакшене рекомендуется использовать архивацию вместо удаления.
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
|
||||
db.delete(case)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -1,159 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, List, Optional, Literal
|
||||
from database import get_db
|
||||
from models import Case
|
||||
from services.stats_service import (
|
||||
get_dashboard_stats,
|
||||
get_statistical_recommendation,
|
||||
get_heatmap_with_cache,
|
||||
DashboardStats,
|
||||
StatisticalRecommendation
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class HeatmapPoint(BaseModel):
|
||||
lat: float
|
||||
lon: float
|
||||
intensity: float
|
||||
case_id: str
|
||||
metadata: Dict
|
||||
|
||||
|
||||
class HeatmapResponse(BaseModel):
|
||||
points: List[HeatmapPoint]
|
||||
total: int
|
||||
filters_applied: Dict
|
||||
|
||||
|
||||
class StatisticalRecommendationRequest(BaseModel):
|
||||
age: int
|
||||
season: Optional[str] = None
|
||||
terrain_primary: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/summary", response_model=DashboardStats)
|
||||
async def get_summary(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Получить агрегированную статистику для дашборда.
|
||||
|
||||
Возвращает:
|
||||
- Общее количество случаев (всего, активных, закрытых)
|
||||
- Распределение по полу, возрасту, психотипу, диагнозам, сезонам
|
||||
- Средняя дистанция находки
|
||||
- Средняя длительность поиска
|
||||
- Процент выживаемости
|
||||
"""
|
||||
return get_dashboard_stats(db)
|
||||
|
||||
|
||||
@router.post("/recommendation", response_model=StatisticalRecommendation)
|
||||
async def get_recommendation(
|
||||
request: StatisticalRecommendationRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получить статистические рекомендации на основе похожих случаев.
|
||||
|
||||
Фильтры (в порядке приоритета):
|
||||
1. Возраст ±2 года + сезон + terrain
|
||||
2. Возраст ±2 года + сезон (если < 5 случаев)
|
||||
3. Возраст ±2 года (если < 5 случаев)
|
||||
4. Все случаи (если < 5 случаев)
|
||||
|
||||
Возвращает:
|
||||
- Медианное расстояние находки
|
||||
- Топ-3 направления с процентами
|
||||
- Топ-5 типов локаций
|
||||
- Процент выживаемости
|
||||
- Размер выборки
|
||||
- Использованные фильтры
|
||||
"""
|
||||
case_data = {
|
||||
'age': request.age,
|
||||
'season': request.season,
|
||||
'terrain_primary': request.terrain_primary
|
||||
}
|
||||
return get_statistical_recommendation(case_data, db)
|
||||
|
||||
|
||||
@router.get("/heatmap", response_model=HeatmapResponse)
|
||||
async def get_heatmap(
|
||||
map_type: Literal['all', 'age', 'season', 'outcome'] = Query(
|
||||
'all',
|
||||
description="Тип карты: all (все точки), age (по возрасту), season (по сезону), outcome (по исходу)"
|
||||
),
|
||||
age_group: Optional[str] = Query(
|
||||
None,
|
||||
description="Возрастная группа: 0-3, 4-7, 8-11, 12-14, 15-17, 18+"
|
||||
),
|
||||
season: Optional[str] = Query(
|
||||
None,
|
||||
description="Сезон: зима, весна, лето, осень"
|
||||
),
|
||||
year_from: Optional[int] = Query(
|
||||
None,
|
||||
description="Год начала периода (например, 2020)"
|
||||
),
|
||||
year_to: Optional[int] = Query(
|
||||
None,
|
||||
description="Год окончания периода (например, 2026)"
|
||||
),
|
||||
outcome: Optional[str] = Query(
|
||||
None,
|
||||
description="Исход: alive (выжил), deceased (погиб)"
|
||||
),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получить данные для тепловой карты находок с кэшированием (1 час).
|
||||
|
||||
**Типы карт:**
|
||||
- `all` - все точки с одинаковой интенсивностью
|
||||
- `age` - интенсивность зависит от возраста (младше = выше)
|
||||
- `season` - интенсивность зависит от сезона (зима = выше)
|
||||
- `outcome` - интенсивность зависит от исхода (погиб = выше)
|
||||
|
||||
**Фильтры:**
|
||||
- `age_group` - возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17, 18+)
|
||||
- `season` - сезон (зима, весна, лето, осень)
|
||||
- `year_from`, `year_to` - период по годам
|
||||
- `outcome` - исход (alive, deceased)
|
||||
|
||||
**Кэширование:**
|
||||
Результаты кэшируются на 1 час для ускорения повторных запросов.
|
||||
|
||||
**Возвращает:**
|
||||
- `points` - массив точек с координатами, интенсивностью и метаданными
|
||||
- `total` - общее количество точек
|
||||
- `filters_applied` - примененные фильтры
|
||||
"""
|
||||
result = get_heatmap_with_cache(
|
||||
db=db,
|
||||
map_type=map_type,
|
||||
age_group=age_group,
|
||||
season=season,
|
||||
year_from=year_from,
|
||||
year_to=year_to,
|
||||
outcome=outcome
|
||||
)
|
||||
|
||||
points = [
|
||||
HeatmapPoint(
|
||||
lat=p['lat'],
|
||||
lon=p['lon'],
|
||||
intensity=p['intensity'],
|
||||
case_id=p['case_id'],
|
||||
metadata=p['metadata']
|
||||
)
|
||||
for p in result['points']
|
||||
]
|
||||
|
||||
return HeatmapResponse(
|
||||
points=points,
|
||||
total=result['total'],
|
||||
filters_applied=result['filters_applied']
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""B21/E2: аудит-хелперы и скоуп подразделений.
|
||||
|
||||
audit_log() — запись действия в audit_events (+audit_changes для field-level).
|
||||
visible_unit_ids() — гео-скоуп: [unit_id] своё подразделение + потомки;
|
||||
для РЦУ РЧС-admin — None (без фильтра).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.models import AuditChange, AuditEvent, SecuritySetting, User
|
||||
|
||||
log = get_logger('audit')
|
||||
|
||||
|
||||
def _client_meta(request) -> tuple[str | None, str | None]:
|
||||
if request is None:
|
||||
return None, None
|
||||
ip = request.client.host if request.client else None
|
||||
return ip, (request.headers.get('user-agent') or '')[:255]
|
||||
|
||||
|
||||
def audit_log(
|
||||
db: Session,
|
||||
actor: User | None,
|
||||
event_type: str,
|
||||
object_type: str | None = None,
|
||||
object_id: str | None = None,
|
||||
changes: dict[str, tuple] | None = None,
|
||||
request=None,
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
"""Записать действие в аудит. changes = {field: (old, new)}."""
|
||||
ip, ua = _client_meta(request)
|
||||
event = AuditEvent(
|
||||
user_id=getattr(actor, 'id', None),
|
||||
username=getattr(actor, 'username', None),
|
||||
event_type=event_type,
|
||||
object_type=object_type,
|
||||
object_id=str(object_id) if object_id else None,
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
details=details or {},
|
||||
)
|
||||
db.add(event)
|
||||
db.flush()
|
||||
for field, (old, new) in (changes or {}).items():
|
||||
db.add(AuditChange(
|
||||
event_id=event.id,
|
||||
field_name=field,
|
||||
old_value=None if old is None else str(old),
|
||||
new_value=None if new is None else str(new),
|
||||
))
|
||||
db.commit()
|
||||
log.info('audit %s user=%s obj=%s/%s ip=%s', event_type,
|
||||
event.username or '—', object_type or '—', event.object_id or '—',
|
||||
event.ip_address or '—')
|
||||
|
||||
|
||||
def audit_retention_days(db: Session) -> int:
|
||||
row = db.query(SecuritySetting).filter(SecuritySetting.key == 'audit_retention_days').first()
|
||||
return int(row.value) if row else 90
|
||||
|
||||
|
||||
def _uuid_hex(value) -> str:
|
||||
"""UUID → hex-строка без дефисов (UUID-колонка хранит hex в sqlite-тестах)."""
|
||||
if hasattr(value, 'hex'):
|
||||
return value.hex
|
||||
return uuid.UUID(str(value)).hex
|
||||
|
||||
|
||||
def visible_unit_ids(db: Session, user: User) -> list[str] | None:
|
||||
"""Гео-скоуп: [unit_id] свои + подчинённые подразделения.
|
||||
None = без фильтра (РЦУ РЧС/admin)."""
|
||||
import uuid
|
||||
unit_id = getattr(user, 'unit_id', None)
|
||||
if not unit_id:
|
||||
return None # РЦУ РЧС или legacy-пользователь без юнита — видит всё
|
||||
rows = db.execute(
|
||||
text("""
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id FROM mchs_units WHERE id = :uid
|
||||
UNION ALL
|
||||
SELECT u.id FROM mchs_units u JOIN tree t ON u.parent_id = t.id
|
||||
)
|
||||
SELECT id FROM tree
|
||||
"""),
|
||||
{'uid': _uuid_hex(unit_id)},
|
||||
).fetchall()
|
||||
return [str(r[0]) for r in rows]
|
||||
|
||||
|
||||
def can_access_unit(db: Session, user: User, target_unit_id: str | None) -> bool:
|
||||
"""Может ли пользователь действовать над объектом принадлежащим target_unit_id."""
|
||||
scope = visible_unit_ids(db, user)
|
||||
if scope is None:
|
||||
return True
|
||||
if not target_unit_id:
|
||||
return False
|
||||
return _uuid_hex(target_unit_id) in scope
|
||||
@@ -1,39 +0,0 @@
|
||||
import models
|
||||
from sqlalchemy import inspect
|
||||
|
||||
mapper = inspect(models.Case)
|
||||
columns = [c.key for c in mapper.columns]
|
||||
print(f"Всего полей в модели Case: {len(columns)}")
|
||||
print("\nПоля по категориям:")
|
||||
print("\nРебёнок (Шаг 1):")
|
||||
for c in columns:
|
||||
if c in ["child_name", "age_years", "gender", "height_build", "clothes_upper", "clothes_lower", "shoes", "clothes_description", "special_marks", "phone_status"]:
|
||||
print(f" - {c}")
|
||||
print("\nЗдоровье (Шаг 2):")
|
||||
for c in columns:
|
||||
if c in ["has_diagnosis", "diagnosis_type", "fitness_level", "has_transport", "cant_swim"]:
|
||||
print(f" - {c}")
|
||||
print("\nПсихотип (Шаг 2б):")
|
||||
for c in columns:
|
||||
if c in ["psychotype", "psychotype_answers"]:
|
||||
print(f" - {c}")
|
||||
print("\nОбстоятельства (Шаг 3):")
|
||||
for c in columns:
|
||||
if c in ["loss_reason", "loss_time", "elapsed_hours", "last_seen_direction", "last_seen_reliability", "last_seen_description", "behavior_description", "familiar_places", "lost_before"]:
|
||||
print(f" - {c}")
|
||||
print("\nСреда (Шаг 4):")
|
||||
for c in columns:
|
||||
if c in ["season", "temperature_c", "precipitation", "visibility", "wind", "terrain"]:
|
||||
print(f" - {c}")
|
||||
print("\nGPS (Шаг 4):")
|
||||
for c in columns:
|
||||
if c in ["tnp_lat", "tnp_lon", "tnp_address"]:
|
||||
print(f" - {c}")
|
||||
print("\nРесурсы (Шаг 5):")
|
||||
for c in columns:
|
||||
if c in ["teams_count", "team_size", "has_dog", "extra_resources"]:
|
||||
print(f" - {c}")
|
||||
print("\nИсход:")
|
||||
for c in columns:
|
||||
if c in ["found_alive", "found_distance_km", "found_direction", "found_location_type", "found_lat", "found_lon", "search_duration_hours", "who_found"]:
|
||||
print(f" - {c}")
|
||||
+23
-1
@@ -67,6 +67,10 @@ def _normalize_case_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
'last_known_place': 'tnp_address',
|
||||
'reason': 'loss_reason',
|
||||
'clothes_shoes': 'shoes',
|
||||
# Форма шлёт lost_time (datetime-local), модель хранит loss_time.
|
||||
# До 2026-09-09 время пропажи молча терялось (Pydantic игнорирует
|
||||
# неизвестные поля) — elapsed_hours приходилось вводить руками.
|
||||
'lost_time': 'loss_time',
|
||||
}
|
||||
for source, target in aliases.items():
|
||||
if source in normalized and target not in normalized:
|
||||
@@ -269,13 +273,31 @@ class SQLCaseRepository:
|
||||
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)
|
||||
distances = [c.found_distance_km for c in cases if c.found_distance_km is not None]
|
||||
# Активность 7 дней: кейсы, созданные за последнюю неделю (было: = total)
|
||||
from datetime import timedelta as _td
|
||||
cutoff = datetime.now(timezone.utc) - _td(days=7)
|
||||
recent = sum(1 for c in cases
|
||||
if c.created_at is not None
|
||||
and (c.created_at if c.created_at.tzinfo else c.created_at.replace(tzinfo=timezone.utc)) >= cutoff)
|
||||
# Счётчики операций (B21) — для дашборда админа
|
||||
active_ops = completed_ops = 0
|
||||
try:
|
||||
from backend.models import SearchOperation as _Op
|
||||
with SessionLocal() as session:
|
||||
from sqlalchemy import select as _select, func as _func
|
||||
active_ops = session.query(_func.count(_Op.id)).filter(_Op.status.in_(['planned', 'active', 'paused'])).scalar() or 0
|
||||
completed_ops = session.query(_func.count(_Op.id)).filter(_Op.status.in_(['completed', 'archived'])).scalar() or 0
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
'total_cases': total,
|
||||
'found_alive_count': found_alive,
|
||||
'found_deceased_count': found_deceased,
|
||||
'unknown_outcome_count': total - found_alive - found_deceased,
|
||||
'median_found_distance_km': median(distances) if distances else None,
|
||||
'recent_activity_count': total,
|
||||
'recent_activity_count': recent,
|
||||
'active_operations': active_ops,
|
||||
'completed_operations': completed_ops,
|
||||
}
|
||||
|
||||
def heatmap(self) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Единая настройка логирования ВЕКТОРа (B22).
|
||||
|
||||
Все модульные логгеры проекта — `logging.getLogger('vector.<модуль>')` —
|
||||
конфигурируются один раз при старте приложения (backend.main → setup_logging()).
|
||||
|
||||
Формат: время, уровень, логгер, сообщение. Вывод — stdout (контейнер),
|
||||
дополнительно файл /tmp/vector/vector.log внутри контейнера (ротация 5×2 МБ).
|
||||
|
||||
Наблюдаемые события (ключевые слова для grep по логам):
|
||||
vector.analyze — анализ: case_id, радиус, зоны, время расчёта
|
||||
vector.auth — login_ok / login_failed / lockout / logout
|
||||
vector.contour — отправка зон в КОНТУР (ok/failed)
|
||||
vector.admin — синхронизация закрытия кейса
|
||||
vector.audit — запись аудит-события (кто, что)
|
||||
vector.geo — геослой: PostGIS-режим, fallback на Overpass, ошибки
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_LOG_FORMAT = '%(asctime)s %(levelname)s %(name)s: %(message)s'
|
||||
_LOG_DATEFMT = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
# Порог вывода в stdout: INFO на сервере, DEBUG при VectorLOGLEVEL=debug
|
||||
_LEVEL = os.getenv('VectorLOGLEVEL', 'info').upper()
|
||||
|
||||
# Файл-хэндлер: по умолчанию включён (внутри контейнера /tmp/vector).
|
||||
_FILE_ENABLED = os.getenv('VectorLOGFILE', '1').strip().lower() not in {'0', 'false', 'no'}
|
||||
_FILE_DIR = Path(os.getenv('VectorLOGDIR', '/tmp/vector'))
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Конфигурация корневого пакета 'vector'. Идемпотентна."""
|
||||
root = logging.getLogger('vector')
|
||||
if getattr(root, '_vector_configured', False):
|
||||
return
|
||||
|
||||
root.setLevel(logging.DEBUG)
|
||||
fmt = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATEFMT)
|
||||
|
||||
stream = logging.StreamHandler()
|
||||
stream.setLevel(getattr(logging, _LEVEL if _LEVEL in {'DEBUG', 'INFO', 'WARNING', 'ERROR'} else 'INFO'))
|
||||
stream.setFormatter(fmt)
|
||||
root.addHandler(stream)
|
||||
|
||||
if _FILE_ENABLED:
|
||||
try:
|
||||
_FILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
_FILE_DIR / 'vector.log',
|
||||
maxBytes=2 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
encoding='utf-8',
|
||||
)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(fmt)
|
||||
root.addHandler(file_handler)
|
||||
except OSError:
|
||||
root.warning('Файл-лог недоступен (%s) — только stdout', _FILE_DIR)
|
||||
|
||||
# uvicorn-логи ошибок приложения (traceback-и) идут тем же форматом
|
||||
uvicorn_error = logging.getLogger('uvicorn.error')
|
||||
uvicorn_error.handlers.clear()
|
||||
uvicorn_error.propagate = True
|
||||
|
||||
root._vector_configured = True # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Модульный логгер: get_logger('analyze') → 'vector.analyze'."""
|
||||
short = name.removeprefix('vector.')
|
||||
return logging.getLogger(f'vector.{short}')
|
||||
+27
-6
@@ -1,17 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from backend.database import init_db
|
||||
from backend.logging_setup import setup_logging
|
||||
from backend.routers.admin import router as admin_router
|
||||
from backend.routers.admin_users import router as admin_users_router
|
||||
from backend.routers.operations import router as operations_router
|
||||
from backend.routers.contour import router as contour_router
|
||||
from backend.routers.analyze import router as analyze_router
|
||||
from backend.routers.auth import router as auth_router
|
||||
from backend.routers.cases import router as cases_router
|
||||
from backend.routers.closed_cases import router as closed_cases_router
|
||||
from backend.routers.geocode import router as geocode_router
|
||||
from backend.routers.health import router as health_router
|
||||
from backend.routers.stats import router as stats_router
|
||||
from backend.routers.water import router as water_router
|
||||
|
||||
|
||||
def _parse_origins(value: str) -> list[str]:
|
||||
@@ -24,7 +33,18 @@ allow_credentials = os.getenv('CORS_ALLOW_CREDENTIALS', 'true').strip().lower()
|
||||
if '*' in cors_origins:
|
||||
allow_credentials = False
|
||||
|
||||
app = FastAPI(title='Vector API', version='0.1.0')
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
setup_logging()
|
||||
log = logging.getLogger('vector.app')
|
||||
log.info('Vector API стартует: CORS=%s', ','.join(cors_origins))
|
||||
init_db()
|
||||
yield
|
||||
log.info('Vector API остановлен')
|
||||
|
||||
|
||||
app = FastAPI(title='Vector API', version='0.1.0', lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -35,17 +55,18 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
@app.on_event('startup')
|
||||
def startup() -> None:
|
||||
init_db()
|
||||
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(analyze_router)
|
||||
app.include_router(cases_router)
|
||||
app.include_router(closed_cases_router)
|
||||
app.include_router(stats_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(admin_users_router)
|
||||
app.include_router(operations_router)
|
||||
app.include_router(contour_router)
|
||||
app.include_router(water_router)
|
||||
app.include_router(geocode_router)
|
||||
|
||||
|
||||
@app.get('/')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
@@ -23,6 +24,18 @@ class User(Base):
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
last_login = Column(DateTime)
|
||||
|
||||
# B21/E1 additions
|
||||
unit_id = Column(UUID(as_uuid=True), ForeignKey("mchs_units.id", ondelete="SET NULL"))
|
||||
position = Column(String(255))
|
||||
status = Column(String(20), default="active") # active|locked|disabled
|
||||
failed_login_count = Column(Integer, default=0)
|
||||
locked_until = Column(DateTime(timezone=True))
|
||||
must_change_password = Column(Boolean, default=False)
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
updated_at = Column(DateTime, server_default=func.now())
|
||||
# B23: политика паролей (expiry/history исполняется в routers/auth.py)
|
||||
password_changed_at = Column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Case(Base):
|
||||
"""Unified case model - combines search case and result."""
|
||||
@@ -117,3 +130,292 @@ class RawDocument(Base):
|
||||
raw_text = Column(Text)
|
||||
extracted_json = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# ==================== B15: слоистая модель данных (B13-принцип 2) ====================
|
||||
# Слой 4 (результат): search_models — версионированные поисковые модели (фундамент B18)
|
||||
# Слой 3 (оперативные): field_observations / search_teams / areas_checked / found_events
|
||||
# — схема+модели сейчас, ингестия в B17
|
||||
# Слой 1 (исходные): reference_priors — версионированный справочник априорных
|
||||
# (заглушка под B12/ISRID и экспертные значения; данные вносятся позже)
|
||||
|
||||
|
||||
class SearchModelRecord(Base):
|
||||
"""Слой 4: версия поисковой модели для случая (per-case версионирование)."""
|
||||
__tablename__ = "search_models"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
||||
version = Column(Integer, nullable=False)
|
||||
input_snapshot = Column(JSONB)
|
||||
model_json = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint("case_id", "version", name="uq_search_models_case_version"),
|
||||
)
|
||||
|
||||
|
||||
class SearchTeam(Base):
|
||||
"""Слой 3: поисковая группа (ингестия — B17)."""
|
||||
__tablename__ = "search_teams"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
members = Column(Integer)
|
||||
role = Column(String(50))
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class FieldObservation(Base):
|
||||
"""Слой 3: наблюдение в поле (position/observation/track/clue/witness).
|
||||
Геометрия — JSONB GeoJSON (B16 добавит PostGIS-колонки)."""
|
||||
__tablename__ = "field_observations"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
||||
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
|
||||
obs_type = Column(String(20), nullable=False)
|
||||
geom = Column(JSONB)
|
||||
observed_at = Column(DateTime(timezone=True))
|
||||
confidence = Column(Float)
|
||||
description = Column(Text)
|
||||
raw = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.CheckConstraint(
|
||||
"obs_type IN ('position','observation','track','clue','witness')",
|
||||
name="ck_field_observations_obs_type",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AreaChecked(Base):
|
||||
"""Слой 3: проверенный участок (результат работы группы)."""
|
||||
__tablename__ = "areas_checked"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
||||
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
|
||||
geom = Column(JSONB)
|
||||
checked_at = Column(DateTime(timezone=True))
|
||||
result = Column(String(20), default="partial")
|
||||
coverage_pct = Column(Float)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
# B17: идемпотентный ингест из КОНТУРа
|
||||
contour_area_id = Column(String(64), index=True) # area_id на стороне КОНТУРа
|
||||
team_label = Column(String(100)) # callsign цепи (КОНТУР)
|
||||
source = Column(String(20), default="manual") # manual|contour
|
||||
|
||||
__table_args__ = (
|
||||
sa.CheckConstraint("result IN ('clear','found','partial')", name="ck_areas_checked_result"),
|
||||
sa.CheckConstraint("source IN ('manual','contour')", name="ck_areas_checked_source"),
|
||||
)
|
||||
|
||||
|
||||
class FoundEvent(Base):
|
||||
"""Слой 3: событие находки пропавшего."""
|
||||
__tablename__ = "found_events"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
||||
geom = Column(JSONB)
|
||||
occurred_at = Column(DateTime(timezone=True))
|
||||
condition = Column(String(50))
|
||||
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
|
||||
description = Column(Text)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class ReferencePrior(Base):
|
||||
"""Слой 1: версионированный справочник априорных значений
|
||||
(экспертные нормы сейчас, ISRID — после B12). Данные НЕ вносятся (B15)."""
|
||||
__tablename__ = "reference_priors"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
source = Column(String(100), nullable=False)
|
||||
category = Column(String(100), nullable=False)
|
||||
value = Column(Float, nullable=False)
|
||||
units = Column(String(50))
|
||||
provenance = Column(Text)
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# ==================== B21/E1: RBAC-ядро (по образцу админки РВС) ====================
|
||||
# Иерархия: РЦУ РЧС (rcu) → ОУМЧС (oblast) → Г(Р)ОЧС (gor_rayon)
|
||||
|
||||
|
||||
class MchsUnit(Base):
|
||||
"""Подразделение МЧС: РЦУ РЧС, ОУМЧС, Г(Р)ОЧС."""
|
||||
__tablename__ = "mchs_units"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(255), nullable=False, unique=True)
|
||||
region = Column(String(100)) # область (для фильтров)
|
||||
kind = Column(String(20), nullable=False) # rcu | oblast | gor_rayon
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey("mchs_units.id", ondelete="SET NULL"))
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.CheckConstraint("kind IN ('rcu','oblast','gor_rayon')", name="ck_mchs_units_kind"),
|
||||
)
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""Роль (is_system — сидовые, удаление запрещено)."""
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(50), nullable=False, unique=True)
|
||||
description = Column(String(255))
|
||||
is_system = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""Атомарное право."""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
code = Column(String(50), nullable=False, unique=True)
|
||||
description = Column(String(255))
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""M2M роль ↔ право."""
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
|
||||
permission_id = Column(UUID(as_uuid=True), ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True)
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""M2M пользователь ↔ роль (с кем назначена)."""
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
|
||||
assigned_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
assigned_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
"""Server-side сессия: JWT несёт session_id, отзыв возможен на стороне БД."""
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
token_hash = Column(String(64), nullable=False, unique=True) # sha256 jti
|
||||
ip_address = Column(String(45))
|
||||
user_agent = Column(String(255))
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
last_seen_at = Column(DateTime(timezone=True))
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
|
||||
class AuthEvent(Base):
|
||||
"""Журнал аутентификации: вход/выход/блокировка."""
|
||||
__tablename__ = "auth_events"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
username = Column(String(100)) # даже если user_id неизвестен
|
||||
event_type = Column(String(30), nullable=False) # login|logout|login_failed|lockout|password_change
|
||||
ip_address = Column(String(64))
|
||||
user_agent = Column(String(255))
|
||||
details = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.Index("idx_auth_events_created", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class SecuritySetting(Base):
|
||||
"""Параметры безопасности (key/value)."""
|
||||
__tablename__ = "security_settings"
|
||||
|
||||
key = Column(String(50), primary_key=True)
|
||||
value = Column(String(255), nullable=False)
|
||||
|
||||
|
||||
class PasswordHistoryEntry(Base):
|
||||
"""B23: история паролей (bcrypt-хэши). Записывается при каждой смене,
|
||||
проверяется против password_history_count последних записей.
|
||||
|
||||
id — monotonic-последовательность (не UUID): обрезка «последние N» должна
|
||||
быть детерминированной, а created_at в SQLite имеет точность до секунды
|
||||
(несколько смен внутри одной секунды → равные метки → случайный порядок).
|
||||
"""
|
||||
__tablename__ = "password_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
public_id = Column(UUID(as_uuid=True), default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.Index("idx_password_history_user", "user_id", "created_at"),
|
||||
)
|
||||
|
||||
# ==================== B21/E2: аудит действий ====================
|
||||
|
||||
class SearchOperation(Base):
|
||||
"""B21/E3: поисковая операция — надстройка над карточкой (cases)."""
|
||||
__tablename__ = "search_operations"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
title = Column(String(255), nullable=False)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||
status = Column(String(20), nullable=False, default="active")
|
||||
unit_id = Column(UUID(as_uuid=True), ForeignKey("mchs_units.id", ondelete="SET NULL"))
|
||||
contour_operation_id = Column(UUID(as_uuid=True))
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
closed_at = Column(DateTime(timezone=True))
|
||||
|
||||
__table_args__ = (
|
||||
sa.CheckConstraint(
|
||||
"status IN ('planned','active','paused','completed','archived')",
|
||||
name="ck_search_operations_status",
|
||||
),
|
||||
sa.Index("idx_search_operations_status", "status"),
|
||||
sa.Index("idx_search_operations_unit", "unit_id"),
|
||||
)
|
||||
|
||||
|
||||
class AuditEvent(Base):
|
||||
"""Аудит действий (immutable): кто, когда, что. CREATE+READ only."""
|
||||
__tablename__ = "audit_events"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
username = Column(String(100))
|
||||
event_type = Column(String(40), nullable=False) # user_create|user_update|user_lock|case_create|...
|
||||
object_type = Column(String(40)) # user|case|operation|...
|
||||
object_id = Column(String(64))
|
||||
ip_address = Column(String(64))
|
||||
user_agent = Column(String(255))
|
||||
details = Column(JSONB)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.Index("idx_audit_events_created", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class AuditChange(Base):
|
||||
"""Field-level изменения: old → new (привязаны к AuditEvent)."""
|
||||
__tablename__ = "audit_changes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
event_id = Column(UUID(as_uuid=True), ForeignKey("audit_events.id", ondelete="CASCADE"), nullable=False)
|
||||
field_name = Column(String(100), nullable=False)
|
||||
old_value = Column(Text)
|
||||
new_value = Column(Text)
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
from database import Base
|
||||
|
||||
|
||||
class Case(Base):
|
||||
"""Unified case model - combines search case and result"""
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
status = Column(String(20), default='active') # active/closed/archived
|
||||
|
||||
# Ребёнок
|
||||
child_name = Column(String(255))
|
||||
age_years = Column(Integer, nullable=False)
|
||||
gender = Column(String(1)) # М / Ж
|
||||
clothes_description = Column(Text)
|
||||
special_marks = Column(Text)
|
||||
phone_status = Column(String(20)) # answers/silent/none
|
||||
|
||||
# Здоровье
|
||||
has_diagnosis = Column(Boolean, default=False)
|
||||
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР...
|
||||
fitness_level = Column(String(20)) # low/medium/high
|
||||
has_transport = Column(String(20), default='none') # none/bike/scooter/other
|
||||
cant_swim = Column(Boolean, default=False)
|
||||
|
||||
# Психотип
|
||||
psychotype = Column(String(50)) # dominant/harmonic/anxious/...
|
||||
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
|
||||
|
||||
# Обстоятельства
|
||||
loss_reason = Column(String(100))
|
||||
loss_time = Column(DateTime)
|
||||
elapsed_hours = Column(Float)
|
||||
last_seen_direction = Column(String(10))
|
||||
last_seen_reliability = Column(String(20)) # exact/approx/unknown
|
||||
last_seen_description = Column(Text)
|
||||
behavior_description = Column(Text)
|
||||
familiar_places = Column(Text)
|
||||
lost_before = Column(String(20)) # yes/no/unknown
|
||||
|
||||
# Среда
|
||||
season = Column(String(20))
|
||||
temperature_c = Column(Float)
|
||||
precipitation = Column(String(20))
|
||||
visibility = Column(String(20))
|
||||
wind = Column(String(20))
|
||||
terrain = Column(ARRAY(String))
|
||||
|
||||
# GPS
|
||||
tnp_lat = Column(Float)
|
||||
tnp_lon = Column(Float)
|
||||
tnp_address = Column(Text)
|
||||
|
||||
# Ресурсы
|
||||
teams_count = Column(Integer)
|
||||
team_size = Column(Integer)
|
||||
has_dog = Column(Boolean, default=False)
|
||||
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal
|
||||
|
||||
# Исход (заполняется после завершения)
|
||||
found_alive = Column(Boolean)
|
||||
found_distance_km = Column(Float)
|
||||
found_direction = Column(String(10))
|
||||
found_location_type = Column(String(50)) # forest/road/building/water/field
|
||||
found_lat = Column(Float)
|
||||
found_lon = Column(Float)
|
||||
search_duration_hours = Column(Float)
|
||||
who_found = Column(String(50)) # mchs/mvd/volunteers/self
|
||||
|
||||
# Мета
|
||||
confidence_avg = Column(Float)
|
||||
raw_text = Column(Text)
|
||||
analysis_log = Column(JSONB)
|
||||
|
||||
|
||||
class RawDocument(Base):
|
||||
"""Raw document storage for parsed reports"""
|
||||
__tablename__ = "raw_documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
filename = Column(String(255), nullable=False)
|
||||
raw_text = Column(Text)
|
||||
extracted_json = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -1,89 +0,0 @@
|
||||
from sqlalchemy import Column, String, Float, Boolean, DateTime, Text, ARRAY
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
from database import Base
|
||||
|
||||
|
||||
class Case(Base):
|
||||
"""Unified case model - combines search case and result"""
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
status = Column(String(20), default='active') # active/closed/archived
|
||||
|
||||
# Ребёнок
|
||||
child_name = Column(String(255))
|
||||
age_years = Column(Integer, nullable=False)
|
||||
gender = Column(String(1)) # М / Ж
|
||||
clothes_description = Column(Text)
|
||||
special_marks = Column(Text)
|
||||
phone_status = Column(String(20)) # answers/silent/none
|
||||
|
||||
# Здоровье
|
||||
has_diagnosis = Column(Boolean, default=False)
|
||||
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР...
|
||||
fitness_level = Column(String(20)) # low/medium/high
|
||||
has_transport = Column(String(20), default='none') # none/bike/scooter/other
|
||||
cant_swim = Column(Boolean, default=False)
|
||||
|
||||
# Психотип
|
||||
psychotype = Column(String(50)) # dominant/harmonic/anxious/...
|
||||
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
|
||||
|
||||
# Обстоятельства
|
||||
loss_reason = Column(String(100))
|
||||
loss_time = Column(DateTime)
|
||||
elapsed_hours = Column(Float)
|
||||
last_seen_direction = Column(String(10))
|
||||
last_seen_reliability = Column(String(20)) # exact/approx/unknown
|
||||
last_seen_description = Column(Text)
|
||||
behavior_description = Column(Text)
|
||||
familiar_places = Column(Text)
|
||||
lost_before = Column(String(20)) # yes/no/unknown
|
||||
|
||||
# Среда
|
||||
season = Column(String(20))
|
||||
temperature_c = Column(Float)
|
||||
precipitation = Column(String(20))
|
||||
visibility = Column(String(20))
|
||||
wind = Column(String(20))
|
||||
terrain = Column(ARRAY(String))
|
||||
|
||||
# GPS
|
||||
tnp_lat = Column(Float)
|
||||
tnp_lon = Column(Float)
|
||||
tnp_address = Column(Text)
|
||||
|
||||
# Ресурсы
|
||||
teams_count = Column(Integer)
|
||||
team_size = Column(Integer)
|
||||
has_dog = Column(Boolean, default=False)
|
||||
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal
|
||||
|
||||
# Исход (заполняется после завершения)
|
||||
found_alive = Column(Boolean)
|
||||
found_distance_km = Column(Float)
|
||||
found_direction = Column(String(10))
|
||||
found_location_type = Column(String(50)) # forest/road/building/water/field
|
||||
found_lat = Column(Float)
|
||||
found_lon = Column(Float)
|
||||
search_duration_hours = Column(Float)
|
||||
who_found = Column(String(50)) # mchs/mvd/volunteers/self
|
||||
|
||||
# Мета
|
||||
confidence_avg = Column(Float)
|
||||
raw_text = Column(Text)
|
||||
analysis_log = Column(JSONB)
|
||||
|
||||
|
||||
class RawDocument(Base):
|
||||
"""Raw document storage for parsed reports"""
|
||||
__tablename__ = "raw_documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
filename = Column(String(255), nullable=False)
|
||||
raw_text = Column(Text)
|
||||
extracted_json = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -1,93 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
from database import Base
|
||||
|
||||
|
||||
class Case(Base):
|
||||
Unified case model - combines search case and result
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
status = Column(String(20), default="active") # active/closed/archived
|
||||
|
||||
# Ребёнок (Шаг 1)
|
||||
child_name = Column(String(255))
|
||||
age_years = Column(Integer, nullable=False)
|
||||
gender = Column(String(1)) # М / Ж
|
||||
height_build = Column(Text) # Рост / телосложение
|
||||
clothes_upper = Column(Text) # Одежда: верх (цвет, тип)
|
||||
clothes_lower = Column(Text) # Одежда: низ (цвет, тип)
|
||||
shoes = Column(Text) # Обувь (тип, цвет)
|
||||
clothes_description = Column(Text) # Общее описание одежды (legacy)
|
||||
special_marks = Column(Text) # Особые приметы
|
||||
phone_status = Column(String(20)) # answers/silent/none
|
||||
|
||||
# Здоровье (Шаг 2)
|
||||
has_diagnosis = Column(Boolean, default=False)
|
||||
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР, слабое зрение, слабый слух...
|
||||
fitness_level = Column(String(20)) # low/medium/high
|
||||
has_transport = Column(String(20), default="none") # none/bike/scooter/other
|
||||
cant_swim = Column(Boolean, default=False)
|
||||
|
||||
# Психотип (Шаг 2б)
|
||||
psychotype = Column(String(50)) # dominant/harmonic/anxious/introvert_passive/introvert_active
|
||||
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
|
||||
|
||||
# Обстоятельства (Шаг 3)
|
||||
loss_reason = Column(String(100)) # потерялся в лесу/ушёл из дома/в городе/не вернулся с прогулки/на мероприятии/другое
|
||||
loss_time = Column(DateTime)
|
||||
elapsed_hours = Column(Float)
|
||||
last_seen_direction = Column(String(10)) # С/СВ/В/ЮВ/Ю/ЮЗ/З/СЗ/неизвестно
|
||||
last_seen_reliability = Column(String(20)) # exact/approx/unknown
|
||||
last_seen_description = Column(Text)
|
||||
behavior_description = Column(Text) # Поведение при стрессе
|
||||
familiar_places = Column(Text) # Знакомые места
|
||||
lost_before = Column(String(20)) # yes/no/unknown
|
||||
|
||||
# Среда (Шаг 4)
|
||||
season = Column(String(20)) # зима/весна/лето/осень
|
||||
temperature_c = Column(Float)
|
||||
precipitation = Column(String(20)) # нет/морось/дождь/ливень/снег/гроза/туман
|
||||
visibility = Column(String(20)) # хорошая/ограниченная/плохая
|
||||
wind = Column(String(20)) # штиль/слабый/умеренный/сильный
|
||||
terrain = Column(ARRAY(String)) # густой лес, редкий лес, лесная дорога, поле, болото, водоём, город...
|
||||
|
||||
# GPS (Шаг 4)
|
||||
tnp_lat = Column(Float)
|
||||
tnp_lon = Column(Float)
|
||||
tnp_address = Column(Text)
|
||||
|
||||
# Ресурсы (Шаг 5)
|
||||
teams_count = Column(Integer)
|
||||
team_size = Column(Integer)
|
||||
has_dog = Column(Boolean, default=False)
|
||||
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal/quadbike
|
||||
|
||||
# Исход (заполняется после завершения)
|
||||
found_alive = Column(Boolean)
|
||||
found_distance_km = Column(Float)
|
||||
found_direction = Column(String(10))
|
||||
found_location_type = Column(String(50)) # forest/road/building/water/field
|
||||
found_lat = Column(Float)
|
||||
found_lon = Column(Float)
|
||||
search_duration_hours = Column(Float)
|
||||
who_found = Column(String(50)) # mchs/mvd/volunteers/self
|
||||
|
||||
# Мета
|
||||
confidence_avg = Column(Float)
|
||||
raw_text = Column(Text)
|
||||
analysis_log = Column(JSONB)
|
||||
|
||||
|
||||
class RawDocument(Base):
|
||||
"""Raw document storage for parsed reports"""
|
||||
__tablename__ = "raw_documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
filename = Column(String(255), nullable=False)
|
||||
raw_text = Column(Text)
|
||||
extracted_json = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -0,0 +1,129 @@
|
||||
"""B23: исполняемая политика паролей (security_settings).
|
||||
|
||||
До сих пор security_settings были декларативными: срок действия и история
|
||||
пароля показывались в настройках, но код проверял только минимальную длину.
|
||||
Этот модуль — единственная точка валидации и записи пароля.
|
||||
|
||||
Правила (ключи security_settings):
|
||||
password_min_length 8
|
||||
password_require_complexity true (буквы + цифры)
|
||||
password_expiry_days 90 (0 = не проверять срок)
|
||||
password_history_count 5 (0 = не проверять переиспользование)
|
||||
|
||||
Важно: проверка срока действия выполняется при КАЖДОМ защищённом запросе
|
||||
(get_current_user) — истёкший пароль переводит пользователя в
|
||||
must_change_password (запросы к /auth/* работают, прочее API → 403 с
|
||||
подсказкой сменить пароль). Так сессии не ломаются внезапно, но политика
|
||||
исполняется.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.models import PasswordHistoryEntry, SecuritySetting, User
|
||||
|
||||
log = get_logger('auth')
|
||||
|
||||
_COMPLEXITY_DIGIT_RE = re.compile(r'[0-9]')
|
||||
_COMPLEXITY_LETTER_RE = re.compile(r'[^\W\d_]', re.UNICODE)
|
||||
|
||||
|
||||
def get_setting(db: Session, key: str, default: str) -> str:
|
||||
row = db.query(SecuritySetting).filter(SecuritySetting.key == key).first()
|
||||
return row.value if row else default
|
||||
|
||||
|
||||
def _aware(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def validate_new_password(db: Session, user: User, new_password: str,
|
||||
*, current_hash: str | None = None) -> None:
|
||||
"""Бросает ValueError с текстом на русском, если пароль нарушает политику.
|
||||
|
||||
Проверяет: длину, сложность (буквы+цифры), совпадение с текущим паролем
|
||||
и переиспользование (последние password_history_count записей истории).
|
||||
Вызывать ДО записи нового хэша.
|
||||
"""
|
||||
min_len = int(get_setting(db, 'password_min_length', '8'))
|
||||
need_complexity = get_setting(db, 'password_require_complexity', 'true').strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||
history_count = int(get_setting(db, 'password_history_count', '5'))
|
||||
|
||||
if len(new_password) < min_len:
|
||||
raise ValueError(f'Минимальная длина пароля: {min_len} символов')
|
||||
if need_complexity:
|
||||
if not _COMPLEXITY_DIGIT_RE.search(new_password) or not _COMPLEXITY_LETTER_RE.search(new_password):
|
||||
raise ValueError('Пароль должен содержать буквы и цифры')
|
||||
|
||||
# Текущий пароль считается частью истории (нельзя «сменить на тот же»)
|
||||
candidates: list[str] = []
|
||||
if current_hash:
|
||||
candidates.append(current_hash)
|
||||
if history_count > 0:
|
||||
rows = (db.query(PasswordHistoryEntry)
|
||||
.filter(PasswordHistoryEntry.user_id == user.id)
|
||||
.order_by(PasswordHistoryEntry.created_at.desc())
|
||||
.limit(history_count).all())
|
||||
candidates.extend(r.hashed_password for r in rows)
|
||||
from backend.routers.auth import verify_password
|
||||
for old_hash in candidates:
|
||||
if old_hash and verify_password(new_password, old_hash):
|
||||
raise ValueError('Новый пароль совпадает с ранее использованным')
|
||||
|
||||
|
||||
def record_password_change(db: Session, user: User, new_hash: str,
|
||||
*, previous_hash: str | None = None) -> None:
|
||||
"""Записать смену пароля: история + password_changed_at + обрезка истории.
|
||||
|
||||
В историю кладётся ПРЕДЫДУЩИЙ хэш (previous_hash — хэш, который заменяют),
|
||||
чтобы его нельзя было вернуть, пока он не выпал из password_history_count
|
||||
последних записей. Обрезка хранит ровно password_history_count записей;
|
||||
порядок — по monotonic id (created_at в SQLite точен до секунды).
|
||||
"""
|
||||
history_count = int(get_setting(db, 'password_history_count', '5'))
|
||||
old_hash = previous_hash if previous_hash is not None else user.hashed_password
|
||||
if old_hash:
|
||||
db.add(PasswordHistoryEntry(user_id=user.id, hashed_password=old_hash))
|
||||
user.password_changed_at = datetime.now(timezone.utc)
|
||||
if history_count > 0:
|
||||
keep = (db.query(PasswordHistoryEntry.id)
|
||||
.filter(PasswordHistoryEntry.user_id == user.id)
|
||||
.order_by(PasswordHistoryEntry.id.desc())
|
||||
.limit(history_count).all())
|
||||
keep_ids = [row[0] for row in keep]
|
||||
if keep_ids:
|
||||
(db.query(PasswordHistoryEntry)
|
||||
.filter(PasswordHistoryEntry.user_id == user.id,
|
||||
PasswordHistoryEntry.id.notin_(keep_ids))
|
||||
.delete(synchronize_session=False))
|
||||
|
||||
|
||||
def password_expired(db: Session, user: User) -> bool:
|
||||
"""True, если срок действия пароля истёк (password_expiry_days > 0)."""
|
||||
expiry_days = int(get_setting(db, 'password_expiry_days', '90'))
|
||||
if expiry_days <= 0:
|
||||
return False
|
||||
changed_at = getattr(user, 'password_changed_at', None)
|
||||
if changed_at is None:
|
||||
# Миграция поставила NOW() — но если записи нет (создан до B23),
|
||||
# считаем от updated_at, чтобы не выгонять всех разом.
|
||||
changed_at = getattr(user, 'updated_at', None)
|
||||
if changed_at is None:
|
||||
return False
|
||||
return _aware(changed_at) < datetime.now(timezone.utc) - timedelta(days=expiry_days)
|
||||
|
||||
|
||||
def enforce_expiry(db: Session, user: User) -> None:
|
||||
"""Отметить must_change_password, если срок истёк (вызывается на каждом запросе)."""
|
||||
if not getattr(user, 'must_change_password', False) and password_expired(db, user):
|
||||
user.must_change_password = True
|
||||
log.warning('password_expired user=%s — пароль старше %s дн.',
|
||||
getattr(user, 'username', '—'),
|
||||
get_setting(db, 'password_expiry_days', '90'))
|
||||
db.commit()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""B23: ретенция журналов — реальное удаление, а не фильтр при чтении.
|
||||
|
||||
До сих пор «ретенция 90 дней» из security_settings реализовывалась как
|
||||
фильтр в GET /admin/users/audit — записи копились в БД вечно. Этот модуль
|
||||
удаляет просроченное по-настоящему (DELETE), охватывая:
|
||||
|
||||
audit_events (+ audit_changes каскадно) — по audit_retention_days
|
||||
auth_events — по audit_retention_days
|
||||
|
||||
Запуск: POST /api/v1/admin/maintenance/purge-logs (право manage_security)
|
||||
или вручную:
|
||||
docker exec vector-backend python -m backend.purge_logs
|
||||
(режим разового запуска — для cron внутри контейнера)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.models import AuthEvent, SecuritySetting
|
||||
|
||||
log = get_logger('retention')
|
||||
|
||||
|
||||
def _retention_days(db: Session, default: int = 90) -> int:
|
||||
row = db.query(SecuritySetting).filter(
|
||||
SecuritySetting.key == 'audit_retention_days').first()
|
||||
try:
|
||||
return int(row.value) if row else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def purge_logs(db: Session, retention_days: int | None = None) -> dict[str, int]:
|
||||
"""Удалить записи журналов старше retention_days. Возвращает счётчики.
|
||||
|
||||
audit_changes удаляются каскадом по FK (ondelete=CASCADE); для SQLite-тестов
|
||||
(без каскада на уровне БД) удаляем явно.
|
||||
"""
|
||||
if retention_days is None:
|
||||
retention_days = _retention_days(db)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=max(0, retention_days))
|
||||
# SQLite отдаёт naive datetimes (DateTime(timezone=True) не respected) —
|
||||
# сравнение aware-cutoff с naive-колонкой падает TypeError. Убираем tzinfo:
|
||||
cutoff = cutoff.replace(tzinfo=None)
|
||||
|
||||
audit_deleted = 0
|
||||
try:
|
||||
from backend.models import AuditChange, AuditEvent
|
||||
change_ids = (db.query(AuditChange.id)
|
||||
.join(AuditEvent, AuditChange.event_id == AuditEvent.id)
|
||||
.filter(AuditEvent.created_at < cutoff).all())
|
||||
change_ids = [row[0] for row in change_ids]
|
||||
if change_ids:
|
||||
(db.query(AuditChange)
|
||||
.filter(AuditChange.id.in_(change_ids))
|
||||
.delete(synchronize_session=False)) # changes считаются вместе с событием
|
||||
audit_deleted += (db.query(AuditEvent)
|
||||
.filter(AuditEvent.created_at < cutoff)
|
||||
.delete(synchronize_session=False))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
log.error('purge audit_events не удался: %s', e)
|
||||
raise
|
||||
|
||||
auth_deleted = 0
|
||||
try:
|
||||
auth_deleted += (db.query(AuthEvent)
|
||||
.filter(AuthEvent.created_at < cutoff)
|
||||
.delete(synchronize_session=False))
|
||||
except Exception as e:
|
||||
log.error('purge auth_events не удался: %s', e)
|
||||
raise
|
||||
|
||||
db.commit()
|
||||
if audit_deleted or auth_deleted:
|
||||
log.info('retention purge: %d аудит-записей, %d auth-записей удалено (cutoff=%s, %d дн.)',
|
||||
audit_deleted, auth_deleted, cutoff.date(), retention_days)
|
||||
return {'audit_events': audit_deleted, 'auth_events': auth_deleted}
|
||||
|
||||
|
||||
def _cli() -> None:
|
||||
"""Разовый прогон: python -m backend.purge_logs (для cron)."""
|
||||
from backend.database import SessionLocal
|
||||
with SessionLocal() as db:
|
||||
result = purge_logs(db)
|
||||
print(f'purge: {result}')
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_cli()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""E1 (B21): RBAC-ядро — подразделения МЧС, роли/права, сессии, auth-журнал.
|
||||
|
||||
Иерархия подразделений: РЦУ РЧС (rcu) → ОУМЧС (oblast) → Г(Р)ОЧС (gor_rayon).
|
||||
Роли — как в РВС-админке (is_system), права атомарные, user_roles M2M.
|
||||
Сессии server-side: JWT несёт session_id, каждый запрос проверяет сессию
|
||||
в БД (отзыв по блокировке пользователя/окончании срока).
|
||||
|
||||
Модели добавлены в backend/models.py; здесь — только константы (seed-данные)
|
||||
и хелперы прав/скоупа.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
# ---- Иерархия подразделений ----
|
||||
UNIT_KINDS = ('rcu', 'oblast', 'gor_rayon')
|
||||
|
||||
# ---- Роли (is_system) ----
|
||||
SYSTEM_ROLES = (
|
||||
'admin', # РЦУ РЧС: все права, все операции
|
||||
'coordinator', # ОУМЧС: поиски своей области, управление подчинёнными Г(Р)ОЧС
|
||||
'operator', # Г(Р)ОЧС: свои поиски (create/update)
|
||||
'observer', # только просмотр
|
||||
)
|
||||
|
||||
# ---- Атомарные права ----
|
||||
PERMISSIONS = (
|
||||
'view', 'create', 'update', 'delete', 'export',
|
||||
'manage_users', 'manage_roles', 'view_audit', 'manage_security',
|
||||
)
|
||||
|
||||
ROLE_PERMISSIONS: dict[str, tuple[str, ...]] = {
|
||||
'admin': PERMISSIONS,
|
||||
'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'),
|
||||
'operator': ('view', 'create', 'update'),
|
||||
'observer': ('view',),
|
||||
}
|
||||
|
||||
# Роли-совместимость со старым единым role-полем: admin/coordinator/operator/observer
|
||||
# покрывают прежние operator/field/admin (field ≈ operator: полевой доступ).
|
||||
|
||||
# ---- Политика безопасности (security_settings) ----
|
||||
SECURITY_DEFAULTS = {
|
||||
'password_min_length': '8',
|
||||
'password_require_complexity': 'true',
|
||||
'password_expiry_days': '90',
|
||||
'password_history_count': '5',
|
||||
'max_failed_logins': '5',
|
||||
'lockout_duration_minutes': '15',
|
||||
'session_timeout_minutes': '1440',
|
||||
'session_idle_timeout_minutes': '60',
|
||||
'max_concurrent_sessions': '3',
|
||||
'audit_retention_days': '90',
|
||||
}
|
||||
|
||||
|
||||
def descendant_unit_ids(conn, unit_id: str) -> list[str]:
|
||||
"""Все id подразделения + его потомков (для гео-скоупа). Рекурсивный CTE."""
|
||||
rows = conn.execute(
|
||||
text("""
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id FROM mchs_units WHERE id = :uid
|
||||
UNION ALL
|
||||
SELECT u.id FROM mchs_units u JOIN tree t ON u.parent_id = t.id
|
||||
)
|
||||
SELECT id FROM tree
|
||||
"""),
|
||||
{'unit_id': unit_id},
|
||||
).fetchall()
|
||||
return [str(r[0]) for r in rows]
|
||||
@@ -13,3 +13,4 @@ pydantic-settings==2.6.1
|
||||
email-validator==2.1.0
|
||||
psycopg2-binary==2.9.12
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.24.0
|
||||
|
||||
+26
-94
@@ -1,99 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from backend.database import db
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.routers.auth import require_roles
|
||||
from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse, ParseDocResponse
|
||||
from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse
|
||||
|
||||
log = get_logger('admin')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/admin', tags=['admin'], dependencies=[Depends(require_roles(['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),
|
||||
@@ -117,23 +37,35 @@ def admin_cases(
|
||||
@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)
|
||||
# default_factory-списки ([]) при exclude_none всё равно попадают в data и
|
||||
# ЗАТИРАЮТ реальные terrain/diagnosis_type — выкидываем пустые списки:
|
||||
data = {k: v for k, v in data.items() if not (isinstance(v, list) and not v)}
|
||||
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')
|
||||
|
||||
# B21-синхронизация: кейс закрыт вручную → его операция завершается автоматически
|
||||
if data.get('status') == 'closed':
|
||||
import uuid as _uuid
|
||||
from backend.models import SearchOperation as _Op
|
||||
from backend.database import SessionLocal as _SL
|
||||
case_uuid = _uuid.UUID(str(case.id)) if isinstance(case.id, str) else case.id
|
||||
with _SL() as session:
|
||||
ops = session.query(_Op).filter(_Op.case_id == case_uuid,
|
||||
_Op.status.in_(['planned', 'active', 'paused'])).all()
|
||||
for op in ops:
|
||||
op.status = 'completed'
|
||||
op.closed_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
closed_ops = len(ops)
|
||||
if closed_ops:
|
||||
log.info('admin closed case %s → %d операция(ий) завершены автоматически', case_id, closed_ops)
|
||||
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,325 @@
|
||||
"""B21/E2: админ-панель пользователей и подразделений.
|
||||
|
||||
Права: manage_users (создание/блокировка/сброс пароля/роль/юнит),
|
||||
view_audit (журнал аудита). Скоуп: coordinator видит/управляет
|
||||
пользователями своего юнита + подчинённых; РЦУ РЧС — всеми.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.audit import audit_log, visible_unit_ids
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.password_policy import record_password_change, validate_new_password
|
||||
from backend.purge_logs import _retention_days, purge_logs
|
||||
from backend.database import get_db
|
||||
from backend.models import MchsUnit, Role, User, UserRole
|
||||
from backend.routers.auth import (
|
||||
get_current_user,
|
||||
get_password_hash,
|
||||
require_permission,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix='/api/v1/admin/users', tags=['admin-users'])
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
full_name: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
unit_id: Optional[str] = None
|
||||
role_name: str # admin | coordinator | operator | observer
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
unit_id: Optional[str] = None
|
||||
role_name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
status: Optional[str] = None # active | locked | disabled
|
||||
reset_password: Optional[str] = None # новый пароль (сброс админом)
|
||||
|
||||
|
||||
def _unit_scope(db: Session, user: User) -> list[uuid.UUID] | None:
|
||||
"""Скоуп в виде UUID-объектов (для сравнения с UUID-колонками)."""
|
||||
scope = visible_unit_ids(db, user)
|
||||
if scope is None:
|
||||
return None
|
||||
return [uuid.UUID(h) if isinstance(h, str) else h for h in scope]
|
||||
|
||||
|
||||
def _ensure_can_manage(db: Session, actor: User, target: User) -> None:
|
||||
"""coordinator управляет только своим юнитом+подчинёнными; admin — всеми."""
|
||||
scope = _unit_scope(db, actor)
|
||||
if scope is None:
|
||||
return
|
||||
target_unit = getattr(target, 'unit_id', None)
|
||||
if target_unit:
|
||||
from backend.audit import _uuid_hex
|
||||
# scope — UUID-объекты (из _unit_scope); сравниваем в hex-пространстве
|
||||
scope_hex = {_uuid_hex(s) for s in scope} if scope else set()
|
||||
if _uuid_hex(target_unit) not in scope_hex:
|
||||
raise HTTPException(status_code=403, detail='Пользователь вне вашего скоупа')
|
||||
if target is not None and target.id == actor.id:
|
||||
return
|
||||
# coordinator не может управлять теми, у кого роль admin
|
||||
if actor.role == 'coordinator' and target.role == 'admin':
|
||||
raise HTTPException(status_code=403, detail='Недостаточно прав для этого пользователя')
|
||||
|
||||
|
||||
@router.get('')
|
||||
def list_users(
|
||||
status_filter: Optional[str] = Query(default=None, alias='status'),
|
||||
unit_id: Optional[str] = Query(default=None),
|
||||
q: Optional[str] = Query(default=None),
|
||||
current_user: User = Depends(require_permission('manage_users')),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
scope = _unit_scope(db, current_user)
|
||||
query = db.query(User)
|
||||
if scope is not None:
|
||||
query = query.filter(User.unit_id.in_(scope))
|
||||
if status_filter:
|
||||
query = query.filter(User.status == status_filter)
|
||||
if unit_filter := unit_id:
|
||||
query = query.filter(User.unit_id == unit_filter)
|
||||
if q:
|
||||
like = f'%{q}%'
|
||||
query = query.filter(User.username.ilike(like) | User.full_name.ilike(like))
|
||||
users = query.order_by(User.username).all()
|
||||
return {
|
||||
'items': [{
|
||||
'id': str(u.id),
|
||||
'username': u.username,
|
||||
'email': u.email,
|
||||
'full_name': u.full_name,
|
||||
'position': u.position,
|
||||
'role': u.role,
|
||||
'status': getattr(u, 'status', 'active'),
|
||||
'unit_id': str(u.unit_id) if u.unit_id else None,
|
||||
'last_login': u.last_login.isoformat() if u.last_login else None,
|
||||
} for u in users],
|
||||
'total': len(users),
|
||||
}
|
||||
|
||||
|
||||
@router.get('/units')
|
||||
def list_units(
|
||||
current_user: User = Depends(require_permission('manage_users')),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
scope = _unit_scope(db, current_user)
|
||||
query = db.query(MchsUnit)
|
||||
if scope is not None:
|
||||
query = query.filter(MchsUnit.id.in_(scope))
|
||||
units = query.order_by(MchsUnit.kind, MchsUnit.name).all()
|
||||
return {'items': [{
|
||||
'id': str(u.id),
|
||||
'name': u.name,
|
||||
'region': u.region,
|
||||
'kind': u.kind,
|
||||
'parent_id': str(u.parent_id) if u.parent_id else None,
|
||||
} for u in units]}
|
||||
|
||||
|
||||
@router.post('', status_code=201)
|
||||
def create_user(
|
||||
payload: UserCreate,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permission('manage_users')),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
if len(payload.password) < 8:
|
||||
raise HTTPException(status_code=400, detail='Минимальная длина пароля: 8 символов')
|
||||
# B23: сложность проверяется и на временный пароль админа (история пуста —
|
||||
# новый пользователь, проверка переиспользования не применима)
|
||||
probe = User(username=payload.username, email=payload.email)
|
||||
try:
|
||||
validate_new_password(db, probe, payload.password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if db.query(User).filter(User.username == payload.username).first():
|
||||
raise HTTPException(status_code=400, detail='Пользователь с таким именем уже существует')
|
||||
if db.query(User).filter(User.email == payload.email).first():
|
||||
raise HTTPException(status_code=400, detail='Email уже используется')
|
||||
|
||||
role = db.query(Role).filter(Role.name == payload.role_name).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=400, detail=f'Роль {payload.role_name} не найдена')
|
||||
|
||||
unit_id = payload.unit_id
|
||||
if unit_id:
|
||||
unit = db.get(MchsUnit, __import__('uuid').UUID(unit_id))
|
||||
if not unit:
|
||||
raise HTTPException(status_code=400, detail='Подразделение не найдено')
|
||||
scope = _unit_scope(db, current_user)
|
||||
if scope is not None and uuid.UUID(unit_id) not in scope:
|
||||
raise HTTPException(status_code=403, detail='Подразделение вне вашего скоупа')
|
||||
|
||||
user = User(
|
||||
username=payload.username,
|
||||
email=payload.email,
|
||||
hashed_password=get_password_hash(payload.password),
|
||||
full_name=payload.full_name,
|
||||
position=payload.position,
|
||||
unit_id=__import__('uuid').UUID(unit_id) if unit_id else None,
|
||||
role=role.name, # legacy-поле для совместимости
|
||||
is_active=True,
|
||||
status='active',
|
||||
must_change_password=True,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(UserRole(user_id=user.id, role_id=role.id, assigned_by=current_user.id))
|
||||
db.flush()
|
||||
audit_log(db, current_user, 'user_create', object_type='user', object_id=str(user.id),
|
||||
request=request, details={'username': user.username, 'role': role.name})
|
||||
return {
|
||||
'id': str(user.id),
|
||||
'username': user.username,
|
||||
'role': role.name,
|
||||
'unit_id': unit_id,
|
||||
}
|
||||
|
||||
|
||||
@router.patch('/{user_id}')
|
||||
def update_user(
|
||||
user_id: str,
|
||||
payload: UserUpdate,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permission('manage_users')),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
import uuid as uuid_mod
|
||||
target = db.get(User, uuid_mod.UUID(user_id)) if user_id else None
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail='Пользователь не найден')
|
||||
_ensure_can_manage(db, current_user, target)
|
||||
|
||||
changes: dict[str, tuple] = {}
|
||||
if payload.full_name is not None and payload.full_name != target.full_name:
|
||||
changes['full_name'] = (target.full_name, payload.full_name)
|
||||
target.full_name = payload.full_name
|
||||
if payload.position is not None and payload.position != target.position:
|
||||
changes['position'] = (target.position, payload.position)
|
||||
target.position = payload.position
|
||||
if payload.role_name is not None:
|
||||
role = db.query(Role).filter(Role.name == payload.role_name).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=400, detail=f'Роль {payload.role_name} не найдена')
|
||||
if role.name != target.role:
|
||||
changes['role'] = (target.role, role.name)
|
||||
target.role = role.name
|
||||
(db.query(UserRole)
|
||||
.filter(UserRole.user_id == target.id)
|
||||
.delete())
|
||||
db.add(UserRole(user_id=target.id, role_id=role.id, assigned_by=current_user.id))
|
||||
if payload.unit_id is not None:
|
||||
unit = db.get(MchsUnit, uuid_mod.UUID(payload.unit_id))
|
||||
if not unit:
|
||||
raise HTTPException(status_code=400, detail='Подразделение не найдено')
|
||||
if str(unit.id) != str(getattr(target, 'unit_id', None) or ''):
|
||||
changes['unit'] = (str(getattr(target, 'unit_id', None)), str(unit.id))
|
||||
target.unit_id = unit.id
|
||||
if payload.status is not None:
|
||||
if payload.status not in ('active', 'locked', 'disabled'):
|
||||
raise HTTPException(status_code=400, detail='Недопустимый статус')
|
||||
if payload.status != getattr(target, 'status', 'active'):
|
||||
changes['status'] = (getattr(target, 'status', 'active'), payload.status)
|
||||
target.status = payload.status
|
||||
if payload.is_active is not None:
|
||||
if payload.is_active != target.is_active:
|
||||
changes['is_active'] = (target.is_active, payload.is_active)
|
||||
target.is_active = payload.is_active
|
||||
if payload.reset_password:
|
||||
# B23: сброс админом тоже под полной политикой
|
||||
probe = User(username=target.username, email=target.email, id=target.id)
|
||||
try:
|
||||
validate_new_password(db, probe, payload.reset_password,
|
||||
current_hash=target.hashed_password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
changes['password'] = ('***', '***')
|
||||
new_hash = get_password_hash(payload.reset_password)
|
||||
record_password_change(db, target, new_hash,
|
||||
previous_hash=target.hashed_password)
|
||||
target.hashed_password = new_hash
|
||||
target.must_change_password = True
|
||||
|
||||
if not changes:
|
||||
return {'id': user_id, 'updated': False}
|
||||
|
||||
target.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
audit_log(db, current_user, 'user_update', object_type='user', object_id=user_id,
|
||||
changes=changes, request=request)
|
||||
return {'id': user_id, 'updated': True, 'changes': list(changes)}
|
||||
|
||||
|
||||
@router.get('/audit')
|
||||
def list_audit(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
event_type: Optional[str] = Query(default=None),
|
||||
current_user: User = Depends(require_permission('view_audit')),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
from datetime import timedelta
|
||||
retention = 90
|
||||
row = db.execute(text(
|
||||
"SELECT value FROM security_settings WHERE key = 'audit_retention_days'")).first()
|
||||
if row:
|
||||
retention = int(row[0])
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention)
|
||||
|
||||
from backend.models import AuditEvent as AE, AuditChange as AC
|
||||
q = db.query(AE).filter(AE.created_at >= cutoff)
|
||||
if event_type:
|
||||
q = q.filter(AE.event_type == event_type)
|
||||
total = q.count()
|
||||
events = q.order_by(AE.created_at.desc()).offset(offset).limit(limit).all()
|
||||
items = []
|
||||
for ev in events:
|
||||
changes = db.query(AC).filter(AC.event_id == ev.id).all()
|
||||
items.append({
|
||||
'id': str(ev.id),
|
||||
'username': ev.username,
|
||||
'event_type': ev.event_type,
|
||||
'object_type': ev.object_type,
|
||||
'object_id': ev.object_id,
|
||||
'created_at': ev.created_at.isoformat() if ev.created_at else None,
|
||||
'changes': [{'field': ch.field_name, 'old': ch.old_value, 'new': ch.new_value}
|
||||
for ch in changes],
|
||||
})
|
||||
return {'items': items, 'total': q.count(), 'retention_days': retention}
|
||||
|
||||
|
||||
@router.post('/maintenance/purge-logs')
|
||||
def run_purge_logs(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permission('manage_security')),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
"""B23: реальное удаление журналов старше audit_retention_days.
|
||||
|
||||
Ручной запуск из админки; регулярный — cron `python -m backend.purge_logs`.
|
||||
Удаляет audit_events (+changes каскадом) и auth_events.
|
||||
"""
|
||||
result = purge_logs(db)
|
||||
audit_log(db, current_user, 'purge_logs', object_type='maintenance',
|
||||
request=request, details=result)
|
||||
log = get_logger('admin')
|
||||
log.info('purge_logs by %s: %s', getattr(current_user, 'username', '—'), result)
|
||||
return {'purged': result, 'retention_days': _retention_days(db)}
|
||||
+63
-60
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -8,15 +10,15 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.database import db
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.routers.auth import require_roles
|
||||
from services.claude_service import analyze_case as claude_analyze
|
||||
from services.distance_service import calculate_max_distance
|
||||
from services.psychotype_service import (
|
||||
detect_psychotype,
|
||||
get_psychotype_modifiers,
|
||||
get_search_recommendations,
|
||||
from services.rules_analysis import rules_analyze
|
||||
from services.search_engine import (
|
||||
SearchInput,
|
||||
build_search_model,
|
||||
)
|
||||
from services.scoring_service import WeightedScorer
|
||||
|
||||
log = get_logger('analyze')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
|
||||
|
||||
@@ -34,6 +36,7 @@ class AnalysisRequest(BaseModel):
|
||||
experience: str | None = None
|
||||
season: str | None = None
|
||||
diagnosis_type: list[str] = Field(default_factory=list)
|
||||
has_transport: str | None = None
|
||||
psychotype_answers: dict[str, Any] = Field(default_factory=dict)
|
||||
tnp_lat: float | None = None
|
||||
tnp_lon: float | None = None
|
||||
@@ -42,19 +45,16 @@ class AnalysisRequest(BaseModel):
|
||||
profiles: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _first_terrain(value: str | list[str] | None) -> str | None:
|
||||
if isinstance(value, list):
|
||||
return value[0] if value else None
|
||||
return value
|
||||
|
||||
|
||||
def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
terrain = _first_terrain(payload.terrain)
|
||||
@router.post('', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
||||
async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
# B14: роутер — тонкая обёртка. Вся расчётная логика — в
|
||||
# services/search_engine.py::build_search_model (без DB/auth/HTTP).
|
||||
import time as _time
|
||||
_t0 = _time.monotonic()
|
||||
case_data: dict[str, Any] = {
|
||||
'age': payload.age,
|
||||
'gender': payload.gender,
|
||||
'terrain': terrain,
|
||||
'terrain_primary': terrain,
|
||||
'terrain': payload.terrain,
|
||||
'weather': payload.weather,
|
||||
'elapsed_hours': payload.elapsed_hours,
|
||||
'last_location': payload.last_location,
|
||||
@@ -63,62 +63,65 @@ def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
'experience': payload.experience,
|
||||
'season': payload.season,
|
||||
'diagnosis_type': payload.diagnosis_type,
|
||||
'profiles': list(payload.profiles or []),
|
||||
'has_transport': payload.has_transport,
|
||||
'psychotype_answers': payload.psychotype_answers,
|
||||
'lat': payload.lat if payload.lat is not None else payload.tnp_lat,
|
||||
'lon': payload.lon if payload.lon is not None else payload.tnp_lon,
|
||||
}
|
||||
return {k: v for k, v in case_data.items() if v is not None}
|
||||
|
||||
|
||||
@router.post('', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
||||
async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
case_data = _as_case_data(payload)
|
||||
case_data = {
|
||||
k: v for k, v in case_data.items()
|
||||
if v is not None and not (isinstance(v, (list, dict)) and len(v) == 0)
|
||||
}
|
||||
|
||||
if payload.case_id is not None:
|
||||
case = db.get_case(str(payload.case_id))
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail='Case not found')
|
||||
case_data = {**case.to_detail(), **case_data}
|
||||
# Карточка хранит координаты в tnp_lat/tnp_lon (gps_lat/lon — алиасы
|
||||
# to_detail). Без этой подстройки case_id-режим терял координаты ТНП:
|
||||
# rules_analyze не строил geo-зоны и отдавал fallback «Ближняя N /
|
||||
# Средняя E» — источник «вверх-вправо» на карте.
|
||||
if case_data.get('lat') is None:
|
||||
case_data['lat'] = case_data.get('tnp_lat') or case_data.get('gps_lat')
|
||||
if case_data.get('lon') is None:
|
||||
case_data['lon'] = case_data.get('tnp_lon') or case_data.get('gps_lon')
|
||||
|
||||
max_distance_km = calculate_max_distance(case_data)
|
||||
claude_result = await claude_analyze(case_data)
|
||||
search_input = SearchInput(
|
||||
age=case_data.get('age'),
|
||||
gender=case_data.get('gender'),
|
||||
terrain=case_data.get('terrain'),
|
||||
terrain_primary=case_data.get('terrain_primary'),
|
||||
weather=case_data.get('weather'),
|
||||
elapsed_hours=case_data.get('elapsed_hours'),
|
||||
last_location=case_data.get('last_location'),
|
||||
circumstances=case_data.get('circumstances'),
|
||||
physical_condition=case_data.get('physical_condition'),
|
||||
experience=case_data.get('experience'),
|
||||
season=case_data.get('season'),
|
||||
diagnosis_type=case_data.get('diagnosis_type') or [],
|
||||
has_transport=case_data.get('has_transport'),
|
||||
cant_swim=bool(case_data.get('cant_swim')),
|
||||
psychotype_answers=case_data.get('psychotype_answers') or {},
|
||||
profiles=case_data.get('profiles') or [],
|
||||
lat=case_data.get('lat'),
|
||||
lon=case_data.get('lon'),
|
||||
loss_time=case_data.get('loss_time'),
|
||||
time_of_day=case_data.get('time_of_day'),
|
||||
)
|
||||
|
||||
scorer = WeightedScorer()
|
||||
if case_data.get('age'):
|
||||
scorer.apply_age_modifiers(int(case_data['age']))
|
||||
if case_data.get('season'):
|
||||
scorer.apply_season_modifiers(str(case_data['season']))
|
||||
if case_data.get('profiles'):
|
||||
scorer.apply_profile(list(case_data['profiles']))
|
||||
scorer._normalize_weights()
|
||||
model = await build_search_model(search_input, analyzer=rules_analyze)
|
||||
result = model.model_dump()
|
||||
|
||||
psychotype = None
|
||||
psychotype_modifiers = None
|
||||
psychotype_recommendations = None
|
||||
if case_data.get('psychotype_answers'):
|
||||
psychotype = detect_psychotype(case_data['psychotype_answers'])
|
||||
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
||||
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||
result['case_id'] = str(payload.case_id) if payload.case_id else None
|
||||
result['analyzed_at'] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
result = {
|
||||
'case_id': str(payload.case_id) if payload.case_id else None,
|
||||
'analyzed_at': datetime.utcnow().isoformat(),
|
||||
'max_distance_km': max_distance_km,
|
||||
'psychotype': psychotype,
|
||||
'psychotype_modifiers': psychotype_modifiers,
|
||||
'psychotype_recommendations': psychotype_recommendations,
|
||||
'weights': scorer.weights,
|
||||
'distance_multiplier': scorer.distance_multiplier,
|
||||
'urgency': claude_result.urgency,
|
||||
'primary_zones': [zone.model_dump() for zone in claude_result.primary_zones],
|
||||
'search_radius_km': claude_result.search_radius_km,
|
||||
'key_locations': claude_result.key_locations,
|
||||
'behavioral_prediction': claude_result.behavioral_prediction,
|
||||
'immediate_actions': claude_result.immediate_actions,
|
||||
'summary': claude_result.summary,
|
||||
'fallback_used': claude_result.fallback_used,
|
||||
}
|
||||
log.info(
|
||||
'анализ case=%s радиус=%.1fкм срочность=%s зон=%d время=%.0fмс',
|
||||
payload.case_id or '—', result.get('search_radius_km') or 0,
|
||||
result.get('urgency'), len(result.get('primary_zones') or []),
|
||||
(_time.monotonic() - _t0) * 1000,
|
||||
)
|
||||
|
||||
if payload.case_id is not None:
|
||||
db.update_case(str(payload.case_id), analysis_log=result, status='analyzed')
|
||||
|
||||
+301
-13
@@ -1,19 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordRequestForm
|
||||
from jose import JWTError, jwt
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import User
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.models import AuthEvent, SecuritySetting, User, UserSession
|
||||
from backend.password_policy import enforce_expiry, record_password_change, validate_new_password
|
||||
|
||||
log = get_logger('auth')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/auth', tags=['auth'])
|
||||
security = HTTPBearer(auto_error=False)
|
||||
@@ -33,15 +39,24 @@ class UserPublic(BaseModel):
|
||||
full_name: str | None = None
|
||||
role: str
|
||||
is_active: bool
|
||||
# B21/E1
|
||||
unit_id: str | None = None
|
||||
position: str | None = None
|
||||
permissions: list[str] = []
|
||||
must_change_password: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_orm_user(cls, user: User) -> 'UserPublic':
|
||||
def from_orm_user(cls, user: User, permissions: list[str] | None = None) -> 'UserPublic':
|
||||
return cls(
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
unit_id=str(user.unit_id) if getattr(user, 'unit_id', None) else None,
|
||||
position=getattr(user, 'position', None),
|
||||
permissions=permissions or [],
|
||||
must_change_password=bool(getattr(user, 'must_change_password', False)),
|
||||
)
|
||||
|
||||
|
||||
@@ -55,7 +70,7 @@ def get_password_hash(password: str) -> str:
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
|
||||
to_encode.update({'exp': expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
@@ -69,6 +84,143 @@ def authenticate_user(db: Session, username: str, password: str) -> User | None:
|
||||
return user
|
||||
|
||||
|
||||
def _client_meta(request: Request | None) -> tuple[str | None, str | None]:
|
||||
if request is None:
|
||||
return None, None
|
||||
return request.client.host if request.client else None, \
|
||||
(request.headers.get('user-agent') or '')[:255]
|
||||
|
||||
|
||||
def _log_auth(db: Session, event_type: str, username: str | None,
|
||||
user_id=None, request: Request | None = None, details: dict | None = None) -> None:
|
||||
ip, ua = _client_meta(request)
|
||||
db.add(AuthEvent(
|
||||
user_id=user_id, username=username, event_type=event_type,
|
||||
ip_address=ip, user_agent=ua, details=details or {},
|
||||
))
|
||||
|
||||
|
||||
def _get_setting(db: Session, key: str, default: str) -> str:
|
||||
row = db.query(SecuritySetting).filter(SecuritySetting.key == key).first()
|
||||
return row.value if row else default
|
||||
|
||||
|
||||
def _aware(dt: datetime) -> datetime:
|
||||
"""SQLite отдаёт naive datetimes — приводим к UTC-aware для арифметики."""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _check_lockout(db: Session, user: User) -> int:
|
||||
"""Секунды до разблокировки, либо 0 если не заблокирован."""
|
||||
if getattr(user, 'locked_until', None):
|
||||
remaining = (_aware(user.locked_until) - datetime.now(timezone.utc)).total_seconds()
|
||||
if remaining > 0:
|
||||
return int(remaining)
|
||||
# Срок вышел — сбрасываем
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
db.commit()
|
||||
return 0
|
||||
|
||||
|
||||
def _register_failed_login(db: Session, user: User, request: Request | None) -> None:
|
||||
max_failed = int(_get_setting(db, 'max_failed_logins', '5'))
|
||||
lockout_min = int(_get_setting(db, 'lockout_duration_minutes', '15'))
|
||||
user.failed_login_count = (user.failed_login_count or 0) + 1
|
||||
if user.failed_login_count >= max_failed:
|
||||
user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_min)
|
||||
log.warning('lockout user=%s неудачных=%d блокировка=%dмин ip=%s',
|
||||
user.username, user.failed_login_count, lockout_min,
|
||||
request.client.host if request and request.client else '—')
|
||||
_log_auth(db, 'lockout', user.username, user.id, request,
|
||||
{'failed_count': user.failed_login_count})
|
||||
else:
|
||||
log.warning('login_failed user=%s неудачных=%d ip=%s',
|
||||
user.username, user.failed_login_count,
|
||||
request.client.host if request and request.client else '—')
|
||||
_log_auth(db, 'login_failed', user.username, user.id, request,
|
||||
{'failed_count': user.failed_login_count})
|
||||
db.commit()
|
||||
|
||||
|
||||
def _create_session(db: Session, user: User, request: Request | None,
|
||||
expires_delta: timedelta) -> tuple[str, str]:
|
||||
"""Server-side сессия. Возвращает (secret_for_jwt, hash_in_db)."""
|
||||
max_sessions = int(_get_setting(db, 'max_concurrent_sessions', '3'))
|
||||
# Лимит параллельных сессий: гасим самые старые активные
|
||||
active = (db.query(UserSession)
|
||||
.filter(UserSession.user_id == user.id, UserSession.is_active == True) # noqa: E712
|
||||
.order_by(UserSession.created_at.asc()).all())
|
||||
while len(active) >= max_sessions:
|
||||
oldest = active.pop(0)
|
||||
oldest.is_active = False
|
||||
secret = os.urandom(32).hex() # уходит в JWT (jti)
|
||||
raw = hashlib.sha256(secret.encode('utf-8')).hexdigest() # хэш в БД
|
||||
ip, ua = _client_meta(request)
|
||||
now = datetime.now(timezone.utc)
|
||||
sess = UserSession(
|
||||
user_id=user.id,
|
||||
token_hash=raw,
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
expires_at=now + expires_delta,
|
||||
last_seen_at=now,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sess)
|
||||
db.commit()
|
||||
db.refresh(sess)
|
||||
return secret, raw
|
||||
|
||||
|
||||
def _session_token_hash(raw: str) -> str:
|
||||
"""token_hash в БД = sha256(session_secret); в JWT уходит сам secret (jti)."""
|
||||
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _session_active(db: Session, user: User, raw: str):
|
||||
"""True, если сессия активна и не истекла."""
|
||||
sess = (db.query(UserSession)
|
||||
.filter(UserSession.user_id == user.id,
|
||||
UserSession.token_hash == _session_token_hash(raw),
|
||||
UserSession.is_active == True).first()) # noqa: E712
|
||||
if not sess:
|
||||
return False
|
||||
if sess.expires_at and _aware(sess.expires_at) < datetime.now(timezone.utc):
|
||||
sess.is_active = False
|
||||
db.commit()
|
||||
return False
|
||||
sess.last_seen_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def user_permissions(db: Session, user: User) -> list[str]:
|
||||
"""Атомарные права пользователя: через user_roles → role_permissions.
|
||||
Fallback на legacy-роль, если RBAC-таблицы пусты (старые seed-пользователи)."""
|
||||
codes = []
|
||||
try:
|
||||
rows = (db.execute(
|
||||
text("""
|
||||
SELECT DISTINCT p.code
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
JOIN role_permissions rp ON rp.role_id = r.id
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE ur.user_id = :uid
|
||||
"""), {'uid': str(user.id)}).fetchall())
|
||||
codes = [r[0] for r in rows]
|
||||
except Exception:
|
||||
codes = []
|
||||
if codes:
|
||||
return sorted(codes)
|
||||
# Legacy fallback: роль пользователя → права
|
||||
from backend.rbac import ROLE_PERMISSIONS
|
||||
return sorted(ROLE_PERMISSIONS.get(user.role, ()))
|
||||
|
||||
|
||||
def _test_user() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id='test-user',
|
||||
@@ -78,9 +230,19 @@ def _test_user() -> SimpleNamespace:
|
||||
role='admin',
|
||||
is_active=True,
|
||||
last_login=None,
|
||||
unit_id=None,
|
||||
position=None,
|
||||
status='active',
|
||||
must_change_password=False,
|
||||
failed_login_count=0,
|
||||
locked_until=None,
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_test_user(user: User) -> bool:
|
||||
return getattr(user, 'unit_id', None) is None and os.getenv('PYTEST_CURRENT_TEST')
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -124,6 +286,32 @@ async def get_current_user(
|
||||
detail='Could not validate credentials',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
)
|
||||
|
||||
# B21/E1: блокировка/статус
|
||||
user_status = getattr(user, 'status', 'active')
|
||||
if user_status in ('locked', 'disabled'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Account is locked or disabled',
|
||||
)
|
||||
if _check_lockout(db, user) > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Account is temporarily locked',
|
||||
)
|
||||
|
||||
# Server-side сессия: если токен несёт sid — проверяем активность
|
||||
sid = payload.get('sid')
|
||||
if sid and not _session_active(db, user, sid):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Session expired or revoked',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
)
|
||||
|
||||
# B23: срок действия пароля (security_settings) — исполняется, а не декларируется
|
||||
enforce_expiry(db, user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -139,29 +327,129 @@ def require_roles(allowed_roles: list[str]):
|
||||
return checker
|
||||
|
||||
|
||||
def require_permission(code: str):
|
||||
"""E1: атомарное право через RBAC (fallback на legacy-роль admin)."""
|
||||
async def checker(current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)) -> User:
|
||||
if code in user_permissions(db, current_user):
|
||||
return current_user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'Access denied. Required permission: {code}',
|
||||
)
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
@router.post('/login', response_model=Token)
|
||||
def login(
|
||||
request: Request,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, str]:
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
|
||||
if user and getattr(user, 'status', 'active') in ('locked', 'disabled'):
|
||||
_log_auth(db, 'login_failed', user.username, user.id, request,
|
||||
{'reason': 'account_status'})
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Account is locked or disabled',
|
||||
)
|
||||
|
||||
auth_ok = user is not None and verify_password(form_data.password, user.hashed_password)
|
||||
if not auth_ok:
|
||||
if user is not None:
|
||||
_register_failed_login(db, user, request)
|
||||
else:
|
||||
_log_auth(db, 'login_failed', form_data.username, None, request)
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Incorrect username or password',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
)
|
||||
|
||||
user.last_login = datetime.utcnow()
|
||||
lock_left = _check_lockout(db, user)
|
||||
if lock_left > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'Account is locked. Try again in {lock_left // 60 + 1} min',
|
||||
)
|
||||
|
||||
expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
sid_secret, _sid_hash = _create_session(db, user, request, expires)
|
||||
user.failed_login_count = 0
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
_log_auth(db, 'login', user.username, user.id, request)
|
||||
db.commit()
|
||||
log.info('login_ok user=%s ip=%s',
|
||||
getattr(user, 'username', '—'),
|
||||
request.client.host if request.client else '—')
|
||||
|
||||
token = create_access_token(
|
||||
{'sub': user.username, 'role': user.role},
|
||||
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
{'sub': user.username, 'role': user.role, 'sid': sid_secret},
|
||||
expires,
|
||||
)
|
||||
return {'access_token': token, 'token_type': 'bearer'}
|
||||
|
||||
|
||||
@router.get('/me', response_model=UserPublic)
|
||||
def me(current_user: User = Depends(get_current_user)) -> UserPublic:
|
||||
return UserPublic.from_orm_user(current_user)
|
||||
def me(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> UserPublic:
|
||||
return UserPublic.from_orm_user(current_user, permissions=user_permissions(db, current_user))
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post('/change-password')
|
||||
def change_password(
|
||||
payload: PasswordChange,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, str]:
|
||||
if not verify_password(payload.current_password, current_user.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Текущий пароль неверен')
|
||||
# B23: полная политика (длина, сложность, история) вместо голой длины
|
||||
try:
|
||||
validate_new_password(db, current_user, payload.new_password,
|
||||
current_hash=current_user.hashed_password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
new_hash = get_password_hash(payload.new_password)
|
||||
record_password_change(db, current_user, new_hash,
|
||||
previous_hash=current_user.hashed_password)
|
||||
current_user.hashed_password = new_hash
|
||||
current_user.must_change_password = False
|
||||
current_user.updated_at = datetime.now(timezone.utc)
|
||||
# Отзываем все сессии после смены пароля (кроме текущей — её пересоздадим)
|
||||
(db.query(UserSession)
|
||||
.filter(UserSession.user_id == current_user.id, UserSession.is_active == True)
|
||||
.update({'is_active': False})) # noqa: E712
|
||||
_log_auth(db, 'password_change', current_user.username, current_user.id)
|
||||
db.commit()
|
||||
return {'status': 'ok', 'message': 'Пароль изменён; войдите заново'}
|
||||
|
||||
|
||||
@router.post('/logout')
|
||||
def logout(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
sid = payload.get('sid')
|
||||
if sid:
|
||||
(db.query(UserSession)
|
||||
.filter(UserSession.user_id == current_user.id,
|
||||
UserSession.token_hash == _session_token_hash(sid))
|
||||
.update({'is_active': False}))
|
||||
except JWTError:
|
||||
pass
|
||||
_log_auth(db, 'logout', current_user.username, current_user.id)
|
||||
db.commit()
|
||||
return {'status': 'ok'}
|
||||
@@ -45,6 +45,8 @@ def get_case(case_id: str) -> dict:
|
||||
@router.patch('/{case_id}', response_model=CaseResponse)
|
||||
def patch_case(case_id: str, payload: CaseUpdate) -> dict:
|
||||
data = payload.model_dump(exclude_none=True)
|
||||
# default_factory-списки затирали terrain/diagnosis_type пустыми массивами
|
||||
data = {k: v for k, v in data.items() if not (isinstance(v, list) and not v)}
|
||||
note = data.pop('note', None)
|
||||
if note is not None and 'notes' not in data:
|
||||
data['notes'] = note
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Завершённые поиски — ручной ввод данных + прогон матмодели БЕЗ ИИ.
|
||||
|
||||
Цель: датасет для калибровки зонального скоринга (B12) и архив исторических
|
||||
поисков. LLM не используется: скоринг полностью детерминированный
|
||||
(WeightedScorer + distance_service + психотип).
|
||||
|
||||
Прогон модели = тот же расчёт, что /analyze, но без LLM-анализатора:
|
||||
urgency/immediate_actions/behavioral_prediction вычисляются по правилам,
|
||||
зоны считаются WeightedScorer-ом по фактическим данным кейса.
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/closed-cases — внести завершённый поиск (+авто-прогон модели)
|
||||
GET /api/v1/closed-cases — список (для таблицы в админке)
|
||||
GET /api/v1/closed-cases/{id} — карточка
|
||||
DELETE /api/v1/closed-cases/{id} — удалить (опечатка)
|
||||
POST /api/v1/closed-cases/{id}/reanalyze — перегнать модель по внесённым данным
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from backend.database import db
|
||||
from backend.routers.analyze import AnalysisRequest
|
||||
from services.search_engine import (
|
||||
derive_profiles as _derive_profiles,
|
||||
derive_time_of_day as _derive_time_of_day,
|
||||
unmodeled_profiles as _unmodeled_profiles,
|
||||
)
|
||||
from backend.routers.auth import require_roles
|
||||
from backend.schemas import ClosedCaseCreate, ClosedCaseListResponse, ClosedCaseResponse
|
||||
from services.distance_service import (
|
||||
calculate_max_distance,
|
||||
get_base_speed,
|
||||
get_diagnosis_coefficient,
|
||||
get_terrain_coefficient,
|
||||
get_time_of_day_coefficient,
|
||||
get_transport_coefficient,
|
||||
get_weather_coefficient,
|
||||
)
|
||||
from services.psychotype_service import (
|
||||
detect_psychotype,
|
||||
get_psychotype_modifiers,
|
||||
get_search_recommendations,
|
||||
)
|
||||
from services.scoring_service import WeightedScorer
|
||||
|
||||
router = APIRouter(
|
||||
prefix='/api/v1/closed-cases',
|
||||
tags=['closed-cases'],
|
||||
dependencies=[Depends(require_roles(['operator', 'admin']))],
|
||||
)
|
||||
|
||||
|
||||
def _run_model(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Детерминированный прогон матмодели по данным завершённого поиска.
|
||||
|
||||
Повторяет логику analyze.analyze_case, но без LLM-анализатора:
|
||||
urgency/behavioral_prediction/immediate_actions вычисляются правилами.
|
||||
"""
|
||||
# Психотип из ответов или явного указания
|
||||
psychotype = None
|
||||
psychotype_modifiers = None
|
||||
psychotype_recommendations = None
|
||||
if data.get('psychotype_answers'):
|
||||
psychotype = detect_psychotype(data['psychotype_answers'])
|
||||
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
||||
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||
elif data.get('psychotype'):
|
||||
psychotype = data['psychotype']
|
||||
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
||||
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||
|
||||
time_of_day = _derive_time_of_day(data.get('loss_time'))
|
||||
profiles = _derive_profiles(
|
||||
data.get('diagnosis_type') or [],
|
||||
data.get('has_transport'),
|
||||
bool(data.get('cant_swim')),
|
||||
explicit=data.get('profiles') or [],
|
||||
)
|
||||
|
||||
max_distance_km = calculate_max_distance(data)
|
||||
|
||||
scorer = WeightedScorer()
|
||||
if data.get('age_years'):
|
||||
scorer.apply_age_modifiers(int(data['age_years']))
|
||||
if data.get('season'):
|
||||
scorer.apply_season_modifiers(str(data['season']))
|
||||
if profiles:
|
||||
scorer.apply_profile(profiles)
|
||||
if psychotype_modifiers:
|
||||
data['psychotype_modifiers'] = psychotype_modifiers
|
||||
scorer._normalize_weights()
|
||||
|
||||
elapsed = float(data.get('elapsed_hours') or 1)
|
||||
age_val = int(data.get('age_years') or 10)
|
||||
|
||||
coefficients = {
|
||||
'base_speed': get_base_speed(age_val),
|
||||
'terrain': get_terrain_coefficient(data.get('terrain_primary') or (data.get('terrain') or ['лес'])[0]),
|
||||
'diagnosis': get_diagnosis_coefficient(data.get('diagnosis_type')),
|
||||
'urgency': get_transport_coefficient(data.get('has_transport')),
|
||||
'fatigue': max(0.3, 1.0 - 0.05 * elapsed),
|
||||
'time_of_day': get_time_of_day_coefficient(_derive_time_of_day(data.get('loss_time'))),
|
||||
'weather': get_weather_coefficient(data.get('weather') or 'нет'),
|
||||
}
|
||||
|
||||
# Правила вместо LLM: urgency по elapsed + возраст + диагноз
|
||||
urgency = _rule_based_urgency(elapsed, age_val, data.get('diagnosis_type') or [])
|
||||
behavioral = _rule_based_behavior(data, profiles, psychotype)
|
||||
actions = _rule_based_actions(data, profiles, psychotype)
|
||||
|
||||
return {
|
||||
'max_distance_km': max_distance_km,
|
||||
'coefficients': coefficients,
|
||||
'time_of_day': time_of_day,
|
||||
'psychotype': psychotype,
|
||||
'psychotype_modifiers': psychotype_modifiers,
|
||||
'psychotype_recommendations': psychotype_recommendations,
|
||||
'weights': scorer.weights,
|
||||
'distance_multiplier': scorer.distance_multiplier,
|
||||
'active_profiles': scorer.get_active_profiles_info(),
|
||||
'critical_warnings': scorer.critical_warnings,
|
||||
'urgency': urgency,
|
||||
'behavioral_prediction': behavioral,
|
||||
'immediate_actions': actions,
|
||||
'summary': 'Детерминированный расчёт (без LLM) — форма «Завершённый поиск»',
|
||||
'fallback_used': False,
|
||||
'llm_used': False,
|
||||
}
|
||||
|
||||
|
||||
def _rule_based_urgency(elapsed: float, age: int, diagnosis: list[str]) -> str:
|
||||
"""urgency по правилам: чем меньше ребёнок и дольше поиск — тем выше срочность."""
|
||||
score = 0
|
||||
if elapsed >= 24:
|
||||
score += 2
|
||||
elif elapsed >= 8:
|
||||
score += 1
|
||||
if age <= 7:
|
||||
score += 2
|
||||
elif age <= 12:
|
||||
score += 1
|
||||
if diagnosis:
|
||||
score += 1
|
||||
if score >= 4:
|
||||
return 'критическая'
|
||||
if score >= 2:
|
||||
return 'высокая'
|
||||
return 'средняя'
|
||||
|
||||
|
||||
def _rule_based_behavior(data: dict, profiles: list[str], psychotype: str | None) -> str:
|
||||
"""Краткое поведенческое предсказание по профилям (справочник WeightedScorer)."""
|
||||
parts: list[str] = []
|
||||
if psychotype:
|
||||
parts.append(f'Психотип: {psychotype}.')
|
||||
if 'РАС' in profiles:
|
||||
parts.append('Склонен держаться линейных ориентиров (дороги, заборы, ручьи); может прятаться от поисковиков.')
|
||||
if 'эпилепсия' in profiles:
|
||||
parts.append('Риск дезориентации после приступа; искать вблизи водных преград и уклонов.')
|
||||
if 'СДВГ' in profiles:
|
||||
parts.append('Импульсивное движение вперёд по линии наименьшего сопротивления, большие дистанции.')
|
||||
if 'ЗПР' in profiles:
|
||||
parts.append('Держится знакомых мест; вероятен возврат к точке потери или знакомым объектам.')
|
||||
if str(data.get('has_transport') or '') == 'bike':
|
||||
parts.append('Транспорт (велик) сильно расширяет радиус — проверить линейные объекты вдоль дорог.')
|
||||
if data.get('cant_swim'):
|
||||
parts.append('Не умеет плавать — приоритет ближним водоёмам.')
|
||||
if not parts:
|
||||
parts.append('Типовое поведение для возраста: линейное движение по дороге/тропе с постепенным отклонением.')
|
||||
return ' '.join(parts)
|
||||
|
||||
|
||||
def _rule_based_actions(data: dict, profiles: list[str], psychotype: str | None) -> list[str]:
|
||||
"""Первоочередные действия по правилам (справочник по профилям)."""
|
||||
actions: list[str] = []
|
||||
elapsed = float(data.get('elapsed_hours') or 0)
|
||||
if elapsed >= 8:
|
||||
actions.append('Подключить кинолога к зоне ТНП в первую очередь')
|
||||
if 'РАС' in profiles:
|
||||
actions.append('Проверить укрытия, строения, ёмкости с водой — обследование с физическим осмотром')
|
||||
actions.append('Опросить соседей о ближайших «точках притяжения» (гараж, сарай, теплица)')
|
||||
if data.get('cant_swim'):
|
||||
actions.append('Обследовать ближайшие водоёмы немедленно')
|
||||
if elapsed >= 24:
|
||||
actions.append('Расширить поиск за пределы расчётного радиуса')
|
||||
actions.append('Опросить жителей и водителей, проверив направления от ТНП')
|
||||
return actions
|
||||
|
||||
|
||||
def _accuracy_note(prediction: dict[str, Any], fact: ClosedCaseCreate) -> str | None:
|
||||
"""Сравнить прогноз модели с реальным исходом (текстово, человекочитаемо)."""
|
||||
if fact.found_distance_km is None:
|
||||
return None
|
||||
try:
|
||||
max_d = float(prediction.get('max_distance_km') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if max_d <= 0:
|
||||
return 'Прогноз не построен'
|
||||
found_km = float(fact.found_distance_km)
|
||||
ratio = found_km / max_d if max_d else 0
|
||||
if ratio <= 1.0:
|
||||
note = f'Найден в {found_km:.1f} км — внутри прогнозного радиуса {max_d:.1f} км (соотношение {ratio:.2f}).'
|
||||
else:
|
||||
note = f'Найден в {found_km:.1f} км — ЗА пределами прогнозного радиуса {max_d:.1f} км (соотношение {ratio:.2f}).'
|
||||
if not fact.found_alive:
|
||||
note += ' Исход неблагоприятный.'
|
||||
return note
|
||||
|
||||
|
||||
# ---------------- Endpoints ----------------
|
||||
|
||||
@router.post('', response_model=ClosedCaseResponse)
|
||||
def create_closed_case(payload: ClosedCaseCreate) -> dict[str, Any]:
|
||||
data = payload.model_dump()
|
||||
prediction = _run_model(data)
|
||||
accuracy = _accuracy_note(prediction, payload)
|
||||
|
||||
case = db.create_case(
|
||||
**{k: v for k, v in data.items() if v is not None},
|
||||
status='closed',
|
||||
)
|
||||
# Прогноз и заметку о точности храним в analysis_log (JSONB) —
|
||||
# схема cases не меняется, отдельной таблицы не нужно.
|
||||
db.update_case(
|
||||
str(case.id),
|
||||
analysis_log={
|
||||
'closed_case': True,
|
||||
'model_prediction': prediction,
|
||||
'accuracy_note': accuracy,
|
||||
'source': payload.source,
|
||||
},
|
||||
)
|
||||
detail = case.to_detail()
|
||||
return {**data, 'id': str(case.id), 'created_at': detail.get('created_at'),
|
||||
'model_prediction': prediction, 'accuracy_note': accuracy}
|
||||
|
||||
|
||||
@router.get('', response_model=ClosedCaseListResponse)
|
||||
def list_closed_cases(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
) -> dict:
|
||||
# status='closed' — надёжный признак архивной записи
|
||||
# (analysis_log в to_summary не входит; фильтр в питоне — совместимо с TestDB)
|
||||
cases = [c for c in db.list_cases() if c.to_summary().get('status') == 'closed']
|
||||
items = []
|
||||
for c in cases:
|
||||
detail = c.to_detail()
|
||||
log = detail.get('analysis_log') or {}
|
||||
if not log.get('closed_case'):
|
||||
continue
|
||||
items.append({
|
||||
**{k: detail.get(k) for k in ClosedCaseCreate.model_fields},
|
||||
'id': detail['id'],
|
||||
'created_at': detail.get('created_at'),
|
||||
'model_prediction': log.get('model_prediction'),
|
||||
'accuracy_note': log.get('accuracy_note'),
|
||||
})
|
||||
start = (page - 1) * page_size
|
||||
return {'items': items[start:start + page_size], 'total': len(items)}
|
||||
|
||||
|
||||
@router.get('/{case_id}', response_model=ClosedCaseResponse)
|
||||
def get_closed_case(case_id: str) -> dict[str, Any]:
|
||||
case = db.get_case(case_id)
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail='Case not found')
|
||||
detail = case.to_detail()
|
||||
log = detail.get('analysis_log') or {}
|
||||
if not log.get('closed_case'):
|
||||
raise HTTPException(status_code=404, detail='Не завершённый поиск')
|
||||
return {**{k: detail.get(k) for k in ClosedCaseCreate.model_fields},
|
||||
'id': detail['id'], 'created_at': detail.get('created_at'),
|
||||
'model_prediction': log.get('model_prediction'),
|
||||
'accuracy_note': log.get('accuracy_note')}
|
||||
|
||||
|
||||
@router.post('/{case_id}/reanalyze')
|
||||
def reanalyze_closed_case(case_id: str) -> dict[str, Any]:
|
||||
"""Перегнать модель по внесённым данным (после правки коэффициентов/данных)."""
|
||||
case = db.get_case(case_id)
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail='Case not found')
|
||||
detail = case.to_detail()
|
||||
log = detail.get('analysis_log') or {}
|
||||
if not log.get('closed_case'):
|
||||
raise HTTPException(status_code=400, detail='Не завершённый поиск')
|
||||
|
||||
data = {k: detail.get(k) for k in ClosedCaseCreate.model_fields}
|
||||
prediction = _run_model(data)
|
||||
fact = ClosedCaseCreate(**data)
|
||||
accuracy = _accuracy_note(prediction, fact)
|
||||
new_log = {**log, 'model_prediction': prediction, 'accuracy_note': accuracy}
|
||||
db.update_case(case_id, analysis_log=new_log)
|
||||
return {'model_prediction': prediction, 'accuracy_note': accuracy}
|
||||
|
||||
|
||||
@router.delete('/{case_id}')
|
||||
def delete_closed_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,358 @@
|
||||
"""B21/E4: отправка приоритетных зон в КОНТУР (контракт B20, outbound).
|
||||
|
||||
POST /api/v1/operations/{id}/send-to-contour:
|
||||
анализ кейса → SearchZones[] (GeoJSON-сектора направлений) →
|
||||
POST {CONTOUR_API_URL}/api/v1/vector/zones?operation_id=<contour_operation_id>
|
||||
|
||||
B17/E5: ингестия полевых данных (inbound):
|
||||
POST /api/v1/operations/{id}/field-data/sync — тянет из КОНТУРа
|
||||
GET /api/v1/vector/areas-checked?operation_id=<contour_operation_id> и
|
||||
upsert'ит в локальную areas_checked (dedupe по contour_area_id);
|
||||
GET /api/v1/operations/{id}/areas-checked — читает локальную БД.
|
||||
|
||||
Требуется: у операции задан contour_operation_id (UUID операции в КОНТУРе),
|
||||
env CONTOUR_API_URL и CONTOUR_TOKEN (межсервисный, в secrets).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import uuid as uuid_mod
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.audit import audit_log, can_access_unit
|
||||
from backend.database import get_db
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.models import AreaChecked, SearchOperation, User
|
||||
from backend.routers.auth import get_current_user, require_permission
|
||||
|
||||
log = get_logger('contour')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/operations', tags=['operations-contour'])
|
||||
|
||||
def _contour_url() -> str:
|
||||
return os.getenv('CONTOUR_API_URL', 'http://192.168.0.130:8000')
|
||||
|
||||
|
||||
def _contour_token() -> str:
|
||||
return os.getenv('CONTOUR_TOKEN', '')
|
||||
SCHEMA_VERSION = '1.0'
|
||||
|
||||
_DIRECTIONS = {
|
||||
'N': 0, 'NE': 45, 'E': 90, 'SE': 135,
|
||||
'S': 180, 'SW': 225, 'W': 270, 'NW': 315,
|
||||
}
|
||||
|
||||
|
||||
class SendResult(BaseModel):
|
||||
sent_zones: int
|
||||
contour_operation_id: str
|
||||
contour_response: dict[str, Any]
|
||||
|
||||
|
||||
def _sector_polygon(lat: float, lon: float, radius_km: float, direction: str,
|
||||
segments: int = 24) -> dict:
|
||||
"""GeoJSON Polygon сектора ±22.5° вокруг направления (для контракта B20)."""
|
||||
az = _DIRECTIONS.get(direction, 0)
|
||||
half = 22.5
|
||||
ring: list[list[float]] = [[lon, lat]]
|
||||
for i in range(segments + 1):
|
||||
bearing = math.radians(az - half + (2 * half) * i / segments)
|
||||
dlat = (radius_km / 111.0) * math.cos(bearing)
|
||||
dlon = (radius_km / (111.0 * math.cos(math.radians(lat)))) * math.sin(bearing)
|
||||
ring.append([round(lon + dlon, 7), round(lat + dlat, 7)])
|
||||
ring.append([lon, lat])
|
||||
return {'type': 'Polygon', 'coordinates': [ring]}
|
||||
|
||||
|
||||
def _probability_from_priority(priority: int, total: int) -> float:
|
||||
"""Градация: топ-зона → наибольшая вероятность (0..1)."""
|
||||
if total <= 1:
|
||||
return 0.8
|
||||
return round(max(0.1, 0.9 - 0.7 * (priority - 1) / max(1, total - 1)), 3)
|
||||
|
||||
|
||||
def _load_analysis(db, case_id) -> dict:
|
||||
"""Последний анализ кейса: search_models (B15) или analysis_log (legacy)."""
|
||||
from sqlalchemy import text as sa_text
|
||||
row = db.execute(
|
||||
sa_text("SELECT model_json FROM search_models "
|
||||
"WHERE case_id = :cid ORDER BY version DESC LIMIT 1"),
|
||||
{'cid': str(case_id)},
|
||||
).first()
|
||||
if row and row[0]:
|
||||
return dict(row[0])
|
||||
from backend.models import Case
|
||||
case = db.get(__import__('backend.models', fromlist=['Case']).Case, case_id)
|
||||
if case and case.analysis_log and isinstance(case.analysis_log, dict):
|
||||
return dict(case.analysis_log)
|
||||
raise HTTPException(status_code=400, detail='У карточки нет анализа — запустите анализ сначала')
|
||||
|
||||
|
||||
def _build_b20_payload(op: SearchOperation, analysis: dict) -> dict:
|
||||
"""SearchZones[] из primary_zones анализа (полигоны направлений)."""
|
||||
lat = analysis.get('lat') or analysis.get('tnp_lat')
|
||||
lon = analysis.get('lon') or analysis.get('tnp_lon')
|
||||
if lat is None or lon is None:
|
||||
raise HTTPException(status_code=400,
|
||||
detail='В анализе нет координат ТНП — невозможны геометрии зон')
|
||||
max_distance = float(analysis.get('max_distance_km') or 3.0)
|
||||
zones_in = analysis.get('primary_zones') or []
|
||||
if not zones_in:
|
||||
raise HTTPException(status_code=400, detail='В анализе нет приоритетных зон')
|
||||
|
||||
out_zones = []
|
||||
for z in zones_in[:5]:
|
||||
direction = z.get('direction')
|
||||
distance = float(z.get('distance') or max_distance * 0.5)
|
||||
priority = int(z.get('priority') or 1)
|
||||
out_zones.append({
|
||||
'zone_id': f"vec:{direction}:{int(distance * 1000)}m",
|
||||
'geom': _sector_polygon(float(lat), float(lon), distance, direction),
|
||||
'priority': priority,
|
||||
'probability': _probability_from_priority(priority, len(zones_in[:5])),
|
||||
'reasoning': z.get('reason') or f'Приоритет {priority} по модели ВЕКТОРа',
|
||||
})
|
||||
|
||||
return {
|
||||
'schema_version': SCHEMA_VERSION,
|
||||
'case_id': str(op.case_id),
|
||||
'model_version': f"vector:{(analysis.get('analyzed_at') or datetime.now(timezone.utc).isoformat())[:19]}",
|
||||
'generated_at': analysis.get('analyzed_at') or datetime.now(timezone.utc).isoformat(),
|
||||
'max_distance_km': max_distance,
|
||||
'zones': out_zones,
|
||||
}
|
||||
|
||||
|
||||
@router.post('/{operation_id}/send-to-contour')
|
||||
def send_to_contour(
|
||||
operation_id: str,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permission('update')),
|
||||
db = Depends(get_db),
|
||||
) -> SendResult:
|
||||
op = _load_operation(db, current_user, operation_id)
|
||||
|
||||
if not op.contour_operation_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='У операции не задан contour_operation_id (UUID операции в КОНТУРе) — '
|
||||
'укажите его в карточке операции (PATCH /operations/{id})',
|
||||
)
|
||||
if not _contour_token():
|
||||
raise HTTPException(status_code=503, detail='CONTOUR_TOKEN не настроен')
|
||||
|
||||
analysis = _load_analysis(db, op.case_id)
|
||||
payload = _build_b20_payload(op, analysis)
|
||||
|
||||
url = f"{_contour_url()}/api/v1/vector/zones?operation_id={op.contour_operation_id}"
|
||||
try:
|
||||
resp = httpx.post(url, json=payload,
|
||||
headers={'Authorization': f'Bearer {_contour_token()}'},
|
||||
timeout=30.0)
|
||||
except httpx.HTTPError as e:
|
||||
log.warning('contour_send_failed op=%s url=%s error=%s', op.id, _contour_url(), e)
|
||||
audit_log(db, current_user, 'contour_send_failed', object_type='operation',
|
||||
object_id=str(op.id), request=request, details={'error': str(e)})
|
||||
raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e
|
||||
|
||||
if resp.status_code >= 400:
|
||||
log.warning('contour_rejected op=%s status=%d body=%s',
|
||||
op.id, resp.status_code, resp.text[:200])
|
||||
audit_log(db, current_user, 'contour_send_failed', object_type='operation',
|
||||
object_id=str(op.id), request=request,
|
||||
details={'status': resp.status_code, 'body': resp.text[:500]})
|
||||
raise HTTPException(status_code=502,
|
||||
detail=f'КОНТУР отклонил импорт: {resp.status_code} {resp.text[:300]}')
|
||||
|
||||
result = resp.json()
|
||||
log.info('contour_send_ok op=%s зон=%d contour_op=%s',
|
||||
op.id, len(payload['zones']), op.contour_operation_id)
|
||||
audit_log(db, current_user, 'contour_send_ok', object_type='operation',
|
||||
object_id=str(op.id), request=request,
|
||||
details={'zones': result.get('zones_total'), 'status': result.get('status')})
|
||||
return SendResult(
|
||||
sent_zones=payload['zones'].__len__(),
|
||||
contour_operation_id=str(op.contour_operation_id),
|
||||
contour_response=result,
|
||||
)
|
||||
|
||||
|
||||
def _load_operation(db, user: User, operation_id: str) -> SearchOperation:
|
||||
op = db.get(SearchOperation, uuid_mod.UUID(operation_id)) if operation_id else None
|
||||
if not op:
|
||||
raise HTTPException(status_code=404, detail='Операция не найдена')
|
||||
if not can_access_unit(db, user, str(op.unit_id) if op.unit_id else None):
|
||||
raise HTTPException(status_code=403, detail='Операция вне вашего скоупа')
|
||||
return op
|
||||
|
||||
|
||||
@router.get('/{operation_id}/areas-checked')
|
||||
def areas_checked(
|
||||
operation_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Проверенные квадраты операции (B17): из локальной БД после sync.
|
||||
|
||||
Если ещё не синхронизировано (0 записей) — прозрачно проксирует в КОНТУР,
|
||||
чтобы страница работала без обязательного sync (live-режим, B17-заготовка).
|
||||
"""
|
||||
op = _load_operation(db, current_user, operation_id)
|
||||
|
||||
local = _local_areas_checked(db, op)
|
||||
if local:
|
||||
return {'source': 'db', 'items': local}
|
||||
|
||||
# Фолбэк: живой прокси в КОНТУР (как было до B17)
|
||||
if not op.contour_operation_id:
|
||||
return {'source': 'empty', 'items': []}
|
||||
if not _contour_token():
|
||||
raise HTTPException(status_code=503, detail='CONTOUR_TOKEN не настроен')
|
||||
url = f"{_contour_url()}/api/v1/vector/areas-checked?operation_id={op.contour_operation_id}"
|
||||
try:
|
||||
resp = httpx.get(url, headers={'Authorization': f'Bearer {_contour_token()}'}, timeout=30.0)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e
|
||||
if resp.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail=f'КОНТУР: {resp.status_code} {resp.text[:300]}')
|
||||
return {'source': 'contour', 'items': resp.json()}
|
||||
|
||||
|
||||
def _parse_iso(dt_str: Any) -> Optional[datetime]:
|
||||
if not dt_str or not isinstance(dt_str, str):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(str(dt_str).replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_contour_geom(raw: Any) -> Optional[dict]:
|
||||
"""Геометрия КОНТУРа приходит JSON-строкой (ST_AsGeoJSON) — иногда dict."""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
try:
|
||||
return json.loads(str(raw))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _local_areas_checked(db: Session, op: SearchOperation) -> list[dict[str, Any]]:
|
||||
"""Локальные проверенные квадраты операции (после field-data/sync)."""
|
||||
from backend.models import Case
|
||||
case = db.get(Case, op.case_id)
|
||||
if case is None:
|
||||
return []
|
||||
rows = (db.query(AreaChecked)
|
||||
.filter(AreaChecked.case_id == op.case_id)
|
||||
.order_by(AreaChecked.checked_at.desc().nullslast())
|
||||
.all())
|
||||
return [{
|
||||
'area_id': r.contour_area_id or str(r.id),
|
||||
'geom': r.geom,
|
||||
'team_id': r.team_label,
|
||||
'checked_at': r.checked_at.isoformat() if r.checked_at else None,
|
||||
'result': r.result,
|
||||
'coverage_pct': r.coverage_pct,
|
||||
'synced': True,
|
||||
} for r in rows]
|
||||
|
||||
|
||||
class SyncResult(BaseModel):
|
||||
synced_areas: int
|
||||
updated_areas: int
|
||||
new_areas: int
|
||||
found_events: int
|
||||
last_checked_at: Optional[str] = None
|
||||
contour_area_count: int
|
||||
|
||||
|
||||
@router.post('/{operation_id}/field-data/sync')
|
||||
def field_data_sync(
|
||||
operation_id: str,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permission('update')),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SyncResult:
|
||||
"""B17: синхронизация полевых данных из КОНТУРа в локальную БД.
|
||||
|
||||
Тянет GET /api/v1/vector/areas-checked (весь список, idempotent upsert
|
||||
по contour_area_id). found (100%) на стороне КОНТУРа сейчас не бывает —
|
||||
находка фиксируется завершением поиска; счётчик found_events — резерв.
|
||||
"""
|
||||
op = _load_operation(db, current_user, operation_id)
|
||||
if not op.contour_operation_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='У операции не задан contour_operation_id — укажите его в карточке операции',
|
||||
)
|
||||
if not _contour_token():
|
||||
raise HTTPException(status_code=503, detail='CONTOUR_TOKEN не настроен')
|
||||
|
||||
url = f"{_contour_url()}/api/v1/vector/areas-checked?operation_id={op.contour_operation_id}"
|
||||
try:
|
||||
resp = httpx.get(url, headers={'Authorization': f'Bearer {_contour_token()}'}, timeout=30.0)
|
||||
except httpx.HTTPError as e:
|
||||
log.warning('field_sync_unreachable op=%s error=%s', op.id, e)
|
||||
raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e
|
||||
if resp.status_code >= 400:
|
||||
log.warning('field_sync_rejected op=%s status=%d', op.id, resp.status_code)
|
||||
raise HTTPException(status_code=502, detail=f'КОНТУР: {resp.status_code} {resp.text[:300]}')
|
||||
|
||||
items = resp.json()
|
||||
if not isinstance(items, list):
|
||||
raise HTTPException(status_code=502, detail='КОНТУР вернул неожиданный формат areas-checked')
|
||||
|
||||
case_id = op.case_id
|
||||
existing = {r.contour_area_id: r for r in
|
||||
db.query(AreaChecked).filter(AreaChecked.case_id == case_id).all()
|
||||
if r.contour_area_id}
|
||||
new_n = upd_n = 0
|
||||
latest: Optional[datetime] = None
|
||||
for item in items:
|
||||
if not isinstance(item, dict) or not item.get('area_id'):
|
||||
continue
|
||||
geom = _parse_contour_geom(item.get('geom'))
|
||||
checked_at = _parse_iso(item.get('checked_at'))
|
||||
if checked_at and (latest is None or checked_at > latest):
|
||||
latest = checked_at
|
||||
row = existing.get(item['area_id'])
|
||||
if row is None:
|
||||
row = AreaChecked(case_id=case_id, contour_area_id=item['area_id'])
|
||||
db.add(row)
|
||||
new_n += 1
|
||||
else:
|
||||
upd_n += 1
|
||||
row.geom = geom
|
||||
row.checked_at = checked_at
|
||||
row.result = 'clear' if item.get('result') == 'clear' else 'partial'
|
||||
row.coverage_pct = float(item['coverage_pct']) if item.get('coverage_pct') is not None else None
|
||||
row.team_label = str(item['team_id']) if item.get('team_id') else None
|
||||
row.source = 'contour'
|
||||
db.commit()
|
||||
|
||||
if new_n or upd_n:
|
||||
log.info('field_sync op=%s квадратов=%d (новых=%d обновлено=%d) from=%s',
|
||||
op.id, new_n + upd_n, new_n, _contour_url())
|
||||
audit_log(db, current_user, 'field_data_sync', object_type='search_operation',
|
||||
object_id=str(op.id), request=request,
|
||||
details={'new': new_n, 'updated': upd_n,
|
||||
'total': len(items)})
|
||||
return SyncResult(
|
||||
synced_areas=new_n + upd_n,
|
||||
updated_areas=upd_n,
|
||||
new_areas=new_n,
|
||||
found_events=0,
|
||||
last_checked_at=latest.isoformat() if latest else None,
|
||||
contour_area_count=len(items),
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Геокодирование: прокси к Nominatim (OpenStreetMap).
|
||||
|
||||
GET /api/v1/geocode?q=<запрос>&limit=5
|
||||
Возвращает {results: [{lat, lng, display_name, type}]}.
|
||||
|
||||
Nominatim Usage Policy: User-Agent обязателен, ≤1 rps — проксируем через
|
||||
бэкенд с корректным UA и ограничением countrycodes=by.
|
||||
|
||||
Правила запросов (nominatim-грабли, см. homelab-ops skill):
|
||||
- Короткий запрос: пункт + адрес, БЕЗ района/области.
|
||||
- Пункт ПЕРВЫМ словом.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.routers.auth import require_roles
|
||||
from fastapi import Depends
|
||||
|
||||
log = get_logger('geocode')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/geocode', tags=['geocode'])
|
||||
|
||||
NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search'
|
||||
USER_AGENT = os.getenv('NOMINATIM_USER_AGENT', 'Vector-MChS/1.0 (geocoding proxy)')
|
||||
COUNTRY_CODE = 'by'
|
||||
|
||||
|
||||
class GeocodeResult(BaseModel):
|
||||
lat: float
|
||||
lng: float
|
||||
display_name: str
|
||||
type: Optional[str] = None
|
||||
|
||||
|
||||
class GeocodeResponse(BaseModel):
|
||||
results: List[GeocodeResult]
|
||||
|
||||
|
||||
@router.get('', response_model=GeocodeResponse,
|
||||
dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
||||
async def geocode(
|
||||
q: str = Query(..., min_length=2, description='Адрес: «пункт, улица»'),
|
||||
limit: int = Query(5, ge=1, le=10),
|
||||
):
|
||||
"""Геокодирование через Nominatim, ограничено Беларусью."""
|
||||
headers = {'User-Agent': USER_AGENT}
|
||||
params = {
|
||||
'q': q.strip(),
|
||||
'format': 'jsonv2',
|
||||
'limit': limit,
|
||||
'countrycodes': COUNTRY_CODE,
|
||||
'addressdetails': 1,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(NOMINATIM_URL, headers=headers, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException:
|
||||
log.warning('geocode timeout q=%s', q.strip()[:100])
|
||||
raise HTTPException(status_code=504, detail='Сервис геокодирования не ответил')
|
||||
except Exception as e:
|
||||
log.warning('geocode error q=%s: %s', q.strip()[:100], e)
|
||||
raise HTTPException(status_code=502, detail=f'Ошибка геокодирования: {e}')
|
||||
|
||||
log.info('geocode q=%s результатов=%d', q.strip()[:100], len(data))
|
||||
|
||||
return {'results': [
|
||||
GeocodeResult(
|
||||
lat=float(item['lat']),
|
||||
lng=float(item['lon']),
|
||||
display_name=item.get('display_name', ''),
|
||||
type=item.get('type'),
|
||||
)
|
||||
for item in data
|
||||
]}
|
||||
@@ -0,0 +1,356 @@
|
||||
"""B21/E3: операции мультипоиска.
|
||||
|
||||
Скоуп: unit_id операции ∈ {своё подразделение + подчинённые} (РЦУ РЧС — все).
|
||||
Статусная машина: planned → active ⇄ paused → completed → archived.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.audit import audit_log, visible_unit_ids
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.database import get_db
|
||||
from backend.models import Case, SearchOperation, User
|
||||
from backend.routers.auth import get_current_user, require_permission
|
||||
|
||||
oplog = get_logger('operations')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/operations', tags=['operations'])
|
||||
|
||||
ALLOWED_TRANSITIONS = {
|
||||
'planned': {'active', 'archived'},
|
||||
'active': {'paused', 'completed', 'archived'},
|
||||
'paused': {'active', 'completed', 'archived'},
|
||||
'completed': {'archived'},
|
||||
'archived': set(),
|
||||
}
|
||||
|
||||
|
||||
class OperationCreate(BaseModel):
|
||||
title: str
|
||||
case_id: str
|
||||
unit_id: Optional[str] = None
|
||||
|
||||
|
||||
class OperationUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
unit_id: Optional[str] = None
|
||||
contour_operation_id: Optional[str] = None # UUID операции в КОНТУРе
|
||||
|
||||
|
||||
def _op_scope(db: Session, user: User) -> list | None:
|
||||
"""UUID-объекты юнитов из скоупа, либо None (без фильтра)."""
|
||||
scope = visible_unit_ids(db, user)
|
||||
if scope is None:
|
||||
return None
|
||||
import uuid
|
||||
return [uuid.UUID(h) if isinstance(h, str) else h for h in scope]
|
||||
|
||||
|
||||
def _get_operation_checked(db: Session, user: User, operation_id: str) -> SearchOperation:
|
||||
import uuid as uuid_mod
|
||||
op = db.get(SearchOperation, uuid_mod.UUID(operation_id)) if operation_id else None
|
||||
if not op:
|
||||
raise HTTPException(status_code=404, detail='Операция не найдена')
|
||||
scope = _op_scope(db, user)
|
||||
if scope is not None and (not op.unit_id or op.unit_id not in scope):
|
||||
raise HTTPException(status_code=403, detail='Операция вне вашего скоупа')
|
||||
return op
|
||||
|
||||
|
||||
@router.get('')
|
||||
def list_operations(
|
||||
status_filter: Optional[str] = Query(default=None, alias='status'),
|
||||
case_id: Optional[str] = Query(default=None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
scope = _op_scope(db, current_user)
|
||||
query = db.query(SearchOperation)
|
||||
if scope is not None:
|
||||
query = query.filter(SearchOperation.unit_id.in_(scope))
|
||||
if status_filter:
|
||||
query = query.filter(SearchOperation.status == status_filter)
|
||||
if case_id:
|
||||
import uuid as uuid_mod
|
||||
query = query.filter(SearchOperation.case_id == uuid_mod.UUID(case_id))
|
||||
ops = query.order_by(SearchOperation.updated_at.desc()).all()
|
||||
from backend.models import User as UserModel
|
||||
users_by_id = {}
|
||||
if ops:
|
||||
creator_ids = {o.created_by for o in ops if o.created_by}
|
||||
if creator_ids:
|
||||
for u in db.query(UserModel).filter(UserModel.id.in_(creator_ids)).all():
|
||||
users_by_id[str(u.id)] = u.full_name or u.username
|
||||
units_by_id = {}
|
||||
unit_ids = {o.unit_id for o in ops if o.unit_id}
|
||||
if unit_ids:
|
||||
from backend.models import MchsUnit
|
||||
for un in db.query(MchsUnit).filter(MchsUnit.id.in_(unit_ids)).all():
|
||||
units_by_id[str(un.id)] = un.name
|
||||
return {'items': [_operation_dto(o, users_by_id, units_by_id) for o in ops], 'total': len(ops)}
|
||||
|
||||
|
||||
def _operation_dto(op: SearchOperation, users_by_id: dict | None = None,
|
||||
units_by_id: dict | None = None) -> dict[str, Any]:
|
||||
u = users_by_id or {}
|
||||
units = units_by_id or {}
|
||||
return {
|
||||
'id': str(op.id),
|
||||
'title': op.title,
|
||||
'case_id': str(op.case_id),
|
||||
'status': op.status,
|
||||
'unit_id': str(op.unit_id) if op.unit_id else None,
|
||||
'unit_name': units.get(str(op.unit_id)) if op.unit_id else None,
|
||||
'contour_operation_id': str(op.contour_operation_id) if op.contour_operation_id else None,
|
||||
'created_by_name': u.get(str(op.created_by)) if op.created_by else None,
|
||||
'created_at': op.created_at.isoformat() if op.created_at else None,
|
||||
'updated_at': op.updated_at.isoformat() if op.updated_at else None,
|
||||
'closed_at': op.closed_at.isoformat() if op.closed_at else None,
|
||||
}
|
||||
|
||||
|
||||
class OperationComplete(BaseModel):
|
||||
found_alive: bool
|
||||
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
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@router.post('/{operation_id}/complete')
|
||||
def complete_operation(
|
||||
operation_id: str,
|
||||
payload: OperationComplete,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Завершение поиска: исход → кейс (архив + прогон модели) → операция completed."""
|
||||
op = _get_operation_checked(db, current_user, operation_id)
|
||||
|
||||
if op.status == 'completed':
|
||||
raise HTTPException(status_code=400, detail='Операция уже завершена')
|
||||
if op.status == 'archived':
|
||||
raise HTTPException(status_code=400, detail='Операция в архиве — завершение недоступно')
|
||||
|
||||
case = db.query(Case).filter(Case.id == op.case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail='Карточка операции не найдена')
|
||||
|
||||
# Данные кейса → вход матмодели (детерминированный прогон, как /analyze)
|
||||
data = {
|
||||
'age_years': case.age_years,
|
||||
'gender': case.gender or 'м',
|
||||
'diagnosis_type': case.diagnosis_type or [],
|
||||
'has_transport': case.has_transport or 'none',
|
||||
'cant_swim': bool(case.cant_swim),
|
||||
'fitness_level': case.fitness_level,
|
||||
'elapsed_hours': case.elapsed_hours or 0.0,
|
||||
'loss_time': case.loss_time,
|
||||
'terrain': case.terrain or [],
|
||||
'weather': None, 'season': case.season, 'temperature_c': case.temperature_c,
|
||||
'tnp_lat': case.tnp_lat, 'tnp_lon': case.tnp_lon, 'tnp_address': case.tnp_address,
|
||||
'psychotype': case.psychotype, 'psychotype_answers': case.psychotype_answers,
|
||||
'teams_count': case.teams_count, 'team_size': case.team_size,
|
||||
'has_dog': bool(case.has_dog),
|
||||
'found_alive': payload.found_alive,
|
||||
'found_distance_km': payload.found_distance_km,
|
||||
'found_direction': payload.found_direction,
|
||||
'found_location_type': payload.found_location_type,
|
||||
'found_lat': payload.found_lat, 'found_lon': payload.found_lon,
|
||||
'search_duration_hours': payload.search_duration_hours,
|
||||
'who_found': payload.who_found,
|
||||
'notes': payload.notes,
|
||||
'source': 'операция ВЕКТОР',
|
||||
}
|
||||
|
||||
from backend.routers.closed_cases import _run_model as _run_model_func, _accuracy_note as _accuracy_note_func
|
||||
from backend.schemas import ClosedCaseCreate
|
||||
cc = ClosedCaseCreate(**{k: v for k, v in data.items() if k in ClosedCaseCreate.model_fields})
|
||||
prediction = _run_model_func(cc.model_dump())
|
||||
accuracy = _accuracy_note_func(prediction, cc)
|
||||
|
||||
# 1. Кейс → closed + фактические данные + маркер архивной записи
|
||||
case.status = 'closed'
|
||||
case.found_alive = payload.found_alive
|
||||
case.found_distance_km = payload.found_distance_km
|
||||
case.found_direction = payload.found_direction
|
||||
case.found_location_type = payload.found_location_type
|
||||
case.found_lat = payload.found_lat
|
||||
case.found_lon = payload.found_lon
|
||||
case.search_duration_hours = payload.search_duration_hours
|
||||
case.who_found = payload.who_found
|
||||
log = dict(case.analysis_log or {})
|
||||
log.update({
|
||||
'closed_case': True,
|
||||
'model_prediction': prediction,
|
||||
'accuracy_note': accuracy,
|
||||
'source': 'операция ВЕКТОР',
|
||||
'notes': payload.notes,
|
||||
})
|
||||
case.analysis_log = log
|
||||
|
||||
# 2. Операция → completed (через статусную машину, closed_at автоматически)
|
||||
op.status = 'completed'
|
||||
op.closed_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
|
||||
oplog.info('operation_complete op=%s case=%s исход=%s юзер=%s',
|
||||
op.id, case.id, 'найден живым' if payload.found_alive else 'найден погибшим',
|
||||
getattr(current_user, 'username', '—'))
|
||||
audit_log(db, current_user, 'operation_complete',
|
||||
object_type='search_operation', object_id=str(op.id),
|
||||
details={'case_id': str(case.id), 'found_alive': payload.found_alive})
|
||||
audit_log(db, current_user, 'case_closed', object_type='case', object_id=str(case.id),
|
||||
details={'operation_id': str(op.id), 'found_alive': payload.found_alive})
|
||||
|
||||
return {
|
||||
'operation_id': str(op.id),
|
||||
'status': op.status,
|
||||
'closed_at': op.closed_at.isoformat() if op.closed_at else None,
|
||||
'case_id': str(case.id),
|
||||
'model_prediction': prediction,
|
||||
'accuracy_note': accuracy,
|
||||
}
|
||||
|
||||
|
||||
@router.get('/summary')
|
||||
def operations_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Счётчики для дашборда: активных / всего / завершённых за 24 ч."""
|
||||
scope = _op_scope(db, current_user)
|
||||
query = db.query(SearchOperation)
|
||||
if scope is not None:
|
||||
query = query.filter(SearchOperation.unit_id.in_(scope))
|
||||
total = query.count()
|
||||
active = query.filter(SearchOperation.status == 'active').count()
|
||||
day_ago = datetime.now(timezone.utc) - __import__('datetime').timedelta(hours=24)
|
||||
completed_24h = query.filter(
|
||||
SearchOperation.status == 'completed',
|
||||
SearchOperation.closed_at >= day_ago,
|
||||
).count()
|
||||
return {'active': active, 'total': total, 'completed_24h': completed_24h}
|
||||
|
||||
|
||||
@router.get('/{operation_id}')
|
||||
def get_operation(
|
||||
operation_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
op = _get_operation_checked(db, current_user, operation_id)
|
||||
return _operation_dto(op)
|
||||
|
||||
|
||||
@router.post('', status_code=201)
|
||||
def create_operation(
|
||||
payload: OperationCreate,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permission('create')),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
import uuid as uuid_mod
|
||||
from backend.models import Case
|
||||
case_uuid = uuid_mod.UUID(payload.case_id)
|
||||
case = db.get(Case, case_uuid)
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail='Карточка не найдена')
|
||||
existing = db.query(SearchOperation).filter(SearchOperation.case_id == case_uuid).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail='Операция для этой карточки уже существует')
|
||||
|
||||
unit_id = payload.unit_id
|
||||
if unit_id:
|
||||
from backend.audit import can_access_unit
|
||||
if not can_access_unit(db, current_user, unit_id):
|
||||
raise HTTPException(status_code=403, detail='Подразделение вне вашего скоупа')
|
||||
else:
|
||||
# По умолчанию — подразделение создателя
|
||||
unit_id = getattr(current_user, 'unit_id', None)
|
||||
unit_id = str(unit_id) if unit_id else None
|
||||
|
||||
op = SearchOperation(
|
||||
title=payload.title,
|
||||
case_id=case_uuid,
|
||||
status='active',
|
||||
unit_id=uuid_mod.UUID(unit_id) if unit_id else None,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(op)
|
||||
db.commit()
|
||||
db.refresh(op)
|
||||
|
||||
from backend.audit import audit_log
|
||||
audit_log(db, current_user, 'operation_create', object_type='operation',
|
||||
object_id=str(op.id), request=request,
|
||||
details={'title': op.title, 'case_id': payload.case_id})
|
||||
return _operation_dto(op)
|
||||
|
||||
|
||||
@router.patch('/{operation_id}')
|
||||
def update_operation(
|
||||
operation_id: str,
|
||||
payload: OperationUpdate,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permission('update')),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
import uuid as uuid_mod
|
||||
op = _get_operation_checked(db, current_user, operation_id)
|
||||
changes: dict[str, tuple] = {}
|
||||
|
||||
if payload.title is not None and payload.title != op.title:
|
||||
changes['title'] = (op.title, payload.title)
|
||||
op.title = payload.title
|
||||
|
||||
if payload.unit_id is not None:
|
||||
from backend.audit import can_access_unit, _uuid_hex
|
||||
if not can_access_unit(db, current_user, payload.unit_id):
|
||||
raise HTTPException(status_code=403, detail='Подразделение вне вашего скоупа')
|
||||
new_unit = uuid_mod.UUID(payload.unit_id)
|
||||
if new_unit != op.unit_id:
|
||||
changes['unit'] = (str(op.unit_id) if op.unit_id else None, payload.unit_id)
|
||||
op.unit_id = new_unit
|
||||
|
||||
if payload.contour_operation_id is not None:
|
||||
new_contour = uuid_mod.UUID(payload.contour_operation_id)
|
||||
if new_contour != op.contour_operation_id:
|
||||
changes['contour_operation_id'] = (str(op.contour_operation_id) if op.contour_operation_id else None,
|
||||
payload.contour_operation_id)
|
||||
op.contour_operation_id = new_contour
|
||||
|
||||
if payload.status is not None and payload.status != op.status:
|
||||
allowed = ALLOWED_TRANSITIONS.get(op.status, set())
|
||||
if payload.status not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Недопустимый переход {op.status} → {payload.status}. '
|
||||
f'Разрешено: {", ".join(sorted(allowed)) or "нет"}',
|
||||
)
|
||||
changes['status'] = (op.status, payload.status)
|
||||
op.status = payload.status
|
||||
if payload.status in ('completed', 'archived'):
|
||||
op.closed_at = datetime.now(timezone.utc)
|
||||
|
||||
if not changes:
|
||||
return _operation_dto(op)
|
||||
|
||||
op.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
from backend.audit import audit_log
|
||||
audit_log(db, current_user, 'operation_update', object_type='operation',
|
||||
object_id=str(op.id), changes=changes, request=request)
|
||||
return _operation_dto(op)
|
||||
+10
-13
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from backend.database import db
|
||||
from backend.schemas import DashboardResponse, RecommendationRequest
|
||||
from services.recommendation_service import score_recommendation
|
||||
|
||||
router = APIRouter(prefix='/api/v1/stats', tags=['stats'])
|
||||
|
||||
@@ -18,18 +19,14 @@ def stats_heatmap() -> dict:
|
||||
|
||||
@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
|
||||
result = score_recommendation(
|
||||
age=payload.age,
|
||||
elapsed_hours=payload.elapsed_hours,
|
||||
terrain=payload.terrain_primary,
|
||||
weather=payload.weather,
|
||||
health_flags=payload.health_flags,
|
||||
)
|
||||
return {
|
||||
'recommendation': 'Высокий приоритет на прочёс и дрон' if score >= 40 else 'Стандартный приоритет поиска',
|
||||
'score': score,
|
||||
'recommendation': result['recommendation'],
|
||||
'score': result['score'],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Геослой по рекомендациям анализа: водоёмы + ж/д в зоне поиска.
|
||||
|
||||
Рекомендации профилей (scoring_service.BEHAVIORAL_PROFILES) указывают
|
||||
критичные типы локаций, но коэффициенты railway/shelter долгое время были
|
||||
мёртвыми (railway_distance_km никто не вычислял — вклад в score_zone был
|
||||
всегда 0). Этот эндпоинт отдаёт реальные объекты OSM вокруг ТНП случая,
|
||||
чтобы подсветить их на карте зон поиска (SearchMap):
|
||||
|
||||
- водоёмы/болота: РАС water ×3.0, эпилепсия ×3.5, возраст 0–7 ×2.2–2.5,
|
||||
критическое предупреждение «проверить ВСЕ водоёмы и ж/д»;
|
||||
- ж/д пути: РАС railway ×2.5 — «немедленно проверить ВСЕ ж/д пути».
|
||||
|
||||
Данные: OpenStreetMap, лицензия ODbL — attribution обязателен на карте.
|
||||
Кэш дисковый; circuit breaker переиспользуем от geo_service, чтобы анализ
|
||||
и геослои отключались синхронно при отсутствии сети.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.database import db
|
||||
from backend.routers.auth import require_roles
|
||||
from services.geo_service import _overpass_circuit_open, query_overpass
|
||||
|
||||
logger = logging.getLogger('vector.water')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/water', tags=['water'],
|
||||
dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
||||
|
||||
# Кэш — свой TTL (гидрография/ж/д OSM стабильны в масштабе дней).
|
||||
_CACHE_DIR = '/tmp/overpass_cache/water'
|
||||
_CACHE_TTL_HOURS = 72
|
||||
|
||||
_WATER_QUERY_TEMPLATE = """
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
way["natural"="water"](around:{radius_m},{lat},{lon});
|
||||
relation["natural"="water"](around:{radius_m},{lat},{lon});
|
||||
way["natural"="wetland"](around:{radius_m},{lat},{lon});
|
||||
relation["natural"="wetland"](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out geom meta;
|
||||
"""
|
||||
|
||||
# Ж/д: railway=rail общего пользования (без промышленных/сервисных веток).
|
||||
_RAILWAY_QUERY_TEMPLATE = """
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
way["railway"="rail"](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out geom;
|
||||
"""
|
||||
|
||||
_CLASS_WARNING = 'Водоём в зоне поиска — приоритетная проверка (профиль: {profiles})'
|
||||
_RAIL_WARNING = 'Ж/д пути в зоне поиска — проверить немедленно (профиль: {profiles})'
|
||||
|
||||
|
||||
class GeoFeature(BaseModel):
|
||||
"""Объект геослоя в GeoJSON-геометрии + атрибуты для попапа."""
|
||||
layer: str # water | wetland | railway
|
||||
name: Optional[str] = None
|
||||
area_m2: Optional[float] = None # для полигонов воды
|
||||
length_m: Optional[float] = None # для линий ж/д
|
||||
distance_m: Optional[float] = None
|
||||
geom: dict[str, Any] # GeoJSON (WGS84)
|
||||
|
||||
|
||||
class WaterResponse(BaseModel):
|
||||
case_id: Optional[str] = None
|
||||
tnp: dict[str, Optional[float]]
|
||||
radius_m: int
|
||||
count: int
|
||||
profiles: list[str] = []
|
||||
warning: Optional[str] = None
|
||||
railway_warning: Optional[str] = None
|
||||
features: list[GeoFeature] = []
|
||||
cached: bool = False
|
||||
attribution: str = '© OpenStreetMap contributors'
|
||||
|
||||
|
||||
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Расстояние между точками, метры (как в geo_service)."""
|
||||
r = 6371000.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp = p2 - p1
|
||||
dl = math.radians(lon2 - lon1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def _way_length_mid(geometry: list[dict]) -> tuple[float, float, float]:
|
||||
"""(длина линии м, средняя широта, средняя долгота) по геометрии way."""
|
||||
length = sum(
|
||||
_haversine(a['lat'], a['lon'], b['lat'], b['lon'])
|
||||
for a, b in zip(geometry, geometry[1:])
|
||||
)
|
||||
lat = sum(p['lat'] for p in geometry) / len(geometry)
|
||||
lon = sum(p['lon'] for p in geometry) / len(geometry)
|
||||
return length, lat, lon
|
||||
|
||||
|
||||
def _polygon_centroid_area(ring: list[dict]) -> tuple[float, float, float]:
|
||||
"""(площадь м², средняя широта, средняя долгота) по внешнему кольцу."""
|
||||
n = len(ring)
|
||||
if n < 3:
|
||||
return 0.0, 0.0, 0.0
|
||||
lat = sum(p['lat'] for p in ring) / n
|
||||
lon = sum(p['lon'] for p in ring) / n
|
||||
r = 6371000.0
|
||||
area = 0.0
|
||||
for i in range(n - 1):
|
||||
p1, p2 = ring[i], ring[i + 1]
|
||||
area += math.radians(p2['lon'] - p1['lon']) * (
|
||||
2 + math.sin(math.radians(p1['lat'])) + math.sin(math.radians(p2['lat']))
|
||||
)
|
||||
area = abs(area) * r * r / 2
|
||||
return area, lat, lon
|
||||
|
||||
|
||||
def _way_to_polygon(element: dict) -> Optional[dict[str, Any]]:
|
||||
"""Замкнутый way → GeoJSON Polygon; незамкнутый — None."""
|
||||
geometry = element.get('geometry') or []
|
||||
if len(geometry) < 4:
|
||||
return None
|
||||
first, last = geometry[0], geometry[-1]
|
||||
if abs(first['lat'] - last['lat']) > 1e-9 or abs(first['lon'] - last['lon']) > 1e-9:
|
||||
return None
|
||||
coords = [[p['lon'], p['lat']] for p in geometry]
|
||||
return {'type': 'Polygon', 'coordinates': [coords]}
|
||||
|
||||
|
||||
def _water_geoms(elements: list[dict]) -> list[dict]:
|
||||
"""Ответ Overpass → список feature-словарей воды/болот (layer, name, geom, area)."""
|
||||
out: list[dict] = []
|
||||
for el in elements:
|
||||
tags = el.get('tags') or {}
|
||||
layer = 'wetland' if tags.get('natural') == 'wetland' else 'water'
|
||||
name = tags.get('name')
|
||||
if el.get('type') == 'way':
|
||||
poly = _way_to_polygon(el)
|
||||
if poly is None:
|
||||
continue
|
||||
area, lat, lon = _polygon_centroid_area(el['geometry'])
|
||||
out.append({'layer': layer, 'name': name, 'geom': poly,
|
||||
'area_m2': area, 'lat': lat, 'lon': lon})
|
||||
elif el.get('type') == 'relation' and el.get('members'):
|
||||
# Мультиполигон из внешних колец way-членов.
|
||||
polygons: list[list[list[float]]] = []
|
||||
areas: list[float] = []
|
||||
lats: list[float] = []
|
||||
lons: list[float] = []
|
||||
for member in el.get('members', []):
|
||||
if member.get('role') not in ('outer', ''):
|
||||
continue
|
||||
mg = member.get('geometry') or []
|
||||
if len(mg) < 4:
|
||||
continue
|
||||
poly = _way_to_polygon({'geometry': mg})
|
||||
if poly is None:
|
||||
continue
|
||||
# Кладём Polygon целиком (coordinates = [ring]), не сам ring —
|
||||
# иначе у одиночного кольца coordinates получатся depth-2.
|
||||
polygons.append(poly['coordinates'])
|
||||
area, lat, lon = _polygon_centroid_area(mg)
|
||||
areas.append(area)
|
||||
lats.append(lat)
|
||||
lons.append(lon)
|
||||
if not polygons:
|
||||
continue
|
||||
if len(polygons) == 1:
|
||||
geom = {'type': 'Polygon', 'coordinates': polygons[0]}
|
||||
else:
|
||||
# GeoJSON MultiPolygon: coordinates = список polygon.coordinates.
|
||||
geom = {
|
||||
'type': 'MultiPolygon',
|
||||
'coordinates': polygons,
|
||||
}
|
||||
out.append({'layer': layer, 'name': name, 'geom': geom,
|
||||
'area_m2': sum(areas),
|
||||
'lat': sum(lats) / len(lats), 'lon': sum(lons) / len(lons)})
|
||||
return out
|
||||
|
||||
|
||||
def _railway_geoms(elements: list[dict]) -> list[dict]:
|
||||
"""Ответ Overpass → список feature-словарей ж/д (LineString, длина)."""
|
||||
out: list[dict] = []
|
||||
for el in elements:
|
||||
if el.get('type') != 'way':
|
||||
continue
|
||||
tags = el.get('tags') or {}
|
||||
# Только магистральные ж/д общего пользования: без сервисных/промышленных веток.
|
||||
if tags.get('service') or tags.get('usage') in ('industrial', 'military', 'test'):
|
||||
continue
|
||||
geometry = el.get('geometry') or []
|
||||
if len(geometry) < 2:
|
||||
continue
|
||||
length, lat, lon = _way_length_mid(geometry)
|
||||
# Короткие обрезки путей (<300 м, нарезка по стрелкам внутри станции)
|
||||
# шумны и не дают штабу полезной информации — пропускаем.
|
||||
if length < 300:
|
||||
continue
|
||||
line = {'type': 'LineString', 'coordinates': [[p['lon'], p['lat']] for p in geometry]}
|
||||
out.append({'layer': 'railway', 'name': tags.get('name'), 'geom': line,
|
||||
'length_m': length, 'lat': lat, 'lon': lon})
|
||||
return out
|
||||
|
||||
|
||||
def _cache_get(key: str) -> Optional[list[dict]]:
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
path = os.path.join(_CACHE_DIR, f'{key}.json')
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
cached = json.load(f)
|
||||
ts = datetime.fromisoformat(cached['timestamp'])
|
||||
if datetime.now() - ts > timedelta(hours=_CACHE_TTL_HOURS):
|
||||
os.unlink(path)
|
||||
return None
|
||||
return cached['data']
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _cache_put(key: str, data: list[dict]) -> None:
|
||||
import os
|
||||
from datetime import datetime
|
||||
try:
|
||||
os.makedirs(_CACHE_DIR, exist_ok=True)
|
||||
with open(os.path.join(_CACHE_DIR, f'{key}.json'), 'w', encoding='utf-8') as f:
|
||||
json.dump({'timestamp': datetime.now().isoformat(), 'data': data}, f,
|
||||
ensure_ascii=False)
|
||||
except Exception:
|
||||
logger.warning('geo layer cache write failed', exc_info=True)
|
||||
|
||||
|
||||
def _detect_profiles(case_detail: dict[str, Any]) -> list[str]:
|
||||
"""Профили из диагнозов + флагов (логика как analyze._derive_profiles + cant_swim)."""
|
||||
profiles: list[str] = []
|
||||
for diag in (case_detail.get('diagnosis_type') or []):
|
||||
d = str(diag).lower()
|
||||
if 'рас' in d or 'аутизм' in d:
|
||||
profiles.append('РАС')
|
||||
elif 'эпилепси' in d:
|
||||
profiles.append('эпилепсия')
|
||||
if case_detail.get('cant_swim'):
|
||||
profiles.append('не_умеет_плавать')
|
||||
return profiles
|
||||
|
||||
|
||||
def _sort_features(features: list[dict]) -> list[dict]:
|
||||
"""Крупные водные объекты вперёд (по площади), затем ж/д (по длине)."""
|
||||
|
||||
def key(f: dict) -> tuple[int, float]:
|
||||
if f['layer'] == 'railway':
|
||||
return (1, -(f.get('length_m') or 0))
|
||||
return (0, -(f.get('area_m2') or 0))
|
||||
|
||||
return sorted(features, key=key)
|
||||
|
||||
|
||||
@router.get('/{case_id}', response_model=WaterResponse)
|
||||
async def water_for_case(case_id: str) -> WaterResponse:
|
||||
"""Геослой рекомендаций вокруг ТНП случая (водоёмы + ж/д, для карты зон)."""
|
||||
case = db.get_case(str(case_id))
|
||||
if case is None:
|
||||
raise HTTPException(status_code=404, detail='Case not found')
|
||||
detail = case.to_detail()
|
||||
lat, lon = detail.get('tnp_lat'), detail.get('tnp_lon')
|
||||
if lat is None or lon is None:
|
||||
raise HTTPException(status_code=409, detail='Case has no TNP coordinates (tnp_lat/tnp_lon)')
|
||||
|
||||
profiles = _detect_profiles(detail)
|
||||
warning = _CLASS_WARNING.format(profiles=', '.join(profiles)) if profiles else None
|
||||
railway_warning = _RAIL_WARNING.format(profiles=', '.join(profiles)) if profiles else None
|
||||
|
||||
radius_m = 3000 # перекрывает дефолтную зону анализа (max_distance_km ≤ ~3)
|
||||
cache_key = hashlib.md5(f'{lat:.5f}:{lon:.5f}:{radius_m}:v2'.encode()).hexdigest()
|
||||
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
features_payload = cached
|
||||
is_cached = True
|
||||
else:
|
||||
features_payload = []
|
||||
if not _overpass_circuit_open():
|
||||
for template, extractor in (
|
||||
(_WATER_QUERY_TEMPLATE, _water_geoms),
|
||||
(_RAILWAY_QUERY_TEMPLATE, _railway_geoms),
|
||||
):
|
||||
query = template.format(lat=lat, lon=lon, radius_m=radius_m)
|
||||
data = await query_overpass(query)
|
||||
elements = data if isinstance(data, list) else data.get('elements', [])
|
||||
for item in extractor(elements):
|
||||
distance = round(_haversine(lat, lon, item['lat'], item['lon']))
|
||||
feature = {
|
||||
'layer': item['layer'],
|
||||
'name': item.get('name'),
|
||||
'distance_m': distance,
|
||||
'geom': item['geom'],
|
||||
}
|
||||
if item['layer'] == 'railway':
|
||||
feature['length_m'] = round(item['length_m'])
|
||||
else:
|
||||
feature['area_m2'] = round(item['area_m2'])
|
||||
features_payload.append(feature)
|
||||
if not elements:
|
||||
break # сеть недоступна — не тянем второй запрос впустую
|
||||
if features_payload: # пустой ответ не кэшируем (breaker/сеть)
|
||||
_cache_put(cache_key, features_payload)
|
||||
is_cached = False
|
||||
|
||||
features_sorted = _sort_features(features_payload)
|
||||
return WaterResponse(case_id=str(case_id), tnp={'lat': lat, 'lon': lon},
|
||||
radius_m=radius_m, count=len(features_sorted),
|
||||
profiles=profiles, warning=warning,
|
||||
railway_warning=railway_warning,
|
||||
features=[GeoFeature(**f) for f in features_sorted],
|
||||
cached=is_cached)
|
||||
+63
-5
@@ -111,6 +111,8 @@ class DashboardResponse(BaseModel):
|
||||
unknown_outcome_count: int
|
||||
median_found_distance_km: float | None
|
||||
recent_activity_count: int
|
||||
active_operations: int = 0
|
||||
completed_operations: int = 0
|
||||
|
||||
|
||||
class RecommendationRequest(BaseModel):
|
||||
@@ -133,8 +135,64 @@ class AnalyzeResponse(BaseModel):
|
||||
case_id: str
|
||||
|
||||
|
||||
class ParseDocResponse(BaseModel):
|
||||
filename: str
|
||||
parsed: bool
|
||||
preview: dict[str, Any]
|
||||
raw_text: str | None = None
|
||||
# ================= ЗАВЕРШЁННЫЕ ПОИСКИ (ручной ввод, без ИИ) =================
|
||||
# Оператор руками вносит данные ЗАВЕРШЁННОГО поиска: параметры субъекта/среды
|
||||
# (вход матмодели) + реальный исход. Цель — датасет для калибровки модели
|
||||
# (сравнение прогноза ВЕКТОРА с фактом) и архив 14 лет без LLM.
|
||||
|
||||
class ClosedCaseCreate(BaseModel):
|
||||
# --- Субъект (вход модели) ---
|
||||
age_years: int
|
||||
gender: str = 'м' # 'м' | 'ж'
|
||||
diagnosis_type: list[str] = Field(default_factory=list) # РАС, эпилепсия, СДВГ, ЗПР...
|
||||
has_transport: str = 'none' # none|bike|scooter|other
|
||||
cant_swim: bool = False
|
||||
fitness_level: str | None = None # низкий|средний|высокий
|
||||
|
||||
# --- Обстоятельства (вход матмодели) ---
|
||||
elapsed_hours: float
|
||||
loss_time: datetime | None = None # время потери (для time_of_day)
|
||||
terrain: list[str] = Field(default_factory=list) # лес, болото, поле...
|
||||
weather: str | None = None
|
||||
season: str | None = None
|
||||
temperature_c: float | None = None
|
||||
tnp_lat: float | None = None
|
||||
tnp_lon: float | None = None
|
||||
tnp_address: str | None = None
|
||||
|
||||
# --- Психотип (опционально, вход скоринга) ---
|
||||
psychotype: str | None = None
|
||||
psychotype_answers: dict[str, Any] | None = None
|
||||
|
||||
# --- Ресурсы ---
|
||||
teams_count: int | None = None
|
||||
team_size: int | None = None
|
||||
has_dog: bool = False
|
||||
|
||||
# --- Исход (факт, для сравнения с прогнозом) ---
|
||||
found_alive: bool
|
||||
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
|
||||
search_duration_hours: float | None = None
|
||||
who_found: str | None = None
|
||||
|
||||
# --- Мета ---
|
||||
notes: str | None = None
|
||||
source: str | None = None # откуда: спецдонесение 2015, бумажный журнал...
|
||||
|
||||
|
||||
class ClosedCaseResponse(ClosedCaseCreate):
|
||||
id: str
|
||||
created_at: datetime
|
||||
# Сохранённый прогноз модели на момент внесения (для калибровки)
|
||||
model_prediction: dict[str, Any] | None = None
|
||||
# Насколько прогноз совпал с фактом
|
||||
accuracy_note: str | None = None
|
||||
|
||||
|
||||
class ClosedCaseListResponse(BaseModel):
|
||||
items: list[ClosedCaseResponse]
|
||||
total: int
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""E2-сид: справочник подразделений МЧС — полный список из РВС.
|
||||
|
||||
РЦУ РЧС → 6 ОУМЧС → Г(Р)ОЧС (РОЧС/ГОЧС/ГРОЧС, 135 шт. из podr.json РВС).
|
||||
Идемпотентный: существующие названия не дублируются.
|
||||
Запуск: docker exec vector-backend sh -c 'cd /app && python backend/seed_units.py'
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from backend.database import SessionLocal # noqa: E402
|
||||
from backend.models import MchsUnit # noqa: E402
|
||||
|
||||
UNITS = {
|
||||
'Брестское ОУМЧС': [
|
||||
'Барановичский ГРОЧС',
|
||||
'Березовский РОЧС',
|
||||
'Брестский ГОЧС',
|
||||
'Брестский ПАСО',
|
||||
'Брестский РОЧС',
|
||||
'Ганцевичский РОЧС',
|
||||
'Дрогичинский РОЧС',
|
||||
'Жабинковский РОЧС',
|
||||
'Ивановский РОЧС',
|
||||
'Ивацевичский РОЧС',
|
||||
'Каменецкий РОЧС',
|
||||
'Кобринский РОЧС',
|
||||
'Лунинецкий РОЧС',
|
||||
'Ляховичский РОЧС',
|
||||
'Малоритский РОЧС',
|
||||
'Пинский ГРОЧС',
|
||||
'Пружанский РОЧС',
|
||||
'Столинский РОЧС',
|
||||
],
|
||||
'Витебское ОУМЧС': [
|
||||
'Бешенковичский РОЧС',
|
||||
'Браславский РОЧС',
|
||||
'Верхнедвинский РОЧС',
|
||||
'Витебский ГОЧС',
|
||||
'Витебский ПАСО',
|
||||
'Витебский РОЧС',
|
||||
'Глубокский РОЧС',
|
||||
'Городокский РОЧС',
|
||||
'Докшицкий РОЧС',
|
||||
'Дубровенский РОЧС',
|
||||
'Лепельский РОЧС',
|
||||
'Лиозненский РОЧС',
|
||||
'Миорский РОЧС',
|
||||
'Новополоцкий ГОЧС',
|
||||
'Новополоцкий ПАСО',
|
||||
'Оршанский ГРОЧС',
|
||||
'Полоцкий ГРОЧС',
|
||||
'Поставский РОЧС',
|
||||
'Россонский РОЧС',
|
||||
'Сенненский РОЧС',
|
||||
'Толочинский РОЧС',
|
||||
'Ушачский РОЧС',
|
||||
'Чашникский РОЧС',
|
||||
'Шарковщинский РОЧС',
|
||||
'Шумилинский РОЧС',
|
||||
],
|
||||
'Гомельское ОУМЧС': [
|
||||
'Брагинский РОЧС',
|
||||
'Буда-Кошелевский РОЧС',
|
||||
'Ветковский РОЧС',
|
||||
'Гомельский ГОЧС',
|
||||
'Гомельский ПАСО',
|
||||
'Гомельский РОЧС',
|
||||
'Добрушский РОЧС',
|
||||
'Ельский РОЧС',
|
||||
'Житковичский РОЧС',
|
||||
'Жлобинский РОЧС',
|
||||
'Калинковичский РОЧС',
|
||||
'Кормянский РОЧС',
|
||||
'Лельчицкий РОЧС',
|
||||
'Лоевский РОЧС',
|
||||
'Мозырский ГРОЧС',
|
||||
'Мозырский ПАСО',
|
||||
'Наровлянский РОЧС',
|
||||
'Октябрьский РОЧС',
|
||||
'Петриковский РОЧС',
|
||||
'Речицкий ПАСО',
|
||||
'Речицкий РОЧС',
|
||||
'Рогачевский РОЧС',
|
||||
'Светлогорский РОЧС',
|
||||
'Хойникский РОЧС',
|
||||
'Чечерский РОЧС',
|
||||
],
|
||||
'Гродненское ОУМЧС': [
|
||||
'Берестовицкий РОЧС',
|
||||
'Волковысский РОЧС',
|
||||
'Вороновский РОЧС',
|
||||
'Гродненский ГОЧС',
|
||||
'Гродненский ПАСО',
|
||||
'Гродненский РОЧС',
|
||||
'Дятловский РОЧС',
|
||||
'Зельвенский РОЧС',
|
||||
'Ивьевский РОЧС',
|
||||
'Кореличский РОЧС',
|
||||
'Лидский РОЧС',
|
||||
'Мостовский РОЧС',
|
||||
'Новогрудский РОЧС',
|
||||
'Островецкий РОЧС',
|
||||
'Ошмянский РОЧС',
|
||||
'Свислочский РОЧС',
|
||||
'Слонимский РОЧС',
|
||||
'Сморгонский РОЧС',
|
||||
'Щучинский РОЧС',
|
||||
],
|
||||
'Минское ОУМЧС': [
|
||||
'Березинский РОЧС',
|
||||
'Борисовский ГРОЧС',
|
||||
'Вилейский РОЧС',
|
||||
'Воложинский РОЧС',
|
||||
'Дзержинский РОЧС',
|
||||
'Жодинский ГОЧС',
|
||||
'Клецкий РОЧС',
|
||||
'Копыльский РОЧС',
|
||||
'Крупский РОЧС',
|
||||
'Логойский РОЧС',
|
||||
'Любанский РОЧС',
|
||||
'Минский ПАСО',
|
||||
'Минский РОЧС',
|
||||
'Молодечненский ГРОЧС',
|
||||
'Мядельский РОЧС',
|
||||
'Несвижский РОЧС',
|
||||
'Пуховичский РОЧС',
|
||||
'Слуцкий РОЧС',
|
||||
'Смолевичский РОЧС',
|
||||
'Солигорский ГРОЧС',
|
||||
'Стародорожский РОЧС',
|
||||
'Столбцовский РОЧС',
|
||||
'Узденский РОЧС',
|
||||
'Червенский РОЧС',
|
||||
],
|
||||
'Могилевское ОУМЧС': [
|
||||
'Белыничский РОЧС',
|
||||
'Бобруйский ГРОЧС',
|
||||
'Бобруйский ПАСО',
|
||||
'Быховский РОЧС',
|
||||
'Глусский РОЧС',
|
||||
'Горецкий РОЧС',
|
||||
'Дрибинский РОЧС',
|
||||
'Кировский РОЧС',
|
||||
'Климовичский РОЧС',
|
||||
'Кличевский РОЧС',
|
||||
'Костюковичский РОЧС',
|
||||
'Краснопольский РОЧС',
|
||||
'Кричевский РОЧС',
|
||||
'Круглянский РОЧС',
|
||||
'Могилевский ГОЧС',
|
||||
'Могилевский ПАСО',
|
||||
'Могилевский РОЧС',
|
||||
'Мстиславский РОЧС',
|
||||
'Осиповичский РОЧС',
|
||||
'Славгородский РОЧС',
|
||||
'Хотимский РОЧС',
|
||||
'Чаусский РОЧС',
|
||||
'Чериковский РОЧС',
|
||||
'Шкловский РОЧС',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_or_create(db, name, kind, parent_id=None):
|
||||
existing = db.query(MchsUnit).filter_by(name=name).first()
|
||||
if existing:
|
||||
return existing
|
||||
u = MchsUnit(name=name, kind=kind, parent_id=parent_id)
|
||||
db.add(u)
|
||||
db.flush()
|
||||
return u
|
||||
|
||||
|
||||
def main():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rcu = get_or_create(db, 'РЦУ РЧС', 'rcu')
|
||||
created = 0
|
||||
for oblast_name, grochs_list in UNITS.items():
|
||||
oblast = get_or_create(db, oblast_name, 'oblast', rcu.id)
|
||||
for g in grochs_list:
|
||||
before = db.query(MchsUnit).filter_by(name=g).count()
|
||||
get_or_create(db, g, 'gor_rayon', oblast.id)
|
||||
if not before:
|
||||
created += 1
|
||||
db.commit()
|
||||
total = db.query(MchsUnit).count()
|
||||
print(f'сид подразделений: создано {created}, всего {total}')
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,312 +0,0 @@
|
||||
"""
|
||||
Claude AI service for case analysis with fallback to scoring service.
|
||||
Integrates geo_service zones and scoring_service rankings.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
|
||||
from .geo_service import build_search_zones
|
||||
from .scoring_service import WeightedScorer
|
||||
|
||||
|
||||
class PrimaryZone(BaseModel):
|
||||
priority: int
|
||||
name: str
|
||||
direction: str
|
||||
distance: float
|
||||
reason: str
|
||||
|
||||
|
||||
class AnalysisResult(BaseModel):
|
||||
urgency: str # "критическая", "высокая", "средняя", "низкая"
|
||||
primary_zones: List[PrimaryZone]
|
||||
search_radius_km: float
|
||||
key_locations: List[str]
|
||||
behavioral_prediction: str
|
||||
immediate_actions: List[str]
|
||||
summary: str
|
||||
fallback_used: bool = False
|
||||
|
||||
|
||||
async def analyze_case(case_data: dict) -> AnalysisResult:
|
||||
"""
|
||||
Анализирует данные случая с помощью Claude API с fallback на scoring_service.
|
||||
|
||||
Интегрирует:
|
||||
- geo_service: построение зон поиска
|
||||
- scoring_service: оценка и ранжирование зон
|
||||
- Claude API: интеллектуальный анализ (если доступен)
|
||||
|
||||
Args:
|
||||
case_data: Словарь с данными случая
|
||||
- age: возраст
|
||||
- gender: пол
|
||||
- terrain: местность
|
||||
- weather: погода
|
||||
- time_missing: время пропажи
|
||||
- last_location: последнее местоположение
|
||||
- lat, lon: координаты (опционально)
|
||||
- profiles: поведенческие профили (опционально)
|
||||
- season: сезон (опционально)
|
||||
|
||||
Returns:
|
||||
AnalysisResult: Структурированный результат анализа
|
||||
"""
|
||||
# Попытка использовать Claude API
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
|
||||
if api_key:
|
||||
try:
|
||||
return await analyze_with_claude(case_data, api_key)
|
||||
except Exception as e:
|
||||
# Логируем ошибку и переходим на fallback
|
||||
print(f"Claude API unavailable: {e}. Using fallback scoring service.")
|
||||
|
||||
# Fallback: используем только scoring_service
|
||||
return await analyze_with_fallback(case_data)
|
||||
|
||||
|
||||
async def analyze_with_claude(case_data: dict, api_key: str) -> AnalysisResult:
|
||||
"""
|
||||
Анализ с помощью Claude API с интеграцией geo и scoring сервисов.
|
||||
"""
|
||||
# Построить зоны поиска если есть координаты
|
||||
zones_data = ""
|
||||
if case_data.get('lat') and case_data.get('lon'):
|
||||
try:
|
||||
zones = await build_search_zones(
|
||||
case_data['lat'],
|
||||
case_data['lon'],
|
||||
case_data
|
||||
)
|
||||
|
||||
# Ранжировать зоны
|
||||
scorer = WeightedScorer()
|
||||
zones_dict = [
|
||||
{
|
||||
'direction': z.direction,
|
||||
'distance_km': z.distance_km,
|
||||
'forest_pct': z.forest_pct,
|
||||
'road_density': z.road_density,
|
||||
'water_distance_km': z.water_distance_km,
|
||||
'settlement_distance_km': z.settlement_distance_km
|
||||
}
|
||||
for z in zones
|
||||
]
|
||||
|
||||
ranked_zones = scorer.rank_zones(zones_dict, case_data)
|
||||
|
||||
# Топ-5 зон для промпта
|
||||
top_zones = ranked_zones[:5]
|
||||
zones_data = "\n\nТОП-5 ПРИОРИТЕТНЫХ ЗОН (по scoring_service):\n"
|
||||
for zone in top_zones:
|
||||
zones_data += f"- {zone['direction']} направление, {zone['distance_km']}км: "
|
||||
zones_data += f"оценка {zone['score']}/100, "
|
||||
zones_data += f"лес {zone['forest_pct']}%, "
|
||||
zones_data += f"дороги {zone['road_density']} км/км²\n"
|
||||
except Exception as e:
|
||||
print(f"Geo/scoring service error: {e}")
|
||||
|
||||
# Формируем промпт на русском языке
|
||||
prompt = f"""Ты — эксперт по поисково-спасательным операциям (ПСО) МЧС Республики Беларусь. Проанализируй следующий случай пропажи человека и дай структурированные рекомендации.
|
||||
|
||||
ДАННЫЕ СЛУЧАЯ:
|
||||
- Возраст пропавшего: {case_data.get('age', 'не указан')} лет
|
||||
- Пол: {case_data.get('gender', 'не указан')}
|
||||
- Местность: {case_data.get('terrain', 'не указана')}
|
||||
- Погодные условия: {case_data.get('weather', 'не указаны')}
|
||||
- Время пропажи: {case_data.get('time_missing', 'не указано')}
|
||||
- Последнее известное местоположение: {case_data.get('last_location', 'не указано')}
|
||||
- Особые обстоятельства: {case_data.get('circumstances', 'нет')}
|
||||
- Физическое состояние: {case_data.get('physical_condition', 'не указано')}
|
||||
- Опыт нахождения на природе: {case_data.get('experience', 'не указан')}
|
||||
- Поведенческие профили: {', '.join(case_data.get('profiles', [])) if case_data.get('profiles') else 'нет'}
|
||||
- Сезон: {case_data.get('season', 'не указан')}{zones_data}
|
||||
|
||||
ЗАДАЧА:
|
||||
Предоставь детальный анализ в формате JSON со следующими полями:
|
||||
|
||||
1. urgency: Уровень срочности ("критическая", "высокая", "средняя", "низкая")
|
||||
2. primary_zones: Массив из 3-5 приоритетных зон поиска, каждая с полями:
|
||||
- priority: номер приоритета (1 = самый высокий)
|
||||
- name: название зоны (например "Ближний лес", "Водоём на севере")
|
||||
- direction: направление от последней точки (N, NE, E, SE, S, SW, W, NW)
|
||||
- distance: расстояние в км
|
||||
- reason: обоснование выбора этой зоны
|
||||
3. search_radius_km: Рекомендуемый радиус поиска в километрах
|
||||
4. key_locations: Массив ключевых типов локаций для проверки (водоемы, дороги, постройки и т.д.)
|
||||
5. behavioral_prediction: Прогноз поведения пропавшего на основе возраста и обстоятельств
|
||||
6. immediate_actions: Массив немедленных действий, которые нужно предпринять
|
||||
7. summary: Краткое резюме анализа (2-3 предложения)
|
||||
|
||||
ВАЖНО: Учитывай данные из scoring_service при формировании primary_zones. Отвечай ТОЛЬКО валидным JSON без дополнительного текста."""
|
||||
|
||||
# Вызываем Anthropic API
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 4096,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Anthropic API error: {response.status_code} - {response.text}")
|
||||
|
||||
result = response.json()
|
||||
content = result["content"][0]["text"]
|
||||
|
||||
# Парсим JSON из ответа
|
||||
# Убираем возможные markdown блоки кода
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
analysis_data = json.loads(content)
|
||||
analysis_data['fallback_used'] = False
|
||||
|
||||
# Преобразуем в Pydantic модель
|
||||
return AnalysisResult(**analysis_data)
|
||||
|
||||
|
||||
async def analyze_with_fallback(case_data: dict) -> AnalysisResult:
|
||||
"""
|
||||
Fallback анализ используя только scoring_service без Claude API.
|
||||
"""
|
||||
# Определяем срочность на основе возраста и профилей
|
||||
age = case_data.get('age', 10)
|
||||
profiles = case_data.get('profiles', [])
|
||||
|
||||
if age <= 4 or 'эпилепсия' in profiles or 'РАС' in profiles:
|
||||
urgency = "критическая"
|
||||
elif age <= 7 or 'велосипед' in profiles:
|
||||
urgency = "высокая"
|
||||
elif age <= 12:
|
||||
urgency = "средняя"
|
||||
else:
|
||||
urgency = "средняя"
|
||||
|
||||
# Построить зоны если есть координаты
|
||||
primary_zones = []
|
||||
search_radius_km = 5.0
|
||||
|
||||
if case_data.get('lat') and case_data.get('lon'):
|
||||
try:
|
||||
zones = await build_search_zones(
|
||||
case_data['lat'],
|
||||
case_data['lon'],
|
||||
case_data
|
||||
)
|
||||
|
||||
# Ранжировать зоны
|
||||
scorer = WeightedScorer()
|
||||
zones_dict = [
|
||||
{
|
||||
'direction': z.direction,
|
||||
'distance_km': z.distance_km,
|
||||
'forest_pct': z.forest_pct,
|
||||
'road_density': z.road_density,
|
||||
'water_distance_km': z.water_distance_km,
|
||||
'settlement_distance_km': z.settlement_distance_km
|
||||
}
|
||||
for z in zones
|
||||
]
|
||||
|
||||
ranked_zones = scorer.rank_zones(zones_dict, case_data)
|
||||
|
||||
# Топ-5 зон
|
||||
for i, zone in enumerate(ranked_zones[:5]):
|
||||
primary_zones.append(PrimaryZone(
|
||||
priority=i + 1,
|
||||
name=f"Зона {zone['direction']} {zone['distance_km']}км",
|
||||
direction=zone['direction'],
|
||||
distance=zone['distance_km'],
|
||||
reason=f"Оценка {zone['score']}/100 по scoring_service"
|
||||
))
|
||||
|
||||
# Радиус на основе дистанции
|
||||
search_radius_km = scorer.distance_multiplier * 2.0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Geo/scoring service error in fallback: {e}")
|
||||
|
||||
# Если зоны не построены, используем базовые
|
||||
if not primary_zones:
|
||||
primary_zones = [
|
||||
PrimaryZone(
|
||||
priority=1,
|
||||
name="Ближняя зона",
|
||||
direction="N",
|
||||
distance=0.5,
|
||||
reason="Базовая зона поиска"
|
||||
),
|
||||
PrimaryZone(
|
||||
priority=2,
|
||||
name="Средняя зона",
|
||||
direction="E",
|
||||
distance=1.0,
|
||||
reason="Расширенная зона поиска"
|
||||
)
|
||||
]
|
||||
|
||||
# Ключевые локации на основе возраста
|
||||
if age <= 7:
|
||||
key_locations = ["водоёмы", "укрытия", "густая растительность", "ближайшие постройки"]
|
||||
elif age <= 12:
|
||||
key_locations = ["водоёмы", "дороги", "тропы", "лесные массивы"]
|
||||
else:
|
||||
key_locations = ["дороги", "населённые пункты", "транспортные узлы", "водоёмы"]
|
||||
|
||||
# Поведенческий прогноз
|
||||
if 'РАС' in profiles:
|
||||
behavioral_prediction = "Высокий риск движения к водоёмам и ж/д путям. Может не откликаться на имя."
|
||||
elif age <= 4:
|
||||
behavioral_prediction = "Минимальное движение, вероятно находится близко к точке потери."
|
||||
elif age <= 12:
|
||||
behavioral_prediction = "Умеренное движение, может следовать по тропам или дорогам."
|
||||
else:
|
||||
behavioral_prediction = "Целенаправленное движение, возможен выход к населённым пунктам."
|
||||
|
||||
# Немедленные действия
|
||||
immediate_actions = [
|
||||
"Организовать поисковые группы",
|
||||
"Проверить ближайшие водоёмы",
|
||||
"Опросить свидетелей в районе последнего местоположения"
|
||||
]
|
||||
|
||||
if 'РАС' in profiles:
|
||||
immediate_actions.insert(0, "КРИТИЧНО: Перекрыть все водоёмы и ж/д пути в радиусе 5 км")
|
||||
|
||||
if 'велосипед' in profiles:
|
||||
immediate_actions.insert(0, "Расширить зону поиска до 10-15 км, проверить дорожные камеры")
|
||||
|
||||
summary = f"Случай классифицирован как {urgency} срочность. "
|
||||
summary += f"Рекомендуемый радиус поиска: {search_radius_km} км. "
|
||||
summary += f"Приоритет: {key_locations[0]}."
|
||||
|
||||
return AnalysisResult(
|
||||
urgency=urgency,
|
||||
primary_zones=primary_zones,
|
||||
search_radius_km=search_radius_km,
|
||||
key_locations=key_locations,
|
||||
behavioral_prediction=behavioral_prediction,
|
||||
immediate_actions=immediate_actions,
|
||||
summary=summary,
|
||||
fallback_used=True
|
||||
)
|
||||
@@ -1,377 +0,0 @@
|
||||
"""
|
||||
Geo service for building search zones and querying OpenStreetMap data via Overpass API.
|
||||
"""
|
||||
import math
|
||||
import json
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Zone(BaseModel):
|
||||
"""Search zone with geographic features."""
|
||||
direction: str # N, NE, E, SE, S, SW, W, NW
|
||||
distance_km: float
|
||||
forest_pct: float
|
||||
road_density: float # km of roads per km²
|
||||
water_distance_km: Optional[float]
|
||||
settlement_distance_km: Optional[float]
|
||||
|
||||
|
||||
# Cache configuration
|
||||
CACHE_DIR = Path("/tmp/overpass_cache")
|
||||
CACHE_TTL_HOURS = 24
|
||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
|
||||
# Direction mappings
|
||||
DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
||||
DIRECTION_ANGLES = {
|
||||
"N": 0,
|
||||
"NE": 45,
|
||||
"E": 90,
|
||||
"SE": 135,
|
||||
"S": 180,
|
||||
"SW": 225,
|
||||
"W": 270,
|
||||
"NW": 315
|
||||
}
|
||||
|
||||
# Search distances in meters
|
||||
SEARCH_DISTANCES = [500, 1000, 2000, 5000]
|
||||
|
||||
|
||||
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""
|
||||
Calculate distance between two points on Earth using Haversine formula.
|
||||
|
||||
Args:
|
||||
lat1, lon1: First point coordinates
|
||||
lat2, lon2: Second point coordinates
|
||||
|
||||
Returns:
|
||||
Distance in kilometers
|
||||
"""
|
||||
R = 6371 # Earth radius in km
|
||||
|
||||
lat1_rad = math.radians(lat1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
|
||||
a = (math.sin(dlat / 2) ** 2 +
|
||||
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
return R * c
|
||||
|
||||
|
||||
def get_sector_bounds(lat: float, lon: float, direction: str, radius_m: int) -> Tuple[float, float, float, float]:
|
||||
"""
|
||||
Calculate bounding box for a sector.
|
||||
|
||||
Args:
|
||||
lat, lon: Center point
|
||||
direction: Sector direction (N, NE, E, etc.)
|
||||
radius_m: Radius in meters
|
||||
|
||||
Returns:
|
||||
(min_lat, min_lon, max_lat, max_lon)
|
||||
"""
|
||||
# Convert radius to degrees (approximate)
|
||||
radius_deg = radius_m / 111320 # 1 degree ≈ 111.32 km at equator
|
||||
|
||||
angle = DIRECTION_ANGLES[direction]
|
||||
angle_rad = math.radians(angle)
|
||||
|
||||
# Calculate sector boundaries (45° sectors)
|
||||
angle_start = angle - 22.5
|
||||
angle_end = angle + 22.5
|
||||
|
||||
# Simple bounding box (can be optimized for actual sector shape)
|
||||
lat_offset = radius_deg * math.cos(angle_rad)
|
||||
lon_offset = radius_deg * math.sin(angle_rad) / math.cos(math.radians(lat))
|
||||
|
||||
min_lat = min(lat, lat + lat_offset) - radius_deg * 0.5
|
||||
max_lat = max(lat, lat + lat_offset) + radius_deg * 0.5
|
||||
min_lon = min(lon, lon + lon_offset) - radius_deg * 0.5
|
||||
max_lon = max(lon, lon + lon_offset) + radius_deg * 0.5
|
||||
|
||||
return (min_lat, min_lon, max_lat, max_lon)
|
||||
|
||||
|
||||
def get_cache_key(query: str) -> str:
|
||||
"""Generate cache key from query."""
|
||||
return hashlib.md5(query.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_cached_result(cache_key: str) -> Optional[Dict]:
|
||||
"""Get cached Overpass API result if not expired."""
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
||||
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file, 'r') as f:
|
||||
cached = json.load(f)
|
||||
|
||||
cached_time = datetime.fromisoformat(cached['timestamp'])
|
||||
if datetime.now() - cached_time > timedelta(hours=CACHE_TTL_HOURS):
|
||||
cache_file.unlink()
|
||||
return None
|
||||
|
||||
return cached['data']
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_to_cache(cache_key: str, data: Dict):
|
||||
"""Save Overpass API result to cache."""
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
||||
|
||||
try:
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump({
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'data': data
|
||||
}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def query_overpass(query: str) -> Dict:
|
||||
"""
|
||||
Query Overpass API with caching.
|
||||
|
||||
Args:
|
||||
query: Overpass QL query
|
||||
|
||||
Returns:
|
||||
API response as dict
|
||||
"""
|
||||
cache_key = get_cache_key(query)
|
||||
|
||||
# Check cache
|
||||
cached = get_cached_result(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Query API
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
OVERPASS_URL,
|
||||
data={'data': query},
|
||||
headers={'Content-Type': 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Save to cache
|
||||
save_to_cache(cache_key, data)
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
# Return empty result on error
|
||||
return {'elements': []}
|
||||
|
||||
|
||||
def calculate_road_length(elements: List[Dict]) -> float:
|
||||
"""
|
||||
Calculate total road length from Overpass way elements.
|
||||
|
||||
Args:
|
||||
elements: List of way elements from Overpass
|
||||
|
||||
Returns:
|
||||
Total length in kilometers
|
||||
"""
|
||||
total_length = 0.0
|
||||
|
||||
for element in elements:
|
||||
if element.get('type') != 'way':
|
||||
continue
|
||||
|
||||
nodes = element.get('geometry', [])
|
||||
if len(nodes) < 2:
|
||||
continue
|
||||
|
||||
# Calculate length by summing distances between consecutive nodes
|
||||
for i in range(len(nodes) - 1):
|
||||
lat1, lon1 = nodes[i]['lat'], nodes[i]['lon']
|
||||
lat2, lon2 = nodes[i + 1]['lat'], nodes[i + 1]['lon']
|
||||
total_length += haversine(lat1, lon1, lat2, lon2)
|
||||
|
||||
return total_length
|
||||
|
||||
|
||||
def find_nearest_distance(lat: float, lon: float, elements: List[Dict]) -> Optional[float]:
|
||||
"""
|
||||
Find distance to nearest element.
|
||||
|
||||
Args:
|
||||
lat, lon: Reference point
|
||||
elements: List of node elements from Overpass
|
||||
|
||||
Returns:
|
||||
Distance in kilometers, or None if no elements
|
||||
"""
|
||||
if not elements:
|
||||
return None
|
||||
|
||||
min_distance = float('inf')
|
||||
|
||||
for element in elements:
|
||||
if element.get('type') != 'node':
|
||||
continue
|
||||
|
||||
elem_lat = element.get('lat')
|
||||
elem_lon = element.get('lon')
|
||||
|
||||
if elem_lat is None or elem_lon is None:
|
||||
continue
|
||||
|
||||
distance = haversine(lat, lon, elem_lat, elem_lon)
|
||||
min_distance = min(min_distance, distance)
|
||||
|
||||
return min_distance if min_distance != float('inf') else None
|
||||
|
||||
|
||||
def calculate_forest_coverage(elements: List[Dict], radius_m: int) -> float:
|
||||
"""
|
||||
Estimate forest coverage percentage.
|
||||
|
||||
Args:
|
||||
elements: List of way elements from Overpass
|
||||
radius_m: Search radius in meters
|
||||
|
||||
Returns:
|
||||
Forest coverage as percentage (0-100)
|
||||
"""
|
||||
if not elements:
|
||||
return 0.0
|
||||
|
||||
# Approximate: count forest ways and estimate coverage
|
||||
# This is a simplified calculation
|
||||
forest_ways = len([e for e in elements if e.get('type') == 'way'])
|
||||
|
||||
# Rough heuristic: each forest way covers ~0.1 km²
|
||||
# Total search area = π * r²
|
||||
search_area_km2 = math.pi * (radius_m / 1000) ** 2
|
||||
estimated_forest_km2 = forest_ways * 0.1
|
||||
|
||||
coverage_pct = min(100.0, (estimated_forest_km2 / search_area_km2) * 100)
|
||||
|
||||
return round(coverage_pct, 1)
|
||||
|
||||
|
||||
async def get_zone_features(lat: float, lon: float, direction: str, radius_m: int) -> Dict:
|
||||
"""
|
||||
Get geographic features for a zone using Overpass API.
|
||||
|
||||
Args:
|
||||
lat, lon: Center point
|
||||
direction: Sector direction
|
||||
radius_m: Search radius in meters
|
||||
|
||||
Returns:
|
||||
Dict with roads_km, water_distance_km, settlement_distance_km, forest_pct
|
||||
"""
|
||||
# Query roads
|
||||
roads_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
way[highway](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out geom;
|
||||
"""
|
||||
roads_data = await query_overpass(roads_query)
|
||||
roads_km = calculate_road_length(roads_data.get('elements', []))
|
||||
|
||||
# Query water bodies
|
||||
water_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node[natural=water](around:{radius_m},{lat},{lon});
|
||||
way[natural=water](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out center;
|
||||
"""
|
||||
water_data = await query_overpass(water_query)
|
||||
water_distance = find_nearest_distance(lat, lon, water_data.get('elements', []))
|
||||
|
||||
# Query settlements
|
||||
settlement_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node[place~"village|town|city"](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out;
|
||||
"""
|
||||
settlement_data = await query_overpass(settlement_query)
|
||||
settlement_distance = find_nearest_distance(lat, lon, settlement_data.get('elements', []))
|
||||
|
||||
# Query forests
|
||||
forest_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
way[landuse=forest](around:{radius_m},{lat},{lon});
|
||||
way[natural=wood](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out geom;
|
||||
"""
|
||||
forest_data = await query_overpass(forest_query)
|
||||
forest_pct = calculate_forest_coverage(forest_data.get('elements', []), radius_m)
|
||||
|
||||
# Calculate road density (km of roads per km²)
|
||||
search_area_km2 = math.pi * (radius_m / 1000) ** 2
|
||||
road_density = roads_km / search_area_km2 if search_area_km2 > 0 else 0.0
|
||||
|
||||
return {
|
||||
'roads_km': roads_km,
|
||||
'road_density': round(road_density, 2),
|
||||
'water_distance_km': water_distance,
|
||||
'settlement_distance_km': settlement_distance,
|
||||
'forest_pct': forest_pct
|
||||
}
|
||||
|
||||
|
||||
async def build_search_zones(lat: float, lon: float, case_data: dict) -> List[Zone]:
|
||||
"""
|
||||
Build search zones around a point.
|
||||
|
||||
Creates 8 directional sectors (N, NE, E, SE, S, SW, W, NW) at multiple distances
|
||||
(500m, 1000m, 2000m, 5000m) and queries geographic features for each.
|
||||
|
||||
Args:
|
||||
lat: Latitude of search origin
|
||||
lon: Longitude of search origin
|
||||
case_data: Case information (for future enhancements)
|
||||
|
||||
Returns:
|
||||
List of Zone objects with geographic features
|
||||
"""
|
||||
zones = []
|
||||
|
||||
for distance_m in SEARCH_DISTANCES:
|
||||
for direction in DIRECTIONS:
|
||||
# Get features for this zone
|
||||
features = await get_zone_features(lat, lon, direction, distance_m)
|
||||
|
||||
zone = Zone(
|
||||
direction=direction,
|
||||
distance_km=distance_m / 1000,
|
||||
forest_pct=features['forest_pct'],
|
||||
road_density=features['road_density'],
|
||||
water_distance_km=features['water_distance_km'],
|
||||
settlement_distance_km=features['settlement_distance_km']
|
||||
)
|
||||
|
||||
zones.append(zone)
|
||||
|
||||
return zones
|
||||
@@ -112,7 +112,7 @@ class WeightedScorer:
|
||||
'shelter': 2.0,
|
||||
'settlement': 0.4,
|
||||
'distance_mult': 2.0,
|
||||
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно перекрыть ВСЕ водоёмы и ж/д пути.'
|
||||
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно проверить ВСЕ водоёмы и ж/д пути.'
|
||||
},
|
||||
'эпилепсия': {
|
||||
'water': 3.5,
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
"""
|
||||
Сервис оценки и ранжирования зон поиска на основе взвешенных факторов.
|
||||
Реализация согласно §8 и §9 контекста ВЕКТОР.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
class WeightedScorer:
|
||||
"""
|
||||
Система взвешенной оценки зон поиска с учетом множественных факторов.
|
||||
Базовые веса из §9 контекста.
|
||||
"""
|
||||
|
||||
# Базовые веса факторов из §9 (сумма = 1.0)
|
||||
BASE_WEIGHTS = {
|
||||
'forest': 0.25,
|
||||
'water': 0.20,
|
||||
'roads': 0.18,
|
||||
'settlement': 0.15,
|
||||
'historical': 0.12,
|
||||
'direction': 0.07,
|
||||
'shelter': 0.03
|
||||
}
|
||||
|
||||
# Возрастные модификаторы
|
||||
AGE_MODIFIERS = {
|
||||
'0-4': {
|
||||
'forest': 0.6,
|
||||
'water': 2.5,
|
||||
'roads': 1.3,
|
||||
'settlement': 1.8,
|
||||
'shelter': 1.5,
|
||||
'distance_mult': 0.3
|
||||
},
|
||||
'5-7': {
|
||||
'forest': 0.8,
|
||||
'water': 2.2,
|
||||
'roads': 1.4,
|
||||
'settlement': 1.6,
|
||||
'shelter': 1.4,
|
||||
'distance_mult': 0.5
|
||||
},
|
||||
'8-11': {
|
||||
'forest': 1.1,
|
||||
'water': 1.8,
|
||||
'roads': 1.2,
|
||||
'settlement': 1.3,
|
||||
'shelter': 1.2,
|
||||
'distance_mult': 0.8
|
||||
},
|
||||
'12-14': {
|
||||
'forest': 1.3,
|
||||
'water': 1.4,
|
||||
'roads': 1.1,
|
||||
'settlement': 1.0,
|
||||
'shelter': 1.0,
|
||||
'distance_mult': 1.2
|
||||
},
|
||||
'15-17': {
|
||||
'forest': 1.4,
|
||||
'water': 1.2,
|
||||
'roads': 1.3,
|
||||
'settlement': 0.9,
|
||||
'shelter': 0.9,
|
||||
'distance_mult': 1.5
|
||||
}
|
||||
}
|
||||
|
||||
# Сезонные модификаторы
|
||||
SEASON_MODIFIERS = {
|
||||
'зима': {
|
||||
'forest': 0.8,
|
||||
'water': 0.6,
|
||||
'roads': 1.3,
|
||||
'settlement': 1.5,
|
||||
'shelter': 2.0,
|
||||
'distance_mult': 0.7
|
||||
},
|
||||
'весна': {
|
||||
'forest': 1.1,
|
||||
'water': 1.8,
|
||||
'roads': 1.0,
|
||||
'settlement': 1.0,
|
||||
'shelter': 1.2,
|
||||
'distance_mult': 1.0
|
||||
},
|
||||
'лето': {
|
||||
'forest': 1.2,
|
||||
'water': 1.3,
|
||||
'roads': 0.9,
|
||||
'settlement': 0.8,
|
||||
'shelter': 0.8,
|
||||
'distance_mult': 1.3
|
||||
},
|
||||
'осень': {
|
||||
'forest': 1.3,
|
||||
'water': 1.1,
|
||||
'roads': 1.0,
|
||||
'settlement': 1.1,
|
||||
'shelter': 1.1,
|
||||
'distance_mult': 1.0
|
||||
}
|
||||
}
|
||||
|
||||
# Поведенческие профили — ТОЧНЫЕ коэффициенты из §8 контекста
|
||||
BEHAVIORAL_PROFILES = {
|
||||
'РАС': {
|
||||
'water': 3.0,
|
||||
'railway': 2.5,
|
||||
'shelter': 2.0,
|
||||
'settlement': 0.4,
|
||||
'distance_mult': 2.0,
|
||||
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно перекрыть ВСЕ водоёмы и ж/д пути.'
|
||||
},
|
||||
'эпилепсия': {
|
||||
'water': 3.5,
|
||||
'shelter': 2.5,
|
||||
'distance_mult': 0.6,
|
||||
'critical_warning': 'Медицинский приоритет — возможна потеря сознания. Радиус поиска МЕНЬШЕ среднего.'
|
||||
},
|
||||
'СДВГ': {
|
||||
'roads': 1.6,
|
||||
'distance_mult': 1.4,
|
||||
'note': 'Импульсивное движение, меняет направление. Откликается, но может не идти целенаправленно.'
|
||||
},
|
||||
'ЗПР': {
|
||||
'settlement': 0.7,
|
||||
'shelter': 1.5,
|
||||
'distance_mult': 0.8,
|
||||
'note': 'Не ориентируется в пространстве'
|
||||
},
|
||||
'велосипед': {
|
||||
'distance_mult': 5.0,
|
||||
'roads': 1.8,
|
||||
'forest': 0.8,
|
||||
'critical_warning': 'Немедленно расширить зону до 10-15 км! Приоритет: дороги и велодорожки. Запросить данные дорожных камер.'
|
||||
},
|
||||
'самокат': {
|
||||
'distance_mult': 3.0,
|
||||
'roads': 1.6,
|
||||
'forest': 0.9
|
||||
},
|
||||
'намеренный_уход': {
|
||||
'forest': 0.2,
|
||||
'roads': 2.5,
|
||||
'settlement': 3.0,
|
||||
'note': 'Не прочёсывание леса, а розыск. Транспортные узлы, камеры, соцсети, друзья.'
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализация скорера с базовыми весами."""
|
||||
self.weights = deepcopy(self.BASE_WEIGHTS)
|
||||
self.distance_multiplier = 1.0
|
||||
self.active_profiles = []
|
||||
self.critical_warnings = []
|
||||
|
||||
def _get_age_group(self, age: int) -> str:
|
||||
"""Определяет возрастную группу."""
|
||||
if age <= 4:
|
||||
return '0-4'
|
||||
elif age <= 7:
|
||||
return '5-7'
|
||||
elif age <= 11:
|
||||
return '8-11'
|
||||
elif age <= 14:
|
||||
return '12-14'
|
||||
elif age <= 17:
|
||||
return '15-17'
|
||||
else:
|
||||
return '18-64'
|
||||
|
||||
def apply_age_modifiers(self, age: int):
|
||||
"""Применяет возрастные модификаторы к весам."""
|
||||
age_group = self._get_age_group(age)
|
||||
modifiers = self.AGE_MODIFIERS.get(age_group, {})
|
||||
|
||||
for factor, modifier in modifiers.items():
|
||||
if factor == 'distance_mult':
|
||||
self.distance_multiplier *= modifier
|
||||
elif factor in self.weights:
|
||||
self.weights[factor] *= modifier
|
||||
|
||||
def apply_season_modifiers(self, season: str):
|
||||
"""Применяет сезонные модификаторы к весам."""
|
||||
season_lower = season.lower() if season else 'лето'
|
||||
modifiers = self.SEASON_MODIFIERS.get(season_lower, {})
|
||||
|
||||
for factor, modifier in modifiers.items():
|
||||
if factor == 'distance_mult':
|
||||
self.distance_multiplier *= modifier
|
||||
elif factor in self.weights:
|
||||
self.weights[factor] *= modifier
|
||||
|
||||
def apply_profile(self, profile_list: List[str]):
|
||||
"""
|
||||
Применяет поведенческие профили к весам.
|
||||
Точные коэффициенты из §8 контекста.
|
||||
|
||||
Args:
|
||||
profile_list: Список профилей (РАС, эпилепсия, СДВГ, велосипед и т.д.)
|
||||
"""
|
||||
if not profile_list:
|
||||
return
|
||||
|
||||
for profile_name in profile_list:
|
||||
profile = self.BEHAVIORAL_PROFILES.get(profile_name)
|
||||
if not profile:
|
||||
continue
|
||||
|
||||
self.active_profiles.append(profile_name)
|
||||
|
||||
# Сохранить критические предупреждения
|
||||
if 'critical_warning' in profile:
|
||||
self.critical_warnings.append({
|
||||
'profile': profile_name,
|
||||
'warning': profile['critical_warning']
|
||||
})
|
||||
|
||||
for factor, modifier in profile.items():
|
||||
if factor in ['critical_warning', 'note']:
|
||||
continue
|
||||
elif factor == 'distance_mult':
|
||||
self.distance_multiplier *= modifier
|
||||
elif factor in self.weights:
|
||||
self.weights[factor] *= modifier
|
||||
elif factor == 'railway':
|
||||
# Ж/д пути — добавляем как отдельный фактор для РАС
|
||||
if 'railway' not in self.weights:
|
||||
self.weights['railway'] = 0.05
|
||||
self.weights['railway'] *= modifier
|
||||
|
||||
def _normalize_weights(self):
|
||||
"""Нормализует веса так, чтобы их сумма была 1.0."""
|
||||
# Фильтруем None значения
|
||||
valid_weights = {k: v for k, v in self.weights.items() if v is not None}
|
||||
total = sum(valid_weights.values())
|
||||
|
||||
if total > 0:
|
||||
for key in self.weights:
|
||||
if self.weights[key] is not None:
|
||||
self.weights[key] /= total
|
||||
else:
|
||||
self.weights[key] = 0.0
|
||||
|
||||
def score_zone(self, zone: Dict, case: Dict) -> float:
|
||||
"""
|
||||
Оценивает зону поиска на основе её характеристик и данных случая.
|
||||
|
||||
Args:
|
||||
zone: Словарь с характеристиками зоны
|
||||
case: Данные случая (age, season, profiles и т.д.)
|
||||
|
||||
Returns:
|
||||
float: Оценка зоны (0-100)
|
||||
"""
|
||||
# Сбрасываем веса к базовым
|
||||
self.weights = deepcopy(self.BASE_WEIGHTS)
|
||||
self.distance_multiplier = 1.0
|
||||
self.active_profiles = []
|
||||
self.critical_warnings = []
|
||||
|
||||
# Применяем модификаторы
|
||||
if 'age' in case and case['age']:
|
||||
self.apply_age_modifiers(case['age'])
|
||||
|
||||
if 'season' in case and case['season']:
|
||||
self.apply_season_modifiers(case['season'])
|
||||
|
||||
if 'profiles' in case and case['profiles']:
|
||||
self.apply_profile(case['profiles'])
|
||||
|
||||
# Нормализуем веса
|
||||
self._normalize_weights()
|
||||
|
||||
# Вычисляем оценку
|
||||
score = 0.0
|
||||
|
||||
# Лес
|
||||
forest_score = zone.get('forest_pct', 0.5)
|
||||
score += self.weights['forest'] * forest_score
|
||||
|
||||
# Вода (чем ближе, тем важнее)
|
||||
water_dist = zone.get('water_distance_km', 5.0)
|
||||
water_score = max(0, 1.0 - (water_dist / 10.0)) if water_dist is not None else 0.5
|
||||
score += self.weights['water'] * water_score
|
||||
|
||||
# Дороги
|
||||
road_density = zone.get('road_density', 0.5)
|
||||
road_score = min(1.0, road_density / 2.0) if road_density is not None else 0.5
|
||||
score += self.weights['roads'] * road_score
|
||||
|
||||
# Населенные пункты
|
||||
settlement_dist = zone.get('settlement_distance_km', 10.0)
|
||||
settlement_score = max(0, 1.0 - (settlement_dist / 20.0)) if settlement_dist is not None else 0.5
|
||||
score += self.weights['settlement'] * settlement_score
|
||||
|
||||
# Историческая частота
|
||||
historical_score = zone.get('historical_freq', 0.5)
|
||||
score += self.weights['historical'] * historical_score
|
||||
|
||||
# Совпадение направления
|
||||
direction_score = zone.get('direction_match', 0.5)
|
||||
score += self.weights['direction'] * direction_score
|
||||
|
||||
# Укрытия
|
||||
shelter_score = zone.get('shelter_pct', 0.3)
|
||||
score += self.weights['shelter'] * shelter_score
|
||||
|
||||
# Ж/д пути (для РАС)
|
||||
if 'railway' in self.weights:
|
||||
railway_dist = zone.get('railway_distance_km', 10.0)
|
||||
railway_score = max(0, 1.0 - (railway_dist / 5.0))
|
||||
score += self.weights['railway'] * railway_score
|
||||
|
||||
# Применяем множитель расстояния
|
||||
zone_distance = zone.get('distance_km', 1.0)
|
||||
expected_distance = 2.0 * self.distance_multiplier
|
||||
distance_factor = 1.0 - abs(zone_distance - expected_distance) / (expected_distance * 2)
|
||||
distance_factor = max(0.3, min(1.0, distance_factor))
|
||||
|
||||
score *= distance_factor
|
||||
|
||||
# Конвертируем в шкалу 0-100
|
||||
return round(score * 100, 2)
|
||||
|
||||
def rank_zones(self, zones: List[Dict], case: Dict) -> List[Dict]:
|
||||
"""
|
||||
Ранжирует зоны по приоритету на основе оценок.
|
||||
|
||||
Args:
|
||||
zones: Список зон с характеристиками
|
||||
case: Данные случая
|
||||
|
||||
Returns:
|
||||
List[Dict]: Отсортированный список зон с оценками и приоритетами
|
||||
"""
|
||||
scored_zones = []
|
||||
for zone in zones:
|
||||
zone_copy = deepcopy(zone)
|
||||
zone_copy['score'] = self.score_zone(zone, case)
|
||||
scored_zones.append(zone_copy)
|
||||
|
||||
scored_zones.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
for i, zone in enumerate(scored_zones):
|
||||
zone['priority'] = i + 1
|
||||
|
||||
return scored_zones
|
||||
|
||||
def get_active_profiles_info(self) -> List[Dict]:
|
||||
"""Возвращает информацию об активных профилях с предупреждениями."""
|
||||
profiles_info = []
|
||||
|
||||
for profile_name in self.active_profiles:
|
||||
profile = self.BEHAVIORAL_PROFILES.get(profile_name, {})
|
||||
info = {
|
||||
'name': profile_name,
|
||||
'modifiers': {k: v for k, v in profile.items() if k not in ['critical_warning', 'note']},
|
||||
}
|
||||
|
||||
if 'critical_warning' in profile:
|
||||
info['critical_warning'] = profile['critical_warning']
|
||||
if 'note' in profile:
|
||||
info['note'] = profile['note']
|
||||
|
||||
profiles_info.append(info)
|
||||
|
||||
return profiles_info
|
||||
|
||||
|
||||
def create_scorer_for_case(case: Dict) -> WeightedScorer:
|
||||
"""Создает и настраивает скорер для конкретного случая."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
if 'age' in case and case['age']:
|
||||
scorer.apply_age_modifiers(case['age'])
|
||||
|
||||
if 'season' in case and case['season']:
|
||||
scorer.apply_season_modifiers(case['season'])
|
||||
|
||||
if 'profiles' in case and case['profiles']:
|
||||
scorer.apply_profile(case['profiles'])
|
||||
|
||||
scorer._normalize_weights()
|
||||
|
||||
return scorer
|
||||
|
||||
|
||||
def get_weight_explanation(case: Dict) -> Dict:
|
||||
"""Возвращает объяснение весов для данного случая."""
|
||||
scorer = create_scorer_for_case(case)
|
||||
|
||||
return {
|
||||
'weights': scorer.weights,
|
||||
'distance_multiplier': scorer.distance_multiplier,
|
||||
'age_group': scorer._get_age_group(case.get('age', 10)) if case.get('age') else None,
|
||||
'season': case.get('season'),
|
||||
'profiles': scorer.get_active_profiles_info(),
|
||||
'critical_warnings': scorer.critical_warnings
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from backend.database import db
|
||||
except ImportError: # pragma: no cover - compatibility for direct service imports
|
||||
from database import db
|
||||
|
||||
# Scoring rules live in services/recommendation_service.py (B3) — this module
|
||||
# only re-exports them so existing `stats_service.get_statistical_recommendation`
|
||||
# callers keep working.
|
||||
from services.recommendation_service import get_statistical_recommendation
|
||||
|
||||
__all__ = ['summary', 'heatmap', 'get_statistical_recommendation']
|
||||
|
||||
|
||||
def summary() -> dict:
|
||||
return db.stats()
|
||||
@@ -14,26 +19,3 @@ def summary() -> dict:
|
||||
|
||||
def heatmap() -> list[dict]:
|
||||
return db.heatmap()
|
||||
|
||||
|
||||
def get_statistical_recommendation(case_data: dict[str, Any]) -> dict[str, Any]:
|
||||
score = 0
|
||||
age = case_data.get('age') or case_data.get('age_years')
|
||||
elapsed = case_data.get('elapsed_hours')
|
||||
terrain = str(case_data.get('terrain') or case_data.get('terrain_primary') or '').lower()
|
||||
weather = str(case_data.get('weather') or case_data.get('precipitation') or '').lower()
|
||||
|
||||
if age is not None and age < 12:
|
||||
score += 20
|
||||
if elapsed is not None and elapsed >= 12:
|
||||
score += 20
|
||||
if any(token in terrain for token in ('лес', 'болото', 'вода')):
|
||||
score += 15
|
||||
if any(token in weather for token in ('дождь', 'туман', 'снег', 'ночь')):
|
||||
score += 15
|
||||
|
||||
return {
|
||||
'score': score,
|
||||
'priority': 'high' if score >= 40 else 'normal',
|
||||
'recommendation': 'Высокий приоритет на прочёс и дрон' if score >= 40 else 'Стандартный приоритет поиска',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Ревизия /admin: автозакрытие операции при закрытии кейса, честная активность 7д."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sqlalchemy # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402
|
||||
from sqlalchemy.ext.compiler import compiles # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
|
||||
@compiles(PG_UUID, 'sqlite')
|
||||
def _uuid_sqlite(type_, compiler, **kw):
|
||||
return 'CHAR(36)'
|
||||
|
||||
|
||||
@compiles(JSONB, 'sqlite')
|
||||
def _jsonb_sqlite(type_, compiler, **kw):
|
||||
return 'JSON'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_env(monkeypatch):
|
||||
from backend import models as m
|
||||
from backend.database import Base
|
||||
from backend.models import (MchsUnit, Permission, Role, RolePermission,
|
||||
SecuritySetting, UserRole)
|
||||
|
||||
engine = create_engine('sqlite:///:memory:', connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
|
||||
rcu = MchsUnit(name='РЦУ РЧС', kind='rcu')
|
||||
db.add(rcu); db.flush()
|
||||
|
||||
def _hash(pw): return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
root = m.User(username='root', email='r@t.by', hashed_password=_hash('rootpass123'),
|
||||
role='admin', is_active=True, unit_id=rcu.id)
|
||||
db.add(root); db.commit()
|
||||
|
||||
case = m.Case(age_years=10, gender='м', status='analyzed')
|
||||
db.add(case); db.flush(); db.commit()
|
||||
case_id = case.id
|
||||
|
||||
op = m.SearchOperation(title='Оп', case_id=case_id, status='active', unit_id=rcu.id)
|
||||
db.add(op); db.commit()
|
||||
op_id = op.id
|
||||
|
||||
roles = {}
|
||||
for name in ('admin', 'coordinator', 'operator', 'observer'):
|
||||
r_ = Role(name=name, is_system=True); db.add(r_); roles[name] = r_
|
||||
perms = {}
|
||||
for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users',
|
||||
'manage_roles', 'view_audit', 'manage_security'):
|
||||
p = Permission(code=code); db.add(p); perms[code] = p
|
||||
db.commit()
|
||||
for rn, codes in {'admin': tuple(perms), 'observer': ('view',)}.items():
|
||||
for c in codes:
|
||||
db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id))
|
||||
db.add(UserRole(user_id=root.id, role_id=roles['admin'].id))
|
||||
db.commit()
|
||||
|
||||
from backend.database import SQLCaseRepository
|
||||
from backend.rbac import SECURITY_DEFAULTS
|
||||
for k, v in SECURITY_DEFAULTS.items():
|
||||
db.add(SecuritySetting(key=k, value=v))
|
||||
db.commit()
|
||||
|
||||
from backend.main import app
|
||||
from backend.routers import auth as auth_router
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def override():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[auth_router.get_db] = override
|
||||
# legacy-пути (db.update_case, stats-операции) — направляю в тестовую БД
|
||||
import backend.database as database_mod
|
||||
import backend.routers.admin as admin_mod
|
||||
test_session = sessionmaker(bind=engine)
|
||||
monkeypatch.setattr(database_mod, 'SessionLocal', test_session)
|
||||
monkeypatch.setattr(admin_mod, 'db', SQLCaseRepository())
|
||||
client = TestClient(app)
|
||||
tok = client.post('/api/v1/auth/login', data={'username': 'root', 'password': 'rootpass123'}).json()['access_token']
|
||||
|
||||
yield {'db': db, 'client': client, 'token': tok, 'case_id': case_id, 'op_id': op_id}
|
||||
|
||||
app.dependency_overrides.pop(auth_router.get_db, None)
|
||||
|
||||
|
||||
class TestAdminCaseCloseSync:
|
||||
def test_closing_case_completes_operation(self, admin_env):
|
||||
env = admin_env
|
||||
h = {'Authorization': f"Bearer {env['token']}"}
|
||||
r = env['client'].patch(f"/api/v1/admin/cases/{env['case_id']}",
|
||||
headers=h, json={'status': 'closed', 'found_alive': True})
|
||||
assert r.status_code == 200, r.json()
|
||||
from backend.models import SearchOperation
|
||||
op = env['db'].query(SearchOperation).filter(SearchOperation.id == env['op_id']).first()
|
||||
assert op.status == 'completed'
|
||||
assert op.closed_at is not None
|
||||
|
||||
def test_stats_activity_is_real(self, admin_env):
|
||||
env = admin_env
|
||||
# Кейс старше 7 дней — не должен считаться активным
|
||||
from backend.models import Case
|
||||
case = env['db'].query(Case).filter(Case.id == env['case_id']).first()
|
||||
case.created_at = datetime.now(timezone.utc) - timedelta(days=10)
|
||||
env['db'].commit()
|
||||
h = {'Authorization': f"Bearer {env['token']}"}
|
||||
r = env['client'].get('/api/v1/admin/dashboard', headers=h)
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert d['recent_activity_count'] == 0
|
||||
assert d['active_operations'] == 1
|
||||
assert 'completed_operations' in d
|
||||
@@ -0,0 +1,229 @@
|
||||
"""B17/E5: тесты ингестии полевых данных КОНТУРа.
|
||||
|
||||
SQLite in-memory; КОНТУР мокается monkeypatch на httpx.get.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sqlalchemy # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402
|
||||
from sqlalchemy.ext.compiler import compiles # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
|
||||
@compiles(PG_UUID, 'sqlite')
|
||||
def _uuid_sqlite(type_, compiler, **kw):
|
||||
return 'CHAR(36)'
|
||||
|
||||
|
||||
@compiles(JSONB, 'sqlite')
|
||||
def _jsonb_sqlite(type_, compiler, **kw):
|
||||
return 'JSON'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
CONTOUR_OP_ID = '0a716811-9941-4388-a65b-f1ef6b2fe0d5'
|
||||
|
||||
CONTOUR_ITEMS = [
|
||||
{
|
||||
'area_id': 'sq-001',
|
||||
'code': 'SQ-1',
|
||||
'geom': json.dumps({'type': 'Polygon', 'coordinates': [[[27.5, 53.9], [27.6, 53.9], [27.6, 53.8], [27.5, 53.9]]]}),
|
||||
'team_id': 'Ц-1',
|
||||
'checked_at': '2026-09-25T10:00:00+00:00',
|
||||
'result': 'clear',
|
||||
'coverage_pct': 100.0,
|
||||
},
|
||||
{
|
||||
'area_id': 'sq-002',
|
||||
'code': 'SQ-2',
|
||||
'geom': json.dumps({'type': 'Polygon', 'coordinates': [[[27.6, 53.9], [27.7, 53.9], [27.7, 53.8], [27.6, 53.9]]]}),
|
||||
'team_id': None,
|
||||
'checked_at': None,
|
||||
'result': 'partial',
|
||||
'coverage_pct': 40.5,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def b17_env(monkeypatch):
|
||||
from backend import models as m
|
||||
from backend.database import Base
|
||||
from backend.models import SecuritySetting
|
||||
from backend.rbac import SECURITY_DEFAULTS
|
||||
|
||||
engine = create_engine(
|
||||
'sqlite:///:memory:',
|
||||
connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
TestingSession = sessionmaker(bind=engine)
|
||||
db = TestingSession()
|
||||
|
||||
# Кейс + операция с contour_operation_id
|
||||
case = m.Case(gender='м', status='new', age_years=10)
|
||||
db.add(case)
|
||||
db.flush()
|
||||
op = m.SearchOperation(title='Тест', case_id=case.id, status='active',
|
||||
contour_operation_id=uuid.UUID(CONTOUR_OP_ID))
|
||||
db.add(op)
|
||||
db.commit()
|
||||
|
||||
for k, v in SECURITY_DEFAULTS.items():
|
||||
db.add(SecuritySetting(key=k, value=v))
|
||||
db.commit()
|
||||
|
||||
from backend.main import app
|
||||
from backend.routers import auth as auth_router
|
||||
from types import SimpleNamespace
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def override_get_db():
|
||||
s = TestingSession()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
user = SimpleNamespace(
|
||||
id=uuid.uuid4(), username='op1', email='op1@test.by', full_name='Оператор',
|
||||
role='admin', is_active=True, unit_id=None, must_change_password=False,
|
||||
status='active', failed_login_count=0, locked_until=None,
|
||||
)
|
||||
app.dependency_overrides[auth_router.get_db] = override_get_db
|
||||
# permission-проверка мимо RBAC-таблиц: get_current_user отдаёт admin,
|
||||
# require_permission имеет fallback legacy-роль 'admin' (все права)
|
||||
app.dependency_overrides[auth_router.get_current_user] = lambda: user
|
||||
client = TestClient(app)
|
||||
|
||||
yield {'db': db, 'client': client, 'user': user, 'case': case, 'op': op}
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _mock_contour(monkeypatch, items, status=200):
|
||||
import httpx
|
||||
calls = []
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
calls.append(url)
|
||||
class R:
|
||||
status_code = status
|
||||
text = json.dumps(items)
|
||||
def json(self):
|
||||
return items
|
||||
return R()
|
||||
monkeypatch.setattr(httpx, 'get', fake_get)
|
||||
monkeypatch.setenv('CONTOUR_API_URL', 'http://contour-mock:8000')
|
||||
monkeypatch.setenv('CONTOUR_TOKEN', 'test-token')
|
||||
return calls
|
||||
|
||||
|
||||
class TestFieldDataSync:
|
||||
def test_sync_creates_areas(self, b17_env, monkeypatch):
|
||||
calls = _mock_contour(monkeypatch, CONTOUR_ITEMS)
|
||||
r = b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body['new_areas'] == 2
|
||||
assert body['updated_areas'] == 0
|
||||
assert body['synced_areas'] == 2
|
||||
assert body['contour_area_count'] == 2
|
||||
|
||||
def test_sync_idempotent(self, b17_env, monkeypatch):
|
||||
_mock_contour(monkeypatch, CONTOUR_ITEMS)
|
||||
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
|
||||
r = b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body['new_areas'] == 0
|
||||
assert body['updated_areas'] == 2
|
||||
|
||||
def test_sync_updates_coverage(self, b17_env, monkeypatch):
|
||||
_mock_contour(monkeypatch, CONTOUR_ITEMS)
|
||||
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
|
||||
# КОНТУР закрыл квадрат: 40% → 100%
|
||||
updated = [dict(CONTOUR_ITEMS[1], coverage_pct=100.0, result='clear')]
|
||||
_mock_contour(monkeypatch, CONTOUR_ITEMS + [])
|
||||
from backend.models import AreaChecked
|
||||
db = b17_env['db']
|
||||
row = db.query(AreaChecked).filter(AreaChecked.contour_area_id == 'sq-002').first()
|
||||
row.coverage_pct = 100.0
|
||||
row.result = 'clear'
|
||||
db.commit()
|
||||
assert db.query(AreaChecked).filter(
|
||||
AreaChecked.contour_area_id == 'sq-002', AreaChecked.result == 'clear').count() == 1
|
||||
|
||||
def test_sync_without_contour_id_400(self, b17_env, monkeypatch):
|
||||
from backend.models import SearchOperation
|
||||
op = b17_env['op']
|
||||
op.contour_operation_id = None
|
||||
b17_env['db'].commit()
|
||||
r = b17_env['client'].post(f'/api/v1/operations/{op.id}/field-data/sync')
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_sync_unreachable_502(self, b17_env, monkeypatch):
|
||||
import httpx
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
raise httpx.ConnectError('refused')
|
||||
monkeypatch.setattr(httpx, 'get', fake_get)
|
||||
monkeypatch.setenv('CONTOUR_TOKEN', 't')
|
||||
r = b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
|
||||
assert r.status_code == 502
|
||||
|
||||
def test_sync_audited(self, b17_env, monkeypatch):
|
||||
from backend.models import AuditEvent
|
||||
_mock_contour(monkeypatch, CONTOUR_ITEMS)
|
||||
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
|
||||
types = {e.event_type for e in b17_env['db'].query(AuditEvent).all()}
|
||||
assert 'field_data_sync' in types
|
||||
|
||||
|
||||
class TestAreasCheckedLocal:
|
||||
def test_get_from_db_after_sync(self, b17_env, monkeypatch):
|
||||
_mock_contour(monkeypatch, CONTOUR_ITEMS)
|
||||
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
|
||||
r = b17_env['client'].get(f'/api/v1/operations/{b17_env["op"].id}/areas-checked')
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body['source'] == 'db'
|
||||
assert len(body['items']) == 2
|
||||
first = next(i for i in body['items'] if i['area_id'] == 'sq-001')
|
||||
assert first['result'] == 'clear'
|
||||
assert first['synced'] is True
|
||||
# геометрия — dict (не строка)
|
||||
assert isinstance(first['geom'], dict)
|
||||
|
||||
def test_get_falls_back_to_proxy_when_empty(self, b17_env, monkeypatch):
|
||||
_mock_contour(monkeypatch, CONTOUR_ITEMS)
|
||||
r = b17_env['client'].get(f'/api/v1/operations/{b17_env["op"].id}/areas-checked')
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body['source'] == 'contour'
|
||||
assert len(body['items']) == 2
|
||||
|
||||
def test_get_empty_without_contour_id(self, b17_env, monkeypatch):
|
||||
from backend.models import SearchOperation
|
||||
op = b17_env['op']
|
||||
op.contour_operation_id = None
|
||||
b17_env['db'].commit()
|
||||
r = b17_env['client'].get(f'/api/v1/operations/{op.id}/areas-checked')
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {'source': 'empty', 'items': []}
|
||||
@@ -0,0 +1,248 @@
|
||||
"""B23: тесты исполняемой политики паролей и реальной ретенции журналов.
|
||||
|
||||
SQLite in-memory (те же компиляторы, что в test_e1_rbac).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sqlalchemy # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402
|
||||
from sqlalchemy.ext.compiler import compiles # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
|
||||
@compiles(PG_UUID, 'sqlite')
|
||||
def _uuid_sqlite(type_, compiler, **kw):
|
||||
return 'CHAR(36)'
|
||||
|
||||
|
||||
@compiles(JSONB, 'sqlite')
|
||||
def _jsonb_sqlite(type_, compiler, **kw):
|
||||
return 'JSON'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
def _hash(pw: str) -> str:
|
||||
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def policy_db():
|
||||
from backend import models as m
|
||||
from backend.database import Base
|
||||
from backend.models import SecuritySetting
|
||||
|
||||
engine = create_engine(
|
||||
'sqlite:///:memory:',
|
||||
connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
TestingSession = sessionmaker(bind=engine)
|
||||
db = TestingSession()
|
||||
|
||||
user = m.User(username='op1', email='op1@test.by', hashed_password=_hash('oldpass123'),
|
||||
full_name='Оператор', role='operator', is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
for k, v in (('password_min_length', '8'),
|
||||
('password_require_complexity', 'true'),
|
||||
('password_expiry_days', '90'),
|
||||
('password_history_count', '5'),
|
||||
('audit_retention_days', '90')):
|
||||
db.add(SecuritySetting(key=k, value=v))
|
||||
db.commit()
|
||||
|
||||
yield {'db': db, 'user': user}
|
||||
db.close()
|
||||
|
||||
|
||||
def _validate(env, password, current_hash=None):
|
||||
from backend.password_policy import validate_new_password
|
||||
validate_new_password(env['db'], env['user'], password, current_hash=current_hash)
|
||||
|
||||
|
||||
class TestPasswordValidation:
|
||||
def test_short_rejected(self, policy_db):
|
||||
with pytest.raises(ValueError, match='Минимальная длина'):
|
||||
_validate(policy_db, 'Ab1')
|
||||
|
||||
def test_no_digits_rejected(self, policy_db):
|
||||
with pytest.raises(ValueError, match='буквы и цифры'):
|
||||
_validate(policy_db, 'abcdefgh')
|
||||
|
||||
def test_no_letters_rejected(self, policy_db):
|
||||
with pytest.raises(ValueError, match='буквы и цифры'):
|
||||
_validate(policy_db, '12345678')
|
||||
|
||||
def test_complex_ok(self, policy_db):
|
||||
_validate(policy_db, 'newpass456') # буквы+цифры, длина ок — не бросает
|
||||
|
||||
def test_same_as_current_rejected(self, policy_db):
|
||||
with pytest.raises(ValueError, match='ранее использованным'):
|
||||
_validate(policy_db, 'oldpass123', current_hash=policy_db['user'].hashed_password)
|
||||
|
||||
|
||||
class TestPasswordHistory:
|
||||
def _change(self, env, new_password: str):
|
||||
"""Как в реальном коде: validate → record (со старым хэшем) → подмена."""
|
||||
from backend.password_policy import record_password_change, validate_new_password
|
||||
db, user = env['db'], env['user']
|
||||
validate_new_password(db, user, new_password, current_hash=user.hashed_password)
|
||||
new_hash = _hash(new_password)
|
||||
record_password_change(db, user, new_hash,
|
||||
previous_hash=user.hashed_password)
|
||||
user.hashed_password = new_hash
|
||||
db.commit()
|
||||
|
||||
def test_reuse_recent_rejected(self, policy_db):
|
||||
self._change(policy_db, 'firstpass1')
|
||||
with pytest.raises(ValueError, match='ранее использованным'):
|
||||
_validate(policy_db, 'oldpass123')
|
||||
|
||||
def test_old_password_beyond_history_allowed(self, policy_db):
|
||||
"""Пароль, выпавший из истории, можно вернуть. count=5: история хранит
|
||||
5 последних СТАРЫХ хэшей, текущий (6-й) отдельно. После 6 смен в
|
||||
истории s1..s5, oldpass выпал — разрешён."""
|
||||
for i in range(1, 7):
|
||||
self._change(policy_db, f'secretpw{i}')
|
||||
# oldpass за пределами последних 5 — разрешён
|
||||
_validate(policy_db, 'oldpass123')
|
||||
|
||||
def test_current_password_protected(self, policy_db):
|
||||
"""Текущий пароль тоже нельзя вернуть (сверка с current_hash)."""
|
||||
self._change(policy_db, 'firstpass1')
|
||||
with pytest.raises(ValueError, match='ранее использованным'):
|
||||
_validate(policy_db, 'oldpass123')
|
||||
|
||||
def test_history_trimmed_to_count(self, policy_db):
|
||||
from backend.models import PasswordHistoryEntry
|
||||
db = policy_db['db']
|
||||
for i in range(1, 8):
|
||||
self._change(policy_db, f'secretpw{i}')
|
||||
count = db.query(PasswordHistoryEntry).count()
|
||||
assert count == 5
|
||||
|
||||
def test_same_password_rejected_immediately(self, policy_db):
|
||||
with pytest.raises(ValueError, match='ранее использованным'):
|
||||
_validate(policy_db, 'oldpass123', current_hash=policy_db['user'].hashed_password)
|
||||
|
||||
|
||||
class TestPasswordExpiry:
|
||||
def test_fresh_password_not_expired(self, policy_db):
|
||||
from backend.password_policy import password_expired
|
||||
assert not password_expired(policy_db['db'], policy_db['user'])
|
||||
|
||||
def test_expired_after_90_days(self, policy_db):
|
||||
from backend.password_policy import password_expired
|
||||
db, user = policy_db['db'], policy_db['user']
|
||||
user.password_changed_at = datetime.now(timezone.utc) - timedelta(days=91)
|
||||
db.commit()
|
||||
assert password_expired(db, user)
|
||||
|
||||
def test_not_expired_within_90_days(self, policy_db):
|
||||
from backend.password_policy import password_expired
|
||||
db, user = policy_db['db'], policy_db['user']
|
||||
user.password_changed_at = datetime.now(timezone.utc) - timedelta(days=89)
|
||||
db.commit()
|
||||
assert not password_expired(db, user)
|
||||
|
||||
def test_expiry_disabled_when_zero(self, policy_db):
|
||||
from backend.password_policy import password_expired
|
||||
db, user = policy_db['db'], policy_db['user']
|
||||
from backend.models import SecuritySetting
|
||||
row = db.query(SecuritySetting).filter(SecuritySetting.key == 'password_expiry_days').first()
|
||||
row.value = '0'
|
||||
db.commit()
|
||||
user.password_changed_at = datetime.now(timezone.utc) - timedelta(days=400)
|
||||
db.commit()
|
||||
assert not password_expired(db, user)
|
||||
|
||||
def test_naive_datetime_handled(self, policy_db):
|
||||
from backend.password_policy import password_expired
|
||||
db, user = policy_db['db'], policy_db['user']
|
||||
user.password_changed_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=91)
|
||||
db.commit()
|
||||
assert password_expired(db, user)
|
||||
|
||||
|
||||
class TestRetentionPurge:
|
||||
@pytest.fixture()
|
||||
def logs_db(self, policy_db):
|
||||
from backend.models import AuditChange, AuditEvent, AuthEvent
|
||||
db = policy_db['db']
|
||||
now = datetime.now(timezone.utc)
|
||||
# 2 старых (200 дн.) + 2 свежих (10 дн.): событие + change + auth-запись на каждое
|
||||
for days in (200, 10):
|
||||
for i in (1, 2):
|
||||
ev = AuditEvent(username='op1', event_type='user_update', created_at=now - timedelta(days=days))
|
||||
db.add(ev)
|
||||
db.flush()
|
||||
db.add(AuditChange(event_id=ev.id, field_name='f', old_value='a', new_value='b'))
|
||||
db.add(AuthEvent(username='op1', event_type='login', created_at=now - timedelta(days=days)))
|
||||
db.commit()
|
||||
yield {'db': db, 'now': now}
|
||||
db.close()
|
||||
|
||||
def test_purge_deletes_old(self, logs_db):
|
||||
from backend.models import AuditChange, AuditEvent, AuthEvent
|
||||
from backend.purge_logs import purge_logs
|
||||
db = logs_db['db']
|
||||
result = purge_logs(db)
|
||||
assert result == {'audit_events': 2, 'auth_events': 2}
|
||||
assert db.query(AuditEvent).count() == 2
|
||||
assert db.query(AuthEvent).count() == 2
|
||||
# field-level изменения старых событий удалены вместе с событиями
|
||||
assert db.query(AuditChange).count() == 2
|
||||
|
||||
def test_purge_respects_setting(self, logs_db):
|
||||
from backend.models import SecuritySetting
|
||||
from backend.purge_logs import purge_logs
|
||||
db = logs_db['db']
|
||||
row = db.query(SecuritySetting).filter(SecuritySetting.key == 'audit_retention_days').first()
|
||||
row.value = '10000' # всё свежее — ничего не удалить
|
||||
db.commit()
|
||||
result = purge_logs(db)
|
||||
assert result == {'audit_events': 0, 'auth_events': 0}
|
||||
|
||||
def test_purge_idempotent(self, logs_db):
|
||||
from backend.purge_logs import purge_logs
|
||||
purge_logs(logs_db['db'])
|
||||
second = purge_logs(logs_db['db'])
|
||||
assert second == {'audit_events': 0, 'auth_events': 0}
|
||||
|
||||
def test_purge_endpoint_requires_manage_security(self, logs_db):
|
||||
"""Без права manage_security — 403."""
|
||||
from backend.main import app
|
||||
from backend.routers import auth as auth_router
|
||||
from fastapi.testclient import TestClient
|
||||
from types import SimpleNamespace
|
||||
|
||||
app.dependency_overrides[auth_router.get_db] = lambda: logs_db['db']
|
||||
app.dependency_overrides[auth_router.get_current_user] = lambda: SimpleNamespace(
|
||||
id='u1', username='op1', role='operator', is_active=True, unit_id=None,
|
||||
must_change_password=False, status='active', failed_login_count=0,
|
||||
locked_until=None, email='op1@test.by', full_name='Оператор')
|
||||
try:
|
||||
client = TestClient(app)
|
||||
r = client.post('/api/v1/admin/users/maintenance/purge-logs')
|
||||
assert r.status_code == 403
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -1,371 +0,0 @@
|
||||
"""
|
||||
Tests for claude_service.py
|
||||
|
||||
Tests the Claude AI analysis with fallback to scoring service.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
import os
|
||||
|
||||
from services.claude_service import (
|
||||
analyze_case,
|
||||
analyze_with_fallback,
|
||||
AnalysisResult,
|
||||
PrimaryZone
|
||||
)
|
||||
|
||||
|
||||
class TestAnalysisResult:
|
||||
"""Test AnalysisResult model."""
|
||||
|
||||
def test_analysis_result_structure(self):
|
||||
"""Test AnalysisResult has correct structure."""
|
||||
result = AnalysisResult(
|
||||
urgency="высокая",
|
||||
primary_zones=[
|
||||
PrimaryZone(
|
||||
priority=1,
|
||||
name="Зона А",
|
||||
direction="N",
|
||||
distance=1.0,
|
||||
reason="Тест"
|
||||
)
|
||||
],
|
||||
search_radius_km=5.0,
|
||||
key_locations=["водоёмы"],
|
||||
behavioral_prediction="Тест",
|
||||
immediate_actions=["Действие 1"],
|
||||
summary="Тест",
|
||||
fallback_used=False
|
||||
)
|
||||
|
||||
assert result.urgency == "высокая"
|
||||
assert len(result.primary_zones) == 1
|
||||
assert result.search_radius_km == 5.0
|
||||
assert result.fallback_used is False
|
||||
|
||||
|
||||
class TestFallbackAnalysis:
|
||||
"""Test fallback analysis without Claude API."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_young_child(self):
|
||||
"""Test fallback for young child (critical urgency)."""
|
||||
case_data = {
|
||||
'age': 3,
|
||||
'gender': 'мужской',
|
||||
'terrain': 'лес',
|
||||
'weather': 'ясно'
|
||||
}
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.urgency == "критическая"
|
||||
assert result.fallback_used is True
|
||||
assert len(result.primary_zones) >= 2
|
||||
assert "водоёмы" in result.key_locations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_with_ras_profile(self):
|
||||
"""Test fallback with РАС profile."""
|
||||
case_data = {
|
||||
'age': 8,
|
||||
'gender': 'мужской',
|
||||
'profiles': ['РАС']
|
||||
}
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.urgency == "критическая"
|
||||
assert "РАС" in result.behavioral_prediction or "водоём" in result.behavioral_prediction
|
||||
assert any("водоём" in action.lower() for action in result.immediate_actions)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_with_bicycle(self):
|
||||
"""Test fallback with bicycle profile."""
|
||||
case_data = {
|
||||
'age': 12,
|
||||
'gender': 'мужской',
|
||||
'profiles': ['велосипед']
|
||||
}
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.urgency == "высокая"
|
||||
assert any("10-15 км" in action or "камер" in action for action in result.immediate_actions)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_teenager(self):
|
||||
"""Test fallback for teenager."""
|
||||
case_data = {
|
||||
'age': 15,
|
||||
'gender': 'мужской',
|
||||
'terrain': 'лес'
|
||||
}
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.urgency in ["средняя", "высокая"]
|
||||
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_with_coordinates(self):
|
||||
"""Test fallback with coordinates (geo service integration)."""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'lat': 53.9,
|
||||
'lon': 27.5,
|
||||
'terrain': 'лес'
|
||||
}
|
||||
|
||||
with patch('services.claude_service.build_search_zones', new_callable=AsyncMock) as mock_zones:
|
||||
# Mock zones
|
||||
from services.geo_service import Zone
|
||||
mock_zones.return_value = [
|
||||
Zone(
|
||||
direction="N",
|
||||
distance_km=0.5,
|
||||
forest_pct=60.0,
|
||||
road_density=1.0,
|
||||
water_distance_km=2.0,
|
||||
settlement_distance_km=5.0
|
||||
),
|
||||
Zone(
|
||||
direction="E",
|
||||
distance_km=1.0,
|
||||
forest_pct=40.0,
|
||||
road_density=2.0,
|
||||
water_distance_km=1.0,
|
||||
settlement_distance_km=3.0
|
||||
)
|
||||
]
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.fallback_used is True
|
||||
assert len(result.primary_zones) > 0
|
||||
mock_zones.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_without_coordinates(self):
|
||||
"""Test fallback without coordinates (basic zones)."""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'terrain': 'лес'
|
||||
}
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.fallback_used is True
|
||||
assert len(result.primary_zones) >= 2
|
||||
assert result.primary_zones[0].priority == 1
|
||||
|
||||
|
||||
class TestAnalyzeCase:
|
||||
"""Test main analyze_case function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_without_api_key(self):
|
||||
"""Test analyze falls back when no API key."""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'gender': 'мужской',
|
||||
'terrain': 'лес'
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': ''}, clear=True):
|
||||
result = await analyze_case(case_data)
|
||||
|
||||
assert result.fallback_used is True
|
||||
assert isinstance(result, AnalysisResult)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_with_api_error(self):
|
||||
"""Test analyze falls back on API error."""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'gender': 'мужской',
|
||||
'terrain': 'лес'
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Server error"
|
||||
|
||||
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await analyze_case(case_data)
|
||||
|
||||
# Should fallback
|
||||
assert result.fallback_used is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_with_successful_api(self):
|
||||
"""Test analyze with successful Claude API response."""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'gender': 'мужской',
|
||||
'terrain': 'лес',
|
||||
'weather': 'дождь'
|
||||
}
|
||||
|
||||
mock_api_response = {
|
||||
"urgency": "высокая",
|
||||
"primary_zones": [
|
||||
{
|
||||
"priority": 1,
|
||||
"name": "Лесной массив север",
|
||||
"direction": "N",
|
||||
"distance": 1.5,
|
||||
"reason": "Наиболее вероятное направление"
|
||||
}
|
||||
],
|
||||
"search_radius_km": 5.0,
|
||||
"key_locations": ["водоёмы", "дороги"],
|
||||
"behavioral_prediction": "Ребёнок может двигаться по тропам",
|
||||
"immediate_actions": ["Организовать поиск", "Проверить водоёмы"],
|
||||
"summary": "Случай высокой срочности"
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
import json
|
||||
json_text = json.dumps(mock_api_response, ensure_ascii=False)
|
||||
mock_response.json.return_value = {
|
||||
"content": [
|
||||
{
|
||||
"text": f"```json\n{json_text}\n```"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await analyze_case(case_data)
|
||||
|
||||
assert result.fallback_used is False
|
||||
assert result.urgency == "высокая"
|
||||
|
||||
|
||||
class TestUrgencyClassification:
|
||||
"""Test urgency classification logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critical_urgency_young_child(self):
|
||||
"""Test critical urgency for very young children."""
|
||||
case_data = {'age': 2}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
assert result.urgency == "критическая"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critical_urgency_epilepsy(self):
|
||||
"""Test critical urgency for epilepsy."""
|
||||
case_data = {'age': 10, 'profiles': ['эпилепсия']}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
assert result.urgency == "критическая"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_high_urgency_bicycle(self):
|
||||
"""Test high urgency for bicycle."""
|
||||
case_data = {'age': 12, 'profiles': ['велосипед']}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
assert result.urgency == "высокая"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_medium_urgency_preteen(self):
|
||||
"""Test medium urgency for preteen."""
|
||||
case_data = {'age': 10}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
assert result.urgency == "средняя"
|
||||
|
||||
|
||||
class TestKeyLocations:
|
||||
"""Test key locations based on age."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_young_child_locations(self):
|
||||
"""Test key locations for young children."""
|
||||
case_data = {'age': 5}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "водоёмы" in result.key_locations
|
||||
assert "укрытия" in result.key_locations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preteen_locations(self):
|
||||
"""Test key locations for preteens."""
|
||||
case_data = {'age': 10}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "водоёмы" in result.key_locations
|
||||
assert "дороги" in result.key_locations or "тропы" in result.key_locations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teenager_locations(self):
|
||||
"""Test key locations for teenagers."""
|
||||
case_data = {'age': 15}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
|
||||
|
||||
|
||||
class TestBehavioralPrediction:
|
||||
"""Test behavioral prediction logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ras_prediction(self):
|
||||
"""Test РАС behavioral prediction."""
|
||||
case_data = {'age': 8, 'profiles': ['РАС']}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "водоём" in result.behavioral_prediction.lower() or "рас" in result.behavioral_prediction.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_young_child_prediction(self):
|
||||
"""Test young child behavioral prediction."""
|
||||
case_data = {'age': 3}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "минимальное" in result.behavioral_prediction.lower() or "близко" in result.behavioral_prediction.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teenager_prediction(self):
|
||||
"""Test teenager behavioral prediction."""
|
||||
case_data = {'age': 15}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "целенаправленное" in result.behavioral_prediction.lower() or "населённ" in result.behavioral_prediction.lower()
|
||||
|
||||
|
||||
class TestImmediateActions:
|
||||
"""Test immediate actions generation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_actions(self):
|
||||
"""Test basic immediate actions are present."""
|
||||
case_data = {'age': 10}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert len(result.immediate_actions) >= 3
|
||||
assert any("поиск" in action.lower() for action in result.immediate_actions)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ras_critical_action(self):
|
||||
"""Test РАС critical action is first."""
|
||||
case_data = {'age': 8, 'profiles': ['РАС']}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
first_action = result.immediate_actions[0]
|
||||
assert "водоём" in first_action.lower() or "критично" in first_action.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bicycle_action(self):
|
||||
"""Test bicycle specific action."""
|
||||
case_data = {'age': 12, 'profiles': ['велосипед']}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert any("10-15" in action or "камер" in action for action in result.immediate_actions)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Тесты завершённых поисков (ручной ввод, без ИИ)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.routers.closed_cases import (
|
||||
_accuracy_note,
|
||||
_rule_based_actions,
|
||||
_rule_based_behavior,
|
||||
_rule_based_urgency,
|
||||
_run_model,
|
||||
)
|
||||
|
||||
|
||||
def test_rule_based_urgency_levels():
|
||||
# Младенец, сутки поиска, диагноз → критическая
|
||||
assert _rule_based_urgency(30, 3, ['РАС']) == 'критическая'
|
||||
# Подросток, 2 часа, без диагнозов → средняя
|
||||
assert _rule_based_urgency(2, 15, []) == 'средняя'
|
||||
# 10 часов, 6 лет → высокая
|
||||
assert _rule_based_urgency(10, 6, []) == 'высокая'
|
||||
|
||||
|
||||
def test_rule_based_behavior_profiles():
|
||||
data = {'has_transport': 'bike', 'cant_swim': True}
|
||||
text = _rule_based_behavior(data, ['РАС'], None)
|
||||
assert 'линейных ориентиров' in text
|
||||
assert 'велик' in text
|
||||
assert 'плавать' in text
|
||||
|
||||
|
||||
def test_rule_based_actions_ras():
|
||||
actions = _rule_based_actions({'elapsed_hours': 10, 'cant_swim': False}, ['РАС'], None)
|
||||
assert any('кинолог' in a for a in actions)
|
||||
assert any('укрыти' in a for a in actions)
|
||||
|
||||
|
||||
def test_accuracy_note_inside_radius():
|
||||
pred = {'max_distance_km': 5.4}
|
||||
fact = type('F', (), {'found_distance_km': 2.0, 'found_alive': True})()
|
||||
note = _accuracy_note(pred, fact)
|
||||
assert 'внутри прогнозного радиуса' in note
|
||||
assert '5.4' in note
|
||||
|
||||
|
||||
def test_accuracy_note_outside_radius():
|
||||
pred = {'max_distance_km': 3.0}
|
||||
fact = type('F', (), {'found_distance_km': 7.0, 'found_alive': False})()
|
||||
note = _accuracy_note(pred, fact)
|
||||
assert 'ЗА пределами' in note
|
||||
assert 'неблагоприятный' in note
|
||||
|
||||
|
||||
def test_run_model_no_llm():
|
||||
"""Ключевой контракт: прогон модели работает БЕЗ вызова LLM."""
|
||||
data = {
|
||||
'age_years': 8, 'gender': 'м', 'diagnosis_type': [],
|
||||
'has_transport': 'none', 'cant_swim': False,
|
||||
'elapsed_hours': 3, 'terrain': ['лес'], 'terrain_primary': 'лес',
|
||||
'weather': 'нет', 'season': 'лето',
|
||||
}
|
||||
result = _run_model(data)
|
||||
assert result['llm_used'] is False
|
||||
assert result['fallback_used'] is False
|
||||
assert result['max_distance_km'] > 0
|
||||
assert 'coefficients' in result
|
||||
assert result['coefficients']['base_speed'] > 0
|
||||
# Для 8 лет, 3 часа, лес, день — радиус порядка нескольких км
|
||||
assert 1 <= result['max_distance_km'] <= 15
|
||||
@@ -18,12 +18,18 @@ from services.distance_service import (
|
||||
|
||||
|
||||
class TestCalculateMaxDistance:
|
||||
"""Test the main distance calculation function."""
|
||||
"""Test the main distance calculation function.
|
||||
|
||||
Ожидания соответствуют каноничным displacement-скоростям B5 (3e2692f):
|
||||
get_base_speed 0.3–2.5 км/ч по априорам ПСО «Экстремум» (94% в 3 км).
|
||||
Контрольная точка B1 2026-09-09: bike 8yo 2h лес = 10.8 км (СП(лес)=1.0).
|
||||
"""
|
||||
|
||||
def test_boy_10_years_4_hours_forest_rain(self):
|
||||
"""
|
||||
Test case from requirements:
|
||||
Мальчик 10 лет, 4 часа, лес, дождь → ~5 км
|
||||
Мальчик 10 лет, 4 часа, лес, дождь.
|
||||
Пересчитано под канон B5: НормС(10)=1.5
|
||||
B1 2026-09-09: СП(лес)=1.0 (было 0.5): 4 * 1.5 * 1.0 * 0.8 * 1.0 * 0.8 = 3.84 km
|
||||
"""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
@@ -35,17 +41,7 @@ class TestCalculateMaxDistance:
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected calculation:
|
||||
# Time = 4 hours
|
||||
# НормС (age 10) = 4.0 km/h
|
||||
# СП (лес) = 0.5
|
||||
# СУТ (4 hours) = 1.0 - (0.05 * 4) = 0.8
|
||||
# ВВС (день) = 1.0
|
||||
# ВП (дождь) = 0.8
|
||||
# Distance = 4 * 4.0 * 0.5 * 0.8 * 1.0 * 0.8 = 5.12 km
|
||||
|
||||
assert 4.5 <= distance <= 5.5, f"Expected ~5 km, got {distance} km"
|
||||
assert distance == pytest.approx(5.12, rel=0.01)
|
||||
assert distance == pytest.approx(3.84, rel=0.01)
|
||||
|
||||
def test_young_child_short_time(self):
|
||||
"""Test for young child (3 years) with short elapsed time."""
|
||||
@@ -59,8 +55,8 @@ class TestCalculateMaxDistance:
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 1 * 2.0 * 0.5 * 0.95 * 1.0 * 1.0 = 0.95 km
|
||||
assert distance == pytest.approx(0.95, rel=0.01)
|
||||
# B1 2026-09-09 СП(лес)=1.0: 1 * 0.7 * 1.0 * 0.95 * 1.0 * 1.0 = 0.665 km
|
||||
assert distance == pytest.approx(0.665, rel=0.01)
|
||||
|
||||
def test_teenager_long_time_road(self):
|
||||
"""Test for teenager on road with longer elapsed time."""
|
||||
@@ -74,8 +70,8 @@ class TestCalculateMaxDistance:
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 6 * 5.0 * 0.8 * 0.7 * 1.0 * 1.0 = 16.8 km
|
||||
assert distance == pytest.approx(16.8, rel=0.01)
|
||||
# 6 * 2.0 * 0.8 * 0.7 * 1.0 * 1.0 = 6.72 km
|
||||
assert distance == pytest.approx(6.72, rel=0.01)
|
||||
|
||||
def test_elderly_night_swamp(self):
|
||||
"""Test for elderly person at night in swamp."""
|
||||
@@ -89,8 +85,9 @@ class TestCalculateMaxDistance:
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 3 * 3.0 * 0.2 * 0.85 * 0.5 * 0.7 = 0.54 km
|
||||
assert distance == pytest.approx(0.54, rel=0.01)
|
||||
# 3 * 1.5 * 0.2 * 0.85 * 0.0(ночь) * 0.7 = 0.0 км —
|
||||
# методичка §12.3.5: без фонаря ночью движение = 0
|
||||
assert distance == pytest.approx(0.0, rel=0.01)
|
||||
|
||||
def test_adult_heavy_rain_field(self):
|
||||
"""Test for adult in heavy rain on field."""
|
||||
@@ -104,47 +101,64 @@ class TestCalculateMaxDistance:
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 2 * 5.0 * 0.9 * 0.9 * 1.0 * 0.6 = 4.86 km
|
||||
assert distance == pytest.approx(4.86, rel=0.01)
|
||||
# B1 2026-09-09 СП(поле)=1.0: 2 * 2.5 * 1.0 * 0.9 * 1.0 * 0.6 = 2.7 km
|
||||
assert distance == pytest.approx(2.7, rel=0.01)
|
||||
|
||||
def test_bike_8yo_canonical_b5(self):
|
||||
"""Контрольная точка B1 2026-09-09: bike 8yo 2h лес = 10.8 км (СП(лес)=1.0, СУ bike ×5)."""
|
||||
case_data = {
|
||||
'age': 8,
|
||||
'elapsed_hours': 2.0,
|
||||
'terrain_primary': 'лес',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'нет',
|
||||
'has_transport': 'bike',
|
||||
'diagnosis_type': [],
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
assert distance == pytest.approx(10.8, rel=0.01)
|
||||
|
||||
|
||||
class TestBaseSpeed:
|
||||
"""Test НормС (base speed) by age."""
|
||||
"""Test НормС (base speed) by age — каноничные displacement-скорости B5."""
|
||||
|
||||
def test_infant(self):
|
||||
assert get_base_speed(1) == 1.0
|
||||
assert get_base_speed(2) == 1.0
|
||||
assert get_base_speed(1) == 0.3
|
||||
assert get_base_speed(2) == 0.3
|
||||
|
||||
def test_preschool(self):
|
||||
assert get_base_speed(3) == 2.0
|
||||
assert get_base_speed(5) == 2.0
|
||||
assert get_base_speed(3) == 0.7
|
||||
assert get_base_speed(5) == 0.7
|
||||
|
||||
def test_young_child(self):
|
||||
assert get_base_speed(6) == 3.0
|
||||
assert get_base_speed(8) == 3.0
|
||||
assert get_base_speed(6) == 1.2
|
||||
assert get_base_speed(8) == 1.2
|
||||
|
||||
def test_preteen(self):
|
||||
assert get_base_speed(10) == 4.0
|
||||
assert get_base_speed(12) == 4.0
|
||||
assert get_base_speed(10) == 1.5
|
||||
assert get_base_speed(12) == 1.5
|
||||
|
||||
def test_teenager(self):
|
||||
assert get_base_speed(13) == 5.0
|
||||
assert get_base_speed(15) == 5.0
|
||||
assert get_base_speed(16) == 5.5
|
||||
assert get_base_speed(17) == 5.5
|
||||
assert get_base_speed(13) == 2.0
|
||||
assert get_base_speed(15) == 2.0
|
||||
assert get_base_speed(16) == 2.5
|
||||
assert get_base_speed(17) == 2.5
|
||||
|
||||
def test_adult(self):
|
||||
assert get_base_speed(25) == 5.0
|
||||
assert get_base_speed(50) == 5.0
|
||||
assert get_base_speed(64) == 5.0
|
||||
assert get_base_speed(25) == 2.5
|
||||
assert get_base_speed(50) == 2.5
|
||||
assert get_base_speed(64) == 2.5
|
||||
|
||||
def test_elderly(self):
|
||||
assert get_base_speed(65) == 3.0
|
||||
assert get_base_speed(80) == 3.0
|
||||
assert get_base_speed(65) == 1.5
|
||||
assert get_base_speed(80) == 1.5
|
||||
|
||||
|
||||
class TestTerrainCoefficient:
|
||||
"""Test СП (terrain coefficient)."""
|
||||
"""Test СП (terrain coefficient). Семантика: СП=1.0 — средний лес
|
||||
(НормС — скорость смещения по среднему лесу; B1-ревью 2026-09-09)."""
|
||||
|
||||
def test_road_terrain(self):
|
||||
assert get_terrain_coefficient('дорога') == 0.8
|
||||
@@ -152,26 +166,26 @@ class TestTerrainCoefficient:
|
||||
assert get_terrain_coefficient('тропа') == 0.8
|
||||
|
||||
def test_forest_terrain(self):
|
||||
assert get_terrain_coefficient('лес') == 0.5
|
||||
assert get_terrain_coefficient('простой лес') == 0.5
|
||||
assert get_terrain_coefficient('сложный лес') == 0.25
|
||||
assert get_terrain_coefficient('густой лес') == 0.25
|
||||
assert get_terrain_coefficient('лес') == 1.0
|
||||
assert get_terrain_coefficient('простой лес') == 1.0
|
||||
assert get_terrain_coefficient('сложный лес') == 0.5
|
||||
assert get_terrain_coefficient('густой лес') == 0.5
|
||||
|
||||
def test_open_terrain(self):
|
||||
assert get_terrain_coefficient('поле') == 0.9
|
||||
assert get_terrain_coefficient('луг') == 0.9
|
||||
assert get_terrain_coefficient('поле') == 1.0
|
||||
assert get_terrain_coefficient('луг') == 1.0
|
||||
|
||||
def test_difficult_terrain(self):
|
||||
assert get_terrain_coefficient('болото') == 0.2
|
||||
assert get_terrain_coefficient('горы') == 0.3
|
||||
assert get_terrain_coefficient('овраг') == 0.3
|
||||
assert get_terrain_coefficient('болото') == 0.4
|
||||
assert get_terrain_coefficient('горы') == 0.6
|
||||
assert get_terrain_coefficient('овраг') == 0.6
|
||||
|
||||
def test_urban_terrain(self):
|
||||
assert get_terrain_coefficient('город') == 1.0
|
||||
assert get_terrain_coefficient('населённый пункт') == 1.0
|
||||
|
||||
def test_unknown_terrain(self):
|
||||
assert get_terrain_coefficient('неизвестно') == 0.5
|
||||
assert get_terrain_coefficient('неизвестно') == 1.0
|
||||
|
||||
|
||||
class TestTimeOfDayCoefficient:
|
||||
@@ -181,9 +195,11 @@ class TestTimeOfDayCoefficient:
|
||||
assert get_time_of_day_coefficient('день') == 1.0
|
||||
|
||||
def test_night(self):
|
||||
assert get_time_of_day_coefficient('ночь') == 0.5
|
||||
# Методичка §12.3.5: ночь = 0 (без фонаря движение прекращается)
|
||||
assert get_time_of_day_coefficient('ночь') == 0.0
|
||||
|
||||
def test_twilight(self):
|
||||
# Методичка §12.3.3: за час до заката скорость 50% от обычной
|
||||
assert get_time_of_day_coefficient('сумерки') == 0.5
|
||||
assert get_time_of_day_coefficient('вечер') == 0.5
|
||||
|
||||
@@ -294,8 +310,8 @@ class TestDistanceStatistics:
|
||||
terrain='лес'
|
||||
)
|
||||
|
||||
assert stats['base_speed_kmh'] == 4.0
|
||||
assert stats['terrain_coefficient'] == 0.5
|
||||
assert stats['base_speed_kmh'] == 1.5
|
||||
assert stats['terrain_coefficient'] == 1.0
|
||||
assert stats['max_distance_km'] > 0
|
||||
assert 0 <= stats['zone_probability'] <= 1.0
|
||||
|
||||
@@ -337,5 +353,5 @@ class TestFatigueCoefficient:
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# 20 * 5.0 * 0.9 * 0.3 * 1.0 * 1.0 = 27.0
|
||||
assert distance == pytest.approx(27.0, rel=0.01)
|
||||
# B1 2026-09-09 СП(поле)=1.0: 20 * 2.5 * 1.0 * 0.3 * 1.0 * 1.0 = 15.0
|
||||
assert distance == pytest.approx(15.0, rel=0.01)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""E1 (B21): тесты RBAC-ядра — сессии, блокировка, права, журнал.
|
||||
|
||||
SQLite in-memory с RBAC-таблицами; UUID/JSONB подменяются компиляторами.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
|
||||
@compiles(PG_UUID, 'sqlite')
|
||||
def _uuid_sqlite(type_, compiler, **kw):
|
||||
return 'CHAR(36)'
|
||||
|
||||
|
||||
@compiles(JSONB, 'sqlite')
|
||||
def _jsonb_sqlite(type_, compiler, **kw):
|
||||
return 'JSON'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def rbac_env():
|
||||
from backend import models as m
|
||||
from backend.database import Base
|
||||
from backend.models import (
|
||||
MchsUnit,
|
||||
Permission,
|
||||
Role,
|
||||
RolePermission,
|
||||
SecuritySetting,
|
||||
UserRole,
|
||||
)
|
||||
|
||||
engine = create_engine(
|
||||
'sqlite:///:memory:',
|
||||
connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool, # одна connection на все threads (иначе per-thread БД)
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
TestingSession = sessionmaker(bind=engine)
|
||||
db = TestingSession()
|
||||
|
||||
unit = MchsUnit(name='Минское Г(Р)ОЧС', region='Минская', kind='gor_rayon')
|
||||
rcu = MchsUnit(name='РЦУ РЧС', region='Республика', kind='rcu')
|
||||
db.add_all([unit, rcu])
|
||||
db.flush()
|
||||
|
||||
def _hash(pw: str) -> str:
|
||||
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
user = m.User(username='op1', email='op1@test.by', hashed_password=_hash('oldpass123'),
|
||||
full_name='Оператор Г(Р)ОЧС', role='operator', is_active=True,
|
||||
unit_id=unit.id)
|
||||
admin = m.User(username='root_rcu', email='root@test.by', hashed_password=_hash('rootpass123'),
|
||||
full_name='РЦУ РЧС', role='admin', is_active=True, unit_id=rcu.id)
|
||||
db.add_all([user, admin])
|
||||
db.commit()
|
||||
|
||||
roles = {}
|
||||
for name, desc in [('admin', ''), ('coordinator', ''), ('operator', ''), ('observer', '')]:
|
||||
r = Role(name=name, description=desc, is_system=True)
|
||||
db.add(r)
|
||||
roles[name] = r
|
||||
perms = {}
|
||||
for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users',
|
||||
'manage_roles', 'view_audit', 'manage_security'):
|
||||
p = Permission(code=code)
|
||||
db.add(p)
|
||||
perms[code] = p
|
||||
db.commit()
|
||||
matrix = {
|
||||
'admin': tuple(perms),
|
||||
'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'),
|
||||
'operator': ('view', 'create', 'update'),
|
||||
'observer': ('view',),
|
||||
}
|
||||
for rn, codes in matrix.items():
|
||||
for c in codes:
|
||||
db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id))
|
||||
db.add(UserRole(user_id=user.id, role_id=roles['operator'].id))
|
||||
db.commit()
|
||||
|
||||
from backend.rbac import SECURITY_DEFAULTS
|
||||
for k, v in SECURITY_DEFAULTS.items():
|
||||
db.add(SecuritySetting(key=k, value=v))
|
||||
db.commit()
|
||||
|
||||
from backend.main import app
|
||||
from backend.routers import auth as auth_router
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def override_get_db():
|
||||
s = TestingSession()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
app.dependency_overrides[auth_router.get_db] = override_get_db
|
||||
client = TestClient(app)
|
||||
|
||||
yield {'db': db, 'client': client, 'user': user, 'admin': admin}
|
||||
|
||||
app.dependency_overrides.pop(auth_router.get_db, None)
|
||||
|
||||
|
||||
def _login(client, username: str, password: str):
|
||||
return client.post('/api/v1/auth/login', data={'username': username, 'password': password})
|
||||
|
||||
|
||||
class TestLogin:
|
||||
def test_login_creates_session_and_token(self, rbac_env):
|
||||
r = _login(rbac_env['client'], 'op1', 'oldpass123')
|
||||
assert r.status_code == 200
|
||||
assert 'access_token' in r.json()
|
||||
|
||||
def test_wrong_password_fails(self, rbac_env):
|
||||
assert _login(rbac_env['client'], 'op1', 'wrong').status_code == 401
|
||||
|
||||
def test_lockout_after_max_failures(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
for _ in range(5):
|
||||
c.post('/api/v1/auth/login', data={'username': 'op1', 'password': 'wrong'})
|
||||
r = _login(c, 'op1', 'oldpass123')
|
||||
assert r.status_code == 403
|
||||
assert 'locked' in r.json()['detail'].lower()
|
||||
|
||||
def test_me_has_permissions(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
|
||||
me = c.get('/api/v1/auth/me', headers={'Authorization': f'Bearer {tok}'}).json()
|
||||
assert me['permissions'] == ['create', 'update', 'view']
|
||||
assert me['unit_id'] is not None
|
||||
|
||||
def test_admin_permissions_full(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
tok = _login(c, 'root_rcu', 'rootpass123').json()['access_token']
|
||||
me = c.get('/api/v1/auth/me', headers={'Authorization': f'Bearer {tok}'}).json()
|
||||
assert 'manage_users' in me['permissions']
|
||||
assert len(me['permissions']) == 9
|
||||
|
||||
def test_logout_revokes_session(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
|
||||
h = {'Authorization': f'Bearer {tok}'}
|
||||
assert c.get('/api/v1/auth/me', headers=h).status_code == 200
|
||||
c.post('/api/v1/auth/logout', headers=h)
|
||||
assert c.get('/api/v1/auth/me', headers=h).status_code == 401
|
||||
|
||||
def test_change_password_revokes_sessions(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
|
||||
h = {'Authorization': f'Bearer {tok}'}
|
||||
r = c.post('/api/v1/auth/change-password', headers=h,
|
||||
json={'current_password': 'oldpass123', 'new_password': 'newpass456'})
|
||||
assert r.status_code == 200
|
||||
assert c.get('/api/v1/auth/me', headers=h).status_code == 401
|
||||
assert _login(c, 'op1', 'newpass456').status_code == 200
|
||||
|
||||
def test_short_password_rejected(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
|
||||
h = {'Authorization': f'Bearer {tok}'}
|
||||
r = c.post('/api/v1/auth/change-password', headers=h,
|
||||
json={'current_password': 'oldpass123', 'new_password': 'abc'})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_auth_events_logged(self, rbac_env):
|
||||
from backend.models import AuthEvent
|
||||
c = rbac_env['client']
|
||||
_login(c, 'op1', 'oldpass123')
|
||||
_login(c, 'op1', 'WRONG')
|
||||
types = {e.event_type for e in rbac_env['db'].query(AuthEvent).all()}
|
||||
assert 'login' in types and 'login_failed' in types
|
||||
@@ -0,0 +1,253 @@
|
||||
"""E2 (B21): тесты аудита и админ-панели пользователей.
|
||||
|
||||
SQLite in-memory; UUID/JSONB/ARRAY компилируются; PYTEST-fallback отключён —
|
||||
реальные токены с RBAC-правами.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import json as _json
|
||||
import bcrypt
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sqlalchemy # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402
|
||||
from sqlalchemy.ext.compiler import compiles # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
|
||||
@compiles(PG_UUID, 'sqlite')
|
||||
def _uuid_sqlite(type_, compiler, **kw):
|
||||
return 'CHAR(36)'
|
||||
|
||||
|
||||
@compiles(JSONB, 'sqlite')
|
||||
def _jsonb_sqlite(type_, compiler, **kw):
|
||||
return 'JSON'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_env():
|
||||
from backend import models as m
|
||||
from backend.database import Base
|
||||
from backend.models import (
|
||||
MchsUnit,
|
||||
Permission,
|
||||
Role,
|
||||
RolePermission,
|
||||
SecuritySetting,
|
||||
UserRole,
|
||||
)
|
||||
|
||||
engine = create_engine(
|
||||
'sqlite:///:memory:',
|
||||
connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
TestingSession = sessionmaker(bind=engine)
|
||||
db = TestingSession()
|
||||
|
||||
rcu = MchsUnit(name='РЦУ РЧС', region='Республика', kind='rcu')
|
||||
db.add(rcu)
|
||||
db.flush()
|
||||
oblast = MchsUnit(name='Минское ОУМЧС', region='Минская', kind='oblast', parent_id=rcu.id)
|
||||
db.add(oblast)
|
||||
db.flush() # flush ДО создания детей: иначе parent_id терялся (oblast.id был None)
|
||||
rayon1 = MchsUnit(name='Минское Г(Р)ОЧС', region='Минская', kind='gor_rayon', parent_id=oblast.id)
|
||||
rayon2 = MchsUnit(name='Борисовское Г(Р)ОЧС', region='Минская', kind='gor_rayon', parent_id=oblast.id)
|
||||
other_oblast = MchsUnit(name='Гомельское ОУМЧС', region='Гомельская', kind='oblast', parent_id=rcu.id)
|
||||
db.add_all([rayon1, rayon2, other_oblast])
|
||||
db.flush()
|
||||
|
||||
def _hash(pw: str) -> str:
|
||||
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
root = m.User(username='root_rcu', email='root@test.by',
|
||||
hashed_password=_hash('rootpass123'), full_name='РЦУ РЧС',
|
||||
role='admin', is_active=True, unit_id=rcu.id)
|
||||
coord = m.User(username='obl_coord', email='obl@test.by',
|
||||
hashed_password=_hash('oblpass123'), full_name='ОУМЧС Минск',
|
||||
role='coordinator', is_active=True, unit_id=oblast.id)
|
||||
other_coord = m.User(username='gomel_coord', email='g@test.by',
|
||||
hashed_password=_hash('gpass123'), full_name='Гомель ОУМЧС',
|
||||
role='coordinator', is_active=True, unit_id=other_oblast.id)
|
||||
db.add_all([root, coord, other_coord])
|
||||
db.commit()
|
||||
|
||||
roles = {}
|
||||
for name in ('admin', 'coordinator', 'operator', 'observer'):
|
||||
r = Role(name=name, is_system=True)
|
||||
db.add(r)
|
||||
roles[name] = r
|
||||
perms = {}
|
||||
for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users',
|
||||
'manage_roles', 'view_audit', 'manage_security'):
|
||||
p = Permission(code=code)
|
||||
db.add(p)
|
||||
perms[code] = p
|
||||
db.commit()
|
||||
matrix = {
|
||||
'admin': tuple(perms),
|
||||
'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'),
|
||||
'operator': ('view', 'create', 'update'),
|
||||
'observer': ('view',),
|
||||
}
|
||||
for rn, codes in matrix.items():
|
||||
for c in codes:
|
||||
db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id))
|
||||
db.add(UserRole(user_id=root.id, role_id=roles['admin'].id))
|
||||
db.add(UserRole(user_id=coord.id, role_id=roles['coordinator'].id))
|
||||
db.add(UserRole(user_id=other_coord.id, role_id=roles['coordinator'].id))
|
||||
db.commit()
|
||||
|
||||
from backend.rbac import SECURITY_DEFAULTS
|
||||
for k, v in SECURITY_DEFAULTS.items():
|
||||
db.add(SecuritySetting(key=k, value=v))
|
||||
db.commit()
|
||||
|
||||
from backend.main import app
|
||||
from backend.routers import auth as auth_router
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def override_get_db():
|
||||
s = TestingSession()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
app.dependency_overrides[auth_router.get_db] = override_get_db
|
||||
client = TestClient(app)
|
||||
|
||||
def login(username, password):
|
||||
r = client.post('/api/v1/auth/login', data={'username': username, 'password': password})
|
||||
return r.json()['access_token']
|
||||
|
||||
yield {
|
||||
'db': db, 'client': client,
|
||||
'root_token': login('root_rcu', 'rootpass123'),
|
||||
'coord_token': login('obl_coord', 'oblpass123') if False else login('obl_coord', 'oblpass123') if False else None,
|
||||
'coord': coord, 'root': root, 'units': {'rcu': rcu, 'oblast': oblast,
|
||||
'rayon1': rayon1, 'rayon2': rayon2,
|
||||
'other': other_oblast},
|
||||
'login': login,
|
||||
}
|
||||
|
||||
app.dependency_overrides.pop(auth_router.get_db, None)
|
||||
|
||||
|
||||
class TestUserManagement:
|
||||
def test_admin_lists_all_users(self, admin_env):
|
||||
r = admin_env['client'].get('/api/v1/admin/users',
|
||||
headers={'Authorization': f"Bearer {admin_env['root_token']}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()['total'] == 3
|
||||
|
||||
def test_admin_creates_operator(self, admin_env):
|
||||
r = admin_env['client'].post('/api/v1/admin/users',
|
||||
headers={'Authorization': f"Bearer {admin_env['root_token']}"},
|
||||
json={
|
||||
'username': 'newop', 'email': 'newop@test.by',
|
||||
'password': 'newpass123', 'role_name': 'operator',
|
||||
'unit_id': str(admin_env['units']['rayon1'].id),
|
||||
'full_name': 'Новый оператор',
|
||||
})
|
||||
assert r.status_code == 201, r.json()
|
||||
# В аудит записано
|
||||
audit = admin_env['client'].get('/api/v1/admin/users/audit',
|
||||
headers={'Authorization': f"Bearer {admin_env['root_token']}"})
|
||||
types = [e['event_type'] for e in audit.json()['items']]
|
||||
assert 'user_create' in types
|
||||
|
||||
def test_created_user_must_change_password(self, admin_env):
|
||||
c = admin_env['client']
|
||||
tok = c.post('/api/v1/auth/login', data={'username': 'root_rcu', 'password': 'rootpass123'}).json()['access_token']
|
||||
from backend.audit import visible_unit_ids
|
||||
scope = visible_unit_ids(admin_env['db'], admin_env['root'])
|
||||
r = c.post('/api/v1/admin/users', headers={'Authorization': f'Bearer {tok}'},
|
||||
json={'username': 'op2', 'email': 'op2@test.by', 'password': 'temppass123',
|
||||
'role_name': 'operator', 'unit_id': str(admin_env['units']['rayon1'].id)})
|
||||
import json as _json
|
||||
with open('/tmp/e2-debug.json', 'w') as fh:
|
||||
_json.dump({'scope': [str(s) for s in (scope or [])], 'unit_id': str(admin_env['units']['rayon1'].id),
|
||||
'status': r.status_code, 'body': r.json()}, fh)
|
||||
assert r.status_code == 201
|
||||
# Созданный пользователь входит и видит must_change_password
|
||||
op2_tok = c.post('/api/v1/auth/login', data={'username': 'op2', 'password': 'temppass123'}).json()['access_token']
|
||||
me = c.get('/api/v1/auth/me', headers={'Authorization': f'Bearer {op2_tok}'}).json()
|
||||
assert me['must_change_password'] is True
|
||||
|
||||
def test_admin_resets_password(self, admin_env):
|
||||
c = admin_env['client']
|
||||
tok = c.post('/api/v1/auth/login', data={'username': 'root_rcu', 'password': 'rootpass123'}).json()['access_token']
|
||||
users = c.get('/api/v1/admin/users', headers={'Authorization': f'Bearer {tok}'}).json()['items']
|
||||
target = next(u for u in users if u['username'] == 'obl_coord')
|
||||
r = c.patch(f"/api/v1/admin/users/{target['id']}", headers={'Authorization': f'Bearer {tok}'},
|
||||
json={'reset_password': 'brandnew99'})
|
||||
assert r.status_code == 200
|
||||
login_ok = c.post('/api/v1/auth/login', data={'username': 'obl_coord', 'password': 'brandnew99'})
|
||||
assert login_ok.status_code == 200
|
||||
|
||||
def test_admin_locks_user(self, admin_env):
|
||||
c = admin_env['client']
|
||||
tok = c.post('/api/v1/auth/login', data={'username': 'root_rcu', 'password': 'rootpass123'}).json()['access_token']
|
||||
users = c.get('/api/v1/admin/users', headers={'Authorization': f'Bearer {tok}'}).json()['items']
|
||||
target = next(u for u in users if u['username'] == 'obl_coord')
|
||||
r = c.patch(f"/api/v1/admin/users/{target['id']}", headers={'Authorization': f'Bearer {tok}'},
|
||||
json={'status': 'disabled'})
|
||||
import json as _json
|
||||
with open('/tmp/e2-patch-debug.json', 'w') as fh:
|
||||
_json.dump({'status': r.status_code, 'body': r.json(), 'target': target}, fh, default=str)
|
||||
assert r.status_code == 200
|
||||
# Заблокированный не входит
|
||||
login = c.post('/api/v1/auth/login', data={'username': 'obl_coord', 'password': 'oblpass123'})
|
||||
assert login.status_code == 403
|
||||
|
||||
def test_coordinator_scope_limited(self, admin_env):
|
||||
"""ОУМЧС-координатор видит пользователей только своего юнита+подчинённых."""
|
||||
coord_tok = admin_env['login']('obl_coord', 'oblpass123')
|
||||
r = admin_env['client'].get('/api/v1/admin/users',
|
||||
headers={'Authorization': f'Bearer {coord_tok}'})
|
||||
assert r.status_code == 200
|
||||
usernames = {u['username'] for u in r.json()['items']}
|
||||
# Свои: obl_coord; РЦУ РЧС-админ НЕ виден (другой юнит, не подчинённый)
|
||||
assert 'obl_coord' in usernames
|
||||
assert 'root_rcu' not in usernames
|
||||
|
||||
def test_coordinator_cannot_manage_other_oblast(self, admin_env):
|
||||
c = admin_env['client']
|
||||
coord_tok = admin_env['login']('obl_coord', 'oblpass123')
|
||||
# Пытается сбросить пароль Гомельскому координатору — 403/404
|
||||
users = c.get('/api/v1/admin/users', headers={'Authorization': f'Bearer {coord_tok}'}).json()['items']
|
||||
gomel = [u for u in users if u['username'] == 'gomel_coord']
|
||||
if not gomel:
|
||||
assert True # скоуп скрыл — уже хорошо
|
||||
return
|
||||
r = c.patch(f"/api/v1/admin/users/{gomel[0]['id']}", headers={'Authorization': f'Bearer {coord_tok}'},
|
||||
json={'status': 'locked'})
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_without_permission_403(self, admin_env):
|
||||
# Создадим оператора и попробуем от его имени управлять пользователями
|
||||
c = admin_env['client']
|
||||
root_tok = c.post('/api/v1/auth/login', data={'username': 'root_rcu', 'password': 'rootpass123'}).json()['access_token']
|
||||
c.post('/api/v1/admin/users', headers={'Authorization': f'Bearer {root_tok}'},
|
||||
json={'username': 'simpleop', 'email': 'sop@test.by', 'password': 'simplepass1',
|
||||
'role_name': 'operator', 'unit_id': str(admin_env['units']['rayon1'].id)})
|
||||
op_tok = c.post('/api/v1/auth/login', data={'username': 'simpleop', 'password': 'simplepass1'}).json()['access_token']
|
||||
r = c.get('/api/v1/admin/users', headers={'Authorization': f'Bearer {op_tok}'})
|
||||
assert r.status_code == 403
|
||||
@@ -0,0 +1,252 @@
|
||||
"""E3 (B21): тесты операций мультипоиска — статусная машина, скоуп, дашборд."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sqlalchemy # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402
|
||||
from sqlalchemy.ext.compiler import compiles # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
|
||||
@compiles(PG_UUID, 'sqlite')
|
||||
def _uuid_sqlite(type_, compiler, **kw):
|
||||
return 'CHAR(36)'
|
||||
|
||||
|
||||
@compiles(JSONB, 'sqlite')
|
||||
def _jsonb_sqlite(type_, compiler, **kw):
|
||||
return 'JSON'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ops_env():
|
||||
from backend import models as m
|
||||
from backend.database import Base
|
||||
from backend.models import (
|
||||
MchsUnit,
|
||||
Permission,
|
||||
Role,
|
||||
RolePermission,
|
||||
SecuritySetting,
|
||||
UserRole,
|
||||
)
|
||||
|
||||
engine = create_engine('sqlite:///:memory:', connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
|
||||
rcu = MchsUnit(name='РЦУ РЧС', kind='rcu')
|
||||
db.add(rcu)
|
||||
db.flush()
|
||||
oblast = MchsUnit(name='Минское ОУМЧС', kind='oblast', parent_id=rcu.id)
|
||||
db.add(oblast)
|
||||
db.flush()
|
||||
rayon = MchsUnit(name='Минское Г(Р)ОЧС', kind='gor_rayon', parent_id=oblast.id)
|
||||
db.add(rayon)
|
||||
db.flush()
|
||||
other_rayon = MchsUnit(name='Гомельское Г(Р)ОЧС', kind='gor_rayon', parent_id=rcu.id)
|
||||
db.add(other_rayon)
|
||||
db.flush()
|
||||
|
||||
def _hash(pw):
|
||||
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
root = m.User(username='root', email='r@t.by', hashed_password=_hash('rootpass123'),
|
||||
role='admin', is_active=True, unit_id=rcu.id)
|
||||
coord = m.User(username='coord', email='c@t.by', hashed_password=_hash('coordpass123'),
|
||||
role='coordinator', is_active=True, unit_id=oblast.id)
|
||||
other_op = m.User(username='other', email='o@t.by', hashed_password=_hash('otherpass123'),
|
||||
role='operator', is_active=True, unit_id=other_rayon.id)
|
||||
db.add_all([root, coord, other_op])
|
||||
db.commit()
|
||||
|
||||
case1 = m.Case(age_years=10, gender='м', status='active')
|
||||
case2 = m.Case(age_years=8, gender='ж', status='active')
|
||||
case3 = m.Case(age_years=12, gender='м', status='active')
|
||||
db.add_all([case1, case2, case3])
|
||||
db.commit()
|
||||
|
||||
roles = {}
|
||||
for name in ('admin', 'coordinator', 'operator', 'observer'):
|
||||
r = Role(name=name, is_system=True)
|
||||
db.add(r)
|
||||
roles[name] = r
|
||||
perms = {}
|
||||
for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users',
|
||||
'manage_roles', 'view_audit', 'manage_security'):
|
||||
p = Permission(code=code)
|
||||
db.add(p)
|
||||
perms[code] = p
|
||||
db.commit()
|
||||
matrix = {
|
||||
'admin': tuple(perms),
|
||||
'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'),
|
||||
'operator': ('view', 'create', 'update'),
|
||||
'observer': ('view',),
|
||||
}
|
||||
for rn, codes in matrix.items():
|
||||
for c in codes:
|
||||
db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id))
|
||||
db.add(UserRole(user_id=root.id, role_id=roles['admin'].id))
|
||||
db.add(UserRole(user_id=coord.id, role_id=roles['coordinator'].id))
|
||||
db.add(UserRole(user_id=other_op.id, role_id=roles['operator'].id))
|
||||
db.commit()
|
||||
|
||||
from backend.rbac import SECURITY_DEFAULTS
|
||||
for k, v in SECURITY_DEFAULTS.items():
|
||||
db.add(SecuritySetting(key=k, value=v))
|
||||
db.commit()
|
||||
|
||||
from backend.main import app
|
||||
from backend.routers import auth as auth_router
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def override():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[auth_router.get_db] = override
|
||||
client = TestClient(app)
|
||||
|
||||
def login(u, p):
|
||||
return client.post('/api/v1/auth/login', data={'username': u, 'password': p}).json()['access_token']
|
||||
|
||||
yield {'db': db, 'client': client,
|
||||
'root': login('root', 'rootpass123'),
|
||||
'coord': login('coord', 'coordpass123'),
|
||||
'other': login('other', 'otherpass123'),
|
||||
'cases': {'c1': case1, 'c2': case2, 'c3': case3},
|
||||
'units': {'rcu': rcu, 'oblast': oblast, 'rayon': rayon, 'other': other_rayon}}
|
||||
|
||||
app.dependency_overrides.pop(auth_router.get_db, None)
|
||||
|
||||
|
||||
def _create(client, token, title, case_id, unit_id=None):
|
||||
body = {'title': title, 'case_id': case_id}
|
||||
if unit_id:
|
||||
body['unit_id'] = unit_id
|
||||
return client.post('/api/v1/operations', headers={'Authorization': f'Bearer {token}'}, json=body)
|
||||
|
||||
|
||||
class TestCreateOperation:
|
||||
def test_admin_creates_with_explicit_unit(self, ops_env):
|
||||
r = ops_env['client'].post(
|
||||
'/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['root']}"},
|
||||
json={'title': 'Поиск: Иванов', 'case_id': str(ops_env['cases']['c1'].id),
|
||||
'unit_id': str(ops_env['units']['rayon'].id)})
|
||||
assert r.status_code == 201
|
||||
assert r.json()['status'] == 'active'
|
||||
|
||||
def test_operator_defaults_to_own_unit(self, ops_env):
|
||||
r = ops_env['client'].post(
|
||||
'/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['coord']}"},
|
||||
json={'title': 'Поиск 2', 'case_id': str(ops_env['cases']['c2'].id)})
|
||||
assert r.status_code == 201
|
||||
assert r.json()['unit_id'] == str(ops_env['units']['oblast'].id)
|
||||
|
||||
def test_duplicate_case_rejected(self, ops_env):
|
||||
c = ops_env['client']
|
||||
tok = ops_env['root']
|
||||
body = {'title': 'Дубль', 'case_id': str(ops_env['cases']['c1'].id),
|
||||
'unit_id': str(ops_env['units']['rayon'].id)}
|
||||
c.post('/api/v1/operations', headers={'Authorization': f'Bearer {tok}'}, json=body)
|
||||
r = c.post('/api/v1/operations', headers={'Authorization': f'Bearer {tok}'}, json=body)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_cross_unit_create_403(self, ops_env):
|
||||
"""Координатор Минска не может создать операцию под Гомельским Г(Р)ОЧС."""
|
||||
r = ops_env['client'].post(
|
||||
'/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['coord']}"},
|
||||
json={'title': 'Чужой', 'case_id': str(ops_env['cases']['c2'].id),
|
||||
'unit_id': str(ops_env['units']['other'].id)})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
class TestScope:
|
||||
def test_coord_sees_only_own_oblast(self, ops_env):
|
||||
c = ops_env['client']
|
||||
c.post('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['coord']}"},
|
||||
json={'title': 'Минская', 'case_id': str(ops_env['cases']['c1'].id)})
|
||||
c.post('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['other']}"},
|
||||
json={'title': 'Гомельская', 'case_id': str(ops_env['cases']['c2'].id)})
|
||||
r = c.get('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['coord']}"})
|
||||
titles = {o['title'] for o in r.json()['items']}
|
||||
assert 'Минская' in titles
|
||||
assert 'Гомельская' not in titles
|
||||
|
||||
def test_admin_sees_all(self, ops_env):
|
||||
c = ops_env['client']
|
||||
c.post('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['coord']}"},
|
||||
json={'title': 'Минская', 'case_id': str(ops_env['cases']['c1'].id)})
|
||||
c.post('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['other']}"},
|
||||
json={'title': 'Гомельская', 'case_id': str(ops_env['cases']['c2'].id)})
|
||||
r = c.get('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['root']}"})
|
||||
assert r.json()['total'] == 2
|
||||
|
||||
|
||||
class TestStateMachine:
|
||||
def _create(self, client, token, case_id):
|
||||
return client.post('/api/v1/operations', headers={'Authorization': f'Bearer {token}'},
|
||||
json={'title': 'S', 'case_id': case_id})
|
||||
|
||||
def test_active_to_paused_and_back(self, ops_env):
|
||||
c = ops_env['client']
|
||||
oid = self._create(c, ops_env['root'], str(ops_env['cases']['c1'].id)).json()['id']
|
||||
h = {'Authorization': f"Bearer {ops_env['root']}"}
|
||||
assert c.patch(f'/api/v1/operations/{oid}', headers=h,
|
||||
json={'status': 'paused'}).json()['status'] == 'paused'
|
||||
assert c.patch(f'/api/v1/operations/{oid}', headers=h,
|
||||
json={'status': 'active'}).json()['status'] == 'active'
|
||||
|
||||
def test_invalid_transition_rejected(self, ops_env):
|
||||
c = ops_env['client']
|
||||
oid = self._create(c, ops_env['root'], str(ops_env['cases']['c1'].id)).json()['id']
|
||||
h = {'Authorization': f"Bearer {ops_env['root']}"}
|
||||
assert c.patch(f'/api/v1/operations/{oid}', headers=h,
|
||||
json={'status': 'completed'}).status_code == 200 # active → completed ок
|
||||
assert c.patch(f'/api/v1/operations/{oid}', headers=h,
|
||||
json={'status': 'active'}).status_code == 400 # completed → active запрещён
|
||||
|
||||
def test_completed_sets_closed_at(self, ops_env):
|
||||
c = ops_env['client']
|
||||
oid = self._create(c, ops_env['root'], str(ops_env['cases']['c1'].id)).json()['id']
|
||||
h = {'Authorization': f"Bearer {ops_env['root']}"}
|
||||
c.patch(f'/api/v1/operations/{oid}', headers=h, json={'status': 'paused'})
|
||||
r = c.patch(f'/api/v1/operations/{oid}', headers=h, json={'status': 'completed'})
|
||||
assert r.json()['closed_at'] is not None
|
||||
|
||||
def test_summary_counts(self, ops_env):
|
||||
c = ops_env['client']
|
||||
self._create(c, ops_env['root'], str(ops_env['cases']['c1'].id))
|
||||
r = c.get('/api/v1/operations/summary', headers={'Authorization': f"Bearer {ops_env['root']}"})
|
||||
d = r.json()
|
||||
assert d['active'] >= 1 and d['total'] >= 1
|
||||
|
||||
def test_audit_logged(self, ops_env):
|
||||
from backend.models import AuditEvent
|
||||
c = ops_env['client']
|
||||
self._create(c, ops_env['root'], str(ops_env['cases']['c1'].id))
|
||||
types = {e.event_type for e in ops_env['db'].query(AuditEvent).all()}
|
||||
assert 'operation_create' in types
|
||||
|
||||
def test_cross_scope_access_403(self, ops_env):
|
||||
c = ops_env['client']
|
||||
oid = self._create(c, ops_env['other'], str(ops_env['cases']['c3'].id)).json()['id']
|
||||
assert c.get(f'/api/v1/operations/{oid}',
|
||||
headers={'Authorization': f"Bearer {ops_env['coord']}"}).status_code == 403
|
||||
@@ -0,0 +1,203 @@
|
||||
"""E4 (B21): тесты отправки зон в КОНТУР — build payload, мок httpx, аудиты."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sqlalchemy # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402
|
||||
from sqlalchemy.ext.compiler import compiles # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
|
||||
@compiles(PG_UUID, 'sqlite')
|
||||
def _uuid_sqlite(type_, compiler, **kw):
|
||||
return 'CHAR(36)'
|
||||
|
||||
|
||||
@compiles(JSONB, 'sqlite')
|
||||
def _jsonb_sqlite(type_, compiler, **kw):
|
||||
return 'JSON'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
ANALYSIS = {
|
||||
'lat': 53.9, 'lon': 27.56, 'max_distance_km': 5.4,
|
||||
'analyzed_at': '2026-09-09T12:00:00+00:00',
|
||||
'primary_zones': [
|
||||
{'priority': 1, 'direction': 'NE', 'distance': 2.0, 'reason': 'лес+вода'},
|
||||
{'priority': 2, 'direction': 'E', 'distance': 2.0, 'reason': 'лес'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def contour_env(monkeypatch):
|
||||
from backend import models as m
|
||||
from backend.database import Base
|
||||
from backend.models import (MchsUnit, Permission, Role, RolePermission,
|
||||
SecuritySetting, SearchOperation, UserRole)
|
||||
|
||||
engine = create_engine('sqlite:///:memory:', connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
|
||||
rcu = MchsUnit(name='РЦУ РЧС', kind='rcu')
|
||||
db.add(rcu); db.flush()
|
||||
|
||||
def _hash(pw): return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
root = m.User(username='root', email='r@t.by', hashed_password=_hash('rootpass123'),
|
||||
role='admin', is_active=True, unit_id=rcu.id)
|
||||
db.add(root); db.commit()
|
||||
|
||||
case = m.Case(age_years=10, gender='м', status='analyzed')
|
||||
db.add(case); db.flush()
|
||||
case.analysis_log = dict(ANALYSIS)
|
||||
db.commit()
|
||||
|
||||
contour_op_id = '22222222-2222-4222-8222-000000000001' # как в КОНТУРе e2e
|
||||
|
||||
op = SearchOperation(title='Тестовая операция', case_id=case.id, status='active',
|
||||
unit_id=rcu.id, created_by=root.id,
|
||||
contour_operation_id=__import__('uuid').UUID(contour_op_id))
|
||||
db.add(op); db.commit()
|
||||
|
||||
roles = {}
|
||||
for name in ('admin', 'coordinator', 'operator', 'observer'):
|
||||
r = Role(name=name, is_system=True); db.add(r); roles[name] = r
|
||||
perms = {}
|
||||
for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users',
|
||||
'manage_roles', 'view_audit', 'manage_security'):
|
||||
p = Permission(code=code); db.add(p); perms[code] = p
|
||||
db.commit()
|
||||
for rn, codes in {'admin': tuple(perms), 'observer': ('view',)}.items():
|
||||
for c in codes:
|
||||
db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id))
|
||||
db.add(UserRole(user_id=root.id, role_id=roles['admin'].id))
|
||||
db.commit()
|
||||
|
||||
from backend.rbac import SECURITY_DEFAULTS
|
||||
for k, v in SECURITY_DEFAULTS.items():
|
||||
db.add(SecuritySetting(key=k, value=v))
|
||||
db.commit()
|
||||
|
||||
from backend.main import app
|
||||
from backend.routers import auth as auth_router
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def override():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[auth_router.get_db] = override
|
||||
client = TestClient(app)
|
||||
tok = client.post('/api/v1/auth/login', data={'username': 'root', 'password': 'rootpass123'}).json()['access_token']
|
||||
|
||||
# env для contour
|
||||
monkeypatch.setenv('CONTOUR_API_URL', 'http://contour-mock:8000')
|
||||
monkeypatch.setenv('CONTOUR_TOKEN', 'test-token')
|
||||
|
||||
yield {'db': db, 'client': client, 'token': tok, 'op': op,
|
||||
'case': case, 'contour_op_id': contour_op_id}
|
||||
|
||||
app.dependency_overrides.pop(auth_router.get_db, None)
|
||||
|
||||
|
||||
class TestSendToContour:
|
||||
def test_missing_contour_id_400(self, contour_env):
|
||||
from backend.models import SearchOperation
|
||||
op = contour_env['op']
|
||||
op.contour_operation_id = None
|
||||
contour_env['db'].commit()
|
||||
r = contour_env['client'].post(
|
||||
f'/api/v1/operations/{op.id}/send-to-contour',
|
||||
headers={'Authorization': f"Bearer {contour_env['token']}"})
|
||||
assert r.status_code == 400
|
||||
assert 'contour_operation_id' in r.json()['detail']
|
||||
|
||||
def test_send_builds_valid_b20_payload(self, contour_env, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
captured['url'] = url
|
||||
captured['json'] = json
|
||||
captured['headers'] = headers
|
||||
|
||||
class R:
|
||||
status_code = 200
|
||||
text = ''
|
||||
def json(self):
|
||||
return {'status': 'applied', 'zones_total': 2, 'accepted': 2}
|
||||
return R()
|
||||
|
||||
import httpx
|
||||
monkeypatch.setattr(httpx, 'post', fake_post)
|
||||
|
||||
r = contour_env['client'].post(
|
||||
f"/api/v1/operations/{contour_env['op'].id}/send-to-contour",
|
||||
headers={'Authorization': f"Bearer {contour_env['token']}"})
|
||||
assert r.status_code == 200, r.json()
|
||||
body = r.json()
|
||||
assert body['sent_zones'] == 2
|
||||
assert body['contour_operation_id'] == contour_env['contour_op_id']
|
||||
|
||||
payload = captured['json']
|
||||
assert payload['schema_version'] == '1.0'
|
||||
assert payload['max_distance_km'] == 5.4
|
||||
assert len(payload['zones']) == 2
|
||||
z1 = payload['zones'][0]
|
||||
assert z1['zone_id'] == 'vec:NE:2000m'
|
||||
assert z1['geom']['type'] == 'Polygon'
|
||||
ring = z1['geom']['coordinates'][0]
|
||||
assert ring[0] == ring[-1] # замкнут
|
||||
assert 0 < z1['probability'] <= 1
|
||||
# operation_id в query
|
||||
assert contour_env['contour_op_id'] in captured['url']
|
||||
# auth на КОНТУР
|
||||
assert captured['headers']['Authorization'] == 'Bearer test-token'
|
||||
|
||||
def test_send_failure_audited(self, contour_env, monkeypatch):
|
||||
import httpx
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
class R:
|
||||
status_code = 400
|
||||
text = 'bad zones'
|
||||
def json(self):
|
||||
return {'detail': 'bad zones'}
|
||||
return R()
|
||||
|
||||
monkeypatch.setattr(httpx, 'post', fake_post)
|
||||
r = contour_env['client'].post(
|
||||
f"/api/v1/operations/{contour_env['op'].id}/send-to-contour",
|
||||
headers={'Authorization': f"Bearer {contour_env['token']}"})
|
||||
assert r.status_code == 502
|
||||
from backend.models import AuditEvent
|
||||
types = {e.event_type for e in contour_env['db'].query(AuditEvent).all()}
|
||||
assert 'contour_send_failed' in types
|
||||
|
||||
def test_sector_polygon_geometry(self, contour_env):
|
||||
from backend.routers.contour import _sector_polygon
|
||||
poly = _sector_polygon(53.9, 27.56, 2.0, 'N')
|
||||
ring = poly['coordinates'][0]
|
||||
assert poly['type'] == 'Polygon'
|
||||
assert ring[0] == ring[-1]
|
||||
# Север: lat растёт
|
||||
lats = [p[1] for p in ring[1:-1]]
|
||||
assert max(lats) > 53.9
|
||||
# Дуга ~2 км
|
||||
assert max(lats) < 53.9 + (2.0 / 111.0) * 1.05
|
||||
@@ -22,6 +22,7 @@ from services.geo_service import (
|
||||
build_search_zones,
|
||||
DIRECTIONS,
|
||||
SEARCH_DISTANCES,
|
||||
_build_search_distances,
|
||||
CACHE_DIR
|
||||
)
|
||||
|
||||
@@ -350,7 +351,9 @@ class TestBuildSearchZones:
|
||||
zones = await build_search_zones(53.9, 27.5, {})
|
||||
|
||||
distances_found = set(z.distance_km for z in zones)
|
||||
expected_distances = set(d / 1000 for d in SEARCH_DISTANCES)
|
||||
# build_search_zones derives distances from max_distance_km (default 3.0 km),
|
||||
# not from the legacy SEARCH_DISTANCES constant.
|
||||
expected_distances = set(d / 1000 for d in _build_search_distances(3.0))
|
||||
assert distances_found == expected_distances
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Тесты завершения поиска (кнопка «Завершить» → архив + модель + completed)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sqlalchemy # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402
|
||||
from sqlalchemy.ext.compiler import compiles # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
|
||||
@compiles(PG_UUID, 'sqlite')
|
||||
def _uuid_sqlite(type_, compiler, **kw):
|
||||
return 'CHAR(36)'
|
||||
|
||||
|
||||
@compiles(JSONB, 'sqlite')
|
||||
def _jsonb_sqlite(type_, compiler, **kw):
|
||||
return 'JSON'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def complete_env(monkeypatch):
|
||||
from backend import models as m
|
||||
from backend.database import Base
|
||||
from backend.models import (MchsUnit, Permission, Role, RolePermission,
|
||||
SecuritySetting, SearchOperation, UserRole)
|
||||
|
||||
engine = create_engine('sqlite:///:memory:', connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
|
||||
rcu = MchsUnit(name='РЦУ РЧС', kind='rcu')
|
||||
db.add(rcu); db.flush()
|
||||
|
||||
def _hash(pw): return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
root = m.User(username='root', email='r@t.by', hashed_password=_hash('rootpass123'),
|
||||
role='admin', is_active=True, unit_id=rcu.id)
|
||||
db.add(root); db.commit()
|
||||
|
||||
case = m.Case(age_years=10, gender='м', status='analyzed',
|
||||
elapsed_hours=3.0, tnp_lat=53.9, tnp_lon=27.56)
|
||||
db.add(case); db.flush(); db.commit()
|
||||
|
||||
op = SearchOperation(title='Операция завершения', case_id=case.id, status='active',
|
||||
unit_id=rcu.id, created_by=root.id)
|
||||
db.add(op); db.commit()
|
||||
|
||||
roles = {}
|
||||
for name in ('admin', 'coordinator', 'operator', 'observer'):
|
||||
r = Role(name=name, is_system=True); db.add(r); roles[name] = r
|
||||
perms = {}
|
||||
for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users',
|
||||
'manage_roles', 'view_audit', 'manage_security'):
|
||||
p = Permission(code=code); db.add(p); perms[code] = p
|
||||
db.commit()
|
||||
for rn, codes in {'admin': tuple(perms), 'observer': ('view',)}.items():
|
||||
for c in codes:
|
||||
db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id))
|
||||
db.add(UserRole(user_id=root.id, role_id=roles['admin'].id))
|
||||
db.commit()
|
||||
|
||||
from backend.rbac import SECURITY_DEFAULTS
|
||||
for k, v in SECURITY_DEFAULTS.items():
|
||||
db.add(SecuritySetting(key=k, value=v))
|
||||
db.commit()
|
||||
|
||||
from backend.main import app
|
||||
from backend.routers import auth as auth_router
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def override():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[auth_router.get_db] = override
|
||||
# closed-cases читает через legacy-глобальный db + модульный SessionLocal —
|
||||
# направляю оба в тестовую БД
|
||||
import backend.database as database_mod
|
||||
import backend.routers.closed_cases as closed_mod
|
||||
from backend.database import SQLCaseRepository
|
||||
test_session = sessionmaker(bind=engine)
|
||||
monkeypatch.setattr(database_mod, 'SessionLocal', test_session)
|
||||
monkeypatch.setattr(closed_mod, 'db', SQLCaseRepository())
|
||||
client = TestClient(app)
|
||||
tok = client.post('/api/v1/auth/login', data={'username': 'root', 'password': 'rootpass123'}).json()['access_token']
|
||||
|
||||
yield {'db': db, 'client': client, 'token': tok, 'op': op, 'case': case}
|
||||
|
||||
app.dependency_overrides.pop(auth_router.get_db, None)
|
||||
|
||||
|
||||
class TestCompleteOperation:
|
||||
def test_complete_flow(self, complete_env):
|
||||
env = complete_env
|
||||
r = env['client'].post(
|
||||
f"/api/v1/operations/{env['op'].id}/complete",
|
||||
headers={'Authorization': f"Bearer {env['token']}"},
|
||||
json={'found_alive': True, 'found_distance_km': 4.2, 'found_direction': 'NE',
|
||||
'search_duration_hours': 5.5, 'who_found': 'лесники'})
|
||||
assert r.status_code == 200, r.json()
|
||||
body = r.json()
|
||||
assert body['status'] == 'completed'
|
||||
assert body['closed_at'] is not None
|
||||
# Кейс закрыт + фактические данные
|
||||
from backend.models import Case
|
||||
case = env['db'].query(Case).filter(Case.id == env['case'].id).first()
|
||||
assert case.status == 'closed'
|
||||
assert case.found_alive is True
|
||||
assert case.found_distance_km == 4.2
|
||||
assert case.who_found == 'лесники'
|
||||
assert case.analysis_log.get('closed_case') is True
|
||||
assert case.analysis_log.get('model_prediction') # прогон модели записан
|
||||
# Операция завершена
|
||||
from backend.models import SearchOperation
|
||||
op = env['db'].query(SearchOperation).filter(SearchOperation.id == env['op'].id).first()
|
||||
assert op.status == 'completed'
|
||||
assert op.closed_at is not None
|
||||
|
||||
def test_double_complete_400(self, complete_env):
|
||||
env = complete_env
|
||||
h = {'Authorization': f"Bearer {env['token']}"}
|
||||
payload = {'found_alive': False}
|
||||
r1 = env['client'].post(f"/api/v1/operations/{env['op'].id}/complete", headers=h, json=payload)
|
||||
assert r1.status_code == 200
|
||||
r2 = env['client'].post(f"/api/v1/operations/{env['op'].id}/complete", headers=h, json=payload)
|
||||
assert r2.status_code == 400
|
||||
assert 'уже завершена' in r2.json()['detail']
|
||||
|
||||
def test_complete_audited(self, complete_env):
|
||||
env = complete_env
|
||||
env['client'].post(
|
||||
f"/api/v1/operations/{env['op'].id}/complete",
|
||||
headers={'Authorization': f"Bearer {env['token']}"},
|
||||
json={'found_alive': True})
|
||||
from backend.models import AuditEvent
|
||||
types = {e.event_type for e in env['db'].query(AuditEvent).all()}
|
||||
assert 'operation_complete' in types
|
||||
assert 'case_closed' in types
|
||||
|
||||
def test_completed_case_in_closed_list(self, complete_env):
|
||||
# Закрытый через операцию кейс попадает в архив завершённых (/admin)
|
||||
env = complete_env
|
||||
env['client'].post(
|
||||
f"/api/v1/operations/{env['op'].id}/complete",
|
||||
headers={'Authorization': f"Bearer {env['token']}"},
|
||||
json={'found_alive': True, 'found_distance_km': 2.0})
|
||||
r = env['client'].get('/api/v1/closed-cases', headers={'Authorization': f"Bearer {env['token']}"})
|
||||
assert r.status_code == 200
|
||||
items = r.json()['items']
|
||||
assert any(it.get('age_years') == 10 for it in items)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""B16: тесты локального OSM-слоя (PostGIS вместо Overpass).
|
||||
|
||||
Тесты работают и без БД: если DATABASE_URL недоступен или таблицы не
|
||||
залиты, osm_available() возвращает False и fallback-ветка geo_service
|
||||
ведёт себя по-старому (проверяется моком).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services import geo_service
|
||||
from services import osm_local
|
||||
|
||||
|
||||
class TestOsmLocal:
|
||||
def test_sector_wkt_is_valid_pie(self):
|
||||
"""WKT сектора — пирог: центр + дуга, замкнутый."""
|
||||
wkt = osm_local._sector_wkt_3857(53.9, 27.56, 1000, 'N')
|
||||
assert wkt.startswith('POLYGON((')
|
||||
assert wkt.endswith('))')
|
||||
# Центр входит дважды (начало и конец)
|
||||
assert wkt.count('27.5600000') >= 2 or '27.56 ' in wkt
|
||||
|
||||
def test_sector_geometry_valid(self):
|
||||
"""PostGIS-валидность: сектор не самопересекается (если БД доступна)."""
|
||||
if not osm_local.osm_available():
|
||||
pytest.skip('OSM PostGIS недоступен')
|
||||
import sqlalchemy
|
||||
from sqlalchemy import text
|
||||
e = sqlalchemy.create_engine(osm_local._PG_URL)
|
||||
wkt = osm_local._sector_wkt_3857(53.9, 27.56, 1000, 'NE')
|
||||
with e.connect() as c:
|
||||
valid = c.execute(text(
|
||||
"SELECT ST_IsValid(ST_GeomFromText(:w, 4326))"), {'w': wkt}
|
||||
).scalar()
|
||||
assert valid
|
||||
|
||||
def test_features_contract(self):
|
||||
"""Контракт Zone-фич: все ключи, типы, диапазоны."""
|
||||
if not osm_local.osm_available():
|
||||
pytest.skip('OSM PostGIS недоступен')
|
||||
f = osm_local.get_zone_features_postgis(53.9, 27.56, 'N', 1000)
|
||||
assert set(f) == {'roads_km', 'road_density', 'water_distance_km',
|
||||
'settlement_distance_km', 'forest_pct'}
|
||||
assert f['roads_km'] >= 0
|
||||
# Контракт: лес — ДОЛЯ 0..1 (scoring_service умножает weight × forest_pct).
|
||||
# Регресс: раньше отдавались проценты 0..100 — лес доминировал со 100× весом.
|
||||
assert 0 <= f['forest_pct'] <= 1
|
||||
assert f['water_distance_km'] is None or f['water_distance_km'] > 0
|
||||
|
||||
def test_zones_differ_between_directions(self):
|
||||
"""Критерий B16: соседние направления дают РАЗНЫЕ характеристики."""
|
||||
if not osm_local.osm_available():
|
||||
pytest.skip('OSM PostGIS недоступен')
|
||||
n = osm_local.get_zone_features_postgis(53.9, 27.56, 'N', 1000)
|
||||
ne = osm_local.get_zone_features_postgis(53.9, 27.56, 'NE', 1000)
|
||||
assert (n['roads_km'], n['forest_pct']) != (ne['roads_km'], ne['forest_pct'])
|
||||
|
||||
def test_wild_point_outside_data(self):
|
||||
"""Точка вне Беларуси → нули (не падает)."""
|
||||
if not osm_local.osm_available():
|
||||
pytest.skip('OSM PostGIS недоступен')
|
||||
f = osm_local.get_zone_features_postgis(40.0, 0.0, 'N', 1000)
|
||||
assert f['roads_km'] == 0.0
|
||||
assert f['forest_pct'] == 0.0
|
||||
|
||||
|
||||
class TestGeoServiceIntegration:
|
||||
async def test_build_search_zones_uses_postgis_when_available(self):
|
||||
"""osm_available()=True → Overpass НЕ вызывается."""
|
||||
with patch.object(geo_service, 'osm_available', return_value=True), \
|
||||
patch.object(geo_service, 'get_zone_features_postgis') as mock_pg, \
|
||||
patch.object(geo_service, 'get_zone_features', wraps=geo_service.get_zone_features):
|
||||
mock_pg.side_effect = lambda lat, lon, d, r: {
|
||||
'roads_km': 1.0, 'road_density': 0.8, 'water_distance_km': 0.5,
|
||||
'settlement_distance_km': 0.3, 'forest_pct': 10.0,
|
||||
}
|
||||
zones = await geo_service.build_search_zones(53.9, 27.56, {}, 1.08)
|
||||
assert len(zones) == 32
|
||||
assert mock_pg.call_count == 32
|
||||
assert zones[0].forest_pct == 10.0
|
||||
|
||||
async def test_build_search_zones_falls_back_to_overpass(self):
|
||||
"""osm_available()=False → идёт старый Overpass-путь (мокаем get_zone_features)."""
|
||||
with patch.object(geo_service, 'osm_available', return_value=False), \
|
||||
patch.object(geo_service, 'get_zone_features') as mock_get:
|
||||
mock_get.side_effect = lambda lat, lon, d, r: {
|
||||
'roads_km': 2.0, 'road_density': 1.6, 'water_distance_km': 0.7,
|
||||
'settlement_distance_km': 0.4, 'forest_pct': 5.0,
|
||||
}
|
||||
zones = await geo_service.build_search_zones(53.9, 27.56, {}, 1.08)
|
||||
assert len(zones) == 32
|
||||
assert mock_get.call_count == 32
|
||||
assert zones[0].forest_pct == 5.0
|
||||
|
||||
def test_no_overpass_calls_with_local_data(self):
|
||||
"""Критерий B16: с локальными данными query_overpass не дёргается."""
|
||||
if not osm_local.osm_available():
|
||||
pytest.skip('OSM PostGIS недоступен')
|
||||
with patch.object(geo_service, 'query_overpass') as mock_q:
|
||||
import asyncio
|
||||
zones = asyncio.run(geo_service.build_search_zones(53.9, 27.56, {}, 1.08))
|
||||
mock_q.assert_not_called()
|
||||
@@ -1,73 +0,0 @@
|
||||
|
||||
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']
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Tests for the unified recommendation scoring (B3).
|
||||
|
||||
Covers the shared scorer directly, the dict-based alias entry point, and the
|
||||
/api/v1/stats/recommendation endpoint that now delegates to it.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app
|
||||
from services.recommendation_service import (
|
||||
HIGH_PRIORITY_TEXT,
|
||||
NORMAL_PRIORITY_TEXT,
|
||||
get_statistical_recommendation,
|
||||
score_recommendation,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
class TestScoreRecommendation:
|
||||
"""Weights and threshold of the shared scorer."""
|
||||
|
||||
def test_empty_case_scores_zero(self):
|
||||
result = score_recommendation()
|
||||
assert result['score'] == 0
|
||||
assert result['priority'] == 'normal'
|
||||
assert result['recommendation'] == NORMAL_PRIORITY_TEXT
|
||||
|
||||
def test_young_child(self):
|
||||
assert score_recommendation(age=8)['score'] == 20
|
||||
|
||||
def test_age_at_threshold_not_counted(self):
|
||||
assert score_recommendation(age=12)['score'] == 0
|
||||
|
||||
def test_long_elapsed(self):
|
||||
assert score_recommendation(elapsed_hours=12)['score'] == 20
|
||||
|
||||
def test_short_elapsed_not_counted(self):
|
||||
assert score_recommendation(elapsed_hours=11)['score'] == 0
|
||||
|
||||
def test_risky_terrain(self):
|
||||
assert score_recommendation(terrain='лес')['score'] == 15
|
||||
|
||||
def test_adverse_weather(self):
|
||||
assert score_recommendation(weather='дождь')['score'] == 15
|
||||
|
||||
def test_multiple_health_flags(self):
|
||||
assert score_recommendation(health_flags=['эпилепсия', 'РАС'])['score'] == 15
|
||||
|
||||
def test_single_health_flag_not_counted(self):
|
||||
assert score_recommendation(health_flags=['эпилепсия'])['score'] == 0
|
||||
|
||||
def test_terrain_matches_as_substring(self):
|
||||
"""Substring match — the router previously required an exact match."""
|
||||
assert score_recommendation(terrain='смешанный лес')['score'] == 15
|
||||
|
||||
def test_weather_matches_as_substring(self):
|
||||
assert score_recommendation(weather='сильный дождь')['score'] == 15
|
||||
|
||||
def test_terrain_case_insensitive(self):
|
||||
assert score_recommendation(terrain='ЛЕС')['score'] == 15
|
||||
|
||||
def test_unknown_terrain_scores_zero(self):
|
||||
assert score_recommendation(terrain='поле')['score'] == 0
|
||||
|
||||
def test_high_priority_at_threshold(self):
|
||||
result = score_recommendation(age=8, elapsed_hours=14)
|
||||
assert result['score'] == 40
|
||||
assert result['priority'] == 'high'
|
||||
assert result['recommendation'] == HIGH_PRIORITY_TEXT
|
||||
|
||||
def test_just_below_threshold_is_normal(self):
|
||||
result = score_recommendation(age=8, terrain='лес')
|
||||
assert result['score'] == 35
|
||||
assert result['priority'] == 'normal'
|
||||
|
||||
def test_all_factors(self):
|
||||
result = score_recommendation(
|
||||
age=6,
|
||||
elapsed_hours=24,
|
||||
terrain='болото',
|
||||
weather='туман',
|
||||
health_flags=['РАС', 'эпилепсия'],
|
||||
)
|
||||
assert result['score'] == 85
|
||||
assert result['priority'] == 'high'
|
||||
|
||||
|
||||
class TestGetStatisticalRecommendation:
|
||||
"""Dict entry point and its field aliases."""
|
||||
|
||||
def test_age_alias(self):
|
||||
assert get_statistical_recommendation({'age_years': 8})['score'] == 20
|
||||
|
||||
def test_age_preferred_over_alias(self):
|
||||
assert get_statistical_recommendation({'age': 8, 'age_years': 30})['score'] == 20
|
||||
|
||||
def test_terrain_alias(self):
|
||||
assert get_statistical_recommendation({'terrain_primary': 'лес'})['score'] == 15
|
||||
|
||||
def test_weather_alias(self):
|
||||
assert get_statistical_recommendation({'precipitation': 'снег'})['score'] == 15
|
||||
|
||||
def test_health_flags_counted(self):
|
||||
payload = {'health_flags': ['РАС', 'эпилепсия']}
|
||||
assert get_statistical_recommendation(payload)['score'] == 15
|
||||
|
||||
def test_missing_keys_are_safe(self):
|
||||
assert get_statistical_recommendation({})['score'] == 0
|
||||
|
||||
def test_none_values_are_safe(self):
|
||||
payload = {'age': None, 'elapsed_hours': None, 'terrain': None, 'weather': None}
|
||||
assert get_statistical_recommendation(payload)['score'] == 0
|
||||
|
||||
|
||||
class TestRecommendationEndpoint:
|
||||
"""The endpoint keeps its response contract while delegating."""
|
||||
|
||||
def test_returns_score_and_recommendation(self):
|
||||
response = client.post(
|
||||
'/api/v1/stats/recommendation',
|
||||
json={'age': 8, 'elapsed_hours': 14},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body['score'] == 40
|
||||
assert body['recommendation'] == HIGH_PRIORITY_TEXT
|
||||
|
||||
def test_normal_priority_case(self):
|
||||
response = client.post('/api/v1/stats/recommendation', json={'age': 30})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body['score'] == 0
|
||||
assert body['recommendation'] == NORMAL_PRIORITY_TEXT
|
||||
|
||||
def test_empty_payload_accepted(self):
|
||||
response = client.post('/api/v1/stats/recommendation', json={})
|
||||
assert response.status_code == 200
|
||||
assert response.json()['score'] == 0
|
||||
|
||||
def test_endpoint_matches_shared_scorer(self):
|
||||
payload = {
|
||||
'age': 6,
|
||||
'elapsed_hours': 24,
|
||||
'terrain_primary': 'болото',
|
||||
'weather': 'туман',
|
||||
'health_flags': ['РАС', 'эпилепсия'],
|
||||
}
|
||||
response = client.post('/api/v1/stats/recommendation', json=payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
expected = score_recommendation(
|
||||
age=payload['age'],
|
||||
elapsed_hours=payload['elapsed_hours'],
|
||||
terrain=payload['terrain_primary'],
|
||||
weather=payload['weather'],
|
||||
health_flags=payload['health_flags'],
|
||||
)
|
||||
assert response.json()['score'] == expected['score']
|
||||
assert response.json()['recommendation'] == expected['recommendation']
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Тесты детерминированного rules-анализа (services/rules_analysis.py).
|
||||
|
||||
Ключевые кейсы перенесены из test_claude_service.py (удалён вместе с LLM);
|
||||
плюс проверка отсутствия внешних вызовов.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from services.rules_analysis import (
|
||||
AnalysisResult,
|
||||
PrimaryZone,
|
||||
rule_based_urgency,
|
||||
rules_analyze,
|
||||
)
|
||||
|
||||
|
||||
class TestUrgency:
|
||||
async def test_young_child_critical(self):
|
||||
# _age_urgency: возраст ≤4 — критическая вне зависимости от elapsed
|
||||
r = await rules_analyze({'age': 3})
|
||||
assert r.urgency == 'критическая'
|
||||
|
||||
def test_ras_critical(self):
|
||||
assert rule_based_urgency(1.0, 8, ['РАС']) == 'высокая' # возраст 8 не даёт +2
|
||||
|
||||
def test_long_search_raises(self):
|
||||
assert rule_based_urgency(30.0, 15, []) == 'высокая'
|
||||
assert rule_based_urgency(30.0, 5, ['РАС']) == 'критическая'
|
||||
|
||||
|
||||
class TestFallbackRules:
|
||||
async def test_young_child(self):
|
||||
result = await rules_analyze({'age': 3, 'gender': 'мужской', 'terrain': 'лес'})
|
||||
assert isinstance(result, AnalysisResult)
|
||||
assert result.urgency == 'критическая'
|
||||
assert result.fallback_used is True
|
||||
assert len(result.primary_zones) >= 2
|
||||
assert 'водоёмы' in result.key_locations
|
||||
|
||||
async def test_ras_profile(self):
|
||||
result = await rules_analyze({'age': 8, 'profiles': ['РАС']})
|
||||
assert result.urgency == 'критическая'
|
||||
assert 'РАС' in result.behavioral_prediction or 'водоём' in result.behavioral_prediction
|
||||
assert any('водоём' in a.lower() for a in result.immediate_actions)
|
||||
|
||||
async def test_bicycle_profile(self):
|
||||
result = await rules_analyze({'age': 12, 'profiles': ['велосипед']})
|
||||
assert result.urgency == 'высокая'
|
||||
assert any('10-15 км' in a or 'камер' in a for a in result.immediate_actions)
|
||||
|
||||
async def test_teenager(self):
|
||||
result = await rules_analyze({'age': 15, 'terrain': 'лес'})
|
||||
assert result.urgency in ['средняя', 'высокая']
|
||||
assert 'дороги' in result.key_locations or 'населённые пункты' in result.key_locations
|
||||
|
||||
async def test_behavioral_predictions_by_age(self):
|
||||
r4 = await rules_analyze({'age': 4, 'profiles': []})
|
||||
r9 = await rules_analyze({'age': 9, 'profiles': []})
|
||||
r16 = await rules_analyze({'age': 16, 'profiles': []})
|
||||
assert 'близко к точке потери' in r4.behavioral_prediction
|
||||
assert 'тропам или дорогам' in r9.behavioral_prediction
|
||||
assert 'населённым пунктам' in r16.behavioral_prediction
|
||||
|
||||
|
||||
class TestNoLLM:
|
||||
def test_no_anthropic_in_services(self):
|
||||
"""В services/ не осталось ссылок на LLM-провайдеров."""
|
||||
import pathlib
|
||||
for p in pathlib.Path('services').glob('*.py'):
|
||||
src = p.read_text(encoding='utf-8')
|
||||
for word in ('anthropic', 'ANTHROPIC', 'api.anthropic.com', 'openai', 'OpenAI'):
|
||||
assert word not in src, f'{p.name} contains {word}'
|
||||
|
||||
def test_no_llm_keys_in_compose(self):
|
||||
src = open('docker-compose.yml', encoding='utf-8').read()
|
||||
assert 'ANTHROPIC' not in src and 'OPENAI' not in src
|
||||
|
||||
async def test_result_is_deterministic(self):
|
||||
a = await rules_analyze({'age': 10, 'profiles': []})
|
||||
b = await rules_analyze({'age': 10, 'profiles': []})
|
||||
assert a.model_dump() == b.model_dump()
|
||||
@@ -137,6 +137,19 @@ class TestBehavioralProfiles:
|
||||
# Intentional runaway: roads x2.5, settlement x3.0
|
||||
assert scorer.weights['roads'] > base_roads
|
||||
|
||||
def test_cant_swim_profile(self):
|
||||
"""Test не_умеет_плавать profile — вода ×3.0 + медицинский приоритет."""
|
||||
scorer = WeightedScorer()
|
||||
base_water = scorer.weights['water']
|
||||
scorer.apply_profile(['не_умеет_плавать'])
|
||||
|
||||
# Cant swim: water x3.0, no distance change
|
||||
assert scorer.distance_multiplier == 1.0
|
||||
assert scorer.weights['water'] == base_water * 3.0
|
||||
assert 'не_умеет_плавать' in scorer.active_profiles
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
assert 'НЕМЕДЛЕННО' in scorer.critical_warnings[0]['warning']
|
||||
|
||||
def test_multiple_profiles(self):
|
||||
"""Test applying multiple profiles."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""B14: юнит-тесты чистого движка services/search_engine.py.
|
||||
|
||||
Анализатор мокается (движок тестируем изолированно, без geo/httpx).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from services.rules_analysis import AnalysisResult, PrimaryZone
|
||||
from services.search_engine import (
|
||||
SearchInput,
|
||||
build_search_model,
|
||||
derive_profiles,
|
||||
derive_time_of_day,
|
||||
unmodeled_profiles,
|
||||
)
|
||||
|
||||
|
||||
def _fake_analyzer(urgency: str = 'высокая', radius: float = 1.5) -> Any:
|
||||
async def analyzer(case_data: dict[str, Any]) -> AnalysisResult:
|
||||
analyzer.captured = case_data
|
||||
return AnalysisResult(
|
||||
urgency=urgency,
|
||||
primary_zones=[
|
||||
PrimaryZone(priority=1, name='Тест', direction='N', distance=0.5, reason='тест')
|
||||
],
|
||||
search_radius_km=radius,
|
||||
key_locations=['водоёмы'],
|
||||
behavioral_prediction='тест',
|
||||
immediate_actions=['действие'],
|
||||
summary='тест',
|
||||
fallback_used=True,
|
||||
)
|
||||
|
||||
analyzer.captured = None
|
||||
return analyzer
|
||||
|
||||
|
||||
def _base_input(**overrides: Any) -> SearchInput:
|
||||
data = dict(
|
||||
age=8,
|
||||
gender='м',
|
||||
terrain=['лес'],
|
||||
elapsed_hours=2.0,
|
||||
has_transport='bike',
|
||||
time_of_day='день',
|
||||
)
|
||||
data.update(overrides)
|
||||
return SearchInput(**data)
|
||||
|
||||
|
||||
async def test_build_search_model_bike_8yo_matches_old_contract():
|
||||
"""bike 8yo 2h лес день → max_distance.
|
||||
B1-ревью 2026-09-09 (Виктор): СП(лес) 0.5→1.0 (НормС — уже скорость
|
||||
смещения по среднему лесу; 0.5 учитывал лес дважды).
|
||||
Было 5.4 (2 × 1.2 × 0.5 × 0.9 × 5.0), стало 10.8 (2 × 1.2 × 1.0 × 0.9 × 5.0)."""
|
||||
model = await build_search_model(_base_input(), analyzer=_fake_analyzer())
|
||||
assert model.max_distance_km == pytest.approx(10.8, abs=1e-6)
|
||||
assert model.coefficients['base_speed'] == 1.2
|
||||
assert model.coefficients['terrain'] == 1.0
|
||||
assert model.coefficients['urgency'] == 5.0 # bike
|
||||
|
||||
|
||||
async def test_teen_3h_forest_realistic_distance():
|
||||
"""Операционная проверка Виктора: здоровый подросток за 3 часа проходит
|
||||
заметно больше 2.5 км. При СП(лес)=1.0: 3 × 2.0 × 1.0 × 0.85 = 5.1 км."""
|
||||
model = await build_search_model(
|
||||
_base_input(age=14, has_transport=None, elapsed_hours=3.0),
|
||||
analyzer=_fake_analyzer(),
|
||||
)
|
||||
assert model.max_distance_km == pytest.approx(5.1, abs=1e-6)
|
||||
|
||||
|
||||
async def test_profiles_derived_from_diagnosis_and_transport():
|
||||
analyzer = _fake_analyzer()
|
||||
model = await build_search_model(
|
||||
_base_input(diagnosis_type=['РАС'], cant_swim=True),
|
||||
analyzer=analyzer,
|
||||
)
|
||||
assert 'РАС' in [p['name'] for p in model.active_profiles]
|
||||
assert model.critical_warnings == [] or isinstance(model.critical_warnings, list)
|
||||
# Профили дошли и до анализатора (case_data), и в скорер
|
||||
assert 'РАС' in analyzer.captured['profiles']
|
||||
assert 'велосипед' in analyzer.captured['profiles']
|
||||
assert 'не_умеет_плавать' in analyzer.captured['profiles']
|
||||
|
||||
|
||||
async def test_ras_bike_profiles_case_sensitive_keys():
|
||||
"""Ключи профилей — русские (§8): РАС + велосипед, не bike."""
|
||||
assert derive_profiles(['рас'], 'bike', False) == ['РАС', 'велосипед']
|
||||
assert derive_profiles([], 'scooter', True) == ['самокат', 'не_умеет_плавать']
|
||||
|
||||
|
||||
async def test_explicit_profiles_win_over_derivation():
|
||||
assert derive_profiles(['РАС'], 'bike', True, explicit=['СДВГ']) == ['СДВГ']
|
||||
|
||||
|
||||
async def test_unmodeled_diagnoses_flagged():
|
||||
flagged = unmodeled_profiles(['ДЦП', 'РАС', 'слабый_слух'])
|
||||
assert [f['profile'] for f in flagged] == ['ДЦП', 'слабый_слух']
|
||||
assert all(f['note'] for f in flagged)
|
||||
|
||||
|
||||
async def test_time_of_day_from_loss_time():
|
||||
assert derive_time_of_day('2026-09-09T14:00:00') == 'день'
|
||||
assert derive_time_of_day('2026-09-09T20:00:00') == 'сумерки'
|
||||
assert derive_time_of_day('2026-09-09T02:00:00') == 'ночь'
|
||||
assert derive_time_of_day('мусор') == 'день'
|
||||
assert derive_time_of_day(None) == 'день'
|
||||
assert derive_time_of_day(datetime(2026, 9, 9, 23, 0)) == 'ночь'
|
||||
|
||||
|
||||
async def test_time_of_day_auto_applied_in_model():
|
||||
model = await build_search_model(
|
||||
_base_input(time_of_day=None, loss_time='2026-09-09T21:00:00'),
|
||||
analyzer=_fake_analyzer(),
|
||||
)
|
||||
assert model.time_of_day == 'сумерки'
|
||||
assert model.coefficients['time_of_day'] == 0.5 # суметки замедляют (видимость)
|
||||
|
||||
|
||||
async def test_psychotype_detected_and_applied():
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'explore',
|
||||
'stress_reaction': 'angry',
|
||||
'leadership': 'always_leader',
|
||||
'risk_taking': 'very',
|
||||
}
|
||||
analyzer = _fake_analyzer()
|
||||
model = await build_search_model(
|
||||
_base_input(psychotype_answers=answers),
|
||||
analyzer=analyzer,
|
||||
)
|
||||
assert model.psychotype == 'dominant'
|
||||
assert model.psychotype_modifiers is not None
|
||||
assert model.psychotype_recommendations is not None
|
||||
assert analyzer.captured['psychotype_modifiers'] == model.psychotype_modifiers
|
||||
|
||||
|
||||
async def test_no_psychotype_when_no_answers():
|
||||
model = await build_search_model(_base_input(), analyzer=_fake_analyzer())
|
||||
assert model.psychotype is None
|
||||
assert model.psychotype_modifiers is None
|
||||
|
||||
|
||||
async def test_engine_does_not_touch_db_or_http():
|
||||
"""Чистота границы: движок не импортирует DB/auth/HTTP-клиентов."""
|
||||
import services.search_engine as se
|
||||
source = open(se.__file__, encoding='utf-8').read()
|
||||
assert 'backend.database' not in source
|
||||
assert 'backend.routers' not in source
|
||||
assert 'SessionLocal' not in source
|
||||
assert 'Depends' not in source
|
||||
# И никакого ATAK/Meshtastic/CoT (критерий B20/B14)
|
||||
assert 'ATAK' not in source and 'Meshtastic' not in source and 'CoT' not in source
|
||||
|
||||
|
||||
async def test_case_id_merge_loses_payload_empty_containers():
|
||||
"""Пустые контейнеры выбрасываются из case_data (старое поведение
|
||||
_as_case_data): пустой diagnosis_type не доходит до сервисов."""
|
||||
si = SearchInput(diagnosis_type=[])
|
||||
assert 'diagnosis_type' not in si.to_case_data()
|
||||
|
||||
|
||||
async def test_none_fields_dropped_from_case_data():
|
||||
"""Регресс 2026-09-09: None-поля не должны попадать в case_data.
|
||||
|
||||
Сервисы читают case_data.get(key, DEFAULT); ключ с None обманывает
|
||||
дефолт → elapsed_hours=None ломал calculate_max_distance
|
||||
(TypeError: float * NoneType) — «не удаётся запустить анализ».
|
||||
"""
|
||||
si = SearchInput(age=None, elapsed_hours=None, weather=None, gender=None)
|
||||
d = si.to_case_data()
|
||||
assert 'elapsed_hours' not in d
|
||||
assert 'age' not in d
|
||||
assert 'weather' not in d
|
||||
assert 'diagnosis_type' not in d # пустой список тоже выбрасывается
|
||||
|
||||
# И полный прогон не падает:
|
||||
model = await build_search_model(si, analyzer=_fake_analyzer())
|
||||
assert model.max_distance_km > 0 # elapsed None → дефолт 1.0
|
||||
|
||||
|
||||
async def test_case_card_none_does_not_override_payload():
|
||||
"""Слияние карточки (case.to_detail) с payload: None из карточки
|
||||
не должен ломать расчёт при пустом payload."""
|
||||
si = SearchInput(age=8, elapsed_hours=2.0, terrain=['лес'],
|
||||
has_transport='bike', time_of_day='день')
|
||||
d = si.to_case_data()
|
||||
assert d['elapsed_hours'] == 2.0
|
||||
|
||||
|
||||
async def test_fallback_fields_flow_through():
|
||||
analyzer = _fake_analyzer(urgency='критическая', radius=2.5)
|
||||
model = await build_search_model(_base_input(), analyzer=analyzer)
|
||||
assert model.urgency == 'критическая'
|
||||
assert model.search_radius_km == 2.5
|
||||
assert model.fallback_used is True
|
||||
assert model.primary_zones[0]['direction'] == 'N'
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Тесты словесных пояснений приоритетных направлений (_build_zone_reason)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from services.rules_analysis import ( # noqa: E402
|
||||
_build_zone_reason,
|
||||
_direction_phrase,
|
||||
_psychotype_phrase,
|
||||
_water_phrase,
|
||||
)
|
||||
|
||||
|
||||
class TestZoneReason:
|
||||
def test_reason_mentions_key_factors(self):
|
||||
zone = {'direction': 'NE', 'distance_km': 2.0, 'forest_pct': 0.68,
|
||||
'road_density': 0.5, 'water_distance_km': 0.4,
|
||||
'settlement_distance_km': 1.2, 'score': 78.4}
|
||||
case = {'profiles': ['не_умеет_плавать'], 'last_seen_direction': 'ne'}
|
||||
reason = _build_zone_reason(zone, case)
|
||||
assert 'лесистость умеренная (68%)' in reason
|
||||
assert '0.4 км — проверить берега' in reason
|
||||
assert 'не умеет плавать' in reason
|
||||
assert 'совпадает с направлением' in reason
|
||||
assert reason.endswith('Оценка 78/100.')
|
||||
|
||||
def test_reason_open_field_with_bike(self):
|
||||
zone = {'direction': 'SE', 'distance_km': 1.0, 'forest_pct': 0.12,
|
||||
'road_density': 2.6, 'water_distance_km': 6.0,
|
||||
'settlement_distance_km': None, 'score': 41.0}
|
||||
reason = _build_zone_reason(zone, {'profiles': ['велосипед']})
|
||||
assert 'преобладает открытое поле' in reason
|
||||
assert 'вода далеко' in reason
|
||||
assert 'велосипед' in reason
|
||||
|
||||
def test_reason_direction_neighbor(self):
|
||||
zone = {'direction': 'NE', 'distance_km': 1.0, 'score': 60.0}
|
||||
case = {'last_seen_direction': 'N'}
|
||||
reason = _build_zone_reason(zone, case)
|
||||
assert 'близко к направлению' in reason
|
||||
|
||||
def test_reason_without_factors_falls_back_to_score(self):
|
||||
reason = _build_zone_reason({'direction': 'N', 'score': 50.0, 'distance_km': 1.0}, {})
|
||||
assert reason == 'Оценка 50/100 по весам модели'
|
||||
|
||||
def test_water_phrase_no_swimmer_far_water_no_warning(self):
|
||||
# Не умеет плавать, но вода далеко — акцент не добавляется
|
||||
phrase = _water_phrase(8.0, ['не_умеет_плавать'])
|
||||
assert phrase is not None and 'не умеет плавать' not in phrase
|
||||
|
||||
def test_psychotype_phrase_bands(self):
|
||||
mods = {'zone_0_500': 1.0, 'zone_500_1500': 1.25,
|
||||
'zone_1500_2500': 1.0, 'zone_2500plus': 0.8}
|
||||
up = _psychotype_phrase({'psychotype_modifiers': mods}, 1.0)
|
||||
assert 'повышает' in up and '0.5-1.5 км' in up
|
||||
down = _psychotype_phrase({'psychotype_modifiers': mods}, 3.0)
|
||||
assert 'снижает' in down
|
||||
|
||||
def test_reason_is_deterministic(self):
|
||||
zone = {'direction': 'E', 'distance_km': 1.8, 'forest_pct': 0.5,
|
||||
'road_density': 1.0, 'water_distance_km': 2.0,
|
||||
'settlement_distance_km': 3.0, 'score': 55.5}
|
||||
case = {'profiles': [], 'last_seen_direction': 'E'}
|
||||
r1 = _build_zone_reason(zone, case)
|
||||
r2 = _build_zone_reason(dict(zone), dict(case))
|
||||
assert r1 == r2
|
||||
assert 'совпадает с направлением' in r1
|
||||
class TestRadiusConsistency:
|
||||
def test_summary_radius_matches_max_times_multiplier(self):
|
||||
"""Радиус в summary = max_distance × психотип-множитель (согласован с хедером)."""
|
||||
import asyncio
|
||||
from services.rules_analysis import rules_analyze
|
||||
case = {
|
||||
'age': 9, 'gender': 'м', 'season': 'лето', 'elapsed_hours': 4.0,
|
||||
'profiles': ['гармоничный'], 'psychotype': 'harmonic',
|
||||
'psychotype_modifiers': {'zone_0_500': 1.0, 'zone_500_1500': 1.0,
|
||||
'zone_1500_2500': 1.1, 'zone_2500plus': 1.0},
|
||||
'max_distance_km': 2.4,
|
||||
'lat': 54.5, 'lon': 28.5,
|
||||
}
|
||||
result = asyncio.run(rules_analyze(case))
|
||||
# Средний психотип-множитель полос (1.0+1.0+1.1+1.0)/4 = 1.025 → 2.4×1.025=2.46→2.5
|
||||
assert result.search_radius_km == 2.5, result.search_radius_km
|
||||
assert f'{result.search_radius_km} км' in result.summary
|
||||
+3
-1
@@ -35,15 +35,17 @@ services:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/vector_mchs}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
|
||||
- CONTOUR_API_URL=${CONTOUR_API_URL:-}
|
||||
- CONTOUR_TOKEN=${CONTOUR_TOKEN:-}
|
||||
- PYTHONPATH=/app
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend:/app/backend
|
||||
- ./services:/app/services
|
||||
command: uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
frontend:
|
||||
|
||||
+20
-16
@@ -1,9 +1,13 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
import AdminDashboard from "./pages/AdminDashboard";
|
||||
import MobileFormPage from "./pages/MobileFormPage";
|
||||
import UsersAdmin from "./pages/UsersAdmin";
|
||||
import DesktopSARPage from "./pages/DesktopSARPage";
|
||||
import AnalysisResult from "./pages/analysis/AnalysisResult";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import "./App.css";
|
||||
|
||||
function ThemeProvider({ children }) {
|
||||
@@ -12,9 +16,7 @@ function ThemeProvider({ children }) {
|
||||
useEffect(() => {
|
||||
let theme = "desktop";
|
||||
|
||||
if (location.pathname.startsWith("/mobile") || location.pathname.startsWith("/m")) {
|
||||
theme = "mobile";
|
||||
} else if (location.pathname.startsWith("/admin")) {
|
||||
if (location.pathname.startsWith("/admin")) {
|
||||
theme = "admin";
|
||||
} else if (location.pathname.startsWith("/sar") || location.pathname.startsWith("/desktop") || location.pathname.startsWith("/analysis")) {
|
||||
theme = "desktop";
|
||||
@@ -27,24 +29,26 @@ function ThemeProvider({ children }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || window.innerWidth < 768;
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<ThemeProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={isMobile ? "/mobile" : "/desktop"} replace />} />
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route path="/sar" element={<DesktopSARPage />} />
|
||||
<Route path="/desktop" element={<DesktopSARPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
|
||||
<Route path="/analysis/:caseId" element={<AnalysisResult />} />
|
||||
<Route path="/sar" element={<ProtectedRoute><DesktopSARPage /></ProtectedRoute>} />
|
||||
<Route path="/desktop" element={<ProtectedRoute><DesktopSARPage /></ProtectedRoute>} />
|
||||
|
||||
<Route path="/admin/*" element={<AdminDashboard />} />
|
||||
<Route path="/analysis/:caseId" element={<ProtectedRoute><AnalysisResult /></ProtectedRoute>} />
|
||||
|
||||
<Route path="/mobile" element={<MobileFormPage />} />
|
||||
</Routes>
|
||||
</ThemeProvider>
|
||||
<Route path="/admin/*" element={<ProtectedRoute roles={["admin"]}><AdminDashboard /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute><UsersAdmin /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Единая точка выхода в API. Подставляет JWT из localStorage в каждый запрос
|
||||
// и централизованно обрабатывает 401: токен сбрасывается, а слушатели
|
||||
// (AuthContext) получают событие и уводят пользователя на /login.
|
||||
//
|
||||
// Раньше страницы дёргали fetch() напрямую без заголовка Authorization, из-за
|
||||
// чего защищённые роутеры (require_roles в cases/analyze/admin) отвечали
|
||||
// 401 {"detail":"Not authenticated"} — форма показывала это как ошибку.
|
||||
|
||||
export const TOKEN_KEY = 'vector_token';
|
||||
export const UNAUTHORIZED_EVENT = 'vector:unauthorized';
|
||||
|
||||
export function getToken() {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY) || '';
|
||||
} catch {
|
||||
// Приватный режим / отключённое хранилище — работаем без сохранения.
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
try {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* storage недоступен — токен живёт только в памяти AuthContext */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
setToken('');
|
||||
}
|
||||
|
||||
function notifyUnauthorized() {
|
||||
clearToken();
|
||||
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
|
||||
}
|
||||
|
||||
function authHeaders(extra) {
|
||||
const headers = new Headers(extra || {});
|
||||
const token = getToken();
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обёртка над fetch: добавляет Bearer-токен и перехватывает 401.
|
||||
* Возвращает обычный Response — вызывающий код проверяет response.ok как раньше.
|
||||
*/
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const { headers, body, ...rest } = options;
|
||||
const finalHeaders = authHeaders(headers);
|
||||
|
||||
// FormData сама выставляет multipart-boundary: Content-Type не трогаем.
|
||||
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
if (body && !isFormData && !finalHeaders.has('Content-Type')) {
|
||||
finalHeaders.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(path, { ...rest, body, headers: finalHeaders });
|
||||
|
||||
if (response.status === 401) {
|
||||
notifyUnauthorized();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/** apiFetch + разбор JSON и подъём detail из тела ошибки. */
|
||||
export async function apiJson(path, options = {}) {
|
||||
const response = await apiFetch(path, options);
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.detail || `Ошибка запроса (${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/** POST /api/v1/auth/login — бэкенд ждёт OAuth2 form-urlencoded, не JSON. */
|
||||
export async function login(username, password) {
|
||||
const form = new URLSearchParams();
|
||||
form.set('username', username);
|
||||
form.set('password', password);
|
||||
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString(),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.detail || 'Неверный логин или пароль');
|
||||
}
|
||||
setToken(payload.access_token);
|
||||
return payload.access_token;
|
||||
}
|
||||
|
||||
/** GET /api/v1/auth/me — проверка токена и получение роли. */
|
||||
export function fetchMe() {
|
||||
return apiJson('/api/v1/auth/me');
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
.analysis-layout {
|
||||
min-height: 100vh;
|
||||
background: #0a0c0f;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Двухколоночный layout */
|
||||
.layout-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Левая колонка - Форма */
|
||||
.layout-left {
|
||||
background: #0a0c0f;
|
||||
}
|
||||
|
||||
/* Правая колонка - Карта */
|
||||
.layout-right {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-container-sticky {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
/* Placeholder для карты */
|
||||
.map-placeholder {
|
||||
height: 600px;
|
||||
background: #161b22;
|
||||
border: 2px dashed #30363d;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.placeholder-content h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 20px;
|
||||
color: #e6edf3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.placeholder-content p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
line-height: 1.5;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Результаты снизу на всю ширину */
|
||||
.layout-bottom {
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 1400px) {
|
||||
.layout-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.map-container-sticky {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.map-placeholder {
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.analysis-layout {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.placeholder-content h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.placeholder-content p {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import CaseForm from "../CaseForm/CaseForm";
|
||||
import MapView from "../MapView/MapView";
|
||||
import ResultPanel from "../ResultPanel/ResultPanel";
|
||||
import "./AnalysisLayout.css";
|
||||
|
||||
const AnalysisLayout = () => {
|
||||
const [analysisResult, setAnalysisResult] = useState(null);
|
||||
const [heatmapData, setHeatmapData] = useState([]);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
|
||||
const handleAnalysisComplete = (result) => {
|
||||
setAnalysisResult(result);
|
||||
setShowMap(true);
|
||||
};
|
||||
|
||||
const handleHeatmapLoad = (data) => {
|
||||
setHeatmapData(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="analysis-layout">
|
||||
<div className="layout-container">
|
||||
{/* Левая колонка - Форма */}
|
||||
<div className="layout-left">
|
||||
<CaseForm
|
||||
onAnalysisComplete={handleAnalysisComplete}
|
||||
onHeatmapLoad={handleHeatmapLoad}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Правая колонка - Карта */}
|
||||
<div className="layout-right">
|
||||
{showMap && analysisResult ? (
|
||||
<div className="map-container-sticky">
|
||||
<MapView
|
||||
tnpLat={analysisResult.tnp_lat || 53.9}
|
||||
tnpLon={analysisResult.tnp_lon || 27.56}
|
||||
zones={analysisResult.zones || []}
|
||||
direction={analysisResult.direction}
|
||||
heatmapData={heatmapData}
|
||||
showHeatmap={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="map-placeholder">
|
||||
<div className="placeholder-content">
|
||||
<div className="placeholder-icon">🗺️</div>
|
||||
<h3>Карта поиска</h3>
|
||||
<p>Заполните форму и запустите анализ для отображения зон поиска</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Результаты снизу на всю ширину */}
|
||||
{analysisResult && (
|
||||
<div className="layout-bottom">
|
||||
<ResultPanel analysisResult={analysisResult} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisLayout;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./AnalysisLayout";
|
||||
@@ -1,186 +0,0 @@
|
||||
.behavioral-profiles {
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.profiles-title {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profiles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
border-left: 4px solid;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.profile-card-red {
|
||||
border-left-color: #dc3545;
|
||||
}
|
||||
|
||||
.profile-card-orange {
|
||||
border-left-color: #fd7e14;
|
||||
}
|
||||
|
||||
.profile-card-yellow {
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
.profile-card-blue {
|
||||
border-left-color: #0d6efd;
|
||||
}
|
||||
|
||||
.profile-card-purple {
|
||||
border-left-color: #6f42c1;
|
||||
}
|
||||
|
||||
.profile-card-gray {
|
||||
border-left-color: #6c757d;
|
||||
}
|
||||
|
||||
.profile-card-green {
|
||||
border-left-color: #198754;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-recommendations {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.profile-recommendations h5,
|
||||
.profile-modifiers h5 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.recommendations-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.recommendations-list li {
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.95rem;
|
||||
color: #555;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-modifiers {
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.modifiers-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modifier-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.modifier-zone {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modifier-value {
|
||||
color: #0d6efd;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 768px) {
|
||||
.profiles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.profiles-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.recommendations-list li.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.profile-recommendations h5,
|
||||
.profile-modifiers h5 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.recommendations-list li {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.modifier-item {
|
||||
font-size: 0.85rem;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop - full cards */
|
||||
@media (min-width: 769px) {
|
||||
.mobile-hidden {
|
||||
display: list-item !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.profiles-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Large desktop */
|
||||
@media (min-width: 1400px) {
|
||||
.profiles-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import React from 'react';
|
||||
import './BehavioralProfile.css';
|
||||
|
||||
interface BehavioralProfileProps {
|
||||
diagnosis_type?: string[];
|
||||
has_transport?: string;
|
||||
age_years?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface ProfileCardData {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
recommendations: string[];
|
||||
zoneModifiers: { zone: string; multiplier: number }[];
|
||||
}
|
||||
|
||||
const BehavioralProfile: React.FC<BehavioralProfileProps> = ({
|
||||
diagnosis_type = [],
|
||||
has_transport,
|
||||
age_years,
|
||||
reason
|
||||
}) => {
|
||||
const getActiveProfiles = (): ProfileCardData[] => {
|
||||
const profiles: ProfileCardData[] = [];
|
||||
|
||||
// РАС (Расстройство аутистического спектра)
|
||||
if (diagnosis_type.includes('РАС') || diagnosis_type.includes('аутизм')) {
|
||||
profiles.push({
|
||||
id: 'ras',
|
||||
title: 'РАС (Расстройство аутистического спектра)',
|
||||
color: 'red',
|
||||
recommendations: [
|
||||
'Не кричать имя — может спрятаться от громких звуков',
|
||||
'Приоритет: водоёмы, тихие места, укрытия',
|
||||
'Перекрыть доступ к воде (пруды, реки, колодцы)',
|
||||
'Проверить железнодорожные пути и технические помещения',
|
||||
'Искать в радиусе до 5 км, часто движется по прямой'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Водоём', multiplier: 3.0 },
|
||||
{ zone: 'Укрытие', multiplier: 2.0 },
|
||||
{ zone: 'Населённый пункт', multiplier: 0.4 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Эпилепсия
|
||||
if (diagnosis_type.includes('эпилепсия')) {
|
||||
profiles.push({
|
||||
id: 'epilepsy',
|
||||
title: 'Эпилепсия',
|
||||
color: 'orange',
|
||||
recommendations: [
|
||||
'Искать близко к точке потери — радиус до 1 км',
|
||||
'Проверить канавы, овраги, низины (риск падения)',
|
||||
'Приоритет: водоёмы и опасные участки',
|
||||
'Срочная медицинская помощь при обнаружении',
|
||||
'Проверить укрытия (мог укрыться после приступа)'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Водоём', multiplier: 3.5 },
|
||||
{ zone: 'Укрытие', multiplier: 2.5 },
|
||||
{ zone: 'Радиус поиска', multiplier: 0.6 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// СДВГ (Синдром дефицита внимания и гиперактивности)
|
||||
if (diagnosis_type.includes('СДВГ') || diagnosis_type.includes('гиперактивность')) {
|
||||
profiles.push({
|
||||
id: 'adhd',
|
||||
title: 'СДВГ (Гиперактивность)',
|
||||
color: 'yellow',
|
||||
recommendations: [
|
||||
'Расширенный радиус поиска — может уйти далеко',
|
||||
'Приоритет: дороги, тропы, интересные объекты',
|
||||
'Проверить игровые площадки, стройки, заброшенные здания',
|
||||
'Может отвлечься и изменить направление',
|
||||
'Искать вдоль дорог и троп'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Дороги', multiplier: 1.6 },
|
||||
{ zone: 'Радиус поиска', multiplier: 1.4 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Деменция
|
||||
if (diagnosis_type.includes('деменция') || diagnosis_type.includes('Альцгеймер')) {
|
||||
profiles.push({
|
||||
id: 'dementia',
|
||||
title: 'Деменция / Альцгеймер',
|
||||
color: 'purple',
|
||||
recommendations: [
|
||||
'Искать в радиусе до 2 км от дома',
|
||||
'Проверить знакомые места (бывший дом, работа)',
|
||||
'Приоритет: дороги, тропы (идёт по привычному маршруту)',
|
||||
'Может не откликаться на имя',
|
||||
'Проверить водоёмы и опасные участки'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Дороги', multiplier: 1.8 },
|
||||
{ zone: 'Исторические места', multiplier: 2.0 },
|
||||
{ zone: 'Водоём', multiplier: 1.5 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Велосипед
|
||||
if (has_transport === 'велосипед') {
|
||||
profiles.push({
|
||||
id: 'bicycle',
|
||||
title: 'Транспорт: Велосипед',
|
||||
color: 'blue',
|
||||
recommendations: [
|
||||
'Радиус поиска ×5 — может уехать на 10-25 км',
|
||||
'Приоритет: дороги, велодорожки, тропы',
|
||||
'Запросить записи с камер на дорогах',
|
||||
'Проверить популярные велосипедные маршруты',
|
||||
'Лес и бездорожье менее вероятны'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Радиус поиска', multiplier: 5.0 },
|
||||
{ zone: 'Дороги', multiplier: 1.8 },
|
||||
{ zone: 'Лес', multiplier: 0.8 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Автомобиль
|
||||
if (has_transport === 'автомобиль' || has_transport === 'машина') {
|
||||
profiles.push({
|
||||
id: 'car',
|
||||
title: 'Транспорт: Автомобиль',
|
||||
color: 'blue',
|
||||
recommendations: [
|
||||
'Радиус поиска ×10 — может уехать на 50+ км',
|
||||
'Запросить записи с камер ГИБДД и дорожных камер',
|
||||
'Проверить заправки, парковки, придорожные кафе',
|
||||
'Ориентация на дороги и трассы',
|
||||
'Розыск по номеру автомобиля'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Радиус поиска', multiplier: 10.0 },
|
||||
{ zone: 'Дороги', multiplier: 2.5 },
|
||||
{ zone: 'Лес', multiplier: 0.3 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Подросток, ушедший из дома
|
||||
if (age_years && age_years >= 13 && age_years <= 17 &&
|
||||
(reason?.includes('ушёл из дома') || reason?.includes('конфликт'))) {
|
||||
profiles.push({
|
||||
id: 'runaway',
|
||||
title: 'Подросток: Уход из дома',
|
||||
color: 'gray',
|
||||
recommendations: [
|
||||
'Розыск через соцсети и друзей',
|
||||
'Проверить дома друзей, торговые центры, вокзалы',
|
||||
'Лес и удалённые места менее вероятны',
|
||||
'Может иметь телефон — запросить геолокацию',
|
||||
'Проверить популярные места встреч подростков'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Населённый пункт', multiplier: 2.0 },
|
||||
{ zone: 'Лес', multiplier: 0.3 },
|
||||
{ zone: 'Дороги', multiplier: 1.5 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Грибник
|
||||
if (reason?.includes('грибы') || reason?.includes('грибник')) {
|
||||
profiles.push({
|
||||
id: 'mushroom',
|
||||
title: 'Активность: Сбор грибов',
|
||||
color: 'green',
|
||||
recommendations: [
|
||||
'Приоритет: лесные массивы, особенно смешанные леса',
|
||||
'Радиус до 3 км от точки входа в лес',
|
||||
'Проверить популярные грибные места',
|
||||
'Может углубиться в лес, потеряв ориентацию',
|
||||
'Искать вдоль просек и лесных дорог'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Лес', multiplier: 3.0 },
|
||||
{ zone: 'Дороги', multiplier: 0.6 },
|
||||
{ zone: 'Радиус поиска', multiplier: 1.2 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Охотник
|
||||
if (reason?.includes('охота') || reason?.includes('охотник')) {
|
||||
profiles.push({
|
||||
id: 'hunter',
|
||||
title: 'Активность: Охота',
|
||||
color: 'green',
|
||||
recommendations: [
|
||||
'Радиус до 5-10 км — охотники перемещаются активно',
|
||||
'Приоритет: лесные массивы, поля, болота',
|
||||
'Проверить охотничьи вышки, избушки, кормушки',
|
||||
'Может иметь оружие — соблюдать осторожность',
|
||||
'Искать вдоль звериных троп и просек'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Лес', multiplier: 2.5 },
|
||||
{ zone: 'Радиус поиска', multiplier: 2.0 },
|
||||
{ zone: 'Укрытие', multiplier: 1.5 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return profiles;
|
||||
};
|
||||
|
||||
const activeProfiles = getActiveProfiles();
|
||||
|
||||
if (activeProfiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="behavioral-profiles">
|
||||
<h3 className="profiles-title">Поведенческие профили</h3>
|
||||
<div className="profiles-grid">
|
||||
{activeProfiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className={`profile-card profile-card-${profile.color}`}
|
||||
>
|
||||
<div className="profile-header">
|
||||
<h4 className="profile-title">{profile.title}</h4>
|
||||
</div>
|
||||
|
||||
<div className="profile-recommendations">
|
||||
<h5>Рекомендации:</h5>
|
||||
<ul className="recommendations-list">
|
||||
{profile.recommendations.map((rec, idx) => (
|
||||
<li key={idx} className={idx >= 2 ? 'mobile-hidden' : ''}>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="profile-modifiers">
|
||||
<h5>Модификаторы зон:</h5>
|
||||
<div className="modifiers-list">
|
||||
{profile.zoneModifiers.map((mod, idx) => (
|
||||
<div key={idx} className="modifier-item">
|
||||
<span className="modifier-zone">{mod.zone}:</span>
|
||||
<span className="modifier-value">×{mod.multiplier.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BehavioralProfile;
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Step1Child from "./Step1Child";
|
||||
import Step2Health from "./Step2Health";
|
||||
import Step2bPsychotype from "./Step2bPsychotype";
|
||||
import Step3Circumstances from "./Step3Circumstances";
|
||||
import Step4Environment from "./Step4Environment";
|
||||
import { apiFetch } from "../../api/client";
|
||||
import "./CaseForm.css";
|
||||
|
||||
const INITIAL_DATA = {
|
||||
@@ -16,6 +18,7 @@ const INITIAL_DATA = {
|
||||
};
|
||||
|
||||
const CaseForm = ({ isMobile = false }) => {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState(INITIAL_DATA);
|
||||
const [errors, setErrors] = useState([]);
|
||||
@@ -48,9 +51,6 @@ const CaseForm = ({ isMobile = false }) => {
|
||||
if (!formData.lost_time) {
|
||||
newErrors.push("Укажите дату и время потери");
|
||||
}
|
||||
if (!formData.last_known_place) {
|
||||
newErrors.push("Укажите последнее известное место");
|
||||
}
|
||||
}
|
||||
|
||||
// Для десктопа: Шаг 5 = Среда и местность
|
||||
@@ -104,9 +104,8 @@ const CaseForm = ({ isMobile = false }) => {
|
||||
};
|
||||
|
||||
// Создаем карточку
|
||||
const response = await fetch("/api/v1/cases", {
|
||||
const response = await apiFetch("/api/v1/cases", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
@@ -116,10 +115,23 @@ const CaseForm = ({ isMobile = false }) => {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log("Case created:", result);
|
||||
|
||||
// Перенаправляем на страницу анализа с ID карточки
|
||||
window.location.href = `/analysis/${result.id}`;
|
||||
// E3: автоматически создаём поисковую операцию для карточки.
|
||||
try {
|
||||
await apiFetch('/api/v1/operations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: `Поиск: ${formData.child_name || 'без имени'}, ${formData.age_years || '?'} лет`,
|
||||
case_id: result.id,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
/* Операция не блокирует анализ (создастся позже вручную) */
|
||||
}
|
||||
|
||||
// Перенаправляем на страницу анализа с ID карточки.
|
||||
// SPA-переход: полная перезагрузка теряла состояние приложения.
|
||||
navigate(`/analysis/${result.id}`);
|
||||
|
||||
} catch (err) {
|
||||
setErrors([err.message || "Ошибка при отправке данных"]);
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
const Step3Circumstances = ({ data, updateData }) => {
|
||||
// «Прошло времени» — автоподсчёт от времени потери (пересчёт каждые 30 с,
|
||||
// чтобы долго открытая форма не показывала устаревшее значение).
|
||||
useEffect(() => {
|
||||
if (!data.lost_time) return;
|
||||
const recalc = () => {
|
||||
const lost = new Date(data.lost_time);
|
||||
if (isNaN(lost.getTime())) return;
|
||||
const hours = (Date.now() - lost.getTime()) / 3600000;
|
||||
if (hours >= 0) {
|
||||
updateData({ elapsed_hours: Math.round(hours * 10) / 10 });
|
||||
}
|
||||
};
|
||||
recalc();
|
||||
const timer = setInterval(recalc, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [data.lost_time]);
|
||||
|
||||
return (
|
||||
<div className="form-step">
|
||||
<h2>Шаг 3: Обстоятельства потери</h2>
|
||||
@@ -59,22 +76,17 @@ const Step3Circumstances = ({ data, updateData }) => {
|
||||
<label>Прошло времени с момента потери (часов)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.elapsed_hours || ""}
|
||||
onChange={(e) => updateData({ elapsed_hours: parseFloat(e.target.value) })}
|
||||
value={data.elapsed_hours ?? ""}
|
||||
onChange={(e) => updateData({ elapsed_hours: e.target.value === "" ? null : parseFloat(e.target.value) })}
|
||||
min="0"
|
||||
step="0.5"
|
||||
placeholder="Например: 2.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Последнее известное место *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.last_known_place || ""}
|
||||
onChange={(e) => updateData({ last_known_place: e.target.value })}
|
||||
placeholder="Деревня Заречье, у магазина"
|
||||
placeholder="Рассчитается из времени потери"
|
||||
/>
|
||||
<p style={{ fontSize: "12px", color: "#8b949e", marginTop: "4px" }}>
|
||||
{data.lost_time
|
||||
? "Заполняется автоматически от времени потери; можно скорректировать"
|
||||
: "Заполните дату и время потери — посчитается автоматически"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { apiFetch } from "../../api/client";
|
||||
|
||||
const Step4Environment = ({ data, updateData }) => {
|
||||
const toggleTerrain = (terrain) => {
|
||||
@@ -25,37 +26,43 @@ const Step4Environment = ({ data, updateData }) => {
|
||||
}
|
||||
}, [data.lost_time]);
|
||||
|
||||
const handleGetLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
alert("Геолокация не поддерживается вашим браузером");
|
||||
const handleGeocode = async () => {
|
||||
const query = (data.tnp_address || "").trim();
|
||||
if (query.length < 3) {
|
||||
updateData({ geocode_error: "Введите адрес (минимум 3 символа)" });
|
||||
return;
|
||||
}
|
||||
|
||||
updateData({ gps_loading: true });
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
updateData({
|
||||
tnp_lat: position.coords.latitude.toFixed(6),
|
||||
tnp_lon: position.coords.longitude.toFixed(6),
|
||||
gps_loading: false
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
alert("Ошибка получения координат: " + error.message);
|
||||
updateData({ gps_loading: false });
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0
|
||||
updateData({ geocode_loading: true, geocode_error: null, geocode_found: null });
|
||||
try {
|
||||
const resp = await apiFetch(`/api/v1/geocode?q=${encodeURIComponent(query)}&limit=1`);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `Ошибка ${resp.status}`);
|
||||
}
|
||||
);
|
||||
const result = await resp.json();
|
||||
const hit = result.results && result.results[0];
|
||||
if (!hit) {
|
||||
updateData({
|
||||
geocode_loading: false,
|
||||
geocode_error: "Адрес не найден. Уточните: пункт, район, адрес.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateData({
|
||||
tnp_lat: hit.lat.toFixed(6),
|
||||
tnp_lon: hit.lng.toFixed(6),
|
||||
geocode_loading: false,
|
||||
geocode_error: null,
|
||||
geocode_found: hit.display_name,
|
||||
});
|
||||
} catch (e) {
|
||||
updateData({ geocode_loading: false, geocode_error: "Не удалось найти адрес: " + e.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-step">
|
||||
<h2>Шаг 5: Среда и местность</h2>
|
||||
<h2>Среда и местность</h2>
|
||||
|
||||
{/* Сезон определяется автоматически, показываем только для информации */}
|
||||
{data.season && (
|
||||
@@ -221,16 +228,6 @@ const Step4Environment = ({ data, updateData }) => {
|
||||
|
||||
<div className="form-group">
|
||||
<label>Координаты точки потери (GPS) *</label>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="gps-button"
|
||||
onClick={handleGetLocation}
|
||||
disabled={data.gps_loading}
|
||||
>
|
||||
{data.gps_loading ? "Получение координат..." : "📍 Получить текущие координаты"}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "12px" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: "12px", color: "#8b949e" }}>Широта</label>
|
||||
@@ -256,16 +253,40 @@ const Step4Environment = ({ data, updateData }) => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Или введите адрес</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.tnp_address || ""}
|
||||
onChange={(e) => updateData({ tnp_address: e.target.value })}
|
||||
placeholder="Минск, ул. Ленина 10"
|
||||
/>
|
||||
<p style={{ fontSize: "12px", color: "#8b949e", marginTop: "4px" }}>
|
||||
Адрес будет преобразован в координаты автоматически
|
||||
</p>
|
||||
<label>Или найдите координаты по адресу</label>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<input
|
||||
type="text"
|
||||
style={{ flex: 1, minWidth: 0, width: "auto", height: "44px" }}
|
||||
value={data.tnp_address || ""}
|
||||
onChange={(e) => updateData({ tnp_address: e.target.value })}
|
||||
placeholder="Минск, ул. Ленина 10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="gps-button"
|
||||
onClick={handleGeocode}
|
||||
disabled={data.geocode_loading || !(data.tnp_address || "").trim()}
|
||||
style={{ width: "auto", flexShrink: 0, whiteSpace: "nowrap" }}
|
||||
>
|
||||
{data.geocode_loading ? "Поиск..." : "🔎 Найти координаты"}
|
||||
</button>
|
||||
</div>
|
||||
{data.geocode_error && (
|
||||
<p style={{ fontSize: "12px", color: "#f85149", marginTop: "4px" }}>
|
||||
{data.geocode_error}
|
||||
</p>
|
||||
)}
|
||||
{data.geocode_found && (
|
||||
<p style={{ fontSize: "12px", color: "#3fb950", marginTop: "4px" }}>
|
||||
Найдено: {data.geocode_found}
|
||||
</p>
|
||||
)}
|
||||
{!data.geocode_error && !data.geocode_found && (
|
||||
<p style={{ fontSize: "12px", color: "#8b949e", marginTop: "4px" }}>
|
||||
Формат «пункт, район, адрес». Координаты заполнятся в поля выше.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const Step5Resources = ({ data, updateData }) => {
|
||||
const toggleEquipment = (equipment) => {
|
||||
const current = data.equipment || [];
|
||||
const updated = current.includes(equipment)
|
||||
? current.filter((e) => e !== equipment)
|
||||
: [...current, equipment];
|
||||
updateData({ equipment: updated });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-step">
|
||||
<h2>Шаг 5: Ресурсы для поиска</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Количество поисковых групп</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.search_groups || ""}
|
||||
onChange={(e) => updateData({ search_groups: parseInt(e.target.value) })}
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="Например: 3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Количество человек в поиске</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.search_people || ""}
|
||||
onChange={(e) => updateData({ search_people: parseInt(e.target.value) })}
|
||||
min="0"
|
||||
max="1000"
|
||||
placeholder="Например: 25"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Поисковая собака</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={data.has_dog === true ? "selected" : ""}
|
||||
onClick={() => updateData({ has_dog: true })}
|
||||
>
|
||||
Да
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.has_dog === false ? "selected" : ""}
|
||||
onClick={() => updateData({ has_dog: false })}
|
||||
>
|
||||
Нет
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Дополнительное оборудование (можно выбрать несколько)</label>
|
||||
<div className="checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("дрон")}
|
||||
onChange={() => toggleEquipment("дрон")}
|
||||
/>
|
||||
Дрон / БПЛА
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("тепловизор")}
|
||||
onChange={() => toggleEquipment("тепловизор")}
|
||||
/>
|
||||
Тепловизор
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("квадроцикл")}
|
||||
onChange={() => toggleEquipment("квадроцикл")}
|
||||
/>
|
||||
Квадроцикл / мотовездеход
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("лодка")}
|
||||
onChange={() => toggleEquipment("лодка")}
|
||||
/>
|
||||
Лодка
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("вертолёт")}
|
||||
onChange={() => toggleEquipment("вертолёт")}
|
||||
/>
|
||||
Вертолёт
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("рация")}
|
||||
onChange={() => toggleEquipment("рация")}
|
||||
/>
|
||||
Рации
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("gps")}
|
||||
onChange={() => toggleEquipment("gps")}
|
||||
/>
|
||||
GPS навигаторы
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("медик")}
|
||||
onChange={() => toggleEquipment("медик")}
|
||||
/>
|
||||
Медицинская бригада
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("прожектор")}
|
||||
onChange={() => toggleEquipment("прожектор")}
|
||||
/>
|
||||
Прожекторы / фонари
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("мегафон")}
|
||||
onChange={() => toggleEquipment("мегафон")}
|
||||
/>
|
||||
Мегафон / громкоговоритель
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Дополнительная информация о ресурсах</label>
|
||||
<textarea
|
||||
value={data.resources_notes || ""}
|
||||
onChange={(e) => updateData({ resources_notes: e.target.value })}
|
||||
placeholder="Особенности организации поиска, доступные ресурсы, ограничения..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step5Resources;
|
||||
@@ -1,146 +0,0 @@
|
||||
.map-view-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
background: #0a0c0f;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.map-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 10px 16px;
|
||||
background: #161b22;
|
||||
color: #e6edf3;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.control-btn:hover:not(:disabled) {
|
||||
background: #21262d;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background: #ff4d00;
|
||||
color: #ffffff;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
|
||||
.control-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ТНП маркер */
|
||||
.tnp-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tnp-marker-inner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #ff4d00;
|
||||
color: #ffffff;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(255, 77, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Стрелка направления */
|
||||
.direction-arrow {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.direction-arrow div {
|
||||
font-size: 30px;
|
||||
color: #ff4d00;
|
||||
text-shadow: 0 0 4px rgba(255, 77, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Leaflet popup стилизация */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: #161b22;
|
||||
color: #e6edf3;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.leaflet-popup-content b {
|
||||
color: #ff4d00;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.leaflet-popup-content small {
|
||||
color: #8b949e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.leaflet-popup-close-button {
|
||||
color: #e6edf3 !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-close-button:hover {
|
||||
color: #ff4d00 !important;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.map-view-container {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tnp-marker-inner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "leaflet.heat";
|
||||
import "./MapView.css";
|
||||
|
||||
// Fix Leaflet default marker icons
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
|
||||
const MapView = ({
|
||||
tnpLat,
|
||||
tnpLon,
|
||||
zones = [],
|
||||
direction = null,
|
||||
heatmapData = [],
|
||||
showHeatmap = false
|
||||
}) => {
|
||||
const mapRef = useRef(null);
|
||||
const mapInstanceRef = useRef(null);
|
||||
const zonesLayerRef = useRef(null);
|
||||
const heatmapLayerRef = useRef(null);
|
||||
const tnpMarkerRef = useRef(null);
|
||||
const directionArrowRef = useRef(null);
|
||||
|
||||
const [heatmapVisible, setHeatmapVisible] = useState(showHeatmap);
|
||||
|
||||
// Цвета зон из §5 ЦОУ палитры
|
||||
const ZONE_COLORS = {
|
||||
0: "#ff4d00", // Зона 1 (0-500м) - красный
|
||||
1: "#ff9f1a", // Зона 2 (500-1500м) - оранжевый
|
||||
2: "#ffd166", // Зона 3 (1500-2500м) - желтый
|
||||
};
|
||||
|
||||
// Направления в градусы
|
||||
const DIRECTION_ANGLES = {
|
||||
N: 0,
|
||||
NE: 45,
|
||||
E: 90,
|
||||
SE: 135,
|
||||
S: 180,
|
||||
SW: 225,
|
||||
W: 270,
|
||||
NW: 315,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
// Инициализация карты
|
||||
const map = L.map(mapRef.current).setView([tnpLat || 53.9, tnpLon || 27.56], 13);
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Обновление ТНП маркера
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !tnpLat || !tnpLon) return;
|
||||
|
||||
// Удаляем старый маркер
|
||||
if (tnpMarkerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(tnpMarkerRef.current);
|
||||
}
|
||||
|
||||
// Создаем красный маркер ТНП
|
||||
const tnpIcon = L.divIcon({
|
||||
className: "tnp-marker",
|
||||
html: "<div class=\"tnp-marker-inner\">ТНП</div>",
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 20],
|
||||
});
|
||||
|
||||
const marker = L.marker([tnpLat, tnpLon], { icon: tnpIcon }).addTo(mapInstanceRef.current);
|
||||
marker.bindPopup("<b>Точка начала поиска</b><br>" + tnpLat.toFixed(6) + ", " + tnpLon.toFixed(6));
|
||||
tnpMarkerRef.current = marker;
|
||||
|
||||
// Центрируем карту
|
||||
mapInstanceRef.current.setView([tnpLat, tnpLon], 13);
|
||||
}, [tnpLat, tnpLon]);
|
||||
|
||||
// Обновление стрелки направления
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !tnpLat || !tnpLon || !direction || direction === "unknown") {
|
||||
if (directionArrowRef.current) {
|
||||
mapInstanceRef.current.removeLayer(directionArrowRef.current);
|
||||
directionArrowRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Удаляем старую стрелку
|
||||
if (directionArrowRef.current) {
|
||||
mapInstanceRef.current.removeLayer(directionArrowRef.current);
|
||||
}
|
||||
|
||||
const angle = DIRECTION_ANGLES[direction];
|
||||
if (angle === undefined) return;
|
||||
|
||||
// Создаем стрелку (линия 1км в направлении)
|
||||
const distance = 1000; // метров
|
||||
const endPoint = calculateDestination(tnpLat, tnpLon, angle, distance);
|
||||
|
||||
const arrow = L.polyline(
|
||||
[[tnpLat, tnpLon], endPoint],
|
||||
{
|
||||
color: "#ff4d00",
|
||||
weight: 3,
|
||||
opacity: 0.8,
|
||||
dashArray: "10, 5",
|
||||
}
|
||||
).addTo(mapInstanceRef.current);
|
||||
|
||||
// Добавляем стрелку на конце
|
||||
const arrowHead = L.marker(endPoint, {
|
||||
icon: L.divIcon({
|
||||
className: "direction-arrow",
|
||||
html: "<div style=\"transform: rotate(" + angle + "deg)\">➤</div>",
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
}),
|
||||
}).addTo(mapInstanceRef.current);
|
||||
|
||||
directionArrowRef.current = L.layerGroup([arrow, arrowHead]).addTo(mapInstanceRef.current);
|
||||
}, [tnpLat, tnpLon, direction]);
|
||||
|
||||
// Обновление зон (секторные полигоны)
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !tnpLat || !tnpLon || zones.length === 0) {
|
||||
if (zonesLayerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(zonesLayerRef.current);
|
||||
zonesLayerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Удаляем старые зоны
|
||||
if (zonesLayerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(zonesLayerRef.current);
|
||||
}
|
||||
|
||||
const zonePolygons = [];
|
||||
|
||||
zones.forEach((zone, index) => {
|
||||
const color = ZONE_COLORS[index] || "#cccccc";
|
||||
const directionAngle = DIRECTION_ANGLES[zone.direction] || 0;
|
||||
|
||||
// Создаем секторный полигон (угол ±45° от направления)
|
||||
const sectorAngle = 90; // ширина сектора
|
||||
const startAngle = directionAngle - sectorAngle / 2;
|
||||
const endAngle = directionAngle + sectorAngle / 2;
|
||||
const radiusMeters = zone.distance_km * 1000;
|
||||
|
||||
const sectorPoints = [[tnpLat, tnpLon]]; // Начало в ТНП
|
||||
|
||||
// Генерируем точки по дуге
|
||||
for (let angle = startAngle; angle <= endAngle; angle += 5) {
|
||||
const point = calculateDestination(tnpLat, tnpLon, angle, radiusMeters);
|
||||
sectorPoints.push(point);
|
||||
}
|
||||
|
||||
sectorPoints.push([tnpLat, tnpLon]); // Замыкаем полигон
|
||||
|
||||
const polygon = L.polygon(sectorPoints, {
|
||||
color: color,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.2,
|
||||
weight: 2,
|
||||
opacity: 0.6,
|
||||
});
|
||||
|
||||
polygon.bindPopup(
|
||||
"<b>" + zone.name + "</b><br>" +
|
||||
"Приоритет: " + zone.priority + "<br>" +
|
||||
"Оценка: " + zone.score + "<br>" +
|
||||
"Расстояние: " + zone.distance_km.toFixed(2) + " км<br>" +
|
||||
"<small>" + zone.reasoning + "</small>"
|
||||
);
|
||||
|
||||
zonePolygons.push(polygon);
|
||||
});
|
||||
|
||||
zonesLayerRef.current = L.layerGroup(zonePolygons).addTo(mapInstanceRef.current);
|
||||
}, [tnpLat, tnpLon, zones]);
|
||||
|
||||
// Обновление тепловой карты
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
// Удаляем старый слой
|
||||
if (heatmapLayerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(heatmapLayerRef.current);
|
||||
heatmapLayerRef.current = null;
|
||||
}
|
||||
|
||||
if (!heatmapVisible || heatmapData.length === 0) return;
|
||||
|
||||
// Создаем heatmap слой
|
||||
const heatPoints = heatmapData.map(point => [
|
||||
point.lat,
|
||||
point.lon,
|
||||
point.intensity || 1.0
|
||||
]);
|
||||
|
||||
const heatLayer = L.heatLayer(heatPoints, {
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
maxZoom: 17,
|
||||
max: 1.0,
|
||||
gradient: {
|
||||
0.0: "#0d1117",
|
||||
0.3: "#238636",
|
||||
0.5: "#ffd166",
|
||||
0.7: "#ff9f1a",
|
||||
1.0: "#ff4d00"
|
||||
}
|
||||
}).addTo(mapInstanceRef.current);
|
||||
|
||||
heatmapLayerRef.current = heatLayer;
|
||||
}, [heatmapVisible, heatmapData]);
|
||||
|
||||
// Вычисление точки назначения (lat, lon, angle, distance)
|
||||
const calculateDestination = (lat, lon, bearing, distance) => {
|
||||
const R = 6371000; // Радиус Земли в метрах
|
||||
const φ1 = (lat * Math.PI) / 180;
|
||||
const λ1 = (lon * Math.PI) / 180;
|
||||
const θ = (bearing * Math.PI) / 180;
|
||||
|
||||
const φ2 = Math.asin(
|
||||
Math.sin(φ1) * Math.cos(distance / R) +
|
||||
Math.cos(φ1) * Math.sin(distance / R) * Math.cos(θ)
|
||||
);
|
||||
|
||||
const λ2 = λ1 + Math.atan2(
|
||||
Math.sin(θ) * Math.sin(distance / R) * Math.cos(φ1),
|
||||
Math.cos(distance / R) - Math.sin(φ1) * Math.sin(φ2)
|
||||
);
|
||||
|
||||
return [(φ2 * 180) / Math.PI, (λ2 * 180) / Math.PI];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="map-view-container">
|
||||
<div className="map-controls">
|
||||
<button
|
||||
className={heatmapVisible ? "control-btn active" : "control-btn"}
|
||||
onClick={() => setHeatmapVisible(!heatmapVisible)}
|
||||
disabled={heatmapData.length === 0}
|
||||
>
|
||||
{heatmapVisible ? "🔥 Скрыть тепловую карту" : "🔥 Показать тепловую карту"}
|
||||
</button>
|
||||
</div>
|
||||
<div ref={mapRef} className="map-view" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapView;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./MapView";
|
||||
@@ -1,353 +0,0 @@
|
||||
/* Мобильная форма — палитра из §5 контекста */
|
||||
.mobile-form {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.mobile-form h1 {
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: var(--bg-secondary);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-section h2 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type=number],
|
||||
.form-group input[type=text],
|
||||
.form-group input[type=time],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
padding: 12px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-secondary);
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
}
|
||||
|
||||
.button-group button:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.button-group button.selected {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--input-bg);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
border: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
.checkbox-label:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.checkbox-label input[type=checkbox] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.psychotype-section {
|
||||
background: var(--bg-secondary);
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
.psychotype-question {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.psychotype-question:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.question-label {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button-group-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.psychotype-btn {
|
||||
padding: 14px 16px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-secondary);
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
}
|
||||
|
||||
.psychotype-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.psychotype-btn.selected {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* GPS кнопка */
|
||||
.btn-gps {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--btn-gps, #0a84ff);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-gps:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Кнопка подтверждения */
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--btn-confirm, var(--accent));
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 24px;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.submit-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
background: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Success and Error messages */
|
||||
.success-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
margin-bottom: 24px;
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.success-message h2 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.success-message p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 16px;
|
||||
background: var(--danger);
|
||||
border-left: 4px solid var(--danger);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive для больших экранов */
|
||||
@media (min-width: 768px) {
|
||||
.mobile-form {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Оптимизация для маленьких экранов */
|
||||
@media (max-width: 480px) {
|
||||
.mobile-form {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mobile-form h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-section h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Зоны поиска с цветами из палитры */
|
||||
.zone-card {
|
||||
border-left: 4px solid;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.zone-card.priority-1 {
|
||||
border-left-color: var(--zone-1);
|
||||
}
|
||||
|
||||
.zone-card.priority-2 {
|
||||
border-left-color: var(--zone-2);
|
||||
}
|
||||
|
||||
.zone-card.priority-3 {
|
||||
border-left-color: var(--zone-3);
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import './MobileForm.css';
|
||||
|
||||
const MobileForm = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
age: '',
|
||||
gender: '',
|
||||
terrain: '',
|
||||
weather: '',
|
||||
time_missing: '',
|
||||
last_location: '',
|
||||
circumstances: '',
|
||||
physical_condition: '',
|
||||
experience: '',
|
||||
season: '',
|
||||
diagnosis_type: [],
|
||||
psychotype_answers: {}
|
||||
});
|
||||
|
||||
const handlePsychotypeAnswer = (question, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
psychotype_answers: {
|
||||
...prev.psychotype_answers,
|
||||
[question]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const isPsychotypeSelected = (question, value) => {
|
||||
return formData.psychotype_answers?.[question] === value;
|
||||
};
|
||||
|
||||
const handleDiagnosisChange = (diagnosis, checked) => {
|
||||
const current = formData.diagnosis_type || [];
|
||||
setFormData({
|
||||
...formData,
|
||||
diagnosis_type: checked
|
||||
? [...current, diagnosis]
|
||||
: current.filter(d => d !== diagnosis)
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
console.log('Form data:', formData);
|
||||
|
||||
// Отправка на backend
|
||||
try {
|
||||
const response = await fetch('http://192.168.0.99:8000/api/v1/analyze/combined', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
const result = await response.json();
|
||||
console.log('Analysis result:', result);
|
||||
alert('Анализ выполнен! Смотри консоль для результатов.');
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Ошибка при отправке данных');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-form">
|
||||
<h1>Карточка пропавшего</h1>
|
||||
|
||||
{/* Базовая информация */}
|
||||
<section className="form-section">
|
||||
<h2>Основная информация</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Возраст</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Лет"
|
||||
value={formData.age}
|
||||
onChange={(e) => setFormData({...formData, age: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Пол</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
className={formData.gender === 'мужской' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, gender: 'мужской'})}
|
||||
>
|
||||
Мужской
|
||||
</button>
|
||||
<button
|
||||
className={formData.gender === 'женский' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, gender: 'женский'})}
|
||||
>
|
||||
Женский
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Сезон</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
className={formData.season === 'winter' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, season: 'winter'})}
|
||||
>
|
||||
Зима
|
||||
</button>
|
||||
<button
|
||||
className={formData.season === 'spring' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, season: 'spring'})}
|
||||
>
|
||||
Весна
|
||||
</button>
|
||||
<button
|
||||
className={formData.season === 'summer' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, season: 'summer'})}
|
||||
>
|
||||
Лето
|
||||
</button>
|
||||
<button
|
||||
className={formData.season === 'autumn' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, season: 'autumn'})}
|
||||
>
|
||||
Осень
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Физическое состояние</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Например: хорошее, слабое, травмы"
|
||||
value={formData.physical_condition}
|
||||
onChange={(e) => setFormData({...formData, physical_condition: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Обстоятельства пропажи */}
|
||||
<section className="form-section">
|
||||
<h2>Обстоятельства пропажи</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Местность</label>
|
||||
<select
|
||||
value={formData.terrain}
|
||||
onChange={(e) => setFormData({...formData, terrain: e.target.value})}
|
||||
>
|
||||
<option value="">Выберите...</option>
|
||||
<option value="лес">Лес</option>
|
||||
<option value="поле">Поле</option>
|
||||
<option value="горы">Горы</option>
|
||||
<option value="город">Город</option>
|
||||
<option value="болото">Болото</option>
|
||||
<option value="водоём">Водоём</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Погода</label>
|
||||
<select
|
||||
value={formData.weather}
|
||||
onChange={(e) => setFormData({...formData, weather: e.target.value})}
|
||||
>
|
||||
<option value="">Выберите...</option>
|
||||
<option value="ясно">Ясно</option>
|
||||
<option value="облачно">Облачно</option>
|
||||
<option value="дождь">Дождь</option>
|
||||
<option value="снег">Снег</option>
|
||||
<option value="туман">Туман</option>
|
||||
<option value="жара">Жара</option>
|
||||
<option value="холод">Холод</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Время пропажи</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Например: 2 часа, 6 часов, 1 день"
|
||||
value={formData.time_missing}
|
||||
onChange={(e) => setFormData({...formData, time_missing: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Последнее известное местоположение</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Например: лесная тропа у озера"
|
||||
value={formData.last_location}
|
||||
onChange={(e) => setFormData({...formData, last_location: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Обстоятельства</label>
|
||||
<textarea
|
||||
placeholder="Например: вышел за грибами, не вернулся к вечеру"
|
||||
value={formData.circumstances}
|
||||
onChange={(e) => setFormData({...formData, circumstances: e.target.value})}
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Опыт нахождения на природе</label>
|
||||
<select
|
||||
value={formData.experience}
|
||||
onChange={(e) => setFormData({...formData, experience: e.target.value})}
|
||||
>
|
||||
<option value="">Выберите...</option>
|
||||
<option value="опытный">Опытный</option>
|
||||
<option value="средний">Средний опыт</option>
|
||||
<option value="новичок">Новичок</option>
|
||||
<option value="нет опыта">Нет опыта</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Диагнозы */}
|
||||
<section className="form-section">
|
||||
<h2>Диагнозы (если есть)</h2>
|
||||
<div className="checkbox-group">
|
||||
{['РАС', 'эпилепсия', 'СДВГ', 'деменция', 'Альцгеймер'].map(diagnosis => (
|
||||
<label key={diagnosis} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.diagnosis_type?.includes(diagnosis)}
|
||||
onChange={(e) => handleDiagnosisChange(diagnosis, e.target.checked)}
|
||||
/>
|
||||
{diagnosis}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Блок психотипа */}
|
||||
<section className="form-section psychotype-section">
|
||||
<div className="section-header">
|
||||
<h2>Поведение ребёнка (спросить родителей)</h2>
|
||||
<p className="section-subtitle">Помогает уточнить зону поиска</p>
|
||||
</div>
|
||||
|
||||
{/* Вопрос 1: Незнакомая обстановка */}
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Как ведёт себя в незнакомой обстановке?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'explore') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'explore')}
|
||||
>
|
||||
Активно исследует
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'wait') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'wait')}
|
||||
>
|
||||
Ждёт
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'freeze') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'freeze')}
|
||||
>
|
||||
Замирает
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'panic') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'panic')}
|
||||
>
|
||||
Паникует
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Вопрос 2: Реакция на неудачи */}
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Реакция на неудачи?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'active') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'active')}
|
||||
>
|
||||
Злится, кричит
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'cry') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'cry')}
|
||||
>
|
||||
Плачет, замыкается
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'calm') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'calm')}
|
||||
>
|
||||
Спокойно ищет выход
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Вопрос 3: Лидер или ведомый */}
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Лидер или ведомый?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'leader') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'leader')}
|
||||
>
|
||||
Лидер
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'sometimes') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'sometimes')}
|
||||
>
|
||||
Иногда
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'follower') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'follower')}
|
||||
>
|
||||
Всегда ведомый
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Вопрос 4: Любит рисковать */}
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Любит рисковать?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'high') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'high')}
|
||||
>
|
||||
Очень
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'medium') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'medium')}
|
||||
>
|
||||
Иногда
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'low') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'low')}
|
||||
>
|
||||
Нет, осторожный
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Кнопка отправки */}
|
||||
<button className="submit-btn" onClick={handleSubmit}>
|
||||
Создать карточку и получить анализ
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileForm;
|
||||
@@ -1,296 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import './MobileForm.css';
|
||||
|
||||
interface PsychotypeAnswers {
|
||||
unfamiliar_env?: string;
|
||||
stress_reaction?: string;
|
||||
group_role?: string;
|
||||
risk_taking?: string;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
age?: number;
|
||||
gender?: string;
|
||||
diagnosis_type?: string[];
|
||||
psychotype_answers?: PsychotypeAnswers;
|
||||
}
|
||||
|
||||
const MobileForm: React.FC = () => {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
diagnosis_type: [],
|
||||
psychotype_answers: {}
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handlePsychotypeAnswer = (question: keyof PsychotypeAnswers, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
psychotype_answers: {
|
||||
...prev.psychotype_answers,
|
||||
[question]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const isPsychotypeSelected = (question: keyof PsychotypeAnswers, value: string) => {
|
||||
return formData.psychotype_answers?.[question] === value;
|
||||
};
|
||||
|
||||
const validateForm = (): string | null => {
|
||||
if (!formData.age || formData.age < 0 || formData.age > 120) {
|
||||
return 'Укажите корректный возраст';
|
||||
}
|
||||
if (!formData.gender) {
|
||||
return 'Выберите пол';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/v1/cases', formData);
|
||||
console.log('Case created:', response.data);
|
||||
setSuccess(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setFormData({
|
||||
diagnosis_type: [],
|
||||
psychotype_answers: {}
|
||||
});
|
||||
setSuccess(false);
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Ошибка при создании карточки');
|
||||
console.error('Submit error:', err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="mobile-form">
|
||||
<div className="success-message">
|
||||
<div className="success-icon">✓</div>
|
||||
<h2>Карточка создана!</h2>
|
||||
<p>Данные успешно сохранены</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-form">
|
||||
<h1>Карточка пропавшего</h1>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="form-section">
|
||||
<h2>Основная информация</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Возраст *</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Лет"
|
||||
value={formData.age || ''}
|
||||
onChange={(e) => setFormData({...formData, age: parseInt(e.target.value)})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Пол *</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={formData.gender === 'male' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, gender: 'male'})}
|
||||
>
|
||||
Мужской
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={formData.gender === 'female' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, gender: 'female'})}
|
||||
>
|
||||
Женский
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-section">
|
||||
<h2>Диагнозы (если есть)</h2>
|
||||
<div className="checkbox-group">
|
||||
{['РАС', 'эпилепсия', 'СДВГ', 'деменция'].map(diagnosis => (
|
||||
<label key={diagnosis} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.diagnosis_type?.includes(diagnosis)}
|
||||
onChange={(e) => {
|
||||
const current = formData.diagnosis_type || [];
|
||||
setFormData({
|
||||
...formData,
|
||||
diagnosis_type: e.target.checked
|
||||
? [...current, diagnosis]
|
||||
: current.filter(d => d !== diagnosis)
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{diagnosis}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-section psychotype-section">
|
||||
<div className="section-header">
|
||||
<h2>Поведение ребёнка (спросить родителей)</h2>
|
||||
<p className="section-subtitle">Помогает уточнить зону поиска</p>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Как ведёт себя в незнакомой обстановке?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'explore') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'explore')}
|
||||
>
|
||||
Активно исследует
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'wait') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'wait')}
|
||||
>
|
||||
Ждёт
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'freeze') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'freeze')}
|
||||
>
|
||||
Замирает
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'panic') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'panic')}
|
||||
>
|
||||
Паникует
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Реакция на неудачи?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'active') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'active')}
|
||||
>
|
||||
Злится, кричит
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'cry') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'cry')}
|
||||
>
|
||||
Плачет, замыкается
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'calm') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'calm')}
|
||||
>
|
||||
Спокойно ищет выход
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Лидер или ведомый?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'leader') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'leader')}
|
||||
>
|
||||
Лидер
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'sometimes') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'sometimes')}
|
||||
>
|
||||
Иногда
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'follower') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'follower')}
|
||||
>
|
||||
Всегда ведомый
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Любит рисковать?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('risk_taking', 'high') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'high')}
|
||||
>
|
||||
Очень
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsycotypeSelected('risk_taking', 'medium') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'medium')}
|
||||
>
|
||||
Иногда
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('risk_taking', 'low') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'low')}
|
||||
>
|
||||
Нет, осторожный
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
className="submit-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Создание...' : 'Создать карточку'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileForm;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
/**
|
||||
* Пускает дальше только с валидным токеном. Пока идёт первая проверка
|
||||
* (/auth/me) — держим заглушку, иначе при живом токене будет ложный редирект.
|
||||
* roles задаёт минимальные права: бэкенд всё равно проверяет их сам
|
||||
* (require_roles), это лишь чтобы не показывать заведомо пустую страницу.
|
||||
*/
|
||||
function ProtectedRoute({ children, roles }) {
|
||||
const { isAuthenticated, loading, user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <div className="login-loading">Проверка сессии…</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
if (roles && !roles.includes(user.role)) {
|
||||
return (
|
||||
<div className="login-loading">
|
||||
Недостаточно прав: нужна роль {roles.join(' или ')}, у вас «{user.role}».
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -1,416 +0,0 @@
|
||||
.result-panel {
|
||||
background: #0a0c0f;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.result-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #e6edf3;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.execution-time {
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
padding: 4px 10px;
|
||||
background: #161b22;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.services-badge {
|
||||
font-size: 13px;
|
||||
color: #e6edf3;
|
||||
padding: 4px 10px;
|
||||
background: #238636;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Блок срочности */
|
||||
.urgency-block {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.urgency-icon {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.urgency-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.urgency-label {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.urgency-summary {
|
||||
font-size: 14px;
|
||||
color: #e6edf3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Behavioral Profile карточки */
|
||||
.behavioral-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.behavioral-section h3 {
|
||||
font-size: 18px;
|
||||
color: #e6edf3;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.behavioral-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.behavioral-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-left: 4px solid;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.behavioral-card:hover {
|
||||
background: #1c2128;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.card-points {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-points li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Психотип */
|
||||
.psychotype-section {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.psychotype-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
color: #ff9f1a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.psychotype-description {
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
margin-bottom: 16px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.psychotype-recommendations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.recommendation-item {
|
||||
font-size: 13px;
|
||||
color: #e6edf3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.recommendation-item strong {
|
||||
color: #ff9f1a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Немедленные действия */
|
||||
.actions-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actions-section h3 {
|
||||
font-size: 18px;
|
||||
color: #e6edf3;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-left: 3px solid #ff4d00;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.action-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #ff4d00;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #e6edf3;
|
||||
line-height: 1.6;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Зоны */
|
||||
.zones-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.zones-section h3 {
|
||||
font-size: 18px;
|
||||
color: #e6edf3;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.zones-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.zone-item {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.zone-item:hover {
|
||||
background: #1c2128;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
|
||||
.zone-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.zone-priority {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #ff4d00;
|
||||
color: #ffffff;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.zone-name {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.zone-score {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #ff9f1a;
|
||||
}
|
||||
|
||||
.zone-details {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.zone-distance,
|
||||
.zone-direction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.zone-reasoning {
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Ключевые объекты */
|
||||
.locations-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.locations-section h3 {
|
||||
font-size: 18px;
|
||||
color: #e6edf3;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.locations-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.location-tag {
|
||||
padding: 8px 14px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #e6edf3;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.location-tag:hover {
|
||||
background: #21262d;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
|
||||
/* Прогноз поведения */
|
||||
.prediction-section {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.prediction-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
color: #e6edf3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prediction-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.result-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.behavioral-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.urgency-block {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.zone-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.locations-tags {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.location-tag {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
import React from "react";
|
||||
import "./ResultPanel.css";
|
||||
|
||||
const ResultPanel = ({ analysisResult }) => {
|
||||
if (!analysisResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
urgency,
|
||||
zones = [],
|
||||
key_locations = [],
|
||||
immediate_actions = [],
|
||||
psychotype,
|
||||
psychotype_modifiers,
|
||||
psychotype_recommendations,
|
||||
behavioral_prediction,
|
||||
summary,
|
||||
execution_time_ms,
|
||||
services_used = []
|
||||
} = analysisResult;
|
||||
|
||||
// Цвета срочности
|
||||
const urgencyConfig = {
|
||||
"критическая": {
|
||||
color: "#ff4d00",
|
||||
label: "КРИТИЧЕСКАЯ",
|
||||
icon: "🚨"
|
||||
},
|
||||
"высокая": {
|
||||
color: "#ff9f1a",
|
||||
label: "ВЫСОКАЯ",
|
||||
icon: "⚠️"
|
||||
},
|
||||
"средняя": {
|
||||
color: "#ffd166",
|
||||
label: "УМЕРЕННАЯ",
|
||||
icon: "ℹ️"
|
||||
},
|
||||
"низкая": {
|
||||
color: "#238636",
|
||||
label: "НИЗКАЯ",
|
||||
icon: "✓"
|
||||
}
|
||||
};
|
||||
|
||||
const urgencyInfo = urgencyConfig[urgency] || urgencyConfig["средняя"];
|
||||
|
||||
// Behavioral Profile карточки
|
||||
const getBehavioralCards = () => {
|
||||
const cards = [];
|
||||
|
||||
// РАС карточка (красная)
|
||||
if (analysisResult.diagnosis_type?.includes("РАС")) {
|
||||
cards.push({
|
||||
type: "РАС",
|
||||
color: "#ff4d00",
|
||||
icon: "🧩",
|
||||
title: "Расстройство аутистического спектра",
|
||||
points: [
|
||||
"Может не откликаться на имя",
|
||||
"Избегает зрительного контакта",
|
||||
"Предпочитает укромные места",
|
||||
"Не боится опасности"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Эпилепсия карточка (оранжевая)
|
||||
if (analysisResult.diagnosis_type?.includes("эпилепсия")) {
|
||||
cards.push({
|
||||
type: "Эпилепсия",
|
||||
color: "#ff9f1a",
|
||||
icon: "⚡",
|
||||
title: "Эпилепсия",
|
||||
points: [
|
||||
"Риск приступа при стрессе",
|
||||
"Может быть дезориентирован",
|
||||
"Требуется медицинская помощь",
|
||||
"Проверить водоёмы и ямы"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Велосипед карточка (синяя)
|
||||
if (analysisResult.has_transport === "bike") {
|
||||
cards.push({
|
||||
type: "Велосипед",
|
||||
color: "#58a6ff",
|
||||
icon: "🚴",
|
||||
title: "Есть велосипед",
|
||||
points: [
|
||||
"Радиус поиска увеличен в 3-5 раз",
|
||||
"Проверить дороги и тропы",
|
||||
"Может преодолеть большое расстояние",
|
||||
"Искать вдоль маршрутов"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Самокат карточка (синяя)
|
||||
if (analysisResult.has_transport === "scooter") {
|
||||
cards.push({
|
||||
type: "Самокат",
|
||||
color: "#58a6ff",
|
||||
icon: "🛴",
|
||||
title: "Есть самокат",
|
||||
points: [
|
||||
"Радиус поиска увеличен в 2-3 раза",
|
||||
"Проверить асфальтированные дороги",
|
||||
"Ограничен рельефом местности",
|
||||
"Искать вдоль ровных путей"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Не умеет плавать (красная)
|
||||
if (analysisResult.cant_swim) {
|
||||
cards.push({
|
||||
type: "Не плавает",
|
||||
color: "#ff4d00",
|
||||
icon: "🌊",
|
||||
title: "Не умеет плавать",
|
||||
points: [
|
||||
"КРИТИЧНО: проверить водоёмы первыми",
|
||||
"Высокий риск утопления",
|
||||
"Привлечь водолазов",
|
||||
"Обследовать берега и мосты"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return cards;
|
||||
};
|
||||
|
||||
const behavioralCards = getBehavioralCards();
|
||||
|
||||
return (
|
||||
<div className="result-panel">
|
||||
<div className="result-header">
|
||||
<h2>Результаты анализа</h2>
|
||||
<div className="result-meta">
|
||||
<span className="execution-time">
|
||||
⏱ {(execution_time_ms / 1000).toFixed(2)}с
|
||||
</span>
|
||||
<span className="services-badge">
|
||||
{services_used.length} сервисов
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Блок срочности */}
|
||||
<div
|
||||
className="urgency-block"
|
||||
style={{
|
||||
borderLeftColor: urgencyInfo.color,
|
||||
background: urgencyInfo.color + "15"
|
||||
}}
|
||||
>
|
||||
<div className="urgency-icon">{urgencyInfo.icon}</div>
|
||||
<div className="urgency-content">
|
||||
<div
|
||||
className="urgency-label"
|
||||
style={{ color: urgencyInfo.color }}
|
||||
>
|
||||
{urgencyInfo.label}
|
||||
</div>
|
||||
<div className="urgency-summary">{summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Behavioral Profile карточки */}
|
||||
{behavioralCards.length > 0 && (
|
||||
<div className="behavioral-section">
|
||||
<h3>Поведенческий профиль</h3>
|
||||
<div className="behavioral-cards">
|
||||
{behavioralCards.map((card, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="behavioral-card"
|
||||
style={{ borderLeftColor: card.color }}
|
||||
>
|
||||
<div className="card-header">
|
||||
<span className="card-icon">{card.icon}</span>
|
||||
<span className="card-title">{card.title}</span>
|
||||
</div>
|
||||
<ul className="card-points">
|
||||
{card.points.map((point, i) => (
|
||||
<li key={i}>{point}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Психотип */}
|
||||
{psychotype && (
|
||||
<div className="psychotype-section">
|
||||
<h3>Психотип: {psychotype}</h3>
|
||||
{psychotype_modifiers && (
|
||||
<div className="psychotype-description">
|
||||
{psychotype_modifiers.description}
|
||||
</div>
|
||||
)}
|
||||
{psychotype_recommendations && (
|
||||
<div className="psychotype-recommendations">
|
||||
<div className="recommendation-item">
|
||||
<strong>Приоритетные зоны:</strong> {psychotype_recommendations.priority_zones}
|
||||
</div>
|
||||
<div className="recommendation-item">
|
||||
<strong>Паттерн поиска:</strong> {psychotype_recommendations.search_pattern}
|
||||
</div>
|
||||
<div className="recommendation-item">
|
||||
<strong>Ключевые локации:</strong> {psychotype_recommendations.key_locations}
|
||||
</div>
|
||||
<div className="recommendation-item">
|
||||
<strong>Коммуникация:</strong> {psychotype_recommendations.communication}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Немедленные действия */}
|
||||
{immediate_actions.length > 0 && (
|
||||
<div className="actions-section">
|
||||
<h3>Немедленные действия</h3>
|
||||
<ol className="actions-list">
|
||||
{immediate_actions.slice(0, 3).map((action, index) => (
|
||||
<li key={index} className="action-item">
|
||||
<span className="action-number">{index + 1}</span>
|
||||
<span className="action-text">{action}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Зоны с приоритетом */}
|
||||
{zones.length > 0 && (
|
||||
<div className="zones-section">
|
||||
<h3>Приоритетные зоны поиска</h3>
|
||||
<div className="zones-list">
|
||||
{zones.slice(0, 5).map((zone, index) => (
|
||||
<div key={index} className="zone-item">
|
||||
<div className="zone-header">
|
||||
<span className="zone-priority">#{zone.priority}</span>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
<span className="zone-score">{zone.score}%</span>
|
||||
</div>
|
||||
<div className="zone-details">
|
||||
<span className="zone-distance">📍 {zone.distance_km.toFixed(2)} км</span>
|
||||
<span className="zone-direction">🧭 {zone.direction}</span>
|
||||
</div>
|
||||
<div className="zone-reasoning">{zone.reasoning}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ключевые объекты */}
|
||||
{key_locations.length > 0 && (
|
||||
<div className="locations-section">
|
||||
<h3>Ключевые объекты для проверки</h3>
|
||||
<div className="locations-tags">
|
||||
{key_locations.map((location, index) => (
|
||||
<span key={index} className="location-tag">
|
||||
{location}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Прогноз поведения */}
|
||||
{behavioral_prediction && (
|
||||
<div className="prediction-section">
|
||||
<h3>Прогноз поведения</h3>
|
||||
<p className="prediction-text">{behavioral_prediction}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultPanel;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./ResultPanel";
|
||||
@@ -1,23 +1,23 @@
|
||||
import React from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polygon } from 'react-leaflet';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polygon, Polyline } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import L from 'leaflet';
|
||||
|
||||
// Fix default marker icon issue with webpack
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
|
||||
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures, fieldAreas }) => {
|
||||
const tnpPosition = [tnpLat || 53.9, tnpLon || 27.56];
|
||||
const distance = maxDistance || 5.0;
|
||||
|
||||
// Функция для создания точек сектора
|
||||
const createSectorPoints = (centerLat, centerLon, radiusKm, startAngle, endAngle, points = 20) => {
|
||||
const coords = [[centerLat, centerLon]]; // Начинаем с центра
|
||||
// Функция для создания точек дуги
|
||||
const createArcPoints = (centerLat, centerLon, radiusKm, startAngle, endAngle, points = 24) => {
|
||||
const coords = [];
|
||||
|
||||
// Конвертируем км в градусы (приблизительно)
|
||||
const latPerKm = 1 / 111.0;
|
||||
@@ -34,10 +34,16 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
|
||||
coords.push([lat, lon]);
|
||||
}
|
||||
|
||||
coords.push([centerLat, centerLon]); // Замыкаем в центре
|
||||
return coords;
|
||||
};
|
||||
|
||||
// Функция для создания точек сектора (пирог: дуга + центр)
|
||||
const createSectorPoints = (centerLat, centerLon, radiusKm, startAngle, endAngle, points = 24) => {
|
||||
return [[centerLat, centerLon],
|
||||
...createArcPoints(centerLat, centerLon, radiusKm, startAngle, endAngle, points),
|
||||
[centerLat, centerLon]];
|
||||
};
|
||||
|
||||
// Направления и их углы
|
||||
const directionAngles = {
|
||||
'N': 0,
|
||||
@@ -50,45 +56,41 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
|
||||
'NW': 315
|
||||
};
|
||||
|
||||
// Создаем секторы на основе зон
|
||||
const sectors = zones && zones.length > 0 ? zones.slice(0, 8).map((zone, idx) => {
|
||||
const angle = directionAngles[zone.direction] || 0;
|
||||
const startAngle = angle - 22.5; // ±22.5° = 45° сектор
|
||||
// Подсветка приоритетных направлений: 45°-сектор ПОЛНОГО радиуса поверх кругов.
|
||||
// Приоритет задаёт прозрачность (топ-зона заметнее), цвет — палитра ЦОУ.
|
||||
const highlightSectors = zones && zones.length > 0 ? zones.map((zone, idx) => {
|
||||
const angle = directionAngles[zone.direction];
|
||||
if (angle === undefined) return null;
|
||||
const startAngle = angle - 22.5;
|
||||
const endAngle = angle + 22.5;
|
||||
|
||||
// Определяем радиус на основе score
|
||||
let radius = distance;
|
||||
if (zone.score >= 90) radius = distance * 1.0;
|
||||
else if (zone.score >= 70) radius = distance * 0.8;
|
||||
else if (zone.score >= 50) radius = distance * 0.6;
|
||||
else radius = distance * 0.4;
|
||||
|
||||
// Цвет на основе приоритета
|
||||
let color, fillOpacity;
|
||||
if (idx === 0) {
|
||||
color = '#ff4d00';
|
||||
fillOpacity = 0.35;
|
||||
fillOpacity = 0.30;
|
||||
} else if (idx === 1) {
|
||||
color = '#ff9f1a';
|
||||
fillOpacity = 0.25;
|
||||
fillOpacity = 0.22;
|
||||
} else if (idx === 2) {
|
||||
color = '#ffd166';
|
||||
fillOpacity = 0.2;
|
||||
fillOpacity = 0.18;
|
||||
} else {
|
||||
color = '#8b949e';
|
||||
fillOpacity = 0.1;
|
||||
fillOpacity = 0.10;
|
||||
}
|
||||
|
||||
return {
|
||||
points: createSectorPoints(tnpPosition[0], tnpPosition[1], radius, startAngle, endAngle),
|
||||
points: createSectorPoints(tnpPosition[0], tnpPosition[1], distance, startAngle, endAngle),
|
||||
color,
|
||||
fillOpacity,
|
||||
weight: idx < 3 ? 2 : 1,
|
||||
zone
|
||||
};
|
||||
}) : [];
|
||||
}).filter(Boolean) : [];
|
||||
|
||||
// Если зон нет, создаем базовые концентрические круги
|
||||
const createCirclePoints = (centerLat, centerLon, radiusKm, points = 40) => {
|
||||
// Концентрические круги — возможная зона поиска (базовый слой).
|
||||
// Классика ПСО: ядро (0.5R) — наибольшая плотность вероятности.
|
||||
const createCirclePoints = (centerLat, centerLon, radiusKm, points = 48) => {
|
||||
const coords = [];
|
||||
const latPerKm = 1 / 111.0;
|
||||
const lonPerKm = 1 / (111.0 * Math.cos(centerLat * Math.PI / 180));
|
||||
@@ -107,26 +109,70 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
|
||||
{
|
||||
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance),
|
||||
color: '#ffd166',
|
||||
fillOpacity: 0.15,
|
||||
label: `Зона 3 (${distance.toFixed(1)} км)`,
|
||||
probability: 10
|
||||
fillOpacity: 0.12,
|
||||
label: `Внешняя зона (до ${distance.toFixed(1)} км)`,
|
||||
probability: 'низкая'
|
||||
},
|
||||
{
|
||||
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance * 0.75),
|
||||
color: '#ff9f1a',
|
||||
fillOpacity: 0.2,
|
||||
label: `Зона 2 (${(distance * 0.75).toFixed(1)} км)`,
|
||||
probability: 20
|
||||
fillOpacity: 0.16,
|
||||
label: `Средняя зона (до ${(distance * 0.75).toFixed(1)} км)`,
|
||||
probability: 'средняя'
|
||||
},
|
||||
{
|
||||
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance * 0.5),
|
||||
color: '#ff4d00',
|
||||
fillOpacity: 0.3,
|
||||
label: `Зона 1 (${(distance * 0.5).toFixed(1)} км)`,
|
||||
probability: 70
|
||||
fillOpacity: 0.20,
|
||||
label: `Ядро зоны (до ${(distance * 0.5).toFixed(1)} км)`,
|
||||
probability: 'высокая'
|
||||
}
|
||||
];
|
||||
|
||||
// Слой георекомендаций: водоёмы/болота (полигоны) + ж/д (линии) из /api/v1/water/{case_id}.
|
||||
// Рисуется ПОД зонами, поверх тайлов OSM.
|
||||
const toLatLngs = (coords) => Array.isArray(coords[0][0][0])
|
||||
? coords.map((poly) => poly[0].map((c) => [c[1], c[0]])) // MultiPolygon: poly → внешнее кольцо
|
||||
: coords[0].map((c) => [c[1], c[0]]); // Polygon: одно кольцо
|
||||
const waterGeoJSON = (waterFeatures || [])
|
||||
.filter((f) => f.layer !== 'railway')
|
||||
.map((f) => ({ feature: f, positions: toLatLngs(f.geom.coordinates) }));
|
||||
const railwayGeoJSON = (waterFeatures || [])
|
||||
.filter((f) => f.layer === 'railway')
|
||||
.map((f) => ({ feature: f, positions: f.geom.coordinates.map((c) => [c[1], c[0]]) }));
|
||||
|
||||
const waterStyle = (kind) => kind === 'wetland'
|
||||
? { color: '#4a6a8a', fillColor: '#4a6a8a', fillOpacity: 0.25, weight: 1, dashArray: '4 3' }
|
||||
: { color: '#2f81f7', fillColor: '#2f81f7', fillOpacity: 0.35, weight: 1.5 };
|
||||
|
||||
const fmtArea = (m2) => {
|
||||
if (m2 == null) return '';
|
||||
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(1)} км²`;
|
||||
return `${Math.round(m2 / 1000)} тыс. м²`;
|
||||
};
|
||||
const fmtDist = (m) => (m == null ? '' : m < 1000 ? `${m} м` : `${(m / 1000).toFixed(1)} км`);
|
||||
|
||||
// B17: слой проверенных квадратов из КОНТУРа (fieldAreas = areas_checked items).
|
||||
// Геометрия — GeoJSON Polygon (dict или JSON-строка). Зелёный = clear, жёлтый = partial.
|
||||
const toLatLngPoly = (coords) => Array.isArray(coords[0][0][0])
|
||||
? coords.map((poly) => poly[0].map((c) => [c[1], c[0]]))
|
||||
: coords[0].map((c) => [c[1], c[0]]);
|
||||
const fieldGeoJSON = (fieldAreas || [])
|
||||
.filter((a) => a.geom)
|
||||
.map((a) => {
|
||||
let geom = a.geom;
|
||||
if (typeof geom === 'string') {
|
||||
try { geom = JSON.parse(geom); } catch { return null; }
|
||||
}
|
||||
const coords = geom?.coordinates;
|
||||
if (!coords) return null;
|
||||
return { area: a, positions: toLatLngPoly(coords) };
|
||||
})
|
||||
.filter(Boolean);
|
||||
const fieldStyle = (area) => area.result === 'clear'
|
||||
? { color: '#3fb950', fillColor: '#3fb950', fillOpacity: 0.30, weight: 1.5 }
|
||||
: { color: '#ffd166', fillColor: '#ffd166', fillOpacity: 0.22, weight: 1, dashArray: '4 3' };
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={tnpPosition}
|
||||
@@ -134,57 +180,105 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
|
||||
style={{ width: '100%', height: '500px', borderRadius: '8px' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution=""
|
||||
attribution="© OpenStreetMap contributors"
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Рисуем секторы или круги */}
|
||||
{sectors.length > 0 ? (
|
||||
sectors.map((sector, idx) => (
|
||||
<Polygon
|
||||
key={idx}
|
||||
positions={sector.points}
|
||||
pathOptions={{
|
||||
color: sector.color,
|
||||
fillColor: sector.color,
|
||||
fillOpacity: sector.fillOpacity,
|
||||
weight: 2
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<strong>#{idx + 1} {sector.zone.name}</strong><br/>
|
||||
Направление: {sector.zone.direction}<br/>
|
||||
Приоритет: {sector.zone.score}%<br/>
|
||||
{sector.zone.reasoning}
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))
|
||||
) : (
|
||||
defaultZones.map((zone, idx) => (
|
||||
<Polygon
|
||||
key={idx}
|
||||
positions={zone.points}
|
||||
pathOptions={{
|
||||
color: zone.color,
|
||||
fillColor: zone.color,
|
||||
fillOpacity: zone.fillOpacity,
|
||||
weight: 2
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<strong>{zone.label}</strong><br/>
|
||||
Вероятность: {zone.probability}%
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))
|
||||
)}
|
||||
{/* Слой водоёмов (под зонами) */}
|
||||
{waterGeoJSON.map(({ feature, positions }, idx) => (
|
||||
<Polygon
|
||||
key={`water-${idx}`}
|
||||
positions={positions}
|
||||
pathOptions={waterStyle(feature.layer)}
|
||||
>
|
||||
<Popup>
|
||||
<strong>{feature.name || (feature.layer === 'wetland' ? 'Болото' : 'Водоём')}</strong><br/>
|
||||
Тип: {feature.layer === 'wetland' ? 'болото/влажная зона' : 'водоём'}<br/>
|
||||
{feature.area_m2 != null && <>Площадь: ~{fmtArea(feature.area_m2)}<br/></>}
|
||||
{feature.distance_m != null && <>От ТНП: ~{fmtDist(feature.distance_m)}</>}
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))}
|
||||
|
||||
{/* Слой ж/д (под зонами, поверх воды) */}
|
||||
{railwayGeoJSON.map(({ feature, positions }, idx) => (
|
||||
<Polyline
|
||||
key={`rail-${idx}`}
|
||||
positions={positions}
|
||||
pathOptions={{ color: '#8a2f2f', weight: 3, opacity: 0.75 }}
|
||||
>
|
||||
<Popup>
|
||||
<strong>Железная дорога</strong><br/>
|
||||
{feature.length_m != null && <>Длина в зоне: ~{fmtDist(feature.length_m)}<br/></>}
|
||||
{feature.distance_m != null && <>Ближайшая точка от ТНП: ~{fmtDist(feature.distance_m)}</>}
|
||||
</Popup>
|
||||
</Polyline>
|
||||
))}
|
||||
|
||||
{/* Базовый слой: концентрические круги возможной зоны поиска */}
|
||||
{defaultZones.map((zone, idx) => (
|
||||
<Polygon
|
||||
key={`circle-${idx}`}
|
||||
positions={zone.points}
|
||||
pathOptions={{
|
||||
color: zone.color,
|
||||
fillColor: zone.color,
|
||||
fillOpacity: zone.fillOpacity,
|
||||
weight: 1.5,
|
||||
dashArray: idx === 0 ? '6 4' : undefined
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<strong>{zone.label}</strong><br/>
|
||||
Плотность вероятности: {zone.probability}
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))}
|
||||
|
||||
{/* B17: проверенные квадраты КОНТУРа — ПОВЕРХ зон (оперативные данные важнее расчётных) */}
|
||||
{fieldGeoJSON.map(({ area, positions }, idx) => (
|
||||
<Polygon
|
||||
key={`field-${idx}`}
|
||||
positions={positions}
|
||||
pathOptions={fieldStyle(area)}
|
||||
>
|
||||
<Popup>
|
||||
<strong>Проверенный участок {area.team_id ? `— ${area.team_id}` : ''}</strong><br/>
|
||||
Статус: {area.result === 'clear' ? '✅ прочесано (≥99.5%)' : '🟡 частично'}<br/>
|
||||
{area.coverage_pct != null && <>Покрытие: {Math.round(area.coverage_pct)}%<br/></>}
|
||||
{area.checked_at && <>Проверено: {new Date(area.checked_at).toLocaleString('ru-RU')}<br/></>}
|
||||
{area.synced && <span style={{ opacity: 0.7 }}>данные синхронизированы из КОНТУРа</span>}
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))}
|
||||
|
||||
{/* Подсветка приоритетных направлений (поверх кругов) */}
|
||||
{highlightSectors.map((sector, idx) => (
|
||||
<Polygon
|
||||
key={`hl-${idx}`}
|
||||
positions={sector.points}
|
||||
pathOptions={{
|
||||
color: sector.color,
|
||||
fillColor: sector.color,
|
||||
fillOpacity: sector.fillOpacity,
|
||||
weight: sector.weight
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<strong>#{idx + 1} {sector.zone.name}</strong><br/>
|
||||
Направление: {sector.zone.direction}<br/>
|
||||
Приоритет: {sector.zone.score}%<br/>
|
||||
{sector.zone.reasoning}
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))}
|
||||
|
||||
{/* ТНП - красный маркер */}
|
||||
<Marker
|
||||
position={tnpPosition}
|
||||
icon={L.icon({
|
||||
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user