"""BAN YARO — Gassi-Treffen""" import math import httpx from datetime import date from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional, List from database import db from auth import get_current_user router = APIRouter() def _haversine(lat1, lon1, lat2, lon2): R = 6_371_000 p1, p2 = math.radians(lat1), 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)) def _haversine_km(lat1, lon1, lat2, lon2): return _haversine(lat1, lon1, lat2, lon2) / 1000 # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class WalkCreate(BaseModel): titel: str datum: str # YYYY-MM-DD uhrzeit: str # HH:MM lat: float lon: float ort_name: Optional[str] = None max_teilnehmer: int = 10 beschreibung: Optional[str] = None class WalkUpdate(BaseModel): titel: Optional[str] = None datum: Optional[str] = None uhrzeit: Optional[str] = None lat: Optional[float] = None lon: Optional[float] = None ort_name: Optional[str] = None max_teilnehmer: Optional[int] = None beschreibung: Optional[str] = None class JoinRequest(BaseModel): dog_ids: List[int] = [] # leere Liste = ohne Hund (selten) # ------------------------------------------------------------------ # GET /api/walks — alle offenen Treffen (ab heute, optional Umkreis) # ------------------------------------------------------------------ @router.get("") async def list_walks( lat: Optional[float] = None, lon: Optional[float] = None, radius: int = 20000, alle: bool = False, # True → auch vergangene / stornierte ): today = date.today().isoformat() with db() as conn: q = """ SELECT w.*, u.name AS veranstalter_name, COUNT(DISTINCT wp.user_id) AS teilnehmer_count FROM walks w LEFT JOIN users u ON u.id = w.user_id LEFT JOIN walk_participants wp ON wp.walk_id = w.id WHERE w.status != 'storniert' """ if not alle: q += f" AND w.datum >= '{today}'" q += " GROUP BY w.id ORDER BY w.datum ASC, w.uhrzeit ASC" rows = conn.execute(q).fetchall() result = [dict(r) for r in rows] # Umkreis-Filter if lat is not None and lon is not None: result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius] return result # ------------------------------------------------------------------ # POST /api/walks — Treffen erstellen # ------------------------------------------------------------------ @router.post("", status_code=201) async def create_walk(data: WalkCreate, user=Depends(get_current_user)): with db() as conn: cur = conn.execute(""" INSERT INTO walks (user_id, titel, datum, uhrzeit, lat, lon, ort_name, max_teilnehmer, beschreibung) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, (user['id'], data.titel, data.datum, data.uhrzeit, data.lat, data.lon, data.ort_name, data.max_teilnehmer, data.beschreibung)) row = conn.execute( "SELECT w.*, u.name AS veranstalter_name FROM walks w " "LEFT JOIN users u ON u.id = w.user_id WHERE w.id = ?", (cur.lastrowid,) ).fetchone() return {**dict(row), 'teilnehmer_count': 0} # ------------------------------------------------------------------ # GET /api/walks/nearby — POI-Suche für Treffpunkt-Autocomplete # WICHTIG: Muss VOR /{walk_id} stehen (FastAPI Route-Reihenfolge) # ------------------------------------------------------------------ @router.get("/nearby") async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)): results = [] with db() as conn: # 1. User-eigene Places places = conn.execute( "SELECT name, typ, lat, lon FROM places WHERE lat IS NOT NULL", ).fetchall() for p in places: km = _haversine_km(lat, lon, p["lat"], p["lon"]) if km <= 5: results.append({"name": p["name"], "type": p["typ"] or "place", "lat": p["lat"], "lon": p["lon"], "distance_m": int(km * 1000), "source": "places"}) # 2. Gecachte OSM-POIs osm = conn.execute( "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''" ).fetchall() for p in osm: km = _haversine_km(lat, lon, p["lat"], p["lon"]) if km <= 2: results.append({"name": p["name"], "type": p["type"], "lat": p["lat"], "lon": p["lon"], "distance_m": int(km * 1000), "source": "osm"}) # 3. Overpass: benannte POIs in 1000m try: async with httpx.AsyncClient(timeout=6) as client: q = ( f'[out:json][timeout:6];' f'(node["name"]["leisure"](around:1000,{lat},{lon});' f' node["name"]["amenity"](around:1000,{lat},{lon});' f' node["name"]["tourism"](around:1000,{lat},{lon});' f' way["name"]["leisure"](around:1000,{lat},{lon});' f');out center;' ) r = await client.post("https://overpass-api.de/api/interpreter", data={"data": q}) if r.status_code == 200: for el in r.json().get("elements", []): name = el.get("tags", {}).get("name") if not name: continue elat = el.get("lat") or el.get("center", {}).get("lat") elon = el.get("lon") or el.get("center", {}).get("lon") if elat is None or elon is None: continue km = _haversine_km(lat, lon, elat, elon) if km <= 1: results.append({"name": name, "type": "osm", "lat": elat, "lon": elon, "distance_m": int(km * 1000), "source": "osm"}) except Exception: pass # Deduplizieren nach Name + Sortieren nach Distanz seen = set() unique = [] for r in sorted(results, key=lambda x: x["distance_m"]): key = r["name"].lower() if key not in seen: seen.add(key) unique.append(r) return unique[:20] # ------------------------------------------------------------------ # GET /api/walks/{id} — Detail mit Teilnehmerliste # ------------------------------------------------------------------ @router.get("/{walk_id}") async def get_walk(walk_id: int): with db() as conn: walk = conn.execute( "SELECT w.*, u.name AS veranstalter_name FROM walks w " "LEFT JOIN users u ON u.id = w.user_id WHERE w.id = ?", (walk_id,) ).fetchone() if not walk: raise HTTPException(404, "Treffen nicht gefunden.") # Teilnehmer mit Hunden participants = conn.execute(""" SELECT wp.user_id, u.name AS user_name, GROUP_CONCAT(d.name, ', ') AS hunde FROM walk_participants wp JOIN users u ON u.id = wp.user_id LEFT JOIN walk_participant_dogs wpd ON wpd.walk_id = wp.walk_id AND wpd.user_id = wp.user_id LEFT JOIN dogs d ON d.id = wpd.dog_id WHERE wp.walk_id = ? GROUP BY wp.user_id """, (walk_id,)).fetchall() result = dict(walk) result['teilnehmer'] = [dict(p) for p in participants] result['teilnehmer_count'] = len(result['teilnehmer']) return result # ------------------------------------------------------------------ # PATCH /api/walks/{id} # ------------------------------------------------------------------ @router.patch("/{walk_id}") async def update_walk(walk_id: int, data: WalkUpdate, user=Depends(get_current_user)): with db() as conn: walk = conn.execute("SELECT * FROM walks WHERE id = ?", (walk_id,)).fetchone() if not walk: raise HTTPException(404, "Treffen nicht gefunden.") if walk['user_id'] != user['id']: raise HTTPException(403, "Nur der Veranstalter kann das Treffen bearbeiten.") updates = data.model_dump(exclude_none=True) if updates: cols = ', '.join(f"{k} = ?" for k in updates) conn.execute(f"UPDATE walks SET {cols} WHERE id = ?", [*updates.values(), walk_id]) row = conn.execute( "SELECT w.*, u.name AS veranstalter_name FROM walks w " "LEFT JOIN users u ON u.id = w.user_id WHERE w.id = ?", (walk_id,) ).fetchone() count = conn.execute( "SELECT COUNT(*) FROM walk_participants WHERE walk_id = ?", (walk_id,) ).fetchone()[0] return {**dict(row), 'teilnehmer_count': count} # ------------------------------------------------------------------ # DELETE /api/walks/{id} — stornieren # ------------------------------------------------------------------ @router.delete("/{walk_id}", status_code=204) async def cancel_walk(walk_id: int, user=Depends(get_current_user)): with db() as conn: walk = conn.execute("SELECT * FROM walks WHERE id = ?", (walk_id,)).fetchone() if not walk: raise HTTPException(404, "Treffen nicht gefunden.") if walk['user_id'] != user['id']: raise HTTPException(403, "Nur der Veranstalter kann das Treffen stornieren.") conn.execute("UPDATE walks SET status = 'storniert' WHERE id = ?", (walk_id,)) # ------------------------------------------------------------------ # POST /api/walks/{id}/join — beitreten # ------------------------------------------------------------------ @router.post("/{walk_id}/join") async def join_walk(walk_id: int, data: JoinRequest, user=Depends(get_current_user)): with db() as conn: walk = conn.execute("SELECT * FROM walks WHERE id = ?", (walk_id,)).fetchone() if not walk: raise HTTPException(404, "Treffen nicht gefunden.") if walk['status'] != 'offen': raise HTTPException(400, "Dieses Treffen ist nicht mehr offen.") # Bereits beigetreten? existing = conn.execute( "SELECT 1 FROM walk_participants WHERE walk_id = ? AND user_id = ?", (walk_id, user['id']) ).fetchone() if existing: raise HTTPException(409, "Du nimmst bereits teil.") # Platz frei? count = conn.execute( "SELECT COUNT(*) FROM walk_participants WHERE walk_id = ?", (walk_id,) ).fetchone()[0] if count >= walk['max_teilnehmer']: raise HTTPException(400, "Das Treffen ist bereits voll.") # Beitreten primary_dog = data.dog_ids[0] if data.dog_ids else None conn.execute( "INSERT INTO walk_participants (walk_id, user_id, dog_id) VALUES (?, ?, ?)", (walk_id, user['id'], primary_dog) ) # Hunde eintragen for dog_id in data.dog_ids: conn.execute( "INSERT OR IGNORE INTO walk_participant_dogs (walk_id, user_id, dog_id) VALUES (?, ?, ?)", (walk_id, user['id'], dog_id) ) new_count = count + 1 if new_count >= walk['max_teilnehmer']: conn.execute("UPDATE walks SET status = 'voll' WHERE id = ?", (walk_id,)) return {"status": "joined", "teilnehmer_count": new_count} # ------------------------------------------------------------------ # DELETE /api/walks/{id}/join — verlassen # ------------------------------------------------------------------ @router.delete("/{walk_id}/join", status_code=200) async def leave_walk(walk_id: int, user=Depends(get_current_user)): with db() as conn: walk = conn.execute("SELECT * FROM walks WHERE id = ?", (walk_id,)).fetchone() if not walk: raise HTTPException(404, "Treffen nicht gefunden.") if walk['user_id'] == user['id']: raise HTTPException(400, "Als Veranstalter kannst du nicht austreten — storniere das Treffen stattdessen.") conn.execute( "DELETE FROM walk_participants WHERE walk_id = ? AND user_id = ?", (walk_id, user['id']) ) conn.execute( "DELETE FROM walk_participant_dogs WHERE walk_id = ? AND user_id = ?", (walk_id, user['id']) ) # Status ggf. wieder auf offen setzen count = conn.execute( "SELECT COUNT(*) FROM walk_participants WHERE walk_id = ?", (walk_id,) ).fetchone()[0] if walk['status'] == 'voll': conn.execute("UPDATE walks SET status = 'offen' WHERE id = ?", (walk_id,)) return {"status": "left", "teilnehmer_count": count}