diff --git a/VERSION b/VERSION index 0948691..5ec4258 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1155 \ No newline at end of file +1159 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 08ac7db..457f5a3 100644 --- a/backend/database.py +++ b/backend/database.py @@ -356,6 +356,50 @@ def init_db(): ); CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon); + -- OSM-Account-Verknüpfung (OAuth2) je Nutzer — Basis für OSM-Beiträge + -- ("Hund war willkommen" → dog=yes) + spätere Gamification/Pro-Freischaltung. + -- access_token verschlüsselt at rest (token_enc). + CREATE TABLE IF NOT EXISTS user_osm ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + osm_uid INTEGER NOT NULL, + osm_name TEXT NOT NULL, + token_enc TEXT NOT NULL, + scopes TEXT, + linked_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- OSM-Beiträge ("Hund war willkommen" → dog=yes). Anti-Fraud: GPS-Beleg + -- über eine kürzliche eigene Tour (route_id) + Zeit/Rate-Limits. + -- status: pending → submitted (an OSM) → confirmed (Revert-überlebt) | rejected. + CREATE TABLE IF NOT EXISTS osm_contributions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + osm_id INTEGER NOT NULL, + osm_type TEXT NOT NULL DEFAULT 'node', -- node | way + poi_type TEXT, + tag_key TEXT NOT NULL DEFAULT 'dog', + tag_value TEXT NOT NULL DEFAULT 'yes', + lat REAL, + lon REAL, + route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL, + gps_distance_m REAL, + gps_points_near INTEGER, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + submitted_at TEXT, + changeset_id INTEGER, + UNIQUE(user_id, osm_id, tag_key) + ); + CREATE INDEX IF NOT EXISTS idx_osm_contrib_user ON osm_contributions(user_id, status); + + -- Pro-Freischaltungen aus OSM-Beiträgen (1 Zeile = 1 freigeschaltetes Jahr). + -- Idempotenz: earned = confirmed//100; nur (earned - vorhandene Zeilen) neu gewähren. + CREATE TABLE IF NOT EXISTS osm_pro_grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + -- VERLORENE HUNDE CREATE TABLE IF NOT EXISTS lost_dogs ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/backend/main.py b/backend/main.py index e954c83..f4ecac6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -227,6 +227,8 @@ from routes.walks import router as walks_router from routes.events import router as events_router from routes.sitting import router as sitting_router from routes.osm import router as osm_router +from routes.osm_auth import router as osm_auth_router +from routes.osm_contrib import router as osm_contrib_router from routes.forum import router as forum_router from routes.lost import router as lost_router from routes.knigge import router as knigge_router @@ -292,6 +294,8 @@ app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Tre app.include_router(events_router, prefix="/api/events", tags=["Events"]) app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"]) app.include_router(osm_router, prefix="/api/osm", tags=["OSM"]) +app.include_router(osm_auth_router, prefix="/api/osm-auth", tags=["OSM-Auth"]) +app.include_router(osm_contrib_router, prefix="/api/osm-contrib", tags=["OSM-Beiträge"]) app.include_router(weather_router, prefix="/api/weather", tags=["Wetter"]) app.include_router(social_router, prefix="/api/social", tags=["Social"]) app.include_router(forum_router, prefix="/api/forum", tags=["Forum"]) diff --git a/backend/requirements.txt b/backend/requirements.txt index 414ec32..d45b6f8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,6 +7,7 @@ pydantic[email]==2.10.6 bcrypt==4.3.0 PyJWT==2.10.1 httpx==0.28.1 +cryptography==44.0.0 openai==1.59.2 anthropic==0.49.0 pywebpush==2.0.0 diff --git a/backend/routes/osm_auth.py b/backend/routes/osm_auth.py new file mode 100644 index 0000000..213826f --- /dev/null +++ b/backend/routes/osm_auth.py @@ -0,0 +1,179 @@ +""" +OSM-Account-Verknüpfung via OAuth2 (Modell A: Beiträge laufen unter dem +eigenen OSM-Account des Nutzers). Basis fürs spätere "Hund war willkommen" +(dog=yes) + Gamification/Pro-Freischaltung. + +Flow: + 1. Frontend ruft (eingeloggt) GET /api/osm-auth/authorize → bekommt die + OSM-Authorize-URL inkl. signiertem `state` (trägt die banyaro-user_id + + CSRF-Nonce, 10 Min gültig) und leitet den Browser dorthin. + 2. OSM leitet zurück auf GET /api/osm-auth/callback?code=&state= (ohne JWT — + daher die user_id aus `state`). Token-Tausch, OSM-Name holen, Token + verschlüsselt in user_osm speichern, zurück in die App leiten. + 3. GET /status zeigt Verknüpfungsstatus, POST /unlink trennt. + +ENV: OSM_CLIENT_ID, OSM_CLIENT_SECRET, OSM_REDIRECT_URI, OSM_POST_LINK_REDIRECT. +Token-Schlüssel wird aus JWT_SECRET abgeleitet (oder OSM_TOKEN_KEY überschreibt). +""" +import os +import base64 +import hashlib +import logging +from urllib.parse import urlencode +from datetime import datetime, timezone, timedelta + +import jwt +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import RedirectResponse +from cryptography.fernet import Fernet, InvalidToken + +from database import db +from auth import get_current_user, JWT_SECRET, JWT_ALGO + +logger = logging.getLogger(__name__) +router = APIRouter() + +# --- OSM-OAuth2-Endpunkte --- +# Konfigurierbar, damit Staging gegen die Dev-Sandbox laufen kann (KEINE echten +# Edits auf der Produktiv-OSM!). Staging-.env: +# OSM_OAUTH_BASE=https://master.apis.dev.openstreetmap.org +# OSM_API_BASE=https://master.apis.dev.openstreetmap.org +OSM_OAUTH_BASE = os.getenv("OSM_OAUTH_BASE", "https://www.openstreetmap.org").rstrip("/") +OSM_API_BASE = os.getenv("OSM_API_BASE", "https://api.openstreetmap.org").rstrip("/") +OSM_AUTHORIZE = OSM_OAUTH_BASE + "/oauth2/authorize" +OSM_TOKEN = OSM_OAUTH_BASE + "/oauth2/token" +OSM_USER_API = OSM_API_BASE + "/api/0.6/user/details.json" +OSM_SCOPES = "read_prefs write_api" + +CLIENT_ID = os.getenv("OSM_CLIENT_ID", "") +CLIENT_SECRET = os.getenv("OSM_CLIENT_SECRET", "") +REDIRECT_URI = os.getenv("OSM_REDIRECT_URI", "https://staging.banyaro.app/api/osm-auth/callback") +POST_LINK_REDIRECT = os.getenv("OSM_POST_LINK_REDIRECT", "/#settings") + +_STATE_TTL_MIN = 10 + +# Fernet-Schlüssel zur Token-Verschlüsselung: dediziertes OSM_TOKEN_KEY oder +# deterministisch aus JWT_SECRET abgeleitet (kein zusätzliches Secret nötig). +def _fernet() -> Fernet: + raw = os.getenv("OSM_TOKEN_KEY") + if raw: + return Fernet(raw.encode() if isinstance(raw, str) else raw) + key = base64.urlsafe_b64encode(hashlib.sha256(JWT_SECRET.encode()).digest()) + return Fernet(key) + +def _encrypt(token: str) -> str: + return _fernet().encrypt(token.encode()).decode() + +def _decrypt(token_enc: str) -> str: + return _fernet().decrypt(token_enc.encode()).decode() + + +# ------------------------------------------------------------------ +# GET /authorize — liefert die OSM-Authorize-URL (Frontend redirectet dorthin) +# ------------------------------------------------------------------ +@router.get("/authorize") +async def authorize(user=Depends(get_current_user)): + if not CLIENT_ID: + raise HTTPException(503, "OSM-Anbindung nicht konfiguriert (OSM_CLIENT_ID fehlt).") + state = jwt.encode( + {"uid": user["id"], + "exp": datetime.now(timezone.utc) + timedelta(minutes=_STATE_TTL_MIN), + "purpose": "osm-link"}, + JWT_SECRET, algorithm=JWT_ALGO, + ) + params = { + "response_type": "code", + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "scope": OSM_SCOPES, + "state": state, + } + url = OSM_AUTHORIZE + "?" + urlencode(params) + return {"authorize_url": url} + + +# ------------------------------------------------------------------ +# GET /callback — OSM leitet hierher zurück (Browser-Redirect, kein JWT) +# ------------------------------------------------------------------ +@router.get("/callback") +async def callback(code: str = Query(...), state: str = Query(...)): + # 1) state verifizieren → banyaro-user_id (CSRF + Zuordnung) + try: + payload = jwt.decode(state, JWT_SECRET, algorithms=[JWT_ALGO]) + if payload.get("purpose") != "osm-link": + raise ValueError("falscher state-Zweck") + uid = int(payload["uid"]) + except Exception: + raise HTTPException(400, "Ungültiger oder abgelaufener Verknüpfungs-Link.") + + # 2) code → access_token tauschen + async with httpx.AsyncClient(timeout=15) as client: + tok = await client.post(OSM_TOKEN, data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }) + if tok.status_code != 200: + logger.warning("OSM-Token-Tausch fehlgeschlagen: %s %s", tok.status_code, tok.text[:200]) + raise HTTPException(502, "OSM-Token-Tausch fehlgeschlagen.") + access_token = tok.json().get("access_token") + if not access_token: + raise HTTPException(502, "OSM lieferte kein access_token.") + + # 3) OSM-Identität holen (uid + Anzeigename) + me = await client.get(OSM_USER_API, headers={"Authorization": f"Bearer {access_token}"}) + if me.status_code != 200: + raise HTTPException(502, "OSM-Nutzerdaten konnten nicht geladen werden.") + u = me.json().get("user", {}) + osm_uid, osm_name = u.get("id"), u.get("display_name") + if not (osm_uid and osm_name): + raise HTTPException(502, "OSM-Nutzerdaten unvollständig.") + + # 4) verschlüsselt speichern (eine Verknüpfung pro banyaro-User) + with db() as conn: + conn.execute( + """INSERT INTO user_osm (user_id, osm_uid, osm_name, token_enc, scopes, linked_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(user_id) DO UPDATE SET + osm_uid=excluded.osm_uid, osm_name=excluded.osm_name, + token_enc=excluded.token_enc, scopes=excluded.scopes, + linked_at=excluded.linked_at""", + (uid, osm_uid, osm_name, _encrypt(access_token), OSM_SCOPES), + ) + logger.info("OSM verknüpft: banyaro-user %s ↔ OSM '%s' (%s)", uid, osm_name, osm_uid) + return RedirectResponse(POST_LINK_REDIRECT, status_code=302) + + +# ------------------------------------------------------------------ +# GET /status — Verknüpfungsstatus des eingeloggten Nutzers +# ------------------------------------------------------------------ +@router.get("/status") +async def status(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT osm_name, osm_uid, linked_at FROM user_osm WHERE user_id=?", + (user["id"],) + ).fetchone() + # Registrierungs-URL umgebungsabhängig: Sandbox auf Staging, echte OSM in Prod. + base = { + "linked": bool(row), + "signup_url": OSM_OAUTH_BASE + "/user/new", + "sandbox": "dev.openstreetmap" in OSM_OAUTH_BASE, + "configured": bool(CLIENT_ID), + } + if row: + base.update(osm_name=row["osm_name"], osm_uid=row["osm_uid"], linked_at=row["linked_at"]) + return base + + +# ------------------------------------------------------------------ +# POST /unlink — Verknüpfung trennen (Token lokal löschen) +# ------------------------------------------------------------------ +@router.post("/unlink") +async def unlink(user=Depends(get_current_user)): + with db() as conn: + conn.execute("DELETE FROM user_osm WHERE user_id=?", (user["id"],)) + return {"status": "ok"} diff --git a/backend/routes/osm_contrib.py b/backend/routes/osm_contrib.py new file mode 100644 index 0000000..672be37 --- /dev/null +++ b/backend/routes/osm_contrib.py @@ -0,0 +1,324 @@ +""" +OSM-Beiträge: "Hund war willkommen" (dog=yes) erfassen — mit Anti-Fraud und +Gamification-Zähler. + +Anti-Fraud (Defense in Depth, soweit serverseitig möglich): + - GPS-Beleg: eine kürzliche EIGENE Tour (routes.gps_track) muss am POI + vorbeiführen (≤ GPS_RADIUS_M) mit Verweil-Proxy (≥ DWELL_MIN_POINTS Punkte + im Radius — ohne Pro-Punkt-Zeitstempel der beste verfügbare Dwell-Proxy). + - Zeitkomponente: Tour-Recency (ROUTE_RECENCY_H) + Tages-Rate-Limit (DAILY_CAP). + - Dedup: 1× pro POI pro User. Positions-Sanity gegen die osm_pois-Koordinate. + +NOCH NICHT hier (folgt separat, höheres Risiko): Geräte-Attestierung + +Sensor-Korroboration (nativ), tatsächliches OSM-Changeset-Upload, Revert- +Überleben/Konsens, und die echte Pro-Freischaltung. Beiträge werden daher als +status='pending' verifiziert erfasst; der Zähler ist provisorisch. +""" +import json +import logging +import xml.etree.ElementTree as ET +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from typing import Optional + +import httpx +from database import db +from auth import get_current_user +from math_utils import haversine_m +from routes.osm_auth import OSM_API_BASE, _decrypt + +logger = logging.getLogger(__name__) +router = APIRouter() + +# --- Anti-Fraud-Parameter --- +GPS_RADIUS_M = 50 # max. Abstand POI ↔ nächster Track-Punkt +DWELL_MIN_POINTS = 2 # mind. so viele Track-Punkte im Radius (Verweil-Proxy) +ROUTE_RECENCY_H = 48 # Tour darf max. so alt sein +POI_NEAR_M = 80 # eingereichte Position muss so nah am POI sein +DAILY_CAP = 20 # max. Beiträge pro Tag/User + +# --- Gamification-Schwellen --- +BADGE_AT = 10 # "Kartograf"-Badge +PRO_AT = 100 # 100 geprüfte → 1 Jahr Pro (Freischaltung folgt separat) + + +class DogFriendlyIn(BaseModel): + osm_id: int + osm_type: str = Field('node', pattern='^(node|way)$') + poi_type: Optional[str] = None + lat: float + lon: float + welcome: bool = True # True → dog=yes, False → dog=no (Pächterwechsel) + + +def _verified_count(conn, uid: int) -> int: + return conn.execute( + "SELECT COUNT(*) FROM osm_contributions WHERE user_id=? AND status!='rejected'", + (uid,) + ).fetchone()[0] + + +# ------------------------------------------------------------------ +# OSM-Changeset-Upload (write_api): Element holen → dog=yes → Changeset. +# ------------------------------------------------------------------ +def _changeset_xml(value: str) -> str: + note = "Hund willkommen" if value == "yes" else "Hund nicht willkommen" + return ('' + '' + f'' + '' + '') + + +def _mark_submitted(contrib_id: int, etype: str, changeset_id): + with db() as conn: + conn.execute( + "UPDATE osm_contributions SET status='submitted', osm_type=?, " + "changeset_id=?, submitted_at=datetime('now') WHERE id=?", + (etype, changeset_id, contrib_id) + ) + + +async def submit_dog_tag(contrib_id: int, osm_id: int, osm_type: str, token: str, value: str) -> bool: + """Setzt dog= (yes|no) am OSM-Element des Nutzers (eigener OAuth-Token). + Idempotent. Wirft bei Fehler → Beitrag bleibt 'pending' (Retry über den Job).""" + headers = {"Authorization": f"Bearer {token}"} + order = [osm_type, "way" if osm_type == "node" else "node"] + async with httpx.AsyncClient(timeout=20) as client: + # 1) Element holen (node/way auto-detect) + elem_xml = etype = None + for t in order: + r = await client.get(f"{OSM_API_BASE}/api/0.6/{t}/{osm_id}", headers=headers) + if r.status_code == 200: + elem_xml, etype = r.text, t + break + if elem_xml is None: + raise RuntimeError(f"OSM-Element {osm_id} nicht gefunden") + root = ET.fromstring(elem_xml) + el = root.find(etype) + existing = el.find("./tag[@k='dog']") + if existing is not None and existing.get("v") == value: + _mark_submitted(contrib_id, etype, None) # schon gesetzt → fertig + return True + + # 2) Changeset öffnen + cs = await client.put(f"{OSM_API_BASE}/api/0.6/changeset/create", + headers=headers, content=_changeset_xml(value)) + cs.raise_for_status() + changeset_id = cs.text.strip() + + # 3) dog= setzen + Element hochladen (Geometrie/andere Tags bleiben) + if existing is not None: + existing.set("v", value) + else: + ET.SubElement(el, "tag", {"k": "dog", "v": value}) + el.set("changeset", changeset_id) + up = await client.put(f"{OSM_API_BASE}/api/0.6/{etype}/{osm_id}", + headers=headers, content=ET.tostring(root, encoding="unicode")) + up.raise_for_status() + + # 4) Changeset schließen + await client.put(f"{OSM_API_BASE}/api/0.6/changeset/{changeset_id}/close", + headers=headers) + + _mark_submitted(contrib_id, etype, int(changeset_id)) + return True + + +@router.post('/dog-friendly') +async def mark_dog_friendly(body: DogFriendlyIn, user=Depends(get_current_user)): + uid = user['id'] + with db() as conn: + # 0) OSM verknüpft? + if not conn.execute("SELECT 1 FROM user_osm WHERE user_id=?", (uid,)).fetchone(): + raise HTTPException(409, "Bitte zuerst dein OSM-Konto verknüpfen.") + + value = 'yes' if body.welcome else 'no' + + # 1) Vorhandene Markierung? Gleicher Wert → fertig. Anderer Wert → + # umdrehen erlaubt (Pächter wechseln → aus willkommen wird nicht mehr). + existing = conn.execute( + "SELECT id, tag_value FROM osm_contributions " + "WHERE user_id=? AND osm_id=? AND tag_key='dog'", + (uid, body.osm_id) + ).fetchone() + if existing and existing['tag_value'] == value: + raise HTTPException(409, "Diesen Ort hast du schon so markiert.") + + # 2) Zeitkomponente: Tages-Rate-Limit + today_n = conn.execute( + "SELECT COUNT(*) FROM osm_contributions " + "WHERE user_id=? AND created_at > datetime('now','-1 day')", + (uid,) + ).fetchone()[0] + if today_n >= DAILY_CAP: + raise HTTPException(429, "Tageslimit erreicht — morgen geht's weiter.") + + # 3) GPS-Beleg: kürzliche Tour, die am POI vorbeiführt (+ Verweil-Proxy) + routes = conn.execute( + "SELECT id, gps_track FROM routes " + "WHERE user_id=? AND created_at > datetime('now', ?) ORDER BY created_at DESC", + (uid, f'-{ROUTE_RECENCY_H} hours') + ).fetchall() + best = None # (route_id, min_dist, points_near) + for r in routes: + try: + track = json.loads(r['gps_track']) + except Exception: + continue + near, mind = 0, float('inf') + for p in track: + d = haversine_m(body.lat, body.lon, p['lat'], p['lon']) + if d < mind: + mind = d + if d <= GPS_RADIUS_M: + near += 1 + if mind <= GPS_RADIUS_M and near >= DWELL_MIN_POINTS: + if best is None or mind < best[1]: + best = (r['id'], mind, near) + if not best: + raise HTTPException( + 422, + "Kein GPS-Beleg: In deinen letzten Touren ist kein Besuch an diesem Ort. " + "Geh mit deinem Hund dorthin, dann kannst du ihn eintragen." + ) + + # 4) Positions-Sanity gegen die bekannte POI-Koordinate + poi = conn.execute( + "SELECT lat, lon FROM osm_pois WHERE osm_id=? LIMIT 1", (body.osm_id,) + ).fetchone() + if poi and haversine_m(body.lat, body.lon, poi['lat'], poi['lon']) > POI_NEAR_M: + raise HTTPException(422, "Position passt nicht zum gewählten Ort.") + + # 5) verifiziert erfassen oder umdrehen (pending; OSM-Upload gleich best-effort) + if existing: + conn.execute( + "UPDATE osm_contributions SET tag_value=?, osm_type=?, poi_type=?, " + "lat=?, lon=?, route_id=?, gps_distance_m=?, gps_points_near=?, " + "status='pending', changeset_id=NULL, submitted_at=NULL, " + "created_at=datetime('now') WHERE id=?", + (value, body.osm_type, body.poi_type, body.lat, body.lon, + best[0], round(best[1], 1), best[2], existing['id']) + ) + contrib_id = existing['id'] + else: + cur = conn.execute( + """INSERT INTO osm_contributions + (user_id, osm_id, osm_type, poi_type, tag_key, tag_value, lat, lon, + route_id, gps_distance_m, gps_points_near, status) + VALUES (?,?,?,?, 'dog',?, ?,?, ?,?,?, 'pending')""", + (uid, body.osm_id, body.osm_type, body.poi_type, value, body.lat, body.lon, + best[0], round(best[1], 1), best[2]) + ) + contrib_id = cur.lastrowid + total = _verified_count(conn, uid) + token_enc = conn.execute( + "SELECT token_enc FROM user_osm WHERE user_id=?", (uid,) + ).fetchone()[0] + + # 6) OSM-Upload best-effort — Fehler → bleibt 'pending', Job versucht erneut + submitted = False + try: + submitted = await submit_dog_tag(contrib_id, body.osm_id, body.osm_type, _decrypt(token_enc), value) + except Exception as e: + logger.warning("OSM-Upload später erneut (contrib %s): %s", contrib_id, e) + + logger.info("dog=%s erfasst: user %s, osm %s, Tour %s (%.0fm, %d Pkt), submitted=%s", + value, uid, body.osm_id, best[0], best[1], best[2], submitted) + return { + "status": "erfasst", "value": value, "verified": True, "submitted": submitted, + "verified_count": total, "badge": total >= BADGE_AT, + "pro_progress": min(total, PRO_AT), "pro_at": PRO_AT, + } + + +@router.get('/status') +async def contrib_status(user=Depends(get_current_user)): + uid = user['id'] + with db() as conn: + total = _verified_count(conn, uid) + by_status = {row[0]: row[1] for row in conn.execute( + "SELECT status, COUNT(*) FROM osm_contributions WHERE user_id=? GROUP BY status", + (uid,) + ).fetchall()} + return { + "verified_count": total, "by_status": by_status, + "badge": total >= BADGE_AT, + "pro_progress": min(total, PRO_AT), "pro_at": PRO_AT, + } + + +# ------------------------------------------------------------------ +# Confirm/Revert + Pro-Freischaltung (vom Scheduler-Job aufgerufen) +# ------------------------------------------------------------------ +CONFIRM_AFTER_DAYS = 7 # Edit muss so lange in OSM ohne Revert überleben + + +def _grant_pro_if_earned(uid: int): + """100 bestätigte Beiträge = 1 Jahr Pro. Idempotent über osm_pro_grants. + HINWEIS: setzt is_premium/subscription_* direkt — vor Produktion mit dem + Abo-/Billing-System abgleichen.""" + with db() as conn: + confirmed = conn.execute( + "SELECT COUNT(*) FROM osm_contributions WHERE user_id=? AND status='confirmed'", + (uid,)).fetchone()[0] + granted = conn.execute( + "SELECT COUNT(*) FROM osm_pro_grants WHERE user_id=?", (uid,)).fetchone()[0] + for _ in range(confirmed // PRO_AT - granted): + conn.execute("INSERT INTO osm_pro_grants (user_id) VALUES (?)", (uid,)) + conn.execute( + "UPDATE users SET is_premium=1, subscription_tier='pro', " + "subscription_expires_at=datetime(" + " MAX(COALESCE(subscription_expires_at, datetime('now')), datetime('now')), '+1 year') " + "WHERE id=?", (uid,)) + logger.info("OSM-Pro freigeschaltet: user %s (+1 Jahr)", uid) + + +async def run_confirmation_round(): + """Täglich: (1) hängengebliebene 'pending' erneut hochladen, (2) 'submitted' + nach CONFIRM_AFTER_DAYS auf Revert-Überleben prüfen → confirmed|rejected, + (3) Pro-Freischaltung prüfen.""" + # (1) Pending-Retry + with db() as conn: + pend = conn.execute( + "SELECT c.id, c.osm_id, c.osm_type, c.tag_value, o.token_enc FROM osm_contributions c " + "JOIN user_osm o ON o.user_id=c.user_id WHERE c.status='pending' LIMIT 50" + ).fetchall() + for r in pend: + try: + await submit_dog_tag(r["id"], r["osm_id"], r["osm_type"] or "node", + _decrypt(r["token_enc"]), r["tag_value"]) + except Exception: + pass + + # (2) Confirm/Revert + with db() as conn: + subs = conn.execute( + "SELECT id, user_id, osm_id, osm_type, tag_value FROM osm_contributions " + "WHERE status='submitted' AND submitted_at < datetime('now', ?)", + (f"-{CONFIRM_AFTER_DAYS} days",) + ).fetchall() + affected = set() + async with httpx.AsyncClient(timeout=15) as client: + for r in subs: + etype = r["osm_type"] or "node" + try: + resp = await client.get(f"{OSM_API_BASE}/api/0.6/{etype}/{r['osm_id']}") + ok = False + if resp.status_code == 200: + el = ET.fromstring(resp.text).find(etype) + tag = el.find("./tag[@k='dog']") if el is not None else None + ok = tag is not None and tag.get("v") == r["tag_value"] + new_status = "confirmed" if ok else "rejected" + except Exception: + continue # nächste Runde erneut + with db() as conn: + conn.execute("UPDATE osm_contributions SET status=? WHERE id=?", (new_status, r["id"])) + affected.add(r["user_id"]) + + # (3) Pro-Freischaltung + for uid in affected: + _grant_pro_if_earned(uid) + if subs or pend: + logger.info("OSM-Confirm-Runde: %d pending-retry, %d geprüft, %d User betroffen", + len(pend), len(subs), len(affected)) diff --git a/backend/scheduler.py b/backend/scheduler.py index 4f8ce5e..700d047 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -186,6 +186,16 @@ def start(): misfire_grace_time=3600, coalesce=True, ) + # Täglich 03:40 Uhr — OSM-Beiträge: Pending-Retry + Revert-Überleben prüfen + # + Pro-Freischaltung (staggered, ruhige Zeit) + _scheduler.add_job( + _job_osm_confirm, + CronTrigger(hour=3, minute=40), + id="osm_confirm", + replace_existing=True, + misfire_grace_time=3600, + coalesce=True, + ) # Jeden Montag 08:10 Uhr — Neue Foto-Challenge anlegen (staggered weg von 08:00) _scheduler.add_job( _job_new_foto_challenge, @@ -1720,6 +1730,16 @@ async def _job_streak_reminder(): # ------------------------------------------------------------------ # JOB: Tierfutter-Rückrufe prüfen (RASFF, täglich 08:00) # ------------------------------------------------------------------ +async def _job_osm_confirm(): + """OSM-Beiträge: Pending-Retry + Revert-Überleben prüfen + Pro-Freischaltung. + Import innen → kein Zirkel-Import beim Modul-Load.""" + try: + from routes.osm_contrib import run_confirmation_round + await run_confirmation_round() + except Exception as e: + logger.warning("OSM-Confirm-Job Fehler: %s", e) + + async def _job_recall_check(): """ Fragt täglich die RASFF EU-API nach neuen Tierfutter-Rückrufen ab. diff --git a/backend/static/index.html b/backend/static/index.html index da37ccd..a934541 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index ce91fdf..7a0fa2b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1155'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1159'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 00a4324..2d9dac0 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -1103,6 +1103,23 @@ window.Page_map = (() => { ? `` : ``; + // "Hund willkommen?" — 👍/👎 (dog=yes/no) bei OSM-POIs, wo's Sinn ergibt. + // dog=no nötig, weil Pächter wechseln und ein Ort nicht mehr hundefreundlich wird. + const DOG_TYPES = ['restaurant', 'hotel', 'shop', 'tierarzt', 'hundesalon']; + const dogBtn = (poi.source === 'osm' && DOG_TYPES.includes(layerKey)) + ? `
+
Hund willkommen?
+
+ + +
+
` + : ''; + const openHours = poi.opening_hours ? `
${poi.opening_hours}
` : ''; const phone = poi.phone @@ -1120,7 +1137,7 @@ window.Page_map = (() => { ? ` Community-Pin${poi.username ? ' · ' + poi.username + '' : ''}` : ' OpenStreetMap'} - ${actionBtn} + ${dogBtn}${actionBtn} `, { maxWidth: 260 }).openPopup(); @@ -1130,6 +1147,27 @@ window.Page_map = (() => { if (isOwn) _deleteUserPoi(poi.user_poi_id, marker, layerKey); else _showReportDialog(poi); }); + const _sendDog = async (welcome) => { + const yes = document.getElementById('mp-dogyes'); + const no = document.getElementById('mp-dogno'); + if (yes) yes.disabled = true; + if (no) no.disabled = true; + try { + const r = await API.post('/osm-contrib/dog-friendly', { + osm_id: poi.id, osm_type: 'node', poi_type: layerKey, + lat: poi.lat, lon: poi.lon, welcome, + }); + UI.toast.success((welcome ? 'Hund willkommen' : 'Hund nicht willkommen') + + (r.submitted ? ' — eingetragen 🐾' : ' — wird übertragen 🐾')); + marker.closePopup(); + } catch (e) { + UI.toast.error(e?.message || 'Konnte nicht eintragen.'); + if (yes) yes.disabled = false; + if (no) no.disabled = false; + } + }; + document.getElementById('mp-dogyes')?.addEventListener('click', () => _sendDog(true)); + document.getElementById('mp-dogno')?.addEventListener('click', () => _sendDog(false)); }, 50); } diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 1cfbe70..b55f1fb 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -672,6 +672,13 @@ window.Page_settings = (() => { +
+
OpenStreetMap – die Karte mitverbessern
+
+
Lädt…
+
+
+