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 = (() => { ? `` : ``; - // "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 = (() => { -
-
OpenStreetMap – die Karte mitverbessern
-
-
Lädt…
-
-
-