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
This commit is contained in:
rene 2026-04-18 13:52:20 +02:00
parent 80e3f0dc0d
commit e3230237a2
4 changed files with 379 additions and 75 deletions

View file

@ -1,6 +1,7 @@
"""BAN YARO — Gassi-Treffen"""
import math
import httpx
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
@ -20,6 +21,10 @@ def _haversine(lat1, lon1, lat2, lon2):
return 2 * R * math.asin(math.sqrt(a))
def _haversine_km(lat1, lon1, lat2, lon2):
return _haversine(lat1, lon1, lat2, lon2) / 1000
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
@ -103,6 +108,79 @@ async def create_walk(data: WalkCreate, user=Depends(get_current_user)):
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
# ------------------------------------------------------------------