diff --git a/VERSION b/VERSION
index 5ec4258..0948691 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1159
\ No newline at end of file
+1155
\ No newline at end of file
diff --git a/backend/database.py b/backend/database.py
index 457f5a3..08ac7db 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -356,50 +356,6 @@ 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 f4ecac6..e954c83 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -227,8 +227,6 @@ 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
@@ -294,8 +292,6 @@ 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 d45b6f8..414ec32 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -7,7 +7,6 @@ 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
deleted file mode 100644
index 213826f..0000000
--- a/backend/routes/osm_auth.py
+++ /dev/null
@@ -1,179 +0,0 @@
-"""
-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
deleted file mode 100644
index 672be37..0000000
--- a/backend/routes/osm_contrib.py
+++ /dev/null
@@ -1,324 +0,0 @@
-"""
-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 700d047..4f8ce5e 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -186,16 +186,6 @@ 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,
@@ -1730,16 +1720,6 @@ 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 a934541..da37ccd 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 7a0fa2b..ce91fdf 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 = '1159'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '1155'; // ← 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 2d9dac0..00a4324 100644
--- a/backend/static/js/pages/map.js
+++ b/backend/static/js/pages/map.js
@@ -1103,23 +1103,6 @@ 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
@@ -1137,7 +1120,7 @@ window.Page_map = (() => {
? ` Community-Pin${poi.username ? ' · ' + poi.username + ' ' : ''}`
: ' OpenStreetMap'}
- ${dogBtn}${actionBtn}
+ ${actionBtn}
`, { maxWidth: 260 }).openPopup();
@@ -1147,27 +1130,6 @@ 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 b55f1fb..1cfbe70 100644
--- a/backend/static/js/pages/settings.js
+++ b/backend/static/js/pages/settings.js
@@ -672,13 +672,6 @@ window.Page_settings = (() => {
-
-