banyaro/backend/routes/routen.py
rene 9a78121a3e Session 2026-04-19: Navigation, Kompass, Übungsfortschritt
Routen-Navigation:
- POI-Marker: farbige Kreise mit Phosphor-Icons (wie Hauptkarte)
- Screensaver: Navi-Pfeil dreht sich via DeviceOrientationEvent (iOS+Android)
- Pfeil-Dämpfung: EMA α=0.12 mit Wrap-Around
- GPS-Distanz-Bug: Fortschritt nur wenn <500m zur Route
- fitBounds: User-Position nur wenn <20km von Route
- Screensaver: "zur Route" vs "verbleibend" kontextabhängig
- Richtungspfeile entlang Route (blau, max 7 Stück)
- Umkehren ins Route-Detail verschoben, Detail-Map rebuildet sich
- rk-header z-index:10 (Leaflet-Tiles liefen drüber)
- 2-Sek. Screensaver-Entsperrung

km-Tracking:
- route_walks Tabelle
- POST /api/routes/{id}/walked (≥50%)
- total_km = erstellte Routes + gelaufene route_walks
- Toast bei neuem Badge

Übungsfortschritt:
- exercise_progress + training_plan_progress Tabellen
- GET/POST /api/training/progress, /plan-progress, /suggestions
- uebungen.js: API-first + localStorage-Fallback + Auto-Migration
- Empfehlungs-Banner (regelbasiert)
- Toast bei "sitzt"
2026-04-19 20:33:01 +02:00

427 lines
17 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 json, math, os, uuid
import httpx
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
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 routes.push import send_push_to_user
router = APIRouter()
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class GPSPoint(BaseModel):
lat: float
lon: float
alt: Optional[float] = None
class RouteCreate(BaseModel):
name: str
beschreibung: Optional[str] = None
gps_track: List[GPSPoint]
distanz_km: Optional[float] = None
dauer_min: Optional[int] = None
schwierigkeit: Optional[str] = "leicht" # leicht | mittel | anspruchsvoll
untergrund: Optional[str] = None # wald | asphalt | wiese | mix
schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = False
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
class RouteUpdate(BaseModel):
name: Optional[str] = None
beschreibung: Optional[str] = None
schwierigkeit: Optional[str] = None
untergrund: Optional[str] = None
schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = None
hunde_tauglichkeit: Optional[str] = None
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(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])
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)
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,
))
row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone()
update_streak(user['id'], conn)
check_and_award(user['id'], conn)
return _parse(row)
# ------------------------------------------------------------------
# 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.")
return _parse(row)
# ------------------------------------------------------------------
# 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}/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]
dlat = math.radians(p2['lat'] - p1['lat'])
dlon = math.radians(p2['lon'] - p1['lon'])
a = math.sin(dlat/2)**2 + math.cos(math.radians(p1['lat'])) * math.cos(math.radians(p2['lat'])) * math.sin(dlon/2)**2
new_km += 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
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.")
ext = os.path.splitext(file.filename or "")[1] or ".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(await file.read())
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
@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": []}