banyaro/backend/routes/routen.py
rene 1ff66a7083 Sicherheit + Tests + A11y, SW by-v1118
PYDANTIC max_length (38 Routen, ~400 Field-Constraints):
Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.).
Pragmatische Limits:
- Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000
- Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100
- Hund-Name/Rasse: 80 · Hund-Bio: 2000

Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py,
notes.py, auth.py, profile.py. Manuelle len()-Checks in profile,
chat, ki entfernt (jetzt durch Field abgedeckt).

PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail):
- test_security.py: require_owner (Places GET/PATCH/DELETE mit
  Fremduser → 403), JWT-Blacklist (Logout invalidiert Token),
  Login-Lockout (5 Fehlversuche → 429 + Retry-After Header)
- test_race.py: Invoice-Counter (20 parallele Threads, alle unique),
  Founder-Number (atomare Vergabe, voll bei 100)
- test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text
  50k → 422 (verifiziert Pydantic max_length-Sweep)

A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast):
- #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44
- dog-profile Wrapped-Slider Prev/Next 40→44
- forum-Lightbox Close 40→44
- --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS)
- --c-text-muted Dark:  #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS)
- Branding-Farben unangetastet
2026-05-27 13:40:30 +02:00

616 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""BAN YARO — Gassi-Routen"""
import datetime as _dt
import json, os, uuid
import httpx
import polyline as _polyline
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from typing import Optional, List
from database import db
from auth import get_current_user, get_current_user_optional
from routes.achievements import update_streak, check_and_award
from timeutils import safe_client_time
from media_utils import convert_media
from routes.push import send_push_to_user
from math_utils import haversine_km, haversine_m
router = APIRouter()
ORS_API_KEY = os.getenv("ORS_API_KEY")
_MAX_AVG_KMH = 15.0 # Über diesem Wert wird die Route nicht für Stats/Trophäen gewertet
def _check_speed(distanz_km, dauer_min) -> bool:
"""True = gültig, False = zu schnell (wahrscheinlich motorisiert)."""
if not distanz_km or not dauer_min or dauer_min <= 0:
return True
return (distanz_km / (dauer_min / 60)) <= _MAX_AVG_KMH
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class GPSPoint(BaseModel):
lat: float
lon: float
alt: Optional[float] = None
class RouteCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
beschreibung: Optional[str] = Field(None, max_length=5000)
gps_track: List[GPSPoint]
distanz_km: Optional[float] = None
dauer_min: Optional[int] = None
schwierigkeit: Optional[str] = Field("leicht", max_length=30) # leicht | mittel | anspruchsvoll
untergrund: Optional[str] = Field(None, max_length=50) # wald | asphalt | wiese | mix
schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = False
hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) # eingeschränkt | gut | sehr_gut | premium
client_time: Optional[str] = Field(None, max_length=64)
dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind
class RouteUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=200)
beschreibung: Optional[str] = Field(None, max_length=5000)
schwierigkeit: Optional[str] = Field(None, max_length=30)
untergrund: Optional[str] = Field(None, max_length=50)
schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = None
hunde_tauglichkeit: Optional[str] = Field(None, max_length=50)
class RouteDogs(BaseModel):
dog_ids: List[int]
def _simplify_track(track: list, max_pts: int = 40) -> list:
"""Reduziert GPS-Track auf max_pts Punkte für Vorschau."""
if len(track) <= max_pts:
return track
step = len(track) / max_pts
return [track[round(i * step)] for i in range(max_pts)]
def _parse(row) -> dict:
d = dict(row)
if isinstance(d.get('gps_track'), str):
d['gps_track'] = json.loads(d['gps_track'])
for k in ('schatten', 'leine_empfohlen', 'is_public'):
if d.get(k) is not None:
d[k] = bool(d[k])
if isinstance(d.get('foto_urls'), str):
d['foto_urls'] = json.loads(d['foto_urls'])
return d
# ------------------------------------------------------------------
# GET /api/routes — Routen (optional: Umkreis vom Startpunkt)
# ------------------------------------------------------------------
@router.get("")
async def list_routes(
lat: Optional[float] = None,
lon: Optional[float] = None,
radius: int = 10000,
user = Depends(get_current_user_optional),
):
with db() as conn:
rows = conn.execute("""
SELECT r.id, r.user_id, r.name, r.beschreibung,
r.distanz_km, r.dauer_min, r.schwierigkeit,
r.untergrund, r.schatten, r.leine_empfohlen,
r.bewertung, r.anz_bewertungen, r.created_at,
r.gps_track, r.is_public, r.hunde_tauglichkeit, r.foto_urls,
u.name AS user_name,
json_extract(r.gps_track, '$[0].lat') AS start_lat,
json_extract(r.gps_track, '$[0].lon') AS start_lon,
json_extract(r.gps_track, '$[#-1].lat') AS end_lat,
json_extract(r.gps_track, '$[#-1].lon') AS end_lon
FROM routes r
LEFT JOIN users u ON u.id = r.user_id
ORDER BY r.created_at DESC
""").fetchall()
result = []
for row in rows:
d = dict(row)
for k in ('schatten', 'leine_empfohlen', 'is_public'):
if d.get(k) is not None:
d[k] = bool(d[k])
if isinstance(d.get('foto_urls'), str):
d['foto_urls'] = json.loads(d['foto_urls'])
raw_track = json.loads(d.get('gps_track') or '[]')
d['preview_track'] = _simplify_track(raw_track, 40)
del d['gps_track']
result.append(d)
if lat is not None and lon is not None:
result = [
r for r in result
if r['start_lat'] and haversine_m(lat, lon, r['start_lat'], r['start_lon']) <= radius
]
user_id = user['id'] if user else None
result = [r for r in result if r.get('is_public', True) or r.get('user_id') == user_id]
return result
# ------------------------------------------------------------------
# POST /api/routes — neue Route speichern (Login erforderlich)
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def create_route(data: RouteCreate, user=Depends(get_current_user)):
if len(data.gps_track) < 2:
raise HTTPException(400, "GPS-Track braucht mindestens 2 Punkte.")
gps_json = json.dumps([p.model_dump() for p in data.gps_track])
is_valid = int(_check_speed(data.distanz_km, data.dauer_min))
ct = safe_client_time(data.client_time)
with db() as conn:
cur = conn.execute("""
INSERT INTO routes
(user_id, name, beschreibung, gps_track, distanz_km, dauer_min,
schwierigkeit, untergrund, schatten, leine_empfohlen, is_public,
hunde_tauglichkeit, is_valid, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user['id'], data.name, data.beschreibung, gps_json,
data.distanz_km, data.dauer_min, data.schwierigkeit, data.untergrund,
int(data.schatten) if data.schatten is not None else None,
int(data.leine_empfohlen) if data.leine_empfohlen is not None else None,
int(data.is_public) if data.is_public is not None else 1,
data.hunde_tauglichkeit, is_valid, ct,
))
route_id = cur.lastrowid
row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone()
# Hunde zuordnen — entweder explizit oder alle Hunde des Users
dog_ids = data.dog_ids or []
if not dog_ids:
# Fallback: alle Hunde des Users
all_dogs = conn.execute(
"SELECT id FROM dogs WHERE user_id=?", (user['id'],)
).fetchall()
dog_ids = [d['id'] for d in all_dogs]
for did in dog_ids:
try:
conn.execute(
"INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?,?)",
(route_id, did)
)
except Exception:
pass
update_streak(user['id'], conn)
check_and_award(user['id'], conn)
result = _parse(row)
result['is_valid'] = bool(is_valid)
return result
# ------------------------------------------------------------------
# POST /api/routes/suggest — Rundweg-Vorschlag via OpenRouteService
# ------------------------------------------------------------------
class SuggestRequest(BaseModel):
lat: float
lon: float
distance_km: float # Zieldistanz in km (z.B. 2.0, 4.0, 6.0)
seed: int = 0 # 0-4: verschiedene Routenvarianten
WEEKLY_LIMIT = 20
@router.post("/suggest")
async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)):
if not (0.5 <= data.distance_km <= 15):
raise HTTPException(400, "distance_km muss zwischen 0.5 und 15 liegen.")
if not ORS_API_KEY:
raise HTTPException(503, "ORS nicht konfiguriert")
from auth import has_pro_access
if not has_pro_access(user):
raise HTTPException(403, "Routenvorschläge sind ein Pro-Feature.")
is_privileged = (
user.get("rolle") in ("admin", "moderator") or
user.get("is_moderator") or
user.get("is_social_media") or
user.get("breeder_status") == "approved"
)
today = _dt.date.today()
week_start = (today - _dt.timedelta(days=today.weekday())).isoformat()
if not is_privileged:
with db() as conn:
row = conn.execute(
"SELECT count FROM route_suggest_usage WHERE user_id=? AND week=?",
(user["id"], week_start)
).fetchone()
current_count = row["count"] if row else 0
if current_count >= WEEKLY_LIMIT:
raise HTTPException(429, f"Wochenlimit von {WEEKLY_LIMIT} Routenvorschlägen erreicht.")
payload = {
"coordinates": [[data.lon, data.lat]],
"options": {
"round_trip": {
"length": data.distance_km * 1000,
"points": 5,
"seed": data.seed,
},
"avoid_features": ["ferries", "steps"],
},
"units": "m",
"geometry": True,
"instructions": False,
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"https://api.openrouteservice.org/v2/directions/foot-walking",
headers={
"Authorization": f"Bearer {ORS_API_KEY}",
"Content-Type": "application/json",
},
json=payload,
)
except httpx.TimeoutException:
raise HTTPException(504, "ORS-Anfrage hat das Zeitlimit überschritten.")
if resp.status_code != 200:
try:
detail = resp.json()
except Exception:
detail = resp.text
raise HTTPException(502, f"ORS-Fehler: {detail}")
body = resp.json()
try:
route = body["routes"][0]
geometry = route["geometry"]
summary = route["summary"]
distanz_m = summary.get("distance", data.distance_km * 1000)
dauer_s = summary.get("duration", 0)
except (KeyError, IndexError) as exc:
raise HTTPException(502, f"Unerwartete ORS-Antwort: {exc}")
# encoded polyline → [[lat, lon], ...]
points = _polyline.decode(geometry)
gps_track = [{"lat": p[0], "lon": p[1]} for p in points]
distanz_km = round(distanz_m / 1000, 2)
dauer_min = max(1, round(dauer_s / 60))
if distanz_km < 3:
schwierigkeit = "leicht"
elif distanz_km <= 5:
schwierigkeit = "mittel"
else:
schwierigkeit = "anspruchsvoll"
if not is_privileged:
with db() as conn:
conn.execute("""
INSERT INTO route_suggest_usage (user_id, week, count) VALUES (?, ?, 1)
ON CONFLICT(user_id, week) DO UPDATE SET count = count + 1
""", (user["id"], week_start))
current_count += 1
# Täglichen Gesamtzähler hochzählen (für Admin-Stats)
today_str = _dt.date.today().isoformat()
with db() as conn:
conn.execute("""
INSERT INTO ors_daily_total (date, count) VALUES (?, 1)
ON CONFLICT(date) DO UPDATE SET count = count + 1
""", (today_str,))
weekly_remaining = None if is_privileged else max(0, WEEKLY_LIMIT - current_count)
return {
"name": f"Rundweg {distanz_km:.0f} km",
"gps_track": gps_track,
"distanz_km": distanz_km,
"dauer_min": dauer_min,
"schwierigkeit": schwierigkeit,
"weekly_remaining": weekly_remaining,
}
# ------------------------------------------------------------------
# GET /api/routes/{id} — Route mit vollem GPS-Track
# ------------------------------------------------------------------
@router.get("/{route_id}")
async def get_route(route_id: int):
with db() as conn:
row = conn.execute(
"SELECT r.*, u.name AS user_name FROM routes r LEFT JOIN users u ON u.id = r.user_id WHERE r.id = ?",
(route_id,)
).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
dog_rows = conn.execute(
"SELECT dog_id FROM route_dogs WHERE route_id = ?", (route_id,)
).fetchall()
result = _parse(row)
result['dog_ids'] = [r['dog_id'] for r in dog_rows]
return result
# ------------------------------------------------------------------
# PATCH /api/routes/{id}
# ------------------------------------------------------------------
@router.patch("/{route_id}")
async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
updates = data.model_dump(exclude_none=True)
if updates:
for key in ('schatten', 'leine_empfohlen', 'is_public'):
if key in updates:
updates[key] = int(updates[key])
cols = ', '.join(f"{k} = ?" for k in updates)
conn.execute(f"UPDATE routes SET {cols} WHERE id = ?", [*updates.values(), route_id])
row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone()
return _parse(row)
# ------------------------------------------------------------------
# PATCH /api/routes/{id}/dogs — Hunde der Route aktualisieren
# ------------------------------------------------------------------
@router.patch("/{route_id}/dogs")
async def update_route_dogs(route_id: int, data: RouteDogs, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute("SELECT user_id FROM routes WHERE id = ?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
conn.execute("DELETE FROM route_dogs WHERE route_id = ?", (route_id,))
for did in data.dog_ids:
conn.execute(
"INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?, ?)",
(route_id, did)
)
return {"ok": True}
# ------------------------------------------------------------------
# PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz)
# ------------------------------------------------------------------
class RouteTrim(BaseModel):
gps_track: List[GPSPoint]
@router.patch("/{route_id}/trim")
async def trim_route(route_id: int, data: RouteTrim, user=Depends(get_current_user)):
if len(data.gps_track) < 2:
raise HTTPException(400, "Mindestens 2 GPS-Punkte erforderlich.")
with db() as conn:
row = conn.execute("SELECT * FROM routes WHERE id=?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
# Original-Werte beim ersten Kürzen einmalig sichern
if row['original_km'] is None:
conn.execute(
"UPDATE routes SET original_km=?, original_dauer_min=? WHERE id=?",
(row['distanz_km'], row['dauer_min'], route_id)
)
orig_km = row['distanz_km'] or 0
orig_min = row['dauer_min'] or 0
else:
orig_km = row['original_km']
orig_min = row['original_dauer_min'] or 0
# Neue Distanz berechnen
new_track = [p.model_dump() for p in data.gps_track]
new_km = 0.0
for i in range(1, len(new_track)):
p1, p2 = new_track[i-1], new_track[i]
new_km += haversine_km(p1['lat'], p1['lon'], p2['lat'], p2['lon'])
new_km = round(new_km, 2)
# Dauer proportional schätzen (Original-Pace)
pace = orig_min / orig_km if orig_km > 0 else 10
new_min = max(1, round(new_km * pace))
conn.execute(
"UPDATE routes SET gps_track=?, distanz_km=?, dauer_min=? WHERE id=?",
(json.dumps(new_track), new_km, new_min, route_id)
)
row = conn.execute("SELECT * FROM routes WHERE id=?", (route_id,)).fetchone()
return _parse(row)
# ------------------------------------------------------------------
# DELETE /api/routes/{id}
# ------------------------------------------------------------------
@router.delete("/{route_id}", status_code=204)
async def delete_route(route_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
conn.execute("DELETE FROM routes WHERE id = ?", (route_id,))
# ------------------------------------------------------------------
# POST /api/routes/{id}/rate — Bewertung abgeben
# ------------------------------------------------------------------
class RouteRate(BaseModel):
wertung: float # 15
@router.post("/{route_id}/rate")
async def rate_route(route_id: int, data: RouteRate, user=Depends(get_current_user)):
if not 1 <= data.wertung <= 5:
raise HTTPException(400, "Wertung muss zwischen 1 und 5 liegen.")
with db() as conn:
row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
r = dict(row)
n = (r['anz_bewertungen'] or 0) + 1
avg = ((r['bewertung'] or 0) * (n - 1) + data.wertung) / n
conn.execute(
"UPDATE routes SET bewertung=?, anz_bewertungen=? WHERE id=?",
(round(avg, 2), n, route_id)
)
return {'bewertung': round(avg, 2), 'anz_bewertungen': n}
# ------------------------------------------------------------------
# POST /api/routes/{id}/walked — Gelaufene km ins Profil eintragen
# ------------------------------------------------------------------
class WalkRecord(BaseModel):
walked_km: float
progress_pct: int
@router.post("/{route_id}/walked", status_code=201)
async def record_walk(route_id: int, body: WalkRecord, user=Depends(get_current_user)):
if body.progress_pct < 50:
raise HTTPException(400, "Mindestens 50 % der Route müssen absolviert sein.")
uid = user["id"]
with db() as conn:
conn.execute(
"INSERT INTO route_walks (user_id, route_id, walked_km) VALUES (?,?,?)",
(uid, route_id, round(max(0.01, body.walked_km), 2))
)
update_streak(uid, conn)
new_badges = check_and_award(uid, conn)
return {"ok": True, "new_badges": new_badges}
# ------------------------------------------------------------------
# POST /api/routes/{id}/reverse — GPS-Track umkehren
# ------------------------------------------------------------------
@router.post("/{route_id}/reverse", status_code=200)
async def reverse_route(route_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
row = conn.execute("SELECT user_id, gps_track FROM routes WHERE id=?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row["user_id"] != uid:
raise HTTPException(403, "Nur der Ersteller kann die Route umkehren.")
track = json.loads(row["gps_track"])
track.reverse()
conn.execute("UPDATE routes SET gps_track=? WHERE id=?", (json.dumps(track), route_id))
return {"ok": True}
# ------------------------------------------------------------------
# POST /api/routes/{id}/photo — Foto hochladen
# ------------------------------------------------------------------
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@router.post("/{route_id}/photo", status_code=201)
async def add_route_photo(
route_id: int,
file: UploadFile = File(...),
user = Depends(get_current_user),
):
with db() as conn:
row = conn.execute("SELECT * FROM routes WHERE id=?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if dict(row)['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
data, ext = convert_media(await file.read(), file.filename or "")
if not ext:
ext = ".jpg"
filename = f"route_{route_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "routes", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
foto_url = f"/media/routes/{filename}"
with db() as conn:
row = conn.execute("SELECT foto_urls FROM routes WHERE id=?", (route_id,)).fetchone()
urls = json.loads(dict(row)['foto_urls'] or '[]')
urls.append(foto_url)
conn.execute("UPDATE routes SET foto_urls=? WHERE id=?", (json.dumps(urls), route_id))
return {'foto_url': foto_url, 'foto_urls': urls}
# ------------------------------------------------------------------
# POST /api/routes/{id}/feedback — Feedback an Route-Ersteller
# ------------------------------------------------------------------
class RouteFeedback(BaseModel):
text: str = Field(..., min_length=5, max_length=2000)
@router.post("/{route_id}/feedback", status_code=201)
async def route_feedback(route_id: int, data: RouteFeedback, user=Depends(get_current_user)):
if len(data.text.strip()) < 5:
raise HTTPException(400, "Feedback zu kurz.")
with db() as conn:
row = conn.execute(
"SELECT user_id, name FROM routes WHERE id=?", (route_id,)
).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row["user_id"] == user["id"]:
raise HTTPException(400, "Eigene Route kann nicht bewertet werden.")
send_push_to_user(row["user_id"], {
"type": "route_feedback",
"title": "📍 Feedback zu \u201e" + row['name'] + "\u201c",
"body": data.text.strip()[:120],
"route_id": route_id,
})
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/routes/{id}/elevation — Höhenprofil via OpenTopoData
# ------------------------------------------------------------------
@router.get("/{route_id}/elevation")
async def route_elevation(route_id: int, _user=Depends(get_current_user_optional)):
with db() as conn:
row = conn.execute("SELECT gps_track FROM routes WHERE id=?", (route_id,)).fetchone()
if not row:
raise HTTPException(404)
track = json.loads(row["gps_track"] or "[]")
if not track:
return {"elevations": []}
# Bereits mit Höhe gespeichert?
if all(p.get("alt") is not None for p in track):
return {"elevations": [{"lat": p["lat"], "lon": p["lon"], "alt": p["alt"]} for p in track]}
# Auf max. 60 Punkte reduzieren
step = max(1, len(track) // 60)
sample = track[::step]
if track[-1] not in sample:
sample.append(track[-1])
locations = "|".join(f"{p['lat']},{p['lon']}" for p in sample)
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.get(
f"https://api.opentopodata.org/v1/srtm90m?locations={locations}"
)
results = r.json().get("results", [])
return {"elevations": [
{"lat": res["location"]["lat"], "lon": res["location"]["lng"], "alt": res.get("elevation", 0)}
for res in results
]}
except Exception:
return {"elevations": []}