OSM-Beiträge: dog=yes-Erfassung mit GPS/Zeit-Anti-Fraud + Gamification-Zähler
- Tabelle osm_contributions (status pending→submitted→confirmed/rejected). - Router /api/osm-contrib: POST /dog-friendly (Anti-Fraud: GPS-Beleg über kürzliche eigene Tour ≤50m + Verweil-Proxy, Tour-Recency 48h, Tages-Cap, Dedup, Positions-Sanity), GET /status (Zähler). - Settings-UI: Zähler "X Orte eingetragen · noch Y bis Badge/Pro". - OSM-Changeset-Upload + Pro-Freischaltung + Geräte-Attestierung folgen separat.
This commit is contained in:
parent
46caa05020
commit
1cfaa0264f
4 changed files with 186 additions and 0 deletions
|
|
@ -368,6 +368,30 @@ def init_db():
|
||||||
linked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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);
|
||||||
|
|
||||||
-- VERLORENE HUNDE
|
-- VERLORENE HUNDE
|
||||||
CREATE TABLE IF NOT EXISTS lost_dogs (
|
CREATE TABLE IF NOT EXISTS lost_dogs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,7 @@ from routes.events import router as events_router
|
||||||
from routes.sitting import router as sitting_router
|
from routes.sitting import router as sitting_router
|
||||||
from routes.osm import router as osm_router
|
from routes.osm import router as osm_router
|
||||||
from routes.osm_auth import router as osm_auth_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.forum import router as forum_router
|
||||||
from routes.lost import router as lost_router
|
from routes.lost import router as lost_router
|
||||||
from routes.knigge import router as knigge_router
|
from routes.knigge import router as knigge_router
|
||||||
|
|
@ -294,6 +295,7 @@ app.include_router(events_router, prefix="/api/events", tags=["Events"])
|
||||||
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
|
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
|
||||||
app.include_router(osm_router, prefix="/api/osm", tags=["OSM"])
|
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_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(weather_router, prefix="/api/weather", tags=["Wetter"])
|
||||||
app.include_router(social_router, prefix="/api/social", tags=["Social"])
|
app.include_router(social_router, prefix="/api/social", tags=["Social"])
|
||||||
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])
|
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])
|
||||||
|
|
|
||||||
150
backend/routes/osm_contrib.py
Normal file
150
backend/routes/osm_contrib.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from database import db
|
||||||
|
from auth import get_current_user
|
||||||
|
from math_utils import haversine_m
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _verified_count(conn, uid: int) -> int:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM osm_contributions WHERE user_id=? AND status!='rejected'",
|
||||||
|
(uid,)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
@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.")
|
||||||
|
|
||||||
|
# 1) Dedup — 1× pro POI
|
||||||
|
if conn.execute(
|
||||||
|
"SELECT 1 FROM osm_contributions WHERE user_id=? AND osm_id=? AND tag_key='dog'",
|
||||||
|
(uid, body.osm_id)
|
||||||
|
).fetchone():
|
||||||
|
raise HTTPException(409, "Diesen Ort hast du schon als hundefreundlich 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 (status pending — OSM-Upload folgt separat)
|
||||||
|
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','yes', ?,?, ?,?,?, 'pending')""",
|
||||||
|
(uid, body.osm_id, body.osm_type, body.poi_type, body.lat, body.lon,
|
||||||
|
best[0], round(best[1], 1), best[2])
|
||||||
|
)
|
||||||
|
total = _verified_count(conn, uid)
|
||||||
|
|
||||||
|
logger.info("dog=yes erfasst: user %s, osm %s, Tour %s (%.0fm, %d Pkt)",
|
||||||
|
uid, body.osm_id, best[0], best[1], best[2])
|
||||||
|
return {
|
||||||
|
"status": "erfasst", "verified": True, "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,
|
||||||
|
}
|
||||||
|
|
@ -943,6 +943,7 @@ window.Page_settings = (() => {
|
||||||
<svg class="ph-icon" style="color:var(--c-success)" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg>
|
<svg class="ph-icon" style="color:var(--c-success)" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg>
|
||||||
<span style="font-size:var(--text-sm)">Verknüpft als <strong>${UI.escape(st.osm_name)}</strong></span>
|
<span style="font-size:var(--text-sm)">Verknüpft als <strong>${UI.escape(st.osm_name)}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="settings-osm-count" class="text-sm-muted" style="margin-top:var(--space-3)">…</div>
|
||||||
<button id="settings-osm-unlink"
|
<button id="settings-osm-unlink"
|
||||||
style="margin-top:var(--space-3);background:none;border:none;
|
style="margin-top:var(--space-3);background:none;border:none;
|
||||||
color:var(--c-text-muted);font-size:var(--text-xs);cursor:pointer">
|
color:var(--c-text-muted);font-size:var(--text-xs);cursor:pointer">
|
||||||
|
|
@ -952,6 +953,15 @@ window.Page_settings = (() => {
|
||||||
try { await API.post('/osm-auth/unlink', {}); } catch (e) {}
|
try { await API.post('/osm-auth/unlink', {}); } catch (e) {}
|
||||||
_osmLink();
|
_osmLink();
|
||||||
});
|
});
|
||||||
|
// Gamification-Zähler
|
||||||
|
API.get('/osm-contrib/status').then(cs => {
|
||||||
|
const c = document.getElementById('settings-osm-count');
|
||||||
|
if (!c) return;
|
||||||
|
const n = cs.verified_count || 0;
|
||||||
|
const next = n >= cs.pro_at ? 0 : (n < 10 ? 10 - n : cs.pro_at - n);
|
||||||
|
c.innerHTML = `🐾 <strong>${n}</strong> hundefreundliche Orte eingetragen`
|
||||||
|
+ (next ? ` · noch ${next} bis ${n < 10 ? 'zum Kartograf-Badge' : '1 Jahr Pro'}` : ' · Ziel erreicht! 🎉');
|
||||||
|
}).catch(() => { const c = document.getElementById('settings-osm-count'); if (c) c.textContent=''; });
|
||||||
} else {
|
} else {
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<p class="text-sm-muted" style="margin:0 0 var(--space-3);line-height:1.45">
|
<p class="text-sm-muted" style="margin:0 0 var(--space-3);line-height:1.45">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue