Compare commits

...

96 Commits

Author SHA1 Message Date
Sadmin ef18f8e28f B24 в vector_tasks.md: мёртвые веса скоринга — диагностика (числа, A/B) и план оживления данными (railway сразу, historical после архива) 2026-09-25 15:10:42 +03:00
Sadmin cdfbf30232 Доки B17/E5: README §11а (полевые данные КОНТУРа: inbound sync, локальное чтение, слой карты) 2026-09-25 11:05:39 +03:00
Sadmin 31757f18b3 SearchMap: слой проверенных квадратов КОНТУРа поверх зон (оперативные данные важнее расчётных) 2026-09-25 11:00:36 +03:00
Sadmin 05d6b3ce68 compose: проброс CONTOUR_API_URL/CONTOUR_TOKEN в backend (без них env из .env не доходит до контейнера) 2026-09-25 10:51:55 +03:00
Sadmin a1de5794e2 fix: password_history.id → INTEGER monotonic (детерминированная обрезка)
Грабля: created_at в SQLite точен до секунды — 7 быстрых смен дают
равные метки, а secondary-сортировка по UUID-id случайна → «последние 5»
выбираются произвольно (флакущий тест + неверно хранимая история).
id теперь autoincrement (обрезка order_by id desc), UUID остаётся
как public_id. Миграция 010 обновлена (таблица только на CT108,
пересоздаётся миграцией заново — см. деплой).
2026-09-25 10:46:18 +03:00
Sadmin 50a261e05e B17/E5: ингестия полевых данных КОНТУРа
- POST /operations/{id}/field-data/sync: тянет GET /api/v1/vector/areas-checked
  → идемпотентный upsert в areas_checked (dedupe по contour_area_id:
  geom/checked_at/result/coverage_pct/team_label, source=contour)
- GET /operations/{id}/areas-checked: теперь из локальной БД после sync
  (source=db), фолбэк на живой прокси КОНТУРа если пусто (source=contour/empty)
- миграция 011_b17_field_data: areas_checked +contour_area_id/team_label/source
  (+check ck_areas_checked_source)
- аудит field_data_sync; лог vector.contour field_sync_*
- фронт: кнопка «⟳ Синхронизировать полевые данные» в КОНТУР-панели,
  слой проверенных квадратов на карте (зелёный=clear ≥99.5%, жёлтый=partial)
- тесты: +9 (267 passed) — sync/идемпотентность/502/аудит/локальное чтение
2026-09-25 10:34:53 +03:00
Sadmin 1fdcc04af6 Доки B23-бэкап: SETUP_GUIDE §15 — скрипты backup/restore, грабля pg_wrapper PG18, выгрузка копий вне сервера, чек-лист 2026-09-25 10:08:06 +03:00
Sadmin 9b6659a85a B23: скрипты бэкапа/восстановления прикладного дампа
- backup-app.sh: pg_dump 16 (--exclude planet_osm_*), .env+compose в tar,
  verify через pg_restore --list, чистка >14 дней
- restore-app.sh: pg_restore 16 --clean --if-exists + TOC без EXTENSION
- ГРАБЛИ pg_wrapper PG18 в контейнере postgres:16: архив версии 1.16
  не читается pg_restore 16 (unsupported version) + SET transaction_timeout
  нет в PG16 — только /usr/lib/postgresql/16/bin/pg_*
2026-09-25 10:07:37 +03:00
Sadmin c6be70ce36 Доки B23: README (010, password_history, таблица политики с колонкой «исполнение»), SETUP_GUIDE (cron бэкапа+purge, alembic 010, диагностика 400-пароль), чек-листы 2026-09-25 09:58:23 +03:00
Sadmin 2222b7ad5b B23: политика паролей исполняется + ретенция журналов по-настоящему
- backend/password_policy.py: validate_new_password (длина, сложность
  буквы+цифры, сверка с текущим и историей), record_password_change
  (хранит ПРЕДЫДУШИЙ хэш, обрезка до password_history_count),
  password_expired + enforce_expiry (пометка must_change_password)
- users.password_changed_at + таблица password_history (010_b23_policy)
- change-password и админ-сброс теперь под полной политикой (сложность
  и переиспользование вместо голой длины); get_current_user проверяет
  срок действия каждого пароля при запросе
- backend/purge_logs.py: реальный DELETE просроченных audit_events
  (+changes) и auth_events; POST /admin/users/maintenance/purge-logs
  (manage_security) + CLI python -m backend.purge_logs для cron
- тесты: +19 (258 passed) — валидация/история/срок, purge/идемпотентность/403
2026-09-25 09:56:26 +03:00
Sadmin d5a270f3af B22: прикладное логирование vector.* + администрирование в доках (по образцу РВС)
- backend/logging_setup.py: setup_logging() (stdout INFO + файл /tmp/vector/vector.log,
  ротация 5×2 МБ, VectorLOGLEVEL/VectorLOGFILE/VectorLOGDIR), get_logger('vector.*')
- события: vector.analyze (матрасчёт с длительностью), vector.auth (login_ok/failed/
  lockout), vector.audit (каждая запись), vector.contour (ok/failed/rejected),
  vector.operations (operation_complete), vector.geocode (успех/timeout/ошибка)
- существующие логгеры переведены в namespace vector.* (geo, geo.osm, rules, water, admin)
- README §13а: таблица наблюдаемых событий + правила (без ПДн/секретов)
- SETUP_GUIDE: §15 RPO/RTO + backup≠restore, §16 grep по vector.*, §17 диагностика
  по цепочкам (БД, 500, fallback-зоны, вход), §17а чек-лист передачи проекта
- DEPLOY.md: вместо устаревшего дубля — ссылка на актуальные доки + шпаргалка стенда
- тесты: 239 passed
2026-09-25 09:38:39 +03:00
Sadmin 1513bad538 Согласование радиуса: summary = max_distance × средний психотип-множитель
- search_engine: max_distance_km передаётся в analyzer (case_data).
- rules_analysis: search_radius = max_distance × _psychotype_radius_multiplier
  (среднее по полосам 0-500/0.5-1.5/1.5-2.5/2.5+). Убран двойной учёт
  age/season: score_zone.distance_multiplier их уже содержит, а max_distance
  их тоже учёл — раньше radius = multiplier×2.0 (хардкод) давал 1.6 км при
  радиусе 2.4 км.
- UI: подсказка у «×0.8» — коэффициент скоринга, не радиуса.
- Тест: согласованность summary↔формула.
2026-09-09 23:25:06 +03:00
Sadmin 1fe3b37d1a Словесное пояснение приоритетных направлений (дополнение к матрасчёту)
- rules_analysis._build_zone_reason: детерминированный текст по тем же
  факторам, что считает WeightedScorer — рельеф (лесистость %), вода
  (расстояние + акцент берегов при не_умеет_плавать), дороги (плотность +
  вероятное следование велосипеда), НП (ориентир), совпадение с направлением
  последнего свидетеля (точное/соседний румб), полосы психотипа. Финал —
  балл оценки.
- ФИКС: weight 'direction' (0.07) был мёртвым — direction_match никогда не
  передавался в score_zone (всегда дефолт 0.5); теперь 1.0 при совпадении
  с last_seen_direction.
- Фронт уже отображает reasoning на карточках зон — бэкенд-фикс достаточен.
- Тесты: 7 новых на пояснения (детерминированность, факторы, фолбэк). 238 passed.
2026-09-09 23:14:00 +03:00
Sadmin c44bc46667 README.md + SETUP_GUIDE.md — документация по образцу РВС
README: назначение/контекст, политика закрытого контура, архитектура,
стек, структура репо, БД (21 таблица), матмодель (контрольные точки),
OSM-геослой, API (12 роутеров), фронт, RBAC (142 юнита, 4 роли/9 прав),
мультипоиск, интеграция КОНТУР, аудит, планы.
SETUP_GUIDE: 18 разделов — требования, Docker, .env (смена секретов),
миграции/сиды, импорт OSM в PostGIS, проверки, пользователи, КОНТУР,
прокси/HTTPS, обновление (up -d для env!), бэкапы, диагностика (11 типовых
проблем), чек-лист развёртывания.
2026-09-09 23:09:03 +03:00
Sadmin 9115a0e7af Тест parse-doc удалён вместе с фичей 2026-09-09 23:01:57 +03:00
Sadmin a7f8879034 Импорт .docx удалён: не использовался (0 кейсов), парсер не совместим с русскими спецдонесениями, LLM-распознавание вырезано
- admin.py: /parse-doc + _extract_docx_text/_extract_kv_pairs/_coerce_preview/
  _parse_bool удалены; импорты почищены.
- schemas.py: ParseDocResponse удалена.
- AdminDashboard: секция «Импорт отчёта .docx» + все upload/parse handlers
  и state удалены (компонент стал на ~160 строк короче).
2026-09-09 23:00:13 +03:00
Sadmin 0b5c70da73 Дизайн: чипы/инпуты/таблица архива — тёмная палитра, читаемые бордеры 2026-09-09 22:50:22 +03:00
Sadmin d65e171788 Дизайн: /admin приведён к тёмной теме ВЕКТОРа
- Табы, кнопки «Пользователи·Аудит»/«Обновить»/«Открыть анализ», фильтр
  операций — dark (#161b22/#21262d/#30363d/#e6edf3), без белых инлайнов.
- ClosedCases.css: светлая форма и чипы → dark-палитра (акцент #238636).
- Аудит: РУ-лейблы case_closed/operation_complete.
2026-09-09 22:49:21 +03:00
Sadmin 57f0f62d42 fix: useCallback импорт в AdminDashboard (вкладка Операции) 2026-09-09 22:44:05 +03:00
Sadmin b635131195 fix: unit_name в DTO операций (сигнатура _operation_dto с units_by_id) 2026-09-09 22:43:13 +03:00
Sadmin a1d33a2b28 Реорганизация /admin: операции, честные метрики, синхронизация закрытия
- Вкладка «Операции»: список search_operations (статус/подразделение/обновлена,
  фильтр, переход в анализ кейса), unit_name в DTO.
- Метрики: + активные/завершённые операции (stats из search_operations);
  «Активность 7 дней» теперь честная (created_at >= now-7d, была = total).
- Синхронизация: закрытие кейса через /admin PATCH → активная операция
  автоматически completed + closed_at (аудит в логе).
- ФИКС legacy-бага PATCH /cases: default_factory-списки затирали
  terrain/diagnosis_type пустыми массивами — выкидываются пустые списки.
- Редактор исхода: «Кто нашёл» → select WHO_FOUND_TYPES.
- Кнопка «Пользователи · Аудит» → /users.
- Тесты: автозакрытие операции, метрики (232 passed).
2026-09-09 22:41:14 +03:00
Sadmin 4fe60b0772 UI: фильтр и inline-селект подразделений — та же иерархия optgroup 2026-09-09 22:32:33 +03:00
Sadmin 7d0a0e9a50 UI: справочник подразделений группировкой optgroup — РЦУ РЧС / ОУМЧС / Г(Р)ОЧС по областям 2026-09-09 22:31:34 +03:00
Sadmin 4d941b7310 Сид подразделений: полный список РВС — РЦУ РЧС → 6 ОУМЧС → 135 РОЧС/ГОЧС/ГРОЧС
Названия областей в краткой форме РВС (Минское ОУМЧС и т.д.). Старые
демо-записи с длинными названиями областей подлежат разовой чистке на стенде.
2026-09-09 22:30:14 +03:00
Sadmin cbdce9625b Справочник «Кто нашёл» по списку: работники МЧС / найден населением / добровольцы / сотрудники милиции / вышел сам / ПСО / другое 2026-09-09 22:15:16 +03:00
Sadmin 6e9dfe8a6f UI: справочники типа места находки / кто нашёл / направление — единые селекты
- constants.js: LOCATION_TYPES (дорога/тропа/лес/дом-стройка/вода-берег/поле/
  не указано), WHO_FOUND_TYPES, FOUND_DIRECTIONS (8 румбов).
- Модал завершения поиска: тип места, кто нашёл, направление — селекты вместо
  свободного ввода (данные пригодны для матобработки архива без нормализации).
- Архив /admin (ClosedCases): «Кто нашёл» → тот же селект; LOCATION_TYPES
  вынесен в общий модуль.
2026-09-09 22:08:25 +03:00
Sadmin 91fe8358ac fix: ClosedCases load — apiJson вместо apiFetch (главный фикс списка архива) 2026-09-09 22:00:50 +03:00
Sadmin 611ca95d3a fix: ClosedCases — apiFetch возвращает Response, а не JSON (архив всегда показывал 0)
- Загрузка списка, создание и удаление переведены на apiJson
  (apiFetch → Response.items === undefined → items=[]).
2026-09-09 21:58:39 +03:00
Sadmin 5a86755e90 Завершение поиска: исход → архив + матмодель + операция completed
- POST /operations/{id}/complete: found_alive/дистанция/направление/кто нашёл/
  длительность → кейс status=closed + фактические данные + прогон детерминированной
  матмодели (model_prediction, accuracy_note в analysis_log) → операция
  status=completed + closed_at. Аудит: operation_complete + case_closed.
- ФИКС list_closed_cases: фильтр по to_summary().analysis_log всегда давал None
  (analysis_log только в to_detail) → список завершённых был пуст; теперь
  фильтр по status='closed' (совместимо с TestDB).
- UI: кнопка «✓ Завершить поиск» на странице анализа (для активных операций),
  модал с исходом, после завершения — плашка «Поиск завершён», в архиве
  (/admin завершённые) появляется запись с прогнозом модели и точностью.
- Тесты (4): полный флоу, повторное завершение 400, аудит, появление в
  /closed-cases. 230 passed.
2026-09-09 21:55:12 +03:00
Sadmin af98ec94e2 E2 UI: журнал аудита — вкладка «Аудит» в админке пользователей
- AuditTab: события (вход/неудачный вход/блокировка/user_create/user_update/
  password_reset/operation_*/contour_send_*), фильтр по типу, пагинация 50,
  retention из security_settings, field-level old→new в раскрываемых деталях.
- Цветовая маркировка: вход/успех зелёным, отказы/ошибки красным,
  сброс пароля жёлтым.
2026-09-09 21:49:06 +03:00
Sadmin bd9e93df05 fix: kind=gor_rayon в сиде подразделений (соответствие ck_mchs_units_kind) 2026-09-09 21:46:40 +03:00
Sadmin d2860118d8 E2: сид справочника подразделений (РЦУ РЧС → 6 ОУМЧС → демо Г(Р)ОЧС Минской области), идемпотентный 2026-09-09 21:46:20 +03:00
Sadmin 39b2e296d1 E3 UI: админка пользователей — создание, роли, подразделения, блокировка, сброс пароля
- UsersAdmin.jsx (/users): список с фильтрами (подразделение/поиск),
  форма создания (логин/email/временный пароль/ФИО/должность/юнит/роль),
  inline-смена роли и подразделения, отключение/включение, сброс пароля
  (все сессии отзываются), статусы цветом. must_change_password
  подсвечивается пользователю при первом входе.
- Доступ: бэкенд require_permission('manage_users') — РЦУ РЧС и ОУМЧС;
  скоуп координатора фильтрует список по подразделению (кросс-областной
  PATCH отбивается 403 на бэкенде).
- Кнопка «Пользователи» из дашборда.
2026-09-09 21:42:30 +03:00
Sadmin cc2f597bf2 scripts: живая проверка E4 на CT108 2026-09-09 21:40:01 +03:00
Sadmin 90213e0491 E4 (B21): отправка зон в КОНТУР по операции (контракт B20)
- contour_operation_id в PATCH /operations/{id} (mapping на операцию КОНТУРа).
- backend/routers/contour.py: POST /{id}/send-to-contour — последний анализ
  (search_models → legacy analysis_log) → SearchZones[] GeoJSON-сектора
  направлений (±22.5°, probability по приоритету) → POST {CONTOUR_API_URL}/
  api/v1/vector/zones?operation_id=... (Bearer CONTOUR_TOKEN). Все шаги
  аудируются (contour_send_ok/failed). GET /{id}/areas-checked — прокси
  проверенных квадратов (inbound к B17).
- env: CONTOUR_API_URL (дефолт CT130:8000), CONTOUR_TOKEN (в secrets).
- case_id-фильтр в GET /operations.
- UI: панель «КОНТУР» на странице анализа: ввод/сохранение UUID операции
  КОНТУРа, кнопка «Отправить зоны в КОНТУР» со статусом результата.
- Тесты E4 (4): build B20-payload (полигон замкнут, probability 0..1,
  operation_id в query, Bearer), недостающий contour_id 400, отказ КОНТУРа
  → 502 + аудит, геометрия сектора.
226 passed, 5 skipped.
2026-09-09 21:39:18 +03:00
Sadmin ab3eebe274 E3 UI: дашборд «Активные поиски» — главная страница после логина
- Dashboard.jsx: счётчики (активных / всего / завершено за 24ч из
  /operations/summary), фильтры по статусу, сетка карточек операций
  (title, статус-бейдж, подразделение, время обновления), клик →
  рабочее пространство (анализ по карточке операции).
- Роут /dashboard, «/» → дашборд (было /desktop).
- CaseForm: после создания карточки автоматически создаётся поисковая
  операция (title «Поиск: имя, N лет») — операция не блокирует анализ.
- VCL-density, палитра ЦОУ (#ff4d00 акценты, статусы цветом).
2026-09-09 21:33:01 +03:00
Sadmin 5c3b8a1424 scripts: живая проверка E3 на CT108 2026-09-09 21:30:09 +03:00
Sadmin 808ef93a52 E3 (B21): операции мультипоиска — статусная машина, скоуп, дашборд
- Модель search_operations: title, case_id (unique — операция на карточку),
  status (planned/active/paused/completed/archived, CheckConstraint),
  unit_id, contour_operation_id (заготовка B20-E4), created_by, closed_at.
- Alembic 009_e3_operations.
- backend/routers/operations.py: POST (создание; unit по умолчанию =
  подразделение создателя; скоуп-проверка), GET список (скоуп-фильтр),
  GET /{id} (403 вне скоупа), GET /summary (active/total/completed_24h),
  PATCH (title/unit/status со статусной машиной: planned→active⇄paused→
  completed→archived; недопустимые переходы 400; completed ставит closed_at).
  Всё с аудитом operation_create/operation_update.
- Скоуп: unit_id ∈ visible_unit_ids (своё+подчинённые); РЦУ РЧС — все.
- Тесты E3 (12): создание с юнитом/дефолт/дубль кейса/cross-unit 403,
  скоуп списков, статусная машина, summary, аудит, cross-scope 403.
222 passed, 5 skipped.
2026-09-09 21:29:33 +03:00
Sadmin e694ad1a05 scripts: живая проверка E2 на CT108 2026-09-09 21:24:20 +03:00
Sadmin 5a2c9a0688 E2 (B21): аудит + админ-панель пользователей + гео-скоуп
- Модели audit_events (immutable) + audit_changes (field-level old→new);
  Alembic 008_e2_audit.
- backend/audit.py: audit_log() (событие + изменения), audit_retention_days
  (90 дней из security_settings), visible_unit_ids (рекурсивный CTE:
  своё подразделение + подчинённые; РЦУ РЧС/legacy → None = без фильтра),
  can_access_unit, _uuid_hex.
- backend/routers/admin_users.py (manage_users/view_audit):
  GET /admin/users (фильтры status/unit/q, скоуп по подразделению),
  POST (создание с must_change_password, роль, скоуп-проверка),
  PATCH /{id} (роль/юнит/статус/сброс пароля, field-level аудит),
  GET /units (справочник подразделений), GET /audit (ретеншн-фильтр).
  coordinator управляет только своим юнитом+подчинёнными и не трогает РЦУ.
- Грабли SQLite-тестов: UUID колонки хранят hex без дефисов — сравнения
  только в hex-пространстве (_uuid_hex); StaticPool обязателен.
- Тесты E2 (8): создание/блокировка/сброс пароля/скоуп координатора/
  cross-oblast 403/без права 403/аудит user_create.
210 passed, 5 skipped.
2026-09-09 21:23:47 +03:00
Sadmin aefd33949c scripts: живая проверка E1 на CT108 2026-09-09 21:12:10 +03:00
Sadmin 628a1d4536 E1 (B21): RBAC-ядро — подразделения МЧС, роли/права, сессии, auth-журнал
- Модель данных: mchs_units (РЦУ РЧС → ОУМЧС → Г(Р)ОЧС, kind+parent_id),
  users +8 колонок (unit_id, position, status, lockout, must_change_password,
  created_by, updated_at), roles/permissions/role_permissions/user_roles
  (M2M матрица), sessions (server-side, token_hash=sha256(secret)),
  auth_events, security_settings.
- Alembic 007_e1_rbac: схема + сид 4 ролей / 9 прав / матрицы (9/6/3/1) /
  10 настроек безопасности; downgrade → upgrade идемпотентен (проверено на
  pg16-контейнере, сид не дублируется).
- auth.py: login с lockout (5 неудач → 15 мин) и auth-журналом; server-side
  сессии (JWT несёт secret, в БД — sha256-хэш; max_concurrent_sessions);
  /me с permissions (user_roles → role_permissions, fallback legacy-роль);
  /change-password (мин. длина из settings, отзыв сессий); /logout.
  require_permission(code) — атомарные права (fallback legacy-роли).
- Обратная совместимость: require_roles не тронут, старые JWT без sid
  продолжают работать.
- Тесты E1 (9): login/logout/lockout/me-права/смена пароля/журнал.
  SQLite in-memory (StaticPool + компиляторы UUID/JSONB/ARRAY).
202 passed, 5 skipped.
2026-09-09 21:11:38 +03:00
Sadmin 15e7e27cb8 B21: терминология МЧС РБ — РЦУ РЧС / ОУМЧС / Г(Р)ОЧС
Правка Виктора: республиканский штаб → РЦУ РЧС; областное ЦОУ → ОУМЧС;
райЦОУ → Г(Р)ОЧС. cou_units → mchs_units (иерархия rcu → oblast →
gor_rayon); unit_id вместо cou_id; роли: admin=РЦУ РЧС,
coordinator=ОУМЧС, operator=Г(Р)ОЧС. Статус: план согласован (Виктор:
«в остальном согласен») — готов к E1.
2026-09-09 21:00:19 +03:00
Sadmin 3a986b0b7c B21: план мультипоиска и многопользовательского доступа (масштаб на республику)
Требование Виктора: несколько одновременных поисков, много пользователей
(каждое ЦОУ = аккаунты), КОНТУР подключается к каждому поиску. За основу —
админка РВС (RBAC + сессии + аудит, паттерн проверен в проде).

План: search_operations (надстройка над cases, не дубль SEARCH_OPERATION
КОНТУРа) + cou_units (справочник ЦОУ с иерархией) + RBAC 4 роли +
гео-скоуп (отличие от РВС) + дашборд активных поисков + contour_operation_id
для B20. Этапы E1 RBAC-ядро → E2 скоуп/аудит → E3 операции → E4 КОНТУР →
E5 B17-ингестия. Границы и критерии приёмки внутри.

СТАТУС: план на ревью, не код.
2026-09-09 20:50:30 +03:00
Sadmin 5794cb74a9 UI: «перекрыть» → «проверить» (рекомендации МЧС); геоформат «пункт, район, адрес»
1. Рекомендации не предписывают перекрывать водоёмы/ж/д — оператор ПРОВЕРЯЕТ
   их (правки в rules_analysis, scoring_service ×2 копии, water.py + доки).
2. Геокодирование: район нужен (дубли улиц между районами) — подсказка формата
   «пункт, район, адрес» и текст ошибки.

198 passed.
2026-09-09 20:21:18 +03:00
Sadmin c1bb7d5249 fix: case_id-анализ терял координаты ТНП → fallback-зоны N/E (корень «вверх-вправо»)
При анализе по case_id координаты карточки (tnp_lat/tnp_lon) не попадали
в SearchInput (читались только payload-lat/lon) → rules_analyze не видел
lat/lon и отдавал базовые fallback-зоны «Ближняя N / Средняя E» —
постоянные направления вверх-вправо на карте независимо от местности.

Фикс: case_id-режим подтягивает lat/lon из tnp_* (gps_* как алиас).
198 passed.
2026-09-09 20:04:40 +03:00
Sadmin b636389ee1 UI карты: приоритет — концентрические круги + подсветка направлений
Дизайн Виктора (классика ПСО): возможная зона поиска — круг радиусом
max_distance от ТНП (ядро 0.5R / средняя 0.75R / внешняя R с градацией
плотности вероятности), а приоритетные направления ПОДСВЕЧИВАЮТСЯ
45°-секторами полного радиуса поверх кругов (топ-1 #ff4d00 op0.30,
топ-2 #ff9f1a 0.22, топ-3 #ffd166 0.18, прочие серые 0.10).
Раньше сектора были «пирогами» разного радиуса по score — зона выглядела
лоскутной. Теперь круги показывают охват, подсветка — приоритет.
2026-09-09 19:58:05 +03:00
Sadmin 2aa45e8932 B1: СП(лес)=1.0 — НормС трактуется как скорость смещения по среднему лесу
Решение Виктора: СП(лес) 0.5→1.0. Прежняя таблица учитывала лес дважды
(в НормС и в СП) — подросток 14 лет за 3 часа получал 2.55 км вместо
реалистичных 5.1. Согласуется с априором ПСО (94% находок в 3 км):
радиусы стали верхне-перцентилями, не медианой.

- terrain_map пересчитан относительно среднего леса: лес/простой лес/поле/
  луг/город = 1.0; сложный/густой лес 0.5; болото 0.4; горы/овраг 0.6;
  дорога/тропа 0.8; дефолт 1.0.
- Контрольные точки тестов пересчитаны (6 шт: 1.92→3.84, 0.33→0.665,
  2.43→2.7, 5.4→10.8, 13.5→15.0, stats 0.5→1.0) + операционный тест
  подростка 5.1 км.
- settlement_score пока НЕ тронут — на обдумывание (Виктор).

193 passed.
2026-09-09 19:51:05 +03:00
Sadmin 61308ed444 fix: вода/НП фильтруются строго по сектору (ST_Intersects, не bbox)
Ближайшая вода бралась по bbox сектора: объект в углу bbox, но вне
45°-пирога, попадал в выборку — вода почти не различала направления
(1.393 км у N/NE/E с одной воды в центре). Теперь строго внутри сектора:
water 1.393–2.337 км по направлениям — честная география.
2026-09-09 19:46:28 +03:00
Sadmin ea19ed0d8f fix: дистанции до воды/НП — real = merc×cos + метры→км (было 850 «км» до озера)
Два бага в одной строке: водные дистанции делились на cos (вместо
умножения, как дороги) и отдавались как км без /1000. Результат —
850.671 «км» до озера в Браславе (реально 271 м).

Верифицировано против geography: merc×cos = 271.0 м ≈ geo 271.5 м
(дельта 0.1%). Теперь Браслав SE: water 0.271 км, settle 0.119 км.
197 passed.
2026-09-09 19:40:20 +03:00
Sadmin c7fce52693 scripts: e2e ранжирования зон после forest-фикса 2026-09-09 19:34:24 +03:00
Sadmin 59f97c850e fix: forest_pct — доля 0..1 вместо процентов (вес леса падает со 100×)
Закономерность «приоритетная зона вверх-вправо»: score_zone умножает
weight × forest_pct, ожидая ДОЛЮ (тесты скоринга 0.3–0.8), а гео-сервисы
(osm_local и legacy Overpass calculate_forest_coverage) отдавали
ПРОЦЕНТЫ 0..100. Лес получил фактический вес в 100 раз больше задумки —
топ зон систематически занимали чуть более зелёные сектора.

Фикс: osm_local.calculate_forest_coverage возвращают долю 0..1.
Разброс скорингов по Минску: 1.244 → 0.032 (честно однородный город).
Остаточные различия — вода/дороги; дифференциация плотностей город/село —
ревью скоринга (B1), коэффициенты не тронуты.

+регресс-тест 0<=forest_pct<=1. 197 passed.
2026-09-09 19:33:53 +03:00
Sadmin 1b2c189ace UI: убрана кнопка «Получить текущие координаты»
Мобильная версия выпилена — браузерная геолокация оператора больше не
нужна: ТНП задаётся адресом (кнопка «Найти координаты» → geocode) или
вручную. handleGetLocation и его состояния удалены; .gps-button остался
только у геокод-кнопки.
2026-09-09 19:29:07 +03:00
Sadmin ee1265c259 UI: фикс адресной строки — width:auto перебивает .gps-button/.form-group width:100%
.gps-button { width:100% } в flex-строке съедал всю ширину, input сжимался
до огрызка. Inline width:auto/flexShrink:0 у кнопки и width:auto/minWidth:0
у инпута.
2026-09-09 19:25:47 +03:00
Sadmin 333b827ba7 UI: высота поля адреса = высоте кнопки геокодирования (44px) 2026-09-09 19:23:26 +03:00
Sadmin e9c51bcfb2 scripts: e2e геокодирования + багфикса lost_time→loss_time 2026-09-09 19:18:49 +03:00
Sadmin fa466fc5ed UI: один адрес на шаге с координатами + рабочее геокодирование + авто«прошло времени»
- Step3Circumstances: удалено дублирующее «Последнее известное место»
  (last_known_place); остаётся только tnp_address на шаге координат.
- Step4Environment: кнопка «Найти координаты» теперь РАБОЧАЯ — вызывает
  новый GET /api/v1/geocode (прокси Nominatim, countrycodes=by, короткий
  запрос «пункт, адрес» по граблям geocoding), заполняет lat/lon,
  показывает найденный display_name или ошибку. Без debounce-автоплейса
  (Pitfall 2) — только явный клик.
- Step3Circumstances: «Прошло времени» считается автоматически из
  lost_time (пересчёт каждые 30 с, округление до 0.1 ч); поле остаётся
  редактируемым для корректировки.
- БАГФИКС: форма шлёт lost_time, модель хранит loss_time — алиас в
  _normalize_case_data. Раньше время пропажи молча терялось
  (Pydantic игнорирует неизвестные поля) — elapsed_hours приходилось
  вводить руками.
- last_known_place убран из валидации шага 4.
- backend/routers/geocode.py: Nominatim-прокси с auth (operator/field/admin).

192 passed, JSX валиден.
2026-09-09 19:18:20 +03:00
Sadmin 6ec9536203 scripts: e2e-проверка фикса None-полей (case_id-режим + минимальный payload) 2026-09-09 19:12:08 +03:00
Sadmin b29f964189 fix: None-поля в case_data ломали /analyze (float * NoneType)
Регресс B14: SearchInput.to_case_data() отдавал ключи с None
(elapsed_hours/age/...), а сервисы читают case_data.get(key, DEFAULT) —
ключ с None обманывает дефолт → TypeError в distance_service
('не удаётся запустить анализ' при запуске поиска со степ 1).

Фикс: to_case_data возвращает старую семантику _as_case_data —
None и пустые контейнеры выбрасываются. +2 регресс-теста.
192 passed.
2026-09-09 19:11:12 +03:00
Sadmin 3d96c1f630 frontend: fix — useEffect не импортирован (eslint no-undef) 2026-09-09 19:05:11 +03:00
Sadmin d4f2cb4f20 frontend: мобильная версия отключена
- Удалён /mobile-роут и MobileFormPage; " /" всегда ведёт на /desktop
  (user-agent/width-детекция выпилена — ЦОУ работает только на десктопе).
- Тема mobile в index.css оставлена (мёртвый CSS, не мешает).
- CaseForm.isMobile не тронут: DesktopSARPage передаёт false явно.
2026-09-09 19:03:19 +03:00
Sadmin d762fd104c scripts: e2e-тайминг B16 (анализ 10 мс, 0 Overpass-вызовов) 2026-09-09 18:59:00 +03:00
Sadmin 3360f7a8ac B16: локальный OSM в PostGIS — geo_service без интернета
- services/osm_local.py: слой запросов к planet_osm_* (osm2pgsql, SRID 3857):
  roads/density (ST_Length merc × cos(lat) — проверено на merc/geo=1/cos),
  forest (честная площадь ST_Intersection), water (полигон/водоток),
  settlements (village/town/city). Сектор-«пирог» 45° строится в WGS84.
- geo_service.get_zone_features: PostGIS-путь, Overpass — fallback
  (osm_available() по наличию planet_osm_line).
- scripts/b16-import.sh: osm2pgsql --slim -C 800, Беларусь 333МБ pbf
  (Geofabrik, md5 проверен) → 4.5 мин на CT108; PostGIS 3.6 установлен
  в живой vector-postgres (apt, без смены образа).
- Валидация: Минск/Полоцк/Браслав — правдоподобные плотности (7.9/2.9/1.5
  км/км²), зоны различаются по направлениям; 32 сектора за 1.9 c (без сети).
- Тесты: +8 (osm_local контракт, интеграция, отсутствие Overpass-вызовов),
  вне БД — skip. Всего 190 passed, 5 skipped.

Критерий B16: build_search_zones без внешнего интернета — ВЫПОЛНЕН
(единственный Overpass остаётся fallback-веткой при пустых таблицах).
2026-09-09 18:58:30 +03:00
Sadmin 8e99ea0290 scripts: e2e правил-режима (без LLM-ключей в контейнере) 2026-09-09 18:31:55 +03:00
Sadmin d8723582c9 Полный отказ от LLM: детерминированный rules-анализ
Решение Виктора: ИИ убран из проекта — карточки детей (ПД) не должны
покиать контур. Анализ полностью локальный и детерминированный.

- services/rules_analysis.py: правила вместо Claude (бывший
  analyze_with_fallback + rule_based_urgency из closed_cases):
  urgency по возрасту/профилям, зоны через geo+scoring,
  behavioral_prediction/immediate_actions/key_locations — справочники.
- search_engine: анализатор инъекцией (CaseAnalyzer, Protocol),
  импорт claude_service удалён; рантайм — rules_analyze.
- УДАЛЕНЫ: services/claude_service.py, backend/services/claude_service.py
  (устаревшая копия), test_claude_service.py; ключевые правила-кейсы
  перенесены в test_rules_analysis.py (+тест отсутствия LLM-упоминаний).
- docker-compose/.env.example: ANTHROPIC_API_KEY удалён.
- Фронт: бейдж 'ИИ-анализ' -> 'Локальный расчёт'.
- Доки: DEPLOY/SERVICES_README/ANALYZE_API переписаны, FIXES_REPORT
  помечен историческим, vector_tasks B20: zones/probability — из движка.

pytest: 187 passed (было 214; -27 удалённых LLM-тестов, +тест rules).
Контракт /analyze не изменён (фронт совместим).
2026-09-09 18:30:44 +03:00
Sadmin 11015a7883 scripts: стемп-бэкфилл для прода + e2e /analyze (login form-urlencoded)
- prod-stamp-backfill.sql: бэкфилл search_models v1 из cases.analysis_log
  (для БД, где таблицы 006 уже созданы create_all — стемп alembic_version).
- e2e-analyze.sh: живая проверка /analyze на CT108 (bike 8yo 2h лес день
  -> max_distance 5.4; login — form-urlencoded, не JSON).
- Мелочь: test-mig-seed.sql ON CONFLICT DO NOTHING.
2026-09-09 18:20:37 +03:00
Sadmin 5bdb345e33 B14+B15: чистый SearchEngine + слоистая модель данных
B14 (поведение-сохраняющий рефакторинг):
- services/search_engine.py — вся SAR-математика из analyze.py:
  SearchInput/SearchModel, build_search_model (чистая функция,
  без DB/auth/HTTP), деривации профилей/времени суток, unmodeled.
- analyze.py — тонкая обёртка: сборка SearchInput + запись БД.
- closed_cases.py — импорты хелперов из движка.
- 12 юнит-тестов движка (claude_analyze мокается).
- Контракт /analyze не изменён; регресс спеки подтверждён:
  bike 8yo 2h лес день -> 5.4 км.

B15 (слои данных, Alembic):
- backend/alembic (env из DATABASE_URL) + миграция 006_b15_layers.
- Слой 4: search_models (case_id, version, input_snapshot, model_json).
- Слой 3: search_teams, field_observations, areas_checked, found_events
  (geom JSONB GeoJSON, PostGIS в B16).
- Слой 1: reference_priors (пустой, B12 заблокирован).
- Бэкфилл: cases.analysis_log (объект с primary_zones) -> search_models v1;
  legacy-массивы и analysis_log-таблица не тронуты.
- Проверено на CT108 в одноразовых pg16-контейнерах: чистая БД (без
  данных и с ними), бэкфилл=1 из 3 seed-кейсов, downgrade->upgrade
  идемпотентен, check-constraints работают, источник не модифицирован.

pytest: 214 passed.
2026-09-09 15:48:29 +03:00
Sadmin c368482d1c fix: relation с одним кольцом — geom как dict, не coordinates-список
polygons.append(poly['coordinates']) хранит [ring]; при 1 кольце
geom=polygons[0] отдавал голый список (не dict) → pydantic dict_type
валидация GeoFeature падала 500. Теперь ветки собирают dict явно.
2026-09-09 14:32:31 +03:00
Sadmin 5464ce370a fix: polygons уже содержит polygon.coordinates — MultiPolygon.coordinates = polygons 2026-09-09 14:28:30 +03:00
Sadmin 10f00e2d2e fix: relation с одним внешним кольцом — Polygon с depth-3 coordinates
Второй баг той же семьи: polygons.append(poly['coordinates'][0]) клал ring
на уровень полигона — у relation с ОДНИМ outer member'ом coordinates
получались depth-2 ([[pair],...]) при type=Polygon. Это 5 крупнейших
водных объектов (Камсамольскае возера и др.) — фронтовый toLatLngs
строил [[undefined,undefined]...] → Leaflet 'latlngs[0] undefined' →
белый экран страницы анализа.
Теперь relation собирает список Polygon'ов (coordinates=[ring]) и:
1 кольцо → отдаётся как Polygon; 2+ → MultiPolygon из polygon.coordinates.
2026-09-09 14:26:20 +03:00
Sadmin 0320cad063 fix: GeoJSON MultiPolygon — кольца оборачивать в полигоны ([[ring]])
Баг: для relation с 2+ внешними кольцами coordinates отдавался [ring0, ring1]
(глубина 3) при type=MultiPolygon — невалидный GeoJSON, Leaflet падал
('latlngs[0] undefined') → белый экран страницы анализа на кейсах с
мультиполигонами (РАС-кейс Минск: Камсамольскае возера из 2 колец).
Фронт: toLatLngs различает Polygon/MultiPolygon по глубине coords[0][0][0].
2026-09-09 14:14:44 +03:00
Sadmin 32af8975f9 ui: переработка страницы результатов — логические блоки, та же палитра
Проблема: 10 инфо-карточек в один столбец, карта с зонами — в самом
низу, немедленные действия терялись в середине, формула занимала
пол-экрана раньше вывода.

Новая структура (сверху вниз = порядок чтения штабом):
1. Шапка-панель решения: срочность | радиус (+множитель профилей
   отдельным чипом) | критические действия списком — решение видно
   без прокрутки; summary свёрнут в одну строку.
2. Главный ряд: КАРТА (2/3) + список ЗОН (1/3) рядом — зоны и карта
   теперь одно визуальное целое; geo-чипы (💧/🚂) и предупреждения
   геослоя компактными плашками в шапке карты.
3. Ряд поведение+действия: психотип (тактика 2×2 сеткой), теги
   профилей, unmodeled-пометки; немедленные действия нумерованным
   списком, КРИТИЧНО-пункты акцентной рамкой.
4. Ряд обоснование радиуса: формула моно-шрифтом + коэффициенты
   чипами (НормС/СП/СКД/СУ/СУТ/ВВС/ВП с подсказками) + психотип-
   поправки полос чипами.
5. Низ: справка (обстоятельства/здоровье/среда) сеткой 3 колонки.

Цветовая схема не тронута: var(--accent) #ff4d00, зоны #ff4d00/
#ff9f1a/#ffd166, тёмная тема ЦОУ. Направления зон — кириллицей
(С/СВ/В/...). Все поля бэкенда рендерятся (проверено живым analyze).
2026-09-09 13:53:17 +03:00
Sadmin 4531ac50b9 fix: ж/д — фильтр коротких сегментов <300 м (нарезка стрелок станции шумит карту) 2026-09-09 13:22:12 +03:00
Sadmin a15b1f42e9 fix: убрать .venv-test из репо (в gitignore) 2026-09-09 13:13:12 +03:00
Sadmin f20080305d feat: ж/д слой + cant_swim в скоринг + профили вне модели
Определение специфических рекомендаций (матрица профилей §8):

1. Ж/д слой (закрыт мёртвый railway ×2.5 у РАС):
   - /api/v1/water/{case_id} отдаёт railway=rail как LineString
     (без service/industrial/military веток), кэш общий v2;
   - railway_warning «перекрыть/проверить немедленно» по профилям;
   - SearchMap: Polyline слой ж/д (тёмно-красный), счётчики 💧/🚂.

2. cant_swim → профиль не_умеет_плавать (water ×3.0, без изменения
   радиуса, critical_warning «обследовать водоёмы НЕМЕДЛЕННО»):
   - раньше чекбокс влиял только на текст, в скоринге был пробел;
   - derive в analyze._derive_profiles — работает и для closed_cases.

3. unmodeled_profiles: ДЦП/слабое зрение/слух — честная пометка
   «вне поведенческой модели» с пояснением (vector_tasks B12:
   профили без аналога не выдавать за учтённые); блок на фронте
   в карточке здоровья.

Площадь воды: сферический эксцесс, проверен на квадрате 53° (744017 м²
vs 743272 точного). Тесты: 202 passed (новый test_cant_swim_profile).
2026-09-09 13:12:55 +03:00
Sadmin 98e8e58023 feat: водоёмы в зоне поиска на карте (рекомендации профилей → геослой)
- GET /api/v1/water/{case_id}: полигоны водоёмов/болот OSM вокруг ТНП
  (Overpass, natural=water+wetland, кэш 72ч, circuit breaker от geo_service);
  бейдж-предупреждение по профилям случая (РАС/эпилепсия/не умеет плавать)
- geo_service: цепочка зеркал Overpass (maps.mail.ru → overpass-api.de):
  основной сервер имеет AAAA, на хостах без IPv6-маршрута httpx падал
  ConnectError'ом до IPv4-фолбэка; на CT108 фапало стабильно
- SearchMap: слой водоёмов под зонами (синие полигоны, болота пунктиром),
  попапы с площадью/дистанцией от ТНП; AnalysisResult грузит слой,
  показывает предупреждение профиля и счётчик водоёмов
- проверено на CT108: РАС-кейс (Минск) — 43 объекта, витя-кейс
  (Каменец-борисовская обл.) — 7 объектов, включая водохранилище Загацце
2026-09-09 12:48:44 +03:00
Sadmin 2d264a5779 fix: коэффициенты времени суток по методичке ПСО «Экстремум» (§12.3.3, §12.3.5)
Сверка с методикой ПСР ред. 12.17 (Евдокимов, Лейтес):
- ночь: 0.5 → 0.0 — методичка §12.3.5 прямо даёт «ночь = 0 км»:
  без фонаря в лесу ночью пострадавший не перемещается.
  Ранее 0.5 ЗАВЫШАЛО радиус ночного поиска.
- сумерки/вечер: 0.7 → 0.5 — «за час до заката скорость 50% от обычной».
  B5 переносил 0.7 из старого фронта вопреки методичке.
- get_base_speed: документирована семантика «скорость смещения»
  (не асфальтовая НормС из §12.3.2) — оператору не нужно вводить
  асфальтовую скорость, она уже свёрнута; СП = поправка конкретной
  местности поверх базового среднего леса.

Тесты обновлены под методичку, добавлен тест ночи = 0.
201 passed, 0 failed.
2026-09-09 11:11:34 +03:00
Sadmin 1119793145 fix: обновить тесты distance_service под канон B5 (3e2692f)
Тесты проверяли старые бэкендовские скорости (1.0–5.5 км/ч), тогда как
B5 перенёс в живую копию services/distance_service.py каноничные
displacement-скорости фронта (0.3–2.5 км/ч, априоры ПСО «Экстремум»,
94% в 3 км) и сумерки 0.7.

Обновлены ожидания (пересчитаны все кейсы):
- TestBaseSpeed: 0.3/0.7/1.2/1.5/2.0/2.5/2.5/1.5 по возрастам
- 5 кейсов calculate_max_distance пересчитаны (5.12→1.92 и т.д.)
- test_twilight: 0.5 → 0.7
- test_statistics_values: base_speed 4.0 → 1.5
- test_fatigue_minimum: 27.0 → 13.5
- Добавлена контрольная точка B5: bike 8yo 2h лес = 5.4 км

Коэффициенты в services/ НЕ тронуты (B1-safe) — только тесты.

201 passed, 0 failed.
2026-09-09 10:56:28 +03:00
Sadmin 3e38aae2be feat: завершённые поиски — ручной ввод + детерминированный прогон модели (без ИИ)
Бэкенд:
- schemas.py: ClosedCaseCreate/Response — форма ~25 полей (субъект, среда,
  ресурсы, исход) для внесения завершённых поисков вручную
- routers/closed_cases.py: CRUD + POST {id}/reanalyze. _run_model() —
  тот же скоринг, что /analyze (WeightedScorer + distance_service +
  психотип), НО БЕЗ claude_service: urgency/behavior/actions по правилам
- _accuracy_note(): прогнозный радиус vs факт (в радиусе / мимо)
- main.py: роутер подключён

Фронтенд:
- pages/ClosedCases.jsx + .css: форма внесения (VCL-density, чипы диагнозов/
  рельефа), блок 'Прогноз vs факт', таблица архива (в радиусе/мимо)
- AdminDashboard.jsx: вкладки Кейсы | Завершённые поиски

Тесты: test_closed_cases.py — 6 passed. Существующие 15 failed
(test_distance_service) не связаны с изменениями — падали до них.
2026-09-09 10:41:33 +03:00
root d6e6277d85 B20: контракт обмена Вектор↔Контур (граница проектов)
Версионированный протокольно-агностичный формат на границе: outbound
(SearchZones/RecommendedRoutes/ResourceTasks/SearchModelMeta) и inbound
(Positions/Tracks/AreasChecked/Observations/FoundEvent). Движок работает
только с форматом, транспорт — адаптеры Контура. От B20 зависят B17 и B18.
Рекоменд.порядок: B14→B15→B20→(B16,B17)→B18.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:53:06 +00:00
root bef603415f B19: переписать как отдельный проект «Контур» (не фаза Вектора)
По концепции пользователя: Вектор = «где искать», Контур = «как выполнять
поиск в поле» — два независимых проекта. Контур = ATAK + Meshtastic + GPS.
Замкнутый цикл: Вектор→зоны→Контур→поле→наблюдения→Вектор→перерасчёт.
Вектор-сторона взаимодействия = B17 (приём данных) + B18 (перерасчёт/выдача)
+ отдельная задача «Контракт обмена» (развязка: движок не знает ATAK/Mesh).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:51:49 +00:00
root 25d852035a B13–B19: дорожная карта реструктуризации Вектора в систему подсистем
B13 — каркас-видение: 7 подсистем (Core/GIS/Search Area/Field Data/
Real-time/Backend/UI) + 4 ключевых принципа (развязка движка, 4 слоя данных,
итеративная модель, очередь задач) + технический контур PostGIS.
B14 — чистый SearchEngine (поведение-сохраняющий, B1-safe).
B15 — 4 слоя данных: схема + Alembic-миграция + версионирование моделей.
B16 — GIS: PostGIS + локальный OSM (замена живого Overpass).
B17 — Field Data: модель + ингестия операционных данных.
B18 — итеративный пересчёт модели + очередь задач (event-driven).
B19 — «Контур» (ATAK + Meshtastic, реал-тайм) — плейсхолдер, отдельный проект.
Сводка и рекомендуемый порядок обновлены.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:46:14 +00:00
root 3e2692f8a5 B5: перенести расчёт анализа в бэкенд (единый источник правды)
AnalysisResult.jsx теперь тонкий клиент над POST /api/v1/analyze — локальные
формулы (calculateFullDistance / generateZonesWithPsychotype / getPsychotypeModifiers)
удалены. Бэкенд становится единственным источником правды.

distance_service:
- get_base_speed: перенесены каноничные displacement-скорости фронта (0.3–2.5)
  вместо расходящихся значений бэкенда (1.0–5.5) — согласовано с априорами ПСО
- реализованы заглушки СКД (get_diagnosis_coefficient: эпилепсия 0.6, ЗПР 0.8,
  РАС 1.0, СДВГ 1.1, здоров 1.0) и СУ (get_transport_coefficient: bike ×5,
  car ×8, иное ×3, none ×1) — критичная безопасность bike×5 теперь в бэкендe
- get_time_of_day_coefficient: сумерки/вечер 0.7 (было 0.5)

analyze.py:
- _derive_profiles: профили (РАС/эпилепсия/СДВГ/ЗПР/велосипед/самокат) формируются
  из diagnosis_type + has_transport, закрывая рассинхрон bike/велосипед на границе
- _derive_time_of_day: время суток из loss_time (порт getTimeOfDay фронта)
- психотип детектится ДО claude_analyze, модификаторы прокидываются в score_zone
- _as_case_data: фильтр пустых контейнеров ([],{}) — фикс затирания данных карточки
  дефолтами payload (psychotype_answers={} затирал реальные ответы)
- в ответ добавлены coefficients, time_of_day, active_profiles, critical_warnings

scoring_service.score_zone: применяется множитель полосы психотипа по дистанции
(порт getPsychotypeModifiers; таблица уже есть в psychotype_service).

AnalysisResult.jsx: рендер бэкенд-ответа, адаптер primary_zones→SearchMap
(score из приоритета, reasoning←reason), локализация пола m/f→М/Ж, бейдж
fallback/ИИ, блоки активных профилей + критических предупреждений + рекомендаций
по психотипу, исправлены ключи отображения (loss_time/behavior_description/
temperature_c). Поведение сохранено (bike 8yo 2h лес = 5.4 км, как во фронте).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:36:35 +00:00
root d0f309cdf6 chore(frontend): remove dead components (B4)
Недостижимые от index.js -> App компоненты (23 файла):
- MobileForm.jsx/.tsx/.css (живая мобильная форма = CaseForm)
- BehavioralProfile.tsx/.css
- AnalysisLayout/, MapView/, ResultPanel/ (MapView/ResultPanel
  импортировались только из неиспользуемого AnalysisLayout; живая
  карта = SearchMap.jsx)
- admin/ (CaseDetail, CasesList, HeatmapView, Statistics) - AdminDashboard
  использует inline-UI, не эти компоненты
- CaseForm/Step5Resources.jsx - не подключён в CaseForm.jsx

Сборка проходит. Мёртвый код с его багами (isPsycotypeSelected опечатка,
хардкод URL, NaN-гварды) уходит вместе с файлами.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:14:25 +00:00
root bef93d159c feat(auth): minimal login/logout + ProtectedRoute (B-LOGOUT)
- api/client.js: apiFetch подставляет Bearer-токен, выставляет
  Content-Type: application/json для не-FormData тел, ловит 401 -> событие
- context/AuthContext: валидация токена через /auth/me, loading-гейт,
  logout(), подписка на 401
- components/ProtectedRoute: guard с ролями и заглушкой на время проверки
- pages/Login: форма входа (OAuth2 form-urlencoded), редирект на from
- App.js: AuthProvider, /login, ProtectedRoute на /sar /desktop
  /analysis /admin (admin -> roles=[admin]) /mobile
- AdminDashboard/CaseForm/AnalysisResult: переход на apiFetch
- AdminDashboard: кнопка "Выйти" в шапке (useAuth().logout)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:04:18 +00:00
Sadmin c2ad5c1915 Обновить vector_tasks.md 2026-08-15 13:36:23 +00:00
root 83e75e7c24 docs(tasks): add B11 (Anthropic -> local Ollama) and B12 (ISRID priors)
Both are Section B (needs-context): B11 touches the AnalysisResult
contract and the question of where an LLM is needed at all; B12 touches
scoring coefficients and the DB schema, which project rules keep off
limits to automated edits.

B11 records the key finding that the LLM does not compute priorities --
the math already lives in geo_service/scoring_service/psychotype_service
and analyze_with_fallback already returns a full AnalysisResult with no
API. The only unique in-hot-path LLM role is parsing free-text reports,
so a strict intake form and a local provider are independent levers.

B12 notes ISRID supplies empirical priors for the currently hand-tuned
zone weights, with the legal status of the database flagged as a
blocking question to resolve before any data enters the repo.

Summary table updated to B1-B12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:31:04 +00:00
root b30ce86fa9 docs: fix LXC host address 192.168.0.108 -> 192.168.0.99
Revert of A9 address change. The .108 value was inferred from the
container VMID (108), not from its actual network config: net0 is
ip=dhcp and the container currently holds 192.168.0.99. Requests to
.108 fail with "No route to host", making the UI look down while all
four compose services are healthy.

A9 section in vector_tasks.md is kept as history and flagged stale so
the .99 -> .108 replacement is not repeated.

Note: the address is DHCP-assigned and may change again; a static IP
would be needed to make these docs durable. Hardcoded .99 URLs remain
in MobileForm.jsx:49 and HeatmapView.jsx:73 (tracked in vector_tasks.md,
untouched here).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:21:15 +00:00
root 3a0d2b0c2d B6: SPA navigation after case creation (useNavigate instead of window.location.href)
B7: stop AdminDashboard refetching the case list on selection change

selectedCaseId was both read and written inside the list-loading effect and
listed in its deps, causing a second fetch on first render and after every
filter reset. Auto-select now uses a functional state update, so the effect
no longer depends on selectedCaseId; editForm stays in sync via the existing
selectedCase effect.
2026-07-25 13:04:19 +00:00
root 8b3a2cbf7e B2: tolerant JSON extraction from Claude responses + graceful fallback
_extract_json_payload handles a json/JSON/bare fence, raw JSON and JSON
embedded in prose; any unparseable response or contract violation now
degrades to the deterministic scoring_service instead of raising.
Also guards the response envelope itself (content[0].text).

B3: single home for recommendation scoring

services/recommendation_service.py holds the rules; routers/stats.py and
backend/services/stats_service.py both delegate to it. Unified rules are the
union of the two old copies: same weights/threshold, substring matching
(superset of the old exact match), tolerant key aliases, health_flags rule
kept. Endpoint response contract unchanged.

Plus: Overpass circuit breaker and concurrent zone queries in geo_service -
128 sequential calls per analysis no longer each burn a connect timeout when
the host has no outbound network.

Tests: 152 -> 194 passed.
2026-07-25 13:01:02 +00:00
root 76278f5fe1 fix: apply A1/A2/A3 to the live services/ copy and ship it in the backend image
backend/routers/analyze.py imports services.* (root package), not
backend.services.* - so the earlier A1/A2/A3 edits landed in an unused
duplicate. Also COPY/mount services/ so the container can start at all
(ModuleNotFoundError: No module named services).
2026-07-25 12:31:07 +00:00
root 5a631f6553 A10-A16: datetime-local time parsing, step heading, gps_error state, unified leaflet 1.9.4 icons, drop debug log, dashboard error surfacing, isMobile on resize 2026-07-25 12:02:56 +00:00
root 0b033c402d A9: unify docs on 192.168.0.108 / vector_mchs / /root/vector 2026-07-25 11:57:36 +00:00
root cd65116a2a A2-A6: logging instead of print, claude-sonnet-5 model id, timezone-aware datetime, FastAPI lifespan, .docx parse errors -> HTTP 400 2026-07-25 11:57:03 +00:00
root 0de5beeb64 A7: enable pytest-asyncio (26 skipped async tests now run); fix stale distance assertion in test_geo_service 2026-07-25 11:49:53 +00:00
root 6ca59a04d0 A8: remove dead backend code (old api/ copy, models_* duplicates, .bak files) 2026-07-25 11:48:30 +00:00
161 changed files with 13050 additions and 8893 deletions
+5 -5
View File
@@ -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
# Примечание: внешний ИИ не используется (решение Виктора: ПД-данные
# не покидают контур). Все расчёты локальные и детерминированные.
+2
View File
@@ -4,3 +4,5 @@ __pycache__/
.env
samples/
/frontend/build/
.venv-test/
+5 -5
View File
@@ -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 или кешем)
## Дата создания
+24 -105
View File
@@ -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).
+3
View File
@@ -1,5 +1,8 @@
# Исправления ошибок - Итоговый отчет
> Исторический документ (2026-05-05). Упоминания Claude Service относятся
> к удалённому с тех пор LLM-слою; текущий анализ — rules_analysis (локально).
## Дата: 2026-05-05
### Исправленные проблемы:
+453
View File
@@ -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
View File
@@ -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
View File
@@ -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).*
+1
View File
@@ -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
+42
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Alembic scripts package for ВЕКТОР."""
+70
View File
@@ -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()
+26
View File
@@ -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"}
+161
View File
@@ -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')
+167
View File
@@ -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')
+51
View File
@@ -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')
+1
View File
@@ -0,0 +1 @@
"""Alembic migration package for ВЕКТОР."""
View File
View File
-347
View File
@@ -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
-23
View File
@@ -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
)
-196
View File
@@ -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
-212
View File
@@ -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
-159
View File
@@ -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']
)
+105
View File
@@ -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
-39
View File
@@ -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
View File
@@ -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]]:
+75
View File
@@ -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
View File
@@ -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('/')
+302
View File
@@ -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)
-89
View File
@@ -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())
-89
View File
@@ -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())
-93
View File
@@ -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())
+129
View File
@@ -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()
+96
View File
@@ -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()
+70
View File
@@ -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]
+1
View File
@@ -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
View File
@@ -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}
+325
View File
@@ -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
View File
@@ -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
View File
@@ -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'}
+2
View File
@@ -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
+309
View File
@@ -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}
+358
View File
@@ -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),
)
+82
View File
@@ -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
]}
+356
View File
@@ -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
View File
@@ -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'],
}
+327
View File
@@ -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
View File
@@ -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
+198
View File
@@ -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()
-312
View File
@@ -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
)
-377
View File
@@ -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
+1 -1
View File
@@ -112,7 +112,7 @@ class WeightedScorer:
'shelter': 2.0,
'settlement': 0.4,
'distance_mult': 2.0,
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно перекрыть ВСЕ водоёмы и ж/д пути.'
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно проверить ВСЕ водоёмы и ж/д пути.'
},
'эпилепсия': {
'water': 3.5,
-403
View File
@@ -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
}
+7 -25
View File
@@ -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 'Стандартный приоритет поиска',
}
+134
View File
@@ -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
+229
View File
@@ -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': []}
+248
View File
@@ -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()
-371
View File
@@ -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)
+69
View File
@@ -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
+72 -56
View File
@@ -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)
+198
View File
@@ -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
+253
View File
@@ -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
+252
View File
@@ -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
+203
View File
@@ -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
+4 -1
View File
@@ -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
+167
View File
@@ -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)
+105
View File
@@ -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()
-73
View File
@@ -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']
+80
View File
@@ -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()
+13
View File
@@ -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()
+202
View File
@@ -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'
+89
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+102
View File
@@ -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;
+20 -8
View File
@@ -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;
-146
View File
@@ -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;
}
}
-273
View File
@@ -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";
-353
View File
@@ -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);
}
-361
View File
@@ -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;
-296
View File
@@ -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";
+175 -81
View File
@@ -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="&copy; 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