fix(gps-tracks): normalise GeoJSON props, add health fields, OSM meta fetch, z-order fix
Refs: ET-008 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
"""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,
|
||||
@@ -58,6 +60,11 @@ def _row_to_geojson_feature(row) -> dict:
|
||||
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}
|
||||
@@ -71,11 +78,14 @@ def _row_to_geojson_feature(row) -> dict:
|
||||
"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"],
|
||||
@@ -219,16 +229,35 @@ def create_gps_router(db_path: str) -> APIRouter:
|
||||
)
|
||||
recent_runs = [dict(row) for row in cur.fetchall()]
|
||||
|
||||
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,
|
||||
"total_tracks": total_tracks,
|
||||
"by_activity": by_activity,
|
||||
"recent_pipeline_runs": recent_runs,
|
||||
"db_size_mb": db_size_mb,
|
||||
"tracks_by_source": tracks_by_source,
|
||||
"tile_cache_size": len(_gps_tile_cache),
|
||||
}
|
||||
|
||||
@router.post("/cache/clear")
|
||||
|
||||
@@ -90,7 +90,26 @@ class OsmParser(SourceParser):
|
||||
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
|
||||
@@ -307,3 +326,88 @@ async def _fetch_with_backoff(
|
||||
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
|
||||
|
||||
@@ -191,7 +191,11 @@ function _ensureGpsLayers(map) {
|
||||
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-'));
|
||||
const routeLayer = style.layers.find(l =>
|
||||
l.id === 'route-line' ||
|
||||
l.id.startsWith('route-') ||
|
||||
l.id.startsWith('gpx-layer-')
|
||||
);
|
||||
return routeLayer ? routeLayer.id : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -349,3 +349,54 @@ async def test_cache_clear_endpoint(db_with_tracks):
|
||||
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
|
||||
|
||||
@@ -180,3 +180,69 @@ def test_u44_multiple_tracks_in_gpx():
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user