Sprint 10: OSM-POI-Cache, Karten-Clustering, Routen-Redesign

Karte (map.js):
- OSM Overpass API: Restaurants, Tierärzte, Parkplätze, Bänke, Wasserstellen
- Leaflet.markercluster für alle OSM-Layer
- Standort-Dot mit GPS-Genauigkeitskreis, Wake-Lock bei Aufzeichnung
- Community-Pins setzen/löschen, Meldungen, Crosshair-Placement
- Layer-Sichtbarkeit in localStorage (by_map_visible_v1)

Routen (routes.js + routen.py):
- Komoot-Stil: SVG-Track-Preview, Foto-Upload, Nearby-POIs im Detail-Modal
- Neue Felder: is_public, hunde_tauglichkeit, foto_urls
- Rate-Endpoint (POST /api/routes/{id}/rate)
- Foto-Upload (POST /api/routes/{id}/photo)
- Fix: json_extract $[-1] → $[#-1] (SQLite-kompatibler Pfad für letztes Element)

Backend (osm.py, database.py, scheduler.py):
- /api/osm/pois: OSM-Overpass-Cache mit Tile-Logik (14 Tage TTL)
- /api/osm/user-poi: Community-Marker CRUD
- /api/osm/report: Marker als ungültig melden
- Neue Tabellen: osm_pois, osm_tiles, user_map_pois, osm_reports
- Giftköder-Archiv-Job (täglich 03:00, soft-delete nach Ablauf)
- Giftköder-Archiv-Job als APScheduler-CronJob

UI: Orte-Menüpunkt entfernt (in Karte integriert), APP_VER auf 62
This commit is contained in:
rene 2026-04-15 16:30:10 +02:00
parent bf26e5faf4
commit ebe4ce20cf
16 changed files with 3020 additions and 737 deletions

317
backend/routes/osm.py Normal file
View file

@ -0,0 +1,317 @@
"""
BAN YARO OSM/Overpass POI-Cache + Community-Pins
Cacht OSM-Daten lokal, erlaubt Nutzern eigene Marker und Meldungen.
"""
import math
import httpx
import logging
from typing import Optional
from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from database import db
from auth import get_current_user, get_current_user_optional as get_optional_user
logger = logging.getLogger(__name__)
router = APIRouter()
CACHE_ZOOM = 12
CACHE_DAYS = 14
OVERPASS_URL = 'https://overpass-api.de/api/interpreter'
OSM_QUERIES = {
'waste_basket': '[out:json][timeout:20];node["amenity"="waste_basket"]({bbox});out;',
'dog_park': '[out:json][timeout:25];(way["leisure"="dog_park"]({bbox});node["leisure"="dog_park"]({bbox});way["leisure"="park"]["dog"="yes"]({bbox});node["leisure"="park"]["dog"="yes"]({bbox}););out center;',
'drinking_water': '[out:json][timeout:20];node["amenity"="drinking_water"]({bbox});out;',
'tierarzt': '[out:json][timeout:25];(node["amenity"="veterinary"]({bbox});way["amenity"="veterinary"]({bbox}););out center;',
'shop': '[out:json][timeout:25];(node["shop"="pet"]({bbox});way["shop"="pet"]({bbox}););out center;',
'restaurant': '[out:json][timeout:35];(node["amenity"="restaurant"]({bbox});way["amenity"="restaurant"]({bbox});node["amenity"="cafe"]({bbox});way["amenity"="cafe"]({bbox});node["amenity"="biergarten"]({bbox});way["amenity"="biergarten"]({bbox}););out center;',
'bank': '[out:json][timeout:20];node["amenity"="bench"]({bbox});out;',
}
# Ab dieser Anzahl Meldungen wird ein Marker ausgeblendet
REPORT_THRESHOLD = 3
# ------------------------------------------------------------------
# Tile-Mathematik
# ------------------------------------------------------------------
def _lat_lon_to_tile(lat, lon, zoom):
n = 2 ** zoom
x = int((lon + 180) / 360 * n)
y = int((1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n)
return x, y
def _tile_to_bbox(x, y, zoom):
n = 2 ** zoom
west = x / n * 360 - 180
east = (x + 1) / n * 360 - 180
north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
return south, west, north, east
def _covering_tiles(south, west, north, east, zoom):
x0, y0 = _lat_lon_to_tile(north, west, zoom)
x1, y1 = _lat_lon_to_tile(south, east, zoom)
return [(x, y) for x in range(x0, x1 + 1) for y in range(y0, y1 + 1)]
# ------------------------------------------------------------------
# Overpass-Fetch + Cache
# ------------------------------------------------------------------
async def _fetch_overpass(query):
async with httpx.AsyncClient(timeout=40) as client:
r = await client.post(OVERPASS_URL, data={'data': query})
r.raise_for_status()
return r.json().get('elements', [])
def _stale_tiles(poi_type, tiles):
stale = []
with db() as conn:
for (x, y) in tiles:
key = f"{CACHE_ZOOM}_{x}_{y}"
row = conn.execute(
"""SELECT 1 FROM osm_tiles WHERE type=? AND tile_key=?
AND cached_at > datetime('now', ?)""",
(poi_type, key, f'-{CACHE_DAYS} days')
).fetchone()
if not row:
stale.append((x, y))
return stale
async def _fetch_and_store_tile(poi_type, x, y):
key = f"{CACHE_ZOOM}_{x}_{y}"
s, w, n, e = _tile_to_bbox(x, y, CACHE_ZOOM)
query = OSM_QUERIES[poi_type].format(bbox=f"{s},{w},{n},{e}")
try:
elements = await _fetch_overpass(query)
except Exception as exc:
logger.warning(f"Overpass Fehler {poi_type} Tile {key}: {exc}")
return
with db() as conn:
for el in elements:
osm_id = el.get('id')
lat = el.get('lat') or (el.get('center') or {}).get('lat')
lon = el.get('lon') or (el.get('center') or {}).get('lon')
if not (osm_id and lat and lon):
continue
tags = el.get('tags') or {}
name = tags.get('name') or tags.get('description')
opening_hours = tags.get('opening_hours')
phone = tags.get('phone') or tags.get('contact:phone') or tags.get('telephone')
website = tags.get('website') or tags.get('contact:website') or tags.get('url')
conn.execute("""
INSERT INTO osm_pois (osm_id, type, lat, lon, name, opening_hours, phone, website, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(osm_id, type) DO UPDATE SET
lat=excluded.lat, lon=excluded.lon,
name=excluded.name,
opening_hours=excluded.opening_hours,
phone=excluded.phone,
website=excluded.website,
cached_at=excluded.cached_at
""", (osm_id, poi_type, lat, lon, name, opening_hours, phone, website))
conn.execute("""
INSERT INTO osm_tiles (type, tile_key, cached_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(type, tile_key) DO UPDATE SET cached_at=excluded.cached_at
""", (poi_type, key))
logger.info(f"OSM Tile {key} ({poi_type}): {len(elements)} POIs gecacht.")
# ------------------------------------------------------------------
# GET /pois — OSM + Community-Pins
# fast=true: nur DB, kein Overpass-Fetch (sofortantwort)
# fast=false (default): fetcht Overpass wenn Tiles veraltet sind
# ------------------------------------------------------------------
@router.get('/pois')
async def get_pois(
type: str = Query(...),
south: float = Query(...),
west: float = Query(...),
north: float = Query(...),
east: float = Query(...),
fast: bool = Query(False),
user = Depends(get_optional_user),
):
result = []
fetched_fresh = False
if type in OSM_QUERIES:
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
stale = _stale_tiles(type, tiles)
if stale and not fast:
for (x, y) in stale:
await _fetch_and_store_tile(type, x, y)
fetched_fresh = True
with db() as conn:
reported = {
row[0] for row in conn.execute(
"""SELECT osm_id FROM osm_reports
WHERE type=? AND osm_id IS NOT NULL
GROUP BY osm_id HAVING COUNT(*) >= ?""",
(type, REPORT_THRESHOLD)
).fetchall()
}
rows = conn.execute("""
SELECT osm_id, lat, lon, name, opening_hours, phone, website FROM osm_pois
WHERE type=? AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?
""", (type, south, north, west, east)).fetchall()
for r in rows:
if r['osm_id'] not in reported:
result.append({
'id': r['osm_id'],
'lat': r['lat'],
'lon': r['lon'],
'name': r['name'],
'opening_hours': r['opening_hours'],
'phone': r['phone'],
'website': r['website'],
'source': 'osm',
'fresh': fetched_fresh,
})
# Community-Pins: user_map_pois passend zum Typ
# type='sonstiges' → zeigt alle 'sonstiges'-Pins
# type='waste_basket' etc. → zeigt user-submitted POIs dieses Typs
user_poi_type = type # direkte Übereinstimmung
with db() as conn:
reported_user = {
row[0] for row in conn.execute(
"""SELECT user_poi_id FROM osm_reports
WHERE user_poi_id IS NOT NULL
GROUP BY user_poi_id HAVING COUNT(*) >= ?""",
(REPORT_THRESHOLD,)
).fetchall()
}
user_pois = conn.execute("""
SELECT p.id, p.lat, p.lon, p.name, p.notiz, p.user_id, p.type,
u.name AS username
FROM user_map_pois p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.type=? AND p.lat BETWEEN ? AND ? AND p.lon BETWEEN ? AND ?
""", (user_poi_type, south, north, west, east)).fetchall()
user_id = user['id'] if user else None
for p in user_pois:
if p['id'] not in reported_user:
result.append({
'id': f"u{p['id']}",
'user_poi_id': p['id'],
'lat': p['lat'],
'lon': p['lon'],
'name': p['name'],
'notiz': p['notiz'],
'username': p['username'],
'source': 'user',
'own': p['user_id'] == user_id,
})
return result
# ------------------------------------------------------------------
# POST /user-poi — Community-Marker setzen
# ------------------------------------------------------------------
class UserPoiIn(BaseModel):
type: str
lat: float
lon: float
name: Optional[str] = None
notiz: Optional[str] = None
ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park',
'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius)
'kotbeutel', # Kotbeutelspender
'gefahr', # Allgemeine Gefahr / Hinweis
'parkplatz', # Hundefreundlicher Parkplatz
'treffpunkt', # Treffpunkt für Hundehalter
'sonstiges',
}
@router.post('/user-poi')
async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)):
if body.type not in ALLOWED_TYPES:
raise HTTPException(400, 'Ungültiger Typ')
with db() as conn:
row = conn.execute("""
INSERT INTO user_map_pois (user_id, type, lat, lon, name, notiz)
VALUES (?, ?, ?, ?, ?, ?)
""", (user['id'], body.type, body.lat, body.lon, body.name, body.notiz))
new_id = row.lastrowid
return {'id': new_id, 'status': 'ok'}
# ------------------------------------------------------------------
# DELETE /user-poi/{id} — eigenen Marker löschen
# ------------------------------------------------------------------
@router.delete('/user-poi/{poi_id}')
async def delete_user_poi(poi_id: int, user = Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT user_id FROM user_map_pois WHERE id=?", (poi_id,)
).fetchone()
if not row:
raise HTTPException(404, 'Nicht gefunden')
if row['user_id'] != user['id']:
raise HTTPException(403, 'Nicht berechtigt')
conn.execute("DELETE FROM user_map_pois WHERE id=?", (poi_id,))
return {'status': 'ok'}
# ------------------------------------------------------------------
# POST /report — Marker als ungültig melden
# ------------------------------------------------------------------
class ReportIn(BaseModel):
type: str
grund: str
osm_id: Optional[int] = None
user_poi_id: Optional[int] = None
ALLOWED_GRUENDE = {'existiert_nicht', 'falsche_position', 'spam', 'sonstiges'}
@router.post('/report')
async def report_poi(body: ReportIn, user = Depends(get_current_user)):
if not body.osm_id and not body.user_poi_id:
raise HTTPException(400, 'osm_id oder user_poi_id erforderlich')
if body.grund not in ALLOWED_GRUENDE:
raise HTTPException(400, 'Ungültiger Grund')
with db() as conn:
# Doppelmeldung vom selben User verhindern
existing = conn.execute("""
SELECT 1 FROM osm_reports
WHERE user_id=? AND osm_id IS ? AND user_poi_id IS ?
""", (user['id'], body.osm_id, body.user_poi_id)).fetchone()
if existing:
return {'status': 'bereits_gemeldet'}
conn.execute("""
INSERT INTO osm_reports (user_id, osm_id, user_poi_id, type, grund)
VALUES (?, ?, ?, ?, ?)
""", (user['id'], body.osm_id, body.user_poi_id, body.type, body.grund))
return {'status': 'ok'}
# ------------------------------------------------------------------
# POST /analyze — Cache-Warmup für alle Typen
# ------------------------------------------------------------------
@router.post('/analyze')
async def analyze_region(
background_tasks: BackgroundTasks,
south: float = Query(...),
west: float = Query(...),
north: float = Query(...),
east: float = Query(...),
):
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
async def _warmup():
for poi_type in OSM_QUERIES:
for (x, y) in _stale_tiles(poi_type, tiles):
await _fetch_and_store_tile(poi_type, x, y)
background_tasks.add_task(_warmup)
return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())}

View file

@ -1,11 +1,11 @@
"""BAN YARO — Gassi-Routen"""
import json, math
from fastapi import APIRouter, Depends, HTTPException
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
from auth import get_current_user, get_current_user_optional
router = APIRouter()
@ -37,6 +37,8 @@ class RouteCreate(BaseModel):
untergrund: Optional[str] = None # wald | asphalt | wiese | mix
schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = True
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
class RouteUpdate(BaseModel):
name: Optional[str] = None
@ -45,15 +47,27 @@ class RouteUpdate(BaseModel):
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'):
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
@ -65,6 +79,7 @@ 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("""
@ -72,11 +87,12 @@ async def list_routes(
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
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
@ -85,9 +101,14 @@ async def list_routes(
result = []
for row in rows:
d = dict(row)
for k in ('schatten', 'leine_empfohlen'):
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:
@ -95,6 +116,9 @@ async def list_routes(
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
@ -112,13 +136,15 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)):
cur = conn.execute("""
INSERT INTO routes
(user_id, name, beschreibung, gps_track, distanz_km, dauer_min,
schwierigkeit, untergrund, schatten, leine_empfohlen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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)
@ -153,7 +179,7 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren
updates = data.model_dump(exclude_none=True)
if updates:
for key in ('schatten', 'leine_empfohlen'):
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)
@ -175,3 +201,61 @@ async def delete_route(route_id: int, user=Depends(get_current_user)):
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}