"""BAN YARO — Gassi-Routen""" import json, math, os, uuid import httpx import polyline as _polyline 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 timeutils import safe_client_time from media_utils import convert_media from routes.push import send_push_to_user 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 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 client_time: Optional[str] = None 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]) 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, )) row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone() 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 @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") 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" return { "name": f"Rundweg {distanz_km:.0f} km", "gps_track": gps_track, "distanz_km": distanz_km, "dauer_min": dauer_min, "schwierigkeit": schwierigkeit, } # ------------------------------------------------------------------ # 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 # 1–5 @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 @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": []}