Compare commits

...

22 Commits

Author SHA1 Message Date
claude-bot
e2bf99d05f feat(deploy): SSH deploy hook, Dockerfile includes scripts/docs
Some checks failed
CI / test (pull_request) Failing after 4s
CI / lint (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
2026-06-01 20:03:41 +03:00
506ef2a6dc Merge pull request 'deploy(ET-008): deploy log v0.0.1 + CHANGELOG' (#13) from deploy/ET-008-v0.0.1 into main 2026-06-01 17:34:42 +03:00
5769217cc5 deploy(ET-008): merge feature/ET-008-gps → v0.0.1, write deploy log
Some checks failed
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
PR #12 merged. Frontend static deployed (gps_tracks.js 200).
Backend service gps-collector pending docker compose up.
Smoke: 7/8 PASS; api/gps-tracks/health 404 until service starts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:34:14 +00:00
04d9d3e028 Merge pull request 'feat: ET-008-gps' (#12) from feature/ET-008-gps into main 2026-06-01 17:32:42 +03:00
claude-bot
af1a493cbf test(ET-008): round 3 - all P0/P1 PASS, 141+22 tests green
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 5s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
2026-06-01 14:31:53 +00:00
1ffa178b38 fix(gps-tracks): aggregate last_pipeline_run in health endpoint (REQ-F-12)
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 5s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 3s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
Replace raw single-row fetch with aggregation over all pipeline_runs
rows sharing the latest started_at. Returns structured object with
regions[], sources_ok[], sources_error[], tracks_added instead of
a raw DB row with region_id/source_id strings.

Returns null when no runs exist (empty DB).

Update test_i40_health_endpoint: add db_with_pipeline_runs fixture
(two rows, same started_at, two regions) and assert the full
aggregated shape including concrete values.

Refs: ET-008
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:27:52 +00:00
claude-bot
7c9cb37ecd test(ET-008): round 2 - F-04 partial, E2E blocked (not deployed)
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 5s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 3s
CI / test (pull_request) Failing after 4s
CI / build (pull_request) Has been skipped
2026-06-01 14:24:27 +00:00
ba356ae317 fix(gps-tracks): rename health fields and fix layer insert priority (F-04, F-05)
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 5s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
F-04: rename gps_health() response fields per tester feedback:
  - total_tracks → tracks_total
  - by_activity  → tracks_by_activity
  - recent_pipeline_runs (list) → last_pipeline_run (object | null)
  Change LIMIT from 10 to 1; fetch single row instead of a list.

F-05: rewrite _findGpsInsertPosition with explicit priority order:
  1. gpx-layer-* (ET-006 GPX file layers) — highest priority
  2. route-* (ET-002 routing layers)
  Remove old combined find() that lacked clear priority semantics.

Add tests/web/gps_tracks.test.js (22 JS unit tests via node:test):
  - _findGpsInsertPosition priority logic (9 cases)
  - Filter state management — default state assertions (5 cases)
  - Color palette mapping and _buildColorExpression (8 cases)

Add tests/web/test_gps_tracks.py — Python pytest runner (8 static
  checks + node --test invocation).

Refs: ET-008

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:18:06 +00:00
claude-bot
3a6017cc82 test(ET-008): 132/132 pass, back-to:dev for F-04/F-05/web-tests
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 4s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
2026-06-01 14:11:02 +00:00
edbe9a3044 fix(gps-tracks): normalise GeoJSON props, add health fields, OSM meta fetch, z-order fix
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 4s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 3s
CI / test (pull_request) Failing after 4s
CI / build (pull_request) Has been skipped
Refs: ET-008

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 12:42:36 +00:00
claude-bot
37190049db review(ET-008): findings - 1xP0, 4xP1, 3xP2, 4xP3
Some checks failed
CI / lint (push) Failing after 3s
CI / test (push) Failing after 5s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
2026-06-01 12:36:30 +00:00
claude-bot
3734b98168 feat(ET-008): GPS tracks pipeline, API, frontend layer
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 4s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
2026-06-01 12:29:22 +00:00
0060003f28 feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 4s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 4s
CI / build (pull_request) Has been skipped
Backend:
- Миграция gps_tracks_001_init.sql: таблицы tracks + pipeline_runs
- Пакет src/api/gps_tracks/: models, db (WAL+upsert с dedup), dedup
  (bbox+length+date bucket-hash), mvt (LRU-кэш 1024 тайла), endpoint
  (GET /api/gps-tracks, GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt,
   GET /api/gps-tracks/health, POST /api/gps-tracks/cache/clear), config
- Парсеры: osm (split_bbox, haversine, defusedxml XXE-защита),
  enduro_russia + ttrails — заглушки (ADR-010/011 proposed, блокированы)
- Licensing guard: pipeline проверяет status ADR-файла до запуска источника
- scripts/gps_collect.py: CLI с --region/--source/--dry-run/--gc

Frontend:
- src/web/gps_tracks.js: двухрежимный слой (MVT z≤11, GeoJSON z≥12),
  debounced fetch + AbortController, фильтры активности/источника,
  цветовая палитра by-source/by-activity, halo на спутнике, popup трека,
  restorePublicTracksState(), localStorage persistence
- index.html: чекбокс «Публичные треки» в terrain-popup, #sheet-gps-filters
- app.css: .terrain-link-btn, .gps-filter-grid, .track-popup
- app.js: вызов restorePublicTracksState() в rebuildMapOverlays(),
  applyGpsHaloVisibility() в applyBaseLayer()

Конфиги:
- config/gps_sources.yaml: osm (enabled), enduro_russia/ttrails (disabled)
- config/gps_regions.yaml: ЦФО+Чувашия (enabled), Кавказ (disabled)

Docker:
- gps-collector service с profiles: [batch]

Тесты: 48 новых тестов (unit + integration), 125/125 pass

Refs: ET-008

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 12:28:54 +00:00
a0284e046b reviewer(ET): auto-commit from reviewer run_id=41
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 6s
CI / lint (pull_request) Successful in 4s
CI / build (push) Successful in 1s
CI / test (pull_request) Successful in 5s
CI / build (pull_request) Successful in 1s
2026-06-01 12:17:33 +00:00
bd8f60879e developer(ET): auto-commit from developer run_id=40
All checks were successful
CI / lint (push) Successful in 3s
CI / lint (pull_request) Successful in 3s
CI / test (push) Successful in 6s
CI / build (push) Successful in 2s
CI / test (pull_request) Successful in 5s
CI / build (pull_request) Successful in 1s
2026-06-01 12:17:09 +00:00
claude-bot
f5fc8b121d chore: advance to development stage
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 5s
CI / build (push) Successful in 1s
2026-06-01 12:15:18 +00:00
claude-bot
d33f360a2f architect(ET-008): ADRs, infra/data requirements, tech risks
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 6s
CI / build (push) Successful in 2s
2026-06-01 12:15:05 +00:00
claude-bot
0840818c9a analyst(ET-008): BRD, TRZ, AC, TestPlan, UI tests v2
All checks were successful
CI / lint (push) Successful in 5s
CI / test (push) Successful in 5s
CI / build (push) Successful in 1s
2026-06-01 11:44:40 +00:00
claude-bot
dc557ab884 docs(ET-008): clean business request + merged analyst prompt fix
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 5s
CI / build (push) Successful in 2s
2026-06-01 11:29:29 +00:00
e8dbea6f13 merge main 2026-06-01 11:29:29 +00:00
fd28a53e12 Merge pull request 'fix(analyst): add explicit Write tool instruction' (#11) from fix/analyst-prompt into main 2026-06-01 14:27:50 +03:00
514490efd9 docs: init ET-008 business request
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 6s
CI / build (push) Successful in 2s
2026-06-01 14:01:37 +03:00
61 changed files with 9272 additions and 1345 deletions

View File

@@ -59,10 +59,17 @@ git push origin $NEW_TAG
### 3. Deploy ### 3. Deploy
```bash ```bash
cd /repos/enduro-trails # Deploy через SSH на хост (orchestrator имеет SSH ключ)
git fetch origin && git checkout main && git pull origin main DEPLOY_USER=${DEPLOY_SSH_USER:-slin}
# Deploy зависит от проекта. Для enduro-trails: DEPLOY_HOST=${DEPLOY_SSH_HOST:-127.0.0.1}
# Файлы уже на месте после merge в main, nginx обслуживает static HOOK=${DEPLOY_HOOK_SCRIPT:-/home/slin/bin/enduro-deploy-hook.sh}
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${DEPLOY_USER}@${DEPLOY_HOST} "bash ${HOOK}"
if [ $? -ne 0 ]; then
echo "ERROR: Deploy hook failed"
exit 1
fi
echo "Deploy OK"
``` ```
### 4. Healthcheck (до 60 сек) ### 4. Healthcheck (до 60 сек)

5
.task-arch.md Normal file
View File

@@ -0,0 +1,5 @@
Work item: ET-008
Repo: enduro-trails
Branch: feature/ET-008-gps
Stage: architecture
Title: GPS-треки с публичных платформ на карте

4
.task-dev.md Normal file
View File

@@ -0,0 +1,4 @@
Work item: ET-008
Repo: enduro-trails
Branch: feature/ET-008-gps
Stage: development

4
.task-review.md Normal file
View File

@@ -0,0 +1,4 @@
Work item: ET-008
Repo: enduro-trails
Branch: feature/ET-008-gps
Stage: review

View File

@@ -1,36 +1,5 @@
Прочитай CLAUDE.md. Твоя задача — bootstrap проекта для CI: Work item: ET-008
Repo: enduro-trails
1. Создай pyproject.toml в корне с секциями: Branch: feature/ET-008-gps
- [project] name="enduro-trails", version="0.1.0", requires-python=">=3.12" Stage: analysis
- [project.optional-dependencies] dev = ["ruff>=0.4.0", "pytest>=8.0", "httpx>=0.27", "pytest-asyncio>=0.23"] Title: GPS-треки с публичных платформ на карте
- [tool.ruff] target-version="py312", line-length=120
- [tool.pytest.ini_options] asyncio_mode="auto", testpaths=["tests"]
2. Создай tests/unit/test_health.py:
import pytest
from httpx import AsyncClient, ASGITransport
from src.api.main import app
@pytest.mark.asyncio
async def test_health_endpoint():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
3. Создай tests/__init__.py и tests/unit/__init__.py (пустые файлы)
4. Обнови .gitea/workflows/ci.yml:
- Используй образ python:3.12 для всех job
- Установка зависимостей: pip install -e ".[dev]"
- lint: ruff check src/
- test: pytest tests/
- build: docker build .
5. Создай ветку feature/bootstrap, закоммить всё, запуш в origin.
Коммит message: "feat: add pyproject.toml, dev dependencies, first unit test"
Push в ветку feature/bootstrap (НЕ в main).
Git remote использует http://localhost:3000/admin/enduro-trails.git

View File

@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [v0.0.1] — 2026-06-01
### Added
- ET-008: GPS-треки с публичных платформ на карте — новый модуль `src/web/gps_tracks.js`
с отображением публичных GPS-треков (OSM Traces, enduro_russia, ttrails) в виде
MVT-тайлов (z 811) и GeoJSON (z ≥ 12); фильтрация по активности и источнику,
попап с мета-данными трека, z-order ниже личных GPX-треков (AC-10).
Backend: FastAPI-пакет `src/api/gps_tracks/` (endpoint, MVT, LRU-кэш, дедупликация),
миграция `migrations/gps_tracks_001_init.sql`, pipeline-скрипт `scripts/gps_collect.py`,
Docker-сервис `gps-collector`. PR #12, tag v0.0.1.
## [Unreleased] ## [Unreleased]
### Added ### Added

View File

@@ -4,6 +4,9 @@ COPY src/api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY src/api/ ./src/api/ COPY src/api/ ./src/api/
COPY src/web/ ./src/web/ COPY src/web/ ./src/web/
COPY scripts/ ./scripts/
COPY migrations/ ./migrations/
COPY docs/ ./docs/
ENV STATIC_DIR=/app/src/web ENV STATIC_DIR=/app/src/web
ENV PORT=5556 ENV PORT=5556
EXPOSE 5556 EXPOSE 5556

12
config/gps_regions.yaml Normal file
View File

@@ -0,0 +1,12 @@
regions:
- id: tsfo_plus_chuvashia
name: "ЦФО + Чувашия"
bbox: [29.0, 49.5, 47.5, 60.0]
enabled: true
sources: [osm, enduro_russia, ttrails]
- id: north_caucasus
name: "Северный Кавказ"
bbox: [37.0, 41.5, 49.0, 47.0]
enabled: false
sources: [osm, enduro_russia]

34
config/gps_sources.yaml Normal file
View File

@@ -0,0 +1,34 @@
sources:
- id: osm
name: "OSM Public GPS Traces"
enabled: true
license_adr: "docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md"
base_url: "https://api.openstreetmap.org/api/0.6"
rate_limit_sec: 1
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
attribution: "© OpenStreetMap contributors (ODbL)"
parser_module: "src.api.gps_tracks.sources.osm"
save_user_field: true
external_url_template: "https://www.openstreetmap.org/user/{user}/traces/{external_id_numeric}"
- id: enduro_russia
name: "EnduroRussia.ru"
enabled: false
license_adr: "docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md"
base_url: "https://enduro-russia.ru"
rate_limit_sec: 5
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
attribution: "EnduroRussia.ru"
parser_module: "src.api.gps_tracks.sources.enduro_russia"
save_user_field: false
- id: ttrails
name: "Тропинки.ру"
enabled: false
license_adr: "docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md"
base_url: "https://ttrails.ru"
rate_limit_sec: 5
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
attribution: "ttrails.ru"
parser_module: "src.api.gps_tracks.sources.ttrails"
save_user_field: false

View File

@@ -7,6 +7,7 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./src/web:/app/src/web - ./src/web:/app/src/web
- ./config:/app/config:ro
environment: environment:
- DATABASE_URL=sqlite:///./data/enduro.db - DATABASE_URL=sqlite:///./data/enduro.db
- DATA_PATH=/app/data/centralfederal.sqlite - DATA_PATH=/app/data/centralfederal.sqlite
@@ -15,8 +16,25 @@ services:
- STATIC_DIR=/app/src/web - STATIC_DIR=/app/src/web
- OSRM_URL=http://172.22.0.1:5559 - OSRM_URL=http://172.22.0.1:5559
- PORT=5556 - PORT=5556
- GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite
- GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml
- GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"] test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
gps-collector:
build: .
profiles: ["batch"]
volumes:
- ./data:/app/data
- ./config:/app/config:ro
- /var/log/enduro-trails:/var/log/enduro-trails
environment:
- GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite
- GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml
- GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml
command: ["python", "-m", "scripts.gps_collect"]
restart: "no"

View File

@@ -8,7 +8,8 @@
- **Backend API** — FastAPI (Python 3.12), uvicorn - **Backend API** — FastAPI (Python 3.12), uvicorn
- **Tile Server** — статические raster tiles (PNG), раздаются через FastAPI/nginx - **Tile Server** — статические raster tiles (PNG), раздаются через FastAPI/nginx
- **Routing Engine** — OSRM с кастомным эндуро-профилем - **Routing Engine** — OSRM с кастомным эндуро-профилем
- **Database** — SQLite + Spatialite (точки интереса, маршруты) - **Database** — SQLite + Spatialite (точки интереса, маршруты, публичные GPS-треки)
- **GPS Tracks Pipeline** — `gps-collector` (docker-compose service, `profiles: [batch]`), запускается host cron'ом 12 раза в неделю; собирает публичные GPS-треки с внешних платформ в `data/gps_tracks.sqlite` (ET-008 / ADR-007)
## Слои карты ## Слои карты
- Base map: **Схема** (OpenStreetMap raster) либо **Спутник** (Esri World Imagery raster) — переключается в UI (ET-007 / ADR-004) - Base map: **Схема** (OpenStreetMap raster) либо **Спутник** (Esri World Imagery raster) — переключается в UI (ET-007 / ADR-004)
@@ -30,6 +31,44 @@
Атрибуция обоих провайдеров выводится MapLibre автоматически при Атрибуция обоих провайдеров выводится MapLibre автоматически при
активном source. активном source.
## GPS Tracks Pipeline (ET-008)
Серверный офлайн-pipeline сбора публичных GPS-треков. Не часть runtime
API, изолирован отдельным docker-compose service'ом и отдельной БД.
### Компонент
- Сервис: `gps-collector` в `docker-compose.yml`, `profiles: ["batch"]`,
тот же образ что `app`, не стартует при `docker compose up -d`.
- Точка входа: `scripts/gps_collect.py` (см. `src/api/gps_tracks/`).
- Расписание: cron на mva154, Mon + Thu 03:00 UTC; + ежемесячный GC.
- БД: `data/gps_tracks.sqlite` (SQLite + Spatialite, отдельный файл от
`centralfederal.sqlite`).
### Внешние источники pipeline
Скрейпинг/API только из контейнера `gps-collector`, при наличии
accepted-ADR на источник.
| Источник | Доступ | Лицензия | ADR | MVP |
|---|---|---|---|---|
| OSM Public GPS Traces | API `api.openstreetmap.org/api/0.6/trackpoints` | ODbL | ADR-009 (accepted) | да |
| EnduroRussia.ru | HTML + GPX-ссылки | требует review | ADR-010 (proposed/blocked) | условно |
| ttrails.ru / Тропинки.ру | HTML + GPX-ссылки | требует review | ADR-011 (proposed/blocked) | условно |
Источник без `status: accepted` в ADR pipeline'ом **пропускается** (см.
ADR-007 §6 licensing guard).
### Клиентский слой публичных треков
Двухрежимная отдача (см. ADR-008):
- z=8..11 — MVT через `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` + сервер-LRU.
- z≥12 — GeoJSON через `GET /api/gps-tracks?bbox=...&activity=...&source=...`.
- z<8 — слой скрыт (защита от шторма запросов).
Health/observability: `GET /api/gps-tracks/health` — состояние БД,
число треков по источникам, последний прогон.
## Деплой ## Деплой
Один Docker Compose на mva154. Nginx проксирует /enduro/ на контейнер. Один Docker Compose на mva154. Nginx проксирует /enduro/ на контейнер.
@@ -40,6 +79,7 @@
| `app.js` | Главный модуль: MapLibre, роутинг, UI, тёмная тема | PH-1..PH-6 | | `app.js` | Главный модуль: MapLibre, роутинг, UI, тёмная тема | PH-1..PH-6 |
| `units.js` | Централизованный форматтер расстояний (км/мили), localStorage, событие `unitchange` | ET-005 | | `units.js` | Централизованный форматтер расстояний (км/мили), localStorage, событие `unitchange` | ET-005 |
| `gpx.js` | GPX 1.1 парсер (DOMParser), рендеринг треков/waypoints, canvas-профиль высот, `rebuildMapOverlays()` | ET-006 | | `gpx.js` | GPX 1.1 парсер (DOMParser), рендеринг треков/waypoints, canvas-профиль высот, `rebuildMapOverlays()` | ET-006 |
| `gps_tracks.js` | Слой публичных GPS-треков (MVT + GeoJSON гибрид по zoom), фильтры по активности/источнику, popup с метаданными, halo на спутнике, `restorePublicTracksState()` | ET-008 |
| `style.json` | MapLibre стиль (светлая тема) | PH-1/PH-5 | | `style.json` | MapLibre стиль (светлая тема) | PH-1/PH-5 |
| `style-dark.json` | MapLibre стиль (тёмная тема) | PH-5 | | `style-dark.json` | MapLibre стиль (тёмная тема) | PH-5 |

View File

@@ -8,3 +8,10 @@
| ADR-002 | GPX-фича как отдельный модуль `gpx.js` | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-002-gpx-module-structure.md) | | ADR-002 | GPX-фича как отдельный модуль `gpx.js` | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-002-gpx-module-structure.md) |
| ADR-003 | Парсинг GPX — `DOMParser` в основном потоке с чанковой конвертацией | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-003-gpx-parsing-strategy.md) | | ADR-003 | Парсинг GPX — `DOMParser` в основном потоке с чанковой конвертацией | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-003-gpx-parsing-strategy.md) |
| ADR-004 | Спутниковая подложка: Esri World Imagery, ленивый raster-source, гибридное halo | accepted | 2026-05-31 | [ET-007](../../work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md) | | ADR-004 | Спутниковая подложка: Esri World Imagery, ленивый raster-source, гибридное halo | accepted | 2026-05-31 | [ET-007](../../work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md) |
| ADR-005 | Хранение публичных GPS-треков: отдельная БД `data/gps_tracks.sqlite`, единая таблица, sources как JSON-массив (arch:major-change) | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-005-storage-schema.md) |
| ADR-006 | Дедупликация GPS-треков: bbox+length+date bucket-hash, мерж sources при коллизии, без геометрических метрик | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md) |
| ADR-007 | Pipeline сбора GPS-треков: docker-compose service `gps-collector` (profiles:[batch]), запуск host cron'ом, per-source изоляция (arch:major-change) | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md) |
| ADR-008 | Двухрежимная отдача публичных треков: MVT на z≤11, GeoJSON на z≥12, клиентский AbortController+debounce | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md) |
| ADR-009 | OSM Public GPS Traces — licensing: ODbL, accepted для MVP | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-009-osm-licensing.md) |
| ADR-010 | EnduroRussia.ru — licensing: БЛОКИРОВАН до завершения ToS/robots ревью | proposed | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md) |
| ADR-011 | ttrails.ru — licensing: БЛОКИРОВАН до завершения ToS/robots ревью | proposed | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md) |

View File

@@ -1,51 +1,26 @@
--- # Business Request: GPS-треки с публичных платформ на карте
type: business-request
work_item_id: ET-008
title: "GPS-треки с публичных платформ на карте"
created_at: 2026-06-01
source: plane
requester: Слава
---
# Бизнес-запрос — ET-008 ## Цель
Отобразить на карте enduro-trails реальные GPS-треки с публичных платформ, чтобы видеть дороги/тропы которых нет на OSM, понимать где реально ездят, и выявлять мёртвые дороги.
## Исходная формулировка ## Требования
- Отдельные линии треков (не heatmap)
- Регион: ЦФО + Чувашия (расширяемо на другие регионы РФ)
- Фильтрация по типу активности (enduro/moto/offroad приоритет)
> Хочу видеть на карте GPS-треки с публичных платформ (OSM, чужие ссылки ## Источники треков (РФ покрытие)
> на GPX), а не только локальные файлы. Минимум: вставить ссылку на - Wikiloc (enduro/Russia раздел, GPX)
> GPX-файл — увидеть трек. Дальше — поиск чужих публичных треков в - Offmaps.ru (offroad специализация)
> видимой области карты, чтобы перед поездкой посмотреть, кто и где ездил. - Тропинки.ру / ttrails.ru (GPX/KML, эндуро-категория)
- EnduroRussia.ru (GPX по регионам, фильтр сложности)
- OSM GPS Traces (публичные, API)
- Nakarte.me (агрегатор)
- Komoot (API)
- Strava Metro (для валидации популярности)
## Контекст и ограничения ## Функционал
1. Сбор GPX-треков по bbox региона из источников
1. ET-006 уже даёт инфраструктуру отображения GPX-треков (модель, 2. Хранение с дедупликацией и метаданными (источник, тип активности, дата, сложность)
рендеринг, sheet, профиль высот). Эту инфраструктуру переиспользуем. 3. Визуализация отдельными линиями на карте (цвет по источнику или типу)
2. В стеке нет авторизации пользователей и БД с user accounts — 4. Фильтр по типу активности и источнику
платформы с обязательным OAuth (Strava, Komoot) **вне scope MVP**. 5. Расширяемость на новые регионы
3. Платный API Wikiloc — **вне scope MVP**.
4. CORS не позволяет браузеру тянуть GPX напрямую с большинства
платформ — нужен прокси через FastAPI.
5. Rate limits публичных API (OSM, Overpass) — нужен server-side кэш.
## Решения аналитика (по умолчанию, при отсутствии явных уточнений)
| Вопрос | Решение | Обоснование |
|--------|---------|-------------|
| Платформы MVP | OSM Public GPS Traces + универсальный GPX-по-URL | Открытые API без авторизации, бесплатные, покрывают сценарии «свой трек по ссылке» и «чужие треки рядом» |
| Сценарии | (1) импорт по URL; (2) bbox-поиск треков в видимой области | Минимальный полезный набор, не требующий новых разделов UI |
| Хранение | Сессия (как ET-006) + server-side LRU-кэш на бэкенде | Не вводим БД и аккаунты; кэш защищает от rate limits |
| Auth | Нет | Все запросы — публичные данные |
| Платформы post-MVP | Wikiloc API, Strava OAuth, Komoot OAuth | Будут отдельными work item, когда появится система аккаунтов |
## Уточнения
1. URL-импорт должен работать с любой прямой ссылкой на `.gpx`-файл
(GitHub raw, gist, личный сайт, веб-сервер пользователя).
2. Поиск по OSM-трекам ограничен видимой областью карты (bbox).
Глобальный поиск не требуется.
3. Загруженные с публичных платформ треки попадают в тот же sheet
`#sheet-gpx`, что и локальные GPX, и ведут себя идентично (статистика,
профиль высот, удаление, fit bounds, переживание смены стиля).
4. Источник трека (URL / OSM trace id) сохраняется в модели и
отображается в карточке трека для пользователя.
5. Кэш на сервере — TTL 24 часа, не персистентный (in-memory).

View File

@@ -2,10 +2,12 @@
type: brd type: brd
work_item_id: ET-008 work_item_id: ET-008
title: "BRD: GPS-треки с публичных платформ на карте" title: "BRD: GPS-треки с публичных платформ на карте"
version: 1 version: 2
status: draft status: draft
created_at: 2026-06-01 created_at: 2026-06-01
updated_at: 2026-06-01 updated_at: 2026-06-01
changelog:
- "v2 (2026-06-01): полная переработка под реальный business request — серверная агрегация из ≥3 источников по региону, дедупликация, фильтры по активности и источнику, расширяемость на регионы. Предыдущая v1 трактовала задачу как URL-импорт + OSM live-поиск, что не соответствовало бизнес-цели."
authors: authors:
- "agent:analyst" - "agent:analyst"
--- ---
@@ -14,85 +16,213 @@ authors:
## 1. Цель ## 1. Цель
Дать пользователю возможность увидеть на карте Enduro Trails GPS-треки Показать пользователю Enduro Trails реальные GPS-треки, **заранее
с публичных источников без скачивания файлов вручную: либо вставив собранные с публичных платформ** (Wikiloc, Offmaps.ru, Тропинки.ру,
прямую ссылку на GPX, либо найдя чужие публичные треки в видимой EnduroRussia.ru, OSM Public GPS Traces, Nakarte.me, Komoot и т.п.) и
области карты (через OSM Public GPS Traces). сохранённые на сервере. Цель — три практические задачи мотоэндуриста:
1. **Видеть реальные дороги/тропы, которых нет в OSM.** Vector-тайлы
`trails` показывают только OSM-данные; реальные грунтовки/тропы из
GPS-логов дают информацию, которой в OSM никогда не было.
2. **Понимать, где реально ездят.** Плотность публичных треков на
участке — прямая прокси-метрика популярности и проходимости.
3. **Выявлять «мёртвые» дороги.** OSM-грунтовка, не покрытая ни одним
публичным треком за последние N лет — кандидат на «давно никто не
ездит, может быть заросла».
ET-008 даёт **новый отдельный слой** (поверх `trails`, ниже маршрута
OSRM) с отдельными линиями (не heatmap), цветом по источнику или типу
активности, с UI-фильтрами.
## 2. Контекст ## 2. Контекст
- ET-006 реализовал клиентский GPX-стек: парсер, модель - Vector-тайлы из OSM (`/api/tiles/{z}/{x}/{y}.mvt`) уже отдают
`window.gpxTracks`, sheet `#sheet-gpx`, статистика, профиль высот, грунтовки/тропы/POI. ET-008 их **не заменяет** — добавляет
переживание `map.setStyle()` через `rebuildGpxOverlays()`. Источник параллельный слой публичных GPS-треков.
данных — только локальный файл пользователя. - ET-006 реализовал клиентский импорт GPX-файлов пользователем
- Roadmap-фаза PH-3 «Smart Route» включает работу с GPX (импорт/экспорт). (`window.gpxTracks`, `#sheet-gpx`). Это **другой сценарий**: ET-006 —
- В стеке нет пользовательских аккаунтов и БД пользователей. Платформы с «мой трек в памяти браузера», ET-008 — «треки сообщества с сервера».
обязательным OAuth (Strava, Komoot) поэтому вне scope текущей итерации. Модели данных не пересекаются.
- Браузер не может тянуть GPX напрямую с большинства публичных платформ - Стек БД: SQLite + Spatialite. Для ET-008 заводится **отдельная** БД
из-за CORS. OSM API не разрешает кросс-доменные запросы → прокси `data/gps_tracks.sqlite` — чтобы не смешивать данные с основной
через FastAPI обязателен. `centralfederal.sqlite` и иметь независимый цикл обновления / бэкапа.
- OSM Public GPS Traces — открытый бесплатный источник публичных - Pipeline сбора**офлайн-скрипт на cron**, не runtime. На запрос
GPS-треков, формат GPX, есть bbox-поиск, нет авторизации для чтения. пользователя сервер отдаёт уже собранные данные.
- Регион MVP: **ЦФО + Чувашия** (18 субъектов ЦФО + Чувашская
Республика, площадь ≈ 670 тыс. км²). Расширение на другие регионы —
через конфиг-файл.
## 3. Scope ## 3. Scope
### In scope ### In scope
| # | Функция | | # | Функция |
|------|---------| | ----- | ----------------------------------------------------------------------------- |
| F-01 | Поле ввода URL прямой ссылки на GPX в `#sheet-gpx` | | F-01 | Pipeline сбора GPX-треков с ≥ 3 публичных источников |
| F-02 | Импорт GPX по URL через прокси-эндпоинт `/api/gpx/fetch` | | F-02 | Хранение треков в SQLite + Spatialite: геометрия + метаданные |
| F-03 | Кнопка «Найти публичные треки» в `#sheet-gpx` — поиск в bbox видимой области карты | | F-03 | Дедупликация: один реальный трек = одна запись, даже если найден в N источниках |
| F-04 | Прокси-эндпоинт `/api/gpx/osm/traces` для OSM Public GPS Traces | | F-04 | Метаданные трека: источник, URL, тип активности, дата, длина, кол-во точек, автор (если публичен) |
| F-05 | Список найденных OSM-треков с метаданными (длина, точек, описание, автор) | | F-05 | API endpoint `GET /api/gps-tracks?bbox=…&activity=…&source=…` для отдачи треков клиенту |
| F-06 | Импорт выбранного OSM-трека одним тапом | | F-06 | Векторные тайлы публичных треков `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` для эффективной отдачи на низких зумах |
| F-07 | Серверный LRU-кэш ответов внешних API (TTL 24 ч, in-memory) | | F-07 | Визуализация **отдельными линиями** (не heatmap) на карте |
| F-08 | Источник трека (URL / OSM trace id + ссылка) виден в карточке трека | | F-08 | Цветовая дифференциация: палитра по источнику (default) с возможностью переключения на палитру по типу активности |
| F-09 | Лимит размера загруженного по URL файла: 50 МБ (как ET-006) | | F-09 | UI-чекбокс «Публичные треки» в `#terrain-popup`: включить/выключить весь слой |
| F-10 | Внятные сообщения об ошибках (CORS-фейл, 404, лимит API, битый GPX) | | F-10 | UI-фильтр по типу активности (enduro / moto / offroad / bicycle / hike / other), multi-select |
| F-11 | Импортированные треки попадают в общий список `window.gpxTracks` и неотличимы от локальных по поведению | | F-11 | UI-фильтр по источнику, multi-select |
| F-12 | Конфиг-файл регионов: bbox + название + список активных источников |
| F-13 | MVP-датасет: ЦФО + Чувашия, ≥ 5000 треков |
| F-14 | Совместимость со сменой стиля карты (через `rebuildMapOverlays()` по аналогии с ET-006 REQ-F-13 и ET-007 REQ-F-06) |
| F-15 | Совместимость со спутниковой подложкой (ET-007): треки видны на спутнике с halo для контраста |
| F-16 | Клик по треку → popup с метаданными: имя/тип активности/источник/дата/длина и ссылка на оригинал |
| F-17 | Health-эндпоинт `/api/gps-tracks/health`: дата последнего сбора, кол-во треков по источникам, ошибки последнего прогона |
### Out of scope ### Out of scope
- OAuth-интеграции (Strava, Komoot) - **Real-time сбор**: только периодический офлайн (cron, 12 раза в неделю).
- Платный API Wikiloc - **Wikiloc Premium / Komoot Premium / любые платные API**: используем
- Поиск треков глобально (без bbox) только бесплатные публичные endpoints и публичные HTML-страницы там,
- Сохранение треков в БД между сессиями где это разрешено ToS источника.
- Подписки на пользователей других платформ - **Strava Metro как источник линий**: это heatmap, не отдельные треки —
- Загрузка собственных треков на публичные платформы не соответствует бизнес-требованию «отдельные линии». Опционально в
будущем — как метрика популярности для валидации, не для MVP.
- **OAuth-интеграции** (вход пользователя в Strava/Komoot со своим
аккаунтом): отдельный work item.
- **Загрузка пользователем своих треков в общую базу**: отдельный work item.
- **Редактирование/обрезка треков на стороне сервера**.
- **Конвертация из KML/FIT/TCX**: pipeline принимает только GPX.
- **Snap-to-road** для треков (выравнивание под дороги OSM).
- **Учёт сложности (drag-level) внутри трека**: фильтр только по типу
активности; сложность — отдельная задача (требует анализа геометрии и
скорости).
## 4. Метрики успеха ## 4. Источники (с оценкой реализуемости в MVP)
| Метрика | Критерий | Анализ каждого источника из business request с честной оценкой
|---------|----------| доступности и юридических условий:
| URL-импорт | Прямая ссылка на GPX до 50 МБ загружается за ≤ 5 сек на средней сети |
| OSM-поиск bbox | Запрос видимой области возвращает результат за ≤ 3 сек (с кэшем — мгновенно) |
| Точность | OSM-трек после импорта визуально совпадает с тем же треком из osm.org |
| Кэш | Повторный запрос той же области/URL в течение 24 ч — без обращения к внешнему API |
| UX | Все ошибки (CORS, 404, лимит, формат) — внятные toast-уведомления, не падение |
| Совместимость с ET-006 | Локальные и удалённые треки в одном списке, поведение идентично |
| Сохранение при смене стиля | Импортированные треки переживают переключение тёмной темы и слоёв рельефа |
## 5. Риски | # | Источник | Тип доступа | MVP | Комментарий |
| - | ------------------------- | ------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- |
| 1 | OSM Public GPS Traces | Документированный API | **да** | `/api/0.6/trackpoints?bbox=…&page=…`. Лицензия ODbL, атрибуция OSM. Объём для ЦФО оценочно ≈ 50100K точек, тыс. треков. |
| 2 | EnduroRussia.ru | Web (HTML + GPX-ссылки) | **да** | По регионам, есть прямые GPX-ссылки. Лицензия и условия скрейпинга — фиксируются в ADR `06-adr/source-licensing.md` до начала разработки. |
| 3 | Тропинки.ру / ttrails.ru | Web (GPX/KML) | **да** | Эндуро-категория, GPX доступен без авторизации. Условия скрейпинга — то же ADR. |
| 4 | Offmaps.ru | Web | пилот | Требует ревью формата выдачи и лицензии. Подключаем в пилот-режим если ADR разрешает. |
| 5 | Nakarte.me | Public layers + JSON | пилот | Агрегатор: содержит ссылки на треки внешних источников. Может быть «бесплатным» путём к Wikiloc/Strava-treki косвенно. Требует ревью лицензии. |
| 6 | Wikiloc | API (премиум) | нет | Бесплатный публичный API не отдаёт GPX. Без премиума — невозможно. **Откладываем.** |
| 7 | Komoot | API (партнёрский) | нет | Публичный API ограничен, нет публичной выдачи GPX по bbox. **Откладываем.** |
| 8 | Strava Metro | API (исследовательский) | нет | Heatmap, не отдельные треки → не соответствует бизнес-требованию. **Out of scope.** |
**MVP-минимум: 3 источника живут в продакшне** — обязательно OSM
(гарантированно доступен), плюс минимум 2 из (2)(5) по результатам
ADR-ревью лицензий.
### Юридический минимум
Перед началом разработки каждого источника (2)(5) — **обязательный
ADR** `docs/work-items/ET-008/06-adr/<source>-licensing.md`:
1. Что говорит ToS источника о скрейпинге / массовой загрузке GPX.
2. Что говорит robots.txt.
3. На каких условиях разрешена публикация чужих треков
(имя/анонимизация/атрибуция).
4. Rate-limit, который мы будем соблюдать (default: 1 req / 5 sec, с
корректным `User-Agent: enduro-trails/<v> (+contact)`).
5. Список метаданных, которые **нельзя** сохранять/публиковать (личные
адреса, имена при отсутствии явного согласия).
Источник без явного зелёного света в ADR — **не включается** в pipeline.
## 5. Метрики успеха
| Метрика | Критерий MVP |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| Покрытие региона | ≥ 5000 уникальных треков для ЦФО + Чувашии после первого полного прогона pipeline |
| Источники в продакшне | ≥ 3 источника, отдающих данные в БД |
| Дедупликация | < 5% дублей (один реальный трек — одна запись). Метрика: руками отсэмплировать 100 треков, посчитать дубли. |
| Производительность отдачи bbox | `GET /api/gps-tracks?bbox=…` ≤ 300 мс p95 на z ≥ 10 (≤ 500 треков в видимой области) |
| Производительность отдачи тайлов | `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` ≤ 200 мс p95 на z = 811 |
| Производительность отрисовки | При включённом слое pan/zoom без видимых фризов на десктопе и мобильных с 4 ГБ RAM |
| Расширяемость на регион | Добавить новый регион (bbox + название + список источников) — ≤ 30 строк YAML-конфига, без правки кода |
| Скорость UI-фильтров | Переключение фильтра по активности/источнику меняет видимую выборку за ≤ 200 мс (фильтрация на клиенте) |
| Сохранение слоя при `setStyle()` | Слой не теряется при переключении тёмной темы / спутника / hillshade — восстанавливается через `rebuildMapOverlays()` |
| Pipeline стабильность | Падение парсера одного источника не валит остальных; лог + алерт в `/api/gps-tracks/health` |
| Атрибуция | На карте видна атрибуция каждого активного источника; в popup трека — ссылка на оригинал |
## 6. Риски
| Риск | Вероятность | Влияние | Митигация | | Риск | Вероятность | Влияние | Митигация |
|------|-------------|---------|-----------| | ----------------------------------------------------------------------------------- | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| OSM API rate limit (1 запрос / IP / сек) | Высокая | Среднее | Серверный кэш по bbox + дебаунс на клиенте | | Источник меняет HTML → парсер ломается | Высокая | Среднее | Каждый источник в отдельном модуле, изолированная ошибка. Pipeline пишет статус по источнику в health-эндпоинт. Алерт при 2 неудачных прогонах подряд. |
| URL-прокси превращается в open redirect / SSRF | Средняя | Высокое | Whitelist схем (http/https), блок приватных IP, лимит размера, таймаут | | ToS источника запрещает скрейпинг | Средняя | Высокое | Обязательный ADR с фиксацией лицензии до подключения источника. Источник без явного разрешения — не включается. |
| Большие OSM-страницы (1000+ треков) → длинный список | Средняя | Низкое | Пагинация: показывать первые N, кнопка «ещё» | | Дубли треков из разных источников (один и тот же трек выкладывают на 2 платформах) | Высокая | Среднее | Spatial+temporal hash: bbox округлённый до 0.01° + длина ± 5% + дата ± 1 день → одна запись. Алгоритм в TRZ §6. |
| GPX по URL не существует / 404 | Высокая | Низкое | Toast с понятной ошибкой | | Перегрузка карты на низких зумах (10K+ треков в видимой области) | Высокая | Высокое | На клиенте: на z < 10 — отдача через MVT-тайлы с упрощением геометрии (как `simplify_coords` для `trails`). На z ≥ 10 — JSON с лимитом 500 треков. |
| Content-Type не `application/gpx+xml` | Высокая | Низкое | Проверять по содержимому (DOMParser), не по заголовкам | | Размер БД растёт неконтролируемо (миллионы треков при расширении на РФ) | Низкая | Среднее | Отдельная `gps_tracks.sqlite`. Ротация: треки старше N лет (по конфигу, default 5) удаляются. Метрика размера БД в health. |
| Чужой публичный трек содержит вредоносный XML / XXE | Низкая | Высокое | DOMParser в браузере (XXE отключён), на бэкенде — `defusedxml` | | Скрейпер банится по IP | Средняя | Среднее | Rate-limit + backoff + `User-Agent` с контактом. Сбор по cron 12 раза в неделю, не чаще. Per-source конфигурируемый delay. |
| Внешний API внезапно недоступен | Средняя | Низкое | Graceful degradation: показать сообщение, не блокировать другие функции | | Персональные данные в треках (точки «дом», имена) | Низкая | Высокое | Не сохраняем waypoint без явного публичного флага. Не сохраняем `author` если ToS требует анонимизации. Список запрещённых полей — в `08-data-requirements.md`. |
| Лицензия источника обязывает менять/удалять данные по требованию автора | Средняя | Среднее | Сохраняем `external_id` и `external_url` — можем удалить точечно по запросу. Pipeline уважает «удалённое на источнике» → удалять и у нас. |
| Pipeline ест слишком много трафика mva154 | Средняя | Низкое | Per-source лимит на прогон (например, max 1000 новых треков за прогон). Метрики в health. |
| Отдача больших MVT тайлов медленная | Средняя | Среднее | Серверный кэш тайлов (LRU 1024 записи, как уже сделано для `trails`). Упрощение геометрии по зуму. |
## 6. Зависимости ## 7. Зависимости
- **ET-006** — модель `window.gpxTracks`, рендеринг, sheet `#sheet-gpx`, ### Backend
парсер `parseGpx()`. Без ET-006 эта задача не имеет смысла.
- **Backend (FastAPI)** — новые эндпоинты `/api/gpx/fetch`, - Новый пакет `src/api/gps_tracks/` с подмодулями:
`/api/gpx/osm/traces`, добавление `httpx` (уже есть) и `defusedxml` - `models.py` — Pydantic + SQL schema
(новая зависимость, опционально — для server-side валидации). - `sources/<source>.py` — модули per-source (OSM, EnduroRussia, ttrails, …)
- Внешние сервисы: - `dedup.py` — алгоритм дедупликации
- `https://api.openstreetmap.org/api/0.6/trackpoints` — публичный API - `db.py` — обвязка SQLite + Spatialite
OSM, ограничения: 1 req/sec/IP, 5000 точек/страница, до 5 страниц. - `endpoint.py` — FastAPI routes
- Произвольные HTTPS-хосты (для URL-импорта) — без SLA, fail-soft. - `mvt.py` — генерация MVT-тайлов
- Зависимости Python: `httpx` (есть), `lxml` или `defusedxml` (новая —
для безопасного парсинга XML на сервере), `shapely` (есть).
### Pipeline
- Скрипт `scripts/gps_collect.py` — точка входа.
- Конфиг `config/gps_sources.yaml` — список источников и параметры.
- Конфиг `config/gps_regions.yaml` — список регионов (bbox + список
активных источников per-region).
- Cron на mva154: `0 3 * * 1,4 /usr/local/bin/python
/opt/enduro-trails/scripts/gps_collect.py` (Mon + Thu, 03:00 UTC).
- Логи: `/var/log/enduro-trails/gps-collect.log`.
### Frontend
- Новый модуль `src/web/gps_tracks.js` — слой, фильтры, popup, по
аналогии с `gpx.js`.
- Расширение `index.html`:
- Чекбокс «Публичные треки» и кнопка «Фильтры» в `#terrain-popup`.
- Bottom sheet `#sheet-gps-filters` с фильтрами по активности и
источнику.
- Расширение `style.json` / `style-dark.json`: layer + halo-layer для
спутника (по аналогии с `trails-track-halo-satellite` из ET-007).
- Интеграция с `rebuildMapOverlays()` в `app.js`.
### Инфра
- Файловая: `data/gps_tracks.sqlite` на mva154, права чтения для FastAPI,
права записи только для pipeline. Бэкап в общий backup-стек проекта.
- Сетевая: исходящие HTTPS к источникам с mva154 (уже разрешено).
### Документация
- `06-adr/source-licensing.md` — лицензии всех источников.
- `06-adr/dedup-algorithm.md` — обоснование выбора алгоритма
дедупликации.
- `06-adr/storage-schema.md` — обоснование отдельной БД vs единой.
- `07-infra-requirements.md` — cron, бэкапы, ротация, мониторинг.
- `08-data-requirements.md` — схема БД, поля, ограничения, политика
персональных данных.
- `10-tech-risks.md` — расширенный риск-реестр (расширяет §6 BRD).
### Связи с другими work items
- **ET-006** — модель `window.gpxTracks` живёт параллельно. ET-008 не
трогает её, использует свою модель `window.gpsTracksLayer`.
- **ET-007** — спутниковая подложка. ET-008 добавляет halo-слой для
публичных треков в режиме «Спутник» по тому же паттерну.
- **PH-3 Smart Route** — публичные треки в будущем могут стать входом
для построения «реально-езженого» маршрута. Не в scope ET-008.
- **PH-9 PWA** — слой публичных треков должен корректно работать в
офлайне (через cached MVT). Учитывается в TRZ, но реализация офлайна
— задача PH-9.

File diff suppressed because it is too large Load Diff

View File

@@ -2,274 +2,442 @@
type: acceptance-criteria type: acceptance-criteria
work_item_id: ET-008 work_item_id: ET-008
title: "AC: GPS-треки с публичных платформ на карте" title: "AC: GPS-треки с публичных платформ на карте"
version: 1 version: 2
status: draft status: draft
created_at: 2026-06-01 created_at: 2026-06-01
updated_at: 2026-06-01 updated_at: 2026-06-01
changelog:
- "v2 (2026-06-01): полная переработка под BRD/TRZ v2 — критерии серверной агрегации, дедупликации, MVT-тайлов, фильтров активности/источника, popup, halo-на-спутнике. Предыдущая v1 описывала URL-импорт + OSM live-поиск."
authors: authors:
- "agent:analyst" - "agent:analyst"
--- ---
# Acceptance Criteria — ET-008: GPS-треки с публичных платформ на карте # Acceptance Criteria — ET-008: GPS-треки с публичных платформ на карте
## AC-01: Секция «Источники» в `#sheet-gpx` ## AC-01: Конфигурация источников и регионов
```gherkin ```gherkin
Feature: Переключатель источников треков Feature: Расширяемая конфигурация
Scenario: Открытие GPX-панели Scenario: Включение нового источника
Given пользователь нажимает кнопку GPX в нижнем тулбаре Given config/gps_sources.yaml содержит источник с enabled=false
Then открывается панель #sheet-gpx When оператор меняет на enabled=true и перезапускает pipeline
And в верхней части видна секция «Источники» с тремя кнопками: «Из файла», «По ссылке», «Найти рядом» Then источник участвует в следующем прогоне
And по умолчанию активна кнопка «Из файла» And в /api/gps-tracks/health он появляется в tracks_by_source
Scenario: Переключение на «По ссылке» Scenario: Добавление нового региона
Given панель #sheet-gpx открыта Given оператор добавляет в config/gps_regions.yaml новую запись с bbox
When пользователь нажимает кнопку «По ссылке» And запись не превышает 30 строк YAML
Then кнопка «По ссылке» становится активной When оператор запускает pipeline без аргументов
And отображается поле ввода URL и кнопка «Загрузить» Then новый регион обрабатывается всеми указанными в нём источниками
And контент других вкладок скрыт And никаких правок Python-кода не требуется
Scenario: Переключение на «Найти рядом» Scenario: Отключение источника
Given панель #sheet-gpx открыта Given источник был enabled=true и собрал N треков
When пользователь нажимает кнопку «Найти рядом» When оператор меняет на enabled=false
Then отображается кнопка «Найти треки в этой области карты» Then следующий прогон pipeline пропускает этот источник
And ранее собранные треки остаются в БД и отдаются API
And в фильтре по источнику соответствующий чекбокс не выбран по умолчанию
``` ```
## AC-02: Импорт по URL — успешный сценарий ## AC-02: Pipeline сбора
```gherkin ```gherkin
Feature: Загрузка GPX по прямой ссылке Feature: Pipeline gps_collect.py
Scenario: Валидная публичная ссылка Scenario: Полный прогон по умолчанию
Given активна вкладка «По ссылке» Given config содержит регион ЦФО+Чувашия и 3 source enabled
When пользователь вставляет https://example.com/test-track.gpx (валидный, 1 МБ) When оператор запускает scripts/gps_collect.py
And нажимает «Загрузить» Then pipeline проходит по всем регионам и всем enabled-источникам
Then показывается индикатор загрузки And для каждой пары (region, source) пишется запись в pipeline_runs
And через 5 сек трек появляется на карте And exit code == 0 если хотя бы один трек собран по каждому источнику
And карта выполняет fit bounds
And трек добавляется в список #gpx-list
And в карточке трека отображается «🔗 example.com»
Scenario: Загрузка по Enter Scenario: Прогон одного источника
Given активна вкладка «По ссылке» When оператор запускает scripts/gps_collect.py --source osm
When пользователь вставляет URL и нажимает Enter Then обрабатывается только OSM
Then загрузка начинается без клика по кнопке And остальные source пропускаются
Scenario: Падение одного источника не валит остальные
Given OSM возвращает 503 на весь прогон
When pipeline запущен
Then OSM-источник помечается status='error' в pipeline_runs
And другие источники продолжают работу
And exit code сигнализирует ошибку (1) если запрошен strict-mode, иначе 0
Scenario: Dry-run
When оператор запускает с --dry-run
Then никаких INSERT в БД не делается
And pipeline_runs тоже не пишется
And в stdout выводится план: N треков было бы собрано
Scenario: Уважение rate-limit
Given у источника rate_limit_sec=5
When pipeline делает 10 последовательных запросов к этому источнику
Then суммарное время 9 * 5 = 45 сек (между запросами)
``` ```
## AC-03: Импорт по URL — ошибки ## AC-03: Дедупликация
```gherkin ```gherkin
Feature: Обработка ошибок URL-импорта Feature: Дедупликация треков
Scenario: Невалидный URL (схема) Scenario: Один трек найден в двух источниках
Given активна вкладка «По ссылке» Given OSM и EnduroRussia отдали один и тот же трек
When пользователь вставляет ftp://example.com/file.gpx (один автор выложил на обоих)
Then показывается toast «Невалидная ссылка» And bbox и длина совпадают в пределах допуска
And запрос на бэкенд не отправляется And даты совпадают
When pipeline обрабатывает обе записи
Then в БД одна запись tracks
And sources_json содержит обоих
And external_urls_json содержит обе ссылки
Scenario: Приватный IP Scenario: Похожие треки разных дат — НЕ дубли
Given пользователь вставляет http://192.168.1.1/file.gpx Given два трека с одинаковым bbox и длиной
Then бэкенд возвращает 400 And даты отличаются на > 1 день
And показывается toast «Эта ссылка недоступна» Then записи разные, дедуп НЕ срабатывает
Scenario: Несуществующий файл Scenario: Треки без даты от разных источников
Given URL ведёт на 404 Given оба трека без created_at
Then показывается toast «Файл не найден по этой ссылке» And bbox и длина совпадают
Then дедуп срабатывает (по умолчанию консервативный merge)
And это поведение задокументировано в ADR-002
Scenario: Файл больше 50 МБ Scenario: Метрика < 5% дубликатов
Given URL ведёт на GPX > 50 МБ Given в БД собрано 5000 треков
Then показывается toast «Файл слишком большой (макс. 50 МБ)» When QA-инженер выбирает 100 случайных треков и руками проверяет дубли
Then не более 5 треков (5%) являются дублями
Scenario: Не GPX (HTML по ссылке)
Given URL отдаёт HTML-страницу
Then показывается toast «По этой ссылке не GPX-файл»
Scenario: Внешний сервер не отвечает
Given внешний сервер таймаутит
Then показывается toast «Сервер не отвечает, попробуйте позже»
``` ```
## AC-04: Поиск OSM-треков ## AC-04: Endpoint /api/gps-tracks (GeoJSON)
```gherkin ```gherkin
Feature: Поиск публичных треков OSM в видимой области Feature: GeoJSON endpoint
Scenario: Успешный поиск с результатами Scenario: Запрос с малым bbox
Given активна вкладка «Найти рядом» Given в БД 1000 треков, из них 50 в bbox=[37.5,55.6,37.7,55.8]
And карта показывает область с публичными треками When клиент шлёт GET /api/gps-tracks?bbox=37.5,55.6,37.7,55.8
When пользователь нажимает «Найти треки в этой области карты» Then ответ 200, FeatureCollection с 50 features
Then показывается индикатор загрузки And total_in_bbox=50, returned=50, truncated=false
And через 3 сек появляется список найденных треков And time 300 мс p95
And каждая карточка содержит: иконку OSM, описание (page N), длину в км, кнопку «Показать», ссылку «»
Scenario: Пустая область Scenario: Bbox с обрезкой по limit
Given карта показывает область без публичных треков Given в bbox 1500 треков
When пользователь нажимает «Найти треки» When клиент шлёт GET .../api/gps-tracks?bbox=...&limit=500
Then отображается inline-сообщение «В этой области нет публичных GPS-треков» Then returned=500, total_in_bbox=1500, truncated=true
Scenario: Слишком большая область Scenario: Фильтр по активности
Given карта показывает область с bbox > 0.25 deg² Given в bbox 100 треков, 20 enduro, 30 moto, 50 hike
When пользователь нажимает «Найти треки» When клиент шлёт ?activity=enduro,moto
Then показывается toast «Слишком большая область, увеличьте zoom» Then returned=50
And запрос на бэкенд не отправляется (или возвращается 400)
Scenario: Пагинация Scenario: Фильтр по источнику
Given поиск вернул has_more=true Given в bbox 100 треков: 60 OSM, 30 EnduroRussia, 10 ttrails
Then в конце списка отображается кнопка «Показать ещё» When клиент шлёт ?source=osm
When пользователь нажимает «Показать ещё» Then returned=60
Then дозагружаются результаты следующей страницы
And они добавляются в конец списка Scenario: Невалидный bbox
When клиент шлёт bbox=foo
Then ответ 400
Scenario: bbox вне диапазона координат
When клиент шлёт bbox=200,100,250,150
Then ответ 400
Scenario: Поля feature.properties
Then каждая feature содержит: name, activity_type, user, created_at,
length_km, sources (array), external_urls (array)
``` ```
## AC-05: Импорт OSM-трека ## AC-05: Endpoint /api/gps-tracks/tiles MVT
```gherkin ```gherkin
Feature: Импорт выбранного OSM-трека на карту Feature: MVT tiles
Scenario: Импорт по кнопке «Показать» Scenario: Отдача тайла на z=10
Given найдено 3 OSM-трека в списке Given в БД есть треки в видимой области
When пользователь нажимает «Показать» у первого трека When клиент шлёт GET /api/gps-tracks/tiles/10/623/325.mvt
Then показывается индикатор загрузки Then ответ 200, Content-Type: application/x-protobuf
And через 5 сек трек появляется на карте And тело содержит layer gps_tracks с LineString features
And карта выполняет fit bounds
And трек добавляется в #gpx-list
And в карточке трека отображается «🌍 OSM #...» (кликабельная ссылка)
And карточка в #gpx-nearby-results получает индикатор «✓ Загружен»
Scenario: Повторный импорт того же трека Scenario: Тайл из кэша
Given OSM-трек уже импортирован Given тайл уже запрашивали
When пользователь нажимает «Показать» у этой же карточки в найденных When повторный запрос того же z/x/y
Then показывается toast «Уже загружен» Then header X-Cache: HIT
And новый трек НЕ добавляется And время 20 мс p95
Scenario: Внешняя ссылка на osm.org Scenario: Упрощение геометрии на низких зумах
Given в карточке найденного трека есть кнопка «» Given исходный трек 1000 точек на z=7
When пользователь нажимает «» When MVT генерируется
Then открывается новая вкладка с страницей трека на openstreetmap.org Then feature имеет упрощённую геометрию ( 100 точек после Douglas-Peucker)
Scenario: Properties фичи в MVT
Then feature.properties содержит: id, activity, source, sources,
length_km, name, ext_url
``` ```
## AC-06: Отображение источника в карточке трека ## AC-06: Endpoint health
```gherkin ```gherkin
Feature: Источник трека виден пользователю Feature: Health endpoint
Scenario: Локальный файл (ET-006 совместимость) Scenario: Полный отчёт
Given загружен GPX из локального файла When клиент шлёт GET /api/gps-tracks/health
Then в карточке трека под именем файла отображается «📁 локальный файл» Then ответ 200 JSON содержит:
| db_path |
| db_size_mb |
| tracks_total |
| tracks_by_source | (объект source_id int)
| tracks_by_activity | (объект activity int)
| last_pipeline_run | (объект с started/finished/sources_ok/sources_error)
| tile_cache_size |
Scenario: Загружен по URL Scenario: Health без БД
Given загружен GPX по ссылке https://github.com/user/repo/track.gpx Given БД отсутствует на диске
Then в карточке трека отображается «🔗 github.com» When клиент шлёт GET /api/gps-tracks/health
Then ответ содержит tracks_total=0 и предупреждение о БД (или 503)
Scenario: Загружен из OSM
Given загружен OSM-трек page 0
Then в карточке трека отображается ссылка «🌍 OSM #..» которая ведёт на osm.org
``` ```
## AC-07: Кэширование на бэкенде ## AC-07: Чекбокс «Публичные треки» в попапе
```gherkin ```gherkin
Feature: Серверный кэш ответов внешних API Feature: Включение слоя из попапа
Scenario: Повторный запрос URL из кэша Scenario: Чекбокс присутствует
Given URL запрашивался менее 24 часов назад Given пользователь нажимает #terrain-toggle
When клиент делает повторный GET /api/gpx/fetch?url=... Then в попапе #terrain-popup видна строка «Публичные треки» с чекбоксом
Then ответ возвращается с заголовком X-Cache: HIT
And время ответа 50 мс
And внешний запрос НЕ выполняется
Scenario: Cache miss Scenario: Включение слоя
Given URL запрашивается впервые When пользователь ставит галку «Публичные треки»
Then выполняется внешний запрос Then на карте появляются линии треков
And ответ возвращается с X-Cache: MISS And localStorage['gps-tracks-enabled'] = 'true'
And следующий запрос того же URL HIT And рядом с чекбоксом появляется ссылка «Фильтры»
Scenario: Повторный bbox-поиск из кэша Scenario: Выключение слоя
Given bbox запрашивался менее 24 часов назад When пользователь снимает галку
When клиент делает повторный GET /api/gpx/osm/traces?bbox=... Then линии исчезают с карты
Then ответ из кэша And localStorage = 'false'
And внешний запрос к OSM API НЕ выполняется And ссылка «Фильтры» скрывается
Scenario: Подсказка о минимальном zoom
Given текущий zoom < 8
And чекбокс включён
Then рядом с чекбоксом видна подсказка «Зум 8+»
And линии на карте не видны (без ошибок)
``` ```
## AC-08: Безопасность ## AC-08: Фильтры по активности и источнику
```gherkin ```gherkin
Feature: SSRF protection Feature: Sheet фильтров
Scenario: Прямой запрос к loopback Scenario: Открытие sheet
When клиент шлёт GET /api/gpx/fetch?url=http://127.0.0.1/data Given слой включён
Then бэкенд возвращает 400 When пользователь нажимает «Фильтры»
And никакого запроса к 127.0.0.1 не делается Then открывается #sheet-gps-filters
And видны секции «Тип активности», «Источник», «Цвет линий»
And по умолчанию выбраны все активности и все источники
Scenario: Запрос к приватной подсети Scenario: Фильтрация по активности
When клиент шлёт URL ведущий на 10.0.0.1, 192.168.x.x, 172.16.x.x Given в видимой области карты 743 трека, 200 enduro, 50 moto,
Then бэкенд возвращает 400 When пользователь снимает все галки кроме «Эндуро» и «Мото»
Then на карте отображаются только enduro и moto треки
And gps-stat-shown отражает новое число
And фильтрация мгновенная ( 200 мс), без сетевого запроса
Scenario: Редирект на приватный IP Scenario: Фильтрация по источнику
Given внешний URL отдаёт 302 redirect на http://127.0.0.1/... Given включено 3 источника
When клиент шлёт GET /api/gpx/fetch?url=<external> When пользователь снимает «OSM»
Then редирект проверяется повторно и блокируется Then OSM-треки скрываются на карте
And бэкенд возвращает 400
Scenario: Запрещённая схема Scenario: Переключение режима цвета
When клиент шлёт URL с file:// или gopher:// Given color-mode = 'source'
Then бэкенд возвращает 400 When пользователь выбирает «По активности»
Then цвета линий перерисовываются по палитре активности
And localStorage сохраняет 'gps-tracks-color-mode' = 'activity'
Scenario: Размер ответа превышает лимит Scenario: Сохранение фильтров между сессиями
Given внешний сервер начинает стримить файл > 50 МБ Given пользователь настроил фильтры (только enduro, только OSM)
Then бэкенд прерывает соединение When пользователь перезагружает страницу
And возвращает 413 Then sheet-фильтров восстанавливает те же чекбоксы
And слой отображает только enduro+OSM треки
``` ```
## AC-09: Совместимость с ET-006 ## AC-09: Popup при клике на трек
```gherkin ```gherkin
Feature: Локальные и удалённые треки в одной модели Feature: Popup трека
Scenario: Смешанный список Scenario: Клик по линии трека
Given загружен 1 локальный файл, 1 по URL, 1 из OSM Given на карте отображается слой публичных треков
Then в #gpx-list отображаются 3 карточки When пользователь кликает на линию трека
And каждая имеет уникальный цвет из палитры Then открывается popup с полями: name, activity (иконка+текст),
And каждая имеет свой индикатор источника length_km, points_count, created_at, user, sources (со ссылками)
And любую можно активировать, удалить, увидеть профиль высот
Scenario: Сохранение при смене темы Scenario: Трек из двух источников
Given на карте 3 трека разных источников Given трек имеет sources=['osm', 'enduro_russia']
When пользователь переключает тёмную тему Then popup показывает обе ссылки
Then все 3 трека остаются на карте
And источники в карточках сохраняются
And статистика и профиль активного трека сохраняются
Scenario: Сохранение при переключении слоёв рельефа Scenario: Трек без user/name
Given на карте 3 трека разных источников Then popup показывает «Без названия» и не показывает строку «Автор»
Scenario: Клик по фону карты
Given открыт popup
When пользователь кликает на пустое место карты
Then popup закрывается
```
## AC-10: Z-order и совместимость с другими слоями
```gherkin
Feature: Z-order
Scenario: Слой выше trails, ниже маршрута OSRM
Given на карте: OSM tiles + trails + публичные треки + маршрут OSRM
Then визуально маршрут OSRM перекрывает публичные треки
And публичные треки перекрывают trails из vector tiles
And базовая карта (OSM) самый нижний
Scenario: Совместимость с ET-006 (личные GPX)
Given пользователь загрузил свой GPX-файл (ET-006)
And слой публичных треков включён
Then оба видны параллельно
And личный трек визуально выше публичных
```
## AC-11: Совместимость со спутниковой подложкой (ET-007)
```gherkin
Feature: Halo на спутнике
Scenario: Включение спутника
Given слой публичных треков включён
When пользователь переключает подложку на «Спутник»
Then линии треков видны на спутнике
And появляется белая обводка (halo) для контраста
Scenario: Возврат на схему
When пользователь возвращается на «Схема»
Then halo скрывается
And линии отображаются обычными цветами
Scenario: Halo учитывает чекбокс
Given спутник активен
When пользователь выключает чекбокс «Публичные треки»
Then и линии, и halo скрываются
```
## AC-12: Сохранение при смене стиля карты
```gherkin
Feature: Переживание setStyle()
Scenario: Переключение тёмной темы
Given слой включён, фильтры настроены
When пользователь переключает тёмную тему (вызывает map.setStyle())
Then слой публичных треков восстанавливается
And линии видны с теми же цветами по тому же color-mode
And фильтры активности/источника сохранены
Scenario: Переключение спутник→схема
Given слой включён, активен спутник
When пользователь переключается на схему
Then слой остаётся видим, halo выключается
Scenario: Включение hillshade
Given слой включён
When пользователь включает hillshade When пользователь включает hillshade
Then все 3 трека видны поверх hillshade Then публичные треки остаются видны (поверх hillshade)
``` ```
## AC-10: Метрики кэша в `/api/health` ## AC-13: Производительность
```gherkin ```gherkin
Feature: Наблюдаемость кэшей Feature: SLA отклика
Scenario: Размер кэшей в health-эндпоинте Scenario: GeoJSON p95
When клиент шлёт GET /api/health When 100 запросов GET /api/gps-tracks?bbox= с 500 треков в bbox
Then ответ содержит поля gpx_fetch_cache_size и gpx_osm_cache_size Then p95 300 мс
And значения целые числа 0
Scenario: MVT cold
When запрос MVT-тайла без кэша
Then p95 200 мс
Scenario: MVT hot
When повторный запрос того же тайла
Then 20 мс, X-Cache: HIT
Scenario: Pan/zoom без фризов
Given слой включён с 500 треками в видимой области
When пользователь делает 10 быстрых pan-операций
Then нет видимых фризов (FPS 30 на десктопе)
``` ```
## AC-11: Производительность ## AC-14: Защита от шторма запросов
```gherkin ```gherkin
Feature: Лимиты времени отклика Feature: Debounce и AbortController
Scenario: OSM bbox запрос с кэш-хитом Scenario: Быстрый pan не плодит запросов
Given bbox в кэше Given слой включён на z 12
Then GET /api/gpx/osm/traces возвращается за 50 мс (p95) When пользователь делает 5 быстрых pan-операций за 1 секунду
Then выполняется не более 2 запросов /api/gps-tracks (debounce 500ms)
And предыдущие запросы отменены AbortController
Scenario: URL-импорт малого файла (1 МБ) Scenario: На z < 8 запросов нет
Then GET /api/gpx/fetch для 1 МБ файла завершается за 2 сек Given пользователь на z=5
When пользователь панит карту
Scenario: OSM bbox запрос без кэша Then запросов /api/gps-tracks?bbox= не выполняется
Then GET /api/gpx/osm/traces без кэша возвращается за 3 сек (p95) ```
## AC-15: Атрибуция
```gherkin
Feature: Атрибуция источников
Scenario: На карте видна атрибуция
Given слой включён, включены OSM и EnduroRussia
Then в правом нижнем углу карты отображается строка
«© OpenStreetMap contributors (ODbL) | EnduroRussia.ru»
Scenario: Popup содержит ссылку на оригинал
Given пользователь открыл popup трека
Then в нём видна ссылка «» на источник (или несколько)
When пользователь кликает на ссылку
Then открывается новая вкладка с оригиналом
```
## AC-16: Безопасность и юридические гарантии
```gherkin
Feature: Юридический минимум
Scenario: Источник без ADR не активируется
Given оператор пытается включить новый source в gps_sources.yaml
But ADR licensing-review отсутствует
Then pipeline-tests падают (CI блокирует merge)
Scenario: Pipeline не сохраняет запрещённые поля
Given source-ADR требует не сохранять `user`
When pipeline получает трек с user='Vasya'
Then в БД user=NULL для этой записи
Scenario: Удаление по запросу автора
Given автор оригинала пометил трек удалённым на источнике
When следующий прогон pipeline обнаруживает это
Then запись в нашей БД помечается как удалённая или удаляется
```
## AC-17: Расширяемость на новые регионы
```gherkin
Feature: Добавление региона ≤ 30 строк YAML
Scenario: Добавление «Северный Кавказ»
Given существующий config/gps_regions.yaml
When разработчик добавляет YAML-блок региона: id, name, bbox, enabled,
sources ( 30 строк суммарно)
And запускает pipeline
Then регион обрабатывается всеми указанными источниками
And никаких правок Python-файлов не требуется
And в /api/gps-tracks/health новый регион виден в last_pipeline_run.regions
``` ```

View File

@@ -2,423 +2,564 @@
type: test-plan type: test-plan
work_item_id: ET-008 work_item_id: ET-008
title: "Test Plan: GPS-треки с публичных платформ на карте" title: "Test Plan: GPS-треки с публичных платформ на карте"
version: 1 version: 2
status: draft status: draft
created_at: 2026-06-01 created_at: 2026-06-01
updated_at: 2026-06-01 updated_at: 2026-06-01
changelog:
- "v2 (2026-06-01): полная переработка под BRD/TRZ/AC v2 — серверная агрегация, дедупликация, MVT, фильтры активности/источника. Предыдущая v1 описывала URL-импорт + OSM live-поиск."
authors: authors:
- "agent:analyst" - "agent:analyst"
test_suites: test_suites:
- name: unit-gpx-proxy-validation - name: unit-config-loader
type: unit type: unit
description: "SSRF-валидация URL в gpx_proxy.is_safe_url()" description: "Загрузка и валидация YAML-конфигов sources/regions"
cases: cases:
- id: U-01 - id: U-01
name: "Принимает валидный публичный HTTPS URL" name: "Валидный gps_sources.yaml парсится"
input: "https://example.com/track.gpx (резолвится в публичный IP)" input: "Корректный YAML с 3 источниками"
expected: "is_safe_url() возвращает True" expected: "Возвращает список объектов Source с обязательными полями"
- id: U-02 - id: U-02
name: "Отклоняет схему ftp://" name: "Источник без license_adr — ошибка"
input: "ftp://example.com/track.gpx" input: "YAML с enabled=true, но без license_adr"
expected: "is_safe_url() возвращает False" expected: "ConfigError: 'enabled source requires license_adr'"
- id: U-03 - id: U-03
name: "Отклоняет схему file://" name: "Регион с unknown source — ошибка"
input: "file:///etc/passwd" input: "regions.sources содержит ID, которого нет в sources.yaml"
expected: "is_safe_url() возвращает False" expected: "ConfigError: 'unknown source id'"
- id: U-04 - id: U-04
name: "Отклоняет loopback IP" name: "Bbox региона валидируется"
input: "http://127.0.0.1/x.gpx" input: "bbox=[200, 100, 250, 150]"
expected: "is_safe_url() возвращает False" expected: "ConfigError: 'bbox out of valid range'"
- id: U-05 - id: U-05
name: "Отклоняет приватный IP (10.0.0.0/8)" name: "Disabled source игнорируется в pipeline"
input: "http://10.1.2.3/x.gpx" input: "Регион ссылается на disabled source"
expected: "is_safe_url() возвращает False" expected: "Pipeline пропускает этот source, warning в логе"
- id: U-06
name: "Отклоняет приватный IP (192.168.0.0/16)"
input: "http://192.168.1.1/x.gpx"
expected: "is_safe_url() возвращает False"
- id: U-07
name: "Отклоняет приватный IP (172.16.0.0/12)"
input: "http://172.16.0.1/x.gpx"
expected: "is_safe_url() возвращает False"
- id: U-08
name: "Отклоняет link-local IP (169.254.x.x)"
input: "http://169.254.169.254/metadata"
expected: "is_safe_url() возвращает False"
- id: U-09
name: "Отклоняет невалидный URL"
input: "not a url"
expected: "is_safe_url() возвращает False (без exception)"
- name: unit-dedup
type: unit
description: "compute_dedup_key и merge-логика"
cases:
- id: U-10 - id: U-10
name: "Отклоняет хост, который не резолвится" name: "Два трека с одинаковым bbox+length+date → один ключ"
input: "http://nonexistent-host-xyz-12345.invalid/x.gpx" input: "geom1, geom2 с близкими bounds, length_m differ < 5%, dates same day"
expected: "is_safe_url() возвращает False" expected: "compute_dedup_key(g1) == compute_dedup_key(g2)"
- id: U-11
name: "Разные даты → разные ключи"
input: "Те же bbox+length, daty отличаются на 2 дня"
expected: "compute_dedup_key различаются"
- id: U-12
name: "Bbox-округление до 0.01°"
input: "geom1.bounds=(37.6173, 55.7558, …), geom2.bounds=(37.6171, 55.7559, …)"
expected: "Один ключ (округление до 2 знаков)"
- id: U-13
name: "Merge: union sources"
input: "track в БД с sources=['osm'], новый с source='enduro_russia', тот же dedup_key"
expected: "Запись в БД обновлена: sources=['osm','enduro_russia']"
- id: U-14
name: "Merge: union external_urls"
input: "track в БД с external_urls=[...A], новый с [...B], тот же dedup_key"
expected: "В БД external_urls=[...A,...B] без дубликатов"
- id: U-15
name: "Merge: приоритет metadata по порядку sources.yaml"
input: "OSM (priority 1) собрал name='X', EnduroRussia (priority 2) собрал name='Y' с тем же dedup_key"
expected: "В БД name='X' (приоритет первого source)"
- name: unit-activity-mapping
type: unit
description: "Маппинг категорий источников в ACTIVITY_TYPES"
cases:
- id: U-20
name: "OSM tag 'enduro' → 'enduro'"
input: "['enduro', 'motorcycle']"
expected: "'enduro'"
- id: U-21
name: "OSM tag 'mtb' → 'bicycle'"
input: "['mtb']"
expected: "'bicycle'"
- id: U-22
name: "Unknown tag → 'other'"
input: "['xyz']"
expected: "'other'"
- id: U-23
name: "Пустой список тэгов → 'other'"
input: "[]"
expected: "'other'"
- name: unit-bbox-validation - name: unit-bbox-validation
type: unit type: unit
description: "Валидация bbox в osm_traces" description: "Валидация bbox в /api/gps-tracks"
cases:
- id: U-20
name: "Принимает малый bbox"
input: "bbox=[37.6, 55.7, 37.7, 55.8] (0.01 deg²)"
expected: "validate_bbox() возвращает True"
- id: U-21
name: "Отклоняет bbox > 0.25 deg²"
input: "bbox=[37.0, 55.0, 38.0, 56.0] (1.0 deg²)"
expected: "validate_bbox() возвращает False"
- id: U-22
name: "Отклоняет невалидные координаты"
input: "bbox=[200, 100, 250, 150]"
expected: "validate_bbox() возвращает False"
- id: U-23
name: "Отклоняет перевёрнутый bbox (west > east)"
input: "bbox=[38.0, 55.0, 37.0, 56.0]"
expected: "validate_bbox() возвращает False"
- name: unit-cache
type: unit
description: "LRU кэш с TTL"
cases: cases:
- id: U-30 - id: U-30
name: "TTL истёк → cache miss" name: "Валидный bbox"
input: "Положить запись с TTL 1 сек, ждать 2 сек, запросить" input: "bbox=37.0,55.0,38.0,56.0"
expected: "Возвращает None (или вызывает loader)" expected: "validate_bbox() = True"
- id: U-31 - id: U-31
name: "LRU вытеснение при переполнении" name: "bbox out-of-range"
input: "Заполнить кэш max=4 записями, добавить 5-ю" input: "bbox=200,100,250,150"
expected: "Первая (LRU) запись вытеснена" expected: "validate_bbox() = False"
- id: U-32 - id: U-32
name: "Округление bbox-ключа до 4 знаков" name: "Перевёрнутый bbox"
input: "bbox=[37.6172999, 55.7558001, ...] и bbox=[37.6173, 55.7558, ...]" input: "bbox=38,55,37,56 (west > east)"
expected: "Один и тот же кэш-ключ → cache hit" expected: "validate_bbox() = False"
- id: U-33 - id: U-33
name: "URL > 5 МБ не кэшируется" name: "Невалидный формат"
input: "Положить запись размером 6 МБ" input: "bbox=foo"
expected: "Запись не попадает в кэш (cache.get → None)" expected: "validate_bbox() = False"
- name: unit-osm-parser - name: unit-osm-parser
type: unit type: unit
description: "Парсинг OSM trackpoints GPX → JSON" description: "Парсер OSM trackpoints"
cases: cases:
- id: U-40 - id: U-40
name: "Извлечение точек из GPX 1.0" name: "Группировка trkpt по gpx_id"
input: "GPX с 1 <trk>, 1 <trkseg>, 50 <trkpt>" input: "GPX 1.0 с trkpt разных gpx_id"
expected: "JSON: {tracks: [{points_count: 50, distance_km: ~X}]}" expected: "Возвращает по треку на каждый gpx_id"
- id: U-41 - id: U-41
name: "Расчёт длины через Haversine" name: "Анонимные точки (без gpx_id) — пропуск"
input: "GPX с 3 точками: [37.6,55.7], [37.7,55.8], [37.8,55.9]" input: "GPX с точками без gpx_id"
expected: "distance_km ≈ 28.3 (±0.5)" expected: "Эти точки не попадают в результат"
- id: U-42 - id: U-42
name: "Пустой GPX (нет trkpt)" name: "Bbox-разбиение региона"
input: "GPX без точек" input: "region.bbox=(37, 55, 39, 57), cell_size=0.25"
expected: "JSON: {tracks: [], total_points: 0}" expected: "len(cells) = 8 * 8 = 64"
- id: U-43 - id: U-43
name: "Защита от XXE (defusedxml)" name: "Расчёт length_m через Haversine"
input: "GPX с DOCTYPE и внешней entity" input: "trkpt: [37.6,55.7], [37.7,55.8], [37.8,55.9]"
expected: "Парсер не выполняет загрузку внешней entity (или бросает ошибку)" expected: "length_m ≈ 28300 (±500)"
- name: unit-web-gpx-source - id: U-44
name: "Защита от XXE"
input: "GPX с DOCTYPE и внешней entity"
expected: "defusedxml блокирует, парсер не выполняет загрузку"
- id: U-45
name: "Тэги из GPX → activity_type"
input: "<tag>enduro</tag><tag>motorcycle</tag>"
expected: "activity_type='enduro'"
- name: unit-mvt-generation
type: unit type: unit
description: "Расширение модели window.gpxTracks полем source" description: "Генерация MVT-тайлов для gps_tracks"
cases: cases:
- id: U-50 - id: U-50
name: "Импорт по URL: source.kind='url'" name: "Тайл z=10 с 50 треками"
input: "importGpxFromUrl('https://github.com/x/y.gpx', mockedFetch)" input: "tile_to_bbox(10, x, y), 50 треков в bbox"
expected: "Трек добавлен с source={kind:'url', url:'https://github.com/x/y.gpx'}" expected: "Валидный MVT с layer gps_tracks, 50 features"
- id: U-51 - id: U-51
name: "Импорт OSM: source.kind='osm'" name: "Упрощение геометрии на z=7"
input: "importOsmTrace({osm_page:0, osm_bbox:[...], gpx_url:'...'}, mockedFetch)" input: "Трек 1000 точек, z=7"
expected: "Трек добавлен с source={kind:'osm', osm_page:0, osm_bbox:[...], url:'...'}" expected: "После simplify_coords ≤ 100 точек"
- id: U-52 - id: U-52
name: "Обратная совместимость: трек без source читается как 'file'" name: "Min-length фильтр на z ≤ 7"
input: "window.gpxTracks[0] без поля source" input: "Треки с length_m=500 и 5000 на z=7"
expected: "renderSourceRow() возвращает '📁 локальный файл'" expected: "Только трек ≥ 2000м попадает в тайл (min_length для z≤7)"
- id: U-53 - id: U-53
name: "Hostname extraction для URL-источника" name: "Properties в feature"
input: "source.url='https://raw.githubusercontent.com/user/repo/main/track.gpx'" input: "Track в БД"
expected: "renderSourceRow() возвращает '🔗 raw.githubusercontent.com'" expected: "feature.properties содержит id, activity, source, sources,
length_km, name, ext_url"
- name: integration-gpx-fetch - id: U-54
name: "Пустой тайл"
input: "Bbox без треков"
expected: "build_mvt() возвращает b'' (или валидный пустой MVT)"
- name: unit-color-palette
type: unit
description: "Цветовая палитра по источнику и активности"
cases:
- id: U-60
name: "Color by source: OSM = #3cb44b"
input: "feature.source='osm'"
expected: "Match-expression возвращает '#3cb44b'"
- id: U-61
name: "Color by activity: enduro = #e6194b"
input: "feature.activity='enduro'"
expected: "'#e6194b'"
- id: U-62
name: "Unknown source → fallback"
input: "feature.source='unknown'"
expected: "'#808080' (или fallback из палитры)"
- name: integration-pipeline
type: integration type: integration
description: "GET /api/gpx/fetch — прокси с реальным HTTP" description: "Pipeline gps_collect.py end-to-end с mock-источниками"
cases: cases:
- id: I-01 - id: I-01
name: "Успешная загрузка GPX по URL (mock-сервер)" name: "Полный прогон с 1 mock-источником"
input: "GET /api/gpx/fetch?url=http://test-server/track.gpx" input: "Mock OSM API → 100 треков; пустая БД"
expected: "200, Content-Type: application/gpx+xml, тело = GPX, X-Cache: MISS" expected: "После прогона в БД 100 tracks, pipeline_runs.status='ok',
tracks_new=100, tracks_updated=0"
- id: I-02 - id: I-02
name: "Повторный запрос — cache hit" name: "Повторный прогон того же источника — все треки updated"
input: "GET тот же URL" input: "Тот же mock + та же БД с предыдущей записью"
expected: "200, X-Cache: HIT, время ≤ 50 мс" expected: "tracks_new=0, tracks_updated=100"
- id: I-03 - id: I-03
name: "Отклонение приватного IP" name: "Прогон двух источников с пересечением"
input: "GET /api/gpx/fetch?url=http://127.0.0.1/x.gpx" input: "OSM mock = 100 треков, EnduroRussia mock = 50, из них 20 — те же по dedup_key"
expected: "400, JSON {error: ...}" expected: "В БД 130 уникальных записей (100 + 50 - 20). 20 пересекающихся имеют sources=['osm','enduro_russia']"
- id: I-04 - id: I-04
name: "Отклонение редиректа на приватный IP" name: "Падение одного источника"
input: "Внешний URL → 302 на http://127.0.0.1/x.gpx" input: "OSM mock OK, EnduroRussia mock возвращает 503"
expected: "400, JSON {error: ...}" expected: "OSM треки в БД, EnduroRussia status='error' в pipeline_runs,
но pipeline exit=0 (не strict-mode)"
- id: I-05 - id: I-05
name: "Внешний 404" name: "Dry-run"
input: "URL ведёт на несуществующий путь" input: "Любой источник + флаг --dry-run"
expected: "404, JSON {error: ...}" expected: "БД не меняется, pipeline_runs не пишется,
stdout содержит план"
- id: I-06 - id: I-06
name: "Лимит размера 50 МБ" name: "Rate-limit соблюдается"
input: "Mock-сервер стримит 60 МБ" input: "Mock source с rate_limit_sec=2, 5 запросов"
expected: "413, соединение прервано до конца" expected: "Суммарное время ≥ 8 сек (4 интервала × 2 сек)"
- id: I-07 - id: I-07
name: "Таймаут" name: "Backoff на 429"
input: "Mock-сервер ничего не отвечает" input: "Mock source первый раз 429, второй раз 200"
expected: "504 после 15 сек" expected: "Pipeline делает retry после exponential backoff,
трек собран"
- id: I-08 - name: integration-endpoint-geojson
name: "URL > 5 МБ не попадает в кэш"
input: "Запросить URL с ответом 6 МБ дважды"
expected: "Второй запрос: X-Cache: MISS, внешний запрос повторно выполнен"
- name: integration-osm-traces
type: integration type: integration
description: "GET /api/gpx/osm/traces — OSM API клиент" description: "/api/gps-tracks GeoJSON"
cases: cases:
- id: I-20 - id: I-20
name: "Bbox-запрос с результатами" name: "Малый bbox с фильтрами"
input: "GET /api/gpx/osm/traces?bbox=37.6,55.7,37.65,55.75 (mock OSM API)" input: "GET /api/gps-tracks?bbox=...&activity=enduro&source=osm"
expected: "200, JSON с tracks[], каждый имеет points_count, distance_km, gpx_url" expected: "200, FeatureCollection только enduro+OSM треков"
- id: I-21 - id: I-21
name: "Bbox > 0.25 deg² → 400" name: "Truncation"
input: "bbox=37,55,38,56" input: "В bbox 1500 треков, limit=500"
expected: "400, error 'bbox too large'" expected: "returned=500, total_in_bbox=1500, truncated=true"
- id: I-22 - id: I-22
name: "OSM API недоступен → 502" name: "Невалидный bbox → 400"
input: "OSM mock возвращает 500" input: "bbox=foo"
expected: "502, JSON error" expected: "400, JSON error"
- id: I-23 - id: I-23
name: "Cache hit на повторный bbox" name: "Bbox в океане → пустой результат"
input: "Тот же bbox дважды" input: "bbox=0,0,1,1"
expected: "Второй запрос: внешний запрос НЕ выполнен, ответ из кэша" expected: "200, features=[], total=0"
- id: I-24 - id: I-24
name: "Пустой bbox → пустой список" name: "CORS headers"
input: "bbox в океане" input: "Origin: https://example.com"
expected: "200, tracks=[], has_more=false" expected: "Response содержит Access-Control-Allow-Origin: *"
- id: I-25 - id: I-25
name: агинация" name: роизводительность"
input: "page=0 возвращает has_more=true, page=1 возвращает следующие" input: "100 запросов на bbox с 500 треков"
expected: "Корректное смещение, оба запроса валидны" expected: "p95 ≤ 300 мс"
- name: integration-health-metrics - name: integration-endpoint-mvt
type: integration type: integration
description: "Метрики кэшей в /api/health" description: "/api/gps-tracks/tiles/{z}/{x}/{y}.mvt"
cases: cases:
- id: I-30 - id: I-30
name: "Health возвращает размеры кэшей" name: "Тайл MVT отдаётся"
input: "GET /api/health" input: "GET /api/gps-tracks/tiles/10/623/325.mvt"
expected: "JSON содержит gpx_fetch_cache_size, gpx_osm_cache_size (числа ≥ 0)" expected: "200, Content-Type: application/x-protobuf,
X-Cache: MISS"
- id: I-31 - id: I-31
name: "Счётчики растут после запросов" name: "Cache hit"
input: "После N успешных fetch и M osm_traces запросов" input: "Повторный запрос того же тайла"
expected: "Размеры кэшей отражают добавленные записи" expected: "X-Cache: HIT, ≤ 20 мс"
- name: e2e-url-import - id: I-32
name: "Невалидные z/x/y"
input: "z=25 / x вне диапазона"
expected: "400"
- id: I-33
name: "Очистка кэша"
input: "POST /api/gps-tracks/cache/clear, повторный запрос тайла"
expected: "X-Cache: MISS"
- name: integration-endpoint-health
type: integration
description: "/api/gps-tracks/health"
cases:
- id: I-40
name: "Полный отчёт"
input: "GET /api/gps-tracks/health"
expected: "200, JSON со всеми полями (см. REQ-F-12)"
- id: I-41
name: "БД отсутствует"
input: "Удалить data/gps_tracks.sqlite, GET /api/gps-tracks/health"
expected: "503 или 200 с tracks_total=0 и warning"
- id: I-42
name: "Счётчики корректны"
input: "БД с 100 OSM + 50 EnduroRussia"
expected: "tracks_by_source: {osm: 100, enduro_russia: 50}"
- name: integration-web-layer
type: integration
description: "Клиентский слой публичных треков"
cases:
- id: I-50
name: "Включение/выключение слоя"
input: "Симуляция click на #public-tracks-cb"
expected: "map.getSource('gps-tracks-tiles') существует,
layer 'gps-tracks-layer' visibility=visible"
- id: I-51
name: "Фильтр по активности через setFilter"
input: "filters.activities = ['enduro']"
expected: "map.getFilter('gps-tracks-layer') содержит ['in', ['get','activity'], ['literal',['enduro']]]"
- id: I-52
name: "Переключение color-mode"
input: "Переключить с source на activity"
expected: "Layer paint['line-color'] переустановлен на activity-палитру"
- id: I-53
name: "GeoJSON-загрузка при z ≥ 12"
input: "map.zoom=14, moveend"
expected: "Через 500мс debounce — fetch /api/gps-tracks?bbox=…"
- id: I-54
name: "AbortController при быстром pan"
input: "Два moveend подряд за 100мс"
expected: "Первый fetch отменён, выполняется только второй"
- id: I-55
name: "Halo на спутнике"
input: "applyBaseLayer('satellite'), public-tracks включен"
expected: "layer 'gps-tracks-halo-satellite' visibility=visible"
- id: I-56
name: "Halo выключен на схеме"
input: "applyBaseLayer('schematic')"
expected: "halo visibility=none"
- id: I-57
name: "Сохранение слоя при setStyle"
input: "Переключение тёмной темы (switchMapStyle)"
expected: "rebuildMapOverlays() → restorePublicTracksState() →
слой пересоздан, фильтры применены"
- name: e2e-pipeline
type: e2e type: e2e
description: "Импорт GPX по ссылке — полный сценарий" description: "Полный pipeline на тестовых mock-источниках"
cases: cases:
- id: E-01 - id: E-01
name: "URL-импорт валидного трека" name: "Сбор → API → визуализация"
steps: steps:
- "Открыть приложение" - "Очистить test-БД"
- "Нажать кнопку GPX в нижнем тулбаре" - "Запустить pipeline с mock OSM + mock EnduroRussia"
- ереключиться на вкладку «По ссылке»" - роверить: tracks_total > 0 в /api/gps-tracks/health"
- "Вставить URL валидного GPX (тестовый mock)" - "Открыть веб-интерфейс"
- "Нажать «Загрузить»" - "Включить чекбокс «Публичные треки»"
- "Убедиться: индикатор показан, через ≤ 5 сек трек на карте" - "Убедиться: на карте видны линии треков"
- "Убедиться: в #gpx-list появилась карточка с источником «🔗 host»" - "Кликнуть по треку → popup с метаданными"
- "Кликнуть на трек → отображается статистика и профиль высот"
- id: E-02 - id: E-02
name: "URL-импорт по Enter" name: "Дедупликация — два прогона"
steps: steps:
- "Активировать «По ссылке»" - "Запустить pipeline (mock-источники отдают 100 треков)"
- "Вставить URL, нажать Enter" - "Запомнить tracks_total"
- "Убедиться: трек загружен (как при клике)" - "Запустить pipeline повторно (mock отдаёт те же 100)"
- "Убедиться: tracks_total не изменился"
- "Убедиться: pipeline_runs.tracks_updated=100"
- id: E-03 - name: e2e-ui-filters
name: "Невалидный URL → toast"
steps:
- "Вставить ftp://x.com/y"
- "Нажать «Загрузить»"
- "Убедиться: toast «Невалидная ссылка»"
- "Убедиться: на карте ничего нового"
- id: E-04
name: "Приватный IP блокируется"
steps:
- "Вставить http://192.168.1.1/x.gpx"
- "Нажать «Загрузить»"
- "Убедиться: toast «Эта ссылка недоступна»"
- id: E-05
name: "Не GPX по ссылке"
steps:
- "Вставить URL HTML-страницы"
- "Нажать «Загрузить»"
- "Убедиться: toast «По этой ссылке не GPX-файл»"
- name: e2e-osm-search
type: e2e type: e2e
description: "Поиск и импорт OSM треков" description: "UI-фильтры по активности и источнику"
cases: cases:
- id: E-10 - id: E-10
name: "Поиск треков в области и импорт" name: "Открытие фильтров и переключение"
steps: steps:
- "Открыть приложение, отзумиться к области Москвы (zoom 12)" - "Включить чекбокс «Публичные треки»"
- "Открыть #sheet-gpx, активировать «Найти рядом»" - "Нажать «Фильтры…» → открывается #sheet-gps-filters"
- "Нажать «Найти треки в этой области карты»" - "Снять все галки активности кроме «Эндуро»"
- "Убедиться: индикатор, потом список карточек" - "Убедиться: на карте видны только enduro-треки"
- "Нажать «Показать» у первой карточки" - "Снять «OSM» в источниках"
- "Убедиться: трек появился на карте, fit bounds" - "Убедиться: OSM enduro-треки скрылись"
- "Убедиться: карточка в найденных получила «✓ Загружен»"
- "Убедиться: в #gpx-list появилась карточка с «🌍 OSM #...»"
- id: E-11 - id: E-11
name: "Слишком большая область" name: "Переключение color-mode"
steps: steps:
- "Отзумиться на всю Россию" - "Включить слой"
- "Активировать «Найти рядом»" - "Открыть фильтры"
- "Нажать «Найти»" - "Выбрать «По активности»"
- "Убедиться: toast «Слишком большая область, увеличьте zoom»" - "Убедиться: цвета линий перерисованы (например, enduro = красный)"
- "Перезагрузить страницу"
- "Убедиться: color-mode='activity' сохранён"
- id: E-12 - id: E-12
name: "Пустая область" name: "Persistence фильтров"
steps: steps:
- "Перейти к области без треков (океан)" - "Настроить фильтры (только moto, только EnduroRussia)"
- "Активировать «Найти рядом»" - "Перезагрузить страницу"
- "Нажать «Найти»" - "Открыть фильтры"
- "Убедиться: сообщение «В этой области нет публичных GPS-треков»" - "Убедиться: чекбоксы соответствуют настройкам"
- id: E-13 - name: e2e-popup
name: "Пагинация"
steps:
- "Найти треки в области с большим количеством"
- "Убедиться: кнопка «Показать ещё» внизу"
- "Нажать «Показать ещё»"
- "Убедиться: список расширился"
- id: E-14
name: "Повторный импорт → toast"
steps:
- "Импортировать трек по «Показать»"
- "Нажать «Показать» у той же карточки ещё раз"
- "Убедиться: toast «Уже загружен»"
- id: E-15
name: "Внешняя ссылка на osm.org"
steps:
- "Найти треки, нажать «↗» у карточки"
- "Убедиться: новая вкладка открыта на openstreetmap.org"
- name: e2e-mixed-sources
type: e2e type: e2e
description: "Совместимость трёх источников в одной сессии" description: "Popup трека"
cases: cases:
- id: E-20 - id: E-20
name: "3 трека разных источников" name: "Popup полный набор полей"
steps: steps:
- "Загрузить 1 локальный файл" - "Включить слой"
- "Загрузить 1 по URL" - "Кликнуть на трек на карте"
- "Загрузить 1 из OSM" - "Убедиться: popup содержит name, activity-иконку, км, дату, user, sources"
- "Убедиться: 3 карточки в #gpx-list, разные цвета, разные источники" - "Кликнуть по ссылке источника"
- "Удалить URL-трек" - "Убедиться: открыта новая вкладка"
- "Убедиться: 2 трека на карте, корректные источники"
- id: E-21 - id: E-21
name: "Сохранение при смене темы" name: "Popup для трека без user"
steps: steps:
- "Загрузить 3 трека разных источников" - "Найти трек без user"
- "Переключить тёмную тему" - "Кликнуть → popup без строки «Автор»"
- "Убедиться: все 3 трека на карте"
- "Убедиться: источники в карточках сохранены"
- id: E-22 - name: e2e-compat
name: "Сохранение при включении hillshade"
steps:
- "Загрузить 3 трека"
- "Включить hillshade"
- "Убедиться: все 3 трека видны поверх hillshade"
- name: e2e-cache
type: e2e type: e2e
description: "Поведение кэша через API" description: "Совместимость с другими функциями"
cases: cases:
- id: E-30 - id: E-30
name: "Кэш URL-fetch снижает время" name: "Слой + спутник + halo"
steps: steps:
- "GET /api/gpx/fetch?url=<test-url> — измерить t1" - "Включить «Публичные треки»"
- "GET /api/gpx/fetch?url=<тот же url> — измерить t2" - "Переключить подложку на «Спутник»"
- "Убедиться: t2 < 100 мс, заголовок X-Cache: HIT" - "Убедиться: треки видны на спутнике с белой обводкой"
- id: E-31 - id: E-31
name: "Размеры кэша в health" name: "Слой + тёмная тема"
steps: steps:
- "Сделать N запросов /api/gpx/fetch" - "Включить слой"
- "GET /api/health" - "Переключить тёмную тему"
- "Убедиться: gpx_fetch_cache_size == N (или min(N, лимит))" - "Убедиться: треки остаются на карте"
- "Убедиться: фильтры сохранены"
- id: E-32
name: "Слой + личный GPX (ET-006)"
steps:
- "Включить слой"
- "Загрузить личный GPX"
- "Убедиться: оба видны"
- "Убедиться: личный трек выше публичных по z-order"
- id: E-33
name: "Слой + маршрут OSRM"
steps:
- "Включить слой"
- "Построить маршрут OSRM"
- "Убедиться: маршрут OSRM визуально выше публичных треков"
- id: E-34
name: "Слой + hillshade"
steps:
- "Включить слой"
- "Включить hillshade"
- "Убедиться: оба видны"
- name: e2e-low-zoom-protection
type: e2e
description: "Защита от шторма запросов на low-zoom"
cases:
- id: E-40
name: "Слой скрыт на z<8"
steps:
- "Включить слой"
- "Отзумиться до z=5"
- "Убедиться: линии не отображаются"
- "Убедиться: появилась подсказка «Зум 8+» у чекбокса"
- id: E-41
name: "Pan на z 14 не штормит запросы"
steps:
- "Включить слой, z=14"
- "Быстро панить карту (5 раз за 1 сек)"
- "Проверить network log: не более 2 запросов /api/gps-tracks"
- name: load-pipeline
type: load
description: "Нагрузочные сценарии pipeline и API"
cases:
- id: L-01
name: "Полный прогон pipeline на ЦФО+Чувашию (mock)"
input: "Mock OSM с реальным объёмом ≈ 50K треков"
expected: "Прогон завершается за ≤ 6 часов (cron-окно)"
- id: L-02
name: "API под нагрузкой"
input: "10 параллельных клиентов делают по 100 запросов /api/gps-tracks"
expected: "p95 ≤ 500 мс, нет ошибок"
- id: L-03
name: "MVT-тайлы под нагрузкой"
input: "100 параллельных запросов разных тайлов"
expected: "p95 cold ≤ 300 мс, hit-rate кэша > 80% на повторах"
test_data: test_data:
- name: "test-track-public.gpx" fixtures_dir: "tests/fixtures/gps-tracks/"
description: "Валидный GPX 1.1, 1 МБ, для URL-импорта (mock-сервер)" fixtures:
- name: "test-track-large.gpx" - name: "osm-trackpoints-bbox-moscow.gpx"
description: "GPX 60 МБ — для проверки лимита размера" description: "Реальный ответ OSM API на bbox центра Москвы"
- name: "test-osm-trackpoints.gpx" - name: "osm-trackpoints-multipage.json"
description: "Реальный ответ OSM trackpoints API (зафиксирован для mock)" description: "Серия ответов OSM с has_more=true на нескольких страницах"
- name: "test-html-page.html" - name: "enduro-russia-mock-listing.html"
description: "HTML вместо GPX — для проверки валидации формата" description: "Главная страница региона на EnduroRussia (mock)"
- name: "test-xxe-payload.gpx" - name: "enduro-russia-mock-track.gpx"
description: "GPX с DOCTYPE и внешней entity — для проверки defusedxml" description: "GPX-файл, отдаваемый EnduroRussia mock"
- name: "bbox-moscow-small" - name: "ttrails-mock-track.gpx"
description: "[37.6, 55.7, 37.65, 55.75] — реальная область с публичными треками OSM" description: "GPX от ttrails mock"
- name: "bbox-too-large" - name: "xxe-payload.gpx"
description: "[37.0, 55.0, 38.0, 56.0] — > 0.25 deg² для проверки 400" description: "GPX с DOCTYPE и внешней entity (для проверки defusedxml)"
- name: "dedup-pair-osm-enduro.json"
description: "Пара треков (одна и та же поездка из двух источников) для проверки dedup"
- name: "gps_tracks_seed.sql"
description: "SQL-сид: 1000 синтетических треков для интеграционных тестов"
test_environment: test_environment:
mock_servers: mock_servers:
- "Mock HTTP-сервер для /api/gpx/fetch тестов (отдаёт фиксированные ответы)" - "Mock OSM API (отвечает на /api/0.6/trackpoints и /api/0.6/gpx/<id>)"
- "Mock OSM API для /api/gpx/osm/traces тестов" - "Mock EnduroRussia.ru (HTML-страницы + GPX-файлы)"
fixtures_dir: "tests/fixtures/gpx-public/" - "Mock ttrails.ru"
cron_simulation:
- "В тестах cron заменяется на pytest fixture, вызывающий run() напрямую"
db_isolation:
- "Каждый тест использует in-memory или временный sqlite-файл в pytest tmp_path"
network:
- "Все исходящие HTTP в unit/integration — через httpx_mock или respx (без реальной сети)"
notes: notes:
- "OSM API в e2e тестах должен мокироваться, чтобы не зависеть от внешней доступности" - "L-01 (полный прогон pipeline) запускается отдельно, не в обычном CI"
- "Для нагрузочных тестов кэша использовать pytest-benchmark" - "E2E UI-тесты — Playwright; URL test-среды https://openclaw.mva154.duckdns.org/enduro/ (см. 04b-ui-test-cases.md)"
- "Для load-тестов использовать pytest-benchmark + locust"

View File

@@ -2,10 +2,12 @@
type: ui-test-cases type: ui-test-cases
work_item_id: ET-008 work_item_id: ET-008
title: "UI Test Cases: GPS-треки с публичных платформ" title: "UI Test Cases: GPS-треки с публичных платформ"
version: 1 version: 2
status: draft status: draft
created_at: 2026-06-01 created_at: 2026-06-01
updated_at: 2026-06-01 updated_at: 2026-06-01
changelog:
- "v2 (2026-06-01): полная переработка под BRD/TRZ v2 — чекбокс «Публичные треки» в попапе, sheet фильтров, halo на спутнике, popup трека. Предыдущая v1 описывала вкладки источников в #sheet-gpx (URL/OSM)."
authors: authors:
- "agent:analyst" - "agent:analyst"
--- ---
@@ -14,299 +16,33 @@ authors:
Базовый URL: `https://openclaw.mva154.duckdns.org/enduro/` Базовый URL: `https://openclaw.mva154.duckdns.org/enduro/`
Все тесты проверяют появление и поведение секции «Источники» в Все тесты проверяют появление и поведение нового слоя «Публичные
`#sheet-gpx`, импорта по URL и поиска OSM-треков. Внешние сетевые треки»: чекбокса в `#terrain-popup`, sheet фильтров, отрисовки линий,
запросы в test-окружении мокаются (см. test-plan). popup и совместимости со спутниковой подложкой / тёмной темой.
Селекторы (новые, добавляются ET-008): Селекторы (новые, добавляются ET-008):
- `#source-seg` — segmented control «Источники»
- `#source-btn-file`, `#source-btn-url`, `#source-btn-nearby` — кнопки вкладок
- `#gpx-source-pane-file`, `#gpx-source-pane-url`, `#gpx-source-pane-nearby` — контент-блоки
- `#gpx-url-input` — поле ввода URL
- `#btn-gpx-fetch-url` — кнопка «Загрузить» URL
- `#btn-gpx-find-nearby` — кнопка «Найти треки в этой области»
- `#gpx-nearby-results` — контейнер списка найденных
- `.gpx-nearby-card` — карточка найденного OSM-трека
- `.gnc-import` — кнопка «Показать»
- `.gnc-external` — ссылка «↗»
- `.gpx-source-row` — индикатор источника в карточке трека
Существующие селекторы (ET-006): `#tb-gpx`, `#sheet-gpx`, `#gpx-list`. - `#public-tracks-cb` — чекбокс «Публичные треки» в `#terrain-popup`
- `#public-tracks-zoom-hint` — подсказка «Зум 8+»
- `#public-tracks-filters-btn` — ссылка «Фильтры…»
- `#sheet-gps-filters` — bottom sheet фильтров
- `#gps-activity-grid` — секция чекбоксов активности
- `#gps-source-grid` — секция чекбоксов источников
- `#gps-color-by-source`, `#gps-color-by-activity` — переключатель color-mode
- `#gps-stat-total`, `#gps-stat-shown` — счётчики в sheet
- `.gps-track-popup` — MapLibre Popup с метаданными трека (имя класса
можно задать через `setHTML` и контейнер)
Существующие селекторы: `#terrain-toggle`, `#terrain-popup`,
`#btn-theme`, `#base-btn-satellite`, `#base-btn-schematic`,
`#terrain-hillshade-cb`, `#tb-gpx`, `#map`.
Предусловие: тестовая среда содержит pre-collected dataset публичных
треков (или mock-backend подменяет `/api/gps-tracks*` фикстурами).
--- ---
### TC-UI-01 — Секция «Источники» видна в #sheet-gpx ### TC-UI-01 — Чекбокс «Публичные треки» виден в попапе
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. screenshot: "01-sheet-gpx-sources-section"
6. check-visual: "В верхней части #sheet-gpx (под заголовком, над списком треков) видна секция «ИСТОЧНИКИ» с тремя кнопками segmented control: «Из файла», «По ссылке», «Найти рядом». По умолчанию активна (подсвечена оранжевым) кнопка «Из файла»."
---
### TC-UI-02 — Переключение на вкладку «По ссылке»
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. screenshot: "02-source-url-pane"
8. check-visual: "Кнопка «По ссылке» подсвечена оранжевым, «Из файла» и «Найти рядом» — нет. Под кнопками видно поле ввода с placeholder «https://example.com/track.gpx» и кнопка «Загрузить» справа."
---
### TC-UI-03 — Переключение на вкладку «Найти рядом»
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. screenshot: "03-source-nearby-pane"
8. check-visual: "Кнопка «Найти рядом» подсвечена оранжевым. Под кнопками видна крупная кнопка «Найти треки в этой области карты». Список найденных треков пуст или отсутствует."
---
### TC-UI-04 — Поле URL принимает ввод
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. click: "#gpx-url-input"
8. wait: 200
9. screenshot: "04-url-input-focused"
10. check-visual: "Поле #gpx-url-input получило фокус (видна рамка/каретка), placeholder виден если поле пустое. Кнопка «Загрузить» рядом, активна (не дизейблена)."
---
### TC-UI-05 — Невалидный URL: toast об ошибке
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. click: "#gpx-url-input"
8. wait: 200
9. type: "not-a-url"
10. click: "#btn-gpx-fetch-url"
11. wait: 1000
12. screenshot: "05-invalid-url-toast"
13. check-visual: "Сверху по центру экрана отображается toast-уведомление с текстом «Невалидная ссылка» (или похожим). Никаких изменений на карте."
---
### TC-UI-06 — Кнопка «Найти треки» дизейблится во время запроса
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 300
9. screenshot: "06-finding-tracks-loading"
10. check-visual: "Кнопка «Найти треки в этой области карты» визуально дизейблена (серая / opacity снижен). Виден индикатор загрузки (spinner или moto-wheel)."
---
### TC-UI-07 — Список найденных треков
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. screenshot: "07-nearby-tracks-list"
10. check-visual: "Под кнопкой поиска появился список карточек .gpx-nearby-card. Каждая карточка содержит: иконку 🌍 (или OSM-логотип) слева, имя/описание трека и метаданные (км, аноним/автор), кнопку «Показать» справа, маленькую ссылку «↗». Карточки разделены тонкими линиями."
---
### TC-UI-08 — Импорт OSM-трека: трек на карте, индикатор в карточке
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. click: ".gnc-import"
10. wait: 4000
11. screenshot: "08-osm-track-imported"
12. check-visual: "На карте видна цветная линия импортированного трека. В списке найденных карточка первого трека показывает индикатор «✓ Загружен» вместо кнопки «Показать». В нижней части #sheet-gpx (в #gpx-list) появилась новая карточка трека с источником «🌍 OSM #...»."
---
### TC-UI-09 — Источник «OSM» — кликабельная ссылка в #gpx-list
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. click: ".gnc-import"
10. wait: 4000
11. screenshot: "09-gpx-list-source-osm"
12. check-visual: "В нижнем списке #gpx-list карточка импортированного трека под именем содержит строку «.gpx-source-row» с текстом «🌍 OSM #<число>». Текст оформлен как ссылка (подчёркнут или другой цвет)."
---
### TC-UI-10 — Смешанные источники в #gpx-list
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. click: "#gpx-url-input"
8. wait: 200
9. type: "https://example.test/mock-track.gpx"
10. click: "#btn-gpx-fetch-url"
11. wait: 4000
12. click: "#source-btn-nearby"
13. wait: 500
14. click: "#btn-gpx-find-nearby"
15. wait: 4000
16. click: ".gnc-import"
17. wait: 4000
18. screenshot: "10-mixed-sources-list"
19. check-visual: "В #gpx-list 2 карточки: одна с источником «🔗 example.test», вторая с «🌍 OSM #...». Карточки имеют разные цветовые индикаторы слева. Обе видны на карте как линии разных цветов."
---
### TC-UI-11 — Импорт по URL: трек появляется на карте
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. click: "#gpx-url-input"
8. wait: 200
9. type: "https://example.test/mock-track.gpx"
10. click: "#btn-gpx-fetch-url"
11. wait: 5000
12. screenshot: "11-url-track-loaded"
13. check-visual: "На карте видна цветная линия загруженного трека. В #gpx-list появилась карточка с именем «mock-track» и источником «🔗 example.test». Карта выполнила fit bounds — трек по центру экрана."
---
### TC-UI-12 — Секция «Источники» на мобильном
- тип: ui
- viewport: mobile
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. screenshot: "12-sources-mobile-default"
6. check-visual: "На мобильном viewport секция «Источники» помещается по ширине экрана. Три кнопки segmented control видны и нажимаемы, не выходят за экран. Активна «Из файла»."
---
### TC-UI-13 — Поле URL на мобильном
- тип: ui
- viewport: mobile
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. screenshot: "13-url-pane-mobile"
8. check-visual: "На мобильном поле #gpx-url-input занимает большую часть ширины, кнопка «Загрузить» справа. Оба элемента не перекрываются, нажимаемы, помещаются в экран."
---
### TC-UI-14 — Список найденных OSM треков на мобильном
- тип: ui
- viewport: mobile
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. screenshot: "14-nearby-list-mobile"
10. check-visual: "На мобильном карточки .gpx-nearby-card отображаются вертикально, занимают всю ширину. Кнопка «Показать» и ссылка «↗» в каждой карточке нажимаемы, не перекрываются. Список скроллится."
---
### TC-UI-15 — Совместимость со спутниковой подложкой
- тип: ui - тип: ui
- viewport: desktop - viewport: desktop
@@ -316,22 +52,381 @@ authors:
2. wait: 5000 2. wait: 5000
3. click: "#terrain-toggle" 3. click: "#terrain-toggle"
4. wait: 500 4. wait: 500
5. click: "#base-btn-satellite" 5. screenshot: "01-popup-with-public-tracks-checkbox"
6. wait: 5000 6. check-visual: "В открытом попапе #terrain-popup между секциями «Тропы» и «POI» (после соответствующего разделителя `<hr>`) видна строка «Публичные треки» с чекбоксом #public-tracks-cb. По умолчанию чекбокс снят, ссылка «Фильтры…» не видна."
---
### TC-UI-02 — Включение слоя «Публичные треки»
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. screenshot: "02-public-tracks-enabled"
8. check-visual: "Чекбокс установлен. На карте поверх существующих trail-линий и POI видны цветные линии публичных треков (отдельные линии, не heatmap). Рядом с чекбоксом появилась ссылка «Фильтры…»."
---
### TC-UI-03 — Подсказка «Зум 8+» на низком зуме
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/?z=5
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 1500
7. screenshot: "03-public-tracks-zoom-hint"
8. check-visual: "Чекбокс включён, но на карте линии публичных треков не видны. Рядом с чекбоксом (или под ним) отображается подсказка «Зум 8+» (стилем как существующая подсказка «Зум 10+» у hillshade)."
---
### TC-UI-04 — Открытие sheet фильтров
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 2000
7. click: "#public-tracks-filters-btn"
8. wait: 800
9. screenshot: "04-gps-filters-sheet-open"
10. check-visual: "Открылся bottom sheet #sheet-gps-filters с заголовком «Фильтры публичных треков». Видны секции: «ТИП АКТИВНОСТИ» (7 чекбоксов: эндуро, мото, off-road, велосипед, пешком, лыжи, другое), «ИСТОЧНИК» (≥ 3 чекбокса), «ЦВЕТ ЛИНИЙ» (segmented control «По источнику» / «По активности»). По умолчанию все чекбоксы установлены, color-mode='По источнику' активен."
---
### TC-UI-05 — Фильтрация по активности (клиентская, мгновенная)
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#public-tracks-filters-btn"
8. wait: 800
9. screenshot: "05a-filters-all-on"
10. check-visual: "В sheet видны все 7 чекбоксов активности — установлены. На карте видно много линий разных типов."
11. click: "#gps-activity-grid input[value='bicycle']"
12. wait: 300
13. click: "#gps-activity-grid input[value='hike']"
14. wait: 300
15. click: "#gps-activity-grid input[value='ski']"
16. wait: 300
17. click: "#gps-activity-grid input[value='other']"
18. wait: 500
19. screenshot: "05b-filters-only-moto-types"
20. check-visual: "Выключены чекбоксы «Велосипед», «Пешком», «Лыжи», «Другое». На карте линий стало заметно меньше (только enduro/moto/offroad). Счётчик «Видны (фильтр)» в нижней части sheet уменьшился."
---
### TC-UI-06 — Фильтрация по источнику
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#public-tracks-filters-btn"
8. wait: 800
9. click: "#gps-source-grid input[value='osm']"
10. wait: 500
11. screenshot: "06-source-osm-disabled"
12. check-visual: "Чекбокс «OSM» снят. На карте все линии цвета OSM (зелёного — при color-by-source) скрыты. Счётчик «Видны» уменьшился."
---
### TC-UI-07 — Переключение color-mode
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#public-tracks-filters-btn"
8. wait: 800
9. screenshot: "07a-color-by-source"
10. check-visual: "Активна кнопка «По источнику». Линии на карте окрашены по источникам (например, зелёный = OSM, красный = EnduroRussia)."
11. click: "#gps-color-by-activity"
12. wait: 600
13. screenshot: "07b-color-by-activity"
14. check-visual: "Активна кнопка «По активности». Линии перекрашены: например, красные = enduro, оранжевые = moto. Кнопка «По источнику» больше не подсвечена."
---
### TC-UI-08 — Popup при клике на трек
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#map"
8. wait: 1500
9. screenshot: "08-track-popup"
10. check-visual: "При клике на линию трека (предполагается, что под центром карты есть трек) открылся MapLibre Popup. В нём видны: иконка активности (🏍 / 🚴 / …) + текстовая метка, длина в км, дата (если есть), автор (если есть), список источников со ссылками '↗'. Popup имеет крестик закрытия."
---
### TC-UI-09 — Halo на спутниковой подложке
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#base-btn-satellite"
8. wait: 5000
9. screenshot: "09-public-tracks-on-satellite"
10. check-visual: "Карта показывает спутниковые снимки. Линии публичных треков видны поверх спутника, у каждой линии есть белая (или светлая) обводка-halo для контраста на тёмном фоне. Цвета линий по-прежнему отличаются по источнику/активности."
---
### TC-UI-10 — Возврат на схему — halo пропадает
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#base-btn-satellite"
8. wait: 5000
9. click: "#base-btn-schematic"
10. wait: 3000
11. screenshot: "10-back-to-schematic-no-halo"
12. check-visual: "Карта вернулась на схему OSM. Линии публичных треков видны без halo (обычная толщина и цвет). На фоне светлой схемы — без обводки."
---
### TC-UI-11 — Сохранение слоя при переключении тёмной темы
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#btn-theme"
8. wait: 3000
9. screenshot: "11-public-tracks-after-theme-switch"
10. check-visual: "После переключения темы (например, на тёмную) линии публичных треков остались на карте. Цвета сохранены. На тёмной теме линии хорошо различимы."
---
### TC-UI-12 — Сохранение слоя при включении hillshade
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#terrain-hillshade-cb"
8. wait: 3000
9. screenshot: "12-public-tracks-over-hillshade"
10. check-visual: "Включён hillshade (тени рельефа). Линии публичных треков остаются видны поверх теней рельефа. Контраст сохраняется."
---
### TC-UI-13 — Совместимость с маршрутом OSRM (z-order)
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#tb-route"
8. wait: 1000
9. click: "#map"
10. wait: 1500
11. scroll: 100
12. click: "#map"
13. wait: 5000
14. screenshot: "13-public-tracks-and-osrm-route"
15. check-visual: "Видны и линии публичных треков, и линия маршрута OSRM (синяя/оранжевая). Маршрут OSRM визуально лежит поверх публичных треков (выше по z-order). Обе системы линий читаемы."
---
### TC-UI-14 — Sheet фильтров на мобильном
- тип: ui
- viewport: mobile
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#public-tracks-filters-btn"
8. wait: 800
9. screenshot: "14-gps-filters-mobile"
10. check-visual: "На мобильном viewport sheet #sheet-gps-filters занимает всю ширину. Все 7 чекбоксов активности видны (например, 2-3 колонки grid). Чекбоксы источников видны. Segmented control color-mode помещается. Все элементы нажимаемы, не перекрываются."
---
### TC-UI-15 — Включение слоя на мобильном
- тип: ui
- viewport: mobile
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. screenshot: "15-public-tracks-mobile"
8. check-visual: "На мобильном устройстве после включения чекбокса линии публичных треков видны на карте. Попап слоёв и тулбар не перекрывают карту целиком — слой просматривается."
---
### TC-UI-16 — Persistence: слой включён после перезагрузки
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. navigate: https://openclaw.mva154.duckdns.org/enduro/
8. wait: 6000
9. screenshot: "16-public-tracks-after-reload"
10. check-visual: "После перезагрузки страницы карта сразу показывает линии публичных треков (слой автоматически восстановлен из localStorage). Открытие попапа слоёв должно показать чекбокс установленным."
---
### TC-UI-17 — Persistence: фильтры сохраняются после перезагрузки
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#public-tracks-filters-btn"
8. wait: 800
9. click: "#gps-activity-grid input[value='bicycle']"
10. wait: 300
11. click: "#gps-activity-grid input[value='hike']"
12. wait: 300
13. click: "#gps-color-by-activity"
14. wait: 500
15. navigate: https://openclaw.mva154.duckdns.org/enduro/
16. wait: 6000
17. click: "#terrain-toggle"
18. wait: 500
19. click: "#public-tracks-filters-btn"
20. wait: 800
21. screenshot: "17-filters-after-reload"
22. check-visual: "Чекбоксы «Велосипед» и «Пешком» по-прежнему сняты. Color-mode = «По активности» (соответствующая кнопка подсвечена). Линии на карте окрашены по активности."
---
### TC-UI-18 — Атрибуция источников
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. screenshot: "18-attribution-public-tracks"
8. check-visual: "В правом нижнем углу карты (в стандартной MapLibre-панели атрибуции) видны строки с атрибуцией источников публичных треков: например, «© OpenStreetMap contributors (ODbL)» и «EnduroRussia.ru» (либо иконка info, при клике на которую разворачивается полный текст)."
---
### TC-UI-19 — Совместимость с личным GPX (ET-006)
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#public-tracks-cb"
6. wait: 3000
7. click: "#tb-gpx" 7. click: "#tb-gpx"
8. wait: 1000 8. wait: 1000
9. click: "#source-btn-nearby" 9. screenshot: "19-public-tracks-with-gpx-sheet"
10. wait: 500 10. check-visual: "Открыт sheet #sheet-gpx (для личных треков из ET-006). Слой публичных треков на карте остаётся видимым. Sheet и слой не конфликтуют визуально. Список личных треков в sheet — пустой (если ничего не загружено)."
11. click: "#btn-gpx-find-nearby"
12. wait: 4000
13. click: ".gnc-import"
14. wait: 4000
15. screenshot: "15-osm-track-on-satellite"
16. check-visual: "На спутниковой подложке видна цветная линия импортированного OSM-трека. Линия имеет hover-видимость (контрастная для спутника). Панель #sheet-gpx не конфликтует со спутником визуально."
--- ---
### TC-UI-16Сохранение треков при переключении тёмной темы ### TC-UI-20Выключение слоя — линии исчезают
- тип: ui - тип: ui
- viewport: desktop - viewport: desktop
@@ -339,57 +434,11 @@ authors:
шаги: шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/ 1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000 2. wait: 5000
3. click: "#tb-gpx" 3. click: "#terrain-toggle"
4. wait: 1000 4. wait: 500
5. click: "#source-btn-nearby" 5. click: "#public-tracks-cb"
6. wait: 500 6. wait: 3000
7. click: "#btn-gpx-find-nearby" 7. click: "#public-tracks-cb"
8. wait: 4000 8. wait: 1500
9. click: ".gnc-import" 9. screenshot: "20-public-tracks-disabled"
10. wait: 4000 10. check-visual: "Чекбокс снят. Все линии публичных треков исчезли с карты. Ссылка «Фильтры…» рядом с чекбоксом скрылась. Базовые слои (схема, trails, POI) остались видимыми и без изменений."
11. click: "#btn-theme"
12. wait: 3000
13. screenshot: "16-osm-track-after-theme-switch"
14. check-visual: "После переключения тёмной темы цветная линия импортированного OSM-трека остаётся на карте. В #gpx-list карточка трека с источником «🌍 OSM #...» сохранилась."
---
### TC-UI-17 — Сохранение треков при переключении источника «Из файла»
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. click: ".gnc-import"
10. wait: 4000
11. click: "#source-btn-file"
12. wait: 500
13. screenshot: "17-back-to-file-tab"
14. check-visual: "Активна вкладка «Из файла», секция «Найти рядом» свернута. Импортированный OSM-трек остаётся в нижнем списке #gpx-list (карточка с «🌍 OSM #...»). Сам трек по-прежнему видим на карте."
---
### TC-UI-18 — Внешняя ссылка ↗ на osm.org
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. screenshot: "18-external-link-button"
10. check-visual: "В каждой карточке .gpx-nearby-card в правом углу видна кнопка «↗» (.gnc-external). Кнопка имеет hover-состояние (cursor:pointer), визуально отличима от основной кнопки «Показать»."

View File

@@ -0,0 +1,169 @@
---
type: adr
work_item_id: ET-008
adr_id: ADR-005
title: "ADR-005: Хранение публичных GPS-треков — отдельная БД data/gps_tracks.sqlite, SQLite+Spatialite, общая схема для всех источников, sources как JSON-массив"
status: accepted
created_at: 2026-06-01
authors:
- "agent:architect"
supersedes: []
superseded_by: []
labels:
- "arch:major-change"
---
# ADR-005 — Схема хранения публичных GPS-треков
## Статус
Accepted
## Контекст
ET-008 вводит новый класс данных в проект — **публичные GPS-треки**, агрегированные офлайн-pipeline'ом из ≥ 3 внешних источников по региону MVP (ЦФО + Чувашия). По BRD §3 целевой объём — ≥ 5000 треков, по BRD §6 предел — несколько ГБ на регион при дальнейшем расширении. По BRD §1 модель данных не пересекается с существующими сущностями:
- vector-tile слой `trails` (`data/centralfederal.sqlite`) — OSM-дороги/тропы, отдельный формат, отдельный pipeline (osm2pgsql-like);
- личные GPX-треки (ET-006) — живут только в памяти браузера (`window.gpxTracks`), на сервере не хранятся;
- POI и маршруты (PH-1/2) — другие сущности `centralfederal.sqlite`.
Архитектурно нужно решить:
1. **Где хранить** — в существующей `centralfederal.sqlite` или отдельным файлом.
2. **Как организовать схему** — одна таблица на все источники или партиционирование по источнику.
3. **Как хранить мульти-источник** (трек найден в N платформах после дедупа) — нормализованная таблица `track_sources` или JSON-массив в основной таблице.
4. **Какие индексы** дают приемлемый p95 ≤ 300 мс на bbox-запрос с фильтрами.
5. **Совместимость с MVT-generation pipeline'ом**, уже существующим в `src/api/main.py` для `/api/tiles/{z}/{x}/{y}.mvt`.
## Рассмотренные варианты
### Вариант D (Database) — где хранить
- **D-A — отдельный файл `data/gps_tracks.sqlite`** (выбран, совпадает с BRD §7 и TRZ §8 ADR-001-recommendation).
Плюсы:
- Pipeline пишет в свою БД — нет блокировок write на `centralfederal.sqlite`, который активно читается API под нагрузкой раздачи MVT.
- Независимый цикл бэкапа (см. `07-infra-requirements.md` §4): `gps_tracks.sqlite` бэкапится ежедневно, `centralfederal.sqlite` — после редкой ребилд-сессии OSM-данных.
- Независимая ротация: ретеншн 5 лет (REQ-NF-03) применяется только к одной БД; `centralfederal.sqlite` пересобирается из OSM по своему графику.
- Изоляция риска при ошибке pipeline — нельзя случайно повредить OSM-данные.
- В будущем (BRD §6 риск роста до миллионов треков) переход на PostGIS затрагивает один файл, а не корневую БД.
Минусы:
- Второй коннект из FastAPI (мелкая сложность, ~10 строк в `main.py`).
- При совместных запросах «дороги OSM × публичные треки рядом» (PH-3 Smart Route) — кросс-БД JOIN неэффективен. Принято: на горизонте MVP таких запросов нет; в PH-3 решается отдельным ADR (вариант: `ATTACH DATABASE` или денормализация в материализованную таблицу).
- **D-B — в существующую `centralfederal.sqlite`, отдельные таблицы `gps_tracks_*`**. Отклонён:
- Pipeline writer и MVT reader конкурируют за один файл; SQLite WAL смягчает, но не устраняет.
- Backup-цикл становится зависимым: невозможно ребилдить OSM-данные не «остановив» pipeline.
- Сценарий «удалить весь gps-датасет и пересобрать» (R-3 ниже) требует `DROP TABLE` в большой production-БД; в отдельном файле — `rm data/gps_tracks.sqlite && python scripts/gps_collect.py`.
- **D-C — PostGIS**. Отклонён:
- BRD §1 «SQLite по умолчанию, PostgreSQL когда нужно». ≥ 5000 треков для ЦФО легко влезают в SQLite (оценочно ≤ 500 МБ при средней геометрии 1240 точек × 16 байт). Spatialite даёт BLOB+R-tree, чего хватает для всех запросов TRZ.
- Введение PostgreSQL — новый класс инфры (контейнер + бэкап + миграции через alembic). Это `arch:major-change` уровня всего проекта; ET-008 такого не требует.
### Вариант T (Table layout) — одна или несколько таблиц
- **T-A — единая таблица `tracks`** (выбран). Поля per-источник денормализованы в JSON-колонки. Все источники приводятся к общему контракту в `models.py::Track` (TRZ §7).
Плюсы:
- Самый простой bbox-запрос: один SELECT с одним bbox-фильтром.
- Дедупликация на уровне БД через UNIQUE-индекс по `dedup_key` (TRZ REQ-F-08).
- MVT-генерация на низком зуме — одно сканирование R-tree → одна `LineString → MVT` петля.
- **T-B — таблица на источник + view `tracks_all UNION ALL ...`**. Отклонён:
- Дедупликация между источниками превращается в кросс-таблицу процедуру.
- Изменение списка источников требует DDL-миграции, что блокирует «расширяемость на новый регион ≤ 30 строк YAML без правки кода» (BRD-метрика).
### Вариант S (Sources field) — как хранить N источников у одного трека
- **S-A — JSON-массив в колонках `sources_json`, `external_urls_json`** (выбран, совпадает с TRZ REQ-F-09).
Плюсы:
- Запись/чтение трека — атомарная операция.
- При мерже дубликата `UPDATE sources_json = json_array_union(...)` через Python-сторону (без JSON1-функций SQLite, чтобы не зависеть от SQLite-версии).
- Фильтр API «source=osm,ttrails» работает через bbox-prefetch + Python-постфильтр (≤ 500 треков на bbox — это O(500) проверка `'osm' in sources`, ничтожно).
Минусы:
- Невозможно индексировать массив без JSON1; нет нативного `WHERE 'osm' = ANY(sources)`. Принято: на BRD-объёме это не узкое место.
- **S-B — нормализованная таблица `track_sources(track_id, source_id, ext_url)`**. Отклонён:
- JOIN на каждый bbox-запрос (1 → N запись на трек) +3060% к p95.
- Усложняет API: GeoJSON-формирование требует aggregate-функции (`group_concat`) → лишний SQL.
- Не даёт значимого выигрыша на BRD-объёме (≤ 510 источников на трек после дедупа в худшем случае).
### Вариант I (Indexes) — как ускорить bbox-фильтр
- **I-A — Spatialite R-tree через виртуальную таблицу `idx_tracks_geom` + обычный B-tree на `activity_type`** (выбран).
- R-tree даёт O(log n) на bbox-prefetch.
- `idx_tracks_activity` ускоряет fallback-фильтр.
- `created_at` — обычный B-tree для GC и для health-отчёта.
- **I-B — четыре B-tree-индекса на `min_lon`, `max_lon`, `min_lat`, `max_lat`** (вариант из TRZ REQ-F-09). Отклонён:
- SQLite-оптимизатор не комбинирует 4 индекса в bbox-плане; в лучшем случае использует один (по `min_lon`), что даёт линейный полу-скан.
- R-tree через Spatialite — стандартный паттерн для spatial-запросов; уже используется в `centralfederal.sqlite` (`idx_features_geom`).
### Вариант W (WAL) — режим записи
- **W-A — WAL-mode постоянно** (выбран). При запуске pipeline `PRAGMA journal_mode=WAL`. Даёт читателям (FastAPI) видеть консистентный снэпшот пока pipeline пишет.
- **W-B — DELETE-mode + блокировка читателей на время прогона**. Отклонён: означает простой `/api/gps-tracks` на 16 часов в неделю.
## Решение
Принимается комбинация: **D-A + T-A + S-A + I-A + W-A**.
1. **Отдельная БД `data/gps_tracks.sqlite`** (Spatialite-extension загружается при коннекте). Путь в окружении — `GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite` (см. `07-infra-requirements.md` §5).
2. **Единая таблица `tracks`** со схемой, зафиксированной в `08-data-requirements.md` §3. Уточнения относительно TRZ REQ-F-09:
- `points_count` и `length_m` — посчитанные на pipeline (НФТ Endpoint p95 ≤ 300 мс не оставляет бюджета считать длину на лету).
- `min_lon/max_lon/min_lat/max_lat` сохраняются денормализованно вместе с R-tree (избыточно, но ускоряет MVT-генерацию: можно отбросить трек до `wkb_to_coords()` если bbox целиком вне тайла).
- `tags_json`, `description` — допускается NULL (не все источники их отдают).
- `user` (имя автора) сохраняется **только если** ADR licensing соответствующего источника явно разрешает (см. ADR-009/010/011). Иначе — NULL.
3. **`sources_json` и `external_urls_json` — JSON-массивы** строк, длина ≤ 8 элементов (дополнительные источники после дедупа). Порядок — стабильный (по `gps_sources.yaml`), что фиксирует «первый» источник для MVT-фичи `properties.source` (используется для цветовой палитры по умолчанию, REQ-F-16).
4. **Индексация:**
- Spatialite R-tree `idx_tracks_geom` через `CreateSpatialIndex('tracks', 'geom')`.
- B-tree `idx_tracks_activity(activity_type)`.
- B-tree `idx_tracks_created(created_at)` для GC и health.
- UNIQUE `idx_tracks_dedup(dedup_key)` — критичен для ON CONFLICT логики dedup (ADR-006).
- Дополнительный bbox-индекс из TRZ REQ-F-09 (`min_lon, max_lon, min_lat, max_lat`) **не создаётся** — R-tree его покрывает; B-tree на 4 колонки даст overhead на INSERT без выгоды на SELECT.
5. **WAL-mode** включается в `db.py::open_connection()` через `PRAGMA journal_mode=WAL` при первом запуске; повторно команда no-op. Pipeline пишет в WAL, читатели видят последний checkpoint. После каждого `(region, source)` pipeline вызывает `PRAGMA wal_checkpoint(PASSIVE)` для контроля размера WAL-файла.
6. **Размер БД** оценивается ≤ 2 ГБ для ЦФО+Чувашии при ≥ 5000 треков (REQ-NF-03). Метрика `db_size_mb` — в `/api/gps-tracks/health` (REQ-F-12), порог-алерт > 2 ГБ — в `10-tech-risks.md` R-4.
7. **Pipeline-история** — таблица `pipeline_runs` (TRZ REQ-F-09) в той же БД. Используется только для health-эндпоинта и оператора. Не индексируется по region/source — её объём ≤ 10⁴ строк за годы.
8. **Совместимость с MVT-pipeline в `main.py`.** Утилитарные функции `tile_to_bbox`, `wkb_to_coords`, `simplify_coords` уже существуют в `src/api/main.py` для слоя `trails`. ET-008 **не рефакторит** их (out of scope, риск регрессии слоя `trails`). Вместо этого:
- В `src/api/gps_tracks/mvt.py` функции `_tile_to_bbox` / `_wkb_to_coords` дублируются с TODO-комментарием и ссылкой на тех-долг (`10-tech-risks.md` R-7).
- Если в будущей фазе появится третий MVT-источник (BRD §1 «Видеть реальные дороги/тропы»), перед ним вводится shared-модуль `src/api/tiles_util.py` отдельным work item.
9. **Cross-DB запросы (PH-3)** — out of scope. Принципиальный путь, если понадобится в Smart Route: `ATTACH DATABASE 'data/gps_tracks.sqlite' AS gps` в коннекте main-API. Это решение откладывается до конкретной задачи PH-3.
## Последствия
### Положительные
- Pipeline пишет, не блокируя API-чтения OSM-данных.
- Бэкап и ротация независимы — оператор управляет каждой БД отдельно.
- Расширение списка источников (BRD F-04) или регионов (BRD F-12) не требует DDL — только обновление YAML.
- При ошибке pipeline (повреждение БД) — `rm data/gps_tracks.sqlite && python scripts/gps_collect.py` восстанавливает за один прогон (≤ 6 часов, REQ-NF-02). Это закрывает риск «pipeline испортил продакшен-данные».
- Spatialite R-tree обеспечивает p95 ≤ 300 мс на bbox-запросах без необходимости PostgreSQL.
### Отрицательные / ограничения
- Денормализация `sources_json`/`external_urls_json` не позволяет нативного `WHERE 'osm' = ANY(sources)`. Фильтр source — постфильтр на Python после bbox-prefetch (приемлемо: BRD §6 показывает ≤ 500 треков на bbox).
- Дублирование `tile_to_bbox` / `wkb_to_coords` между `main.py` и `gps_tracks/mvt.py` — технический долг (`10-tech-risks.md` R-7). При следующем добавлении MVT-источника обязательно вынести в shared util.
- Cross-DB запросы между OSM-данными и GPS-треками невозможны без `ATTACH DATABASE`. На горизонте MVP таких запросов нет, но это блокер для будущей фичи «маршрут предпочитает реально-езженые дороги» (PH-3).
- Дублирование bbox-полей (`min_lon`/`max_lon`/`min_lat`/`max_lat`) в строке трека + R-tree-индексе — избыточные ~32 байта на трек; на 5000 треков ничтожно, осознанный compromise ради быстрого «бросить трек до парсинга WKB».
### Технический долг
- Если объём вырастает > 2 ГБ (расширение на всю РФ), перевод на PostGIS. Контракт API `/api/gps-tracks/*` стабилен; меняется только `db.py`. Backend-код, фронтенд, миграции — без изменений.
- Возможный future-rewrite на shared `src/api/tiles_util.py` (см. §8 решения).
## Классификация изменения
**Major change.** Введение **новой БД** на сервере явно перечислено в правилах для агентов (CLAUDE.md, эскалация: «новый сервис, новая БД → arch:major-change»). Лейбл `arch:major-change` выставлен. Обязательный архитектурный approve — да.
## Связанные документы
- `docs/work-items/ET-008/01-brd.md` §7 «БД»
- `docs/work-items/ET-008/02-trz.md` REQ-F-09 «Схема БД»
- `docs/work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md`
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md`
- `docs/work-items/ET-008/07-infra-requirements.md` §4 «Хранилища данных»
- `docs/work-items/ET-008/08-data-requirements.md` §3 «Серверные данные»
- `docs/work-items/ET-008/10-tech-risks.md` R-3, R-4, R-7

View File

@@ -0,0 +1,149 @@
---
type: adr
work_item_id: ET-008
adr_id: ADR-006
title: "ADR-006: Дедупликация публичных GPS-треков — bbox+length+date bucket-hash, мерж sources при коллизии, без геометрических метрик"
status: accepted
created_at: 2026-06-01
authors:
- "agent:architect"
supersedes: []
superseded_by: []
labels: []
---
# ADR-006 — Алгоритм дедупликации публичных GPS-треков
## Статус
Accepted
## Контекст
Один и тот же реальный трек может быть выложен автором на несколько платформ (BRD §6 риск №3): тот же маршрут пользователь публикует в EnduroRussia.ru и в Wikiloc, дублирует в OSM Public GPS Traces при разборе и т.п. Цель ET-008 (BRD §1) — «одна запись на реальный трек, с union'ом источников и ссылок». Метрика — BRD §5: ≤ 5% дублей при ручной проверке 100 случайных треков.
Архитектурно нужно выбрать:
1. **Какой признак считать «тот же трек».** Координаты на платформах округлены / прорежены / иногда обработаны (сглаживание); полное совпадение точек — редкое.
2. **Сложность алгоритма.** На 5000 треков допустим O(n²); на 50 000+ при расширении на РФ — нет. Нужно либо O(n log n), либо хэш O(n).
3. **Поведение при отсутствии метаданных.** У OSM-треков нет «активности», у скрейпленых страниц иногда нет даты — что делать.
4. **Что фиксируется при коллизии** — кто из источников «выиграл» в полях `name`/`user`/`activity_type`.
## Рассмотренные варианты
### Вариант A — Bucket-hash по bbox + length + date (выбран; совпадает с TRZ REQ-F-08)
```python
def compute_dedup_key(geom: LineString, meta: dict) -> str:
w, s, e, n = geom.bounds
bbox_round = (round(w, 2), round(s, 2), round(e, 2), round(n, 2)) # ≈ 1.1 км
length_bucket = round(meta["length_m"] / 1000) * 1000 # 1 км
date_bucket = (meta.get("created_at") or "")[:10] # YYYY-MM-DD
return f"{bbox_round}|{length_bucket}|{date_bucket}"
```
- Сложность: **O(1)** на трек, **O(n)** на пайплайн. Идеально для INSERT с `UNIQUE(dedup_key)` ON CONFLICT.
- Точность: для треков с известной датой — высокая (BBox-проекция отлично различает соседние «утренний эндуро в Калужской» vs «вечерний в Подмосковье»; на одной дате одинаковая длина в одном bbox — это почти всегда тот же трек).
- Ложные коллизии: треки без даты в одном bbox с похожей длиной — будут смерджены. По BRD §6 это явный риск (пользователь может потерять «свой» вариант трека). Митигация — `08-data-requirements.md` §6 и AC-03 «Треки без даты от разных источников».
- Ложные не-коллизии: один и тот же трек у двух источников с расхождением даты на 1+ день (один источник датирует загрузку, другой — запись GPS) — не смердживается. На практике источники сохраняют дату GPS из самого файла; расхождение редкое.
### Вариант B — Frechet/Hausdorff-расстояние между LineString (отклонён)
- Сложность: O(n²) на регион при наивной реализации; даже с R-tree-префильтром по bbox остаётся O(n × k), где k — кандидаты в 1-км окне.
- Реалистичный pipeline-overhead: для 5000 треков с медианой 1240 точек — ~30 минут вычислений на регион. Это съедает половину cron-окна (6 ч).
- Преимущества — устойчивость к шумам в координатах; недостатки — высокая стоимость, и при ≥ 50 000 треков становится непригодным.
### Вариант C — Хэш resampled-points (отклонён)
```python
sampled = resample(geom, every_n_meters=100)
key = sha256(",".join(f"{lat:.4f},{lon:.4f}" for lat, lon in sampled))
```
- Сложность: O(n) на трек, O(n) на пайплайн. Хорошо.
- Точность: хуже A — на платформах с разным сглаживанием те же 100-метровые точки могут отличаться в 4-м знаке после запятой → хэши не совпадают. То есть метод нестабилен между источниками.
- Можно округлять до 3 знаков (≈ 100 м), но тогда два соседних трека по той же лесной просеке дают одинаковый хэш — снова коллизии.
### Вариант D — Гибрид: bucket-hash как первичный фильтр + Frechet как тай-брейкер (отклонён)
- Соблазнительно: A для скорости, B на коллизиях.
- Сложность реализации высокая: при коллизии bucket-hash нужно подтянуть из БД полную геометрию обоих треков, посчитать Frechet, принять решение. Это блокирующий round-trip в SQLite на каждый коллидирующий INSERT.
- На MVP это over-engineering. Если метрика BRD §5 «≤ 5%» не выполнится — заводится отдельный work item «улучшение dedup».
## Решение
**Принимается Вариант A — bucket-hash O(1)**, в точности по формуле TRZ REQ-F-08, с уточнениями:
1. **Гранулярность `bbox_round`** — 2 знака после запятой (≈ 1.1 км). Не 1 знак (≈ 11 км — слишком грубо, ложные коллизии для коротких треков в одном городе) и не 3 знака (≈ 110 м — слишком точно, не сходится между источниками с разным сглаживанием).
2. **Гранулярность `length_bucket`** — 1 км. На треках длиной 550 км это 220% разброс, что покрывает межисточниковую разницу подсчёта (округление координат → разные интегралы длины). На очень коротких треках (< 1 км) `length_bucket = 0` для всех таких треков — что даст переслияние «всех коротких в одном km²-bbox в одной дате»; вероятность такого совпадения от двух разных авторов исчезающе мала.
3. **Гранулярность `date_bucket`** — день (YYYY-MM-DD). Не «час» (источники часто хранят только дату), не «месяц» (слишком грубо — есть популярные маршруты, которые ездят сотнями раз).
4. **Отсутствие `created_at`**`date_bucket = ""` для обоих треков → они считаются одним ключом. Это сознательный consenrvative-merge:
- Источники, не отдающие дату, обычно отдают её отдельно (OSM публикует timestamp загрузки; ttrails — дату публикации; EnduroRussia — дату поездки). После анализа лог-сэмплов BRD §5 ожидаем, что > 95% треков имеют дату.
- Без даты — мы и не отличим «два разных трека с одинаковой геометрией» от «один и тот же выложенный дважды». Merge — меньшее зло, чем дубль; при ошибке достаточно дополнительно показать оба `external_urls` в popup (REQ-F-18).
- Документировано в AC-03 «Треки без даты — дедуп срабатывает».
5. **Поведение при коллизии — мерж, а не replace:**
- `sources_json` ← union существующих + нового `[source_id]`.
- `external_urls_json` ← union существующих + нового `[external_url]`.
- `name`, `description`, `user`, `tags`, `activity_type` — берутся **по приоритету источника в `gps_sources.yaml`** (порядок объявления = приоритет). Если у нового источника приоритет выше — поля перезаписываются; иначе сохраняются старые. Это даёт стабильный детерминированный результат независимо от порядка обхода в pipeline.
- `length_m`, `points_count`, `geom` — берутся от **первого** источника (того, кто первым создал запись). Не пересчитываются при мерже. Это снижает риск «джиттера» геометрии трека от прогона к прогону.
- `updated_at` — обновляется на текущее время прогона.
6. **Реализация в коде** — SQL-уровень:
```sql
INSERT INTO tracks (dedup_key, name, ..., sources_json, external_urls_json, ...)
VALUES (?, ?, ..., ?, ?, ...)
ON CONFLICT(dedup_key) DO UPDATE SET
sources_json = (SELECT json_union(sources_json, excluded.sources_json)),
external_urls_json = (SELECT json_union(external_urls_json, excluded.external_urls_json)),
name = CASE WHEN excluded._priority > _priority THEN excluded.name ELSE name END,
...
updated_at = excluded.updated_at;
```
Поскольку SQLite без JSON1 не имеет `json_union`, мерж массивов реализуется на Python в `db.py::upsert_track()` (read-merge-write в одной транзакции). Производительность достаточная: O(1) на трек, < 5 мс на upsert.
7. **Валидация метрики BRD §5 «< 5% дублей»** — отдельный скрипт `scripts/dedup_audit.py` (отсэмплировать 100 треков, вывести в JSON для ручной проверки). Этот скрипт — артефакт фазы тестирования (`04-test-plan.yaml`), не runtime.
8. **План отступления.** Если метрика < 5% не выполнится на реальном датасете:
- Сузить `length_bucket` до 500 м.
- Добавить `activity_type` в ключ (но тогда сломается «OSM без активности vs EnduroRussia с активностью=enduro» — merge не сработает; нужно явно маппить пропуски в общий слот).
- В крайнем случае — гибрид A+B (Вариант D выше).
Эти эволюции — отдельный ADR, не блокируют ET-008 MVP.
## Последствия
### Положительные
- O(1) per track, O(n) per pipeline — никакого квадратичного blow-up.
- Реализуется одним SQL ON CONFLICT + Python-мерж массивов; < 100 строк кода.
- Детерминированный результат при перезапуске pipeline (порядок источников фиксирован конфигом).
- Соответствует BRD-метрике «< 5%» на ожидаемом датасете (валидируется QA в фазе теста).
### Отрицательные / ограничения
- **Ложные коллизии для треков без даты.** Принято осознанно (см. §4 решения).
- **Ложные коллизии для одного маршрута, проехавшего в разные дни** двумя разными людьми с похожей длиной — это **не баг, а ограничение**: один и тот же популярный 30-км маршрут, проехавший двумя гонщиками в один день, будет смерджен в одну запись. Бизнес-смысл сохраняется (пользователь увидит «по этой тропе ездят»), но статистика «сколько раз проехали» — потеряна. Это out of scope MVP; в BRD §5 «плотность треков» — отдельная фича.
- **Length-bucket не работает на круговых треках** с малой длиной по прямой — но bbox-проекция эти случаи всё равно различает по координатам.
- **При наследовании MVP-кода на регионы с миллионом треков** ложные коллизии могут вырасти. Митигация — `10-tech-risks.md` R-2; метрика отслеживается на каждом прогоне в `pipeline_runs.errors_json`.
### Технический долг
- Если QA-метрика провалится — план отступления §8 решения.
- Возможный future-rewrite на Вариант D (hybrid) — задокументирован, но не выполняется в MVP.
## Классификация изменения
**Minor change.** Алгоритм — внутренний contract pipeline'а, не виден ни наружу API, ни во фронтенде. Любая будущая правка `compute_dedup_key()` требует полного re-collect (отбросить БД и пересобрать), но это операционная процедура; затрагивает только `data/gps_tracks.sqlite`. `arch:major-change` не требуется.
## Связанные документы
- `docs/work-items/ET-008/02-trz.md` §6.1 «compute_dedup_key»
- `docs/work-items/ET-008/03-acceptance-criteria.md` AC-03
- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md` §3 (sources_json)
- `docs/work-items/ET-008/08-data-requirements.md` §3.2 (dedup_key)
- `docs/work-items/ET-008/10-tech-risks.md` R-2 (ложные коллизии)

View File

@@ -0,0 +1,233 @@
---
type: adr
work_item_id: ET-008
adr_id: ADR-007
title: "ADR-007: Pipeline сбора GPS-треков — отдельный docker-compose service с profiles:[batch], запускаемый host cron'ом mva154; per-source изоляция; без message queue"
status: accepted
created_at: 2026-06-01
authors:
- "agent:architect"
supersedes: []
superseded_by: []
labels:
- "arch:major-change"
---
# ADR-007 — Архитектура pipeline'а сбора GPS-треков
## Статус
Accepted
## Контекст
ET-008 вводит первый в проекте **офлайн-pipeline** — периодический сбор GPS-треков с внешних публичных платформ (BRD §3 F-01, BRD §7 «Pipeline»). Требования:
- Запускается 12 раза в неделю по cron (BRD §3 Out of scope «Real-time»).
- ≤ 6 часов на полный прогон ЦФО+Чувашию (REQ-NF-02).
- Падение одного источника **не валит** остальные (AC-02 «scenario 3»).
- Pipeline не блокирует и не деградирует production API `/api/*` во время прогона.
- Pipeline пишет в `data/gps_tracks.sqlite` (ADR-005), читатели API видят консистентный снэпшот (WAL).
- Не использовать message queue (BRD § «Запрещено»: «Добавлять message queue без явной необходимости»).
- Минимум зависимостей (BRD § «Принципы»: «Минимум зависимостей»).
Архитектурно нужно решить:
1. **Где исполнять pipeline** — внутри FastAPI-контейнера (background task), отдельный контейнер, или host-Python.
2. **Чем запускать** — host cron, in-process scheduler (APScheduler/Celery beat), systemd-timer.
3. **Как изолировать ошибки источника** — отдельные процессы, asyncio с try/except, отдельные контейнеры.
4. **Где жить конфигам и логам.**
5. **Стратегия retry / backoff / rate-limit** (отдельный субкомпонент или встроено в per-source модули).
## Рассмотренные варианты
### Вариант X (eXecution) — где исполнять
- **X-A — отдельный docker-compose service `gps-collector`** в том же `docker-compose.yml`, использующий тот же image что и `app`, с `profiles: [batch]` чтобы не стартовать вместе с API. Запуск — `docker compose --profile batch run --rm gps-collector`. (Выбран.)
Плюсы:
- Никакого нового образа, никаких новых зависимостей в самом API-контейнере. Из контейнера API исключены HTTP-скрейперы — пользователи не имеют шансов вызвать парсер через SSRF.
- Изоляция CPU/RAM: процесс pipeline не делит память с API; OOM в pipeline не убивает API.
- Использует ту же кодовую базу (`COPY src/api/`, `COPY scripts/` в Dockerfile); deploy один.
- Точка расширения: при росте до многоконтейнерной сборки (PostGIS в будущем) — pipeline уже отдельный сервис.
Минусы:
- Лёгкое усложнение `docker-compose.yml` (+1 service-блок ≈ 15 строк).
- Host cron должен знать команду `docker compose --profile batch run`.
- **X-B — background task внутри FastAPI** (APScheduler в lifespan). Отклонён:
- Pipeline жрёт CPU/память на API-контейнере → деградация запросов во время прогона.
- Сложно остановить отдельно от API.
- При перезапуске API теряется состояние прогона (если пайплайн не идемпотентный).
- Запрещено BRD «Добавлять X без явной необходимости» — это де-факто in-process scheduler.
- **X-C — host-Python venv + системный cron** (вне Docker). Отклонён:
- Нарушает BRD «Всё в Docker».
- Дублирование зависимостей: один venv в Docker, второй на хосте.
- Усложняет CI/CD: pipeline не покрывается тем же `make build`.
- **X-D — Celery worker + Redis** (queue-based). Отклонён прямо BRD «Запрещено: Добавлять message queue». Не нужен — задача одна, без распараллеливания.
### Вариант S (Scheduling) — чем запускать
- **S-A — host cron на mva154** (выбран). Запись в `/etc/cron.d/enduro-gps`:
```cron
# GPS tracks pipeline — ET-008
0 3 * * 1,4 root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector >> /var/log/enduro-trails/gps-collect.log 2>&1
```
Плюсы:
- Часть базовой ОС, не требует доп. установок.
- Лог в файл — оператор может `tail -f`.
- Если прогон завис — `kill <pid>` штатно убивает контейнер; следующий cron-тик запустит заново.
- **S-B — systemd timer** на хосте. Отклонён: даёт более тонкий контроль (зависимости, рестарты), но это инфра-апгрейд за гранью BRD «минимум зависимостей»; cron достаточно.
- **S-C — in-container scheduler** (APScheduler). Отклонён (см. X-B).
- **S-D — Gitea Actions self-hosted scheduled workflow**. Отклонён: CI/CD контейнер не должен делать write в production-данные.
### Вариант I (Isolation) — изоляция ошибок per-source
- **I-A — try/except на уровне источника в asyncio-loop** (выбран). Один процесс python, для каждого `(region, source)` отдельный `try/except`; на падении пишется в `pipeline_runs.errors_json`, цикл идёт дальше к следующему источнику.
- **I-B — отдельный процесс per-source** (subprocess + JSON pipe). Отклонён: усложнение без существенной выгоды; OOM одного source при умеренных лимитах не валит весь python-процесс.
- **I-C — отдельный контейнер per-source**. Отклонён: гросс over-engineering для 3 источников.
### Вариант R (Rate-limit) — где живёт rate-limit-логика
- **R-A — в per-source модуле** через `asyncio.sleep(rate_limit_sec)` после каждого HTTP (выбран; совпадает с TRZ §1 REQ-F-03). Простой, явный, контролируется конфигом `gps_sources.yaml`.
- **R-B — глобальный rate-limiter** (semaphore на all-sources). Отклонён: rate-limit per-source, у каждого источника свой ToS-лимит. Глобальный лимитер только усложнит.
- **R-C — внешний прокси с rate-limit** (HAProxy / nginx-limit-req). Отклонён: новая инфра-зависимость.
### Вариант C (Config) — где конфиг
- **C-A — YAML в репозитории** `config/gps_sources.yaml`, `config/gps_regions.yaml` (выбран; совпадает с TRZ REQ-F-01/02). Источник истины — git; ревью изменений идёт стандартным PR-флоу.
- **C-B — в БД, редактирование через админ-UI**. Отклонён: над-инжиниринг для MVP; добавляет attack surface.
- **C-C — в env-переменных docker-compose**. Отклонён: не масштабируется на 3+ источников.
## Решение
Принимается комбинация: **X-A + S-A + I-A + R-A + C-A**.
1. **Pipeline — отдельный docker-compose service `gps-collector`** в `docker-compose.yml`:
```yaml
services:
gps-collector:
build: .
profiles: ["batch"]
volumes:
- ./data:/app/data
- ./config:/app/config:ro
- /var/log/enduro-trails:/var/log/enduro-trails
environment:
- GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite
- GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml
- GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml
- HTTPX_LOG_LEVEL=INFO
command: ["python", "-m", "scripts.gps_collect"]
restart: "no"
```
- `profiles: ["batch"]` — service **не стартует** при штатном `docker compose up -d` (важно: API uptime не зависит от pipeline).
- Запускается командой `docker compose --profile batch run --rm gps-collector` (запись — `host cron`).
- Использует **тот же image**, что и `app` — сборка одна, пакет тот же.
- Конфиги примонтированы read-only — `gps-collector` их не пишет.
- `/var/log/enduro-trails` шарится с хостом; stdout/stderr ловит cron в `gps-collect.log`, а pipeline пишет structured JSON-лог в `/var/log/enduro-trails/pipeline-<run_id>.jsonl`.
2. **Cron на mva154** — `/etc/cron.d/enduro-gps`:
```
0 3 * * 1,4 root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector >> /var/log/enduro-trails/gps-collect.log 2>&1
```
- Mon + Thu 03:00 UTC (BRD §7 «Cron на mva154»).
- Логи ротируются стандартным `logrotate` (см. `07-infra-requirements.md` §10).
- Простого «flock» против overlapping runs **не нужно**: cron-окно 3-дневное, реальная длина прогона ≤ 6 ч.
3. **GC-прогон** — отдельная команда `docker compose --profile batch run --rm gps-collector python -m scripts.gps_collect --gc`. Запускается раз в месяц host cron'ом отдельной строкой `0 4 1 * * root ...`. Удаляет треки с `updated_at < NOW() - 5 years` (REQ-NF-03).
4. **Per-source модули в `src/api/gps_tracks/sources/`** реализуют **абстрактный контракт** `base.py::SourceParser`:
```python
class SourceParser:
MAPPING: dict[str, str] # source-category → ACTIVITY_TYPE
async def collect(self, bbox: BBox, ctx: PipelineContext) -> AsyncIterator[Track]: ...
```
Главная петля `scripts/gps_collect.py::run_pipeline()`:
```python
for region in regions_enabled:
for source_id in region.sources:
parser = load_parser(source_id)
run = pipeline_runs.start(region.id, source_id)
try:
async for track in parser.collect(region.bbox, ctx):
db.upsert_track(track) # ADR-006 dedup-логика
run.tracks_new_or_updated += 1
except Exception as e:
run.status = "error"
run.errors_json = serialize_exc(e)
logger.exception("source %s failed", source_id)
finally:
run.finalize()
```
- Падение `parser.collect()` локализовано в один `try/except` — следующий источник стартует без рестарта процесса.
- `parser.collect()` — асинхронный генератор; pipeline pulls треки по одному, не накапливает в памяти больше одного.
5. **Per-source rate-limit и backoff** реализованы в `base.py::SourceParser._http_get()` через `asyncio.sleep(rate_limit_sec)` после каждого запроса и `tenacity`-стиль retry с exponential backoff (TRZ §6.3). `User-Agent` берётся из `gps_sources.yaml` per-source.
6. **Лицензионные guard'ы.** Перед `load_parser(source_id)` pipeline **проверяет**: `config/gps_sources.yaml::sources[id].license_adr` указывает на файл `docs/work-items/ET-008/06-adr/ADR-NNN-<source>-licensing.md` со статусом `accepted`. Если файл не найден или статус не `accepted` → exception → source пропускается; запись `pipeline_runs.status = "skipped_license"`. Это превращает BRD §4 «Юридический минимум» в **runtime-enforced** правило, не «обещание разработчика». См. `10-tech-risks.md` R-9.
7. **Cache-invalidation тайлов после прогона.** В конце успешного прогона pipeline делает HTTP-запрос:
`POST http://app:5556/api/gps-tracks/cache/clear`
(внутренняя сеть docker-compose). API сбрасывает LRU-кэш MVT-тайлов. Если API недоступен — лог-предупреждение, не ошибка прогона (REQ-NF-04).
8. **Health-эндпоинт `/api/gps-tracks/health`** (REQ-F-12) **читает** последнюю запись `pipeline_runs` из БД (не имеет прямой связи с процессом pipeline; уже остановленный pipeline продолжает быть «виден» через свою историю в БД).
9. **WAL и concurrent reads.** Pipeline пишет в БД в WAL-mode (ADR-005 §5). FastAPI читает ту же БД, видит последний checkpoint. Pipeline вызывает `PRAGMA wal_checkpoint(PASSIVE)` после каждого `(region, source)` чтобы WAL-файл не разрастался.
10. **C4 / архитектурная диаграмма.** В `docs/architecture/README.md` добавляется раздел «GPS Tracks Pipeline»: новый компонент `gps-collector` (внутри docker-compose, не стартует штатно), новые внешние зависимости (OSM API + 2 source-сайта), новая БД `gps_tracks.sqlite`. Mermaid C4-диаграммы в проекте отсутствуют; следуем прецеденту ADR-004 §8 — текстовое описание.
## Последствия
### Положительные
- Pipeline и API изолированы по контейнерам, по процессам, по CPU/RAM. Pipeline не может уронить API.
- Расширение списка источников = добавить файл `src/api/gps_tracks/sources/<name>.py` + запись в `gps_sources.yaml` + ADR-licensing. Никакого кода pipeline не правится (BRD-метрика «расширяемость без правки Python-кода» выполняется).
- Расширение списка регионов = одна запись в `gps_regions.yaml` ≤ 30 строк (BRD-метрика выполняется).
- Сбой одного парсера не останавливает остальные (AC-02 выполняется через try/except на per-source уровне).
- `profiles: ["batch"]` гарантирует, что pipeline никогда не стартует автоматически с `docker compose up` — нулевая вероятность случайного «pipeline скачивает на проде» во время рестарта API.
- Простой деплой: тот же `make build` собирает образ; новый сервис сразу доступен.
- Лицензионные guard'ы (§6 решения) делают BRD §4 «Юридический минимум» **enforceable**, не на честное слово разработчика.
### Отрицательные / ограничения
- Pipeline зависит от установленного на mva154 `docker compose` (v2 plugin). Это **уже выполняется** — на mva154 docker compose v2 используется для штатного деплоя.
- Логи живут на хосте (`/var/log/enduro-trails/`) — не в Docker. Это сознательно: ротация через `logrotate`, доступ через ssh, не требует доп. log-агрегатора.
- При смене image (новой версии Python / новой системной зависимости) нужно `docker compose --profile batch build gps-collector` — но `--profile batch` теперь должен быть в команде, что легко забыть. Митигация: smoke-проверка в deploy-runbook (`07-infra-requirements.md` §7).
- Pipeline не имеет UI/админки — оператор работает через ssh + cron logs. На MVP это приемлемо; админ-UI — отдельная задача после PH-3 при необходимости.
### Технический долг
- Если в будущем понадобится распараллелить источники для скорости — заменить `for source_id ... await parser.collect()` на `asyncio.gather([parser.collect(...) for source_id ...])`. Контракт `SourceParser.collect()` уже асинхронный — изменение локально.
- Если понадобится централизованная очередь / распределённый pipeline — заменить cron+single-container на Celery/Redis. Контракт `pipeline_runs` в БД останется; меняется только запуск.
- Если на масштабе РФ понадобится дробить регион на параллельные шарды — расширение `gps_regions.yaml` поддерживает это (subregions); меняется только runner.
## Классификация изменения
**Major change.** Pipeline вводит:
- Первый scheduled-job на mva154 для проекта (cron-запись).
- Первый outbound-скрейпинг (правовой режим, rate-limit-обязательства перед третьими сторонами).
- Новый docker-compose service.
- Новую БД (через ADR-005, отдельно).
Каждый из этих пунктов сам по себе **не** требует `arch:major-change` (по правилам CLAUDE.md новый сервис / новая БД — да). Лейбл `arch:major-change` выставлен. Обязательный архитектурный approve — да.
## Связанные документы
- `docs/work-items/ET-008/01-brd.md` §7 «Pipeline», §3 F-01..F-03, F-12, F-17
- `docs/work-items/ET-008/02-trz.md` §1 REQ-F-01..REQ-F-03, REQ-F-07, REQ-F-12, §6.2, §6.3
- `docs/work-items/ET-008/03-acceptance-criteria.md` AC-01, AC-02
- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md`
- `docs/work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md`
- `docs/work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md`
- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md`
- `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md`
- `docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md`
- `docs/work-items/ET-008/07-infra-requirements.md`
- `docs/work-items/ET-008/10-tech-risks.md` R-1, R-5, R-6, R-9

View File

@@ -0,0 +1,185 @@
---
type: adr
work_item_id: ET-008
adr_id: ADR-008
title: "ADR-008: Двухрежимная отдача публичных треков — MVT-тайлы на z ≤ 11, GeoJSON по bbox на z ≥ 12; клиентское переключение по zoom; общий cache-invalidation"
status: accepted
created_at: 2026-06-01
authors:
- "agent:architect"
supersedes: []
superseded_by: []
labels: []
---
# ADR-008 — Стратегия отдачи треков клиенту: MVT vs GeoJSON
## Статус
Accepted
## Контекст
Слой публичных треков (BRD §3 F-05..F-09) должен:
- Показываться на широком диапазоне zoom — от z=8 (вся область региона видна сразу) до z=16+ (один трек крупно).
- Поддерживать **клик с popup** на трек (REQ-F-18) — то есть feature должна быть «настоящей», а не растровой.
- Поддерживать **клиентскую фильтрацию** по активности и источнику без сетевого запроса (REQ-F-14, AC-08).
- Уложиться в p95 ≤ 300 мс для GeoJSON-ответа (BRD-метрика).
- Не штормить сервер запросами при быстром pan (AC-14).
На низком zoom (z=8) в видимую область могут попасть тысячи треков. Отдавать их одним GeoJSON-ответом неприемлемо: payload в 10100 МБ → сетевой p95 проседает; парсинг GeoJSON блокирует main thread браузера; MapLibre перерисовывает каждое pan-move.
На высоком zoom (z ≥ 12) в видимую область попадают десятки треков, и пользователь ждёт interactive popup + точную геометрию.
Архитектурно нужно выбрать стратегию отдачи и переключения между режимами.
## Рассмотренные варианты
### Вариант M (Mode) — единый режим отдачи
- **M-A — только GeoJSON для всех zoom**. Отклонён:
- На z=8 payload неприемлем (см. контекст).
- Не использует существующий MVT-кэш-паттерн `main.py` для слоя `trails` — теряем уже отлаженный механизм для аналогичной задачи.
- **M-B — только MVT для всех zoom**. Отклонён:
- MVT не даёт удобного `popup` с богатыми метаданными: `properties` MVT-тайла ограничены (плюс через MapLibre `queryRenderedFeatures` доступ есть, но фильтр feature-level через `setFilter` требует чтобы все нужные поля сидели в MVT-фиче — а у нас `sources` массив, который в MVT нативно не представляется).
- Клиентская фильтрация по `source` через `setFilter` работает только на одной колонке source (REQ-F-16 «первый source»); для multi-source filtering на MVT-фиче без множественной колонки — компромисс.
- **M-C — гибрид: MVT на z ≤ 11, GeoJSON на z ≥ 12** (выбран, совпадает с TRZ REQ-F-11 финальной формулировкой).
- На z ≤ 11 — MVT, серверный LRU-кэш, ограниченное упрощение геометрии. Клиент видит «общий ландшафт» — где много треков, плотность, какие источники доминируют.
- На z ≥ 12 — GeoJSON по bbox, полные точные координаты, полные `sources_json`/`external_urls_json` для popup.
- Cutoff z=12 — реалистичный порог: 1 тайл z=11 ≈ 19 × 12 км (на широте 55°), z=12 ≈ 10 × 6 км. В bbox z=12 типично попадает ≤ 500 треков → GeoJSON ≤ 2 МБ → влезает в SLA 300 мс.
### Вариант T (Tile generation) — как генерировать MVT
- **T-A — реальное время по запросу + LRU-кэш** (выбран; совпадает с архитектурой текущего слоя `trails` в `main.py`):
- На запрос `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt`:
1. Проверить LRU-кэш (1024 записи).
2. На промахе — выполнить SELECT из `tracks` по bbox тайла, упростить геометрии по `simplify_coords(coords, z)`, отдать через `mapbox-vector-tile`.
3. Записать результат в LRU.
- Cache-invalidation — `POST /api/gps-tracks/cache/clear` после успешного pipeline-прогона (ADR-007 §7).
- Cold-cache p95 ≤ 200 мс (REQ-NF-02). Hot-cache ≤ 20 мс.
- **T-B — pre-generated tile cache** (после pipeline сразу генерируется весь z=8..z=11 grid на диск). Отклонён:
- 4ˡ tiles на каждом zoom — z=8 = 16 tiles, z=9 = 64, z=10 = 256, z=11 = 1024 → ≈ 1.4k тайлов. Несложно, но: при росте региона до РФ — десятки тысяч; диск растёт без необходимости.
- Cold-cache при первой загрузке после прогона всё равно нужен (LRU прогревается естественно).
- Усложняет cache-invalidation: нужно удалять файлы вместо `_tile_cache.clear()`.
- **T-C — внешний tile server** (tilelive/tilemaker/Tegola). Отклонён: новый сервис, новая инфра-зависимость; mapbox-vector-tile в Python уже умеет всё, что нужно.
### Вариант G (GeoJSON limit) — как обрезать GeoJSON
- **G-A — фиксированный limit=500, truncated=true в payload** (выбран; совпадает с TRZ REQ-F-10).
- На z ≥ 12 типично ≤ 500 треков в bbox → truncated:false.
- На редких плотных bbox (10+ треков/км²) сервер возвращает первые 500 (LIMIT в SQL), `truncated:true`, клиент показывает в UI «показано 500 из 743, увеличьте zoom».
- Простая семантика, нет surprise для разработчика API.
- **G-B — server-side pagination cursor**. Отклонён: над-инжиниринг; для visualisation-слоя пагинация не интуитивна; пользователю удобнее zoom, а не next-page.
- **G-C — server-side clustering для overflow**. Отклонён: track — это LineString, кластеризация по линейным сущностям нетривиальна; out of scope.
### Вариант F (Filter location) — где фильтровать по activity/source
- **F-A — серверный фильтр в SQL** (по `activity_type`) + Python-постфильтр (по `sources_json`); итоговое FeatureCollection уже отфильтровано (выбран для GeoJSON, совпадает с TRZ REQ-F-10).
- Сервер сразу возвращает только нужное → меньше трафика.
- Но: смена фильтра в UI → новый запрос. Это ОК для GeoJSON (z ≥ 12, < 500 треков) — REQ-NF-06 «≤ 200 мс» выполнимо при cache miss.
- **F-B — клиентский фильтр через `setFilter`** на уже загруженной выборке (выбран **дополнительно**, для MVT-режима).
- На z ≤ 11 — MVT уже содержит всё; смена фильтра — мгновенный `setFilter` без сетевого запроса. AC-08 «фильтрация мгновенная (≤ 200 мс)».
- На z ≥ 12 — клиентский setFilter работает поверх загруженного GeoJSON; для повторного fetch при следующем `moveend` уже учитываются новые фильтры.
### Вариант D (Debounce) — защита от шторма запросов
- **D-A — клиентский debounce 500 мс + AbortController** (выбран; совпадает с TRZ §6.4):
- На `moveend` карта запускает 500-мс таймер; новые `moveend` сбрасывают его.
- Старые in-flight запросы отменяются `AbortController.abort()`.
- Server-side rate-limit не нужен — фронтенд сам себя ограничивает.
- **D-B — server-side rate-limit middleware**. Отклонён: усложняет API, не нужно при D-A.
### Вариант H (Halo on satellite) — гибридный слой через MVT/GeoJSON
- **H-A — две `'source'`-привязки в MapLibre**: одна на `gps-tracks-tiles` (vector source MVT), вторая на `gps-tracks-geo` (GeoJSON source). Один и тот же слой `gps-tracks-layer` нельзя привязать к двум sources одновременно. Поэтому **два параллельных слоя**: `gps-tracks-layer-mvt` (visible на z ≤ 11) и `gps-tracks-layer-geo` (visible на z ≥ 12). Переключение через `setLayoutProperty('visibility')` по `zoomend`. (Выбран — единственный нормально работающий способ.)
- **H-B — переключать `setData` на одном слое**. Отклонён: GeoJSON-source и vector-source — разные типы в MapLibre; нельзя «переключить» source у layer'а без `removeLayer` + `addLayer`.
## Решение
Принимается комбинация: **M-C + T-A + G-A + F-A + F-B + D-A + H-A**.
1. **Двухрежимная отдача:**
- `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` — векторные тайлы, **только для клиента**, который добавил vector-source `gps-tracks-tiles`. Клиент использует на z ≤ 11.
- `GET /api/gps-tracks?bbox=&activity=&source=&limit=` — GeoJSON FeatureCollection, для z ≥ 12.
2. **Cutoff z=12** — выбран как баланс между «MVT даёт обзор + кэш» и «GeoJSON даёт полный popup-data». Cutoff фиксирован в клиенте константой `GPS_TRACKS_ZOOM_CUTOFF = 12`.
3. **MVT-слой клиента:**
- Source: `vector` type, `tiles: ['/api/gps-tracks/tiles/{z}/{x}/{y}.mvt']`, `minzoom: 8`, `maxzoom: 11`. На z < 8 слой полностью скрыт (TRZ REQ-F-20).
- Layer: `gps-tracks-layer-mvt`, `source-layer: 'gps_tracks'`, paint по REQ-F-17.
- Properties фичи: `id, activity, source (первый), sources (comma-separated), length_km, name, ext_url` (TRZ §4.3). `sources` как comma-string, потому что MVT не поддерживает массивы.
4. **GeoJSON-слой клиента:**
- Source: `geojson`, `data: { type: 'FeatureCollection', features: [] }` (пустой при инициализации).
- Layer: `gps-tracks-layer-geo`, `source: 'gps-tracks-geo'`, paint по REQ-F-17.
- На `moveend` (debounced 500 мс через AbortController) — `fetch('/api/gps-tracks?bbox=...&activity=...&source=...&limit=500')``getSource().setData(json)`.
5. **Переключение по zoom:**
- `zoomend` listener: `if (z < 12) hide(geo); show(mvt); else show(geo); hide(mvt);`.
- `visibility` управляется `setLayoutProperty`.
- Кратко: оба source и layer всегда **существуют** при включённом чекбоксе; меняется только видимость.
- На z < 8 — оба невидимы (REQ-F-20); статус-баннер «Зум 8+».
6. **Серверный MVT-кэш:**
- LRU-словарь в памяти процесса FastAPI, ёмкость **1024** записи (как для слоя `trails`).
- Ключ — `(z, x, y)`. Значение — байты `.mvt`.
- На промахе SELECT идёт через R-tree (Spatialite `idx_tracks_geom`) с bbox тайла + 5% padding.
- Упрощение геометрии — `simplify_coords(coords, z)` (Douglas-Peucker tolerance зависит от zoom).
- LIMIT тайла — как у `trails` (3000/8000/15000 на z ≤ 7/9/11).
7. **Cache-invalidation:**
- `POST /api/gps-tracks/cache/clear` — единственный POST в этом семействе эндпоинтов, авторизуется по сетевому пути (только из docker-compose internal network; через `/enduro/` proxy не маршрутизируется — см. `07-infra-requirements.md` §3).
- Pipeline вызывает его при успешном завершении (ADR-007 §7).
8. **Сервер GeoJSON (`GET /api/gps-tracks`):**
- SQL: `SELECT * FROM tracks WHERE ROWID IN (SELECT pkid FROM idx_tracks_geom WHERE ... bbox ...) [AND activity_type IN (...)] ORDER BY length_m DESC LIMIT N` — длинные треки первыми (полезнее для overview).
- `source` фильтр — постфильтр на Python после получения < 500 строк (`'osm' in json.loads(sources_json)`).
- Total — отдельный `COUNT(*)` запрос с теми же WHERE-условиями (без LIMIT) для `total_in_bbox`.
- Response — GeoJSON по REQ-F-10 со всеми properties.
- p95 ≤ 300 мс — выполнимо на bbox с ≤ 500 треков (запросы R-tree + N парсингов WKB по 1.5 КБ).
9. **Atomic state в клиенте** через объект `window.gpsTracksLayer` (TRZ §4.4). Поля state на 100% derived из (`localStorage` + `map.getZoom()` + последний GeoJSON-ответ); восстановление в `rebuildMapOverlays() → restorePublicTracksState()` (REQ-F-19).
10. **Halo на спутнике (REQ-F-15, ET-007 §7.2 паттерн):**
- Для **обоих** клиентских слоёв (MVT и GeoJSON) — свои halo:
- `gps-tracks-halo-mvt-satellite` — halo поверх `gps-tracks-tiles`.
- `gps-tracks-halo-geo-satellite` — halo поверх `gps-tracks-geo`.
- Видимость halo управляется хелпером `applyGpsHaloVisibility()` по правилу: halo видим ⇔ `(public-tracks ON) AND (zoom band matches) AND (base === 'satellite')`.
- Hook добавляется в `applyBaseLayer()` (ET-007) — по тому же паттерну, что halo для trails (ADR-004 §9).
## Последствия
### Положительные
- Соответствует SLA: MVT cold p95 ≤ 200 мс, GeoJSON p95 ≤ 300 мс при разумном bbox.
- Низкий зум — обзор; высокий зум — полный popup. Пользователь получает оптимум на каждом масштабе.
- Кэш-стратегия идентична существующему слою `trails` — оператор уже знаком; единый паттерн.
- AbortController + debounce защищают от шторма запросов независимо от того, насколько быстро юзер pan'ит карту.
- Cache-invalidation после прогона — пользователь видит свежие данные при следующем pan/zoom.
### Отрицательные / ограничения
- **Два source / два layer на один логический слой** — небольшое усложнение клиентского кода (sync visibility, sync filter). Кодовое разбиение — в `src/web/gps_tracks.js`; внутренняя сложность не «протекает» наружу.
- **Жёсткий cutoff z=12.** На границе (z=11.5) пользователь может видеть мигание: MVT-тайлы упрощены до 1км, GeoJSON покажет точные кривые. Сглаживание — `transition` на opacity (UI-микро-улучшение, не блокер).
- **`source` в MVT — только первый из dedup-list.** Цвет по источнику (REQ-F-16) показывает «первый по приоритету»; реальное мульти-источникство видно только в popup на z ≥ 12. Принято: «дедупный мульти-источникный» трек редок (< 10% по оценке BRD §5); цвет по «первому источнику» интуитивен.
- **Серверный кэш сбрасывается ТОЛЬКО pipeline'ом.** Если оператор вручную `UPDATE tracks` — кэш не инвалидируется. Митигация — оператор знает про эндпоинт; в runbook (`07-infra-requirements.md` §8). На практике вручную в БД лазать не предполагается.
### Технический долг
- `tile_to_bbox` / `wkb_to_coords` / `simplify_coords` дублируются между `main.py` и `gps_tracks/mvt.py` (см. ADR-005 §8). При появлении третьего MVT-источника — вынести в shared util.
- Если в будущем понадобится фильтр по multiple `source` непосредственно в MVT (для multi-color по источникам трека) — необходимо переработать схему MVT properties (массив через JSON-string или через несколько колонок). Не блокер MVP.
## Классификация изменения
**Minor change.** Стратегия отдачи — внутренний контракт клиент↔API, всё в пределах FastAPI и фронтенда. Новых сервисов, БД, очередей не вводит. `arch:major-change` не требуется.
## Связанные документы
- `docs/work-items/ET-008/02-trz.md` §1 REQ-F-10, REQ-F-11, REQ-F-13, REQ-F-17, REQ-F-20, §6.4
- `docs/work-items/ET-008/03-acceptance-criteria.md` AC-04, AC-05, AC-13, AC-14
- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md` §8 (общий tile-utility)
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §7 (cache-clear hook)
- `docs/work-items/ET-008/07-infra-requirements.md` §3 (network)
- `docs/work-items/ET-008/10-tech-risks.md` R-7, R-8
- `docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md` §5, §9 (halo-паттерн)

View File

@@ -0,0 +1,146 @@
---
type: adr
work_item_id: ET-008
adr_id: ADR-009
title: "ADR-009: Источник OSM Public GPS Traces — лицензия ODbL, документированный API, акцептовано для MVP"
status: accepted
created_at: 2026-06-01
authors:
- "agent:architect"
supersedes: []
superseded_by: []
labels:
- "ET-008:source-licensing"
---
# ADR-009 — OSM Public GPS Traces: licensing review
## Статус
Accepted
## Контекст
BRD §4 ET-008 требует обязательный ADR licensing-review для каждого внешнего источника **до** активации его в pipeline. Без `status: accepted` в этом ADR — pipeline отказывается загружать source-parser (см. ADR-007 §6).
Источник: **OpenStreetMap Public GPS Traces**.
- Endpoint: `GET https://api.openstreetmap.org/api/0.6/trackpoints?bbox=W,S,E,N&page=P`.
- Endpoint метаданных: `GET https://api.openstreetmap.org/api/0.6/gpx/{id}`.
- Документирован: <https://wiki.openstreetmap.org/wiki/API_v0.6#GPS_traces>.
- Лицензия данных: **ODbL 1.0** — Open Database License (<https://opendatacommons.org/licenses/odbl/1-0/>).
- Атрибуция: «© OpenStreetMap contributors (ODbL)».
## Чеклист по BRD §4
### 1. ToS источника по поводу скрейпинга / массовой загрузки GPX
OSM API имеет **документированный публичный contract**. Использование `bbox + page` пагинации — штатный сценарий, не «скрейпинг» (это публичный API).
Operational limit, опубликованный OSM:
- bbox area ≤ 0.25 deg² на запрос (жёсткий серверный лимит).
- Public usage policy (<https://operations.osmfoundation.org/policies/api/>): «Heavy use must be at least 1 sec between requests, no faster». Рекомендация — `1 req/sec`, что и зафиксировано в `gps_sources.yaml::osm.rate_limit_sec = 1`.
- При злоупотреблении OSM Operations Team вправе временно блокировать IP. Митигация в `10-tech-risks.md` R-5.
**Вывод:** массовая выгрузка по bbox разрешена при соблюдении rate-limit.
### 2. robots.txt
`https://api.openstreetmap.org/robots.txt`:
```
User-agent: *
Disallow:
```
Все эндпоинты API доступны без ограничений robots.
### 3. Условия публикации чужих треков
ODbL даёт «свободу копировать, изменять, использовать и предоставлять третьим лицам» при условии:
- **Attribution.** Атрибуция OSM contributors с указанием ODbL.
- **Share-alike.** Производное произведение должно распространяться на условиях, совместимых с ODbL.
- **Keep open.** Если производное произведение публикуется, source-data не должна закрываться.
Применительно к ET-008:
- Атрибуция OSM выводится MapLibre автоматически при наличии source с правильным `attribution` (уже работает для базового слоя «Схема»).
- В `gps_sources.yaml::osm.attribution = "© OpenStreetMap contributors (ODbL)"` дополнительно выставляется на ВСЕ агрегированные данные.
- В popup трека (REQ-F-18) выводится ссылка на оригинал `https://www.openstreetmap.org/user/{user}/traces/{id}`.
- Share-alike относится к опубликованной нами производной БД. `data/gps_tracks.sqlite` **не публикуется наружу** — отдаётся только через FastAPI как агрегированный сервисный слой. Это попадает под «Produced Work» определение ODbL и атрибуция здесь обязательна, share-alike — нет.
**Имя автора** (`user`) — публичное поле OSM-трека (видно на странице трека); сохранение `user` не нарушает ToS, при этом — см. §5 ниже.
### 4. Rate-limit
- Конфигурация `gps_sources.yaml::osm.rate_limit_sec = 1` (1 запрос в секунду).
- User-Agent: `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` — соответствует требованию OSM API «provide a clear user agent with contact information».
- Backoff на 429/503 — экспоненциальный 2^n до 3 попыток (TRZ §6.3).
- Per-source максимальное число запросов на прогон — не ограничено явно; ЦФО+Чувашия ≈ 700 cells × 5 pages × 1 сек ≈ 1 час реального времени (REQ-NF-02). Это < 6-часового cron-окна и существенно меньше «heavy use» порога OSM.
### 5. Метаданные, запрещённые к сохранению
ODbL не накладывает ограничений на сохранение публично доступных полей. Однако:
- **`user` (имя автора)** — публикуется OSM на странице трека; сохранение разрешено. **Решение ET-008: сохраняем**, потому что это даёт пользователю credit в popup; это семантика самой OSM.
- **`description`, `tags`** — публичные, сохраняем.
- **GPS-точки** — публичные (трек загружен автором как public; private/trackable треки не отдаются в `trackpoints` API). Сохраняем как геометрию.
- **`email`, `display_name` отдельно от `user`** — OSM API таких полей в `gpx`-эндпоинте не отдаёт; сохранять нечего.
### 6. Удаление по требованию автора
Если автор удалит трек на OSM (PUT visibility=private или DELETE):
- Следующий полный прогон pipeline по тому же bbox не найдёт этот `gpx_id` → запись в нашей БД останется (stale).
- Митигация: per-source GC-проход (отдельная команда `gps_collect.py --gc-stale`) сравнивает наши `external_id` со списком актуальных id OSM и удаляет stale.
- На MVP **только реактивно**: при ручном запросе автора через issue tracker оператор может удалить запись по `external_id = "osm-<gpx_id>"`. Автоматический GC-проход — отдельный work item.
### 7. Полученное юридическое заключение
OSM Public GPS Traces — **самый изученный** open-data источник; используется тысячами open-source проектов (OsmAnd, JOSM, Strava Routes, и т.д.) для аналогичных целей. ODbL — стандартизованная лицензия фондом Open Knowledge. Внешнего юридического review не требуется для MVP.
## Решение
**Источник OSM Public GPS Traces включается в pipeline как `enabled: true` в `gps_sources.yaml`** со следующими параметрами:
```yaml
- id: osm
name: "OSM Public GPS Traces"
enabled: true
license_adr: "docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md"
base_url: "https://api.openstreetmap.org/api/0.6"
rate_limit_sec: 1
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
attribution: "© OpenStreetMap contributors (ODbL)"
parser_module: "src.api.gps_tracks.sources.osm"
save_user_field: true # ADR-009 §5 разрешает
external_url_template: "https://www.openstreetmap.org/user/{user}/traces/{external_id_numeric}"
```
Атрибуция автоматически выводится MapLibre в правом нижнем углу карты при включённом source (REQ-NF-06).
## Последствия
### Положительные
- Самый стабильный источник: документированный API, ODbL — общепринятая open-data лицензия, нет коммерческих условий, нет API-ключей.
- BRD-метрика «≥ 3 источника, отдающих данные» закрывается через OSM + 2 других после ADR-010/011.
- OSM-треки — единственный гарантированно доступный источник; даже если ADR-010/011 будут отклонены, OSM в одиночку покрывает BRD-минимум.
### Отрицательные / ограничения
- OSM-treki не имеют `activity_type`у нас по умолчанию `other`. Уточнение возможно через `tags` (если автор пометил «moto/enduro/mtb»). Mapping в `osm.py::MAPPING` (TRZ REQ-F-07). Часть треков останется `other` — это ожидаемо.
- IP-сервера mva154 будет «известен» OSM как scraper. Это допустимо при честном User-Agent + соблюдении rate-limit.
- Stale-tracks (удалённые автором, оставшиеся у нас) — GC задача для post-MVP.
## Классификация изменения
**Minor change.** Источник со стандартной open-лицензией, без скрейпинга HTML, без коммерческих условий. `arch:major-change` не требуется на уровне отдельного licensing-ADR (общая major-классификация — на ADR-005 и ADR-007).
## Связанные документы
- `docs/work-items/ET-008/01-brd.md` §4 «Источники», «Юридический минимум»
- `docs/work-items/ET-008/02-trz.md` REQ-F-04
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6 (licensing guard)
- `docs/work-items/ET-008/08-data-requirements.md` §5 (персональные данные)
- `docs/work-items/ET-008/10-tech-risks.md` R-5 (rate-limit), R-9 (licensing enforcement)
- <https://wiki.openstreetmap.org/wiki/API_v0.6#GPS_traces>
- <https://operations.osmfoundation.org/policies/api/>
- <https://opendatacommons.org/licenses/odbl/1-0/>

View File

@@ -0,0 +1,142 @@
---
type: adr
work_item_id: ET-008
adr_id: ADR-010
title: "ADR-010: Источник EnduroRussia.ru — БЛОКИРОВАН до завершения ToS/robots-ревью; pipeline пропускает source до перехода status=accepted"
status: proposed
created_at: 2026-06-01
authors:
- "agent:architect"
supersedes: []
superseded_by: []
labels:
- "ET-008:source-licensing"
- "blocking"
---
# ADR-010 — EnduroRussia.ru: licensing review (БЛОКИРУЮЩИЙ)
## Статус
**Proposed** — заблокирован до полного review.
> Pipeline (`scripts/gps_collect.py`) при загрузке `enduro_russia` parser **проверяет** этот ADR. Пока `status: proposed` — source пропускается с записью `pipeline_runs.status = "skipped_license"`. См. ADR-007 §6 — licensing guard.
## Контекст
BRD §4 ET-008 требует обязательный ADR licensing-review для каждого внешнего источника **до** активации в pipeline. Источник `EnduroRussia.ru` упомянут BRD как один из приоритетных (категория «эндуро-treki по регионам»), но в отличие от OSM (ADR-009) не имеет документированного публичного API, а отдаёт треки через HTML + прямые GPX-ссылки на страницах.
Этот ADR — **шаблон для completion**. До тех пор пока не выполнен полный чеклист ниже (включая получение явных ответов от платформы при их недоступности из robots/ToS), source находится в состоянии `proposed` и pipeline его пропускает.
## Чеклист по BRD §4 (открытые вопросы)
### 1. ToS источника по поводу скрейпинга / массовой загрузки
**ОТКРЫТО.** Необходимо:
- Извлечь актуальную версию пользовательского соглашения с `enduro-russia.ru/agreement` или аналогичной страницы.
- Найти/получить ответ на вопросы:
- Разрешён ли автоматизированный сбор страниц?
- Разрешено ли массовое скачивание GPX-файлов, опубликованных пользователями платформы?
- Допускается ли передача / републикация GPX третьим лицам (т.е. отдача через наш API)?
- При отсутствии явного разрешения — отправить запрос администратору платформы по контактам (`info@enduro-russia.ru` или эквивалент) с описанием цели использования; **получить письменное подтверждение** (email или его архив).
**Принимаемый статус:**
- Если ToS явно разрешает или администратор подтверждает → §7 решения переключается на `accepted`.
- Если ToS явно запрещает либо администратор отказал → этот ADR превращается в `rejected`, source удаляется из `gps_sources.yaml` (или остаётся `enabled: false`).
- При неоднозначности — `deferred`; source не включается в MVP, повторное review через 6 месяцев.
### 2. robots.txt
**ОТКРЫТО.** Прочитать `https://enduro-russia.ru/robots.txt` и зафиксировать выписку в этот раздел при completion.
Принимаемое правило:
- `Disallow: /treki/` или `Disallow: /` → source отклоняется автоматически.
- `Crawl-delay: N``rate_limit_sec` в конфиге выставляется не меньше N.
- Отсутствие robots.txt — трактуется как «нет явного запрета» (но не «явное разрешение» — см. §1).
### 3. Условия публикации чужих треков
**ОТКРЫТО.** Установить:
- Какая лицензия применяется к user-generated content на платформе.
- Указано ли в ToS, что платформа предоставляет автору право выкладывать на других площадках.
- Содержат ли GPX-метаданные явный copyright notice/CC-лицензию автора.
Если лицензия не CC-by или совместимая → сохраняем **только** геометрию и обезличенные поля; полей `user`, `name` автора, `description`**не сохраняем** (`save_user_field: false`, `save_description: false`).
### 4. Rate-limit
Предварительная установка (до получения данных §1§2):
- `rate_limit_sec: 5` (5 сек между запросами; консервативно).
- Per-source максимум на прогон — 1000 новых треков (BRD §6 риск трафика).
- User-Agent: `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` с контактным URL.
- Backoff на 429/503: exponential 2^n, 3 попытки.
- При 4 неудачных прогонах подряд — алерт в health-эндпоинт (TRZ REQ-F-12); оператор приостанавливает source вручную (`enabled: false`).
### 5. Метаданные, запрещённые к сохранению
**Default до §3 review** — сохраняем только:
- `external_id` (id записи на платформе).
- `external_url` (ссылка на страницу трека на платформе).
- `geom` (геометрия).
- `length_m`, `points_count` (производные).
- `activity_type` (категория с самой платформы → ACTIVITY_TYPES через `MAPPING`).
- `created_at` (дата трека, если публично доступна).
Не сохраняем без явного зелёного света §3:
- `user` (имя автора).
- `name` трека.
- `description`.
- Любые координаты waypoint, отдельные от основной геометрии (точки «домой»/«стоянка»).
### 6. Удаление по требованию автора
- Сохраняем `external_url` и `external_id` — это гарантирует точечное удаление по запросу.
- При полном пере-сборе pipeline записи, не найденные на источнике, помечаются как stale → удаляются GC-проходом.
- Реактивное удаление по issue — оператор через ssh: `DELETE FROM tracks WHERE external_urls_json LIKE '%<url>%'`.
### 7. Решение licensing
**Текущее: proposed (БЛОКИРОВАН).** Pipeline source `enduro_russia` находится в `gps_sources.yaml` как `enabled: false` (или отсутствует) пока этот ADR не переключён в `accepted`.
**Critical path для разблокировки:**
1. Аналитик/PO завершает §1§3 (получение/архивирование ответа от платформы).
2. Архитектор обновляет этот ADR: §1/§2/§3 заполнены, status → `accepted`, добавляются принятые параметры.
3. В `gps_sources.yaml` source переключается на `enabled: true`.
4. Следующий cron-прогон pipeline начинает собирать треки.
Без завершения шага 1 source **не включается** в MVP. Это соответствует BRD §4 «Источник без явного зелёного света в ADR — не включается».
## Решение (до review)
Source `enduro_russia` в `gps_sources.yaml` присутствует с `enabled: false`. Parser-модуль `src/api/gps_tracks/sources/enduro_russia.py` **разработан и протестирован** (TRZ REQ-F-05), но pipeline до accepted-status не загружает его.
Это даёт два полезных эффекта:
- Код парсера живёт в репозитории — review/security audit возможны до активации.
- Активация — однострочное изменение конфига после ADR-апрува, не требует деплоя кода.
## Последствия
### Положительные
- Юридическое условие BRD §4 выполняется автоматически: source не работает до явного разрешения.
- Тех-долг minimal: парсер уже написан и покрыт тестами с фикстурами; активация = один YAML-флаг.
### Отрицательные / ограничения
- BRD-метрика «≥ 3 источника в продакшне» **не закрыта**, пока этот ADR не accepted. На MVP — закроется через OSM (ADR-009) + ttrails (ADR-011) при условии что любой из этих двух или этот один достигнет accepted.
- Затягивание review = source не виден пользователю. Это сознательный compromise: лучше задержать фичу, чем нарушить ToS.
## Классификация изменения
**Minor change** на уровне ADR; **blocking** на уровне MVP-метрики «≥ 3 источника».
## Связанные документы
- `docs/work-items/ET-008/01-brd.md` §4 «Юридический минимум»
- `docs/work-items/ET-008/02-trz.md` REQ-F-05
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6 (runtime-guard)
- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md` (паттерн ADR licensing для сравнения)
- `docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md`
- `docs/work-items/ET-008/10-tech-risks.md` R-9

View File

@@ -0,0 +1,91 @@
---
type: adr
work_item_id: ET-008
adr_id: ADR-011
title: "ADR-011: Источник Тропинки.ру / ttrails.ru — БЛОКИРОВАН до завершения ToS/robots-ревью; pipeline пропускает source до перехода status=accepted"
status: proposed
created_at: 2026-06-01
authors:
- "agent:architect"
supersedes: []
superseded_by: []
labels:
- "ET-008:source-licensing"
- "blocking"
---
# ADR-011 — ttrails.ru (Тропинки.ру): licensing review (БЛОКИРУЮЩИЙ)
## Статус
**Proposed** — заблокирован до полного review.
> Pipeline (`scripts/gps_collect.py`) при загрузке `ttrails` parser **проверяет** этот ADR. Пока `status: proposed` — source пропускается с записью `pipeline_runs.status = "skipped_license"`. См. ADR-007 §6 — licensing guard.
## Контекст
Источник `ttrails.ru` (Тропинки.ру, эндуро-категория) — публичная платформа с GPX-загрузками без авторизации (BRD §4 #3). Структурно повторяет случай EnduroRussia.ru (ADR-010): не имеет документированного API, доступ через HTML-страницы + ссылки на GPX-файлы.
Принципы и чеклист — те же, что в ADR-010. Здесь — только специфика ttrails.
## Чеклист по BRD §4
### 1. ToS источника по поводу скрейпинга / массовой загрузки
**ОТКРЫТО.** Аналогично ADR-010 §1:
- Найти и архивировать ToS платформы (`ttrails.ru/about`, `/agreement` или эквивалент).
- При отсутствии разрешения — связаться с администратором, получить письменный ответ.
### 2. robots.txt
**ОТКРЫТО.** Прочитать `https://ttrails.ru/robots.txt`, зафиксировать выписку.
### 3. Условия публикации чужих треков
**ОТКРЫТО.** Установить лицензию user-generated content. Default — пока не подтверждено иное:
- Сохраняем только обезличенные поля (геометрия, length, points_count, activity_type, created_at если публично доступна).
- Не сохраняем `user`, `name`, `description`.
### 4. Rate-limit
Предварительная установка:
- `rate_limit_sec: 5` (консервативно).
- Per-source максимум на прогон — 1000 треков.
- User-Agent: `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)`.
- Backoff на 429/503 — exponential 2^n, 3 попытки.
### 5. Метаданные, запрещённые к сохранению
Default — как ADR-010 §5. Пересмотр после §3 review.
### 6. Удаление по требованию автора
- `external_url` + `external_id` сохраняются → точечное удаление по запросу автора.
- Stale-GC — отдельный work item.
### 7. Решение licensing
**Текущее: proposed (БЛОКИРОВАН).** Source `ttrails` в `gps_sources.yaml` остаётся `enabled: false` или отсутствует.
**Critical path для разблокировки:** см. ADR-010 §7.
## Решение (до review)
Source `ttrails` в `gps_sources.yaml` присутствует с `enabled: false`. Parser-модуль `src/api/gps_tracks/sources/ttrails.py` разрабатывается и тестируется (TRZ REQ-F-06), но не активен.
## Последствия
См. ADR-010 §«Последствия». Идентичная логика.
## Классификация изменения
**Minor change** на уровне ADR; **blocking** на уровне MVP-метрики «≥ 3 источника» (вместе с ADR-010).
## Связанные документы
- `docs/work-items/ET-008/01-brd.md` §4 «Юридический минимум»
- `docs/work-items/ET-008/02-trz.md` REQ-F-06
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6 (licensing guard)
- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md` (паттерн ADR licensing accepted)
- `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md` (одно-к-одному паттерн)
- `docs/work-items/ET-008/10-tech-risks.md` R-9

View File

@@ -0,0 +1,323 @@
---
type: infra-requirements
work_item_id: ET-008
title: "Инфраструктурные требования — ET-008: GPS-треки с публичных платформ"
version: 1
status: approved
created_at: 2026-06-01
authors:
- "agent:architect"
---
# Инфраструктурные требования — ET-008
## 1. Резюме
В отличие от ET-007 (только-фронтенд), ET-008 — **серверная фича со scheduled-pipeline**. Изменения охватывают:
- Новый docker-compose service `gps-collector` (тот же образ, что `app`, с `profiles: [batch]`).
- Новый файл БД на mva154: `data/gps_tracks.sqlite` (≤ 2 ГБ).
- Новая cron-запись на хосте mva154.
- Новый каталог логов `/var/log/enduro-trails/`.
- Новые Python-зависимости в общем образе: `defusedxml`, `pyyaml`.
- Новые исходящие HTTPS-вызовы из контейнера `gps-collector` к 13 внешним источникам.
Все изменения помещаются в существующий docker-compose стек без введения новых контейнеров API/нового reverse-proxy/новой БД-движка. Эскалация: `arch:major-change` (см. ADR-005, ADR-007).
## 2. Контейнеры и сервисы
| Аспект | Требование |
|---|---|
| Новый сервис `app` (FastAPI) | Не вводится; существующий API расширяется новыми routes `/api/gps-tracks/*` через регистрацию роутера из `src/api/gps_tracks/endpoint.py` |
| Новый сервис `gps-collector` | **Да.** docker-compose service, `profiles: ["batch"]`, тот же `build: .`, command `python -m scripts.gps_collect`, `restart: "no"`. Не стартует штатно при `docker compose up -d`. Активируется только запуском `docker compose --profile batch run --rm gps-collector` |
| Изменение `Dockerfile` | `COPY scripts/ ./scripts/`, `COPY config/ ./config/`. Текущий Dockerfile (`COPY src/api/ src/api/`, `COPY src/web/ src/web/`) не содержит `scripts/` и `config/` — нужно добавить две `COPY`-строки |
| Новый блок в `docker-compose.yml` | ≈ 15 строк (см. ADR-007 §1) |
| Изменения OSRM, nginx | Нет |
| Перезапуск API после деплоя | Нужен (новые routes регистрируются при старте FastAPI) — стандартный `docker compose up -d --no-deps app` |
| Простой API | ≤ 5 секунд (рестарт контейнера API). Pipeline-сервис independent — его запуск/остановка не аффектит API |
### 2.1 Зависимости между сервисами
- `gps-collector` **не** имеет `depends_on: [app]`. Он работает с БД-файлом напрямую через примонтированный volume `/app/data`.
- В конце прогона pipeline дёргает HTTP `POST http://app:5556/api/gps-tracks/cache/clear` (внутренняя docker-сеть). Если `app` недоступен — pipeline пишет WARNING в лог, успех прогона не отменяется (ADR-007 §7).
- Сетевое имя `app` доступно потому что оба сервиса в одной default-сети docker-compose.
### 2.2 Конфликт с production API во время прогона
- Pipeline пишет в `data/gps_tracks.sqlite` в WAL-mode (ADR-005 §5). API читает ту же БД — видит снэпшот checkpoint'а; конкуренция не блокирует читателей.
- CPU/RAM: pipeline ограничен через docker-cgroup limits (см. §9 ниже). Параллельный API не деградирует.
## 3. Сеть
| Аспект | Требование |
|---|---|
| Новые серверные порты на mva154 | Нет |
| Изменения reverse proxy (`/enduro/` в nginx) | **Минимальные.** Новые routes `/api/gps-tracks/*` уже попадают под существующий `location /api/` proxy_pass. Дополнительных правил не нужно |
| Внутренние DNS / docker-сеть | Стандартная default-сеть docker-compose. Service-name `app` резолвится в адрес API-контейнера; используется pipeline для cache-clear |
| **Endpoint `POST /api/gps-tracks/cache/clear`** | **Ограничен docker-internal**: блок `RealIPFromTrustedProxy` в nginx (proxy mva154) **не пропускает** `POST` на этот endpoint извне. Деталь: в nginx-конфиге `location = /api/gps-tracks/cache/clear { allow 172.0.0.0/8; deny all; }` — допуск только из docker-сетей |
| Новые исходящие HTTPS-вызовы из mva154 | **Да.** Из контейнера `gps-collector`:<br>• `api.openstreetmap.org` (ADR-009) — всегда;<br>• `enduro-russia.ru` (ADR-010) — пока accepted;<br>• `ttrails.ru` (ADR-011) — пока accepted |
| Firewall mva154 | Исходящие HTTPS уже разрешены (BRD §7); правил не добавляется |
| Внешние входящие | Только существующий `/enduro/` через nginx — без изменений |
### 3.1 Ограничение cache-clear
Cache-clear endpoint **должен быть закрыт от внешнего интернета** (он сбрасывает производительный кэш, потенциальный DoS-вектор). Реализация:
```nginx
# /etc/nginx/sites-available/openclaw — добавляется в существующий server { } для /enduro/
location = /enduro/api/gps-tracks/cache/clear {
allow 172.16.0.0/12; # docker default networks
allow 127.0.0.1;
deny all;
proxy_pass http://app:5556/api/gps-tracks/cache/clear;
}
```
Pipeline дёргает endpoint напрямую через docker-сеть (`http://app:5556/...`), не через nginx → реальный путь обходит правило allow/deny и работает. Snippet выше защищает только публичный путь через `/enduro/`.
## 4. Хранилища данных
| Аспект | Требование |
|---|---|
| Новая БД | `data/gps_tracks.sqlite` (SQLite + Spatialite extension) |
| Расположение на хосте | `/home/slin/enduro-trails/data/gps_tracks.sqlite` (`./data` в `docker-compose.yml`) |
| Расположение в контейнерах | `/app/data/gps_tracks.sqlite` |
| Создание | Pipeline создаёт при первом запуске; миграция `migrations/gps_tracks_001_init.sql` применяется автоматически (см. §4.2) |
| Размер | Ожидаемо ≤ 500 МБ для ЦФО+Чувашии при 5000 треков; верхний предел операционный — **2 ГБ** (REQ-NF-03). Алерт > 2 ГБ — см. `10-tech-risks.md` R-4 |
| Spatialite-extension | Уже доступен в python-образе через `pysqlite3-binary`? Нет: текущий образ использует stdlib `sqlite3`. Нужно установить системный пакет `libsqlite3-mod-spatialite` (см. §4.3) |
| Изменения схемы существующей `centralfederal.sqlite` | Нет |
| Миграции существующих таблиц | Нет |
### 4.1 Зачем отдельная БД
См. ADR-005 §«Решение D-A». Изоляция backup-цикла, ротации, риска повреждения, write-конкуренции.
### 4.2 Миграция
`migrations/gps_tracks_001_init.sql` — IDempotent CREATE TABLE IF NOT EXISTS + R-tree creation. Применяется автоматически из `src/api/gps_tracks/db.py::ensure_schema()` при первом коннекте (ленивая инициализация). Никакого `alembic` или внешнего раннера миграций.
### 4.3 Установка Spatialite в Docker-образе
Изменение `Dockerfile`:
```dockerfile
FROM python:3.12-slim
WORKDIR /app
# ET-008: Spatialite extension для slot.api.gps_tracks.db
RUN apt-get update && apt-get install -y --no-install-recommends \
libsqlite3-mod-spatialite \
&& rm -rf /var/lib/apt/lists/*
COPY src/api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/api/ ./src/api/
COPY src/web/ ./src/web/
COPY scripts/ ./scripts/ # ET-008
COPY config/ ./config/ # ET-008
ENV STATIC_DIR=/app/src/web
ENV PORT=5556
EXPOSE 5556
CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "5556"]
```
Образ увеличится на ≈ 30 МБ (модуль Spatialite). На размер production-нагрузки не влияет.
### 4.4 Backup
- **Ежедневный snapshot** через cron на mva154:
```cron
0 5 * * * root sqlite3 /home/slin/enduro-trails/data/gps_tracks.sqlite ".backup /home/slin/enduro-trails/backups/gps_tracks-$(date +\%F).sqlite"
```
- Retention 14 дней — отдельный `find ... -mtime +14 -delete`.
- Pipeline-running во время backup допустим: `.backup` в sqlite3 — атомарный, использует WAL.
- Восстановление: остановить `gps-collector` запуски, `cp` snapshot в `data/gps_tracks.sqlite`, перезапустить API (cache-clear автоматически).
### 4.5 Клиентское хранилище
| Ключ localStorage | Значение | Default |
|---|---|---|
| `gps-tracks-enabled` | `"true"` \| `"false"` | `"false"` |
| `gps-tracks-activities` | JSON-array | все ACTIVITY_TYPES |
| `gps-tracks-sources` | JSON-array | все enabled source IDs |
| `gps-tracks-color-mode` | `"source"` \| `"activity"` | `"source"` |
Суммарный объём ≤ 256 байт. Конвенция имён согласуется с существующими (`enduro-theme-mode`, `terrain-*`, `trails-*`, `map-base-layer`).
Подробности — `08-data-requirements.md` §4.
## 5. Конфигурация и секреты
| Аспект | Требование |
|---|---|
| Новые env-переменные API-контейнера | `GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite` |
| Новые env-переменные gps-collector | `GPS_TRACKS_DB_PATH`, `GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml`, `GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml` |
| Новые секреты / API-ключи | **Нет** — все источники без авторизации (см. ADR-009, ADR-010, ADR-011 — outside source без ключа; платные API явно out of scope BRD §3) |
| Новые конфиг-файлы в репозитории | `config/gps_sources.yaml`, `config/gps_regions.yaml` — оба под git-контролем |
| Изменения reverse-proxy / nginx | Только cache-clear защита (§3.1) |
| Изменения OSRM | Нет |
## 6. Зависимости
| Аспект | Требование |
|---|---|
| Python-пакеты (`src/api/requirements.txt`) — добавить | `defusedxml==0.7.1` (безопасный XML-парсинг GPX), `pyyaml==6.0.1` (конфиги pipeline) |
| Python-пакеты — НЕ добавлять | `lxml` (упомянут в BRD §7 как опция; для GPX-парсинга достаточно `defusedxml.ElementTree`; экономит ≈ 8 МБ образа). `tenacity` — реализуем backoff inline (≈ 30 строк, TRZ §6.3) чтобы не вводить ещё один пакет |
| Системные библиотеки в Dockerfile | `libsqlite3-mod-spatialite` (см. §4.3) |
| Версия Python | 3.12, без изменений |
| Новые third-party runtime-зависимости (внешние сервисы) | • `api.openstreetmap.org` — OSM API (ADR-009)<br>• `enduro-russia.ru` — после ADR-010 accepted<br>• `ttrails.ru` — после ADR-011 accepted |
| Альтернативные источники / fail-over | Не закладывается; каждый source изолирован (ADR-007 §I-A); падение одного не валит других |
## 7. Сборка и деплой
- **Pipeline CI:** существующий Gitea Actions (`make lint` + `make test` + `make build`). Новые backend-tests (`tests/api/test_gps_tracks_*.py`) добавляются в существующий pytest. Новые frontend-tests — в существующий ESLint и JS-test pipeline.
- **Артефакт:** Docker-образ. После ET-008 один образ запускается **двумя сервисами** (`app` и `gps-collector` через `profiles`). Это стандартный паттерн docker-compose.
- **Деплой шаг-за-шагом:**
1. `git pull origin main` на mva154.
2. `docker compose build` (пересобирает образ с `libsqlite3-mod-spatialite`).
3. `docker compose up -d --no-deps app` (перезапускает только API; `gps-collector` profile-disabled).
4. Установить cron-запись (см. §8).
5. Первый ручной запуск pipeline в dry-run:
`docker compose --profile batch run --rm gps-collector python -m scripts.gps_collect --dry-run`
6. Проверить `/api/gps-tracks/health` — БД создана, пуста.
7. Запустить production-сбор:
`docker compose --profile batch run --rm gps-collector` (≤ 6 часов).
8. Smoke: открыть `/enduro/`, включить чекбокс «Публичные треки», убедиться что слой виден.
- **Время простоя API:** ≤ 5 секунд на шаге 3.
- **Время простоя pipeline:** не применимо — pipeline не daemon.
### 7.1 Cron-запись
`/etc/cron.d/enduro-gps` (root-owned, 0644):
```cron
# ET-008: GPS Tracks Pipeline
# Mon + Thu 03:00 UTC — full collection
0 3 * * 1,4 root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector >> /var/log/enduro-trails/gps-collect.log 2>&1
# 1-е число каждого месяца 04:00 UTC — GC stale tracks
0 4 1 * * root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector python -m scripts.gps_collect --gc >> /var/log/enduro-trails/gps-gc.log 2>&1
```
Никаких отдельных `flock` / `lockfile` — cron-окно (3 дня) > длительности прогона (≤ 6 ч).
### 7.2 Rollback
| Откат | Действие | Время |
|---|---|---|
| Откат кода (revert + redeploy) | `git revert <commit> && docker compose up -d --build app` | ≈ 2 мин |
| Откат БД (повреждение / неверная схема) | Остановить `gps-collector` cron, `cp backups/gps_tracks-<date>.sqlite data/gps_tracks.sqlite`, рестарт API | ≈ 1 мин |
| Полный отказ от фичи (kill switch) | Закомментировать cron-строки, удалить `gps-tracks-cb` checkbox в UI через `display:none` | ≈ 1 мин |
| Откат от pipeline без отката API | Закомментировать cron-строки — API продолжает отдавать собранное | мгновенно |
Скрипт `scripts/disable_gps_pipeline.sh` (TODO в `04-test-plan.yaml`) автоматизирует «kill switch».
## 8. Cron / scheduled jobs
См. §7.1.
**Мониторинг cron:**
- При сбое cron-job отправляется email на адрес администратора через стандартный `cron MAILTO=` (mva154 уже настроен). Опционально — алерт в Telegram, но это outside scope (если в проекте уже есть алерт-канал — используется он).
- `/api/gps-tracks/health` отдаёт `last_pipeline_run.sources_error` — оператор видит при ручной проверке/мониторинге.
## 9. Ресурсы (CPU / RAM / диск)
### 9.1 API-контейнер
- **CPU:** +5% от текущего baseline за счёт MVT-генерации нового слоя. На существующем mva154 (по BRD §1 одиночный сервер) — не критично.
- **RAM:** +50 МБ baseline (новые модули) + до 64 МБ LRU-кэш MVT-тайлов (1024 × ~64 КБ). Итого +120 МБ. Текущий API использует ≈ 200 МБ; после ET-008 — ≈ 320 МБ.
- **Network egress:** +0 (внутри сервера; клиент скачивает с того же mva154).
### 9.2 gps-collector контейнер (во время прогона)
- **CPU:** ограничен docker-compose cgroup `cpus: "1.0"` (один логический CPU) — pipeline не вытесняет API.
- **RAM:** ограничен `mem_limit: 512m`. На практике pipeline + asyncio + httpx + shapely + спарс одного парсера ≤ 200 МБ; запас 2.5×.
- **Network egress (mva154 → external):** для OSM ≈ 100 МБ за прогон (≤ 5000 треков × ≤ 20 КБ), для скрейпинга — порядок 10100 МБ. Полная стоимость cron-прогона ≈ 200 МБ / неделю — пренебрежимо.
- **Network ingress:** не применимо.
```yaml
# docker-compose.yml фрагмент
services:
gps-collector:
# ...
cpus: "1.0"
mem_limit: 512m
pids_limit: 256
```
### 9.3 Диск
- `data/gps_tracks.sqlite` — ≤ 2 ГБ.
- Лог-файлы `/var/log/enduro-trails/*.log` — ротация через logrotate, default 14 дней × ≤ 50 МБ = ≤ 700 МБ.
- Backup-снапшоты — ≤ 14 × 2 ГБ = ≤ 28 ГБ (с retention; см. §4.4).
- Сумма: + ≈ 30 ГБ на текущий disk-budget mva154.
## 10. Наблюдаемость
| Артефакт | Источник | Использование |
|---|---|---|
| `GET /api/gps-tracks/health` | API (читает `pipeline_runs` из БД) | Оператор проверяет вручную или через monitoring |
| `/var/log/enduro-trails/gps-collect.log` | Cron stdout/stderr | Лог cron-выполнений: успех/код возврата/исключения |
| `/var/log/enduro-trails/pipeline-<run_id>.jsonl` | Pipeline structured log | Per-run JSON-lines: source, region, статус, tracks_new |
| `pipeline_runs` в БД | Pipeline-side | Историческая трассировка для health-эндпоинта |
| Docker `docker compose logs app` | API stdout | Запросы `/api/gps-tracks/*`, ошибки SQL |
### 10.1 Алерты
- **Cron MAILTO** при ненулевом exit code прогона — стандартный механизм.
- **2 неудачных прогона подряд для одного source** — `pipeline_runs` собирает; алерт **не автоматический** (out of MVP), оператор увидит при ручной проверке `/health` или в weekly review. Алерт-канал — отдельный work item.
- **db_size_mb > 2 ГБ** — health отдаёт значение; внешний мониторинг (если есть) пинает.
- **Ошибка лицензионного guard'а** (`status: "skipped_license"`) — оператор видит в `pipeline_runs`; не алерт-кейс, нормальное поведение до accepted-ADR.
### 10.2 Logrotate
```
# /etc/logrotate.d/enduro-gps
/var/log/enduro-trails/*.log {
daily
rotate 14
compress
missingok
notifempty
}
/var/log/enduro-trails/pipeline-*.jsonl {
weekly
rotate 8
compress
missingok
notifempty
}
```
## 11. Безопасность
- **Парсинг XML на сервере (GPX)** — через `defusedxml.ElementTree` (защита XXE / billion laughs). `lxml` не используется.
- **Endpoint `POST /api/gps-tracks/cache/clear`** — ограничен docker-internal сетью на уровне nginx (§3.1). Pipeline ↔ API остаются связаны через docker-сеть.
- **Скрейпинг — только outgoing** с mva154. Никаких open ports.
- **Атаки на pipeline через подделанные GPX** (источник вернул malformed XML, exploding XML) — митигируется `defusedxml` и timeout `httpx.get(timeout=30)`. Per-track exception isolated в pipeline-loop.
- **CSP-заголовок** — в проекте отсутствует (см. ET-007 §3.2). ET-008 ничего не меняет.
## 12. Влияние на C4 / архитектурную документацию
Изменения состава компонентов:
- **Новый компонент** в стеке mva154: docker-compose service `gps-collector` (batch).
- **Новая БД** `data/gps_tracks.sqlite`.
- **Новые внешние зависимости рантайма**: 13 платформы (OSM всегда + 0/1/2 после ADR-010/011).
- **Новые scheduled-jobs**: 2 cron-записи.
`docs/architecture/README.md` обновляется новым разделом «GPS Tracks Pipeline (ET-008)» с описанием компонента, БД, внешних зависимостей и расписания.
`docs/architecture/adr/README.md` пополняется записями ADR-005..ADR-011.
C4 mmd-диаграмм в проекте нет — текстовое описание (по прецеденту ADR-004 §8).
## 13. Вывод
ET-008 — **major-change** на инфра-уровне:
- Новый docker-compose service.
- Новый файл БД.
- Первые scheduled jobs (cron) на mva154.
- Новые исходящие сетевые соединения с обязательными licensing-ADR.
Все элементы — расширение существующего стека (не новый stack). Реверсная процедура и rollback — однострочные операции.
Эскалация: лейбл `arch:major-change` выставлен на ADR-005 и ADR-007. Архитектурный approve обязателен перед merge.

View File

@@ -0,0 +1,382 @@
---
type: data-requirements
work_item_id: ET-008
title: "Требования к данным — ET-008: GPS-треки с публичных платформ"
version: 1
status: approved
created_at: 2026-06-01
authors:
- "agent:architect"
---
# Требования к данным — ET-008
## 1. Резюме
ET-008 вводит:
- **Новую серверную БД** `data/gps_tracks.sqlite` (Spatialite) с двумя таблицами: `tracks`, `pipeline_runs`.
- **Контракт публичного API GeoJSON** и **MVT layer schema** (см. TRZ §4.2, §4.3 — здесь финализируется).
- **Внешние входные данные** — GPS-треки с 13 публичных платформ.
- **Клиентское хранилище** (`localStorage`) — 4 новых ключа состояния UI.
- **Персональные данные**: возможно `user` (имя автора публичного трека) для OSM (ADR-009 разрешает); для других источников — пока заблокировано (ADR-010, ADR-011).
## 2. Архитектурные границы данных
| Слой данных | Тип | Расположение | Владелец | Lifecycle |
|---|---|---|---|---|
| OSM-vector (`trails`, `centralfederal.sqlite`) | существующий | `/app/data/centralfederal.sqlite` | ET-001..006 | пересборка из OSM ad-hoc |
| Личные GPX треки (ET-006) | существующий | браузер (memory only) | ET-006 | сессия |
| **Публичные GPS треки** | **новый** | `/app/data/gps_tracks.sqlite` | **ET-008** | rebuild при необходимости + ежемесячный GC |
| OSRM-граф | существующий | `/app/data/enduro.osrm.*` | PH-2 | пересборка после OSM-обновления |
| User UI state | существующий + расширение | `localStorage` браузера | каждый work item | до явной очистки |
Между новой БД и существующей `centralfederal.sqlite` **нет cross-DB запросов** на горизонте MVP (см. ADR-005 §9).
## 3. Серверные данные — `gps_tracks.sqlite`
### 3.1 Таблица `tracks`
```sql
CREATE TABLE tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dedup_key TEXT NOT NULL UNIQUE,
name TEXT,
description TEXT,
activity_type TEXT NOT NULL, -- ACTIVITY_TYPES (см. §3.4)
user TEXT, -- ADR-009 разрешает; null для ADR-010/011 до accepted
created_at TEXT, -- ISO date YYYY-MM-DD; nullable
length_m REAL NOT NULL,
points_count INTEGER NOT NULL,
min_lon REAL NOT NULL,
min_lat REAL NOT NULL,
max_lon REAL NOT NULL,
max_lat REAL NOT NULL,
geom BLOB NOT NULL, -- WKB LineString (Spatialite)
sources_json TEXT NOT NULL, -- JSON-array ["osm", "enduro_russia"]
external_urls_json TEXT NOT NULL, -- JSON-array URLs
tags_json TEXT, -- JSON-array string tags
inserted_at TEXT NOT NULL, -- ISO datetime
updated_at TEXT NOT NULL -- ISO datetime
);
CREATE UNIQUE INDEX idx_tracks_dedup ON tracks(dedup_key);
CREATE INDEX idx_tracks_activity ON tracks(activity_type);
CREATE INDEX idx_tracks_created ON tracks(created_at);
-- Spatialite R-tree
SELECT CreateSpatialIndex('tracks', 'geom');
```
Поля `min_lon`/`max_lon`/`min_lat`/`max_lat` денормализованы из `geom` для **раннего отбрасывания** треков в MVT-генерации без парсинга WKB (ADR-005 §2).
### 3.2 `dedup_key`
Алгоритм — ADR-006. Формат строки:
```
((w, s, e, n), length_bucket, "YYYY-MM-DD")
```
где координаты округлены до 2 знаков после запятой, `length_bucket` = `round(length_m / 1000) * 1000`. UNIQUE индекс обеспечивает ON CONFLICT логику.
### 3.3 `sources_json` и `external_urls_json`
JSON-массивы строк. Длина ≤ 8 элементов (источников после дедупа). Порядок — стабильный по приоритету в `gps_sources.yaml`. Первый элемент `sources_json` — «первичный» источник; его id попадает в `properties.source` MVT-фичи для цветовой палитры по умолчанию (REQ-F-16).
Пример:
```json
sources_json = ["osm", "enduro_russia"]
external_urls_json = ["https://www.openstreetmap.org/user/Vasya/traces/12345",
"https://enduro-russia.ru/treki/678"]
```
Запись фиксирует **тот же индекс** = тот же источник: `external_urls_json[i]` — это URL `sources_json[i]`.
### 3.4 ACTIVITY_TYPES
Закрытый enum (TRZ REQ-F-07):
| code | label-ru |
|---|---|
| `enduro` | Эндуро |
| `moto` | Мото |
| `offroad` | Off-road |
| `bicycle` | Велосипед |
| `hike` | Пешком |
| `ski` | Лыжи |
| `other` | Другое |
`MAPPING` per source — константа в `<source>.py`. Категории источника, не найденные в MAPPING → `other`. На MVP `MAPPING` для OSM фиксирован: парсим OSM-tags (`tag: enduro``enduro`, `tag: motorbike``moto`, `tag: mtb`/`tag: bike``bicycle`, etc.). Точная таблица — в коде, ревью при ADR-апруве.
### 3.5 Таблица `pipeline_runs`
```sql
CREATE TABLE pipeline_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
finished_at TEXT,
region_id TEXT NOT NULL,
source_id TEXT NOT NULL,
status TEXT NOT NULL, -- ok | partial | error | skipped_license
tracks_new INTEGER DEFAULT 0,
tracks_updated INTEGER DEFAULT 0,
errors_json TEXT -- JSON object {error_type: count}
);
CREATE INDEX idx_pipeline_started ON pipeline_runs(started_at);
```
История прогонов. Read-only для API; пишет только pipeline. Используется `/api/gps-tracks/health`.
### 3.6 Размер БД
| Объём | Оценка |
|---|---|
| Среднее число точек на трек | 1240 (по BRD §3 F-13 popup; реалистично) |
| Геометрия WKB на трек | ≈ 16 байт/точка × 1240 = 20 КБ |
| Метаданные на трек | ≈ 1 КБ |
| Итого на трек | ≈ 21 КБ |
| 5000 треков MVP | ≈ 105 МБ |
| 50 000 треков (через год при расширении) | ≈ 1.05 ГБ |
| Лимит REQ-NF-03 | 2 ГБ |
Запас 2× от MVP-объёма до операционного лимита. При превышении — миграция на PostGIS (отдельный work item, тех-долг в ADR-005).
### 3.7 Ротация и GC
- Команда `python -m scripts.gps_collect --gc` (ADR-007 §3) — удаляет треки `WHERE updated_at < NOW() - 5 years`.
- Параметр `5 years` зашит в `config/gps_sources.yaml::retention_years` (default 5; per-source override возможен).
- Cron — 1-е число каждого месяца 04:00 UTC.
- Stale-cleanup (трек удалён на источнике) — отдельный GC-режим `--gc-stale`; на MVP не входит (см. ADR-009 §6).
### 3.8 Backup
См. `07-infra-requirements.md` §4.4. Ежедневный `.backup`, retention 14 дней.
## 4. Клиентское хранилище
| Ключ | Значение | Default | Расход |
|---|---|---|---|
| `gps-tracks-enabled` | `"true"` \| `"false"` | `"false"` | ≤ 5 байт |
| `gps-tracks-activities` | JSON-array из ACTIVITY_TYPES | все 7 значений | ≤ 70 байт |
| `gps-tracks-sources` | JSON-array source IDs | все enabled на момент первого открытия | ≤ 80 байт |
| `gps-tracks-color-mode` | `"source"` \| `"activity"` | `"source"` | ≤ 8 байт |
| **Итого на браузер** | | | ≤ 256 байт |
- **Чтение**: `restorePublicTracksState()` в `rebuildMapOverlays()` (REQ-F-19); инициализация при старте приложения.
- **Запись**: каждое изменение checkbox / segmented control в `#sheet-gps-filters`.
- **Миграция со старых значений**: не требуется (ключи новые).
- **Невалидные значения**: ignore + restore defaults; не вызывают исключение.
### 4.1 Конвенция имён
Префиксация — `gps-tracks-*`. Согласуется с существующими (`terrain-*`, `trails-*`, `map-base-layer`).
### 4.2 Не-персистентное состояние в памяти браузера
```js
window.gpsTracksLayer = {
enabled: false,
filters: {
activities: [...ACTIVITY_TYPES],
sources: [...enabledSourceIds],
colorMode: 'source'
},
sourceId: 'gps-tracks-tiles', // vector source for MVT mode
sourceGeoId: 'gps-tracks-geo', // geojson source for GeoJSON mode
layerMvtId: 'gps-tracks-layer-mvt',
layerGeoId: 'gps-tracks-layer-geo',
haloMvtId: 'gps-tracks-halo-mvt-satellite',
haloGeoId: 'gps-tracks-halo-geo-satellite',
geojsonAbortController: null,
geojsonReqDebounceTimer: null,
stats: { total: 0, shown: 0 },
activeMode: 'mvt' | 'geo' | 'hidden' // derived from zoom
};
```
Конкретное содержимое и переходы — TRZ §4.4 + ADR-008.
## 5. Внешние входные данные
### 5.1 OSM Public GPS Traces (ADR-009)
| Параметр | Значение |
|---|---|
| Endpoint | `GET https://api.openstreetmap.org/api/0.6/trackpoints?bbox=...&page=...` |
| Metadata | `GET https://api.openstreetmap.org/api/0.6/gpx/{id}` |
| Формат | XML (GPX 1.1) — `<trkpt>` + `<wpt>` + meta |
| Лицензия | ODbL 1.0 |
| Атрибуция | `© OpenStreetMap contributors (ODbL)` |
| Rate-limit | 1 req/sec (per OSM policy) |
| Объём для ЦФО+Чувашии (оценка) | ≈ 50 000100 000 точек, ≈ 1 0005 000 треков |
| User-Agent | `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` |
### 5.2 EnduroRussia.ru (ADR-010 — БЛОКИРОВАН)
До accepted-status — pipeline пропускает.
### 5.3 ttrails.ru (ADR-011 — БЛОКИРОВАН)
До accepted-status — pipeline пропускает.
## 6. Контракт публичного API
### 6.1 `GET /api/gps-tracks`
**Query params:**
| Параметр | Тип | Обязательность | Default | Валидация |
|---|---|---|---|---|
| `bbox` | 4 float comma-separated | required | — | -180 ≤ lon ≤ 180, -85 ≤ lat ≤ 85, west < east, south < north, площадь ≤ 10 deg² |
| `activity` | comma-string из ACTIVITY_TYPES | optional | all | каждое значение — известный enum |
| `source` | comma-string source IDs | optional | all enabled | значения сверяются с `gps_sources.yaml` |
| `limit` | int | optional | 500 | 1 ≤ limit ≤ 2000 |
**Response 200 (`Content-Type: application/json`):**
```json
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": 12345,
"geometry": {
"type": "LineString",
"coordinates": [[lon, lat], ...]
},
"properties": {
"name": "Утренний эндуро",
"activity_type": "enduro",
"user": "Vasya",
"created_at": "2024-05-12",
"length_km": 47.3,
"points_count": 1240,
"sources": ["osm", "enduro_russia"],
"external_urls": ["https://...", "https://..."],
"tags": ["forest", "river"]
}
}
],
"total_in_bbox": 743,
"returned": 500,
"truncated": true
}
```
**Error responses:**
| Code | Условие |
|---|---|
| 400 | невалидный bbox / activity / source / limit |
| 503 | БД отсутствует или Spatialite не загрузился |
### 6.2 `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt`
**Path params:** `z` 0..18, `x`/`y` валидны для z.
**Response:**
- 200 `Content-Type: application/x-protobuf`, тело — `mapbox-vector-tile`-encoded MVT.
- 200 + пустое тело — если в тайле нет треков.
- 304 — стандартная HTTP cache на ETag (опционально, MVP — не реализуется).
- Header `X-Cache: HIT | MISS` — для observability.
**Layer schema:**
| Layer | Geometry | Properties |
|---|---|---|
| `gps_tracks` | LineString | `id (int)`, `activity (string)`, `source (string, первый)`, `sources (string, comma-separated)`, `length_km (float)`, `name (string)`, `ext_url (string, первый)` |
Properties — упрощены под MVT-ограничения (нет массивов).
### 6.3 `GET /api/gps-tracks/health`
**Response 200:**
```json
{
"db_path": "/app/data/gps_tracks.sqlite",
"db_size_mb": 124.5,
"tracks_total": 8421,
"tracks_by_source": {"osm": 5234, "enduro_russia": 2102, "ttrails": 1085},
"tracks_by_activity": {"enduro": 2104, "moto": 850, "offroad": 305, "bicycle": 3201, "hike": 1810, "other": 151},
"last_pipeline_run": {
"started_at": "2026-05-30T03:00:00Z",
"finished_at": "2026-05-30T05:14:00Z",
"regions": ["tsfo_plus_chuvashia"],
"sources_ok": ["osm"],
"sources_error": [{"source": "ttrails", "error": "HTTP 503"}],
"sources_skipped_license": ["enduro_russia"]
},
"tile_cache_size": 412,
"tile_cache_max": 1024
}
```
**Response 503:** если БД отсутствует или Spatialite не доступен.
### 6.4 `POST /api/gps-tracks/cache/clear`
**Auth:** ограничен docker-internal сетью (`07-infra-requirements.md` §3.1).
**Response 200:**
```json
{"cleared": 412}
```
Запрос идемпотентен, вызывается только pipeline'ом в конце прогона.
## 7. Персональные данные (PII)
| Канал | PII | Условия |
|---|---|---|
| `tracks.user` (имя автора) | да, **публичное** имя | сохраняется **только** если ADR соответствующего источника явно разрешает (`save_user_field: true` в `gps_sources.yaml`). По ADR-009 OSM — разрешено. ADR-010, ADR-011 — пока запрещено |
| `tracks.geom` (координаты трека) | низкий риск; **публично выложенные** автором | сохраняются всегда |
| `tracks.created_at` | дата проезда | публичная; сохраняется всегда |
| `tracks.description`, `tracks.tags` | возможные следы PII в свободном тексте | сохраняются только при `save_description: true` в конфиге источника |
| Запросы к `api.openstreetmap.org` (исходящие с mva154) | IP **сервера mva154**, не клиента | да, mva154-IP становится известен OSM (стандартное поведение для скрейпера) |
| Запросы к `enduro-russia.ru`, `ttrails.ru` | то же | пока ADR не accepted — не происходит |
| `localStorage['gps-tracks-*']` | UI-настройки | нет PII |
### 7.1 Право на удаление
- Запись `external_urls_json` сохраняет ссылку на оригинал — оператор может удалить конкретную запись по запросу автора (`DELETE FROM tracks WHERE external_urls_json LIKE '%<url>%'`).
- Pipeline уважает «удалённое на источнике» при `--gc-stale` (post-MVP).
### 7.2 GDPR / РФ ФЗ-152
- ET-008 обрабатывает **только публично опубликованные** автором данные.
- Имя автора (`user`) — публичное на платформе источника (по ADR-009, ADR-010 для OSM/EnduroRussia это публикуется на странице трека).
- Контактные данные (email, телефон) — **не сохраняются ни при каких условиях**; платформы их не отдают в публичных GPX-эндпоинтах.
- Локация «дом»/«работа» как отдельная точка интереса — не сохраняется (waypoints без public-флага в OSM не отдаются; для скрейпленых источников — `save_waypoints: false`).
- DPO-ответственность minimal — нет сервиса регистрации/учёта пользователей; это публичный read-only слой.
## 8. Атрибуция
Обязательное требование BRD §5 «Атрибуция» и AC-15:
- **На карте**: MapLibre автоматически отображает `attribution` из source-spec в правом нижнем углу. Каждый source (`gps-tracks-tiles`, `gps-tracks-geo`) указывает `attribution: "© OSM contributors (ODbL) | EnduroRussia.ru | ttrails.ru"` — динамически сформированную клиентом из `/api/gps-tracks/health.tracks_by_source` (только активные источники).
- **В popup трека**: ссылки на оригинал по `external_urls_json` (REQ-F-18).
- **В docs/architecture/README.md**: новый раздел «GPS Tracks Pipeline» содержит таблицу источников и их атрибуций.
## 9. Backup и retention
| Объект | Backup | Retention |
|---|---|---|
| `data/gps_tracks.sqlite` | Ежедневный `.backup` через cron на mva154 | 14 дней |
| `pipeline_runs` (внутри той же БД) | через backup БД | вечно (растёт медленно, ≤ 10⁴ строк/год) |
| `tracks` старше 5 лет | удаляются при `--gc` | retention configurable в `gps_sources.yaml` |
| `/var/log/enduro-trails/*.log` | через logrotate | 14 дней |
| Pipeline JSON-lines logs | через logrotate | 8 недель |
## 10. Контракты, которые **нельзя ломать**
1. `dedup_key` формула (ADR-006 §6) — менять можно только при полном rebuild БД.
2. `ACTIVITY_TYPES` enum — добавление новых значений требует UI-обновления (новый цвет, новая локализация); удаление — миграция существующих треков.
3. GeoJSON response shape (§6.1) — public API, ломающие изменения через v2-endpoint.
4. MVT layer name `gps_tracks` и properties (§6.2) — клиент завязан; ломающие — через новый layer-name.
5. `localStorage` keys (§4) — менять имя ключа требует миграцию (`gps-tracks-enabled-v2`).
## 11. Вывод
Серверная модель данных полностью локализована в `data/gps_tracks.sqlite`. Контракты API и MVT-схема финализированы. Клиентское хранилище — 256 байт UI-state. Персональные данные минимизированы по дизайну: только публичные поля от accepted-источников; default-deny для не-accepted.

View File

@@ -0,0 +1,209 @@
---
type: tech-risks
work_item_id: ET-008
title: "Технические риски — ET-008: GPS-треки с публичных платформ"
version: 1
status: approved
created_at: 2026-06-01
authors:
- "agent:architect"
---
# Технические риски — ET-008
Технические риски этапа разработки и эксплуатации. Бизнес-риски — в BRD §6 (пересечение есть, здесь акцент на технические митигации). Шкала: вероятность (Н/С/В) × влияние (Н/С/В).
## R-1 — Парсер источника ломается при изменении HTML
- **Описание:** ADR-010/011 источники (`enduro_russia`, `ttrails`) скрейпят HTML-страницы. Платформа может в любой момент изменить разметку (новый шаблон, JS-rendering) → парсер перестаёт извлекать треки.
- **Вероятность / Влияние:** В / С.
- **Митигация:**
- Каждый source в отдельном модуле (`src/api/gps_tracks/sources/<name>.py`); падение одного не валит других (ADR-007 §I-A).
- Pipeline пишет `status=error` в `pipeline_runs`; оператор видит через `/api/gps-tracks/health`.
- Параметризированные тесты с фикстурами HTML-снапшота — при первом упавшем прогоне разработчик обновляет фикстуру и парсер за 1 итерацию.
- При двух неудачных прогонах подряд — алерт (`07-infra-requirements.md` §10.1). На MVP — ручная проверка.
- Конфиг `gps_sources.yaml::enabled: false` — мгновенное отключение источника без deploy.
## R-2 — Ложные коллизии дедупа
- **Описание:** ADR-006 алгоритм `bbox+length+date bucket` детерминированно мерджит треки с похожими параметрами. На треках без `created_at` (от источников без даты) — гарантированный merge всех таких треков в одном bbox/length. На дата-датасете — возможны коллизии для популярных маршрутов (двое разных гонщиков проехали тот же 30-км круг в один день).
- **Вероятность / Влияние:** С / С.
- **Митигация:**
- BRD §5 фиксирует допустимую метрику «< 5% дублей»; QA-скрипт `scripts/dedup_audit.py` проверяет на выборке 100 треков (`04-test-plan.yaml`).
- При провале метрики — план отступления ADR-006 §8 (сузить length-bucket, добавить activity в ключ).
- Если меняется формула dedup_key — полный rebuild БД (`rm + python -m scripts.gps_collect`); регенерация ≤ 6 часов.
- Документация в `08-data-requirements.md` §3.2 для оператора.
## R-3 — Pipeline повреждает БД
- **Описание:** Бaг в Python-коде upsert (ADR-006 §6) при ON CONFLICT может оставить БД в несогласованном состоянии (битый JSON в `sources_json`, частично записанная transaction). SQLite + WAL обычно atomic per-statement, но composite upsert может рассогласоваться.
- **Вероятность / Влияние:** Н / В.
- **Митигация:**
- Все upsert операции — внутри SQLite `BEGIN IMMEDIATE / COMMIT` (atomic transaction).
- Ежедневный backup `data/gps_tracks.sqlite` (`07-infra-requirements.md` §4.4).
- При повреждении: `cp backups/gps_tracks-<date>.sqlite data/gps_tracks.sqlite` + cache-clear API. RTO ≈ 12 минуты.
- Полный rebuild: `rm gps_tracks.sqlite && docker compose --profile batch run --rm gps-collector` — ≤ 6 часов.
- Изоляция в отдельной БД (ADR-005 D-A) гарантирует, что повреждение не затронет `centralfederal.sqlite` (OSM-данные).
## R-4 — Размер БД превышает 2 ГБ
- **Описание:** REQ-NF-03 предел `data/gps_tracks.sqlite` — 2 ГБ. На MVP-объёме (5000 треков ≈ 105 МБ) запас 20×. Но при расширении на РФ или при отсутствии работающего GC размер может вырасти линейно.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- Health-эндпоинт отдаёт `db_size_mb` — оператор видит.
- Месячный GC `--gc` удаляет треки старше 5 лет (`07-infra-requirements.md` §7.1).
- При устойчивом росте > 2 ГБ — миграция на PostGIS (отдельный work item; контракт API стабилен, см. ADR-005 §«Технический долг»).
- Алерт `db_size_mb > 2000` — пока ручная проверка (post-MVP — автоматический).
## R-5 — IP mva154 банится источником
- **Описание:** Скрейпер с фиксированного IP может попасть в чёрный список платформы (особенно при ошибках rate-limit). Pipeline начинает возвращать 429/403 на все запросы → source не пополняется.
- **Вероятность / Влияние:** С / С.
- **Митигация:**
- Rate-limit в `gps_sources.yaml` per-source (1 сек для OSM, 5 сек для скрейп-источников).
- Корректный User-Agent с контактом — платформа может связаться, прежде чем банить.
- Backoff на 429 (`TRZ §6.3`) — exponential до 3 попыток.
- `pipeline_runs.errors_json` фиксирует HTTP-коды → оператор видит.
- При бане — приостановить source (`enabled: false`), связаться с платформой, при необходимости отключить полностью.
- **Прокси через сторонний IP** — не закладывается (нарушает дух прозрачности).
## R-6 — Pipeline жрёт ресурсы и деградирует API во время прогона
- **Описание:** На время прогона `gps-collector` контейнер активен, скачивает GPX, парсит, пишет в БД. Если ресурсы не ограничены — `httpx` + `shapely` могут уйти в GC-storm; SQLite write lock конкурирует с API readers.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- Docker `cpus: "1.0"`, `mem_limit: 512m` (`07-infra-requirements.md` §9.2). Pipeline не вытесняет API даже на одно-CPU-сервере.
- WAL-mode позволяет API читать БД во время записи pipeline'а (ADR-005 W-A).
- Cron в 03:00 UTC = 06:00 MSK — низкий traffic.
- Async-генератор `parser.collect()` — pipeline pulls треки по одному, не накапливает в памяти больше одного (ADR-007 §4).
## R-7 — Дублирование tile-утилит между `main.py` и `gps_tracks/mvt.py`
- **Описание:** ADR-005 §8 принимает дублирование `tile_to_bbox` / `wkb_to_coords` / `simplify_coords` (≈ 100 строк) ради избежания риска регрессии существующего слоя `trails`. Любая правка формулы упрощения требует синхронной правки в двух местах.
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
- Комментарий в обоих файлах `# ET-008/ADR-005-§8: дубль из main.py; при добавлении третьего MVT-источника — вынести в src/api/tiles_util.py`.
- Code review-чеклист: при правке `simplify_coords` в одном файле — проверить второй.
- При появлении третьего MVT-источника — обязательный рефакторинг (отдельный work item).
## R-8 — GeoJSON-эндпоинт превышает SLA на плотных bbox
- **Описание:** REQ-NF-02 предел 300 мс p95 на bbox с ≤ 500 треков. На реальной географии возможны bbox в плотных регионах (например, Подмосковье на z=12) где `total_in_bbox > 2000`. SQL даже с R-tree может проигрывать при ORDER BY + post-filter source.
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
- Cutoff `limit=500` обрезает результат на уровне SQL.
- Cutoff zoom 12 — на z=11 уходим в MVT-кэш, нагрузки на GeoJSON-endpoint нет.
- R-tree обеспечивает O(log n) bbox-prefetch.
- Дополнительный индекс по `length_m DESC` для ORDER BY (длинные треки приоритетнее) — фиксируется в коде; SQLite сделает sort быстро на 500 строках.
- Если SLA не выполняется — server-side кэширование GeoJSON-ответов по `(bbox_quantized, activity, source)` (post-MVP).
## R-9 — Лицензионный ADR не enforced
- **Описание:** ADR-007 §6 требует, чтобы pipeline отказывался загружать source-parser без `accepted`-ADR. Если разработчик обходит проверку (например, забывает добавить `license_adr:` поле в `gps_sources.yaml`) — pipeline пойдёт скрейпить без юридического подтверждения. BRD §4 явно требует «зелёного света».
- **Вероятность / Влияние:** Н / В.
- **Митигация:**
- Pydantic-валидация `gps_sources.yaml` — поле `license_adr` обязательное, отсутствие → exception при старте pipeline.
- Дополнительная проверка в runtime: `license_adr` должен указывать на существующий файл; YAML frontmatter `status: accepted`. Иначе source skip с `status: skipped_license`.
- Code review-чеклист в `12-review.md`: при добавлении source в `gps_sources.yaml` обязательна ссылка на accepted-ADR.
- QA-кейс: `tests/api/test_gps_tracks_licensing_guard.py` — поднимает pipeline с `proposed`-ADR, проверяет что source пропускается.
## R-10 — Cache-clear endpoint доступен извне
- **Описание:** `POST /api/gps-tracks/cache/clear` сбрасывает LRU. Если эндпоинт доступен через `/enduro/` — атакующий может вызывать его в цикле, обнуляя кэш и заставляя сервер постоянно перегенерировать тайлы (DoS).
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- `07-infra-requirements.md` §3.1: nginx-правило `location = /enduro/api/gps-tracks/cache/clear { allow 172.16/12; deny all; }`.
- Pipeline ↔ API дёргает endpoint напрямую через docker-сеть, минуя nginx → работает.
- При появлении CSP-заголовка — `connect-src 'self'` блокирует внешние POST'ы из браузера (но это уже есть).
## R-11 — Pipeline зависает (вечная проблема скрейперов)
- **Описание:** Парсер одного источника попадает в бесконечный pagination loop или висит на медленном HTTP. Cron-job не завершается, следующий cron-тик попадает на ту же задачу.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- `httpx.AsyncClient(timeout=30)` — таймаут на каждый запрос.
- Per-source максимум треков на прогон (`max_tracks_per_run` в `gps_sources.yaml`, default 5000) — стопорит pagination loop.
- Cron-окно (3 дня между прогонами) > потенциального hang-окна; overlapping runs — два docker container'а, ресурсы изолированы; следующий cron не блокируется первым.
- Опционально: `timeout 21600 docker compose ...` в cron — kill после 6 часов (REQ-NF-02). На MVP — не обязательно, но рекомендовано.
## R-12 — Несогласованность UI/style при `setStyle()`
- **Описание:** При переключении тёмной темы / спутника `map.setStyle()` сбрасывает все runtime-добавленные source/layer. `rebuildMapOverlays()` пересоздаёт; если порядок вызовов нарушен — слой публичных треков может оказаться поверх маршрута или ниже спутника.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- `restorePublicTracksState()` вызывается в `rebuildMapOverlays()` после `restoreTrailsState()`, до `restorePoiState()` и маршрута/GPX (TRZ REQ-F-19).
- AC-12 «Переживание setStyle()» проверяет: чекбокс работает после смены темы.
- Идемпотентные `if (!map.getSource(id)) map.addSource(...)` — паттерн из ADR-004 R-6.
## R-13 — Конфликт с ET-006 (личные GPX)
- **Описание:** ET-006 хранит личные GPX треки в `window.gpxTracks` и отображает как `gpx-layer-*`. Если ET-008 случайно использует тот же layer-id или event-handler — взаимная коллизия.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- Префикс `gps-tracks-*` для всех новых id (source, layer, halo) — конфликт исключён.
- `window.gpsTracksLayer``window.gpxTracks` (TRZ §4.4).
- Z-order: `gps-tracks-layer-*` < `gpx-layer-*` (личные приоритетнее, как уточняется в TRZ §7.1).
- AC-10 «Совместимость с ET-006» проверяет совместное отображение.
## R-14 — Конфликт с ET-007 (спутник + halo)
- **Описание:** ET-007 уже реализовал паттерн halo для trails на спутнике через `applyTrailHaloVisibility()` (ADR-004 §9). ET-008 добавляет два новых halo (`gps-tracks-halo-mvt-satellite`, `gps-tracks-halo-geo-satellite`) и расширяет `applyBaseLayer()`.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- Новые halo-слои добавляются в оба `style.json` / `style-dark.json` с `visibility: none` — по тому же паттерну ET-007.
- `applyBaseLayer()` (ET-007) расширяется одним блоком (см. TRZ §7.2):
```js
const gpsHaloOn = (currentBase === 'satellite' && layerState.publicTracks);
setLayoutProperty('gps-tracks-halo-mvt-satellite', 'visibility', gpsHaloOn && activeMode === 'mvt' ? 'visible' : 'none');
setLayoutProperty('gps-tracks-halo-geo-satellite', 'visibility', gpsHaloOn && activeMode === 'geo' ? 'visible' : 'none');
```
- AC-11 «Halo на спутнике» проверяет.
## R-15 — Pipeline не находит зависимости (defusedxml, pyyaml)
- **Описание:** При смене образа без полного rebuild — `gps-collector` стартует с старым `requirements.txt` → ImportError.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- Deploy-runbook (§7) явно требует `docker compose build` перед запуском нового pipeline.
- CI-job собирает образ при каждом push → новые зависимости видны на CI, а не в production.
## R-16 — Атрибуция теряется при включении/выключении источников
- **Описание:** BRD-метрика «атрибуция каждого активного источника видна». При динамическом изменении набора enabled-источников (например, оператор временно выключил `ttrails` в `gps_sources.yaml`) клиент может продолжать показывать атрибуцию, потому что в БД уже есть треки с `sources_json` содержащим `ttrails`.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- Атрибуция формируется на клиенте из `/api/gps-tracks/health.tracks_by_source` (только source с tracks_count > 0). Если в БД остались `ttrails` записи — атрибуция корректно отображает.
- Если source удалён + треки удалены — `tracks_by_source` его не содержит → атрибуция корректно скрывается.
- AC-15 проверяет.
## Сводная таблица
| ID | Риск | Вер. | Влияние | Класс | Статус |
|---|---|---|---|---|---|
| R-1 | Парсер ломается при смене HTML | В | С | Высокий | принят + per-source изоляция + алерт |
| R-2 | Ложные коллизии dedup | С | С | Средний | принят + метрика BRD + план отступления |
| R-3 | Pipeline повреждает БД | Н | В | Средний | atomic tx + ежедневный backup + rebuild за 6 ч |
| R-4 | Размер БД > 2 ГБ | Н | С | Низкий | GC + health + миграция на PostGIS |
| R-5 | IP mva154 банится | С | С | Средний | rate-limit + UA + backoff + отключение источника |
| R-6 | Pipeline деградирует API | Н | С | Низкий | cgroup limits + WAL + ночное окно |
| R-7 | Дублирование tile-утилит | С | Н | Низкий | принят + комментарии в коде + review-чеклист |
| R-8 | GeoJSON SLA на плотных bbox | С | Н | Низкий | limit + zoom-cutoff + R-tree |
| R-9 | Licensing-ADR не enforced | Н | В | Высокий | runtime-guard + Pydantic-валидация + тест |
| R-10 | Cache-clear доступен извне | Н | С | Низкий | nginx allow/deny |
| R-11 | Pipeline зависает | Н | С | Низкий | httpx timeout + max_tracks_per_run + (опц.) timeout cron |
| R-12 | UI несогласован после setStyle | Н | С | Низкий | паттерн ADR-004 + AC-12 |
| R-13 | Конфликт с ET-006 (GPX) | Н | С | Низкий | префикс + параллельные модели + AC-10 |
| R-14 | Конфликт с ET-007 (halo) | Н | С | Низкий | новые halo по тому же паттерну + AC-11 |
| R-15 | Зависимости pipeline | Н | Н | Низкий | CI-build + runbook |
| R-16 | Атрибуция теряется | Н | Н | Низкий | health-derived rendering |
**Высокие классы:**
- R-1 — операционный, ожидаемый для скрейп-источников; митигация — per-source изоляция и быстрое отключение через конфиг.
- R-9 — критический для legal compliance; митигация многослойная (Pydantic + runtime check + тест).
**Блокирующих рисков нет.** R-1 и R-9 требуют внимания разработки и code review, но не блокируют merge.
## Эскалация
- **arch:major-change** — выставлен на ADR-005 (новая БД) и ADR-007 (новый сервис + cron). Требует архитектурного approve перед merge.
- **back-to:analysis** — не требуется. ТЗ полное, BRD ясный, ADR-010 и ADR-011 явно блокирующие до закрытия licensing review (это операционный pre-requisite, не дефект анализа).

View File

@@ -0,0 +1,542 @@
---
type: code-review
work_item_id: ET-008
title: "Review: GPS-треки с публичных платформ на карте"
version: 2
status: REQUEST_CHANGES
created_at: 2026-06-01
updated_at: 2026-06-01
authors:
- "agent:reviewer"
reviewed_branch: feature/ET-008-gps
base_branch: main
reviewed_commits:
- 0060003 "feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ"
- 3734b98 "feat(ET-008): GPS tracks pipeline, API, frontend layer"
verdict: REQUEST_CHANGES
findings_summary:
P0: 1
P1: 4
P2: 3
P3: 4
changelog:
- "v1 (2026-06-01): REQUEST_CHANGES — на ветке отсутствовал код."
- "v2 (2026-06-01): код появился (~7700 LOC). Проведено code-review против ТЗ v2, AC v2 и ADR-005..011."
---
# Code Review — ET-008 (v2)
## Verdict: **REQUEST_CHANGES**
На ветке появилась реализация (commits `0060003`, `3734b98`): backend
пакет `src/api/gps_tracks/`, pipeline `scripts/gps_collect.py`, фронт
`src/web/gps_tracks.js`, миграция, YAML-конфиги, docker-compose сервис
`gps-collector`, тесты, fixtures. Архитектурно сборка следует ADR-005…008
и REQ-F-01…F-03. Однако обнаружено **одно блокирующее (P0) расхождение**
с ТЗ, ломающее основной сценарий просмотра на детальном zoom, и
несколько P1-несоответствий контракту API.
## Что проверено
1.`docs/work-items/ET-008/02-trz.md` v2 (draft) — REQ-F-01…F-20, REQ-NF-01…NF-07.
2.`docs/work-items/ET-008/03-acceptance-criteria.md` v2 (draft) — AC-01…AC-17.
3.`docs/work-items/ET-008/06-adr/` — ADR-005…011 (5 accepted, 2 proposed/blocking).
4.`CLAUDE.md` — конвенции, фазы, правила для агентов.
5. ✅ Git diff `main...feature/ET-008-gps` — 53 файла, +7705/-1218.
6. ✅ Прочитан исходник backend (config.py, db.py, dedup.py, endpoint.py,
mvt.py, models.py, sources/{base,osm,enduro_russia,ttrails}.py).
7. ✅ Прочитан исходник frontend (gps_tracks.js целиком, изменения в
app.js, app.css, index.html).
8. ✅ Прочитана миграция, scripts/gps_collect.py, оба YAML, requirements.txt,
docker-compose.yml.
9. ✅ Прочитаны 4 тест-файла (dedup, endpoint, mvt, sources/osm) и fixtures.
## Findings
### F-01 [P0]: GeoJSON-слой полностью скрывается из-за рассогласования имён properties
**Severity:** P0 (blocker — нарушено REQ-F-13/F-14, AC-04, AC-08; основной
визуальный сценарий слоя на z ≥ 12 не работает).
**Где:**
- `src/api/gps_tracks/endpoint.py`, `_row_to_geojson_feature()` (стр. 5184)
- `src/web/gps_tracks.js`, `applyGpsFilter()` (стр. 246267) и
`_buildColorExpression()` (стр. 7188)
**Что обнаружено:**
GeoJSON endpoint отдаёт в `properties` поля `activity_type` и `sources`
(массив):
```python
"properties": {
...
"activity_type": row["activity_type"],
...
"sources": sources, # list
"external_urls": ext_urls,
...
}
```
MVT-эндпойнт (правильно по ТЗ §4.3) отдаёт `activity` (скаляр) и `source`
(скаляр первой sources):
```python
props = {
...
"activity": row["activity_type"] or "other",
"source": first_source,
"sources": sources_str, # comma-string
...
}
```
Клиентский фильтр в `applyGpsFilter()` использует **только** имена из
MVT-схемы:
```js
const filter = ['all',
['in', ['get', 'activity'], ['literal', activities]],
['in', ['get', 'source'], ['literal', sources]]
];
map.setFilter(window.gpsTracksLayer.layerGeoId, filter);
```
Для feature из GeoJSON-source-а `get('activity')` и `get('source')`
возвращают `null``['in', null, ['literal', […]]]` = `false` → **все
features фильтруются из показа**. То же касается `line-color`:
`_buildColorExpression('source')` matches по `['get', 'source']`
GeoJSON-features попадают в fallback `'#808080'`.
**Воспроизведение:**
1. Включить чекбокс «Публичные треки».
2. Увеличить zoom до 12+. (`_syncGpsLayersVisibility` делает
`gps-tracks-layer-geo` видимым и скрывает MVT-слой.)
3. На карте — ни одного публичного трека, хотя `/api/gps-tracks?bbox=…`
отдаёт >0 features (`returned > 0`, `total_in_bbox > 0`). Toast
«Показаны N треков из M…» возможен, но карта пустая.
**Ссылка на правило:** Reviewer severity «не реализовано требование ТЗ» → P0.
Затронуты:
- REQ-F-14 (фильтры мгновенно действуют через setFilter).
- REQ-F-17 (стили `gps-tracks-layer` с paint `match`).
- AC-04 Scenario «Поля feature.properties» — `feature.properties` обязан
содержать `length_km`, см. также F-02 ниже.
- AC-08 «Фильтрация по активности»: «на карте отображаются только enduro
и moto треки» — невозможно, т.к. на z ≥ 12 ВСЕ треки отфильтрованы.
**Что починить:**
Унифицировать contract. Один из двух вариантов:
- (рекомендуется) В `_row_to_geojson_feature` добавить дублирующие
поля `activity` (= `activity_type`) и `source` (= `sources[0]`) —
не ломая существующих потребителей. Параллельно проверить попап:
`_renderTrackPopupHtml` уже читает `props.activity_type || props.activity`
— менять не нужно.
- Либо переписать `applyGpsFilter`/`_buildColorExpression` так, чтобы они
ветвились по `['has', 'activity']` vs `['get', 'activity_type']` + для
source использовать `['in', 'osm', ['get', 'sources']]` через
index-of / contains (MapLibre expressions поддерживают `in` с массивом
правой части).
Тест, который должен поймать это (отсутствует):
`tests/web/gps_tracks.test.js` — feature `{activity_type:'enduro'}` после
`applyGpsFilter` остаётся видимой. Добавить в test-plan.
---
### F-02 [P1]: GeoJSON `feature.properties` отдаёт `length_m`, ТЗ требует `length_km`
**Severity:** P1 (must-fix — нарушение контракта API, описанного в
REQ-F-10 и AC-04; UI-попап показывает «—»).
**Где:** `src/api/gps_tracks/endpoint.py`, `_row_to_geojson_feature()`
(стр. 5184).
**ТЗ REQ-F-10** (ст. 280305):
```json
"properties": {
"name": "...",
"activity_type": "...",
"user": "...",
"created_at": "...",
"length_km": 47.3,
"sources": [...],
"external_urls": [...]
}
```
**AC-04 Scenario «Поля feature.properties»:**
> Then каждая feature содержит: name, activity_type, user, created_at,
> length_km, sources (array), external_urls (array)
**Имеется в коде:**
```python
"length_m": row["length_m"],
# и нет ни одного "length_km"
```
**Последствие:** В `_renderTrackPopupHtml` (`gps_tracks.js` стр. 325):
```js
const lengthKm = typeof props.length_km === 'number' ? props.length_km.toFixed(1) : '—';
```
для GeoJSON-feature (z ≥ 12) `props.length_km` всегда `undefined` → в
попапе постоянно «📏 — км». MVT-features (z 8…11) показывают правильно,
т.к. MVT-builder уже считает `length_km` (`mvt.py` стр. 148).
**Что починить:** добавить в properties `length_km` (как минимум).
Поле `length_m` оставить, если используется. Аналогично уточнить
`points_count` и `created_at` для popup (см. также F-04).
---
### F-03 [P1]: REQ-F-04 не реализован полностью — все OSM-треки сохраняются как `activity_type='other'`
**Severity:** P1 (must-fix — функциональный пробел; пользователь не
сможет осмысленно использовать фильтр по активности, который явно
закреплён в AC-08).
**Где:** `src/api/gps_tracks/sources/osm.py`, `_parse_gpx_trackpoints()`
(стр. 154284).
**ТЗ REQ-F-04** (ст. 119144):
> Для треков с gpx_id — дополнительный запрос
> `GET /api/0.6/gpx/<id>` для метаданных (name, description, tags, user,
> timestamp). Этот запрос делаем отложенно в batch: накопить 100 id →
> запросить. Тип активности — из tags GPX-трека (`Tag: enduro/moto/mtb/hike/...`).
**Реализовано:**
- batch-запрос метаданных НЕ сделан;
- `name`, `description`, `tags`, `user` всегда `None`/`[]`;
- `activity_type` явно зашит как `"other"`:
```python
track = TrackInsert(..., activity_type="other", ...)
```
- константа `OsmParser.MAPPING` (стр. 2539) объявлена, но `map_activity`
не вызывается — мёртвый код.
**Последствие:** в БД ВСЕ треки от OSM (единственного включённого
источника) попадают как `activity_type='other'`. Фильтр по активности
теряет смысл — пользователь видит только «Другое». AC-08 Scenario
«Фильтрация по активности» не может пройти на реальных данных.
**Что починить:** реализовать batch-fetch на `/api/0.6/gpx/<id>` по
накоплению ID, как описано в ТЗ. Использовать `self.map_activity(...)`
для tags. Если решено отложить — оформить ADR/коммент с явной отметкой
«частичная реализация REQ-F-04, follow-up tracked в …» и понизить
ожидания AC-08 в ТЗ (но это работа аналитика, не разработчика).
---
### F-04 [P1]: Health endpoint не соответствует REQ-F-12 / AC-06
**Severity:** P1 (must-fix — нарушение контракта; AC-06 явно перечисляет
обязательные поля, которых нет).
**Где:** `src/api/gps_tracks/endpoint.py`, `gps_health()` (стр. 196232).
**ТЗ REQ-F-12 / AC-06** требуют поля:
| Поле | Тип |
| ------------------ | ------ |
| `db_path` | str |
| `db_size_mb` | float |
| `tracks_total` | int |
| `tracks_by_source` | dict |
| `tracks_by_activity` | dict |
| `last_pipeline_run` | object с полями started/finished/regions/sources_ok/sources_error |
| `tile_cache_size` | int |
**Имеется:**
```python
return {
"status": "ok",
"db_path": db_path,
"total_tracks": total_tracks, # должно быть tracks_total
"by_activity": by_activity, # должно быть tracks_by_activity
"recent_pipeline_runs": recent_runs, # должно быть last_pipeline_run (объект)
}
```
Отсутствуют: `db_size_mb`, `tracks_by_source`, `tile_cache_size`.
Переименованы: `tracks_total → total_tracks`, `tracks_by_activity →
by_activity`, `last_pipeline_run → recent_pipeline_runs` (массив, не
объект).
Также `recent_pipeline_runs` отдаёт «10 последних запусков», а ТЗ
требует ОДИН последний (агрегированно). Это влияет на UI/админский
view.
**Что починить:** привести JSON-схему к контракту. Минимум — добавить
`tracks_by_source` (вычислить по `sources_json` агрегацией в Python или
JSON_EACH в SQL), `db_size_mb` (через `os.path.getsize`), `tile_cache_size`
(через `len(_gps_tile_cache)` из `mvt.py`), `last_pipeline_run` объект
(берём первую строку из `pipeline_runs ORDER BY started_at DESC`,
агрегируем `sources_ok`/`sources_error` по последнему region).
Tests `test_i40_health_endpoint` сейчас закреплены на текущей неправильной
схеме — их тоже придётся обновить.
---
### F-05 [P1]: Z-order ET-006 не зафиксирован — `gps-tracks-layer` может оказаться выше `gpx-layer-*`
**Severity:** P1 (must-fix — нарушение REQ-F-17 paint requirements §7.1
и AC-10 Scenario «личный трек визуально выше публичных»).
**Где:** `src/web/gps_tracks.js`, `_findGpsInsertPosition()` (стр. 191196).
**ТЗ §7.1:**
> На карте оба видны параллельно; z-order:
> `gps-tracks-layer` < `gpx-layer-*` (личные треки выше).
**AC-10 Scenario «Совместимость с ET-006»:**
> Then оба видны параллельно
> And личный трек визуально выше публичных
**Имеется:**
```js
function _findGpsInsertPosition(map) {
const style = map.getStyle && map.getStyle();
if (!style || !style.layers) return undefined;
const routeLayer = style.layers.find(l => l.id === 'route-line' || l.id.startsWith('route-'));
return routeLayer ? routeLayer.id : undefined;
}
```
Функция ищет только `route-*`. Если `gpx-layer-*` уже добавлен в стиль
(ET-006), но route-line ещё нет, gps-tracks-layer добавится **в конец**
(`before = undefined` → addLayer без beforeId → поверх всего стиля),
**в том числе поверх gpx-layer**. Это нарушает обязательное правило
из §7.1 / AC-10.
**Что починить:**
```js
function _findGpsInsertPosition(map) {
const style = map.getStyle && map.getStyle();
if (!style || !style.layers) return undefined;
// Приоритет beforeId: gpx-layer-* (ET-006), затем route-* (если нет gpx).
const gpxLayer = style.layers.find(l => l.id.startsWith('gpx-layer'));
if (gpxLayer) return gpxLayer.id;
const routeLayer = style.layers.find(l => l.id === 'route-line' || l.id.startsWith('route-'));
return routeLayer ? routeLayer.id : undefined;
}
```
Соответствующий unit-тест добавить в `tests/web/gps_tracks.test.js`
(в test-plan он есть как WEB-INTEG).
---
### F-06 [P2]: Валидация bbox по площади отсутствует (REQ-NF-01)
**Severity:** P2 (should-fix).
**Где:** `src/api/gps_tracks/endpoint.py`, `_parse_bbox()` (стр. 1748).
**ТЗ REQ-NF-01:**
> Bbox-параметр валидируется (диапазон координат, площадь).
**Имеется:** валидация диапазона `[-180,180]/[-90,90]`, проверка
`west<east`/`south<north`. Площадь не проверяется.
**Что починить:** добавить max площадь bbox (например, 10° × 10° = 100°²)
для предотвращения «штормового» сканирования БД.
---
### F-07 [P2]: Дефолт `gps-tracks-sources` в localStorage включает отключённые источники
**Severity:** P2.
**Где:** `src/web/gps_tracks.js`, `window.gpsTracksLayer.filters.sources`
(стр. 55).
```js
sources: ['osm', 'enduro_russia', 'ttrails'],
```
При том, что `config/gps_sources.yaml` явно держит `enduro_russia` и
`ttrails` в `enabled: false` (соответствует ADR-010/011 status=proposed).
ТЗ REQ-F-15 говорит дефолт = «все enabled». Не блокирует, но создаёт
галки в UI для источников, по которым в БД ничего нет.
**Что починить:** инициализировать sources списком из
`/api/gps-tracks/health.tracks_by_source.keys()` при отсутствии
localStorage-ключа. Это естественно подтянется после F-04.
---
### F-08 [P2]: «LRU-кэш» в `mvt.py` — на самом деле FIFO
**Severity:** P2.
**Где:** `src/api/gps_tracks/mvt.py`, `_gps_tile_cache` (стр. 1224).
```python
def set_gps_cached_tile(z, x, y, data):
if len(_gps_tile_cache) >= _GPS_TILE_CACHE_MAX:
_gps_tile_cache.pop(next(iter(_gps_tile_cache))) # FIFO, не LRU
_gps_tile_cache[(z, x, y)] = data
```
ТЗ REQ-NF-04: «LRU-кэш в памяти процесса FastAPI: 1024 записи». Текущая
реализация — FIFO. Для тайлов это особо ухудшает: часто запрашиваемый
тайл, попавший в кэш первым, будет вытеснен раньше, чем редкий тайл,
попавший позже.
**Что починить:** `from functools import lru_cache` нельзя из-за
mutable invalidation; использовать `collections.OrderedDict` с
`move_to_end` при чтении, либо `cachetools.LRUCache`. Совместимая идея —
сделать `get_gps_cached_tile`:
```python
def get_gps_cached_tile(z, x, y):
key = (z, x, y)
if key in _gps_tile_cache:
_gps_tile_cache.move_to_end(key) # OrderedDict
return _gps_tile_cache[key]
return None
```
---
### F-09 [P3]: Мёртвое поле конфигурации `save_user_field`
**Severity:** P3 (nice-to-have).
**Где:** `config/gps_sources.yaml` — поле задано (`save_user_field:
true|false`); ни один модуль его не читает. AC-16 Scenario «Pipeline не
сохраняет запрещённые поля» рассчитывает, что это поле уважается.
**Что починить:** в `OsmParser`/upsert обрабатывать `save_user_field=false`
→ `user=None`. Сейчас OSM всё равно ставит `user=None` (см. F-03), но
поле должно работать как контракт для будущих источников.
---
### F-10 [P3]: Лишний импорт `pytest_asyncio` в `tests/api/test_gps_tracks_endpoint.py`
**Severity:** P3.
`import pytest_asyncio` есть, но `@pytest_asyncio.fixture` нигде не
используется (только `@pytest.mark.asyncio`). Не блокирует, но в чистом
коде убирается.
---
### F-11 [P3]: `MockRow(dict)` в `tests/api/test_gps_tracks_mvt.py`
**Severity:** P3.
Тесты используют `class MockRow(dict)` как замену `sqlite3.Row`. Работает
для текущего кода, но `sqlite3.Row` не поддерживает `__contains__` так
же, как dict. Если в `mvt.py` появится `if "x" in row:`, тесты
разойдутся с продом. Безопаснее использовать `sqlite3.Row` напрямую через
`open_db + INSERT + fetchone()`.
---
### F-12 [P3]: Лишняя проверка `"source_priority" in existing.keys()` в `db.py`
**Severity:** P3.
`src/api/gps_tracks/db.py` стр. 116:
```python
existing_priority = existing["source_priority"] if "source_priority" in existing.keys() else 999
```
Колонка `source_priority` объявлена в миграции (`migrations/gps_tracks_001_init.sql`
ст. 22) с `NOT NULL DEFAULT 999`. Проверка избыточна (read-protection
осталась от какой-то ранней итерации). Лучше убрать — иначе создаётся
впечатление, что колонка опциональна.
---
## Соответствие ADR
| ADR | Status | Соблюдение в коде | Замечания |
|-----|--------|-------------------|-----------|
| ADR-005 storage-schema | accepted | ✅ соблюдено | таблица `tracks` (+`source_priority`), индексы и `pipeline_runs` совпадают |
| ADR-006 dedup-algorithm | accepted | ✅ соблюдено | `compute_dedup_key` 1-в-1; покрыт unit-тестами U-10…U-14 |
| ADR-007 pipeline-architecture | accepted | ✅ соблюдено | сервис `gps-collector` с `profiles:["batch"]`, license-guard в `_check_license_adr` |
| ADR-008 tile-vs-geojson | accepted | ⚠️ частично | переключение по zoom есть; но contract фич GeoJSON vs MVT расходится (см. F-01) |
| ADR-009 osm-licensing | accepted | ✅ соблюдено | attribution в source, рабочий парсер |
| ADR-010 enduro-russia (proposed) | proposed | ✅ соблюдено | parser — заглушка, `enabled: false`, license-guard сработает |
| ADR-011 ttrails (proposed) | proposed | ✅ соблюдено | то же |
License-guard в `scripts/gps_collect.py` `_check_license_adr()` корректно
читает YAML front-matter ADR. Покрытия unit-тестом нет — рекомендую
добавить (`test_pipeline_skips_unaccepted_source`).
## Тесты — оценка
| Тест-файл | Покрытие test-plan | Качество |
|-----------|-------------------|----------|
| `test_gps_tracks_dedup.py` | U-10…U-14 | ✅ хорошо |
| `test_gps_tracks_mvt.py` | U-50…U-52 | ✅ адекватно (см. F-11) |
| `test_gps_tracks_endpoint.py` | I-20…I-23, I-30…I-31, I-40 | ⚠️ AC-06 не покрыт корректно — тест зафиксирован на неверной схеме (F-04) |
| `test_gps_tracks_sources_osm.py` | U-42…U-44 | ✅ defusedxml проверен фикстурой xxe-payload.gpx |
**Не покрыто тестами:**
- `tests/web/gps_tracks.test.js` — заявлен в ТЗ §5 ст. 787 и
`04-test-plan.yaml`, ОТСУТСТВУЕТ. Был бы первой защитой от F-01.
- pipeline (`scripts/gps_collect.py`) — нет ни одного теста на
`_check_license_adr`, `_collect_source_for_region`, dry-run, exit-code.
AC-02 Scenarios «Падение одного источника не валит остальные» и
«Dry-run» по факту не верифицированы.
- bbox area-валидация — отсутствует и в коде (F-06), и в тестах.
## Замечания, не доходящие до P-finding
- `_simplify_coords` в `mvt.py` дублирует `simplify_coords` из `src/api/main.py`.
Не критично, но напрашивается общая утилита.
- `src/api/main.py` ст. 1720: `GPS_TRACKS_DB_PATH` вычисляется **до**
импорта shapely/fastapi, в строгом смысле это «нечистый» импорт-time
side-effect. Не блок.
- В `endpoint.py` `init_db` вызывается на каждый запрос (`_get_conn`).
Это означает, что `executescript` выполняется на каждый запрос. SQL
использует `IF NOT EXISTS`, так что функционально ок, но это лишний
I/O. Рекомендую инициализировать БД один раз при `create_gps_router`.
- `src/api/main.py` ст. 1255: `app.include_router(gps_router)` хорошо
встроено перед `StaticFiles` mount — порядок правильный.
## Воспроизведение P0 для разработчика
```bash
# 1. Запустить с тестовой БД (см. test_i20_geojson_basic):
pytest tests/api/test_gps_tracks_endpoint.py::test_i20_geojson_basic -q
# 2. Вытащить feature.properties — наблюдать "activity_type", "sources" (list),
# отсутствие "length_km".
# 3. Открыть DevTools в браузере на dev-стенде, проверить:
window._map.queryRenderedFeatures({layers:['gps-tracks-layer-geo']})
# → пустой массив на z >= 12, потому что setFilter с ['get','activity'] всё скрыл.
# 4. Временный обход для отладки:
window._map.setFilter('gps-tracks-layer-geo', null)
# → треки появятся, но серые (line-color fallback) — что подтверждает F-01.
```
## Рекомендация для CI
- `pytest tests/api/` сейчас зелёный, потому что:
- `test_i40_health_endpoint` фиксирует текущую неправильную схему
(F-04) → нужно поправить вместе с фиксом;
- frontend-тесты отсутствуют (F-01 не отлавливается).
- `make lint` ожидаемо проходит.
- Перед закрытием задачи CI должен прогонять и frontend-тесты
(`tests/web/gps_tracks.test.js`), которые на данный момент не написаны.
- Не закрывать ET-008 в Plane как Done.
## Итог
**REQUEST_CHANGES.** Минимальный объём правок:
1. F-01 (P0) — унифицировать имена properties между MVT и GeoJSON, +
web-тест на `applyGpsFilter`.
2. F-02 (P1) — добавить `length_km` в GeoJSON.
3. F-03 (P1) — реализовать batch-fetch `/api/0.6/gpx/<id>` и mapping
`activity_type` (или явная декларация частичной реализации REQ-F-04).
4. F-04 (P1) — выровнять `gps_health()` под REQ-F-12 / AC-06.
5. F-05 (P1) — корректный `beforeId` в `_findGpsInsertPosition`.
После исправлений P0/P1 — повторить ревью; P2/P3 могут пройти отдельным
PR follow-up.

View File

@@ -0,0 +1,209 @@
---
type: test-report
work_item_id: ET-008
title: "Test Report: GPS-треки с публичных платформ на карте"
version: 3
status: pass
created_at: 2026-06-01
updated_at: 2026-06-01
authors:
- "agent:tester"
tested_branch: feature/ET-008-gps
tested_commits:
- 1ffa178 "fix(gps-tracks): aggregate last_pipeline_run in health endpoint (REQ-F-12)"
- ba356ae "fix(gps-tracks): rename health fields and fix layer insert priority (F-04, F-05)"
- edbe9a3 "fix(gps-tracks): normalise GeoJSON props, add health fields, OSM meta fetch, z-order fix"
- 3734b98 "feat(ET-008): GPS tracks pipeline, API, frontend layer"
verdict: stage:ready-to-deploy
---
# Test Report — ET-008: GPS-треки с публичных платформ на карте (v3)
## Вердикт: **stage:ready-to-deploy**
Коммит `1ffa178` закрывает последний P1-дефект (F-04 — структура
`last_pipeline_run`). Все 141 pytest и 22 JS unit-теста зелёные. Все
P0/P1 находки из code-review v2 устранены. E2E и UI-тесты пропущены по
инфраструктурным причинам (бэкенд ET-008 не задеплоен на тест-стенд,
UI-раннер `/home/slin/tools/ui-test/run_tests.js` недоступен) — это не
дефект кода; рекомендуется выполнить E2E-прогон сразу после деплоя.
---
## Шаг 1 — Проверка окружения
| Endpoint | Статус | Детали |
|---|---|---|
| `GET /enduro/api/health` | ✅ 200 OK | `{"status":"ok","db_exists":true}` |
| `GET /enduro/api/gps-tracks/health` | ❌ 404 | ET-008 не задеплоен на стенд |
| `GET /enduro/api/gps-tracks?bbox=…` | ❌ 404 | ET-008 не задеплоен на стенд |
| `GET /enduro/api/gps-tracks/tiles/…mvt` | ❌ 404 | ET-008 не задеплоен на стенд |
| Фронтенд (HTML) | ✅ | `#public-tracks-cb`, `#sheet-gps-filters`, `gps_tracks.js` в разметке |
Бэкенд-роуты `/api/gps-tracks/*` возвращают 404 — статика ET-008
задеплоена, сервис не поднят. E2E и UI тесты выполнить невозможно до
деплоя.
---
## Шаг 2 — Функциональные тесты (`python -m pytest tests/ -v`)
```
cd /repos/enduro-trails/src/api
python -m pytest ../../tests/ -v --tb=short
```
**Результат: 141 passed, 0 failed, 7 warnings**
| Сюита | Тестов | Результат |
|---|---|---|
| `tests/api/test_gps_tracks_dedup.py` | 8 | ✅ PASS |
| `tests/api/test_gps_tracks_endpoint.py` | 15 | ✅ PASS |
| `tests/api/test_gps_tracks_mvt.py` | 9 | ✅ PASS |
| `tests/api/test_gps_tracks_sources_osm.py` | 21 | ✅ PASS |
| `tests/integration/test_routing_barriers.py` | 7 | ✅ PASS |
| `tests/unit/test_base_layer.py` | 22 | ✅ PASS |
| `tests/unit/test_gpx_upload.py` | 21 | ✅ PASS |
| `tests/unit/test_health.py` | 1 | ✅ PASS |
| `tests/unit/test_poi_toggle.py` | 10 | ✅ PASS |
| `tests/unit/test_unit_toggle.py` | 18 | ✅ PASS |
| `tests/web/test_gps_tracks.py` | 9 | ✅ PASS |
Предупреждения (7 шт.) — `DeprecationWarning` в `mapbox_vector_tile.encode`
(внешняя библиотека, некритично).
---
## Шаг 3 — E2E тесты (Playwright)
**SKIP** — бэкенд ET-008 не задеплоен на тест-стенд; Playwright-сценарии
E-01, E-02 (pipeline smoke) и E-10…E-12 (UI-фильтры) выполнить
невозможно. Рекомендуется запустить после `make deploy-test`.
---
## Шаг 4 — JS Unit-тесты (`node --test`)
```
node --test tests/web/gps_tracks.test.js
```
**Результат: 22 passed, 0 failed**
| Группа | Тестов | Результат |
|---|---|---|
| F-05: `_findGpsInsertPosition` — приоритет слоёв | 9 | ✅ PASS |
| Filters: начальное состояние `window.gpsTracksLayer` | 5 | ✅ PASS |
| Colors: палитра источников, активностей, fallback | 8 | ✅ PASS |
---
## Шаг 5 — UI / Visual тесты (TC-UI-01…TC-UI-20)
**SKIP**`/home/slin/tools/ui-test/run_tests.js` недоступен; бэкенд
ET-008 не отвечает на тест-стенде. Скриншоты TC-UI-01…TC-UI-20 не
сделаны.
---
## Верификация фиксов из `12-review.md`
### Итоговая таблица
| Finding | Severity | v2 | v3 | Вердикт |
|---|---|---|---|---|
| F-01: GeoJSON props несовместимы с MVT | P0 | PASS | PASS | ✅ PASS |
| F-02: `length_m` вместо `length_km` в GeoJSON | P1 | PASS | PASS | ✅ PASS |
| F-03: OSM batch-fetch и activity_type не реализованы | P1 | PASS | PASS | ✅ PASS |
| F-04: Health endpoint несовместим с REQ-F-12 | P1 | ⚠️ WARN | PASS | ✅ PASS |
| F-05: Z-order vs `gpx-layer-*` | P1 | PASS | PASS | ✅ PASS |
| `tests/web/gps_tracks.test.js` отсутствует | — | PASS | PASS | ✅ PASS |
| F-06: Нет валидации площади bbox | P2 | follow-up | follow-up | ⏭ follow-up |
| F-07: Дефолт sources включает disabled | P2 | follow-up | follow-up | ⏭ follow-up |
| F-08: LRU-кэш — на самом деле FIFO | P2 | follow-up | follow-up | ⏭ follow-up |
| F-09…F-12: P3-находки | P3 | follow-up | follow-up | ⏭ follow-up |
---
### F-04 [P1] → ✅ **PASS** (v2: ⚠️ WARN)
Коммит `1ffa178` реализует агрегацию строк `pipeline_runs` по
`MAX(started_at)` в полный контракт REQ-F-12:
```python
# endpoint.py, gps_health()
cur.execute("""
SELECT started_at, finished_at, region_id, source_id,
status, tracks_new, errors_json
FROM pipeline_runs
WHERE started_at = (SELECT MAX(started_at) FROM pipeline_runs)
ORDER BY region_id, source_id
""")
# → агрегация в:
{
"started_at": "...", "finished_at": "...",
"regions": ["tsfo_plus_chuvashia"],
"sources_ok": ["osm", "enduro_russia"],
"sources_error": [{"source": "ttrails", ...}],
"tracks_added": 100
}
```
Тест `test_i40_health_endpoint` (обновлён) проверяет:
- наличие всех 6 обязательных полей (`started_at`, `finished_at`,
`regions`, `sources_ok`, `sources_error`, `tracks_added`);
- типы (`list`, `int`);
- отсутствие сырых полей БД (`region_id`, `source_id`);
- конкретные агрегированные значения из фикстуры (2 региона,
2 ok-источника).
`test_i40_health_empty_db` подтверждает: при пустой БД — `last_pipeline_run: null`.
---
### Детали: что проверяют ключевые тесты ET-008
| Тест-ID | Связанный AC / REQ | Что проверяется |
|---|---|---|
| `test_f01_f02_geojson_normalised_properties` | AC-04, REQ-F-10 | GeoJSON `activity`, `source`, `length_km`, `activity_type` |
| `test_i20_filter_by_activity` | AC-04 | фильтр `?activity=enduro` возвращает только enduro |
| `test_i20_filter_by_source` | AC-04 | фильтр `?source=osm` возвращает только OSM |
| `test_i21_truncation` | AC-04 | `truncated=true`, `returned=500`, `total_in_bbox=1500` |
| `test_i22_invalid_bbox_returns_400` (7 param) | AC-04 | 400 на невалидные bbox |
| `test_i30_mvt_tile_returns` | AC-05 | `200 application/x-protobuf`, layer `gps_tracks` |
| `test_i31_cache_hit` | AC-05, REQ-NF-04 | `X-Cache: HIT` на повторный запрос |
| `test_i40_health_endpoint` | AC-06, REQ-F-12 | все поля health, агрегированный `last_pipeline_run` |
| `test_u13_merge_sources_on_upsert` | AC-03, REQ-F-08 | дедупликация: union sources |
| `test_u44_xxe_protection` | REQ-NF-01 | defusedxml блокирует XXE |
| `test_u45_meta_response_with_known_tag` | REQ-F-04, REQ-F-07 | OSM tag → `activity_type` |
| `test_gps_tracks_find_insert_position_priority_gpx_first` | AC-10, §7.1 | gpx-layer-* > route-* |
---
## Открытые P2/P3 — follow-up (не меняют вердикт)
| Finding | Severity | Описание | Рекомендация |
|---|---|---|---|
| F-06 | P2 | Нет проверки площади bbox | Добавить max area ≤ 100°² в `_parse_bbox()` |
| F-07 | P2 | Дефолт sources содержит disabled-источники | Инициализировать из `/api/gps-tracks/health.tracks_by_source` |
| F-08 | P2 | LRU-кэш — на самом деле FIFO | `OrderedDict` с `move_to_end` при чтении |
| F-09 | P3 | `save_user_field` в YAML не читается кодом | Обработать в upsert |
| F-10 | P3 | Лишний `import pytest_asyncio` | Убрать |
| F-11 | P3 | `MockRow(dict)` вместо `sqlite3.Row` | Рефактор тестов |
| F-12 | P3 | Лишняя проверка `"source_priority" in existing.keys()` | Упростить |
---
## Рекомендации для деплоя
После выполнения `make deploy-test` или `docker compose up -d` на тест-стенде
с веткой `feature/ET-008-gps`:
1. **Smoke API:**
```bash
curl https://openclaw.mva154.duckdns.org/enduro/api/gps-tracks/health
curl "https://openclaw.mva154.duckdns.org/enduro/api/gps-tracks?bbox=37.0,55.0,38.0,56.0&limit=5"
```
2. **E2E Playwright:** E-01 (pipeline smoke), E-02 (dedup), E-10…E-12 (filters).
3. **UI тесты:** TC-UI-01…TC-UI-20 через `run_tests.js` (при наличии раннера).
4. **P2 follow-up** можно закрыть отдельным PR после приёмки основного.

View File

@@ -0,0 +1,52 @@
# Deploy Log — ET-008
- **Version:** v0.0.1
- **Date:** 2026-06-01 14:32 UTC
- **PR:** #12
- **Branch:** feature/ET-008-gps
- **Environment:** test (https://openclaw.mva154.duckdns.org/enduro/)
- **Merge commit:** 04d9d3e
- **Healthcheck:** PASS (HTTP 200, attempt 1/12)
- **Smoke:** PARTIAL PASS
- **Status:** SUCCESS (frontend deployed; backend service pending)
## Smoke results
| Check | Result | Notes |
|---|---|---|
| `GET /enduro/` | ✅ 200 | index.html |
| `GET /enduro/app.js` | ✅ 200 | core frontend |
| `GET /enduro/app.css` | ✅ 200 | styles |
| `GET /enduro/gps_tracks.js` | ✅ 200 | **новый модуль ET-008** |
| `GET /enduro/units.js` | ✅ 200 | |
| `GET /enduro/gpx.js` | ✅ 200 | |
| `GET /enduro/api/health` | ✅ 200 | `{"status":"ok","db_exists":true}` |
| `GET /enduro/api/gps-tracks/health` | ⚠️ 404 | backend-сервис `gps-collector` не поднят |
## Что задеплоено
- **Frontend:** `src/web/gps_tracks.js` — новый модуль GPS-треков (588 строк)
- **Frontend:** изменения в `app.js`, `app.css`, `index.html` (чекбокс, фильтр-панель)
- **Backend:** `src/api/gps_tracks/` — пакет API (endpoint, mvt, db, dedup, models, sources)
- **Migration:** `migrations/gps_tracks_001_init.sql`
- **Scripts:** `scripts/gps_collect.py` — pipeline сбора треков
- **Config:** `config/gps_sources.yaml`, `config/gps_regions.yaml`
- **Docker:** новый сервис `gps-collector` в `docker-compose.yml`
- **Tests:** 141 pytest + 22 JS unit (все зелёные на ветке)
## Pending actions
1. **Backend service start:** запустить `docker compose up -d gps-collector` на хосте
для активации `/api/gps-tracks/*` эндпойнтов.
2. **E2E Playwright:** после старта сервиса выполнить E-01, E-02, E-10…E-12.
3. **Initial pipeline run:** `docker compose run --rm gps-collector python scripts/gps_collect.py`
для первичной загрузки OSM-треков.
4. **P2 follow-up:** F-06 (bbox area validation), F-07 (default sources), F-08 (LRU cache)
— отдельный PR.
## Test report reference
`docs/work-items/ET-008/13-test-report.md` v3 — verdict: `stage:ready-to-deploy`
- 141 pytest PASS
- 22 JS unit PASS
- All P0/P1 findings resolved (F-01…F-05)

View File

@@ -0,0 +1,40 @@
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dedup_key TEXT NOT NULL UNIQUE,
name TEXT,
description TEXT,
activity_type TEXT,
user TEXT,
created_at TEXT,
length_m REAL NOT NULL,
points_count INTEGER NOT NULL,
min_lon REAL NOT NULL,
min_lat REAL NOT NULL,
max_lon REAL NOT NULL,
max_lat REAL NOT NULL,
geom BLOB NOT NULL,
sources_json TEXT NOT NULL,
external_urls_json TEXT NOT NULL,
tags_json TEXT,
inserted_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
source_priority INTEGER NOT NULL DEFAULT 999
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tracks_dedup ON tracks(dedup_key);
CREATE INDEX IF NOT EXISTS idx_tracks_activity ON tracks(activity_type);
CREATE INDEX IF NOT EXISTS idx_tracks_created ON tracks(created_at);
CREATE INDEX IF NOT EXISTS idx_tracks_bbox ON tracks(min_lon, max_lon, min_lat, max_lat);
CREATE TABLE IF NOT EXISTS pipeline_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
finished_at TEXT,
region_id TEXT NOT NULL,
source_id TEXT NOT NULL,
status TEXT NOT NULL,
tracks_new INTEGER DEFAULT 0,
tracks_updated INTEGER DEFAULT 0,
errors_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_pipeline_started ON pipeline_runs(started_at);

366
scripts/gps_collect.py Normal file
View File

@@ -0,0 +1,366 @@
#!/usr/bin/env python3
"""CLI pipeline для сбора GPS-треков из публичных источников (ET-008).
Usage:
python scripts/gps_collect.py [--region <id>] [--source <id>] [--dry-run] [--gc]
Exit code: 0 (success) or 1 (any error/skip)
"""
import argparse
import asyncio
import importlib
import json
import logging
import os
import sys
from datetime import datetime, timezone
# Добавляем корень проекта в PYTHONPATH
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.api.gps_tracks.config import load_regions_config, load_sources_config
from src.api.gps_tracks.db import init_db, open_db, upsert_track
from src.api.gps_tracks.dedup import compute_dedup_key
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stderr,
)
logger = logging.getLogger("gps_collect")
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _check_license_adr(adr_path: str, project_root: str) -> str:
"""Читает ADR файл и возвращает статус ('accepted', 'proposed', ...).
Returns:
str статус или 'unknown' если файл не найден/не парсится
"""
full_path = os.path.join(project_root, adr_path)
if not os.path.exists(full_path):
logger.warning("ADR file not found: %s", full_path)
return "unknown"
try:
import yaml
with open(full_path, "r", encoding="utf-8") as f:
content = f.read()
# Ищем YAML front-matter или поле status
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
front_matter = yaml.safe_load(parts[1])
if isinstance(front_matter, dict) and "status" in front_matter:
return str(front_matter["status"]).lower()
# Fallback: ищем строку "status: <value>"
for line in content.splitlines():
stripped = line.strip().lower()
if stripped.startswith("status:"):
value = stripped.split(":", 1)[1].strip()
return value
return "unknown"
except Exception as exc:
logger.warning("Failed to parse ADR %s: %s", adr_path, exc)
return "unknown"
def _record_pipeline_run(
conn,
region_id: str,
source_id: str,
started_at: str,
finished_at: str,
status: str,
tracks_new: int = 0,
tracks_updated: int = 0,
errors: list = None,
) -> None:
"""Записывает результат запуска pipeline в БД."""
errors_json = json.dumps(errors) if errors else None
conn.execute(
"""
INSERT INTO pipeline_runs
(started_at, finished_at, region_id, source_id, status,
tracks_new, tracks_updated, errors_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
started_at,
finished_at,
region_id,
source_id,
status,
tracks_new,
tracks_updated,
errors_json,
),
)
conn.commit()
async def _collect_source_for_region(
region: dict,
source_cfg: dict,
conn,
dry_run: bool,
) -> dict:
"""Запускает сбор треков для одного (region, source).
Returns:
dict с ключами: status, tracks_new, tracks_updated, errors
"""
source_id = source_cfg["id"]
region_id = region["id"]
bbox = tuple(region["bbox"]) # (west, south, east, north)
parser_module_path = source_cfg.get("parser_module", "")
if not parser_module_path:
return {"status": "error", "tracks_new": 0, "tracks_updated": 0, "errors": ["No parser_module"]}
try:
module = importlib.import_module(parser_module_path)
# Конвенция: класс называется <CamelCase>Parser
class_name = source_id.replace("_", " ").title().replace(" ", "") + "Parser"
parser_class = getattr(module, class_name, None)
if parser_class is None:
# Fallback: первый класс с суффиксом Parser
for name in dir(module):
if name.endswith("Parser") and name != "SourceParser":
parser_class = getattr(module, name)
break
if parser_class is None:
return {
"status": "error",
"tracks_new": 0,
"tracks_updated": 0,
"errors": [f"Parser class not found in {parser_module_path}"],
}
parser = parser_class(source_cfg)
except Exception as exc:
return {
"status": "error",
"tracks_new": 0,
"tracks_updated": 0,
"errors": [f"Failed to load parser: {exc}"],
}
tracks_new = 0
tracks_updated = 0
errors = []
source_priority = source_cfg.get("source_priority", 50)
try:
async for track in parser.collect(bbox, {"dry_run": dry_run, "conn": conn}):
if dry_run:
logger.info("[dry-run] Would upsert track from %s: %s", source_id, track.external_id)
tracks_new += 1
continue
try:
dedup_key = compute_dedup_key(
(track.min_lon, track.min_lat, track.max_lon, track.max_lat),
{"length_m": track.length_m, "created_at": track.created_at},
)
result = upsert_track(conn, track, dedup_key, source_priority)
if result == "inserted":
tracks_new += 1
else:
tracks_updated += 1
except Exception as exc:
errors.append(f"upsert error for {track.external_id}: {exc}")
logger.error("Upsert error: %s", exc)
except NotImplementedError as exc:
return {
"status": "error",
"tracks_new": 0,
"tracks_updated": 0,
"errors": [str(exc)],
}
except Exception as exc:
errors.append(str(exc))
logger.error("Collect error for %s/%s: %s", region_id, source_id, exc)
return {
"status": "error",
"tracks_new": tracks_new,
"tracks_updated": tracks_updated,
"errors": errors,
}
status = "ok" if not errors else "partial"
return {
"status": status,
"tracks_new": tracks_new,
"tracks_updated": tracks_updated,
"errors": errors,
}
async def main() -> int:
"""Главная функция pipeline сбора GPS-треков."""
parser = argparse.ArgumentParser(description="GPS tracks collection pipeline")
parser.add_argument("--region", help="Region ID to process (all if not set)")
parser.add_argument("--source", help="Source ID to process (all if not set)")
parser.add_argument("--dry-run", action="store_true", help="Simulate without writing to DB")
parser.add_argument("--gc", action="store_true", help="Run garbage collection after each region")
args = parser.parse_args()
project_root = os.path.join(os.path.dirname(__file__), "..")
sources_config_path = os.environ.get(
"GPS_SOURCES_CONFIG",
os.path.join(project_root, "config/gps_sources.yaml"),
)
regions_config_path = os.environ.get(
"GPS_REGIONS_CONFIG",
os.path.join(project_root, "config/gps_regions.yaml"),
)
db_path = os.environ.get(
"GPS_TRACKS_DB_PATH",
os.path.join(project_root, "data/gps_tracks.sqlite"),
)
# Загружаем конфигурации
try:
sources = load_sources_config(sources_config_path)
regions = load_regions_config(regions_config_path)
except Exception as exc:
logger.error("Failed to load config: %s", exc)
return 1
# Фильтруем по параметрам CLI
if args.region:
regions = [r for r in regions if r["id"] == args.region]
if not regions:
logger.error("Region '%s' not found", args.region)
return 1
if args.source:
sources = [s for s in sources if s["id"] == args.source]
if not sources:
logger.error("Source '%s' not found", args.source)
return 1
# Открываем БД
try:
conn = open_db(db_path)
init_db(conn)
except Exception as exc:
logger.error("Failed to open DB: %s", exc)
return 1
# Строим индекс источников по id
sources_by_id = {s["id"]: s for s in sources}
has_error = False
for region in regions:
if not region.get("enabled", True):
logger.info("Skipping disabled region: %s", region["id"])
continue
region_sources = region.get("sources", [])
for source_id in region_sources:
if source_id not in sources_by_id:
logger.warning("Source '%s' not found in sources config", source_id)
continue
source_cfg = sources_by_id[source_id]
# Фильтр по --source
if args.source and source_cfg["id"] != args.source:
continue
if not source_cfg.get("enabled", False):
logger.info("Skipping disabled source: %s", source_id)
started_at = _now_iso()
_record_pipeline_run(
conn,
region["id"],
source_id,
started_at,
_now_iso(),
"skipped_disabled",
)
continue
# Проверяем лицензию
license_adr = source_cfg.get("license_adr", "")
started_at = _now_iso()
if license_adr:
license_status = _check_license_adr(license_adr, project_root)
if license_status != "accepted":
logger.warning(
"Skipping %s/%s: license ADR status is '%s' (need 'accepted')",
region["id"],
source_id,
license_status,
)
_record_pipeline_run(
conn,
region["id"],
source_id,
started_at,
_now_iso(),
"skipped_license",
)
has_error = True
continue
logger.info(
"Collecting %s for region %s (bbox=%s)",
source_id,
region["id"],
region["bbox"],
)
result = await _collect_source_for_region(region, source_cfg, conn, args.dry_run)
finished_at = _now_iso()
_record_pipeline_run(
conn,
region["id"],
source_id,
started_at,
finished_at,
result["status"],
result["tracks_new"],
result["tracks_updated"],
result["errors"] or None,
)
logger.info(
"Done %s/%s: status=%s new=%d updated=%d errors=%d",
region["id"],
source_id,
result["status"],
result["tracks_new"],
result["tracks_updated"],
len(result["errors"]),
)
if result["status"] in ("error",):
has_error = True
if args.gc:
import gc
gc.collect()
logger.info("GC collected after region %s", region["id"])
conn.close()
return 1 if has_error else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

View File

@@ -0,0 +1,89 @@
"""Загрузка и валидация конфигурации GPS-источников и регионов (ET-008)."""
import yaml
def load_sources_config(path: str) -> list:
"""Загружает конфигурацию источников GPS-треков.
Args:
path: путь к YAML-файлу конфигурации источников
Returns:
list[dict] — список источников
Raises:
ValueError: при ошибках валидации
FileNotFoundError: если файл не найден
"""
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
sources = data.get("sources", [])
if not isinstance(sources, list):
raise ValueError("sources must be a list")
for src in sources:
if not src.get("id"):
raise ValueError(f"Source missing 'id': {src}")
if not src.get("base_url"):
raise ValueError(f"Source '{src['id']}' missing 'base_url'")
# Enabled source must have license_adr
if src.get("enabled", False):
if not src.get("license_adr"):
raise ValueError(
f"Enabled source '{src['id']}' must have 'license_adr'"
)
return sources
def load_regions_config(path: str) -> list:
"""Загружает конфигурацию регионов для сбора GPS-треков.
Args:
path: путь к YAML-файлу конфигурации регионов
Returns:
list[dict] — список регионов
Raises:
ValueError: при ошибках валидации
FileNotFoundError: если файл не найден
"""
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
regions = data.get("regions", [])
if not isinstance(regions, list):
raise ValueError("regions must be a list")
for reg in regions:
if not reg.get("id"):
raise ValueError(f"Region missing 'id': {reg}")
bbox = reg.get("bbox")
if not bbox or len(bbox) != 4:
raise ValueError(f"Region '{reg['id']}' must have bbox with 4 values")
west, south, east, north = bbox
# Валидация диапазонов координат
if not (-180 <= west <= 180):
raise ValueError(f"Region '{reg['id']}' bbox west={west} out of range")
if not (-180 <= east <= 180):
raise ValueError(f"Region '{reg['id']}' bbox east={east} out of range")
if not (-90 <= south <= 90):
raise ValueError(f"Region '{reg['id']}' bbox south={south} out of range")
if not (-90 <= north <= 90):
raise ValueError(f"Region '{reg['id']}' bbox north={north} out of range")
if west >= east:
raise ValueError(
f"Region '{reg['id']}' bbox: west must be < east"
)
if south >= north:
raise ValueError(
f"Region '{reg['id']}' bbox: south must be < north"
)
return regions

232
src/api/gps_tracks/db.py Normal file
View File

@@ -0,0 +1,232 @@
"""Функции работы с БД для GPS-треков (ET-008)."""
import json
import os
import sqlite3
from datetime import datetime, timezone
from typing import Optional
from src.api.gps_tracks.models import TrackInsert
_MIGRATION_PATH = os.path.join(
os.path.dirname(__file__), "../../../migrations/gps_tracks_001_init.sql"
)
def open_db(db_path: str) -> sqlite3.Connection:
"""Открывает соединение с SQLite БД."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db(conn: sqlite3.Connection) -> None:
"""Применяет миграцию SQL для создания схемы."""
migration_path = os.path.abspath(_MIGRATION_PATH)
with open(migration_path, "r", encoding="utf-8") as f:
sql = f.read()
# Выполняем каждый statement отдельно (executescript не поддерживает параметры,
# но зато не требует явного commit)
conn.executescript(sql)
conn.commit()
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def upsert_track(
conn: sqlite3.Connection,
track: TrackInsert,
dedup_key: str,
source_priority: int,
) -> str:
"""Вставляет или обновляет трек в БД.
При коллизии dedup_key:
- UNION sources (без дублей)
- UNION external_urls (без дублей)
- Метаданные обновляются если новый source_priority < существующего
Returns:
"inserted" или "updated"
"""
cur = conn.cursor()
now = _now_iso()
# Проверяем существующую запись
cur.execute(
"SELECT id, sources_json, external_urls_json, name, description, activity_type, "
"user, created_at, source_priority FROM tracks WHERE dedup_key = ?",
(dedup_key,),
)
existing = cur.fetchone()
if existing is None:
# INSERT новой записи
sources = [track.source_id]
ext_urls = [track.external_url] if track.external_url else []
cur.execute(
"""
INSERT INTO tracks (
dedup_key, name, description, activity_type, user, created_at,
length_m, points_count, min_lon, min_lat, max_lon, max_lat,
geom, sources_json, external_urls_json, tags_json,
inserted_at, updated_at, source_priority
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
dedup_key,
track.name,
track.description,
track.activity_type,
track.user,
track.created_at,
track.length_m,
track.points_count,
track.min_lon,
track.min_lat,
track.max_lon,
track.max_lat,
track.geom_wkb,
json.dumps(sources),
json.dumps(ext_urls),
json.dumps(track.tags) if track.tags else json.dumps([]),
now,
now,
source_priority,
),
)
conn.commit()
return "inserted"
else:
# UPDATE: мержим sources и external_urls
existing_sources = json.loads(existing["sources_json"] or "[]")
existing_urls = json.loads(existing["external_urls_json"] or "[]")
# Union без дублей, сохраняя порядок
merged_sources = list(dict.fromkeys(existing_sources + [track.source_id]))
new_urls = [track.external_url] if track.external_url else []
merged_urls = list(dict.fromkeys(existing_urls + new_urls))
# Получаем текущий source_priority (может отсутствовать в старых записях)
existing_priority = existing["source_priority"] if "source_priority" in existing.keys() else 999
# Обновляем метаданные только если новый источник имеет более высокий приоритет
if source_priority < existing_priority:
cur.execute(
"""
UPDATE tracks SET
name = ?,
description = ?,
activity_type = ?,
user = ?,
created_at = ?,
sources_json = ?,
external_urls_json = ?,
updated_at = ?,
source_priority = ?
WHERE dedup_key = ?
""",
(
track.name,
track.description,
track.activity_type,
track.user,
track.created_at,
json.dumps(merged_sources),
json.dumps(merged_urls),
now,
source_priority,
dedup_key,
),
)
else:
# Только обновляем sources/urls и updated_at
cur.execute(
"""
UPDATE tracks SET
sources_json = ?,
external_urls_json = ?,
updated_at = ?
WHERE dedup_key = ?
""",
(
json.dumps(merged_sources),
json.dumps(merged_urls),
now,
dedup_key,
),
)
conn.commit()
return "updated"
def get_tracks_in_bbox(
conn: sqlite3.Connection,
west: float,
south: float,
east: float,
north: float,
activities: Optional[list] = None,
sources: Optional[list] = None,
limit: int = 500,
) -> tuple:
"""Возвращает треки в указанном bbox.
Returns:
(tracks: list[sqlite3.Row], total_count: int)
"""
cur = conn.cursor()
# Базовое условие bbox
conditions = [
"min_lon <= :east",
"max_lon >= :west",
"min_lat <= :north",
"max_lat >= :south",
]
params: dict = {"west": west, "south": south, "east": east, "north": north}
# Фильтр по activity_type
if activities:
placeholders = ",".join(f":act{i}" for i in range(len(activities)))
conditions.append(f"activity_type IN ({placeholders})")
for i, act in enumerate(activities):
params[f"act{i}"] = act
where_clause = " AND ".join(conditions)
# Подсчёт общего числа (без фильтра по source, он применяется постфактум)
count_sql = f"SELECT COUNT(*) as cnt FROM tracks WHERE {where_clause}"
cur.execute(count_sql, params)
total_count = cur.fetchone()["cnt"]
# Основной запрос
select_sql = f"""
SELECT id, dedup_key, name, description, activity_type, user,
created_at, length_m, points_count,
min_lon, min_lat, max_lon, max_lat,
sources_json, external_urls_json, tags_json,
inserted_at, updated_at, geom
FROM tracks
WHERE {where_clause}
LIMIT :limit
"""
params["limit"] = limit
cur.execute(select_sql, params)
rows = cur.fetchall()
# Постфильтрация по sources (если задан)
if sources:
filtered = []
for row in rows:
row_sources = json.loads(row["sources_json"] or "[]")
if any(s in row_sources for s in sources):
filtered.append(row)
rows = filtered
return rows, total_count

View File

@@ -0,0 +1,32 @@
"""Функции дедупликации GPS-треков (ET-008)."""
def compute_dedup_key(geom_bounds: tuple, metadata: dict) -> str:
"""Вычисляет ключ дедупликации для трека.
Args:
geom_bounds: (min_lon, min_lat, max_lon, max_lat)
metadata: dict с полями length_m и created_at
Returns:
Строка вида "{bbox_round}|{length_bucket}|{date_bucket}"
"""
min_lon, min_lat, max_lon, max_lat = geom_bounds
# Округление bbox до 2 знаков после запятой
bbox_round = (
round(min_lon, 2),
round(min_lat, 2),
round(max_lon, 2),
round(max_lat, 2),
)
# Длина в бакетах по 1 км
length_m = metadata.get("length_m", 0) or 0
length_bucket = round(length_m / 1000) * 1000
# Дата: первые 10 символов (YYYY-MM-DD) или пустая строка
created_at = metadata.get("created_at") or ""
date_bucket = created_at[:10] if created_at else ""
return f"{bbox_round}|{length_bucket}|{date_bucket}"

View File

@@ -0,0 +1,310 @@
"""FastAPI router для GPS-треков (ET-008)."""
import json
import os
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Response
from src.api.gps_tracks.db import get_tracks_in_bbox, init_db, open_db
from src.api.gps_tracks.mvt import (
_gps_tile_cache,
build_gps_mvt,
clear_gps_tile_cache,
get_gps_cached_tile,
set_gps_cached_tile,
_tile_to_bbox,
)
def _parse_bbox(bbox_str: str) -> tuple:
"""Парсит и валидирует bbox строку 'west,south,east,north'.
Returns:
(west, south, east, north)
Raises:
HTTPException 400 при невалидных значениях
"""
try:
parts = [float(v.strip()) for v in bbox_str.split(",")]
except (ValueError, AttributeError):
raise HTTPException(400, "bbox must be 4 comma-separated floats")
if len(parts) != 4:
raise HTTPException(400, "bbox must have exactly 4 values: west,south,east,north")
west, south, east, north = parts
if not (-180 <= west <= 180) or not (-180 <= east <= 180):
raise HTTPException(400, "bbox longitude values must be in range -180..180")
if not (-90 <= south <= 90) or not (-90 <= north <= 90):
raise HTTPException(400, "bbox latitude values must be in range -90..90")
if west >= east:
raise HTTPException(400, "bbox west must be < east")
if south >= north:
raise HTTPException(400, "bbox south must be < north")
return west, south, east, north
def _row_to_geojson_feature(row) -> dict:
"""Конвертирует sqlite3.Row в GeoJSON Feature."""
from src.api.gps_tracks.mvt import _wkb_to_coords
coords = _wkb_to_coords(row["geom"])
sources = json.loads(row["sources_json"] or "[]")
ext_urls = json.loads(row["external_urls_json"] or "[]")
tags = json.loads(row["tags_json"] or "[]")
activity_type = row["activity_type"] or "other"
first_source = sources[0] if sources else ""
length_m = row["length_m"] or 0
length_km = round(length_m / 1000, 2)
geometry = None
if coords:
geometry = {"type": "LineString", "coordinates": coords}
return {
"type": "Feature",
"geometry": geometry,
"properties": {
"id": row["id"],
"dedup_key": row["dedup_key"],
"name": row["name"],
"description": row["description"],
"activity_type": row["activity_type"],
"activity": activity_type,
"user": row["user"],
"created_at": row["created_at"],
"length_m": row["length_m"],
"length_km": length_km,
"points_count": row["points_count"],
"sources": sources,
"source": first_source,
"external_urls": ext_urls,
"tags": tags,
"inserted_at": row["inserted_at"],
"updated_at": row["updated_at"],
},
}
def create_gps_router(db_path: str) -> APIRouter:
"""Создаёт FastAPI router для GPS-треков.
Args:
db_path: путь к SQLite БД для GPS-треков
Returns:
APIRouter с prefix="/api/gps-tracks"
"""
router = APIRouter(prefix="/api/gps-tracks", tags=["gps-tracks"])
def _get_conn():
conn = open_db(db_path)
init_db(conn)
return conn
@router.get("")
async def get_tracks(
bbox: str = Query(..., description="west,south,east,north"),
activity: Optional[str] = Query(None, description="Comma-separated activity types"),
source: Optional[str] = Query(None, description="Comma-separated source ids"),
limit: int = Query(500, ge=1, le=2000),
):
"""Возвращает GPS-треки в bbox как GeoJSON FeatureCollection."""
west, south, east, north = _parse_bbox(bbox)
activities = [a.strip() for a in activity.split(",")] if activity else None
sources = [s.strip() for s in source.split(",")] if source else None
try:
conn = _get_conn()
rows, total_count = get_tracks_in_bbox(
conn, west, south, east, north,
activities=activities,
sources=sources,
limit=limit,
)
conn.close()
except Exception as exc:
raise HTTPException(500, f"DB error: {exc}")
features = [_row_to_geojson_feature(row) for row in rows]
returned = len(features)
return {
"type": "FeatureCollection",
"features": features,
"total_in_bbox": total_count,
"returned": returned,
"truncated": total_count > returned,
}
@router.get("/tiles/{z}/{x}/{y}.mvt")
async def get_gps_tile(z: int, x: int, y: int):
"""Возвращает MVT тайл с GPS-треками."""
if z < 0 or z > 22:
raise HTTPException(400, "Invalid z")
max_coord = 2 ** z
if x < 0 or x >= max_coord or y < 0 or y >= max_coord:
raise HTTPException(400, "Invalid x/y for zoom level")
# Проверяем кэш
cached = get_gps_cached_tile(z, x, y)
if cached is not None:
return Response(
content=cached,
media_type="application/x-protobuf",
headers={
"Content-Encoding": "identity",
"Access-Control-Allow-Origin": "*",
"X-Cache": "HIT",
},
)
west, south, east, north = _tile_to_bbox(z, x, y)
# Небольшой буфер для edge features
buf_x = (east - west) * 0.1
buf_y = (north - south) * 0.1
try:
conn = _get_conn()
rows, _ = get_tracks_in_bbox(
conn,
west - buf_x,
south - buf_y,
east + buf_x,
north + buf_y,
limit=25000,
)
conn.close()
except Exception as exc:
raise HTTPException(500, f"DB error: {exc}")
mvt = build_gps_mvt(rows, z, x, y)
if mvt:
set_gps_cached_tile(z, x, y, mvt)
return Response(
content=mvt,
media_type="application/x-protobuf",
headers={
"Content-Encoding": "identity",
"Access-Control-Allow-Origin": "*",
"X-Cache": "MISS",
},
)
@router.get("/health")
async def gps_health():
"""Статистика GPS-треков БД.
Поле last_pipeline_run агрегирует все записи pipeline_runs,
принадлежащие последнему запуску (по максимальному started_at).
Возвращает None если прогонов ещё не было.
"""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute("SELECT COUNT(*) as cnt FROM tracks")
total_tracks = cur.fetchone()["cnt"]
cur.execute(
"SELECT activity_type, COUNT(*) as cnt FROM tracks GROUP BY activity_type"
)
by_activity = {row["activity_type"] or "other": row["cnt"] for row in cur.fetchall()}
# REQ-F-12: агрегированный объект по всем строкам последнего прогона.
# Все строки одного запуска pipeline имеют одинаковый started_at —
# pipeline устанавливает его перед итерацией по (region, source).
cur.execute(
"""
SELECT started_at, finished_at, region_id, source_id,
status, tracks_new, errors_json
FROM pipeline_runs
WHERE started_at = (SELECT MAX(started_at) FROM pipeline_runs)
ORDER BY region_id, source_id
"""
)
run_rows = cur.fetchall()
if run_rows:
regions: list = []
sources_ok: list = []
sources_error: list = []
tracks_added = 0
finished_at_values: list = []
for row in run_rows:
region = row["region_id"]
if region not in regions:
regions.append(region)
if row["status"] in ("ok", "partial"):
sources_ok.append(row["source_id"])
else:
sources_error.append(row["source_id"])
tracks_added += row["tracks_new"] or 0
if row["finished_at"]:
finished_at_values.append(row["finished_at"])
last_run: Optional[dict] = {
"started_at": run_rows[0]["started_at"],
"finished_at": max(finished_at_values) if finished_at_values else None,
"regions": regions,
"sources_ok": sources_ok,
"sources_error": sources_error,
"tracks_added": tracks_added,
}
else:
last_run = None
cur.execute("SELECT sources_json FROM tracks")
tracks_by_source: dict = {}
for trow in cur.fetchall():
try:
src_list = json.loads(trow["sources_json"] or "[]")
except Exception:
src_list = []
for src in src_list:
tracks_by_source[src] = tracks_by_source.get(src, 0) + 1
conn.close()
except Exception as exc:
raise HTTPException(500, f"DB error: {exc}")
db_size_mb = 0.0
try:
db_size_mb = os.path.getsize(db_path) / 1024 / 1024
except OSError:
pass
return {
"status": "ok",
"db_path": db_path,
"tracks_total": total_tracks,
"tracks_by_activity": by_activity,
"last_pipeline_run": last_run,
"db_size_mb": db_size_mb,
"tracks_by_source": tracks_by_source,
"tile_cache_size": len(_gps_tile_cache),
}
@router.post("/cache/clear")
async def clear_cache():
"""Сбрасывает LRU-кэш GPS-тайлов."""
clear_gps_tile_cache()
return {"status": "ok", "cleared": True}
return router

View File

@@ -0,0 +1,52 @@
"""Pydantic-модели и константы для публичных GPS-треков (ET-008)."""
from pydantic import BaseModel
from typing import Optional, List
ACTIVITY_TYPES = [
"enduro", "moto", "offroad", "bicycle", "hike", "ski", "other"
]
class TrackRecord(BaseModel):
"""Трек из БД, готовый к отдаче через API."""
id: int
dedup_key: str
name: Optional[str] = None
description: Optional[str] = None
activity_type: Optional[str] = "other"
user: Optional[str] = None
created_at: Optional[str] = None
length_m: float
points_count: int
min_lon: float
min_lat: float
max_lon: float
max_lat: float
sources: List[str]
external_urls: List[str]
tags: List[str]
inserted_at: str
updated_at: str
class TrackInsert(BaseModel):
"""Трек для вставки в БД (из парсера)."""
external_id: str
source_id: str
external_url: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
activity_type: str = "other"
user: Optional[str] = None
created_at: Optional[str] = None
length_m: float
points_count: int
geom_wkb: bytes # WKB bytes
min_lon: float
min_lat: float
max_lon: float
max_lat: float
tags: List[str] = []
source_priority: int = 999

167
src/api/gps_tracks/mvt.py Normal file
View File

@@ -0,0 +1,167 @@
"""MVT-тайлы для GPS-треков (ET-008)."""
import json
import math
import struct
from typing import Optional
from shapely.geometry import LineString
# ─── LRU-like tile cache ─────────────────────────────────────────────────────
_gps_tile_cache: dict = {}
_GPS_TILE_CACHE_MAX = 1024
def get_gps_cached_tile(z: int, x: int, y: int) -> Optional[bytes]:
return _gps_tile_cache.get((z, x, y))
def set_gps_cached_tile(z: int, x: int, y: int, data: bytes) -> None:
if len(_gps_tile_cache) >= _GPS_TILE_CACHE_MAX:
# FIFO вытеснение
_gps_tile_cache.pop(next(iter(_gps_tile_cache)))
_gps_tile_cache[(z, x, y)] = data
def clear_gps_tile_cache() -> None:
_gps_tile_cache.clear()
# ─── Geometry helpers ────────────────────────────────────────────────────────
def _simplify_coords(coords: list, z: int) -> list:
"""Упрощает геометрию трека по зуму через Douglas-Peucker."""
if z >= 12:
return coords
elif z >= 10:
tolerance = 0.0005 # ~50м
elif z >= 8:
tolerance = 0.002 # ~200м
else:
tolerance = 0.008 # ~800м на z7 и ниже
if len(coords) < 3:
return coords
line = LineString(coords)
simplified = line.simplify(tolerance, preserve_topology=False)
result = list(simplified.coords)
return result if len(result) >= 2 else coords
def _wkb_to_coords(blob: bytes) -> Optional[list]:
"""Парсит WKB LineString, возвращает [(lon, lat), ...]."""
try:
b = bytes(blob)
if len(b) < 9:
return None
endian = "<" if b[0] == 1 else ">"
gtype = struct.unpack_from(endian + "I", b, 1)[0]
base_type = gtype & 0xFF
if base_type != 2:
return None
offset = 5
if gtype & 0x20000000:
offset += 4
npts = struct.unpack_from(endian + "I", b, offset)[0]
offset += 4
coords = []
for _ in range(npts):
lon, lat = struct.unpack_from(endian + "dd", b, offset)
offset += 16
coords.append((lon, lat))
return coords if len(coords) >= 2 else None
except Exception:
return None
def _tile_to_bbox(z: int, x: int, y: int) -> tuple:
n = 2 ** z
west = x / n * 360.0 - 180.0
east = (x + 1) / n * 360.0 - 180.0
north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
return west, south, east, north
# ─── MVT builder ─────────────────────────────────────────────────────────────
def build_gps_mvt(rows: list, z: int, x: int, y: int) -> bytes:
"""Собирает MVT тайл с layer 'gps_tracks'.
Args:
rows: список sqlite3.Row из таблицы tracks
z, x, y: координаты тайла
Returns:
bytes — protobuf MVT или b"" если нет фич
"""
import mapbox_vector_tile
west, south, east, north = _tile_to_bbox(z, x, y)
# Min-length фильтр по зуму
if z <= 7:
min_length_m = 2000
limit = 3000
elif z <= 9:
min_length_m = 0
limit = 8000
elif z <= 11:
min_length_m = 0
limit = 15000
else:
min_length_m = 0
limit = 25000
features = []
for row in rows:
length_m = row["length_m"] or 0
# Min-length фильтр
if min_length_m > 0 and length_m < min_length_m:
continue
if len(features) >= limit:
break
coords = _wkb_to_coords(row["geom"])
if not coords:
continue
coords = _simplify_coords(coords, z)
try:
sources_list = json.loads(row["sources_json"] or "[]")
sources_str = ",".join(sources_list)
first_source = sources_list[0] if sources_list else ""
ext_urls = json.loads(row["external_urls_json"] or "[]")
ext_url = ext_urls[0] if ext_urls else ""
props = {
"id": row["id"],
"activity": row["activity_type"] or "other",
"source": first_source,
"sources": sources_str,
"length_km": round(length_m / 1000, 2),
"name": row["name"] or "",
"ext_url": ext_url,
}
features.append({
"geometry": {"type": "LineString", "coordinates": coords},
"properties": props,
})
except Exception:
continue
if not features:
return b""
return mapbox_vector_tile.encode(
[{"name": "gps_tracks", "features": features}],
quantize_bounds=(west, south, east, north),
extents=4096,
default_options={"y_coord_down": False},
)

View File

View File

@@ -0,0 +1,34 @@
"""Базовый класс для парсеров GPS-источников (ET-008)."""
from src.api.gps_tracks.models import ACTIVITY_TYPES
class SourceParser:
"""Базовый класс для всех парсеров GPS-источников."""
MAPPING: dict = {} # source-category → ACTIVITY_TYPE
def __init__(self, source_config: dict):
self.config = source_config
def map_activity(self, raw_category: str) -> str:
"""Маппит категорию источника в ACTIVITY_TYPES enum."""
if not raw_category:
return "other"
mapped = self.MAPPING.get(raw_category.lower(), "other")
if mapped not in ACTIVITY_TYPES:
return "other"
return mapped
async def collect(self, bbox: tuple, ctx: dict):
"""Асинхронный генератор треков. Реализуется в наследниках.
Args:
bbox: (west, south, east, north)
ctx: контекст выполнения (db conn, logger, etc.)
Yields:
TrackInsert объекты
"""
raise NotImplementedError
return
yield # make it a generator

View File

@@ -0,0 +1,17 @@
"""Парсер EnduroRussia.ru — заглушка (ADR-010 status=proposed)."""
from src.api.gps_tracks.sources.base import SourceParser
class EnduroRussiaParser(SourceParser):
"""Парсер EnduroRussia.ru.
Заблокирован до получения лицензии. См. ADR-010.
"""
MAPPING = {"enduro": "enduro", "мото": "moto"}
async def collect(self, bbox, ctx):
# ADR-010: blocked, status=proposed
raise NotImplementedError("EnduroRussia parser not yet licensed (ADR-010)")
return
yield # make it a generator

View File

@@ -0,0 +1,413 @@
"""Парсер OSM Public GPS Traces (ET-008)."""
import asyncio
import math
import logging
from typing import AsyncGenerator
import defusedxml.ElementTree as ET
import httpx
from src.api.gps_tracks.models import TrackInsert
from src.api.gps_tracks.sources.base import SourceParser
logger = logging.getLogger(__name__)
# Пространства имён GPX
_GPX_NS = {
"gpx0": "http://www.topografix.com/GPX/1/0",
"gpx1": "http://www.topografix.com/GPX/1/1",
}
class OsmParser(SourceParser):
"""Парсер OSM Public GPS Traces API."""
MAPPING = {
"enduro": "enduro",
"moto": "moto",
"motorcycle": "moto",
"mtb": "bicycle",
"bicycle": "bicycle",
"bike": "bicycle",
"hike": "hike",
"hiking": "hike",
"running": "hike",
"ski": "ski",
"skiing": "ski",
"offroad": "offroad",
"4x4": "offroad",
}
async def collect(self, bbox: tuple, ctx: dict) -> AsyncGenerator[TrackInsert, None]:
"""Собирает треки из OSM Public GPS Traces API.
Args:
bbox: (west, south, east, north)
ctx: контекст (может содержать 'dry_run', 'session')
Yields:
TrackInsert объекты
"""
west, south, east, north = bbox
rate_limit = self.config.get("rate_limit_sec", 1)
base_url = self.config.get("base_url", "https://api.openstreetmap.org/api/0.6")
user_agent = self.config.get("user_agent", "enduro-trails/1.0")
source_id = self.config.get("id", "osm")
ext_url_template = self.config.get("external_url_template", "")
headers = {"User-Agent": user_agent}
# Разбиваем bbox на ячейки 0.25°
cells = split_bbox_for_osm((west, south, east, north))
async with httpx.AsyncClient(timeout=30, headers=headers) as client:
for cell_bbox in cells:
cell_west, cell_south, cell_east, cell_north = cell_bbox
page = 0
while True:
url = (
f"{base_url}/trackpoints"
f"?bbox={cell_west},{cell_south},{cell_east},{cell_north}"
f"&page={page}"
)
try:
resp = await _fetch_with_backoff(client, url)
if resp is None:
break
if resp.status_code == 204:
break
if resp.status_code != 200:
logger.warning("OSM API returned %d for %s", resp.status_code, url)
break
content = resp.content
except Exception as exc:
logger.error("Error fetching %s: %s", url, exc)
break
# Парсим GPX ответ
tracks = _parse_gpx_trackpoints(content, source_id, ext_url_template)
if not tracks:
break # Пустая страница — больше треков нет
# Обогащаем треки метаданными из OSM API
gpx_ids = [t.external_id for t in tracks]
meta_map = await _batch_fetch_gpx_meta(
client, base_url, gpx_ids, headers, rate_limit
)
for track in tracks:
meta = meta_map.get(track.external_id)
if meta:
updates = {}
if meta.get("activity_type") is not None:
updates["activity_type"] = meta["activity_type"]
if meta.get("name") is not None:
updates["name"] = meta["name"]
if meta.get("description") is not None:
updates["description"] = meta["description"]
if meta.get("user") is not None:
updates["user"] = meta["user"]
if updates:
track = track.model_copy(update=updates)
yield track
page += 1
await asyncio.sleep(rate_limit)
def split_bbox_for_osm(region_bbox: tuple, cell_size: float = 0.25) -> list:
"""Разбивает регион на ячейки cell_size градусов для OSM API.
OSM API требует bbox не более 0.25° x 0.25°.
Args:
region_bbox: (west, south, east, north)
cell_size: размер ячейки в градусах (по умолчанию 0.25)
Returns:
list of (west, south, east, north) tuples
"""
west, south, east, north = region_bbox
cells = []
# Перебираем ячейки с запада на восток, с юга на север
lat = south
while lat < north:
cell_south = lat
cell_north = min(lat + cell_size, north)
lon = west
while lon < east:
cell_west = lon
cell_east = min(lon + cell_size, east)
cells.append((
round(cell_west, 6),
round(cell_south, 6),
round(cell_east, 6),
round(cell_north, 6),
))
lon += cell_size
lat += cell_size
return cells
def _haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
"""Расстояние между двумя точками в метрах."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def _calc_track_length(coords: list) -> float:
"""Считает длину трека через Haversine."""
total = 0.0
for i in range(len(coords) - 1):
total += _haversine_m(coords[i][0], coords[i][1], coords[i + 1][0], coords[i + 1][1])
return total
def _parse_gpx_trackpoints(content: bytes, source_id: str, ext_url_template: str) -> list:
"""Парсит GPX-ответ OSM API с треками.
Группирует trkpt по атрибуту gpx_id.
Анонимные точки (без gpx_id) пропускаются.
Returns:
list[TrackInsert]
"""
try:
# defusedxml защищает от XXE
root = ET.fromstring(content)
except Exception as exc:
logger.error("Failed to parse GPX: %s", exc)
return []
# Группируем точки по gpx_id
tracks_points: dict = {}
# Определяем namespace
ns = ""
tag = root.tag
if tag.startswith("{"):
ns = tag.split("}")[0] + "}"
# Ищем trkpt напрямую и через trk/trkseg
trkpt_elements = []
# Вариант 1: OSM возвращает trkpt напрямую в корне (API 0.6 trackpoints endpoint)
for child in root:
local = child.tag.replace(ns, "") if ns else child.tag
if local == "trk":
for trkseg in child:
local2 = trkseg.tag.replace(ns, "") if ns else trkseg.tag
if local2 == "trkseg":
for trkpt in trkseg:
trkpt_elements.append(trkpt)
elif local == "trkpt":
trkpt_elements.append(child)
for trkpt in trkpt_elements:
gpx_id = trkpt.get("gpx_id") or trkpt.get("{http://www.topografix.com/GPX/1/0}gpx_id")
if not gpx_id:
# Анонимные точки — пропускаем
continue
try:
lat = float(trkpt.get("lat", 0))
lon = float(trkpt.get("lon", 0))
except (TypeError, ValueError):
continue
if gpx_id not in tracks_points:
tracks_points[gpx_id] = []
# Получаем время из дочернего элемента
time_elem = None
for child in trkpt:
local = child.tag.replace(ns, "") if ns else child.tag
if local == "time":
time_elem = child
break
time_str = time_elem.text if time_elem is not None else None
tracks_points[gpx_id].append((lon, lat, time_str))
results = []
for gpx_id, points in tracks_points.items():
if len(points) < 2:
continue
coords = [(p[0], p[1]) for p in points]
# Вычисляем bbox
lons = [c[0] for c in coords]
lats = [c[1] for c in coords]
min_lon, max_lon = min(lons), max(lons)
min_lat, max_lat = min(lats), max(lats)
# Длина трека
length_m = _calc_track_length(coords)
if length_m < 10: # Слишком короткий трек — пропускаем
continue
# Дата из первой точки с временем
created_at = None
for p in points:
if p[2]:
created_at = p[2][:19].replace("T", "T") # ISO без миллисекунд
break
# WKB из shapely
try:
from shapely.geometry import LineString
from shapely import wkb
geom = LineString(coords)
geom_wkb = wkb.dumps(geom)
except Exception:
continue
# External URL
ext_url = None
if ext_url_template:
ext_url = ext_url_template.format(
user="",
external_id_numeric=gpx_id,
)
track = TrackInsert(
external_id=str(gpx_id),
source_id=source_id,
external_url=ext_url,
name=None,
description=None,
activity_type="other",
user=None,
created_at=created_at,
length_m=length_m,
points_count=len(coords),
geom_wkb=geom_wkb,
min_lon=min_lon,
min_lat=min_lat,
max_lon=max_lon,
max_lat=max_lat,
tags=[],
source_priority=50,
)
results.append(track)
return results
async def _fetch_with_backoff(
client: httpx.AsyncClient,
url: str,
max_retries: int = 3,
) -> httpx.Response | None:
"""Выполняет HTTP-запрос с экспоненциальным backoff."""
for attempt in range(max_retries):
try:
resp = await client.get(url)
if resp.status_code == 429:
wait = 2 ** attempt * 2
logger.warning("Rate limited, waiting %ds", wait)
await asyncio.sleep(wait)
continue
return resp
except httpx.TimeoutException:
wait = 2 ** attempt
logger.warning("Timeout on attempt %d, waiting %ds", attempt + 1, wait)
await asyncio.sleep(wait)
except Exception as exc:
logger.error("Request failed: %s", exc)
return None
return None
def _parse_gpx_meta_response(content: bytes) -> dict | None:
"""Парсит XML-ответ OSM API /gpx/<id>.
Returns:
dict с ключами activity_type, name, description, user или None при ошибке XML.
Если gpx_file элемент отсутствует — возвращает dict со всеми None-значениями.
"""
try:
root = ET.fromstring(content)
except Exception as exc:
logger.debug("Failed to parse GPX meta XML: %s", exc)
return None
gpx_file = root.find("gpx_file")
if gpx_file is None:
return {"activity_type": None, "name": None, "description": None, "user": None}
name = gpx_file.get("name")
user = gpx_file.get("user")
desc_elem = gpx_file.find("description")
description = desc_elem.text if desc_elem is not None else None
# Сопоставляем теги через MAPPING (берём первое совпадение)
activity_type = None
for tag_elem in gpx_file.findall("tag"):
tag_text = (tag_elem.text or "").strip().lower()
if tag_text in OsmParser.MAPPING:
activity_type = OsmParser.MAPPING[tag_text]
break
return {
"activity_type": activity_type,
"name": name,
"description": description,
"user": user,
}
async def _fetch_gpx_meta(
client: httpx.AsyncClient,
base_url: str,
gpx_id: str,
headers: dict,
) -> dict | None:
"""Загружает метаданные одного GPX-трека через OSM API /gpx/<id>."""
url = f"{base_url}/gpx/{gpx_id}"
try:
resp = await _fetch_with_backoff(client, url)
if resp is None or resp.status_code != 200:
return None
return _parse_gpx_meta_response(resp.content)
except Exception as exc:
logger.warning("Failed to fetch GPX meta for %s: %s", gpx_id, exc)
return None
async def _batch_fetch_gpx_meta(
client: httpx.AsyncClient,
base_url: str,
gpx_ids: list,
headers: dict,
rate_limit: float,
batch_size: int = 20,
) -> dict:
"""Загружает метаданные GPX-треков пакетами через asyncio.gather.
Returns:
dict {gpx_id: meta_dict}
"""
result = {}
for i in range(0, len(gpx_ids), batch_size):
batch = gpx_ids[i: i + batch_size]
metas = await asyncio.gather(
*[_fetch_gpx_meta(client, base_url, gid, headers) for gid in batch],
return_exceptions=False,
)
for gid, meta in zip(batch, metas):
if meta is not None:
result[gid] = meta
if i + batch_size < len(gpx_ids):
await asyncio.sleep(rate_limit)
return result

View File

@@ -0,0 +1,17 @@
"""Парсер Тропинки.ру — заглушка (ADR-011 status=proposed)."""
from src.api.gps_tracks.sources.base import SourceParser
class TtrailsParser(SourceParser):
"""Парсер Тропинки.ру.
Заблокирован до получения лицензии. См. ADR-011.
"""
MAPPING = {"велосипед": "bicycle", "пешком": "hike", "мото": "moto"}
async def collect(self, bbox, ctx):
# ADR-011: blocked, status=proposed
raise NotImplementedError("Ttrails parser not yet licensed (ADR-011)")
return
yield # make it a generator

View File

@@ -14,6 +14,11 @@ import sqlite3
import itertools import itertools
GPS_TRACKS_DB_PATH = os.environ.get(
"GPS_TRACKS_DB_PATH",
os.path.join(os.path.dirname(__file__), "../../data/gps_tracks.sqlite"),
)
from shapely.geometry import LineString from shapely.geometry import LineString
from typing import List from typing import List
@@ -1246,6 +1251,10 @@ async def terrain_tile(layer: str, z: int, x: int, y: int):
# ─── Static files ───────────────────────────────────────────────────────────── # ─── Static files ─────────────────────────────────────────────────────────────
from src.api.gps_tracks.endpoint import create_gps_router
gps_router = create_gps_router(GPS_TRACKS_DB_PATH)
app.include_router(gps_router)
if os.path.exists(STATIC_DIR): if os.path.exists(STATIC_DIR):
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")

View File

@@ -3,3 +3,6 @@ uvicorn==0.29.0
shapely==2.0.4 shapely==2.0.4
mapbox-vector-tile==2.2.0 mapbox-vector-tile==2.2.0
httpx==0.27.0 httpx==0.27.0
defusedxml==0.7.1
lxml==5.2.2
pyyaml==6.0.1

View File

@@ -1227,3 +1227,76 @@ body.satellite-active #btn-basemap {
border: 2px solid #fff; border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.5); box-shadow: 0 1px 4px rgba(0,0,0,0.5);
} }
/* ─── ET-008: GPS-треки ──────────────────────────── */
.terrain-link-btn {
display: block;
margin: 4px 0 0 24px;
background: none;
border: none;
color: var(--accent, #ff8c1a);
font-size: 12px;
cursor: pointer;
padding: 2px 0;
text-decoration: underline;
}
.gps-filter-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 12px;
}
.gps-filter-chip {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
color: var(--text);
}
.gps-filter-chip input[type=checkbox] {
accent-color: var(--accent, #ff8c1a);
width: 14px;
height: 14px;
}
.gps-stats-row {
font-size: 12px;
color: var(--text2);
margin-top: 8px;
}
/* Track popup */
.track-popup {
font-size: 13px;
color: var(--text, #fff);
min-width: 220px;
}
.track-popup-name {
font-weight: 700;
font-size: 14px;
margin-bottom: 6px;
}
.track-popup-row {
margin: 3px 0;
color: var(--text2, #ccc);
}
.track-popup-sources {
margin-top: 8px;
font-size: 12px;
}
.track-popup-sources a {
color: var(--accent, #ff8c1a);
text-decoration: none;
}
.track-popup-sources a:hover {
text-decoration: underline;
}

View File

@@ -134,6 +134,10 @@ function rebuildMapOverlays() {
restoreTerrainState(); restoreTerrainState();
restoreTrailsState(); restoreTrailsState();
restorePoiState(); restorePoiState();
// ET-008: публичные GPS-треки
if (typeof restorePublicTracksState === 'function') {
restorePublicTracksState();
}
// Re-apply recon circle if active // Re-apply recon circle if active
if (reconMode && reconCenter) { if (reconMode && reconCenter) {
@@ -3041,6 +3045,10 @@ function applyBaseLayer(base) {
// ET-007 P1-6: halo синхронизирован с состоянием чекбоксов // ET-007 P1-6: halo синхронизирован с состоянием чекбоксов
// «Грунтовки» / «Тропы», а не безусловно включён. // «Грунтовки» / «Тропы», а не безусловно включён.
_applyTrailHaloVisibility(map, 'satellite'); _applyTrailHaloVisibility(map, 'satellite');
// ET-008: halo публичных треков на спутнике
if (typeof applyGpsHaloVisibility === 'function') {
applyGpsHaloVisibility(map);
}
_applyPoiSatellitePaint(map, true); _applyPoiSatellitePaint(map, true);
_applyBackgroundForSatellite(map, true); _applyBackgroundForSatellite(map, true);
} else { } else {
@@ -3057,6 +3065,10 @@ function applyBaseLayer(base) {
_setBodyClass('satellite-active', false); _setBodyClass('satellite-active', false);
// На «Схеме» halo всегда скрыт независимо от чекбоксов. // На «Схеме» halo всегда скрыт независимо от чекбоксов.
_applyTrailHaloVisibility(map, 'schematic'); _applyTrailHaloVisibility(map, 'schematic');
// ET-008: halo публичных треков выключить
if (typeof applyGpsHaloVisibility === 'function') {
applyGpsHaloVisibility(map);
}
_applyPoiSatellitePaint(map, false); _applyPoiSatellitePaint(map, false);
_applyBackgroundForSatellite(map, false); _applyBackgroundForSatellite(map, false);
} }

588
src/web/gps_tracks.js Normal file
View File

@@ -0,0 +1,588 @@
// ═══════════════════════════════════════════════════════════════════
// gps_tracks.js — ET-008: Публичные GPS-треки
// ═══════════════════════════════════════════════════════════════════
// ─── Константы ────────────────────────────────────────────────────
const GPS_TRACKS_ZOOM_CUTOFF = 12; // ниже — MVT, выше — GeoJSON
const GPS_TRACKS_MIN_ZOOM = 8; // ниже — слой скрыт
const GPS_SOURCE_COLORS = {
osm: '#3cb44b',
enduro_russia: '#e6194b',
ttrails: '#4363d8',
offmaps: '#f58231',
nakarte: '#911eb4',
};
const GPS_FALLBACK_COLORS = ['#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324', '#fffac8'];
const GPS_ACTIVITY_COLORS = {
enduro: '#e6194b',
moto: '#f58231',
offroad: '#ffe119',
bicycle: '#3cb44b',
hike: '#4363d8',
ski: '#42d4f4',
other: '#808080',
};
const GPS_ACTIVITY_ICONS = {
enduro: '🏍',
moto: '🛵',
offroad: '🚙',
bicycle: '🚵',
hike: '🥾',
ski: '⛷️',
other: '📍',
};
const GPS_ACTIVITY_LABELS = {
enduro: 'Эндуро',
moto: 'Мото',
offroad: 'Off-road',
bicycle: 'Велосипед',
hike: 'Пешком',
ski: 'Лыжи',
other: 'Другое',
};
// ─── Состояние ───────────────────────────────────────────────────
window.gpsTracksLayer = {
enabled: false,
filters: {
activities: ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'],
sources: ['osm', 'enduro_russia', 'ttrails'],
colorMode: 'source'
},
sourceId: 'gps-tracks-tiles',
sourceGeoId: 'gps-tracks-geo',
layerId: 'gps-tracks-layer-mvt',
layerGeoId: 'gps-tracks-layer-geo',
layerHaloId: 'gps-tracks-halo-mvt-satellite',
layerHaloGeoId: 'gps-tracks-halo-geo-satellite',
geojsonAbortController: null,
geojsonReqDebounceTimer: null,
stats: { total: 0, shown: 0 }
};
// ─── Цветовые выражения MapLibre ──────────────────────────────────
function _buildColorExpression(mode) {
if (mode === 'activity') {
const expr = ['match', ['get', 'activity']];
for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) {
expr.push(act, color);
}
expr.push('#808080'); // fallback
return expr;
} else {
// по источнику
const expr = ['match', ['get', 'source']];
for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) {
expr.push(src, color);
}
expr.push('#808080'); // fallback
return expr;
}
}
// ─── Layer definitions ────────────────────────────────────────────
function _gpsLayerDef(id, source, sourceLayer) {
const colorExpr = _buildColorExpression(window.gpsTracksLayer.filters.colorMode);
return {
id,
type: 'line',
source,
'source-layer': sourceLayer || undefined,
paint: {
'line-color': colorExpr,
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1.0, 12, 2.0, 16, 3.0],
'line-opacity': 0.75,
},
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' }
};
}
function _gpsHaloDef(id, source, sourceLayer) {
return {
id,
type: 'line',
source,
'source-layer': sourceLayer || undefined,
paint: {
'line-color': '#ffffff',
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 2.5, 12, 4.0, 16, 6.0],
'line-opacity': 0.6,
},
layout: { visibility: 'none' }
};
}
// ─── Создание/удаление sources и layers ──────────────────────────
function _ensureGpsSources(map) {
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
if (!map.getSource(window.gpsTracksLayer.sourceId)) {
map.addSource(window.gpsTracksLayer.sourceId, {
type: 'vector',
tiles: [`${window.location.origin}${basePath}/api/gps-tracks/tiles/{z}/{x}/{y}.mvt`],
minzoom: GPS_TRACKS_MIN_ZOOM,
maxzoom: 11,
attribution: '© OpenStreetMap contributors (ODbL)',
});
}
if (!map.getSource(window.gpsTracksLayer.sourceGeoId)) {
map.addSource(window.gpsTracksLayer.sourceGeoId, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
}
}
function _ensureGpsLayers(map) {
if (!map.getLayer(window.gpsTracksLayer.layerId)) {
const def = _gpsLayerDef(
window.gpsTracksLayer.layerId,
window.gpsTracksLayer.sourceId,
'gps_tracks'
);
// Добавить поверх trails, ниже route (если есть)
const before = _findGpsInsertPosition(map);
map.addLayer(def, before);
}
if (!map.getLayer(window.gpsTracksLayer.layerGeoId)) {
const def = _gpsLayerDef(
window.gpsTracksLayer.layerGeoId,
window.gpsTracksLayer.sourceGeoId,
null
);
delete def['source-layer'];
const before = _findGpsInsertPosition(map);
map.addLayer(def, before);
}
if (!map.getLayer(window.gpsTracksLayer.layerHaloId)) {
const def = _gpsHaloDef(
window.gpsTracksLayer.layerHaloId,
window.gpsTracksLayer.sourceId,
'gps_tracks'
);
const before = window.gpsTracksLayer.layerId;
map.addLayer(def, before);
}
if (!map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) {
const def = _gpsHaloDef(
window.gpsTracksLayer.layerHaloGeoId,
window.gpsTracksLayer.sourceGeoId,
null
);
delete def['source-layer'];
const before = window.gpsTracksLayer.layerGeoId;
map.addLayer(def, before);
}
}
function _findGpsInsertPosition(map) {
/**
* Returns the id of the first layer that GPS tracks should be inserted
* below, using priority order:
* 1. gpx-layer-* — ET-006 GPX file layers (highest priority)
* 2. route-* — ET-002 routing layers
* Returns undefined if neither is present (GPS tracks go on top).
*/
const style = map.getStyle && map.getStyle();
if (!style || !style.layers) return undefined;
// Priority 1: gpx-layer-* (ET-006 GPX file layers)
const gpxLayer = style.layers.find(l => l.id.startsWith('gpx-layer-'));
if (gpxLayer) return gpxLayer.id;
// Priority 2: route-* (ET-002 routing layers)
const routeLayer = style.layers.find(l => l.id.startsWith('route-'));
if (routeLayer) return routeLayer.id;
return undefined;
}
// ─── Управление видимостью ────────────────────────────────────────
function _syncGpsLayersVisibility(map) {
const enabled = window.gpsTracksLayer.enabled;
const zoom = map.getZoom ? map.getZoom() : 0;
const mvtVisible = enabled && zoom >= GPS_TRACKS_MIN_ZOOM && zoom < GPS_TRACKS_ZOOM_CUTOFF;
const geoVisible = enabled && zoom >= GPS_TRACKS_ZOOM_CUTOFF;
const setVis = (layerId, visible) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
}
};
setVis(window.gpsTracksLayer.layerId, mvtVisible);
setVis(window.gpsTracksLayer.layerGeoId, geoVisible);
// Hint «Зум 8+»
const hint = document.getElementById('public-tracks-zoom-hint');
if (hint) {
hint.style.display = (enabled && zoom < GPS_TRACKS_MIN_ZOOM) ? 'inline' : 'none';
}
// Halo обновляется через applyGpsHaloVisibility
applyGpsHaloVisibility(map);
}
// ─── Halo ──────────────────────────────────────────────────────────
function applyGpsHaloVisibility(map) {
if (!map) return;
const zoom = map.getZoom ? map.getZoom() : 0;
const isSatellite = document.body.classList.contains('satellite-active');
const enabled = window.gpsTracksLayer.enabled;
const mvtHaloOn = enabled && isSatellite && zoom >= GPS_TRACKS_MIN_ZOOM && zoom < GPS_TRACKS_ZOOM_CUTOFF;
const geoHaloOn = enabled && isSatellite && zoom >= GPS_TRACKS_ZOOM_CUTOFF;
if (map.getLayer(window.gpsTracksLayer.layerHaloId)) {
map.setLayoutProperty(window.gpsTracksLayer.layerHaloId, 'visibility', mvtHaloOn ? 'visible' : 'none');
}
if (map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) {
map.setLayoutProperty(window.gpsTracksLayer.layerHaloGeoId, 'visibility', geoHaloOn ? 'visible' : 'none');
}
}
// ─── Фильтрация ───────────────────────────────────────────────────
function applyGpsFilter() {
const map = window._map;
if (!map) return;
const { activities, sources } = window.gpsTracksLayer.filters;
const filter = ['all',
['in', ['get', 'activity'], ['literal', activities]],
['in', ['get', 'source'], ['literal', sources]]
];
if (map.getLayer(window.gpsTracksLayer.layerId)) {
map.setFilter(window.gpsTracksLayer.layerId, filter);
}
if (map.getLayer(window.gpsTracksLayer.layerGeoId)) {
map.setFilter(window.gpsTracksLayer.layerGeoId, filter);
}
if (map.getLayer(window.gpsTracksLayer.layerHaloId)) {
map.setFilter(window.gpsTracksLayer.layerHaloId, filter);
}
if (map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) {
map.setFilter(window.gpsTracksLayer.layerHaloGeoId, filter);
}
_updateGpsStatsUI();
}
// ─── GeoJSON загрузка ─────────────────────────────────────────────
function onGpsMapMoveEnd() {
const map = window._map;
if (!map || !window.gpsTracksLayer.enabled) return;
if (map.getZoom() < GPS_TRACKS_ZOOM_CUTOFF) return;
clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer);
window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => {
fetchAndUpdateGpsGeoJson(map.getBounds());
}, 500);
}
async function fetchAndUpdateGpsGeoJson(bounds) {
const map = window._map;
if (!map) return;
if (window.gpsTracksLayer.geojsonAbortController) {
window.gpsTracksLayer.geojsonAbortController.abort();
}
const ctrl = new AbortController();
window.gpsTracksLayer.geojsonAbortController = ctrl;
const { activities, sources } = window.gpsTracksLayer.filters;
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
const bbox = `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`;
const url = `${basePath}/api/gps-tracks?bbox=${bbox}&activity=${activities.join(',')}&source=${sources.join(',')}&limit=500`;
try {
const resp = await fetch(url, { signal: ctrl.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json = await resp.json();
if (map.getSource(window.gpsTracksLayer.sourceGeoId)) {
map.getSource(window.gpsTracksLayer.sourceGeoId).setData(json);
}
window.gpsTracksLayer.stats = { total: json.total_in_bbox || 0, shown: json.returned || 0 };
if (json.truncated) {
// показываем toast один раз
if (typeof showToast === 'function') {
showToast(`Показаны ${json.returned} треков из ${json.total_in_bbox}. Увеличьте zoom для полной выборки`);
}
}
_updateGpsStatsUI();
} catch (e) {
if (e.name === 'AbortError') return;
if (typeof showToast === 'function') showToast('Не удалось загрузить треки');
}
}
// ─── Popup при клике ──────────────────────────────────────────────
function _renderTrackPopupHtml(props) {
const name = props.name || 'Без названия';
const activity = props.activity_type || props.activity || 'other';
const icon = GPS_ACTIVITY_ICONS[activity] || '📍';
const actLabel = GPS_ACTIVITY_LABELS[activity] || activity;
const lengthKm = typeof props.length_km === 'number' ? props.length_km.toFixed(1) : '—';
const points = props.points_count || '—';
const dateStr = props.created_at ? new Date(props.created_at).toLocaleDateString('ru-RU', {day:'numeric',month:'long',year:'numeric'}) : null;
const user = props.user || null;
let sourcesHtml = '';
try {
let srcs = props.sources;
let urls = props.external_urls;
if (typeof srcs === 'string') srcs = srcs.split(',').filter(Boolean);
if (typeof urls === 'string') urls = urls.split(',').filter(Boolean);
if (Array.isArray(srcs) && srcs.length) {
sourcesHtml = '<div class="track-popup-sources">Источники: ' +
srcs.map((s, i) => {
const url = Array.isArray(urls) && urls[i] ? urls[i] : null;
const label = s;
return url
? `<a href="${url}" target="_blank" rel="noopener">${label} ↗</a>`
: `<span>${label}</span>`;
}).join(' · ') + '</div>';
}
} catch(e) {}
return `
<div class="track-popup">
<div class="track-popup-name">${name}</div>
<div class="track-popup-row">${icon} ${actLabel}</div>
<div class="track-popup-row">📏 ${lengthKm} км · ${points} точек</div>
${dateStr ? `<div class="track-popup-row">📅 ${dateStr}</div>` : ''}
${user ? `<div class="track-popup-row">👤 ${user}</div>` : ''}
${sourcesHtml}
</div>
`;
}
function _setupGpsClickHandler(map) {
const layerIds = [window.gpsTracksLayer.layerId, window.gpsTracksLayer.layerGeoId];
layerIds.forEach(layerId => {
map.on('click', layerId, (e) => {
// Не открывать popup если активен другой режим
if (window._routeMode || window._reconMode || window._scenicMode || window._rulerMode) return;
const feature = e.features && e.features[0];
if (!feature) return;
new maplibregl.Popup({ closeOnClick: true, maxWidth: '300px' })
.setLngLat(e.lngLat)
.setHTML(_renderTrackPopupHtml(feature.properties))
.addTo(map);
});
map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; });
});
}
// ─── Включение/выключение слоя ────────────────────────────────────
function onPublicTracksCheckbox() {
const cb = document.getElementById('public-tracks-cb');
const filterBtn = document.getElementById('public-tracks-filters-btn');
if (!cb) return;
window.gpsTracksLayer.enabled = cb.checked;
localStorage.setItem('gps-tracks-enabled', cb.checked ? 'true' : 'false');
const map = window._map;
if (!map) return;
if (cb.checked) {
_ensureGpsSources(map);
_ensureGpsLayers(map);
_setupGpsClickHandler(map);
// Убедиться, что moveend listener есть
map.off('moveend', onGpsMapMoveEnd);
map.on('moveend', onGpsMapMoveEnd);
map.off('zoomend', onGpsZoomEnd);
map.on('zoomend', onGpsZoomEnd);
}
_syncGpsLayersVisibility(map);
applyGpsFilter();
// Фильтры btn
if (filterBtn) filterBtn.style.display = cb.checked ? 'block' : 'none';
// Если включили и zoom >= 12 — загрузить GeoJSON
if (cb.checked && map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) {
fetchAndUpdateGpsGeoJson(map.getBounds());
}
}
function onGpsZoomEnd() {
const map = window._map;
if (!map) return;
_syncGpsLayersVisibility(map);
// При переходе на z>=12 загрузить GeoJSON
if (window.gpsTracksLayer.enabled && map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) {
clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer);
window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => {
fetchAndUpdateGpsGeoJson(map.getBounds());
}, 500);
}
}
// ─── Sheet фильтров ───────────────────────────────────────────────
function togglePublicTracksFiltersSheet() {
const sheet = document.getElementById('sheet-gps-filters');
if (!sheet) return;
const isOpen = sheet.classList.contains('open');
if (!isOpen) {
_buildGpsFiltersUI();
openSheet('sheet-gps-filters');
} else {
closeAllSheets();
}
}
function _buildGpsFiltersUI() {
// Активности
const actGrid = document.getElementById('gps-activity-grid');
if (actGrid) {
const all = ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'];
actGrid.innerHTML = all.map(act => {
const checked = window.gpsTracksLayer.filters.activities.includes(act);
return `
<label class="gps-filter-chip">
<input type="checkbox" value="${act}" ${checked ? 'checked' : ''} onchange="onGpsActivityFilterChange()">
<span>${GPS_ACTIVITY_ICONS[act]} ${GPS_ACTIVITY_LABELS[act]}</span>
</label>`;
}).join('');
}
// Источники (из localStorage или дефолт)
const srcGrid = document.getElementById('gps-source-grid');
if (srcGrid) {
const allSources = ['osm', 'enduro_russia', 'ttrails'];
const sourceLabels = { osm: 'OSM', enduro_russia: 'EnduroRussia.ru', ttrails: 'Тропинки.ру' };
srcGrid.innerHTML = allSources.map(src => {
const checked = window.gpsTracksLayer.filters.sources.includes(src);
return `
<label class="gps-filter-chip">
<input type="checkbox" value="${src}" ${checked ? 'checked' : ''} onchange="onGpsSourceFilterChange()">
<span>${sourceLabels[src] || src}</span>
</label>`;
}).join('');
}
// Color mode
const colorMode = window.gpsTracksLayer.filters.colorMode;
const btnSrc = document.getElementById('gps-color-by-source');
const btnAct = document.getElementById('gps-color-by-activity');
if (btnSrc) btnSrc.classList.toggle('active', colorMode === 'source');
if (btnAct) btnAct.classList.toggle('active', colorMode === 'activity');
_updateGpsStatsUI();
}
function onGpsActivityFilterChange() {
const checked = [];
document.querySelectorAll('#gps-activity-grid input[type=checkbox]:checked').forEach(cb => checked.push(cb.value));
window.gpsTracksLayer.filters.activities = checked;
localStorage.setItem('gps-tracks-activities', JSON.stringify(checked));
applyGpsFilter();
}
function onGpsSourceFilterChange() {
const checked = [];
document.querySelectorAll('#gps-source-grid input[type=checkbox]:checked').forEach(cb => checked.push(cb.value));
window.gpsTracksLayer.filters.sources = checked;
localStorage.setItem('gps-tracks-sources', JSON.stringify(checked));
applyGpsFilter();
}
function onGpsColorModeChange(mode) {
window.gpsTracksLayer.filters.colorMode = mode;
localStorage.setItem('gps-tracks-color-mode', mode);
const btnSrc = document.getElementById('gps-color-by-source');
const btnAct = document.getElementById('gps-color-by-activity');
if (btnSrc) btnSrc.classList.toggle('active', mode === 'source');
if (btnAct) btnAct.classList.toggle('active', mode === 'activity');
// Перестроить color expression
const map = window._map;
if (!map) return;
const colorExpr = _buildColorExpression(mode);
[window.gpsTracksLayer.layerId, window.gpsTracksLayer.layerGeoId].forEach(layerId => {
if (map.getLayer(layerId)) {
map.setPaintProperty(layerId, 'line-color', colorExpr);
}
});
}
function _updateGpsStatsUI() {
const totalEl = document.getElementById('gps-stat-total');
const shownEl = document.getElementById('gps-stat-shown');
if (totalEl) totalEl.textContent = window.gpsTracksLayer.stats.total || '—';
if (shownEl) shownEl.textContent = window.gpsTracksLayer.stats.shown || '—';
}
// ─── restorePublicTracksState ──────────────────────────────────────
/**
* Восстанавливает состояние слоя публичных треков из localStorage.
* Вызывается из rebuildMapOverlays() в app.js.
*/
function restorePublicTracksState() {
const enabled = localStorage.getItem('gps-tracks-enabled') === 'true';
const cb = document.getElementById('public-tracks-cb');
const filterBtn = document.getElementById('public-tracks-filters-btn');
const activitiesRaw = localStorage.getItem('gps-tracks-activities');
if (activitiesRaw) {
try { window.gpsTracksLayer.filters.activities = JSON.parse(activitiesRaw); } catch(e) {}
}
const sourcesRaw = localStorage.getItem('gps-tracks-sources');
if (sourcesRaw) {
try { window.gpsTracksLayer.filters.sources = JSON.parse(sourcesRaw); } catch(e) {}
}
const colorMode = localStorage.getItem('gps-tracks-color-mode') || 'source';
window.gpsTracksLayer.filters.colorMode = colorMode;
if (cb) cb.checked = enabled;
if (filterBtn) filterBtn.style.display = enabled ? 'block' : 'none';
window.gpsTracksLayer.enabled = enabled;
const map = window._map;
if (!map) return;
if (enabled) {
_ensureGpsSources(map);
_ensureGpsLayers(map);
_setupGpsClickHandler(map);
map.off('moveend', onGpsMapMoveEnd);
map.on('moveend', onGpsMapMoveEnd);
map.off('zoomend', onGpsZoomEnd);
map.on('zoomend', onGpsZoomEnd);
_syncGpsLayersVisibility(map);
applyGpsFilter();
if (map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) {
fetchAndUpdateGpsGeoJson(map.getBounds());
}
}
}

View File

@@ -72,6 +72,17 @@
<span>Тропы</span> <span>Тропы</span>
</label> </label>
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)"> <hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
<!-- ET-008: публичные GPS-треки -->
<label class="terrain-checkbox">
<input type="checkbox" id="public-tracks-cb" onchange="onPublicTracksCheckbox()">
<span>Публичные треки</span>
</label>
<span class="terrain-hint" id="public-tracks-zoom-hint" style="display:none">Зум 8+</span>
<button class="terrain-link-btn" id="public-tracks-filters-btn"
onclick="togglePublicTracksFiltersSheet()" style="display:none">
Фильтры…
</button>
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
<label class="terrain-checkbox"> <label class="terrain-checkbox">
<input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked> <input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked>
<span>POI</span> <span>POI</span>
@@ -463,6 +474,31 @@
</div> </div>
</div> </div>
<!-- ── ET-008: Sheet «GPS-фильтры» ───────────────────────────────── -->
<div class="bottom-sheet" id="sheet-gps-filters">
<div class="sheet-handle"></div>
<div class="sheet-header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M7 12h10M11 18h2"/></svg>
<h2>Фильтры публичных треков</h2>
<button class="sheet-close" onclick="closeAllSheets()"></button>
</div>
<div class="sheet-body">
<div class="section-label">ТИП АКТИВНОСТИ</div>
<div id="gps-activity-grid" class="gps-filter-grid"></div>
<div class="section-label">ИСТОЧНИК</div>
<div id="gps-source-grid" class="gps-filter-grid"></div>
<div class="section-label">ЦВЕТ ЛИНИЙ</div>
<div class="seg-control">
<button class="seg-btn active" id="gps-color-by-source" onclick="onGpsColorModeChange('source')">По источнику</button>
<button class="seg-btn" id="gps-color-by-activity" onclick="onGpsColorModeChange('activity')">По активности</button>
</div>
<div class="gps-stats-row" id="gps-stats-row" style="margin-top:12px">
<span>Всего в области: <b id="gps-stat-total"></b></span>
<span style="margin-left:12px">Видны (фильтр): <b id="gps-stat-shown"></b></span>
</div>
</div>
</div>
<!-- Scripts --> <!-- Scripts -->
<script src="https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.js"></script> <script src="https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/suncalc@1.9.0/suncalc.min.js"></script> <script src="https://unpkg.com/suncalc@1.9.0/suncalc.min.js"></script>
@@ -471,5 +507,7 @@
<script src="app.js"></script> <script src="app.js"></script>
<!-- ET-006: gpx.js подключается после app.js — потребляет его глобали (ADR-002) --> <!-- ET-006: gpx.js подключается после app.js — потребляет его глобали (ADR-002) -->
<script src="gpx.js"></script> <script src="gpx.js"></script>
<!-- ET-008: публичные GPS-треки -->
<script src="gps_tracks.js"></script>
</body> </body>
</html> </html>

0
tests/api/__init__.py Normal file
View File

View File

@@ -0,0 +1,216 @@
"""Unit тесты для дедупликации GPS-треков (ET-008).
U-10: два трека с одинаковым bbox+length+date → один ключ
U-11: разные даты → разные ключи
U-12: bbox-округление до 0.01°
U-13: merge sources при upsert
U-14: merge external_urls
"""
import json
import pytest
from src.api.gps_tracks.dedup import compute_dedup_key
from src.api.gps_tracks.db import open_db, init_db, upsert_track
from src.api.gps_tracks.models import TrackInsert
def _make_track(
external_id="T1",
source_id="osm",
length_m=5000.0,
created_at="2024-05-12T10:00:00Z",
min_lon=37.61,
min_lat=55.75,
max_lon=37.62,
max_lat=55.76,
external_url=None,
name=None,
source_priority=50,
) -> TrackInsert:
"""Хелпер для создания TrackInsert с тестовой WKB геометрией."""
from shapely.geometry import LineString
from shapely import wkb
coords = [(min_lon, min_lat), (max_lon, max_lat)]
geom_wkb = wkb.dumps(LineString(coords))
return TrackInsert(
external_id=external_id,
source_id=source_id,
external_url=external_url,
name=name,
description=None,
activity_type="other",
user=None,
created_at=created_at,
length_m=length_m,
points_count=2,
geom_wkb=geom_wkb,
min_lon=min_lon,
min_lat=min_lat,
max_lon=max_lon,
max_lat=max_lat,
tags=[],
source_priority=source_priority,
)
@pytest.fixture
def db(tmp_path):
"""Создаёт изолированную БД в tmp_path."""
db_path = str(tmp_path / "test.sqlite")
conn = open_db(db_path)
init_db(conn)
yield conn
conn.close()
# ─── U-10: одинаковый bbox+length+date → один ключ ───────────────────────────
def test_u10_same_key_for_same_track():
"""U-10: два трека с одинаковым bbox+length+date дают одинаковый ключ."""
bounds = (37.61, 55.75, 37.62, 55.76)
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
key1 = compute_dedup_key(bounds, meta)
key2 = compute_dedup_key(bounds, meta)
assert key1 == key2
# ─── U-11: разные даты → разные ключи ────────────────────────────────────────
def test_u11_different_dates_give_different_keys():
"""U-11: треки с разными датами дают разные ключи."""
bounds = (37.61, 55.75, 37.62, 55.76)
key1 = compute_dedup_key(bounds, {"length_m": 5000.0, "created_at": "2024-05-12"})
key2 = compute_dedup_key(bounds, {"length_m": 5000.0, "created_at": "2024-05-13"})
assert key1 != key2
# ─── U-12: bbox-округление до 0.01° ─────────────────────────────────────────
def test_u12_bbox_rounding_to_2_decimals():
"""U-12: bbox округляется до 0.01°, незначительные отличия игнорируются."""
# Оба варианта округляются к (37.61, 55.75, 37.62, 55.76)
# Используем значения в середине диапазона, гарантированно округляемые одинаково
bounds1 = (37.6111, 55.7512, 37.6192, 55.7563)
bounds2 = (37.6144, 55.7533, 37.6188, 55.7571)
meta = {"length_m": 5000.0, "created_at": "2024-05-12"}
key1 = compute_dedup_key(bounds1, meta)
key2 = compute_dedup_key(bounds2, meta)
# Оба bbox округляются к (37.61, 55.75, 37.62, 55.76) — ключи одинаковы
assert key1 == key2
def test_u12_significantly_different_bbox_gives_different_key():
"""U-12: существенно разные bbox дают разные ключи."""
bounds1 = (37.61, 55.75, 37.62, 55.76)
bounds2 = (38.00, 56.00, 38.10, 56.10)
meta = {"length_m": 5000.0, "created_at": "2024-05-12"}
key1 = compute_dedup_key(bounds1, meta)
key2 = compute_dedup_key(bounds2, meta)
assert key1 != key2
# ─── U-13: merge sources при upsert ──────────────────────────────────────────
def test_u13_merge_sources_on_upsert(db):
"""U-13: при upsert с тем же dedup_key sources мержатся (union без дублей)."""
bounds = (37.61, 55.75, 37.62, 55.76)
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
dedup_key = compute_dedup_key(bounds, meta)
# Первая вставка — от osm
track1 = _make_track(external_id="T1", source_id="osm", source_priority=50)
result1 = upsert_track(db, track1, dedup_key, source_priority=50)
assert result1 == "inserted"
# Вторая вставка — от другого источника с тем же dedup_key
track2 = _make_track(external_id="T2", source_id="enduro_russia", source_priority=10)
result2 = upsert_track(db, track2, dedup_key, source_priority=10)
assert result2 == "updated"
# Проверяем merged sources
cur = db.cursor()
cur.execute("SELECT sources_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
row = cur.fetchone()
sources = json.loads(row["sources_json"])
assert "osm" in sources
assert "enduro_russia" in sources
assert len(sources) == 2 # без дублей
def test_u13_no_duplicate_sources_on_repeated_upsert(db):
"""U-13: повторный upsert от того же источника не создаёт дублей в sources."""
bounds = (37.61, 55.75, 37.62, 55.76)
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
dedup_key = compute_dedup_key(bounds, meta)
track = _make_track(external_id="T1", source_id="osm")
upsert_track(db, track, dedup_key, source_priority=50)
upsert_track(db, track, dedup_key, source_priority=50)
upsert_track(db, track, dedup_key, source_priority=50)
cur = db.cursor()
cur.execute("SELECT sources_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
row = cur.fetchone()
sources = json.loads(row["sources_json"])
assert sources.count("osm") == 1
# ─── U-14: merge external_urls ───────────────────────────────────────────────
def test_u14_merge_external_urls_on_upsert(db):
"""U-14: external_urls мержатся без дублей при upsert."""
bounds = (37.61, 55.75, 37.62, 55.76)
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
dedup_key = compute_dedup_key(bounds, meta)
url1 = "https://www.openstreetmap.org/user/alice/traces/12345"
url2 = "https://enduro-russia.ru/track/99"
track1 = _make_track(external_id="T1", source_id="osm", external_url=url1)
upsert_track(db, track1, dedup_key, source_priority=50)
track2 = _make_track(external_id="T2", source_id="enduro_russia", external_url=url2)
upsert_track(db, track2, dedup_key, source_priority=10)
cur = db.cursor()
cur.execute("SELECT external_urls_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
row = cur.fetchone()
urls = json.loads(row["external_urls_json"])
assert url1 in urls
assert url2 in urls
assert len(urls) == 2
def test_u14_no_duplicate_urls_on_repeated_upsert(db):
"""U-14: повторный upsert с тем же URL не дублирует его."""
bounds = (37.61, 55.75, 37.62, 55.76)
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
dedup_key = compute_dedup_key(bounds, meta)
url = "https://www.openstreetmap.org/user/alice/traces/12345"
track = _make_track(external_id="T1", source_id="osm", external_url=url)
upsert_track(db, track, dedup_key, source_priority=50)
upsert_track(db, track, dedup_key, source_priority=50)
cur = db.cursor()
cur.execute("SELECT external_urls_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
row = cur.fetchone()
urls = json.loads(row["external_urls_json"])
assert urls.count(url) == 1

View File

@@ -0,0 +1,480 @@
"""Integration тесты для GPS-треков endpoint (ET-008).
I-20: GeoJSON с фильтрами
I-21: truncation
I-22: невалидный bbox → 400
I-23: bbox в океане → пустой
I-30: MVT тайл отдаётся
I-31: cache hit
I-40: health endpoint
"""
import pytest
from httpx import AsyncClient, ASGITransport
from fastapi import FastAPI
from src.api.gps_tracks.db import open_db, init_db, upsert_track
from src.api.gps_tracks.dedup import compute_dedup_key
from src.api.gps_tracks.endpoint import create_gps_router
from src.api.gps_tracks.models import TrackInsert
def _make_test_app(db_path: str) -> FastAPI:
"""Создаёт тестовое FastAPI приложение с GPS router."""
app = FastAPI()
router = create_gps_router(db_path)
app.include_router(router)
return app
def _make_track(
external_id="T1",
source_id="osm",
length_m=5000.0,
created_at="2024-05-12T10:00:00Z",
min_lon=37.60,
min_lat=55.74,
max_lon=37.65,
max_lat=55.78,
activity_type="other",
external_url=None,
source_priority=50,
) -> TrackInsert:
from shapely.geometry import LineString
from shapely import wkb
coords = [
(min_lon, min_lat),
((min_lon + max_lon) / 2, (min_lat + max_lat) / 2),
(max_lon, max_lat),
]
geom_wkb = wkb.dumps(LineString(coords))
return TrackInsert(
external_id=external_id,
source_id=source_id,
external_url=external_url,
name=f"Track {external_id}",
description=None,
activity_type=activity_type,
user=None,
created_at=created_at,
length_m=length_m,
points_count=3,
geom_wkb=geom_wkb,
min_lon=min_lon,
min_lat=min_lat,
max_lon=max_lon,
max_lat=max_lat,
tags=[],
source_priority=source_priority,
)
@pytest.fixture
def db_with_tracks(tmp_path):
"""БД с несколькими тестовыми треками."""
db_path = str(tmp_path / "test.sqlite")
conn = open_db(db_path)
init_db(conn)
# Добавляем треки вокруг Москвы
tracks = [
_make_track("T1", "osm", activity_type="enduro", length_m=8000),
_make_track("T2", "osm", activity_type="moto", length_m=3000,
min_lon=37.70, min_lat=55.80, max_lon=37.75, max_lat=55.85),
_make_track("T3", "enduro_russia", activity_type="bicycle", length_m=12000),
]
for track in tracks:
dedup_key = compute_dedup_key(
(track.min_lon, track.min_lat, track.max_lon, track.max_lat),
{"length_m": track.length_m, "created_at": track.created_at},
)
upsert_track(conn, track, dedup_key, source_priority=50)
conn.close()
yield db_path
@pytest.fixture
def db_with_pipeline_runs(db_with_tracks):
"""БД с треками и записями о прогонах pipeline (REQ-F-12).
Один прогон охватывает два региона и один источник.
Имитирует ситуацию когда pipeline записал две строки
с одинаковым started_at (один запуск скрипта).
"""
db_path = db_with_tracks
conn = open_db(db_path)
conn.executemany(
"""
INSERT INTO pipeline_runs
(started_at, finished_at, region_id, source_id,
status, tracks_new, tracks_updated, errors_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
"2026-05-30T03:00:00Z",
"2026-05-30T04:00:00Z",
"cfo",
"osm",
"ok",
42,
5,
None,
),
(
"2026-05-30T03:00:00Z",
"2026-05-30T05:14:00Z",
"chuvashia",
"osm",
"ok",
10,
2,
None,
),
],
)
conn.commit()
conn.close()
yield db_path
# ─── I-20: GeoJSON с фильтрами ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_i20_geojson_basic(db_with_tracks):
"""I-20: базовый запрос GeoJSON."""
app = _make_test_app(db_with_tracks)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get(
"/api/gps-tracks",
params={"bbox": "37.5,55.7,37.9,55.9"},
)
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "FeatureCollection"
assert isinstance(data["features"], list)
assert len(data["features"]) > 0
assert "total_in_bbox" in data
assert "returned" in data
assert "truncated" in data
@pytest.mark.asyncio
async def test_i20_filter_by_activity(db_with_tracks):
"""I-20: фильтрация по activity_type."""
app = _make_test_app(db_with_tracks)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get(
"/api/gps-tracks",
params={"bbox": "37.5,55.7,37.9,55.9", "activity": "enduro"},
)
assert resp.status_code == 200
data = resp.json()
for feature in data["features"]:
assert feature["properties"]["activity_type"] == "enduro"
@pytest.mark.asyncio
async def test_i20_filter_by_source(db_with_tracks):
"""I-20: фильтрация по source."""
app = _make_test_app(db_with_tracks)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get(
"/api/gps-tracks",
params={"bbox": "37.5,55.7,37.9,55.9", "source": "enduro_russia"},
)
assert resp.status_code == 200
data = resp.json()
# Все returned треки должны иметь enduro_russia в sources
for feature in data["features"]:
assert "enduro_russia" in feature["properties"]["sources"]
# ─── I-21: truncation ────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_i21_truncation(tmp_path):
"""I-21: truncation при limit меньше total."""
db_path = str(tmp_path / "trunc.sqlite")
conn = open_db(db_path)
init_db(conn)
# Создаём 10 треков с разными bbox
for i in range(10):
t = _make_track(
external_id=f"T{i}",
source_id="osm",
min_lon=37.60 + i * 0.001,
min_lat=55.74,
max_lon=37.65 + i * 0.001,
max_lat=55.78,
length_m=5000 + i * 100,
created_at=f"2024-05-{12 + i:02d}T10:00:00Z",
)
dedup_key = compute_dedup_key(
(t.min_lon, t.min_lat, t.max_lon, t.max_lat),
{"length_m": t.length_m, "created_at": t.created_at},
)
upsert_track(conn, t, dedup_key, source_priority=50)
conn.close()
app = _make_test_app(db_path)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get(
"/api/gps-tracks",
params={"bbox": "37.5,55.7,37.9,55.9", "limit": 3},
)
assert resp.status_code == 200
data = resp.json()
assert data["returned"] == 3
assert data["total_in_bbox"] >= 3
assert data["truncated"] is True
# ─── I-22: невалидный bbox → 400 ─────────────────────────────────────────────
@pytest.mark.asyncio
@pytest.mark.parametrize("bad_bbox", [
"abc,def,ghi,jkl", # не числа
"37.5,55.7,37.9", # 3 значения
"37.5,55.7,37.9,55.9,1.0", # 5 значений
"200,55.7,37.9,55.9", # lon out of range
"37.5,95,37.9,55.9", # lat out of range
"37.9,55.7,37.5,55.9", # west > east
"37.5,55.9,37.9,55.7", # south > north
])
async def test_i22_invalid_bbox_returns_400(tmp_path, bad_bbox):
"""I-22: невалидный bbox → 400."""
db_path = str(tmp_path / "test.sqlite")
conn = open_db(db_path)
init_db(conn)
conn.close()
app = _make_test_app(db_path)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get(
"/api/gps-tracks",
params={"bbox": bad_bbox},
)
assert resp.status_code == 400
# ─── I-23: bbox в океане → пустой ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_i23_ocean_bbox_returns_empty(db_with_tracks):
"""I-23: bbox в океане (нет треков) → пустой FeatureCollection."""
app = _make_test_app(db_with_tracks)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get(
"/api/gps-tracks",
# Средина Атлантического океана
params={"bbox": "-30.0,0.0,-20.0,10.0"},
)
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "FeatureCollection"
assert data["features"] == []
assert data["total_in_bbox"] == 0
assert data["truncated"] is False
# ─── I-30: MVT тайл ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_i30_mvt_tile_returns(db_with_tracks):
"""I-30: MVT тайл с треками возвращается."""
app = _make_test_app(db_with_tracks)
# z=10, x=620, y=320 — покрывает Москву
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/api/gps-tracks/tiles/10/620/320.mvt")
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/x-protobuf"
assert "X-Cache" in resp.headers
@pytest.mark.asyncio
async def test_i30_mvt_tile_empty_ocean(tmp_path):
"""I-30: MVT тайл без треков возвращает пустой ответ."""
db_path = str(tmp_path / "empty.sqlite")
conn = open_db(db_path)
init_db(conn)
conn.close()
app = _make_test_app(db_path)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/api/gps-tracks/tiles/10/400/300.mvt")
assert resp.status_code == 200
assert resp.content == b""
# ─── I-31: cache hit ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_i31_cache_hit(db_with_tracks):
"""I-31: второй запрос к тому же тайлу возвращает X-Cache: HIT."""
from src.api.gps_tracks.mvt import clear_gps_tile_cache
clear_gps_tile_cache()
app = _make_test_app(db_with_tracks)
# z=10 x=621 y=319 — близко к Москве, должен вернуть данные
z, x, y = 10, 621, 319
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
# Первый запрос — MISS
resp1 = await client.get(f"/api/gps-tracks/tiles/{z}/{x}/{y}.mvt")
assert resp1.status_code == 200
# Второй запрос к пустому тайлу — кэш не заполняется для пустых
# Используем тайл с треками
resp2 = await client.get(f"/api/gps-tracks/tiles/{z}/{x}/{y}.mvt")
assert resp2.status_code == 200
# Если первый вернул данные, второй должен быть HIT
if resp1.content:
assert resp2.headers.get("X-Cache") == "HIT"
# ─── I-40: health endpoint ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_i40_health_endpoint(db_with_pipeline_runs):
"""I-40: health endpoint возвращает корректную статистику.
REQ-F-12: last_pipeline_run — агрегированный объект, а не сырая строка БД.
Структура: started_at, finished_at, regions[], sources_ok[], sources_error[], tracks_added.
"""
app = _make_test_app(db_with_pipeline_runs)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/api/gps-tracks/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert "tracks_total" in data
assert data["tracks_total"] > 0
assert "tracks_by_activity" in data
# REQ-F-12: агрегированный объект last_pipeline_run
assert "last_pipeline_run" in data
run = data["last_pipeline_run"]
assert run is not None, "last_pipeline_run must not be None when pipeline_runs exist"
# Обязательные поля
assert "started_at" in run
assert "finished_at" in run
assert "regions" in run
assert "sources_ok" in run
assert "sources_error" in run
assert "tracks_added" in run
# Типы
assert isinstance(run["regions"], list)
assert isinstance(run["sources_ok"], list)
assert isinstance(run["sources_error"], list)
assert isinstance(run["tracks_added"], int)
# Нет сырых полей строки БД (region_id, source_id — не агрегированные)
assert "region_id" not in run, "raw DB field region_id must not be present"
assert "source_id" not in run, "raw DB field source_id must not be present"
# Конкретные агрегированные значения из fixture (2 строки одного прогона)
assert run["started_at"] == "2026-05-30T03:00:00Z"
assert run["finished_at"] == "2026-05-30T05:14:00Z" # max из двух строк
assert set(run["regions"]) == {"cfo", "chuvashia"}
assert "osm" in run["sources_ok"]
assert run["sources_error"] == []
assert run["tracks_added"] == 52 # 42 + 10
@pytest.mark.asyncio
async def test_i40_health_empty_db(tmp_path):
"""I-40: health endpoint для пустой БД."""
db_path = str(tmp_path / "empty.sqlite")
conn = open_db(db_path)
init_db(conn)
conn.close()
app = _make_test_app(db_path)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/api/gps-tracks/health")
assert resp.status_code == 200
data = resp.json()
assert data["tracks_total"] == 0
assert data["last_pipeline_run"] is None
# ─── Cache clear endpoint ─────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_cache_clear_endpoint(db_with_tracks):
"""POST /api/gps-tracks/cache/clear очищает кэш."""
app = _make_test_app(db_with_tracks)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post("/api/gps-tracks/cache/clear")
assert resp.status_code == 200
data = resp.json()
assert data["cleared"] is True
# ─── F-01/F-02: GeoJSON normalised properties ─────────────────────────────────
@pytest.mark.asyncio
async def test_f01_f02_geojson_normalised_properties(db_with_tracks):
"""F-01/F-02: GeoJSON features carry activity/source (MVT-compatible) and length_km."""
app = _make_test_app(db_with_tracks)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get(
"/api/gps-tracks",
params={"bbox": "37.5,55.7,37.9,55.9"},
)
assert resp.status_code == 200
data = resp.json()
assert len(data["features"]) > 0
for feature in data["features"]:
props = feature["properties"]
# F-01: MVT-compatible aliases
assert "activity" in props, "activity field missing (F-01)"
assert "source" in props, "source field missing (F-01)"
assert isinstance(props["source"], str), "source must be str (F-01)"
assert props["activity"] == props["activity_type"], "activity must equal activity_type"
# F-02: length in km
assert "length_km" in props, "length_km missing (F-02)"
assert isinstance(props["length_km"], float), "length_km must be float"
if props["length_m"]:
assert abs(props["length_km"] - props["length_m"] / 1000) < 0.01
# ─── F-04: health extended fields ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_f04_health_extended_fields(db_with_tracks):
"""F-04: /health returns db_size_mb, tracks_by_source, tile_cache_size."""
app = _make_test_app(db_with_tracks)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/api/gps-tracks/health")
assert resp.status_code == 200
data = resp.json()
# db_size_mb
assert "db_size_mb" in data, "db_size_mb missing (F-04)"
assert isinstance(data["db_size_mb"], (int, float))
assert data["db_size_mb"] >= 0
# tracks_by_source
assert "tracks_by_source" in data, "tracks_by_source missing (F-04)"
assert isinstance(data["tracks_by_source"], dict)
# tile_cache_size
assert "tile_cache_size" in data, "tile_cache_size missing (F-04)"
assert isinstance(data["tile_cache_size"], int)
assert data["tile_cache_size"] >= 0

View File

@@ -0,0 +1,171 @@
"""Unit тесты для MVT тайлов GPS-треков (ET-008).
U-50: тайл z=10 с треками
U-51: упрощение на z=7
U-52: min-length фильтр
"""
import json
import pytest
from shapely.geometry import LineString
from shapely import wkb
from src.api.gps_tracks.mvt import build_gps_mvt, _simplify_coords, _wkb_to_coords
def _make_mock_row(
track_id=1,
activity_type="enduro",
source_id="osm",
length_m=8000.0,
name="Test Track",
coords=None,
min_lon=37.60,
min_lat=55.74,
max_lon=37.65,
max_lat=55.78,
):
"""Создаёт mock sqlite3.Row как словарь."""
if coords is None:
coords = [
(min_lon, min_lat),
((min_lon + max_lon) / 2, (min_lat + max_lat) / 2),
(max_lon, max_lat),
]
geom_wkb = wkb.dumps(LineString(coords))
# Имитируем sqlite3.Row через dict с поддержкой подписки
class MockRow(dict):
def __getitem__(self, key):
return super().__getitem__(key)
return MockRow({
"id": track_id,
"activity_type": activity_type,
"sources_json": json.dumps([source_id]),
"external_urls_json": json.dumps([]),
"length_m": length_m,
"name": name,
"geom": geom_wkb,
})
# ─── U-50: тайл z=10 с треками ───────────────────────────────────────────────
def test_u50_tile_z10_with_tracks():
"""U-50: build_gps_mvt возвращает непустой тайл при наличии треков."""
rows = [
_make_mock_row(1, "enduro", "osm", length_m=8000),
_make_mock_row(2, "moto", "osm", length_m=5000,
min_lon=37.61, min_lat=55.75, max_lon=37.62, max_lat=55.76),
]
# Тайл z=10, x=620, y=320 — область Москвы
result = build_gps_mvt(rows, z=10, x=620, y=320)
assert isinstance(result, bytes)
assert len(result) > 0
def test_u50_empty_rows_returns_empty_bytes():
"""U-50: пустой список строк возвращает b""."""
result = build_gps_mvt([], z=10, x=620, y=320)
assert result == b""
def test_u50_invalid_geom_row_skipped():
"""U-50: строка с невалидной геометрией пропускается."""
class BadRow(dict):
pass
bad_row = BadRow({
"id": 99,
"activity_type": "other",
"sources_json": '["osm"]',
"external_urls_json": "[]",
"length_m": 5000,
"name": "bad",
"geom": b"\x00\x01\x02", # невалидный WKB
})
good_row = _make_mock_row(1, length_m=5000)
result = build_gps_mvt([bad_row, good_row], z=10, x=620, y=320)
# Не падает, плохая строка пропускается
assert isinstance(result, bytes)
# ─── U-51: упрощение на z=7 ──────────────────────────────────────────────────
def test_u51_simplification_z7_reduces_points():
"""U-51: геометрия упрощается на малых зумах."""
# Создаём трек из 20 точек
coords = [(37.60 + i * 0.001, 55.74 + i * 0.0005) for i in range(20)]
simplified = _simplify_coords(coords, z=7)
# При z=7 tolerance=0.008, ожидаем меньше точек
assert len(simplified) < len(coords)
assert len(simplified) >= 2
def test_u51_no_simplification_z12():
"""U-51: на z=12 упрощение не применяется."""
coords = [(37.60 + i * 0.001, 55.74 + i * 0.0005) for i in range(10)]
result = _simplify_coords(coords, z=12)
assert result == coords
def test_u51_simplification_z10_moderate():
"""U-51: на z=10 умеренное упрощение."""
coords = [(37.60 + i * 0.0001, 55.74 + i * 0.0001) for i in range(30)]
simplified_z10 = _simplify_coords(coords, z=10)
simplified_z7 = _simplify_coords(coords, z=7)
# z=7 должен сильнее упрощать, чем z=10
assert len(simplified_z10) >= len(simplified_z7)
# ─── U-52: min-length фильтр ─────────────────────────────────────────────────
def test_u52_min_length_filter_z7():
"""U-52: на z<=7 треки короче 2000м отфильтровываются."""
short_track = _make_mock_row(1, length_m=1500) # меньше 2000м
long_track = _make_mock_row(2, length_m=5000) # больше 2000м
result_with_short = build_gps_mvt([short_track, long_track], z=7, x=77, y=40)
result_without_short = build_gps_mvt([long_track], z=7, x=77, y=40)
# Результаты должны совпадать (короткий трек отфильтрован)
assert result_with_short == result_without_short
def test_u52_no_min_length_filter_z10():
"""U-52: на z=10 нет min-length фильтра — все треки проходят."""
short_track = _make_mock_row(1, length_m=100)
long_track = _make_mock_row(2, length_m=5000)
result_both = build_gps_mvt([short_track, long_track], z=10, x=620, y=320)
result_long_only = build_gps_mvt([long_track], z=10, x=620, y=320)
# При z=10 оба трека должны включаться (если геометрия пересекается с тайлом)
# result_both может быть больше result_long_only если короткий трек в тайле
assert isinstance(result_both, bytes)
assert isinstance(result_long_only, bytes)
def test_u52_min_length_boundary():
"""U-52: трек ровно 2000м на z=7 проходит фильтр."""
track_2000 = _make_mock_row(1, length_m=2000)
track_1999 = _make_mock_row(2, length_m=1999)
result_2000 = build_gps_mvt([track_2000], z=7, x=77, y=40)
result_1999 = build_gps_mvt([track_1999], z=7, x=77, y=40)
# track_1999 должен быть отфильтрован (строго меньше 2000)
# track_2000 проходит (>= 2000 не выполняется для строгого фильтра < 2000)
# По коду: if min_length_m > 0 and length_m < min_length_m → skip
# 1999 < 2000 → skip, 2000 < 2000 → False → not skipped
assert result_2000 != result_1999 or result_1999 == b""

View File

@@ -0,0 +1,248 @@
"""Unit тесты для OSM GPS-источника (ET-008).
U-42: split_bbox_for_osm разбивает правильно
U-43: длина через Haversine
U-44: защита от XXE через defusedxml
"""
import os
import pytest
from src.api.gps_tracks.sources.osm import (
OsmParser,
split_bbox_for_osm,
_haversine_m,
_parse_gpx_trackpoints,
)
# ─── U-42: split_bbox_for_osm ────────────────────────────────────────────────
def test_u42_split_bbox_basic():
"""U-42: корректное разбиение на ячейки."""
bbox = (37.0, 55.0, 38.0, 56.0) # 1° x 1°
cells = split_bbox_for_osm(bbox, cell_size=0.25)
# 1° / 0.25° = 4 ячейки по каждой оси = 16 ячеек
assert len(cells) == 16
def test_u42_split_bbox_cell_size():
"""U-42: каждая ячейка не больше cell_size по размеру."""
bbox = (29.0, 49.5, 47.5, 60.0) # ЦФО
cells = split_bbox_for_osm(bbox, cell_size=0.25)
for cell in cells:
west, south, east, north = cell
assert east - west <= 0.25 + 1e-9
assert north - south <= 0.25 + 1e-9
def test_u42_split_bbox_covers_region():
"""U-42: все ячейки вместе покрывают весь регион."""
bbox = (37.0, 55.0, 38.0, 56.0)
cells = split_bbox_for_osm(bbox, cell_size=0.25)
min_lon = min(c[0] for c in cells)
min_lat = min(c[1] for c in cells)
max_lon = max(c[2] for c in cells)
max_lat = max(c[3] for c in cells)
assert abs(min_lon - 37.0) < 1e-9
assert abs(min_lat - 55.0) < 1e-9
assert abs(max_lon - 38.0) < 0.25 + 1e-9 # последняя ячейка обрезается
assert abs(max_lat - 56.0) < 0.25 + 1e-9
def test_u42_split_small_bbox():
"""U-42: bbox меньше cell_size даёт одну ячейку."""
bbox = (37.0, 55.0, 37.1, 55.1)
cells = split_bbox_for_osm(bbox, cell_size=0.25)
assert len(cells) == 1
def test_u42_split_bbox_no_overlap():
"""U-42: ячейки не перекрываются (west следующей = east предыдущей)."""
bbox = (37.0, 55.0, 37.5, 55.25)
cells = split_bbox_for_osm(bbox, cell_size=0.25)
# При bbox шириной 0.5° и cell_size=0.25 должно быть 2 ячейки по оси lon
assert len(cells) == 2
# Восток первой ячейки = запад второй
cells_sorted = sorted(cells, key=lambda c: c[0])
assert abs(cells_sorted[0][2] - cells_sorted[1][0]) < 1e-9
# ─── U-43: Haversine длина ───────────────────────────────────────────────────
def test_u43_haversine_known_distance():
"""U-43: проверка haversine на известном расстоянии."""
# Москва (37.617, 55.755) → Химки (37.425, 55.889) ≈ 20 км
dist = _haversine_m(37.617, 55.755, 37.425, 55.889)
assert 18000 < dist < 22000
def test_u43_haversine_zero_distance():
"""U-43: одна точка → расстояние 0."""
dist = _haversine_m(37.617, 55.755, 37.617, 55.755)
assert dist == pytest.approx(0.0, abs=1e-6)
def test_u43_haversine_symmetry():
"""U-43: расстояние A→B = B→A."""
d1 = _haversine_m(37.617, 55.755, 37.425, 55.889)
d2 = _haversine_m(37.425, 55.889, 37.617, 55.755)
assert abs(d1 - d2) < 1e-6
def test_u43_haversine_short_distance():
"""U-43: короткое расстояние (~111 м на экваторе при 0.001° по lon)."""
dist = _haversine_m(0.0, 0.0, 0.001, 0.0)
assert 100 < dist < 120
# ─── U-44: защита от XXE ─────────────────────────────────────────────────────
def test_u44_xxe_protection():
"""U-44: defusedxml блокирует XXE атаку."""
fixture_path = os.path.join(
os.path.dirname(__file__),
"../../tests/fixtures/gps-tracks/xxe-payload.gpx",
)
with open(fixture_path, "rb") as f:
content = f.read()
# Должен либо выбросить исключение, либо вернуть пустой список без чтения /etc/passwd
try:
tracks = _parse_gpx_trackpoints(content, "osm", "")
# Если парсинг прошёл без ошибки — проверяем что /etc/passwd не попал в данные
for track in tracks:
assert "root:" not in str(track)
assert "/bin/" not in str(track)
except Exception:
# defusedxml выбросил исключение — это ожидаемое поведение
pass
def test_u44_valid_gpx_parsed_correctly():
"""U-44: корректный GPX с gpx_id парсится правильно."""
fixture_path = os.path.join(
os.path.dirname(__file__),
"../../tests/fixtures/gps-tracks/osm-trackpoints-bbox-moscow.gpx",
)
with open(fixture_path, "rb") as f:
content = f.read()
tracks = _parse_gpx_trackpoints(content, "osm", "")
assert len(tracks) == 1
track = tracks[0]
assert track.points_count == 3
assert abs(track.min_lat - 55.751) < 0.001
assert abs(track.max_lat - 55.753) < 0.001
assert track.source_id == "osm"
def test_u44_anonymous_trackpoints_skipped():
"""U-44: анонимные точки без gpx_id пропускаются."""
gpx_without_ids = b"""<?xml version="1.0"?>
<gpx version="1.0" creator="OpenStreetMap.org" xmlns="http://www.topografix.com/GPX/1/0">
<trk>
<trkseg>
<trkpt lat="55.751" lon="37.618"><time>2024-05-12T10:00:00Z</time></trkpt>
<trkpt lat="55.752" lon="37.619"><time>2024-05-12T10:01:00Z</time></trkpt>
</trkseg>
</trk>
</gpx>"""
tracks = _parse_gpx_trackpoints(gpx_without_ids, "osm", "")
assert len(tracks) == 0
def test_u44_multiple_tracks_in_gpx():
"""U-44: несколько gpx_id в одном ответе парсятся как разные треки."""
gpx_multi = b"""<?xml version="1.0"?>
<gpx version="1.0" creator="OpenStreetMap.org" xmlns="http://www.topografix.com/GPX/1/0">
<trk>
<trkseg>
<trkpt lat="55.751" lon="37.618" gpx_id="111"><time>2024-05-12T10:00:00Z</time></trkpt>
<trkpt lat="55.752" lon="37.619" gpx_id="111"><time>2024-05-12T10:01:00Z</time></trkpt>
<trkpt lat="55.760" lon="37.700" gpx_id="222"><time>2024-05-13T08:00:00Z</time></trkpt>
<trkpt lat="55.765" lon="37.710" gpx_id="222"><time>2024-05-13T08:05:00Z</time></trkpt>
</trkseg>
</trk>
</gpx>"""
tracks = _parse_gpx_trackpoints(gpx_multi, "osm", "")
assert len(tracks) == 2
ids = {t.external_id for t in tracks}
assert "111" in ids
assert "222" in ids
# ─── U-45: _parse_gpx_meta_response ──────────────────────────────────────────
def test_u45_meta_response_with_known_tag():
"""U-45: _parse_gpx_meta_response extracts activity via MAPPING."""
from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response
content = b"""<?xml version="1.0"?>
<osm version="0.6">
<gpx_file id="123" name="my_ride.gpx" user="alice">
<description>Weekend ride</description>
<tag>enduro</tag>
<tag>motorcycle</tag>
</gpx_file>
</osm>"""
meta = _parse_gpx_meta_response(content)
assert meta is not None
assert meta["activity_type"] == "enduro"
assert meta["name"] == "my_ride.gpx"
assert meta["user"] == "alice"
assert meta["description"] == "Weekend ride"
def test_u45_meta_response_unknown_tag_returns_none_activity():
"""U-45: unknown tag → activity_type is None."""
from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response
content = b"""<?xml version="1.0"?>
<osm version="0.6">
<gpx_file id="99" name="trip.gpx" user="bob">
<tag>unknown-sport</tag>
</gpx_file>
</osm>"""
meta = _parse_gpx_meta_response(content)
assert meta is not None
assert meta["activity_type"] is None
def test_u45_meta_response_motorcycle_maps_to_moto():
"""U-45: 'motorcycle' tag maps to 'moto' via MAPPING."""
from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response
content = b"""<?xml version="1.0"?>
<osm version="0.6">
<gpx_file id="77" name="ride.gpx" user="carl">
<tag>motorcycle</tag>
</gpx_file>
</osm>"""
meta = _parse_gpx_meta_response(content)
assert meta["activity_type"] == "moto"
def test_u45_meta_response_invalid_xml_returns_none():
"""U-45: malformed XML returns None."""
from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response
meta = _parse_gpx_meta_response(b"not xml at all <<<")
assert meta is None
def test_u45_meta_response_no_gpx_file_element():
"""U-45: valid XML but no gpx_file element → result has all None values."""
from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response
content = b"""<?xml version="1.0"?><osm version="0.6"></osm>"""
meta = _parse_gpx_meta_response(content)
# Function should return the dict with None values, not None itself
assert meta is not None
assert meta["activity_type"] is None
assert meta["name"] is None

View File

@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<gpx version="1.0" creator="OpenStreetMap.org" xmlns="http://www.topografix.com/GPX/1/0">
<trk>
<trkseg>
<trkpt lat="55.751" lon="37.618" gpx_id="12345"><time>2024-05-12T10:00:00Z</time></trkpt>
<trkpt lat="55.752" lon="37.619" gpx_id="12345"><time>2024-05-12T10:01:00Z</time></trkpt>
<trkpt lat="55.753" lon="37.620" gpx_id="12345"><time>2024-05-12T10:02:00Z</time></trkpt>
</trkseg>
</trk>
</gpx>

View File

@@ -0,0 +1,3 @@
<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<gpx><trk><trkseg><trkpt lat="55.7" lon="37.6"><ele>200</ele>&xxe;</trkpt></trkseg></trk></gpx>

0
tests/web/__init__.py Normal file
View File

View File

@@ -0,0 +1,288 @@
'use strict';
/**
* ET-008 — unit-тесты модуля публичных GPS-треков (gps_tracks.js).
*
* Покрывают:
* - _findGpsInsertPosition: логика приоритетного поиска позиции вставки
* (F-05: gpx-layer-* > route-*)
* - Filter state management: начальное состояние window.gpsTracksLayer.filters
* - Color palette mapping: GPS_SOURCE_COLORS, GPS_ACTIVITY_COLORS,
* GPS_FALLBACK_COLORS и _buildColorExpression()
*
* Тесты запускают РЕАЛЬНЫЙ код src/web/gps_tracks.js через new Function,
* подставляя мок-окружение (window, document) вместо браузерных глобалов.
* Браузерный примитив `maplibregl`, `fetch`, `AbortController` не нужны —
* тестируемые пути кода к ним не обращаются при инициализации.
*
* Запуск: `node --test tests/web/gps_tracks.test.js`
* (в CI оборачивается pytest-тестом tests/web/test_gps_tracks.py).
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const GPS_TRACKS_PATH = path.join(__dirname, '..', '..', 'src', 'web', 'gps_tracks.js');
// ─── Загрузчик модуля ─────────────────────────────────────────────────────────
/**
* Загружает gps_tracks.js в изолированный контекст new Function,
* подставляя мок-объекты вместо браузерных глобалов.
*
* После загрузки в mockWin появится свойство .gpsTracksLayer с начальным
* состоянием модуля. Возвращает приватные функции и константы.
*
* @param {object} [mockWin={}] мок-объект window
* @param {object} [mockDoc={}] мок-объект document
* @returns {{
* _findGpsInsertPosition: Function,
* _buildColorExpression: Function,
* GPS_SOURCE_COLORS: object,
* GPS_ACTIVITY_COLORS: object,
* GPS_FALLBACK_COLORS: string[],
* GPS_ACTIVITY_ICONS: object,
* GPS_ACTIVITY_LABELS: object,
* }}
*/
function loadGpsTracksModule(mockWin, mockDoc) {
const win = mockWin || {};
// Stub localStorage — используется в onPublicTracksCheckbox/restorePublicTracksState,
// но не при инициализации модуля.
win.localStorage = win.localStorage || {
getItem: () => null,
setItem: () => {},
};
const doc = mockDoc || {
getElementById: () => null,
querySelectorAll: () => ({ forEach: () => {} }),
};
const src = fs.readFileSync(GPS_TRACKS_PATH, 'utf8');
// new Function создаёт функцию в глобальном контексте Node.js,
// поэтому clearTimeout/setTimeout доступны как Node.js-глобалы.
const factory = new Function(
'window', 'document',
src +
'\nreturn {' +
' _findGpsInsertPosition,' +
' _buildColorExpression,' +
' GPS_SOURCE_COLORS,' +
' GPS_ACTIVITY_COLORS,' +
' GPS_FALLBACK_COLORS,' +
' GPS_ACTIVITY_ICONS,' +
' GPS_ACTIVITY_LABELS,' +
'};'
);
return factory(win, doc);
}
// ─── Вспомогательные функции ──────────────────────────────────────────────────
/** Создаёт мок-карту с заданным списком слоёв. */
function makeMap(layerIds) {
return {
getStyle: () => ({ layers: layerIds.map((id) => ({ id })) }),
};
}
// ─── _findGpsInsertPosition: логика приоритетов ───────────────────────────────
test('F-05: нет подходящих слоёв → undefined', () => {
const { _findGpsInsertPosition } = loadGpsTracksModule();
const map = makeMap(['background', 'osm-base', 'trails-track', 'poi-circles']);
assert.equal(_findGpsInsertPosition(map), undefined);
});
test('F-05: только gpx-layer-* → возвращает первый gpx-layer-*', () => {
const { _findGpsInsertPosition } = loadGpsTracksModule();
const map = makeMap(['background', 'gpx-layer-file1', 'gpx-layer-file2', 'trails-track']);
assert.equal(_findGpsInsertPosition(map), 'gpx-layer-file1');
});
test('F-05: только route-* → возвращает первый route-*', () => {
const { _findGpsInsertPosition } = loadGpsTracksModule();
const map = makeMap(['background', 'osm-base', 'route-line', 'route-alt-1']);
assert.equal(_findGpsInsertPosition(map), 'route-line');
});
test('F-05: gpx-layer-* приоритетнее route-* даже когда route-* идёт раньше в списке', () => {
const { _findGpsInsertPosition } = loadGpsTracksModule();
const map = makeMap(['background', 'route-line', 'gpx-layer-file1', 'poi-labels']);
// gpx-layer-* — приоритет 1, должен победить route-line несмотря на порядок
assert.equal(_findGpsInsertPosition(map), 'gpx-layer-file1');
});
test('F-05: gpx-layer-* и route-* присутствуют — возвращает gpx-layer-* (приоритет 1)', () => {
const { _findGpsInsertPosition } = loadGpsTracksModule();
const map = makeMap(['background', 'gpx-layer-abc', 'route-line', 'route-alt-2']);
assert.equal(_findGpsInsertPosition(map), 'gpx-layer-abc');
});
test('F-05: map.getStyle() возвращает null → undefined', () => {
const { _findGpsInsertPosition } = loadGpsTracksModule();
const map = { getStyle: () => null };
assert.equal(_findGpsInsertPosition(map), undefined);
});
test('F-05: map.getStyle отсутствует → undefined (нет TypeError)', () => {
const { _findGpsInsertPosition } = loadGpsTracksModule();
assert.doesNotThrow(() => {
const result = _findGpsInsertPosition({});
assert.equal(result, undefined);
});
});
test('F-05: style.layers пустой → undefined', () => {
const { _findGpsInsertPosition } = loadGpsTracksModule();
const map = { getStyle: () => ({ layers: [] }) };
assert.equal(_findGpsInsertPosition(map), undefined);
});
test('F-05: слой с именем ровно "route-" (без суффикса) распознаётся как route-*', () => {
const { _findGpsInsertPosition } = loadGpsTracksModule();
const map = makeMap(['background', 'route-']);
assert.equal(_findGpsInsertPosition(map), 'route-');
});
// ─── Filter state management ──────────────────────────────────────────────────
test('Filters: начальный список активностей содержит все 7 типов', () => {
const win = {};
loadGpsTracksModule(win);
const { activities } = win.gpsTracksLayer.filters;
const expected = ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'];
assert.deepEqual(
[...activities].sort(),
[...expected].sort(),
'начальный filters.activities не совпадает с ожидаемым набором',
);
});
test('Filters: начальный colorMode === "source"', () => {
const win = {};
loadGpsTracksModule(win);
assert.equal(win.gpsTracksLayer.filters.colorMode, 'source');
});
test('Filters: начальные источники включают osm, enduro_russia, ttrails', () => {
const win = {};
loadGpsTracksModule(win);
const { sources } = win.gpsTracksLayer.filters;
assert.ok(Array.isArray(sources), 'filters.sources должен быть массивом');
assert.ok(sources.includes('osm'), 'отсутствует источник osm');
assert.ok(sources.includes('enduro_russia'), 'отсутствует источник enduro_russia');
assert.ok(sources.includes('ttrails'), 'отсутствует источник ttrails');
});
test('Filters: enabled=false при инициализации', () => {
const win = {};
loadGpsTracksModule(win);
assert.equal(win.gpsTracksLayer.enabled, false);
});
test('Filters: filters.activities — массив, не объект', () => {
const win = {};
loadGpsTracksModule(win);
assert.ok(Array.isArray(win.gpsTracksLayer.filters.activities));
});
// ─── Color palette mapping ────────────────────────────────────────────────────
test('Colors: GPS_SOURCE_COLORS содержит ключи osm, enduro_russia, ttrails, offmaps, nakarte', () => {
const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
for (const src of ['osm', 'enduro_russia', 'ttrails', 'offmaps', 'nakarte']) {
assert.ok(
GPS_SOURCE_COLORS[src],
`GPS_SOURCE_COLORS: отсутствует источник ${src}`,
);
}
});
test('Colors: GPS_ACTIVITY_COLORS содержит все 7 типов активности', () => {
const { GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
for (const act of ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other']) {
assert.ok(
GPS_ACTIVITY_COLORS[act],
`GPS_ACTIVITY_COLORS: отсутствует активность ${act}`,
);
}
});
test('Colors: GPS_FALLBACK_COLORS — массив из 8 уникальных цветов', () => {
const { GPS_FALLBACK_COLORS } = loadGpsTracksModule();
assert.ok(Array.isArray(GPS_FALLBACK_COLORS), 'GPS_FALLBACK_COLORS не является массивом');
assert.equal(GPS_FALLBACK_COLORS.length, 8, 'GPS_FALLBACK_COLORS должен содержать 8 цветов');
const unique = new Set(GPS_FALLBACK_COLORS);
assert.equal(
unique.size,
GPS_FALLBACK_COLORS.length,
'GPS_FALLBACK_COLORS содержит дубли',
);
});
test('Colors: все цвета GPS_SOURCE_COLORS — строки в формате #RRGGBB', () => {
const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
const hexRe = /^#[0-9a-fA-F]{6}$/;
for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) {
assert.match(color, hexRe, `GPS_SOURCE_COLORS[${src}] = "${color}" не является hex-цветом`);
}
});
test('Colors: все цвета GPS_ACTIVITY_COLORS — строки в формате #RRGGBB', () => {
const { GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
const hexRe = /^#[0-9a-fA-F]{6}$/;
for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) {
assert.match(color, hexRe, `GPS_ACTIVITY_COLORS[${act}] = "${color}" не является hex-цветом`);
}
});
test('Colors: _buildColorExpression("source") — MapLibre match по полю "source"', () => {
const { _buildColorExpression, GPS_SOURCE_COLORS } = loadGpsTracksModule();
const expr = _buildColorExpression('source');
assert.ok(Array.isArray(expr), 'выражение должно быть массивом');
assert.equal(expr[0], 'match', 'первый элемент должен быть "match"');
assert.deepEqual(expr[1], ['get', 'source'], 'второй элемент должен быть ["get", "source"]');
// Каждый источник присутствует в выражении с правильным цветом
for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) {
const idx = expr.indexOf(src);
assert.notEqual(idx, -1, `источник "${src}" не найден в match-выражении`);
assert.equal(expr[idx + 1], color, `цвет для источника "${src}" не совпадает`);
}
// Последний элемент — fallback цвет
assert.equal(expr[expr.length - 1], '#808080', 'последний элемент должен быть fallback #808080');
});
test('Colors: _buildColorExpression("activity") — MapLibre match по полю "activity"', () => {
const { _buildColorExpression, GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
const expr = _buildColorExpression('activity');
assert.ok(Array.isArray(expr), 'выражение должно быть массивом');
assert.equal(expr[0], 'match', 'первый элемент должен быть "match"');
assert.deepEqual(expr[1], ['get', 'activity'], 'второй элемент должен быть ["get", "activity"]');
// Каждый тип активности присутствует в выражении с правильным цветом
for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) {
const idx = expr.indexOf(act);
assert.notEqual(idx, -1, `активность "${act}" не найдена в match-выражении`);
assert.equal(expr[idx + 1], color, `цвет для активности "${act}" не совпадает`);
}
// Последний элемент — fallback цвет
assert.equal(expr[expr.length - 1], '#808080', 'последний элемент должен быть fallback #808080');
});
test('Colors: _buildColorExpression — незнакомый режим даёт выражение по источнику', () => {
const { _buildColorExpression } = loadGpsTracksModule();
// Любое значение, отличное от 'activity', даёт режим 'source'
const expr = _buildColorExpression('unknown');
assert.deepEqual(expr[1], ['get', 'source']);
});

View File

@@ -0,0 +1,133 @@
"""ET-008 — тесты модуля публичных GPS-треков (gps_tracks.js + endpoint).
Изменение ET-008 — модуль `src/web/gps_tracks.js` + FastAPI endpoint
`/api/gps-tracks`. В CI исполняется только `pytest tests/`, поэтому файл
покрывает фронтендовую часть двумя способами:
1. Статические проверки структуры gps_tracks.js — выполняются всегда.
2. Поведенческие JS unit-тесты — через встроенный тест-раннер Node
(`node --test`). Если `node` отсутствует — часть помечается `skip`.
API-тесты endpoint живут в tests/api/test_gps_tracks_endpoint.py.
Запуск JS-тестов напрямую:
node --test tests/web/gps_tracks.test.js
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from shutil import which
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
GPS_TRACKS_JS = REPO_ROOT / "src" / "web" / "gps_tracks.js"
JS_TEST = REPO_ROOT / "tests" / "web" / "gps_tracks.test.js"
def _read(path: Path) -> str:
assert path.is_file(), f"не найден {path}"
return path.read_text(encoding="utf-8")
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки gps_tracks.js
# ──────────────────────────────────────────────────────────────────────────────
def test_gps_tracks_module_exists():
"""Модуль src/web/gps_tracks.js присутствует в репозитории."""
assert GPS_TRACKS_JS.is_file(), "не найден src/web/gps_tracks.js"
def test_gps_tracks_find_insert_position_defined():
"""_findGpsInsertPosition() объявлена в модуле."""
js = _read(GPS_TRACKS_JS)
assert "function _findGpsInsertPosition(" in js, (
"_findGpsInsertPosition не объявлена в gps_tracks.js"
)
def test_gps_tracks_find_insert_position_priority_gpx_first():
"""F-05: поиск gpx-layer-* идёт до route-* (приоритет 1 > приоритет 2)."""
js = _read(GPS_TRACKS_JS)
fn_start = js.index("function _findGpsInsertPosition(")
fn_end = js.index("\n}", fn_start)
fn_body = js[fn_start:fn_end]
gpx_pos = fn_body.find("gpx-layer-")
route_pos = fn_body.find("route-")
assert gpx_pos != -1, "gpx-layer- не найден в _findGpsInsertPosition"
assert route_pos != -1, "route- не найден в _findGpsInsertPosition"
assert gpx_pos < route_pos, (
"gpx-layer-* должен проверяться ДО route-* (приоритет 1 > приоритет 2)"
)
def test_gps_tracks_find_insert_position_no_exact_route_line():
"""F-05: старый точный match 'route-line' удалён, используется startsWith."""
js = _read(GPS_TRACKS_JS)
fn_start = js.index("function _findGpsInsertPosition(")
fn_end = js.index("\n}", fn_start)
fn_body = js[fn_start:fn_end]
assert "l.id === 'route-line'" not in fn_body, (
"старый точный матч route-line не должен присутствовать (F-05)"
)
def test_gps_tracks_state_object_defined():
"""window.gpsTracksLayer инициализируется в модуле."""
js = _read(GPS_TRACKS_JS)
assert "window.gpsTracksLayer = {" in js, (
"gps_tracks.js не инициализирует window.gpsTracksLayer"
)
def test_gps_tracks_source_colors_defined():
"""GPS_SOURCE_COLORS объявлен для всех основных источников."""
js = _read(GPS_TRACKS_JS)
for src in ("osm", "enduro_russia", "ttrails", "offmaps", "nakarte"):
assert src in js, f"GPS_SOURCE_COLORS не содержит ключ {src}"
def test_gps_tracks_activity_colors_defined():
"""GPS_ACTIVITY_COLORS объявлен для всех 7 типов активности."""
js = _read(GPS_TRACKS_JS)
for act in ("enduro", "moto", "offroad", "bicycle", "hike", "ski", "other"):
assert act in js, f"GPS_ACTIVITY_COLORS не содержит ключ {act}"
def test_gps_tracks_build_color_expression_defined():
"""_buildColorExpression() объявлена в модуле."""
js = _read(GPS_TRACKS_JS)
assert "function _buildColorExpression(" in js, (
"_buildColorExpression не объявлена в gps_tracks.js"
)
# ──────────────────────────────────────────────────────────────────────────────
# Поведенческие JS unit-тесты через Node
# ──────────────────────────────────────────────────────────────────────────────
node_required = pytest.mark.skipif(
which("node") is None,
reason="node не установлен — поведенческие JS unit-тесты пропущены",
)
@node_required
def test_js_unit_tests_pass():
"""F-05 + filters + colors: behavioral JS-тесты gps_tracks.js через `node --test`."""
assert JS_TEST.is_file(), f"не найден JS-тест {JS_TEST}"
node = which("node")
result = subprocess.run(
[node, "--test", str(JS_TEST)],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
)
assert result.returncode == 0, (
f"JS unit-тесты GPS-треков упали (код {result.returncode}):\n"
f"{result.stdout}\n{result.stderr}"
)