All checks were successful
Закрывает findings из docs/work-items/ET-011/12-review.md (REQUEST_CHANGES,
попытка 3/3):
P1-01 — добавлены поведенческие JS unit-тесты UI download-flow
- tests/web/track_download.test.js — 28 кейсов (node --test):
• _parseFilenameFromCD — RFC 5987 приоритет, plain fallback,
битый percent-encoding, null/empty (REQ-F-05.2, AC-2 UI)
• _handleDownloadError — 400/403/404/413/5xx тосты, defensive
при отсутствии showToast, поддержка flat (ADR-015 §G) и legacy
wrapped 403-форм (REQ-F-05.4, AC-7 UI)
• _renderTrackPopupHtml — наличие кнопки, aria-label «Скачать GPX»,
data-track-id, отсутствие при невалидном id, регрессия прочих
полей (REQ-F-01, AC-1)
- tests/web/test_track_download.py — pytest-обёртка (статические
проверки + запуск Node-раннера), исполняется в обычном pytest tests/
- 04b-ui-test-cases.md: AC-13 (mobile-bbox) явно маркирован как
MANUAL release-smoke (Playwright-раннер в проекте не настроен;
альтернатива согласована reviewer'ом в P1-01).
P2-01 — устранено расхождение «doc vs runtime» по контракту 403
- endpoint.py: HTTPException(detail={...}) → JSONResponse(content={...}),
чтобы FastAPI не оборачивал dict во второй слой «detail». Контракт
теперь совпадает с ADR-015 §G и ADR-014 §6:
{"detail":"source_forbidden","external_urls":[...]}
- test_gps_tracks_download.py IT-05: упрощено — body уже плоский,
без двухуровневого `body.get("detail", body)` workaround.
- gps_tracks.js::_handleDownloadError: flat-форма стала приоритетной,
wrapped-форма оставлена как defensive fallback (с комментарием).
Регрессия: 89/89 API-тестов + 24/24 предыдущих JS-тестов + 28 новых
JS-тестов download-flow проходят. ruff check — clean.
Refs: ET-011
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
444 lines
16 KiB
Python
444 lines
16 KiB
Python
"""FastAPI router для GPS-треков (ET-008, расширен в ET-011)."""
|
||
import json
|
||
import logging
|
||
import os
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, HTTPException, Path, Query, Response
|
||
from fastapi.responses import JSONResponse
|
||
|
||
from src.api.gps_tracks.config import load_download_allowed_sources
|
||
from src.api.gps_tracks.db import get_tracks_in_bbox, init_db, open_db
|
||
from src.api.gps_tracks.export import build_gpx, safe_filename
|
||
from src.api.gps_tracks.mvt import (
|
||
_gps_tile_cache,
|
||
_wkb_to_coords,
|
||
build_gps_mvt,
|
||
clear_gps_tile_cache,
|
||
get_gps_cached_tile,
|
||
set_gps_cached_tile,
|
||
_tile_to_bbox,
|
||
)
|
||
|
||
logger = logging.getLogger("uvicorn.access")
|
||
|
||
# ET-011 / ADR-014:
|
||
ALLOWED_DOWNLOAD_FORMATS = {"gpx"}
|
||
MAX_POINTS_FOR_DOWNLOAD = 200_000 # REQ-NF-02
|
||
GPX_MEDIA_TYPE = "application/gpx+xml; charset=utf-8"
|
||
|
||
|
||
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."""
|
||
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,
|
||
sources_config_path: Optional[str] = None,
|
||
) -> APIRouter:
|
||
"""Создаёт FastAPI router для GPS-треков.
|
||
|
||
Args:
|
||
db_path: путь к SQLite БД для GPS-треков.
|
||
sources_config_path: путь к ``config/gps_sources.yaml``.
|
||
Если None — для ET-011 download-эндпоинта используется
|
||
default-deny whitelist ``{"osm"}`` (см. ADR-015).
|
||
|
||
Returns:
|
||
APIRouter с prefix="/api/gps-tracks"
|
||
"""
|
||
router = APIRouter(prefix="/api/gps-tracks", tags=["gps-tracks"])
|
||
|
||
# ET-011 / ADR-015: whitelist source_id, для которых разрешено
|
||
# скачивание GPX. Читается один раз при старте router'а.
|
||
allowed_download_sources: set[str] = load_download_allowed_sources(
|
||
sources_config_path
|
||
)
|
||
|
||
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}
|
||
|
||
# ─── ET-011: скачивание GPX из popup ──────────────────────────
|
||
@router.get("/{track_id}/download")
|
||
async def download_track(
|
||
track_id: int = Path(..., ge=1),
|
||
format: str = Query("gpx", description="Формат файла (только 'gpx' в MVP)"),
|
||
):
|
||
"""Отдаёт GPX-файл для трека с правильным Content-Disposition.
|
||
|
||
Реализует ADR-014 / ADR-015 для ET-011.
|
||
|
||
Порядок проверок (ADR-014 §H):
|
||
1. format ∈ whitelist (иначе 400).
|
||
2. SELECT по id (иначе 404).
|
||
3. points_count <= MAX (иначе 413).
|
||
4. licence policy по sources (иначе 403).
|
||
5. Сборка GPX → 200.
|
||
"""
|
||
if format not in ALLOWED_DOWNLOAD_FORMATS:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="unsupported_format",
|
||
)
|
||
|
||
try:
|
||
conn = _get_conn()
|
||
cur = conn.cursor()
|
||
cur.execute(
|
||
"""
|
||
SELECT id, name, description, activity_type, user, created_at,
|
||
length_m, points_count, geom, sources_json,
|
||
external_urls_json
|
||
FROM tracks
|
||
WHERE id = ?
|
||
""",
|
||
(track_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
conn.close()
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"DB error: {exc}")
|
||
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="track_not_found")
|
||
|
||
points_count = row["points_count"] or 0
|
||
if points_count > MAX_POINTS_FOR_DOWNLOAD:
|
||
raise HTTPException(status_code=413, detail="track_too_large")
|
||
|
||
sources = json.loads(row["sources_json"] or "[]")
|
||
external_urls = json.loads(row["external_urls_json"] or "[]")
|
||
|
||
# ADR-015 §B1: разрешение по принципу ANY — хотя бы один разрешённый.
|
||
# ADR-015 §G: контракт ответа — одноуровневый JSON
|
||
# {"detail": "source_forbidden", "external_urls": [...]}.
|
||
# Используем JSONResponse напрямую вместо HTTPException(detail={...}),
|
||
# чтобы FastAPI не оборачивал dict в `{"detail": {...}}` (P2-01 в
|
||
# 12-review.md: контракт docs vs runtime разъезжался).
|
||
if not any(s in allowed_download_sources for s in sources):
|
||
return JSONResponse(
|
||
status_code=403,
|
||
content={
|
||
"detail": "source_forbidden",
|
||
"external_urls": external_urls,
|
||
},
|
||
)
|
||
|
||
coords = _wkb_to_coords(row["geom"]) or []
|
||
|
||
try:
|
||
xml_str = build_gpx(
|
||
track_id=row["id"],
|
||
name=row["name"],
|
||
description=row["description"],
|
||
activity_type=row["activity_type"],
|
||
user=row["user"],
|
||
created_at=row["created_at"],
|
||
sources=sources,
|
||
external_urls=external_urls,
|
||
coords=coords,
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("build_gpx failed for track_id=%s", track_id)
|
||
raise HTTPException(500, f"GPX build error: {exc}")
|
||
|
||
ascii_name, utf8_quoted = safe_filename(row["name"], track_id)
|
||
content_disposition = (
|
||
f'attachment; filename="{ascii_name}.gpx"; '
|
||
f"filename*=UTF-8''{utf8_quoted}.gpx"
|
||
)
|
||
|
||
xml_bytes = xml_str.encode("utf-8")
|
||
|
||
# REQ-F-07: лёгкое журналирование успешной отдачи.
|
||
logger.info(
|
||
"track_download id=%d sources=%s size_bytes=%d",
|
||
track_id,
|
||
",".join(sources) if sources else "",
|
||
len(xml_bytes),
|
||
)
|
||
|
||
return Response(
|
||
content=xml_bytes,
|
||
media_type=GPX_MEDIA_TYPE,
|
||
headers={
|
||
"Content-Disposition": content_disposition,
|
||
"Cache-Control": "private, max-age=3600",
|
||
"Access-Control-Allow-Origin": "*",
|
||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||
},
|
||
)
|
||
|
||
return router
|