""" BAN YARO — OSM/Overpass POI-Cache + Community-Pins Cacht OSM-Daten lokal, erlaubt Nutzern eigene Marker und Meldungen. """ import math import asyncio 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 = 90 OVERPASS_URLS = [ 'https://overpass.kumi.systems/api/interpreter', 'https://lz4.overpass-api.de/api/interpreter', 'https://overpass-api.de/api/interpreter', ] # Max 1 gleichzeitige Overpass-Anfrage + 2s Mindestabstand (Fair Use) _overpass_sem = asyncio.Semaphore(1) _overpass_last_req = 0.0 _OVERPASS_MIN_DELAY = 2.0 # Sekunden zwischen Anfragen _OVERPASS_UA = 'BanYaro/1.0 (https://banyaro.app; dog-walking PWA; contact: mail@motocamp.de)' 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): global _overpass_last_req for url in OVERPASS_URLS: for attempt in range(2): try: async with _overpass_sem: # Fair-Use: Mindestabstand zwischen Anfragen einhalten import time wait = _OVERPASS_MIN_DELAY - (time.monotonic() - _overpass_last_req) if wait > 0: await asyncio.sleep(wait) async with httpx.AsyncClient( timeout=40, headers={'User-Agent': _OVERPASS_UA}, ) as client: r = await client.post(url, data={'data': query}) _overpass_last_req = time.monotonic() if r.status_code == 200: return r.json().get('elements', []) if r.status_code == 429: logger.warning(f"Overpass 429 {url} (Versuch {attempt + 1}/2)") await asyncio.sleep(30 * (attempt + 1)) continue # gleiche URL nochmal logger.warning(f"Overpass {r.status_code} {url} — nächste Instanz") break # nächste URL except Exception as exc: logger.warning(f"Overpass Verbindungsfehler {url}: {exc}") break # nächste URL raise Exception("Alle Overpass-Instanzen fehlgeschlagen") 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: await asyncio.gather(*[_fetch_and_store_tile(type, x, y) for (x, y) in stale]) 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(): tasks = [ _fetch_and_store_tile(pt, x, y) for pt in OSM_QUERIES for (x, y) in _stale_tiles(pt, tiles) ] await asyncio.gather(*tasks) background_tasks.add_task(_warmup) return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())}