- 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
333 lines
13 KiB
Python
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}
|