From 6064a1d7505b1c5e2b1ec38e55be78f889bbc6cb Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 25 Apr 2026 09:53:24 +0200 Subject: [PATCH 1/2] Wiki-Foto-System: Gallery-Flow, Community-Fotos, Wiki-Fotos-Badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - review_submission: Fotos ins gallery/-Verzeichnis statt breeds/ kopieren; foto_url der Rasse nur überschreiben wenn noch keins vorhanden (Erstbild) - Rassen-Detail-API: user_fotos (approved submissions) mitliefern - Rassen-Listen-API: user_foto-Subquery als Fallback wenn foto_url leer - achievements: neue Badge-Kategorie "Wiki-Fotos" (bronze 1, silber 3, gold 10) mit wiki_fotos-Metrik in check_and_award und my_achievements - Badge-Check + Push nach Foto-Approval - wiki.js: Karten-Bild nutzt r.foto_url || r.user_foto - wiki.js: Detail-Ansicht zeigt Community-Foto-Galerie (scrollbar, clickable) - Dockerfile: breeds/gallery + breeds/submissions im Image anlegen - SW by-v366, APP_VER 351 --- Dockerfile | 3 +- backend/routes/achievements.py | 38 +++++++++++----- backend/routes/wiki.py | 80 ++++++++++++++++++++++----------- backend/static/js/app.js | 2 +- backend/static/js/pages/wiki.js | 29 ++++++++++-- backend/static/sw.js | 2 +- 6 files changed, 111 insertions(+), 43 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5707b44..623ddef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY backend/ . # Media-Verzeichnis -RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison +RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \ + /data/media/breeds/gallery /data/media/breeds/submissions EXPOSE 8000 diff --git a/backend/routes/achievements.py b/backend/routes/achievements.py index dae91ff..1c0c1e8 100644 --- a/backend/routes/achievements.py +++ b/backend/routes/achievements.py @@ -80,6 +80,18 @@ CATEGORIES = [ ("diamant", 365, "Ein ganzes Jahr"), ], }, + { + "id": "wiki_fotos", + "name": "Wiki-Fotos", + "emoji": "📸", + "metrik": "wiki_fotos", + "einheit": " Foto(s)", + "stufen": [ + ("bronze", 1, "Erster Klick"), + ("silber", 3, "Foto-Fan"), + ("gold", 10, "Wiki-Fotograf"), + ], + }, ] # Flat-Liste aller Badge-IDs für DB-Kompatibilität @@ -129,19 +141,21 @@ def check_and_award(user_id: int, conn): COALESCE((SELECT SUM(w.walked_km) FROM route_walks w WHERE w.user_id=?), 0), 1) AS total_km, (SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_valid=1) AS routen, - (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?) AS pois + (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?) AS pois, + (SELECT COUNT(*) FROM wiki_foto_submissions WHERE user_id=? AND status='approved') AS wiki_fotos FROM (SELECT 1) - """, (user_id, user_id, user_id, user_id)).fetchone() + """, (user_id, user_id, user_id, user_id, user_id)).fetchone() streak_row = conn.execute( "SELECT current_streak FROM users WHERE id=?", (user_id,) ).fetchone() metrics = { - "total_km": stats["total_km"] if stats else 0, - "routen": stats["routen"] if stats else 0, - "pois": stats["pois"] if stats else 0, - "streak": (streak_row["current_streak"] if streak_row else 0), + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + "wiki_fotos": stats["wiki_fotos"] if stats else 0, } earned = {r["badge_id"] for r in @@ -183,6 +197,7 @@ async def my_achievements(user=Depends(get_current_user)): 1) AS total_km, (SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_valid=1) AS routen, (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?) AS pois, + (SELECT COUNT(*) FROM wiki_foto_submissions WHERE user_id=? AND status='approved') AS wiki_fotos, ROUND( COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=? AND r.is_valid=1), 0) + COALESCE((SELECT SUM(w.walked_km) FROM route_walks w WHERE w.user_id=?), 0), @@ -190,7 +205,7 @@ async def my_achievements(user=Depends(get_current_user)): + (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?)*5 + (SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_valid=1)*10 AS punkte FROM (SELECT 1) - """, (uid, uid, uid, uid, uid, uid, uid, uid)).fetchone() + """, (uid, uid, uid, uid, uid, uid, uid, uid, uid)).fetchone() streak_row = conn.execute( "SELECT current_streak, max_streak FROM users WHERE id=?", (uid,) @@ -215,10 +230,11 @@ async def my_achievements(user=Depends(get_current_user)): """, (stats["punkte"] if stats else 0,)).fetchone() metrics = { - "total_km": stats["total_km"] if stats else 0, - "routen": stats["routen"] if stats else 0, - "pois": stats["pois"] if stats else 0, - "streak": (streak_row["current_streak"] if streak_row else 0), + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + "wiki_fotos": stats["wiki_fotos"] if stats else 0, } # Kategorien mit aktuellem Tier + Fortschritt aufbauen diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index ecb7819..83093d7 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -12,9 +12,10 @@ from auth import get_current_user, get_current_user_optional from ratelimit import check as rl_check, block_ip logger = logging.getLogger(__name__) -MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") -BREEDS_DIR = os.path.join(MEDIA_DIR, "breeds") -SUBMIT_DIR = os.path.join(BREEDS_DIR, "submissions") +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +BREEDS_DIR = os.path.join(MEDIA_DIR, "breeds") +SUBMIT_DIR = os.path.join(BREEDS_DIR, "submissions") +GALLERY_DIR = os.path.join(BREEDS_DIR, "gallery") router = APIRouter() @@ -119,7 +120,10 @@ async def get_rassen( with db() as conn: rows = conn.execute(f""" SELECT id, name, gruppe, groesse, aktivitaet, erfahrung, - foto_url, slug, kinder_geeignet, wohnung_geeignet + foto_url, slug, kinder_geeignet, wohnung_geeignet, + (SELECT s.foto_url FROM wiki_foto_submissions s + WHERE s.rasse_id = wiki_rassen.id AND s.status='approved' + ORDER BY s.reviewed_at DESC LIMIT 1) AS user_foto FROM wiki_rassen {where} ORDER BY name ASC @@ -166,8 +170,18 @@ async def get_rasse(rasse_slug: str, request: Request): (rasse_slug,), ).fetchall() + user_fotos = conn.execute(""" + SELECT s.foto_url, u.name AS user_name, s.created_at + FROM wiki_foto_submissions s + JOIN users u ON u.id = s.user_id + WHERE s.rasse_id = ? AND s.status = 'approved' + ORDER BY s.reviewed_at DESC + LIMIT 10 + """, (rasse["id"],)).fetchall() + result = dict(rasse) - result["berichte"] = [dict(r) for r in rows] + result["berichte"] = [dict(r) for r in rows] + result["user_fotos"] = [dict(r) for r in user_fotos] return result @@ -400,47 +414,61 @@ async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_cur ).fetchone() if data.action == "approve": - # Ziel-Dateiname aus external_id ableiten - ext_id = rasse["external_id"] if rasse else None - if ext_id and str(ext_id).startswith("wd_"): - qid = str(ext_id).replace("wd_", "") - dest_name = f"{qid}.jpg" - elif ext_id: - dest_name = f"{ext_id}.jpg" - else: - dest_name = f"{rasse['slug']}.jpg" - - os.makedirs(BREEDS_DIR, exist_ok=True) - src = sub["foto_url"].replace("/media/", MEDIA_DIR + "/", 1) - dest = os.path.join(BREEDS_DIR, dest_name) + # Ins gallery-Verzeichnis verschieben + os.makedirs(GALLERY_DIR, exist_ok=True) + src = sub["foto_url"].replace("/media/", MEDIA_DIR + "/", 1) + dest_name = f"{rasse['slug']}_{sub_id}.jpg" + dest = os.path.join(GALLERY_DIR, dest_name) try: shutil.copy2(src, dest) except Exception as e: raise HTTPException(500, f"Datei konnte nicht kopiert werden: {e}") - new_url = f"/media/breeds/{dest_name}" - conn.execute( - "UPDATE wiki_rassen SET foto_url=? WHERE id=?", - (new_url, rasse["id"]) - ) + new_url = f"/media/breeds/gallery/{dest_name}" + + # Nur als Hauptbild setzen wenn noch keins vorhanden + if not rasse["foto_url"]: + conn.execute( + "UPDATE wiki_rassen SET foto_url=? WHERE id=?", + (new_url, rasse["id"]) + ) + conn.execute(""" UPDATE wiki_foto_submissions - SET status='approved', reviewed_by=?, reviewed_at=datetime('now') + SET status='approved', foto_url=?, reviewed_by=?, reviewed_at=datetime('now') WHERE id=? - """, (user["id"], sub_id)) + """, (new_url, user["id"], sub_id)) # Push-Notification an Einreicher try: from routes.push import send_push_to_user send_push_to_user(sub["user_id"], { "title": "Foto freigeschalten!", - "body": f"Dein Foto wurde im Wiki veröffentlicht.", + "body": "Dein Foto wurde im Wiki veröffentlicht.", "type": "wiki_foto_approved", "data": {"page": "wiki"}, }) except Exception: pass + # Badge-Check + try: + from routes.achievements import check_and_award + with db() as conn2: + new_badges = check_and_award(sub["user_id"], conn2) + if new_badges: + try: + send_push_to_user(sub["user_id"], { + "title": "\U0001f3c5 Neues Badge!", + "body": f"Du hast '{new_badges[0]['name']}' verdient!", + "type": "badge_earned", + "data": {"page": "achievements"}, + }) + except Exception: + pass + except Exception: + pass + else: # reject conn.execute(""" UPDATE wiki_foto_submissions diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 22217d2..ae4c5ab 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 = '347'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '351'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/wiki.js b/backend/static/js/pages/wiki.js index 658fe96..2011037 100644 --- a/backend/static/js/pages/wiki.js +++ b/backend/static/js/pages/wiki.js @@ -371,10 +371,11 @@ window.Page_wiki = (() => { const _DOG_SILHOUETTE = ``; function _breedCardHtml(r) { - const photoHtml = r.foto_url - ? `${_esc(r.name)}` + const fotoUrl = r.foto_url || r.user_foto || ''; + const photoHtml = fotoUrl + ? `${_esc(r.name)}` : ''; - const fallbackHtml = `
${_DOG_SILHOUETTE}
`; + const fallbackHtml = `
${_DOG_SILHOUETTE}
`; return `
@@ -727,10 +728,32 @@ window.Page_wiki = (() => { const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug); + const userFotosHtml = (rasse.user_fotos || []).length + ? `
+
📸 Community-Fotos
+
+ ${rasse.user_fotos.map(f => ` +
+ ${_esc(f.user_name)} +
von ${_esc(f.user_name)}
+
+ `).join('')} +
+
` + : ''; + const body = ` ${/* 1. Hero */ ''}
${photoHtml} + ${userFotosHtml}

${_esc(rasse.name)}

${rasse.herkunft ? `
${UI.icon('map-pin')} ${_esc(rasse.herkunft)}
` : ''} ${rasse.gruppe ? `
${_esc(rasse.gruppe)}
` : ''} diff --git a/backend/static/sw.js b/backend/static/sw.js index b3531e2..fde77c2 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v365'; +const CACHE_VERSION = 'by-v366'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From af4b1a4a5548b4867f02bdd4e4e04232f31abb8a Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 25 Apr 2026 10:08:42 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Registrierung:=20Hundepassphrase-Generator?= =?UTF-8?q?=20(3=20W=C3=B6rter=20+=20Zahl,=2060+=20Hundew=C3=B6rter),=20SW?= =?UTF-8?q?=20by-v367?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/js/pages/settings.js | 92 +++++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 27f3249..d4a45fd 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -9,6 +9,44 @@ window.Page_settings = (() => { let _appState = null; let _mode = 'login'; // 'login' | 'register' + // ---------------------------------------------------------- + // HUNDEPASSPHRASE — sicheres Passwort aus Hundewelt + // ---------------------------------------------------------- + const _PW_WOERTER = [ + // Rassen + 'Labrador','Pudel','Beagle','Husky','Dackel','Spitz','Mops','Boxer', + 'Collie','Setter','Pointer','Retriever','Shepherd','Terrier','Welpe', + // Körper & Natur + 'Pfote','Schwanz','Schnauze','Schnurrbart','Fell','Nase','Ohr', + // Aktivität + 'Gassi','Laufen','Bellen','Springen','Graben','Schnüffeln','Spielen', + 'Apportieren','Schwimmen','Hecheln','Wackeln','Toben', + // Gegenstände + 'Leckerli','Leine','Halsband','Ball','Napf','Knochen','Frisbee', + 'Körbchen','Bürste','Leine','Stöckchen','Kauspielzeug', + // Orte & Personen + 'Wiese','Wald','Park','Bach','Pfütze','Tierarzt','Züchter', + // Eigenschaften + 'Treu','Tapfer','Mutig','Flauschig','Verspielt','Neugierig', + 'Wachsam','Flink','Sanft','Lieb', + // Geräusche & Aktionen + 'Wuff','Jaulen','Schnuppern','Wedeln','Gähnen','Strecken', + // Futter + 'Trockenfutter','Nassfutter','Kausnack','Futternapf', + ]; + + function _genPassphrase() { + const pick = () => _PW_WOERTER[Math.floor(Math.random() * _PW_WOERTER.length)]; + const num = Math.floor(Math.random() * 90) + 10; // 2-stellig + // 3 zufällige Wörter + Zahl, mit Bindestrich + const words = []; + while (words.length < 3) { + const w = pick(); + if (!words.includes(w)) words.push(w); + } + return words.join('-') + '-' + num; + } + // ---------------------------------------------------------- // INIT / REFRESH // ---------------------------------------------------------- @@ -749,6 +787,34 @@ window.Page_settings = (() => {
+ +
+
+ 🐾 Passwort-Vorschlag + +
+
+ + +
+
+ Sichere Passphrase aus der Hundewelt — leicht zu merken, schwer zu knacken. +
+