feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ
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>
This commit is contained in:
0
src/api/gps_tracks/__init__.py
Normal file
0
src/api/gps_tracks/__init__.py
Normal file
89
src/api/gps_tracks/config.py
Normal file
89
src/api/gps_tracks/config.py
Normal 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
232
src/api/gps_tracks/db.py
Normal 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
|
||||
32
src/api/gps_tracks/dedup.py
Normal file
32
src/api/gps_tracks/dedup.py
Normal 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}"
|
||||
240
src/api/gps_tracks/endpoint.py
Normal file
240
src/api/gps_tracks/endpoint.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""FastAPI router для GPS-треков (ET-008)."""
|
||||
import json
|
||||
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 (
|
||||
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 "[]")
|
||||
|
||||
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"],
|
||||
"user": row["user"],
|
||||
"created_at": row["created_at"],
|
||||
"length_m": row["length_m"],
|
||||
"points_count": row["points_count"],
|
||||
"sources": sources,
|
||||
"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-треков БД."""
|
||||
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()}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, started_at, finished_at, region_id, source_id,
|
||||
status, tracks_new, tracks_updated
|
||||
FROM pipeline_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 10
|
||||
"""
|
||||
)
|
||||
recent_runs = [dict(row) for row in cur.fetchall()]
|
||||
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"DB error: {exc}")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"db_path": db_path,
|
||||
"total_tracks": total_tracks,
|
||||
"by_activity": by_activity,
|
||||
"recent_pipeline_runs": recent_runs,
|
||||
}
|
||||
|
||||
@router.post("/cache/clear")
|
||||
async def clear_cache():
|
||||
"""Сбрасывает LRU-кэш GPS-тайлов."""
|
||||
clear_gps_tile_cache()
|
||||
return {"status": "ok", "cleared": True}
|
||||
|
||||
return router
|
||||
52
src/api/gps_tracks/models.py
Normal file
52
src/api/gps_tracks/models.py
Normal 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
167
src/api/gps_tracks/mvt.py
Normal 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},
|
||||
)
|
||||
0
src/api/gps_tracks/sources/__init__.py
Normal file
0
src/api/gps_tracks/sources/__init__.py
Normal file
34
src/api/gps_tracks/sources/base.py
Normal file
34
src/api/gps_tracks/sources/base.py
Normal 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
|
||||
17
src/api/gps_tracks/sources/enduro_russia.py
Normal file
17
src/api/gps_tracks/sources/enduro_russia.py
Normal 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
|
||||
309
src/api/gps_tracks/sources/osm.py
Normal file
309
src/api/gps_tracks/sources/osm.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""Парсер 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 # Пустая страница — больше треков нет
|
||||
|
||||
for track in tracks:
|
||||
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
|
||||
17
src/api/gps_tracks/sources/ttrails.py
Normal file
17
src/api/gps_tracks/sources/ttrails.py
Normal 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
|
||||
@@ -14,6 +14,11 @@ import sqlite3
|
||||
|
||||
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 typing import List
|
||||
|
||||
@@ -1246,6 +1251,10 @@ async def terrain_tile(layer: str, z: int, x: int, y: int):
|
||||
|
||||
# ─── 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):
|
||||
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
||||
|
||||
|
||||
@@ -3,3 +3,6 @@ uvicorn==0.29.0
|
||||
shapely==2.0.4
|
||||
mapbox-vector-tile==2.2.0
|
||||
httpx==0.27.0
|
||||
defusedxml==0.7.1
|
||||
lxml==5.2.2
|
||||
pyyaml==6.0.1
|
||||
|
||||
Reference in New Issue
Block a user