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>
This commit is contained in:
2026-06-01 12:42:36 +00:00
parent 37190049db
commit edbe9a3044
5 changed files with 255 additions and 1 deletions

View File

@@ -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")

View File

@@ -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