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
|
||||
|
||||
Reference in New Issue
Block a user