"""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 # 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.") 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": []}