banyaro/backend/routes/routen.py
rene a7753c9cf5 Sprint 16: Chat-Fotos/Online/Read-Receipts, Gesundheit-Dokumente löschen, Bugfixes
- Chat: Foto-Versand (POST /api/chat/conversations/{id}/upload, media_url/media_type)
- Chat: Online-Indikator (last_seen Heartbeat, grüner Dot, 3min-Fenster)
- Chat: Read Receipts (read_at, Einzel-/Doppelhaken-Icons)
- Gesundheit: Dokument löschen (DELETE .../dokument, Datei + DB-Eintrag)
- Bug: events.user_id NOT NULL → nullable (Table-Recreation-Migration)
- Bug: scheduler INSERT user_id 0 → NULL
- Bug: Wikidata Rate-Limit: sleep 0.3s→1.0s, retries 2→4, exponentielles Backoff
- SW: by-v146, APP_VER 119
2026-04-17 22:38:33 +02:00

261 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""BAN YARO — Gassi-Routen"""
import json, math, os, uuid
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
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
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()
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)
# ------------------------------------------------------------------
# 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 # 15
@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}/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}