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