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 = (() => {
? `Löschen `
: `Als ungültig melden `;
+ // "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 = (() => {
+
+