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)."""
|
"""FastAPI router для GPS-треков (ET-008)."""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
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.db import get_tracks_in_bbox, init_db, open_db
|
||||||
from src.api.gps_tracks.mvt import (
|
from src.api.gps_tracks.mvt import (
|
||||||
|
_gps_tile_cache,
|
||||||
build_gps_mvt,
|
build_gps_mvt,
|
||||||
clear_gps_tile_cache,
|
clear_gps_tile_cache,
|
||||||
get_gps_cached_tile,
|
get_gps_cached_tile,
|
||||||
@@ -58,6 +60,11 @@ def _row_to_geojson_feature(row) -> dict:
|
|||||||
ext_urls = json.loads(row["external_urls_json"] or "[]")
|
ext_urls = json.loads(row["external_urls_json"] or "[]")
|
||||||
tags = json.loads(row["tags_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
|
geometry = None
|
||||||
if coords:
|
if coords:
|
||||||
geometry = {"type": "LineString", "coordinates": coords}
|
geometry = {"type": "LineString", "coordinates": coords}
|
||||||
@@ -71,11 +78,14 @@ def _row_to_geojson_feature(row) -> dict:
|
|||||||
"name": row["name"],
|
"name": row["name"],
|
||||||
"description": row["description"],
|
"description": row["description"],
|
||||||
"activity_type": row["activity_type"],
|
"activity_type": row["activity_type"],
|
||||||
|
"activity": activity_type,
|
||||||
"user": row["user"],
|
"user": row["user"],
|
||||||
"created_at": row["created_at"],
|
"created_at": row["created_at"],
|
||||||
"length_m": row["length_m"],
|
"length_m": row["length_m"],
|
||||||
|
"length_km": length_km,
|
||||||
"points_count": row["points_count"],
|
"points_count": row["points_count"],
|
||||||
"sources": sources,
|
"sources": sources,
|
||||||
|
"source": first_source,
|
||||||
"external_urls": ext_urls,
|
"external_urls": ext_urls,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"inserted_at": row["inserted_at"],
|
"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()]
|
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()
|
conn.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(500, f"DB error: {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 {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"db_path": db_path,
|
"db_path": db_path,
|
||||||
"total_tracks": total_tracks,
|
"total_tracks": total_tracks,
|
||||||
"by_activity": by_activity,
|
"by_activity": by_activity,
|
||||||
"recent_pipeline_runs": recent_runs,
|
"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")
|
@router.post("/cache/clear")
|
||||||
|
|||||||
@@ -90,7 +90,26 @@ class OsmParser(SourceParser):
|
|||||||
if not tracks:
|
if not tracks:
|
||||||
break # Пустая страница — больше треков нет
|
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:
|
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
|
yield track
|
||||||
|
|
||||||
page += 1
|
page += 1
|
||||||
@@ -307,3 +326,88 @@ async def _fetch_with_backoff(
|
|||||||
logger.error("Request failed: %s", exc)
|
logger.error("Request failed: %s", exc)
|
||||||
return None
|
return None
|
||||||
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) {
|
function _findGpsInsertPosition(map) {
|
||||||
const style = map.getStyle && map.getStyle();
|
const style = map.getStyle && map.getStyle();
|
||||||
if (!style || !style.layers) return undefined;
|
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;
|
return routeLayer ? routeLayer.id : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -349,3 +349,54 @@ async def test_cache_clear_endpoint(db_with_tracks):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["cleared"] is True
|
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}
|
ids = {t.external_id for t in tracks}
|
||||||
assert "111" in ids
|
assert "111" in ids
|
||||||
assert "222" 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