banyaro/backend/routes/walks.py
rene e3230237a2 Feature: Gassi-Treffen — Orts-Autocomplete, Modal-UX, Teilnehmerliste, Karten-Fix
- Orts-/POI-Suche mit GPS und Vorschlägen (wie Tagebuch) + Mini-Karte im Formular
- Stornieren/Austreten als Zwei-Klick-Pattern (kein UI.modal.confirm in Modals)
- Teilnehmerliste im Detail-Modal mit User-Namen und Hunden
- Leaflet invalidateSize auf 150ms (Memory-Regel), _loadLeaflet robuster
- /api/walks/nearby Backend-Endpunkt (vor /{walk_id} Route)
- SW by-v203, APP_VER 169
2026-04-18 13:52:20 +02:00

333 lines
13 KiB
Python

"""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}