diff --git a/src/api/gps_tracks/endpoint.py b/src/api/gps_tracks/endpoint.py index 54a754a..508cc5a 100644 --- a/src/api/gps_tracks/endpoint.py +++ b/src/api/gps_tracks/endpoint.py @@ -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") diff --git a/src/api/gps_tracks/sources/osm.py b/src/api/gps_tracks/sources/osm.py index 7a7da83..e69d9fb 100644 --- a/src/api/gps_tracks/sources/osm.py +++ b/src/api/gps_tracks/sources/osm.py @@ -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/. + + 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/.""" + 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 diff --git a/src/web/gps_tracks.js b/src/web/gps_tracks.js index 7e801e3..4987db5 100644 --- a/src/web/gps_tracks.js +++ b/src/web/gps_tracks.js @@ -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; } diff --git a/tests/api/test_gps_tracks_endpoint.py b/tests/api/test_gps_tracks_endpoint.py index 50b9627..fc433bc 100644 --- a/tests/api/test_gps_tracks_endpoint.py +++ b/tests/api/test_gps_tracks_endpoint.py @@ -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 diff --git a/tests/api/test_gps_tracks_sources_osm.py b/tests/api/test_gps_tracks_sources_osm.py index e00988f..e7c1317 100644 --- a/tests/api/test_gps_tracks_sources_osm.py +++ b/tests/api/test_gps_tracks_sources_osm.py @@ -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""" + + + Weekend ride + enduro + motorcycle + +""" + 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""" + + + unknown-sport + +""" + 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""" + + + motorcycle + +""" + 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"""""" + 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