From 57849515ea78ce429f7bafcd75a850752ccf51ff Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 3 Jun 2026 21:40:50 +0200 Subject: [PATCH] =?UTF-8?q?OSM-Beitr=C3=A4ge:=20Map-Button=20(dog=3Dyes),?= =?UTF-8?q?=20Changeset-Upload,=20Confirm/Pro-Job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Map-Popup: "Hund war willkommen"-Button (dog=yes) für Restaurant/Hotel/ Shop/Tierarzt/Hundesalon → POST /osm-contrib/dog-friendly. - OSM-Changeset-Upload (write_api): Element holen (node/way) → dog=yes → Changeset create/upload/close; idempotent; best-effort beim Tap. - OSM-Endpunkte konfigurierbar (OSM_OAUTH_BASE/OSM_API_BASE) — Staging gegen Dev-Sandbox, KEINE echten Edits auf Produktiv-OSM. - Scheduler-Job (täglich 03:40): Pending-Retry + Revert-Überleben (7 Tage) → confirmed/rejected; Pro-Freischaltung (100 confirmed = 1 Jahr, idempotent via osm_pro_grants). HINWEIS: is_premium/subscription direkt gesetzt — vor Prod mit Billing abgleichen. - Native Attestierung/Sensoren: bewusst NICHT (iOS-App-Thema, nicht PWA). --- VERSION | 2 +- backend/database.py | 8 ++ backend/routes/osm_auth.py | 12 ++- backend/routes/osm_contrib.py | 168 +++++++++++++++++++++++++++++++-- backend/scheduler.py | 20 ++++ backend/static/index.html | 24 ++--- backend/static/js/app.js | 2 +- backend/static/js/pages/map.js | 25 ++++- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 10 files changed, 239 insertions(+), 26 deletions(-) diff --git a/VERSION b/VERSION index ed426f7..646782c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1156 \ No newline at end of file +1157 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 219e52e..457f5a3 100644 --- a/backend/database.py +++ b/backend/database.py @@ -392,6 +392,14 @@ def init_db(): ); 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/routes/osm_auth.py b/backend/routes/osm_auth.py index 0de97b8..bc5ae39 100644 --- a/backend/routes/osm_auth.py +++ b/backend/routes/osm_auth.py @@ -35,9 +35,15 @@ logger = logging.getLogger(__name__) router = APIRouter() # --- OSM-OAuth2-Endpunkte --- -OSM_AUTHORIZE = "https://www.openstreetmap.org/oauth2/authorize" -OSM_TOKEN = "https://www.openstreetmap.org/oauth2/token" -OSM_USER_API = "https://api.openstreetmap.org/api/0.6/user/details.json" +# 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", "") diff --git a/backend/routes/osm_contrib.py b/backend/routes/osm_contrib.py index a8e00a3..0d032ec 100644 --- a/backend/routes/osm_contrib.py +++ b/backend/routes/osm_contrib.py @@ -16,13 +16,16 @@ 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() @@ -54,6 +57,73 @@ def _verified_count(conn, uid: int) -> int: ).fetchone()[0] +# ------------------------------------------------------------------ +# OSM-Changeset-Upload (write_api): Element holen → dog=yes → Changeset. +# ------------------------------------------------------------------ +_CHANGESET_XML = ( + '' + '' + '' + '' + '' +) + + +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_yes(contrib_id: int, osm_id: int, osm_type: str, token: str) -> bool: + """Setzt dog=yes 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") == "yes": + _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) + cs.raise_for_status() + changeset_id = cs.text.strip() + + # 3) dog=yes setzen + Element hochladen (Geometrie/andere Tags bleiben) + if existing is not None: + existing.set("v", "yes") + else: + ET.SubElement(el, "tag", {"k": "dog", "v": "yes"}) + 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'] @@ -114,8 +184,8 @@ async def mark_dog_friendly(body: DogFriendlyIn, user=Depends(get_current_user)) 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 (status pending — OSM-Upload folgt separat) - conn.execute( + # 5) verifiziert erfassen (pending; OSM-Upload gleich best-effort) + 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) @@ -123,13 +193,24 @@ async def mark_dog_friendly(body: DogFriendlyIn, user=Depends(get_current_user)) (uid, body.osm_id, body.osm_type, body.poi_type, 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] - logger.info("dog=yes erfasst: user %s, osm %s, Tour %s (%.0fm, %d Pkt)", - uid, body.osm_id, best[0], best[1], best[2]) + # 6) OSM-Upload best-effort — Fehler → bleibt 'pending', Job versucht erneut + submitted = False + try: + submitted = await submit_dog_yes(contrib_id, body.osm_id, body.osm_type, _decrypt(token_enc)) + except Exception as e: + logger.warning("OSM-Upload später erneut (contrib %s): %s", contrib_id, e) + + logger.info("dog=yes erfasst: user %s, osm %s, Tour %s (%.0fm, %d Pkt), submitted=%s", + uid, body.osm_id, best[0], best[1], best[2], submitted) return { - "status": "erfasst", "verified": True, "verified_count": total, - "badge": total >= BADGE_AT, + "status": "erfasst", "verified": True, "submitted": submitted, + "verified_count": total, "badge": total >= BADGE_AT, "pro_progress": min(total, PRO_AT), "pro_at": PRO_AT, } @@ -148,3 +229,78 @@ async def contrib_status(user=Depends(get_current_user)): "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, 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_yes(r["id"], r["osm_id"], r["osm_type"] or "node", _decrypt(r["token_enc"])) + except Exception: + pass + + # (2) Confirm/Revert + with db() as conn: + subs = conn.execute( + "SELECT id, user_id, osm_id, osm_type 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") == "yes" + 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 5749432..0c4bd72 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 f6026d0..7a79dd0 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 = '1156'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1157'; // ← 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..656060f 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -1103,6 +1103,15 @@ window.Page_map = (() => { ? `` : ``; + // "Hund war willkommen" (dog=yes) — nur bei OSM-POIs, wo's Sinn ergibt + const DOG_TYPES = ['restaurant', 'hotel', 'shop', 'tierarzt', 'hundesalon']; + const dogBtn = (poi.source === 'osm' && DOG_TYPES.includes(layerKey)) + ? `` + : ''; + const openHours = poi.opening_hours ? `
${poi.opening_hours}
` : ''; const phone = poi.phone @@ -1120,7 +1129,7 @@ window.Page_map = (() => { ? ` Community-Pin${poi.username ? ' · ' + poi.username + '' : ''}` : ' OpenStreetMap'} - ${actionBtn} + ${dogBtn}${actionBtn} `, { maxWidth: 260 }).openPopup(); @@ -1130,6 +1139,20 @@ window.Page_map = (() => { if (isOwn) _deleteUserPoi(poi.user_poi_id, marker, layerKey); else _showReportDialog(poi); }); + document.getElementById('mp-dogyes')?.addEventListener('click', async () => { + const b = document.getElementById('mp-dogyes'); + b.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, + }); + UI.toast.success(r.submitted ? 'Eingetragen — danke! 🐾' : 'Erfasst — wird übertragen 🐾'); + b.innerHTML = '✓ Eingetragen'; + } catch (e) { + UI.toast.error(e?.message || 'Konnte nicht eintragen.'); + b.disabled = false; + } + }); }, 50); } diff --git a/backend/static/landing.html b/backend/static/landing.html index 43307af..1873553 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 7bd9eb3..0e33dea 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1156'; +const VER = '1157'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten