OSM-Beiträge: Map-Button (dog=yes), Changeset-Upload, Confirm/Pro-Job

- Map-Popup: "Hund war willkommen"-Button (dog=yes) für Restaurant/Hotel/
  Shop/Tierarzt/Hundesalon → POST /osm-contrib/dog-friendly.
- OSM-Changeset-Upload (write_api): Element holen (node/way) → dog=yes →
  Changeset create/upload/close; idempotent; best-effort beim Tap.
- OSM-Endpunkte konfigurierbar (OSM_OAUTH_BASE/OSM_API_BASE) — Staging gegen
  Dev-Sandbox, KEINE echten Edits auf Produktiv-OSM.
- Scheduler-Job (täglich 03:40): Pending-Retry + Revert-Überleben (7 Tage) →
  confirmed/rejected; Pro-Freischaltung (100 confirmed = 1 Jahr, idempotent via
  osm_pro_grants). HINWEIS: is_premium/subscription direkt gesetzt — vor Prod
  mit Billing abgleichen.
- Native Attestierung/Sensoren: bewusst NICHT (iOS-App-Thema, nicht PWA).
This commit is contained in:
rene 2026-06-03 21:40:50 +02:00
parent dc9c0d2cc0
commit 57849515ea
10 changed files with 239 additions and 26 deletions

View file

@ -1 +1 @@
1156
1157

View file

@ -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,

View file

@ -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", "")

View file

@ -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 = (
'<osm><changeset>'
'<tag k="created_by" v="BanYaro/1.0"/>'
'<tag k="comment" v="Hund willkommen (dog=yes) — via Ban Yaro"/>'
'<tag k="source" v="survey"/>'
'</changeset></osm>'
)
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))

View file

@ -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.

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1156"></script>
<script src="/js/boot-early.js?v=1157"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1156">
<link rel="stylesheet" href="/css/layout.css?v=1156">
<link rel="stylesheet" href="/css/components.css?v=1156">
<link rel="stylesheet" href="/css/utilities.css?v=1156">
<link rel="stylesheet" href="/css/lists.css?v=1156">
<link rel="stylesheet" href="/css/design-system.css?v=1157">
<link rel="stylesheet" href="/css/layout.css?v=1157">
<link rel="stylesheet" href="/css/components.css?v=1157">
<link rel="stylesheet" href="/css/utilities.css?v=1157">
<link rel="stylesheet" href="/css/lists.css?v=1157">
</head>
<body>
@ -617,11 +617,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1156"></script>
<script src="/js/ui.js?v=1156"></script>
<script src="/js/app.js?v=1156"></script>
<script src="/js/worlds.js?v=1156"></script>
<script src="/js/offline-indicator.js?v=1156"></script>
<script src="/js/api.js?v=1157"></script>
<script src="/js/ui.js?v=1157"></script>
<script src="/js/app.js?v=1157"></script>
<script src="/js/worlds.js?v=1157"></script>
<script src="/js/offline-indicator.js?v=1157"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -631,7 +631,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1156"></script>
<script src="/js/boot.js?v=1157"></script>
</body>

View file

@ -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;

View file

@ -1103,6 +1103,15 @@ window.Page_map = (() => {
? `<button class="btn btn-danger btn-sm" id="mp-action">Löschen</button>`
: `<button class="btn btn-secondary btn-sm" id="mp-action">Als ungültig melden</button>`;
// "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))
? `<button class="btn btn-primary btn-sm" id="mp-dogyes" style="width:100%;margin-bottom:6px">
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#dog"></use></svg>
Hund war willkommen
</button>`
: '';
const openHours = poi.opening_hours
? `<div style="font-size:11px;color:#555;margin-bottom:4px"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg> ${poi.opening_hours}</div>` : '';
const phone = poi.phone
@ -1120,7 +1129,7 @@ window.Page_map = (() => {
? `<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#push-pin"></use></svg> Community-Pin${poi.username ? ' · <b>' + poi.username + '</b>' : ''}`
: '<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#map-trifold"></use></svg> OpenStreetMap'}
</div>
${actionBtn}
${dogBtn}${actionBtn}
</div>
`, { 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);
}

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1156"></script>
<script src="/js/landing-init.js?v=1157"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -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