From ce8aa2b699d68f85acb41e5df11f1ed51101e3c1 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 17:20:20 +0200 Subject: [PATCH 01/15] =?UTF-8?q?Feature:=20Partner-Profile=20Backend=20+?= =?UTF-8?q?=20Pro-Zugang=20f=C3=BCr=20Partner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Partner-Showcase-Seite (#partner) und der Profil-Editor (#partner-profil) existierten seit v1102 nur als Frontend — /api/partners/public und /api/partner/my-profile gab es nie (vermutlich Worktree-Merge-Verlust). Backend neu: - partner_profiles-Tabelle (user_id PK, ON DELETE CASCADE → DSGVO-Delete greift) - GET/PUT /partner/my-profile (Texte, Website-Normalisierung, @-Instagram) - Logo-Upload (≤5 MB → WebP 512px, altes Logo wird geräumt) - Foto/Video-Upload (max 6, 200-MB-Budget, HEIC→JPEG, MOV→MP4 via ffmpeg, Bilder→WebP 1600px) + Lösch-Endpoint - Submit-Workflow (approved 0/1/-1) + Admin-Mail (best effort) - GET /partners/public (nur freigegebene, JOIN users für Name/Avatar) - Admin: GET /admin/partner/profiles + POST .../review Pro für Partner: has_pro_access() + App._hasPro() prüfen jetzt is_partner — Multiplikatoren bekommen Pro gratis (mehrere Hunde, KI-Trainer etc.). UI: Admin-Partner-Tab mit Freigabe-Sektion (offen-Badge, ✓/✗), Settings zeigt Partnern eine Karte mit Link zum Profil-Editor. Tests: tests/test_partner_profile.py — 5 Smoke-Tests (403, Voll-Flow inkl. Freigabe/Ablehnung, Pflicht-Anzeigename, Logo+Foto-Upload, Pro-Zugang). Suite: 44 passed. --- VERSION | 2 +- backend/auth.py | 2 + backend/database.py | 23 ++ backend/routes/partner.py | 336 +++++++++++++++++++++++++++- backend/static/index.html | 24 +- backend/static/js/app.js | 3 +- backend/static/js/pages/admin.js | 45 +++- backend/static/js/pages/settings.js | 18 ++ backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_partner_profile.py | 119 ++++++++++ 11 files changed, 557 insertions(+), 19 deletions(-) create mode 100644 tests/test_partner_profile.py diff --git a/VERSION b/VERSION index 03ce6df..b0a0271 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1252 \ No newline at end of file +1253 \ No newline at end of file diff --git a/backend/auth.py b/backend/auth.py index 1b5f126..f5cabd7 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -265,6 +265,8 @@ def has_pro_access(user: dict) -> bool: return True if user.get("is_moderator") or user.get("is_social_media"): return True + if user.get("is_partner"): # Partner (Multiplikatoren) bekommen Pro gratis + return True return tier in ("pro", "breeder", "pro_test", "breeder_test") diff --git a/backend/database.py b/backend/database.py index 457f5a3..5b5b0c8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1630,6 +1630,29 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration partner_codes: {e}") + # Partner-Profile (öffentlicher Showcase auf /#partner) + # approved: 0=Entwurf/in Prüfung, 1=freigegeben, -1=abgelehnt + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS partner_profiles ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + display_name TEXT, + tagline TEXT, + bio TEXT, + website TEXT, + instagram TEXT, + logo_url TEXT, + photos_json TEXT NOT NULL DEFAULT '[]', + approved INTEGER NOT NULL DEFAULT 0, + submitted_at TEXT, + updated_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + logger.info("Migration: partner_profiles Tabelle bereit.") + except Exception as e: + logger.warning(f"Migration partner_profiles: {e}") + # Outreach-Log (Admin-E-Mail-Versand) try: conn.executescript(""" diff --git a/backend/routes/partner.py b/backend/routes/partner.py index 16caafb..b405e8a 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -1,10 +1,16 @@ -"""BAN YARO — Partner-Codes + Gründer-Lizenz""" +"""BAN YARO — Partner-Codes + Gründer-Lizenz + Partner-Profile (Showcase)""" +import asyncio +import json +import os +import uuid from typing import Optional -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, UploadFile, File from pydantic import BaseModel, Field from database import db from auth import require_admin, get_current_user +from config import MEDIA_DIR +from media_utils import validate_upload, convert_media, safe_media_path router = APIRouter() @@ -206,3 +212,329 @@ def partner_code_info(code: str): r["founder_slots_open"] = None r["redeemable"] = r["max_uses"] is None or r["uses"] < r["max_uses"] return r + + +# ------------------------------------------------------------------ +# Partner-Profile — Self-Service-Editor + öffentlicher Showcase +# Frontend: partner-profil.js (Editor), partner.js (Showcase) +# ------------------------------------------------------------------ + +_PP_STORAGE_LIMIT_MB = 200 # Gesamt-Budget pro Partner (Frontend zeigt die Bar dazu) +_PP_MAX_PHOTOS = 6 +_PP_LOGO_MAX_MB = 5 +_PP_FILE_MAX_MB = 200 # pro Datei (Videos) + +_PP_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".heic", ".heif"} +_PP_VIDEO_EXTS = {".mp4", ".webm", ".mov", ".avi", ".m4v"} + + +def require_partner(user=Depends(get_current_user)): + if not (user.get("is_partner") or user.get("rolle") == "admin"): + raise HTTPException(403, "Nur für Partner.") + return user + + +def _pp_dir(user_id: int) -> str: + path = os.path.join(MEDIA_DIR, "partner", str(user_id)) + os.makedirs(path, exist_ok=True) + return path + + +def _pp_storage_mb(user_id: int) -> float: + """Belegter Speicher des Partners in MB (Logo + Fotos/Videos).""" + path = os.path.join(MEDIA_DIR, "partner", str(user_id)) + if not os.path.isdir(path): + return 0.0 + total = sum( + os.path.getsize(os.path.join(path, f)) + for f in os.listdir(path) + if os.path.isfile(os.path.join(path, f)) + ) + return round(total / (1024 * 1024), 4) + + +def _pp_profile_dict(row) -> dict: + d = dict(row) + try: + d["photos"] = json.loads(d.pop("photos_json") or "[]") + except (ValueError, TypeError): + d["photos"] = [] + return d + + +def _pp_get_or_empty(conn, user_id: int) -> dict: + row = conn.execute( + "SELECT * FROM partner_profiles WHERE user_id=?", (user_id,) + ).fetchone() + return _pp_profile_dict(row) if row else {} + + +class PartnerProfileUpdate(BaseModel): + display_name: Optional[str] = Field(None, max_length=60) + tagline: Optional[str] = Field(None, max_length=80) + bio: Optional[str] = Field(None, max_length=500) + website: Optional[str] = Field(None, max_length=300) + instagram: Optional[str] = Field(None, max_length=100) + + +@router.get("/partner/my-profile") +def get_my_partner_profile(user=Depends(require_partner)): + with db() as conn: + profile = _pp_get_or_empty(conn, user["id"]) + return { + "profile": profile, + "storage_mb": _pp_storage_mb(user["id"]), + "storage_limit_mb": _PP_STORAGE_LIMIT_MB, + } + + +@router.put("/partner/my-profile") +def update_my_partner_profile(data: PartnerProfileUpdate, user=Depends(require_partner)): + website = (data.website or "").strip() + if website and not website.startswith(("http://", "https://")): + website = "https://" + website + instagram = (data.instagram or "").strip().lstrip("@") + with db() as conn: + conn.execute( + """INSERT INTO partner_profiles + (user_id, display_name, tagline, bio, website, instagram, updated_at) + VALUES (?,?,?,?,?,?, datetime('now')) + ON CONFLICT(user_id) DO UPDATE SET + display_name=excluded.display_name, tagline=excluded.tagline, + bio=excluded.bio, website=excluded.website, + instagram=excluded.instagram, updated_at=datetime('now')""", + (user["id"], (data.display_name or "").strip() or None, + (data.tagline or "").strip() or None, (data.bio or "").strip() or None, + website or None, ("@" + instagram) if instagram else None) + ) + profile = _pp_get_or_empty(conn, user["id"]) + return {"profile": profile} + + +@router.post("/partner/my-profile/logo") +async def upload_partner_logo(file: UploadFile = File(...), user=Depends(require_partner)): + raw = await file.read() + filename = file.filename or "logo.png" + ext = os.path.splitext(filename)[1].lower() + if ext not in _PP_IMAGE_EXTS: + raise HTTPException(400, "Nur Bilder (PNG, JPG, WebP) als Logo.") + if len(raw) > _PP_LOGO_MAX_MB * 1024 * 1024: + raise HTTPException(400, f"Logo zu groß (max. {_PP_LOGO_MAX_MB} MB).") + try: + validate_upload(raw, filename) + except ValueError as e: + raise HTTPException(400, str(e)) + + save_dir = _pp_dir(user["id"]) + new_name = f"logo_{uuid.uuid4().hex[:8]}.webp" + new_path = os.path.join(save_dir, new_name) + + def _save(): + import io + from PIL import Image, ImageOps + img = Image.open(io.BytesIO(raw)) + img = ImageOps.exif_transpose(img) + # Transparenz erhalten (Logos sind oft PNG mit Alpha) + img = img.convert("RGBA" if "A" in (img.mode or "") or img.mode == "P" else "RGB") + img.thumbnail((512, 512)) + img.save(new_path, format="WEBP", quality=85) + + loop = asyncio.get_event_loop() + try: + await loop.run_in_executor(None, _save) + except Exception: + raise HTTPException(400, "Bild konnte nicht verarbeitet werden.") + + logo_url = f"/media/partner/{user['id']}/{new_name}" + with db() as conn: + old = conn.execute( + "SELECT logo_url FROM partner_profiles WHERE user_id=?", (user["id"],) + ).fetchone() + conn.execute( + """INSERT INTO partner_profiles (user_id, logo_url, updated_at) + VALUES (?,?, datetime('now')) + ON CONFLICT(user_id) DO UPDATE SET + logo_url=excluded.logo_url, updated_at=datetime('now')""", + (user["id"], logo_url) + ) + # Altes Logo vom Datenträger räumen + if old and old["logo_url"]: + old_path = safe_media_path(MEDIA_DIR, old["logo_url"]) + if old_path and os.path.isfile(old_path): + try: + os.unlink(old_path) + except OSError: + pass + return {"logo_url": logo_url} + + +@router.post("/partner/my-profile/photos") +async def upload_partner_photo(file: UploadFile = File(...), user=Depends(require_partner)): + raw = await file.read() + filename = file.filename or "upload.jpg" + ext = os.path.splitext(filename)[1].lower() + if ext not in _PP_IMAGE_EXTS | _PP_VIDEO_EXTS: + raise HTTPException(400, "Nur Bilder (JPG, PNG, HEIC) oder Videos (MP4, MOV).") + if len(raw) > _PP_FILE_MAX_MB * 1024 * 1024: + raise HTTPException(400, f"Datei zu groß (max. {_PP_FILE_MAX_MB} MB).") + used_mb = _pp_storage_mb(user["id"]) + if used_mb + len(raw) / (1024 * 1024) > _PP_STORAGE_LIMIT_MB: + raise HTTPException(400, f"Speicherlimit erreicht ({_PP_STORAGE_LIMIT_MB} MB). Bitte zuerst Dateien löschen.") + try: + validate_upload(raw, filename) + except ValueError as e: + raise HTTPException(400, str(e)) + + with db() as conn: + profile = _pp_get_or_empty(conn, user["id"]) + photos = profile.get("photos", []) + if len(photos) >= _PP_MAX_PHOTOS: + raise HTTPException(400, f"Maximal {_PP_MAX_PHOTOS} Fotos/Videos.") + + loop = asyncio.get_event_loop() + # HEIC→JPEG bzw. MOV/AVI→MP4 (ffmpeg, komprimiert) — blockierend, daher Threadpool + data, ext = await loop.run_in_executor(None, lambda: convert_media(raw, filename)) + + save_dir = _pp_dir(user["id"]) + file_id = uuid.uuid4().hex[:12] + if ext in _PP_VIDEO_EXTS: + new_name = f"media_{file_id}{ext}" + new_path = os.path.join(save_dir, new_name) + + def _save_video(): + with open(new_path, "wb") as f: + f.write(data) + await loop.run_in_executor(None, _save_video) + else: + new_name = f"media_{file_id}.webp" + new_path = os.path.join(save_dir, new_name) + + def _save_image(): + import io + from PIL import Image, ImageOps + img = Image.open(io.BytesIO(data)) + img = ImageOps.exif_transpose(img) + img = img.convert("RGB") + img.thumbnail((1600, 1600)) + img.save(new_path, format="WEBP", quality=85) + try: + await loop.run_in_executor(None, _save_image) + except Exception: + raise HTTPException(400, "Bild konnte nicht verarbeitet werden.") + + photos.append(f"/media/partner/{user['id']}/{new_name}") + with db() as conn: + conn.execute( + """INSERT INTO partner_profiles (user_id, photos_json, updated_at) + VALUES (?,?, datetime('now')) + ON CONFLICT(user_id) DO UPDATE SET + photos_json=excluded.photos_json, updated_at=datetime('now')""", + (user["id"], json.dumps(photos)) + ) + return {"photos": photos} + + +@router.post("/partner/my-profile/photos/{idx}/delete") +def delete_partner_photo(idx: int, user=Depends(require_partner)): + with db() as conn: + profile = _pp_get_or_empty(conn, user["id"]) + photos = profile.get("photos", []) + if not (0 <= idx < len(photos)): + raise HTTPException(404, "Foto nicht gefunden.") + url = photos.pop(idx) + conn.execute( + "UPDATE partner_profiles SET photos_json=?, updated_at=datetime('now') WHERE user_id=?", + (json.dumps(photos), user["id"]) + ) + path = safe_media_path(MEDIA_DIR, url) + if path and os.path.isfile(path): + try: + os.unlink(path) + except OSError: + pass + return {"photos": photos} + + +@router.post("/partner/my-profile/submit") +def submit_partner_profile(user=Depends(require_partner)): + with db() as conn: + profile = _pp_get_or_empty(conn, user["id"]) + if not profile.get("display_name"): + raise HTTPException(400, "Bitte zuerst einen Anzeigenamen speichern.") + # Abgelehnt → erneutes Einreichen setzt zurück auf 'in Prüfung' + conn.execute( + """UPDATE partner_profiles + SET submitted_at=datetime('now'), + approved=CASE WHEN approved=1 THEN 1 ELSE 0 END, + updated_at=datetime('now') + WHERE user_id=?""", + (user["id"],) + ) + profile = _pp_get_or_empty(conn, user["id"]) + # Admin benachrichtigen (best effort — Silent-Skip ohne ADMIN_EMAIL) + admin_email = os.getenv("ADMIN_EMAIL", "") + if admin_email and profile.get("approved") != 1: + try: + from routes.outreach import _send_smtp + _send_smtp( + admin_email, + f"[Ban Yaro] Partner-Profil eingereicht: {profile.get('display_name')}", + (f"Partner {user['name']} ({user['email']}) hat sein Profil zur " + f"Freigabe eingereicht.\n\nAdmin-Panel: https://banyaro.app/#admin"), + "support", + ) + except Exception: + pass + return {"profile": profile} + + +@router.get("/partners/public") +def list_public_partners(): + """Freigegebene Partner-Profile für die öffentliche Partner-Seite.""" + with db() as conn: + rows = conn.execute( + """SELECT pp.user_id, pp.display_name, pp.tagline, pp.bio, pp.website, + pp.instagram, pp.logo_url, pp.photos_json, + u.name, u.avatar_url + FROM partner_profiles pp + JOIN users u ON u.id = pp.user_id + WHERE pp.approved=1 AND u.is_partner=1 + ORDER BY pp.submitted_at ASC""" + ).fetchall() + return {"partners": [_pp_profile_dict(r) for r in rows]} + + +# ---- Admin: Freigabe-Workflow ------------------------------------ + +class PartnerProfileReview(BaseModel): + approved: int = Field(..., ge=-1, le=1) + + +@router.get("/admin/partner/profiles") +def list_partner_profiles(user=Depends(require_admin)): + """Alle Partner-Profile mit Status für den Admin-Tab.""" + with db() as conn: + rows = conn.execute( + """SELECT pp.*, u.name, u.email + FROM partner_profiles pp + JOIN users u ON u.id = pp.user_id + ORDER BY CASE WHEN pp.submitted_at IS NOT NULL AND pp.approved=0 THEN 0 ELSE 1 END, + pp.updated_at DESC""" + ).fetchall() + return [_pp_profile_dict(r) for r in rows] + + +@router.post("/admin/partner/profiles/{user_id}/review") +def review_partner_profile(user_id: int, data: PartnerProfileReview, user=Depends(require_admin)): + with db() as conn: + row = conn.execute( + "SELECT user_id FROM partner_profiles WHERE user_id=?", (user_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Partner-Profil nicht gefunden.") + conn.execute( + "UPDATE partner_profiles SET approved=?, updated_at=datetime('now') WHERE user_id=?", + (data.approved, user_id) + ) + profile = _pp_get_or_empty(conn, user_id) + return {"profile": profile} diff --git a/backend/static/index.html b/backend/static/index.html index 5a330e3..3657214 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 9a5e14c..92a040a 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 = '1252'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1253'; // ← 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; @@ -104,6 +104,7 @@ const App = (() => { // Normale Prüfung: Admin/Mod/Social bekommen immer Pro if (user.rolle === 'admin' || user.rolle === 'moderator') return true; if (user.is_moderator || user.is_social_media) return true; + if (user.is_partner) return true; // Partner (Multiplikatoren) bekommen Pro gratis return ['pro','breeder'].includes(t); } diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 294b532..717677c 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2289,7 +2289,8 @@ window.Page_admin = (() => { // TAB: AUDIT-LOG // ------------------------------------------------------------------ async function _renderPartner(el) { - const codes = (await API.get('/admin/partner/codes')) || []; + const codes = (await API.get('/admin/partner/codes')) || []; + const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || []; el.innerHTML = `
@@ -2383,6 +2384,36 @@ window.Page_admin = (() => {
+ +
+

+ Profil-Freigaben + ${profiles.filter(p => p.submitted_at && p.approved === 0).length + ? `${profiles.filter(p => p.submitted_at && p.approved === 0).length} offen` : ''} +

+ ${profiles.length === 0 + ? `

Noch keine Partner-Profile angelegt.

` + : profiles.map(p => ` +
+ ${p.logo_url + ? `` + : `
`} +
+
${UI.escape(p.display_name || p.name)}
+
${UI.escape(p.name)} · ${UI.escape(p.email)}${p.photos?.length ? ` · ${p.photos.length} Medien` : ''}
+
+ ${p.approved === 1 + ? `✓ Frei` + : p.approved === -1 + ? `✗ Abgelehnt` + : p.submitted_at + ? `⏳ Prüfen` + : `Entwurf`} + ${p.approved !== 1 ? `` : ''} + ${p.approved !== -1 ? `` : ''} +
`).join('')} +
+

Nutzer-Status manuell vergeben

@@ -2414,6 +2445,18 @@ window.Page_admin = (() => {
`; + // Partner-Profil freigeben / ablehnen + el.querySelectorAll('.adm-pp-review').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await API.post(`/admin/partner/profiles/${btn.dataset.uid}/review`, + { approved: parseInt(btn.dataset.val) }); + UI.toast.success(btn.dataset.val === '1' ? 'Profil freigegeben.' : 'Profil abgelehnt.'); + await _renderPartner(el); + } catch (err) { UI.toast.error(err.message); } + }); + }); + // Code erstellen el.querySelector('#adm-partner-create')?.addEventListener('submit', async e => { e.preventDefault(); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 20d857e..331cbdb 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -665,6 +665,21 @@ window.Page_settings = (() => {
+ ${u.is_partner ? ` + +
+
${UI.icon('handshake')} Partner
+
+

+ Als Partner hast du vollen Pro-Zugang und eine öffentliche Karte auf der + Partner-Seite. Richte dein Profil ein — nach der Freigabe ist es für alle sichtbar. +

+ +
+
` : ''} +
Trophäen
@@ -1660,6 +1675,9 @@ window.Page_settings = (() => { _loadReferral(); _loadBreederCard(); + + document.getElementById('settings-partner-profile-btn') + ?.addEventListener('click', () => App.navigate('partner-profil')); } // ---------------------------------------------------------- diff --git a/backend/static/landing.html b/backend/static/landing.html index f513edc..75f1905 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index d3a5f27..449fd49 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1252'; +const VER = '1253'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_partner_profile.py b/tests/test_partner_profile.py new file mode 100644 index 0000000..d26c26f --- /dev/null +++ b/tests/test_partner_profile.py @@ -0,0 +1,119 @@ +"""Smoke-Tests fuer Partner-Profile (Editor + Freigabe-Workflow + oeffentlicher Showcase).""" + +import io + + +def _make_partner(user_email: str): + """Setzt is_partner=1 direkt in der Test-DB.""" + from database import db + with db() as conn: + conn.execute("UPDATE users SET is_partner=1 WHERE email=?", (user_email,)) + + +def test_my_profile_requires_partner(client, user): + """GET /api/partner/my-profile -> 403 fuer normale User.""" + r = client.get("/api/partner/my-profile", headers=user["headers"]) + assert r.status_code == 403 + + +def test_partner_profile_full_flow(client, user, admin): + """Texte speichern -> einreichen -> Admin gibt frei -> oeffentlich sichtbar.""" + _make_partner(user["email"]) + + # Leeres Profil mit Storage-Infos + r = client.get("/api/partner/my-profile", headers=user["headers"]) + assert r.status_code == 200, r.text + assert r.json()["storage_limit_mb"] == 200 + + # Texte speichern (Website ohne Schema wird normalisiert) + r = client.put("/api/partner/my-profile", headers=user["headers"], json={ + "display_name": "Hundeblog Test", + "tagline": "Testkanal", + "bio": "Wir testen Ban Yaro.", + "website": "hundeblog-test.de", + "instagram": "@hundeblogtest", + }) + assert r.status_code == 200, r.text + p = r.json()["profile"] + assert p["display_name"] == "Hundeblog Test" + assert p["website"] == "https://hundeblog-test.de" + + # Vor Freigabe nicht oeffentlich + r = client.get("/api/partners/public") + assert all(x.get("display_name") != "Hundeblog Test" for x in r.json()["partners"]) + + # Einreichen + r = client.post("/api/partner/my-profile/submit", headers=user["headers"], json={}) + assert r.status_code == 200, r.text + assert r.json()["profile"]["submitted_at"] + + # Admin sieht das Profil und gibt frei + r = client.get("/api/admin/partner/profiles", headers=admin["headers"]) + assert r.status_code == 200 + mine = [x for x in r.json() if x.get("display_name") == "Hundeblog Test"] + assert mine, "Profil fehlt in der Admin-Liste" + uid = mine[0]["user_id"] + + r = client.post(f"/api/admin/partner/profiles/{uid}/review", + headers=admin["headers"], json={"approved": 1}) + assert r.status_code == 200 + + # Jetzt oeffentlich (ohne Login) + r = client.get("/api/partners/public") + names = [x["display_name"] for x in r.json()["partners"]] + assert "Hundeblog Test" in names + + # Ablehnen entfernt es wieder von der oeffentlichen Seite + r = client.post(f"/api/admin/partner/profiles/{uid}/review", + headers=admin["headers"], json={"approved": -1}) + assert r.status_code == 200 + r = client.get("/api/partners/public") + assert "Hundeblog Test" not in [x["display_name"] for x in r.json()["partners"]] + + +def test_submit_requires_display_name(client, user): + """Einreichen ohne Anzeigename -> 400.""" + _make_partner(user["email"]) + r = client.post("/api/partner/my-profile/submit", headers=user["headers"], json={}) + assert r.status_code == 400 + + +def test_logo_and_photo_upload(client, user): + """Logo + Foto hochladen, Foto wieder loeschen.""" + from PIL import Image + _make_partner(user["email"]) + + def _png(size=(64, 64), color="red"): + buf = io.BytesIO() + Image.new("RGB", size, color).save(buf, format="PNG") + buf.seek(0) + return buf + + # Logo + r = client.post("/api/partner/my-profile/logo", headers=user["headers"], + files={"file": ("logo.png", _png(), "image/png")}) + assert r.status_code == 200, r.text + assert r.json()["logo_url"].startswith("/media/partner/") + + # Foto + r = client.post("/api/partner/my-profile/photos", headers=user["headers"], + files={"file": ("foto.png", _png(color="blue"), "image/png")}) + assert r.status_code == 200, r.text + photos = r.json()["photos"] + assert len(photos) == 1 and photos[0].endswith(".webp") + + # Speicher belegt + r = client.get("/api/partner/my-profile", headers=user["headers"]) + assert r.json()["storage_mb"] > 0 + + # Foto loeschen + r = client.post("/api/partner/my-profile/photos/0/delete", headers=user["headers"], json={}) + assert r.status_code == 200 + assert r.json()["photos"] == [] + + +def test_partner_has_pro_access(client, user): + """is_partner=1 -> has_pro_access True (Pro gratis fuer Partner).""" + from auth import has_pro_access + assert has_pro_access({"rolle": "user", "subscription_tier": "standard", "is_partner": 1}) + assert not has_pro_access({"rolle": "user", "subscription_tier": "standard", "is_partner": 0}) From 21f54f478bd893605e08cca480375905b406266d Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 17:22:59 +0200 Subject: [PATCH 02/15] =?UTF-8?q?Fix:=20partner=5Fprofiles=20Alt-Schema-Mi?= =?UTF-8?q?gration=20=E2=80=94=20Tabelle=20aus=20verlorener=20v1102-Sessio?= =?UTF-8?q?n=20existiert=20auf=20Staging/Prod=20(photos=20statt=20photos?= =?UTF-8?q?=5Fjson);=20Umbau=20mit=20Daten=C3=BCbernahme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/backend/database.py b/backend/database.py index 5b5b0c8..1ddfc8e 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1633,6 +1633,38 @@ def _migrate(conn_factory): # Partner-Profile (öffentlicher Showcase auf /#partner) # approved: 0=Entwurf/in Prüfung, 1=freigegeben, -1=abgelehnt try: + # Alt-Schema aus der verlorenen v1102-Session (photos statt photos_json, + # id-Autoincrement-PK) kann auf Staging/Prod noch existieren → umbauen. + existing_cols = [r[1] for r in conn.execute( + "PRAGMA table_info(partner_profiles)" + ).fetchall()] + if existing_cols and "photos_json" not in existing_cols: + conn.executescript(""" + ALTER TABLE partner_profiles RENAME TO partner_profiles_old; + CREATE TABLE partner_profiles ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + display_name TEXT, + tagline TEXT, + bio TEXT, + website TEXT, + instagram TEXT, + logo_url TEXT, + photos_json TEXT NOT NULL DEFAULT '[]', + approved INTEGER NOT NULL DEFAULT 0, + submitted_at TEXT, + updated_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO partner_profiles + (user_id, display_name, tagline, bio, website, instagram, + logo_url, photos_json, approved, submitted_at, updated_at, created_at) + SELECT user_id, display_name, tagline, bio, website, instagram, + logo_url, COALESCE(photos, '[]'), COALESCE(approved, 0), + submitted_at, NULL, datetime('now') + FROM partner_profiles_old; + DROP TABLE partner_profiles_old; + """) + logger.info("Migration: partner_profiles Alt-Schema → neues Schema umgebaut.") conn.executescript(""" CREATE TABLE IF NOT EXISTS partner_profiles ( user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, From 8a614eef1aa20864135fd29a44b8675381d1c80b Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 17:29:59 +0200 Subject: [PATCH 03/15] Fix: HEIC/MOV-Konvertierung bei Partner-Uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logo-Pfad akzeptierte .heic, öffnete aber direkt mit Pillow (kein HEIF-Opener) — iPhone-Fotos schlugen fehl. Jetzt convert_media-Vorstufe wie im Foto-Pfad. Fehlgeschlagene Konvertierungen (HEIC→JPEG, MOV→MP4) brechen mit klarer Meldung ab statt rohe Dateien zu speichern (MOV wäre als kaputt gerendert). Test: echter HEIC-Roundtrip (pillow-heif) für Logo + Foto. --- backend/routes/partner.py | 14 ++++++++++++-- tests/test_partner_profile.py | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/backend/routes/partner.py b/backend/routes/partner.py index b405e8a..b73ae1a 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -329,17 +329,22 @@ async def upload_partner_logo(file: UploadFile = File(...), user=Depends(require new_name = f"logo_{uuid.uuid4().hex[:8]}.webp" new_path = os.path.join(save_dir, new_name) + loop = asyncio.get_event_loop() + # HEIC/HEIF (iPhone) zuerst nach JPEG wandeln — Pillow kann HEIC nicht ohne Opener + data, ext = await loop.run_in_executor(None, lambda: convert_media(raw, filename)) + if ext in (".heic", ".heif"): + raise HTTPException(400, "HEIC-Bild konnte nicht konvertiert werden. Bitte als JPG/PNG exportieren.") + def _save(): import io from PIL import Image, ImageOps - img = Image.open(io.BytesIO(raw)) + img = Image.open(io.BytesIO(data)) img = ImageOps.exif_transpose(img) # Transparenz erhalten (Logos sind oft PNG mit Alpha) img = img.convert("RGBA" if "A" in (img.mode or "") or img.mode == "P" else "RGB") img.thumbnail((512, 512)) img.save(new_path, format="WEBP", quality=85) - loop = asyncio.get_event_loop() try: await loop.run_in_executor(None, _save) except Exception: @@ -394,6 +399,11 @@ async def upload_partner_photo(file: UploadFile = File(...), user=Depends(requir loop = asyncio.get_event_loop() # HEIC→JPEG bzw. MOV/AVI→MP4 (ffmpeg, komprimiert) — blockierend, daher Threadpool data, ext = await loop.run_in_executor(None, lambda: convert_media(raw, filename)) + if ext in (".heic", ".heif"): + raise HTTPException(400, "HEIC-Bild konnte nicht konvertiert werden. Bitte als JPG/PNG exportieren.") + if ext in (".mov", ".avi", ".m4v"): + # ffmpeg-Konvertierung fehlgeschlagen — unkonvertiert wäre es im Browser nicht abspielbar + raise HTTPException(400, "Video konnte nicht konvertiert werden. Bitte als MP4 hochladen.") save_dir = _pp_dir(user["id"]) file_id = uuid.uuid4().hex[:12] diff --git a/tests/test_partner_profile.py b/tests/test_partner_profile.py index d26c26f..2dbe2d0 100644 --- a/tests/test_partner_profile.py +++ b/tests/test_partner_profile.py @@ -112,6 +112,30 @@ def test_logo_and_photo_upload(client, user): assert r.json()["photos"] == [] +def test_heic_uploads_convert(client, user): + """HEIC (iPhone-Format) wird bei Logo UND Foto nach WebP konvertiert.""" + import pillow_heif + from PIL import Image + _make_partner(user["email"]) + + pillow_heif.register_heif_opener() + buf = io.BytesIO() + Image.new("RGB", (64, 64), "green").save(buf, format="HEIF") + heic_bytes = buf.getvalue() + + # Logo als HEIC + r = client.post("/api/partner/my-profile/logo", headers=user["headers"], + files={"file": ("IMG_0001.HEIC", io.BytesIO(heic_bytes), "image/heic")}) + assert r.status_code == 200, r.text + assert r.json()["logo_url"].endswith(".webp") + + # Foto als HEIC + r = client.post("/api/partner/my-profile/photos", headers=user["headers"], + files={"file": ("IMG_0002.heic", io.BytesIO(heic_bytes), "image/heic")}) + assert r.status_code == 200, r.text + assert r.json()["photos"][0].endswith(".webp") + + def test_partner_has_pro_access(client, user): """is_partner=1 -> has_pro_access True (Pro gratis fuer Partner).""" from auth import has_pro_access From 73ca66bbf5fe918fe32602a5e12e0c1932656074 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 17:30:07 +0200 Subject: [PATCH 04/15] bump v1254 --- VERSION | 2 +- backend/static/index.html | 24 ++++++++++++------------ backend/static/js/app.js | 2 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/VERSION b/VERSION index b0a0271..c789257 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1253 \ No newline at end of file +1254 \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index 3657214..42da1ae 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 92a040a..a0c2263 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 = '1253'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1254'; // ← 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/landing.html b/backend/static/landing.html index 75f1905..ef59aea 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 449fd49..6d66ba9 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1253'; +const VER = '1254'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From a40aa183eca32eeda35b53db17b828ee08a9d372 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 17:34:56 +0200 Subject: [PATCH 05/15] Admin: offene Partner-Profil-Freigaben in 'Zu erledigen'-Leiste + ADMIN_EMAIL-Befund MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rene reichte ein Partner-Profil ein und sah als Admin nirgends einen Hinweis: 1. Action-Items kannten Partner-Profile nicht — partner_profiles_pending (submitted_at gesetzt, approved=0) jetzt im Endpoint + Chip im Admin-Kopf (Klick -> Partner-Tab). Test ergänzt (7 passed). 2. ADMIN_EMAIL fehlte in BEIDEN .env auf der DS (Prod+Staging) — damit wurden auch Upgrade-Anfragen-Mails still verschluckt (bekanntes Silent-Skip-Muster). Auf der DS nachgetragen; greift je beim nächsten Deploy. --- VERSION | 2 +- backend/routes/admin.py | 7 +++++++ backend/static/index.html | 24 ++++++++++++------------ backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 1 + backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_partner_profile.py | 12 ++++++++++++ 8 files changed, 36 insertions(+), 16 deletions(-) diff --git a/VERSION b/VERSION index c789257..b8d1607 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1254 \ No newline at end of file +1255 \ No newline at end of file diff --git a/backend/routes/admin.py b/backend/routes/admin.py index e709b53..715addd 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -152,6 +152,12 @@ async def action_items(user=Depends(require_mod)): ).fetchone()[0] except Exception: invoices_unpaid = 0 + try: + partner_profiles_pending = conn.execute( + "SELECT COUNT(*) FROM partner_profiles WHERE submitted_at IS NOT NULL AND approved=0" + ).fetchone()[0] + except Exception: + partner_profiles_pending = 0 return { "jobs_pending": jobs, "breeder_pending": breeders, @@ -161,6 +167,7 @@ async def action_items(user=Depends(require_mod)): "users_today": users_today, "upgrades_pending": upgrades_pending, "invoices_unpaid": invoices_unpaid, + "partner_profiles_pending": partner_profiles_pending, } diff --git a/backend/static/index.html b/backend/static/index.html index 42da1ae..da8b74c 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a0c2263..f6bd714 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 = '1254'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1255'; // ← 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/admin.js b/backend/static/js/pages/admin.js index 717677c..cd992a5 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -123,6 +123,7 @@ window.Page_admin = (() => { { key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' }, { key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' }, { key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' }, + { key: 'partner_profiles_pending', label: 'Partner-Profile', tab: 'partner', icon: 'handshake' }, ]; const open = items.filter(i => d[i.key] > 0); diff --git a/backend/static/landing.html b/backend/static/landing.html index ef59aea..2d5176f 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 6d66ba9..8b178df 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1254'; +const VER = '1255'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_partner_profile.py b/tests/test_partner_profile.py index 2dbe2d0..f62d256 100644 --- a/tests/test_partner_profile.py +++ b/tests/test_partner_profile.py @@ -136,6 +136,18 @@ def test_heic_uploads_convert(client, user): assert r.json()["photos"][0].endswith(".webp") +def test_submit_appears_in_admin_action_items(client, user, admin): + """Eingereichtes Profil taucht im Admin-'Zu erledigen'-Zaehler auf.""" + _make_partner(user["email"]) + client.put("/api/partner/my-profile", headers=user["headers"], + json={"display_name": "Action-Item-Test"}) + before = client.get("/api/admin/action-items", headers=admin["headers"]).json() + r = client.post("/api/partner/my-profile/submit", headers=user["headers"], json={}) + assert r.status_code == 200 + after = client.get("/api/admin/action-items", headers=admin["headers"]).json() + assert after["partner_profiles_pending"] == before.get("partner_profiles_pending", 0) + 1 + + def test_partner_has_pro_access(client, user): """is_partner=1 -> has_pro_access True (Pro gratis fuer Partner).""" from auth import has_pro_access From cadfb24a8db3d8d5e13b849dcedc16939ff09a08 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 17:43:42 +0200 Subject: [PATCH 06/15] Partner-Freigabe: Live-Vorschau im Admin + Mail-Fehler sichtbar machen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rene: 'kann nichts prüfen — ich würde den Output erwarten, der auf der Partner-Seite zu sehen sein wird'. Freigabe-Zeile hat jetzt einen Vorschau-Toggle, der die Karte 1:1 wie auf #partner rendert (Logo/Initial, Slogan, Website, Instagram, Bio, Medien-Grid). Mail-Ursache gefunden: Staging-.env fehlte SMTP_SUPPORT_USER → Code-Default support@banyaro.de → 535 Auth-Fehler, vom Silent-Catch verschluckt. .env ergänzt (partner@banyaro.app wie Prod); Submit loggt SMTP-Fehler jetzt über _log_smtp_failure in failed_emails statt still zu schlucken. --- VERSION | 2 +- backend/routes/partner.py | 18 ++++---- backend/static/index.html | 24 +++++----- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 76 +++++++++++++++++++++++++------- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 7 files changed, 84 insertions(+), 42 deletions(-) diff --git a/VERSION b/VERSION index b8d1607..832d4ca 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1255 \ No newline at end of file +1256 \ No newline at end of file diff --git a/backend/routes/partner.py b/backend/routes/partner.py index b73ae1a..dff79c9 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -481,20 +481,18 @@ def submit_partner_profile(user=Depends(require_partner)): (user["id"],) ) profile = _pp_get_or_empty(conn, user["id"]) - # Admin benachrichtigen (best effort — Silent-Skip ohne ADMIN_EMAIL) + # Admin benachrichtigen — Fehler landen in failed_emails (Admin-Retry), kein Silent-Skip admin_email = os.getenv("ADMIN_EMAIL", "") if admin_email and profile.get("approved") != 1: + subject = f"[Ban Yaro] Partner-Profil eingereicht: {profile.get('display_name')}" + body = (f"Partner {user['name']} ({user['email']}) hat sein Profil zur " + f"Freigabe eingereicht.\n\nAdmin-Panel: https://banyaro.app/#admin") try: from routes.outreach import _send_smtp - _send_smtp( - admin_email, - f"[Ban Yaro] Partner-Profil eingereicht: {profile.get('display_name')}", - (f"Partner {user['name']} ({user['email']}) hat sein Profil zur " - f"Freigabe eingereicht.\n\nAdmin-Panel: https://banyaro.app/#admin"), - "support", - ) - except Exception: - pass + _send_smtp(admin_email, subject, body, "support") + except Exception as exc: + from routes.auth import _log_smtp_failure + _log_smtp_failure(admin_email, subject, body, exc, context="partner_profile_submit") return {"profile": profile} diff --git a/backend/static/index.html b/backend/static/index.html index da8b74c..ac806cb 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f6bd714..77b177d 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 = '1255'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1256'; // ← 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/admin.js b/backend/static/js/pages/admin.js index cd992a5..21d2f92 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2395,23 +2395,60 @@ window.Page_admin = (() => { ${profiles.length === 0 ? `

Noch keine Partner-Profile angelegt.

` : profiles.map(p => ` -
- ${p.logo_url - ? `` - : `
`} -
-
${UI.escape(p.display_name || p.name)}
-
${UI.escape(p.name)} · ${UI.escape(p.email)}${p.photos?.length ? ` · ${p.photos.length} Medien` : ''}
+
+
+ ${p.logo_url + ? `` + : `
`} +
+
${UI.escape(p.display_name || p.name)}
+
${UI.escape(p.name)} · ${UI.escape(p.email)}${p.photos?.length ? ` · ${p.photos.length} Medien` : ''}
+
+ ${p.approved === 1 + ? `✓ Frei` + : p.approved === -1 + ? `✗ Abgelehnt` + : p.submitted_at + ? `⏳ Prüfen` + : `Entwurf`} + + ${p.approved !== 1 ? `` : ''} + ${p.approved !== -1 ? `` : ''} +
+ + - ${p.approved === 1 - ? `✓ Frei` - : p.approved === -1 - ? `✗ Abgelehnt` - : p.submitted_at - ? `⏳ Prüfen` - : `Entwurf`} - ${p.approved !== 1 ? `` : ''} - ${p.approved !== -1 ? `` : ''}
`).join('')}
@@ -2446,6 +2483,13 @@ window.Page_admin = (() => {
`; + // Partner-Profil-Vorschau auf-/zuklappen (.hidden hat !important → classList) + el.querySelectorAll('.adm-pp-preview').forEach(btn => { + btn.addEventListener('click', () => { + el.querySelector(`#adm-pp-preview-${btn.dataset.uid}`)?.classList.toggle('hidden'); + }); + }); + // Partner-Profil freigeben / ablehnen el.querySelectorAll('.adm-pp-review').forEach(btn => { btn.addEventListener('click', async () => { diff --git a/backend/static/landing.html b/backend/static/landing.html index 2d5176f..a693e89 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 8b178df..2782ffa 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1255'; +const VER = '1256'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From f604ab7c4ff30a45a9588ca8e2f4382eb94e62bc Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 18:20:23 +0200 Subject: [PATCH 07/15] =?UTF-8?q?Feature:=20QR-Kontingente=20f=C3=BCr=20Pa?= =?UTF-8?q?rtner=20=E2=80=94=20Bestellung,=20=C3=9Cbergabe,=20R=C3=BCckver?= =?UTF-8?q?folgung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partner verteilen gedruckte QR-Codes (Sticker/Flyer); jeder physische Code ist einzeln rückverfolgbar von Scan bis Registrierung. Backend: - partner_qr_batches + partner_qr_codes (Token 8-stellig, ohne 0/O/1/l/I), users.referred_qr, partner_codes.owner_user_id (+Backfill über referred_by) - /q/{token}: Scan zählen (scans, first/last_scan_at) → Redirect /?ref=CODE&qr=TOKEN — dockt am bestehenden Referral-Flow an - Registrierung: qr_token wird nur zugeordnet, wenn er zum eingelösten Partner-Code gehört (Manipulationsschutz) - Admin: Kontingent bestellen (max 500), Liste mit Scans/Registrierungen, Löschen (Zweiklick), druckfertiges A4-PDF (segno+fpdf2, 3×4 Grid mit Kurz-URL + laufender Nummer), Code-Besitzer zuordnen - Partner-Self-Service: /partner/my-qr (+PDF) für Code-Besitzer Frontend: - Admin-Partner-Tab: Karte 'QR-Kontingente' (Bestellung, Stats, PDF, Besitzer) - Partner-Profil: 'Meine QR-Codes' mit Scans/Registrierungen + PDF-Download - boot.js/app.js speichern ?qr=, Registrierung schickt qr_token mit Neu: segno==1.6.6 (pure-python QR). Tests: 5 neue (PDF, Scan-Zählung, Attribution, Fremd-Token-Schutz, Self-Service). Suite: 51 passed. --- VERSION | 2 +- backend/database.py | 46 +++++ backend/main.py | 31 +++ backend/requirements.txt | 1 + backend/routes/auth.py | 11 ++ backend/routes/partner.py | 223 +++++++++++++++++++++- backend/static/index.html | 24 +-- backend/static/js/api.js | 3 +- backend/static/js/app.js | 5 +- backend/static/js/boot.js | 3 + backend/static/js/pages/admin.js | 125 +++++++++++- backend/static/js/pages/partner-profil.js | 32 ++++ backend/static/js/pages/settings.js | 5 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_partner_qr.py | 129 +++++++++++++ 16 files changed, 621 insertions(+), 23 deletions(-) create mode 100644 tests/test_partner_qr.py diff --git a/VERSION b/VERSION index 832d4ca..94ad64d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1256 \ No newline at end of file +1257 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 1ddfc8e..1169b5d 100644 --- a/backend/database.py +++ b/backend/database.py @@ -623,6 +623,10 @@ def _migrate(conn_factory): ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), ("users", "founder_number", "INTEGER"), ("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"), + # QR-Rückverfolgung: über welchen physischen QR-Code (Sticker/Flyer) kam die Registrierung + ("users", "referred_qr", "TEXT"), + # Partner-Code → Besitzer (für Self-Service: eigene QR-Kontingente + Stats einsehen) + ("partner_codes", "owner_user_id", "INTEGER"), # Passwort-Zurücksetzen ("users", "password_reset_token", "TEXT"), ("users", "password_reset_expires", "TEXT"), @@ -1685,6 +1689,48 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration partner_profiles: {e}") + # QR-Kontingente für Partner (gedruckte Sticker/Flyer mit Rückverfolgung) + # Jeder physische QR-Code hat einen eigenen Token → Scan- und + # Registrierungs-Tracking pro Einzelcode und pro Kontingent. + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS partner_qr_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + partner_code_id INTEGER NOT NULL REFERENCES partner_codes(id) ON DELETE CASCADE, + label TEXT NOT NULL, + quantity INTEGER NOT NULL, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS partner_qr_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id INTEGER NOT NULL REFERENCES partner_qr_batches(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + seq INTEGER NOT NULL, + scans INTEGER NOT NULL DEFAULT 0, + first_scan_at TEXT, + last_scan_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_pqr_token ON partner_qr_codes(token); + CREATE INDEX IF NOT EXISTS idx_pqr_batch ON partner_qr_codes(batch_id); + """) + logger.info("Migration: partner_qr Tabellen bereit.") + except Exception as e: + logger.warning(f"Migration partner_qr: {e}") + try: + # Backfill: Partner, die sich mit ihrem eigenen Code registriert haben, + # als Code-Besitzer verknüpfen (für Self-Service-Zugriff auf QR-Stats). + # Eigener try-Block: owner_user_id kommt auf frischen DBs erst im 2nd pass. + conn.execute(""" + UPDATE partner_codes SET owner_user_id = ( + SELECT u.id FROM users u + WHERE u.referred_by = -partner_codes.id AND u.is_partner = 1 + LIMIT 1 + ) WHERE owner_user_id IS NULL + """) + except Exception as e: + logger.debug(f"Backfill partner_codes.owner_user_id übersprungen: {e}") + # Outreach-Log (Admin-E-Mail-Versand) try: conn.executescript(""" diff --git a/backend/main.py b/backend/main.py index b473156..309e184 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2156,6 +2156,37 @@ setTimeout(() => location.href = '/?_t=' + Date.now() + '&hard=1', 6000); return HTMLResponse(content=html, headers={"Cache-Control": "no-store"}) +# /q/{token} — Partner-QR-Scan: zählen + auf Registrierung mit Code umleiten +# ------------------------------------------------------------------ +@app.get("/q/{token}") +async def partner_qr_scan(token: str): + from fastapi.responses import RedirectResponse as _Redirect + from database import db as _db + token = token.strip() + with _db() as conn: + row = conn.execute( + """SELECT q.token, pc.code + FROM partner_qr_codes q + JOIN partner_qr_batches b ON b.id = q.batch_id + JOIN partner_codes pc ON pc.id = b.partner_code_id + WHERE q.token = ?""", + (token,) + ).fetchone() + if not row: + return _Redirect("/", status_code=302) + conn.execute( + """UPDATE partner_qr_codes + SET scans = scans + 1, + first_scan_at = COALESCE(first_scan_at, datetime('now')), + last_scan_at = datetime('now') + WHERE token = ?""", + (token,) + ) + # ?ref= nutzt den bestehenden Partner-Code-Flow, ?qr= ergänzt die Einzelcode-Zuordnung + return _Redirect(f"/?ref={row['code']}&qr={row['token']}", status_code=302) + + +# ------------------------------------------------------------------ # /partner — Influencer-Landingpage # ------------------------------------------------------------------ @app.get("/partner") diff --git a/backend/requirements.txt b/backend/requirements.txt index d45b6f8..6508529 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,5 +15,6 @@ apscheduler==3.10.4 odfpy==1.4.1 polyline==2.0.2 fpdf2==2.8.3 +segno==1.6.6 python-dateutil>=2.9 brotli-asgi==1.4.0 diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 18b092b..718d6cd 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -153,6 +153,7 @@ class RegisterRequest(BaseModel): password: str = Field(..., min_length=8, max_length=200) name: str = Field(..., min_length=2, max_length=40) ref_code: Optional[str] = Field(None, max_length=50) + qr_token: Optional[str] = Field(None, max_length=20) # physischer Partner-QR (Sticker/Flyer) def _gen_referral_code() -> str: @@ -227,6 +228,16 @@ async def register(data: RegisterRequest, response: Response, request: Request): if redeemed: updates = {"referred_by": -partner["id"]} + # QR-Rückverfolgung: Token muss zu einem Kontingent DIESES Codes gehören + if data.qr_token: + qr = conn.execute( + """SELECT q.token FROM partner_qr_codes q + JOIN partner_qr_batches b ON b.id = q.batch_id + WHERE q.token=? AND b.partner_code_id=?""", + (data.qr_token.strip(), partner["id"]) + ).fetchone() + if qr: + updates["referred_qr"] = qr["token"] if partner["grants_founder"]: total_founders = conn.execute( "SELECT COUNT(*) FROM users WHERE is_founder=1" diff --git a/backend/routes/partner.py b/backend/routes/partner.py index dff79c9..ec0ebc5 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -37,10 +37,12 @@ def list_partner_codes(user=Depends(require_admin)): with db() as conn: rows = conn.execute( """SELECT pc.id, pc.code, pc.label, pc.grants_founder, - pc.max_uses, pc.uses, pc.created_at, - u.name AS created_by_name + pc.max_uses, pc.uses, pc.created_at, pc.owner_user_id, + u.name AS created_by_name, + o.name AS owner_name FROM partner_codes pc LEFT JOIN users u ON u.id = pc.created_by + LEFT JOIN users o ON o.id = pc.owner_user_id ORDER BY pc.created_at DESC""" ).fetchall() return [dict(r) for r in rows] @@ -546,3 +548,220 @@ def review_partner_profile(user_id: int, data: PartnerProfileReview, user=Depend ) profile = _pp_get_or_empty(conn, user_id) return {"profile": profile} + + +# ------------------------------------------------------------------ +# QR-Kontingente — gedruckte Sticker/Flyer mit Scan- und +# Registrierungs-Rückverfolgung pro Einzelcode und Kontingent. +# Bestellung: Admin legt Kontingent für einen Partner-Code an. +# Übergabe: PDF-Download (Admin + Partner im eigenen Profil). +# ------------------------------------------------------------------ + +_QR_MAX_QUANTITY = 500 +# Ohne verwechselbare Zeichen (0/O, 1/l/I) — Tokens landen gedruckt auf Stickern +_QR_ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789" +_QR_BASE_URL = os.getenv("APP_URL", "https://banyaro.app") + + +def _qr_new_token(conn) -> str: + import secrets + for _ in range(20): + token = "".join(secrets.choice(_QR_ALPHABET) for _ in range(8)) + if not conn.execute( + "SELECT 1 FROM partner_qr_codes WHERE token=?", (token,) + ).fetchone(): + return token + raise HTTPException(500, "Token-Generierung fehlgeschlagen.") + + +def _qr_batch_stats(conn, batch_id: int) -> dict: + row = conn.execute( + """SELECT COUNT(*) AS codes, COALESCE(SUM(q.scans),0) AS scans, + (SELECT COUNT(*) FROM users u + JOIN partner_qr_codes q2 ON q2.token = u.referred_qr + WHERE q2.batch_id = ?) AS registrations + FROM partner_qr_codes q WHERE q.batch_id = ?""", + (batch_id, batch_id) + ).fetchone() + return dict(row) + + +def _qr_list_batches(conn, where_sql: str, params: tuple) -> list: + rows = conn.execute( + f"""SELECT b.id, b.label, b.quantity, b.created_at, + pc.code, pc.label AS code_label, pc.id AS partner_code_id + FROM partner_qr_batches b + JOIN partner_codes pc ON pc.id = b.partner_code_id + {where_sql} + ORDER BY b.created_at DESC""", + params + ).fetchall() + result = [] + for r in rows: + d = dict(r) + d.update(_qr_batch_stats(conn, r["id"])) + result.append(d) + return result + + +def _qr_batch_pdf(conn, batch_id: int) -> bytes: + """Druckfertiges A4-PDF: 3×4 QR-Codes pro Seite mit Kurz-URL + laufender Nummer.""" + import io as _io + import segno + from fpdf import FPDF + + batch = conn.execute( + """SELECT b.id, b.label, b.quantity, pc.code, pc.label AS code_label + FROM partner_qr_batches b + JOIN partner_codes pc ON pc.id = b.partner_code_id + WHERE b.id=?""", + (batch_id,) + ).fetchone() + if not batch: + raise HTTPException(404, "Kontingent nicht gefunden.") + codes = conn.execute( + "SELECT token, seq FROM partner_qr_codes WHERE batch_id=? ORDER BY seq", + (batch_id,) + ).fetchall() + + pdf = FPDF(format="A4") + pdf.set_auto_page_break(False) + pdf.set_title(f"Ban Yaro QR-Kontingent — {batch['label']}") + + COLS, ROWS = 3, 4 + CELL_W, CELL_H = 60, 64 # mm — Zelle inkl. Beschriftung + MARGIN_X = (210 - COLS * CELL_W) / 2 + MARGIN_Y = 18 + QR_SIZE = 42 # mm + + def _latin1(s: str) -> str: + return s.encode("latin-1", "replace").decode("latin-1") + + for i, c in enumerate(codes): + pos = i % (COLS * ROWS) + if pos == 0: + pdf.add_page() + pdf.set_font("Helvetica", "B", 11) + pdf.set_text_color(60) + pdf.cell(0, 6, _latin1(f"Ban Yaro — {batch['code_label']} · Kontingent: {batch['label']} ({batch['quantity']} Stk.)"), + align="C", new_x="LMARGIN", new_y="NEXT") + col, row_ = pos % COLS, pos // COLS + x = MARGIN_X + col * CELL_W + y = MARGIN_Y + 10 + row_ * CELL_H + + url = f"{_QR_BASE_URL}/q/{c['token']}" + buf = _io.BytesIO() + segno.make(url, error="m").save(buf, kind="png", scale=8, border=2) + buf.seek(0) + pdf.image(buf, x=x + (CELL_W - QR_SIZE) / 2, y=y, w=QR_SIZE, h=QR_SIZE) + + pdf.set_xy(x, y + QR_SIZE + 1) + pdf.set_font("Helvetica", "", 8) + pdf.set_text_color(90) + pdf.cell(CELL_W, 4, _latin1(f"banyaro.app/q/{c['token']}"), align="C", new_x="LEFT", new_y="NEXT") + pdf.set_x(x) + pdf.set_font("Helvetica", "B", 8) + pdf.cell(CELL_W, 4, f"#{c['seq']}", align="C") + + return bytes(pdf.output()) + + +class QrBatchCreate(BaseModel): + label: str = Field(..., min_length=1, max_length=100) + quantity: int = Field(..., ge=1, le=_QR_MAX_QUANTITY) + + +@router.post("/admin/partner/codes/{code_id}/qr-batches", status_code=201) +def create_qr_batch(code_id: int, data: QrBatchCreate, user=Depends(require_admin)): + """Bestellung: neues QR-Kontingent für einen Partner-Code anlegen.""" + with db() as conn: + code = conn.execute( + "SELECT id FROM partner_codes WHERE id=?", (code_id,) + ).fetchone() + if not code: + raise HTTPException(404, "Partner-Code nicht gefunden.") + conn.execute( + "INSERT INTO partner_qr_batches (partner_code_id, label, quantity, created_by) VALUES (?,?,?,?)", + (code_id, data.label.strip(), data.quantity, user["id"]) + ) + batch_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + for seq in range(1, data.quantity + 1): + conn.execute( + "INSERT INTO partner_qr_codes (batch_id, token, seq) VALUES (?,?,?)", + (batch_id, _qr_new_token(conn), seq) + ) + batches = _qr_list_batches(conn, "WHERE b.id=?", (batch_id,)) + return batches[0] + + +@router.get("/admin/partner/qr-batches") +def list_qr_batches(user=Depends(require_admin)): + """Alle QR-Kontingente mit Stats (Scans, Registrierungen).""" + with db() as conn: + return _qr_list_batches(conn, "", ()) + + +@router.delete("/admin/partner/qr-batches/{batch_id}", status_code=204) +def delete_qr_batch(batch_id: int, user=Depends(require_admin)): + """Kontingent löschen (z. B. Fehlbestellung) — Codes via CASCADE mit weg.""" + with db() as conn: + if not conn.execute( + "SELECT id FROM partner_qr_batches WHERE id=?", (batch_id,) + ).fetchone(): + raise HTTPException(404, "Kontingent nicht gefunden.") + conn.execute("DELETE FROM partner_qr_batches WHERE id=?", (batch_id,)) + return None + + +@router.get("/admin/partner/qr-batches/{batch_id}/pdf") +def qr_batch_pdf_admin(batch_id: int, user=Depends(require_admin)): + from fastapi.responses import Response + with db() as conn: + pdf = _qr_batch_pdf(conn, batch_id) + return Response(content=pdf, media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'}) + + +@router.get("/partner/my-qr") +def my_qr_batches(user=Depends(require_partner)): + """Übergabe/Self-Service: eigene Kontingente mit Stats (Code-Besitzer).""" + with db() as conn: + return _qr_list_batches( + conn, "WHERE pc.owner_user_id = ?", (user["id"],) + ) + + +@router.get("/partner/my-qr/{batch_id}/pdf") +def qr_batch_pdf_partner(batch_id: int, user=Depends(require_partner)): + from fastapi.responses import Response + with db() as conn: + own = conn.execute( + """SELECT b.id FROM partner_qr_batches b + JOIN partner_codes pc ON pc.id = b.partner_code_id + WHERE b.id=? AND pc.owner_user_id=?""", + (batch_id, user["id"]) + ).fetchone() + if not own and user.get("rolle") != "admin": + raise HTTPException(403, "Kein Zugriff auf dieses Kontingent.") + pdf = _qr_batch_pdf(conn, batch_id) + return Response(content=pdf, media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'}) + + +class CodeOwnerSet(BaseModel): + user_id: int + + +@router.post("/admin/partner/codes/{code_id}/owner") +def set_code_owner(code_id: int, data: CodeOwnerSet, user=Depends(require_admin)): + """Partner-Code einem User zuordnen (für Self-Service-QR-Zugriff).""" + with db() as conn: + if not conn.execute("SELECT id FROM partner_codes WHERE id=?", (code_id,)).fetchone(): + raise HTTPException(404, "Partner-Code nicht gefunden.") + if not conn.execute("SELECT id FROM users WHERE id=?", (data.user_id,)).fetchone(): + raise HTTPException(404, "User nicht gefunden.") + conn.execute( + "UPDATE partner_codes SET owner_user_id=? WHERE id=?", + (data.user_id, code_id) + ) + return {"ok": True} diff --git a/backend/static/index.html b/backend/static/index.html index ac806cb..1acae3c 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 7fd420e..b5022e2 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -114,9 +114,10 @@ const API = (() => { login(email, password) { return post('/auth/login', { email, password }); }, - register(email, password, name, ref_code) { + register(email, password, name, ref_code, qr_token) { const body = { email, password, name }; if (ref_code) body.ref_code = ref_code; + if (qr_token) body.qr_token = qr_token; // Partner-QR (Sticker/Flyer) — Rückverfolgung return post('/auth/register', body); }, logout() { diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 77b177d..5ea91f4 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 = '1256'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1257'; // ← 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; @@ -1140,10 +1140,13 @@ const App = (() => { // überlebt App-Schließen, sodass die Zuordnung auch bei späterer Registrierung klappt) const urlParams = new URLSearchParams(window.location.search); const refCode = urlParams.get('ref'); + const qrToken = urlParams.get('qr'); if (refCode) { try { localStorage.setItem('by_ref_code', refCode.toUpperCase()); localStorage.setItem('by_ref_code_ts', String(Date.now())); + // Partner-QR-Token (Sticker/Flyer) für Einzelcode-Rückverfolgung mitspeichern + if (qrToken) localStorage.setItem('by_qr_token', qrToken); } catch {} // URL bereinigen ohne Reload history.replaceState({}, '', window.location.pathname + window.location.hash); diff --git a/backend/static/js/boot.js b/backend/static/js/boot.js index 09e1624..8165c64 100644 --- a/backend/static/js/boot.js +++ b/backend/static/js/boot.js @@ -17,6 +17,9 @@ localStorage.setItem('by_ref_code', rc.toUpperCase()); localStorage.setItem('by_ref_code_ts', String(Date.now())); } + // Partner-QR-Token (?qr= aus /q/{token}-Redirect) — Rückverfolgung pro Sticker/Flyer + var qt = new URLSearchParams(location.search).get('qr'); + if (qt) localStorage.setItem('by_qr_token', qt); // Vektor-Basemap-Feature-Flag aus ?vectormap=1/0 SOFORT sichern (bevor Boot // die URL-Query strippt). Wird in ui.js Map.create ausgewertet. var vm = new URLSearchParams(location.search).get('vectormap'); diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 21d2f92..3f15c6b 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2290,8 +2290,9 @@ window.Page_admin = (() => { // TAB: AUDIT-LOG // ------------------------------------------------------------------ async function _renderPartner(el) { - const codes = (await API.get('/admin/partner/codes')) || []; - const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || []; + const codes = (await API.get('/admin/partner/codes')) || []; + const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || []; + const qrBatches = (await API.get('/admin/partner/qr-batches').catch(() => [])) || []; el.innerHTML = `
@@ -2364,7 +2365,14 @@ window.Page_admin = (() => { ${c.code} - ${c.label} + + ${c.label} +
+ ${c.owner_name + ? `👤 ${UI.escape(c.owner_name)}` + : ``} +
+ ${c.uses}${c.max_uses ? `/${c.max_uses}` : ''} @@ -2385,6 +2393,69 @@ window.Page_admin = (() => {
+ +
+

QR-Kontingente

+

+ Druckfertige QR-Codes für Partner (Sticker, Flyer, Visitenkarten). Jeder Code ist einzeln + rückverfolgbar: Scans und Registrierungen werden pro Kontingent gezählt. +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ ${qrBatches.length === 0 + ? `

Noch keine Kontingente bestellt.

` + : ` + + + + + + + + + + + + ${qrBatches.map(b => ` + + + + + + + + `).join('')} + +
CodeKontingentStk.ScansRegistr.
${UI.escape(b.code)}${UI.escape(b.label)}
${(b.created_at || '').slice(0, 10)}
${b.quantity}${b.scans}${b.registrations} + + ${UI.icon('file-pdf')} PDF + + +
`} +
+

@@ -2483,6 +2554,54 @@ window.Page_admin = (() => {

`; + // Code-Besitzer zuordnen (Self-Service-QR-Zugriff für den Partner) + el.querySelectorAll('.adm-code-owner').forEach(btn => { + btn.addEventListener('click', async () => { + const q = window.prompt('Benutzername des Partners (exakt):'); + if (!q) return; + try { + const hits = await API.get(`/admin/users/search?q=${encodeURIComponent(q.trim())}`); + const hit = (hits || []).find(u => u.name.toLowerCase() === q.trim().toLowerCase()) || (hits || [])[0]; + if (!hit) { UI.toast.warning('Kein User gefunden.'); return; } + await API.post(`/admin/partner/codes/${btn.dataset.id}/owner`, { user_id: hit.id }); + UI.toast.success(`Code gehört jetzt ${hit.name} — er sieht seine QR-Kontingente im Partner-Profil.`); + await _renderPartner(el); + } catch (err) { UI.toast.error(err.message); } + }); + }); + + // QR-Kontingent anlegen + el.querySelector('#adm-qr-create')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const fd = UI.formData(e.target); + await UI.asyncButton(btn, async () => { + const b = await API.post(`/admin/partner/codes/${fd.code_id}/qr-batches`, { + label: fd.label, + quantity: parseInt(fd.quantity), + }); + UI.toast.success(`Kontingent "${b.label}" mit ${b.quantity} QR-Codes erstellt.`); + await _renderPartner(el); + }); + }); + + // QR-Kontingent löschen (Zweiklick-Pattern statt confirm — Memory: kein Modal-in-Modal) + el.querySelectorAll('.adm-qr-del').forEach(btn => { + btn.addEventListener('click', async () => { + if (btn.dataset.armed !== '1') { + btn.dataset.armed = '1'; + btn.textContent = 'Wirklich löschen?'; + setTimeout(() => { btn.dataset.armed = '0'; btn.innerHTML = UI.icon('trash'); }, 3000); + return; + } + try { + await API.del(`/admin/partner/qr-batches/${btn.dataset.id}`); + UI.toast.success(`Kontingent "${btn.dataset.label}" gelöscht.`); + await _renderPartner(el); + } catch (err) { UI.toast.error(err.message); } + }); + }); + // Partner-Profil-Vorschau auf-/zuklappen (.hidden hat !important → classList) el.querySelectorAll('.adm-pp-preview').forEach(btn => { btn.addEventListener('click', () => { diff --git a/backend/static/js/pages/partner-profil.js b/backend/static/js/pages/partner-profil.js index aafd6a5..0402c36 100644 --- a/backend/static/js/pages/partner-profil.js +++ b/backend/static/js/pages/partner-profil.js @@ -36,6 +36,8 @@ window.Page_partner_profil = (() => { `; } + let _qrBatches = []; + async function _load() { const el = _container.querySelector('#pp-content'); try { @@ -43,6 +45,7 @@ window.Page_partner_profil = (() => { _profile = d.profile || {}; _profile._storage_mb = d.storage_mb || 0; _profile._storage_limit_mb = d.storage_limit_mb || 200; + _qrBatches = (await API.get('/partner/my-qr').catch(() => [])) || []; el.innerHTML = _renderEditor(); _bindEvents(el); } catch (e) { @@ -178,6 +181,35 @@ window.Page_partner_profil = (() => {
+ ${_qrBatches.length ? ` + +
+
Meine QR-Codes
+

+ Deine gedruckten QR-Codes (Sticker, Flyer). Jeder Scan und jede Registrierung + darüber wird gezählt — so siehst du, was wo funktioniert. +

+ ${_qrBatches.map(b => ` +
+
+
${UI.escape(b.label)}
+
${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)}
+
+
+
${b.scans}
+
Scans
+
+
+
${b.registrations}
+
Registr.
+
+ + ${UI.icon('file-pdf')} PDF + +
`).join('')} +
` : ''} +
` : ''} ${UI.icon('file-pdf')} PDF @@ -2451,6 +2457,11 @@ window.Page_admin = (() => { ${UI.icon('trash')} + + + +
Lädt…
+ `).join('')} `} @@ -2585,6 +2596,35 @@ window.Page_admin = (() => { }); }); + // QR-Detail: Accounts hinter einem Kontingent (lazy laden, .hidden via classList) + el.querySelectorAll('.adm-qr-detail').forEach(btn => { + btn.addEventListener('click', async () => { + const row = el.querySelector(`#adm-qr-detail-${btn.dataset.id}`); + if (!row) return; + row.classList.toggle('hidden'); + if (row.classList.contains('hidden') || row.dataset.loaded === '1') return; + try { + const regs = await API.get(`/admin/partner/qr-batches/${btn.dataset.id}/registrations`); + row.dataset.loaded = '1'; + const cell = row.querySelector('td'); + cell.innerHTML = !regs.length + ? `
Keine Accounts.
` + : regs.map(u => ` +
+
+ ${UI.escape(u.name)} + · ${UI.escape(u.email)} +
+ #${u.seq} + ${(u.created_at || '').slice(0, 16).replace(' ', ' · ')} + ${u.email_verified + ? `✓ bestätigt` + : `⏳ Versuch`} +
`).join(''); + } catch (err) { UI.toast.error(err.message); } + }); + }); + // QR-Kontingent löschen (Zweiklick-Pattern statt confirm — Memory: kein Modal-in-Modal) el.querySelectorAll('.adm-qr-del').forEach(btn => { btn.addEventListener('click', async () => { diff --git a/backend/static/js/pages/partner-profil.js b/backend/static/js/pages/partner-profil.js index 0402c36..ca07102 100644 --- a/backend/static/js/pages/partner-profil.js +++ b/backend/static/js/pages/partner-profil.js @@ -196,12 +196,13 @@ window.Page_partner_profil = (() => {
${UI.escape(b.label)}
${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)}
-
+
${b.scans}
Scans
-
-
${b.registrations}
+
+
${b.registrations}${b.attempts ? ` +${b.attempts}` : ''}
Registr.
diff --git a/backend/static/landing.html b/backend/static/landing.html index 6863a37..2c309e8 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index b2c12ec..db1b74a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1257'; +const VER = '1258'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_partner_qr.py b/tests/test_partner_qr.py index 5c3b677..c9e1376 100644 --- a/tests/test_partner_qr.py +++ b/tests/test_partner_qr.py @@ -63,7 +63,7 @@ def test_scan_redirects_and_counts(client, admin): def test_registration_attributed_to_qr(client, admin): - """Registrierung mit ref+qr -> users.referred_qr gesetzt, Kontingent-Stats zaehlen.""" + """Registrierung mit ref+qr -> referred_qr gesetzt; unbestaetigt=Versuch, bestaetigt=Registrierung.""" code = _create_code(client, admin) batch = _create_batch(client, admin, code["id"], quantity=2) token = _batch_tokens(batch["id"])[0] @@ -81,9 +81,27 @@ def test_registration_attributed_to_qr(client, admin): assert row["referred_by"] == -code["id"] assert row["referred_qr"] == token - r = client.get("/api/admin/partner/qr-batches", headers=admin["headers"]) - mine = [b for b in r.json() if b["id"] == batch["id"]][0] - assert mine["registrations"] == 1 + # Frisch registriert = E-Mail unbestaetigt -> zaehlt als Versuch + def _batch(): + r = client.get("/api/admin/partner/qr-batches", headers=admin["headers"]) + return [b for b in r.json() if b["id"] == batch["id"]][0] + assert _batch()["attempts"] == 1 and _batch()["registrations"] == 0 + + # Nach E-Mail-Bestaetigung -> echte Registrierung + with db() as conn: + conn.execute("UPDATE users SET email_verified=1 WHERE email=?", (email,)) + assert _batch()["registrations"] == 1 and _batch()["attempts"] == 0 + + # Admin-Detail-Liste: Account mit Datum, Status und Sticker-Nr + r = client.get(f"/api/admin/partner/qr-batches/{batch['id']}/registrations", + headers=admin["headers"]) + assert r.status_code == 200 + regs = r.json() + assert len(regs) == 1 + assert regs[0]["email"] == email + assert regs[0]["email_verified"] == 1 + assert regs[0]["seq"] == 1 + assert regs[0]["created_at"] def test_qr_token_must_match_code(client, admin): From df2f42f8ac7acdb383628b5915f79ffa0721538b Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 18:46:54 +0200 Subject: [PATCH 09/15] =?UTF-8?q?Partner-Self-Service:=20Einzel-Code-Statu?= =?UTF-8?q?s=20=E2=80=94=20welcher=20Sticker=20ist=20verbraucht=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rene: 'wo sieht der Partner welche QR-Codes er hat und wieviele verbraucht sind?' Neu in 'Meine QR-Codes': - Kontingent-Zeile zeigt 'X/Y verbraucht' (Codes mit ≥1 bestätigter Registrierung) - Listen-Button klappt Einzel-Codes auf: #Nr, Kurz-URL, Scans und Status ● verbraucht (mit Registrierungs-Datum) / ◐ gescannt / ○ frei - Endpoint /partner/my-qr/{id}/codes (owner-gated, keine personenbezogenen Daten — nur Zähler + Zeitstempel) --- VERSION | 2 +- backend/routes/partner.py | 49 ++++++++++++---- backend/static/index.html | 24 ++++---- backend/static/js/app.js | 2 +- backend/static/js/pages/partner-profil.js | 70 ++++++++++++++++++----- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_partner_qr.py | 24 ++++++++ 8 files changed, 134 insertions(+), 41 deletions(-) diff --git a/VERSION b/VERSION index 1e36b91..6ee69cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1258 \ No newline at end of file +1259 \ No newline at end of file diff --git a/backend/routes/partner.py b/backend/routes/partner.py index ba3802e..23f0bcd 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -583,9 +583,12 @@ def _qr_batch_stats(conn, batch_id: int) -> dict: WHERE q2.batch_id = ? AND u.email_verified = 1) AS registrations, (SELECT COUNT(*) FROM users u JOIN partner_qr_codes q2 ON q2.token = u.referred_qr - WHERE q2.batch_id = ? AND u.email_verified = 0) AS attempts + WHERE q2.batch_id = ? AND u.email_verified = 0) AS attempts, + (SELECT COUNT(DISTINCT q3.id) FROM partner_qr_codes q3 + JOIN users u ON u.referred_qr = q3.token AND u.email_verified = 1 + WHERE q3.batch_id = ?) AS codes_used FROM partner_qr_codes q WHERE q.batch_id = ?""", - (batch_id, batch_id, batch_id) + (batch_id, batch_id, batch_id, batch_id) ).fetchone() return dict(row) @@ -756,18 +759,44 @@ def my_qr_batches(user=Depends(require_partner)): ) +def _require_own_batch(conn, batch_id: int, user: dict): + own = conn.execute( + """SELECT b.id FROM partner_qr_batches b + JOIN partner_codes pc ON pc.id = b.partner_code_id + WHERE b.id=? AND pc.owner_user_id=?""", + (batch_id, user["id"]) + ).fetchone() + if not own and user.get("rolle") != "admin": + raise HTTPException(403, "Kein Zugriff auf dieses Kontingent.") + + +@router.get("/partner/my-qr/{batch_id}/codes") +def my_qr_batch_codes(batch_id: int, user=Depends(require_partner)): + """Einzel-Code-Status fürs eigene Kontingent: welcher Sticker ist verbraucht? + Keine personenbezogenen Daten — nur Zähler und Zeitstempel.""" + with db() as conn: + _require_own_batch(conn, batch_id, user) + rows = conn.execute( + """SELECT q.seq, q.token, q.scans, q.last_scan_at, + (SELECT COUNT(*) FROM users u + WHERE u.referred_qr = q.token AND u.email_verified = 1) AS registrations, + (SELECT COUNT(*) FROM users u + WHERE u.referred_qr = q.token AND u.email_verified = 0) AS attempts, + (SELECT MIN(u.created_at) FROM users u + WHERE u.referred_qr = q.token AND u.email_verified = 1) AS first_registration_at + FROM partner_qr_codes q + WHERE q.batch_id = ? + ORDER BY q.seq""", + (batch_id,) + ).fetchall() + return [dict(r) for r in rows] + + @router.get("/partner/my-qr/{batch_id}/pdf") def qr_batch_pdf_partner(batch_id: int, user=Depends(require_partner)): from fastapi.responses import Response with db() as conn: - own = conn.execute( - """SELECT b.id FROM partner_qr_batches b - JOIN partner_codes pc ON pc.id = b.partner_code_id - WHERE b.id=? AND pc.owner_user_id=?""", - (batch_id, user["id"]) - ).fetchone() - if not own and user.get("rolle") != "admin": - raise HTTPException(403, "Kein Zugriff auf dieses Kontingent.") + _require_own_batch(conn, batch_id, user) pdf = _qr_batch_pdf(conn, batch_id) return Response(content=pdf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'}) diff --git a/backend/static/index.html b/backend/static/index.html index 99aaef9..08c14dc 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 53d3573..f167267 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 = '1258'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1259'; // ← 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/partner-profil.js b/backend/static/js/pages/partner-profil.js index ca07102..da834ec 100644 --- a/backend/static/js/pages/partner-profil.js +++ b/backend/static/js/pages/partner-profil.js @@ -191,23 +191,34 @@ window.Page_partner_profil = (() => { darüber wird gezählt — so siehst du, was wo funktioniert.

${_qrBatches.map(b => ` -
-
-
${UI.escape(b.label)}
-
${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)}
+
` : ''} @@ -276,6 +287,35 @@ window.Page_partner_profil = (() => { }); }); + // Einzel-Code-Status eines QR-Kontingents (lazy, .hidden via classList) + el.querySelectorAll('.pp-qr-codes-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const box = el.querySelector(`#pp-qr-codes-${btn.dataset.id}`); + if (!box) return; + box.classList.toggle('hidden'); + if (box.classList.contains('hidden') || box.dataset.loaded === '1') return; + try { + const codes = await API.get(`/partner/my-qr/${btn.dataset.id}/codes`); + box.dataset.loaded = '1'; + box.innerHTML = codes.map(c => { + const used = c.registrations > 0; + const scanned = c.scans > 0; + return ` +
+ #${c.seq} + banyaro.app/q/${UI.escape(c.token)} + ${c.scans} Scan${c.scans === 1 ? '' : 's'} + ${used + ? `● verbraucht` + : scanned + ? `◐ gescannt` + : `○ frei`} +
`; + }).join(''); + } catch (err) { UI.toast.error(err.message); } + }); + }); + // Einreichen el.querySelector('#pp-submit-btn')?.addEventListener('click', async () => { const btn = el.querySelector('#pp-submit-btn'); diff --git a/backend/static/landing.html b/backend/static/landing.html index 2c309e8..e989a93 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index db1b74a..f513e8a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1258'; +const VER = '1259'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_partner_qr.py b/tests/test_partner_qr.py index c9e1376..f02f1d9 100644 --- a/tests/test_partner_qr.py +++ b/tests/test_partner_qr.py @@ -143,5 +143,29 @@ def test_partner_self_service_qr(client, admin, user): assert r.status_code == 200 r = client.get("/api/partner/my-qr", headers=user["headers"]) assert [b["id"] for b in r.json()] == [batch["id"]] + assert r.json()[0]["codes_used"] == 0 r = client.get(f"/api/partner/my-qr/{batch['id']}/pdf", headers=user["headers"]) assert r.status_code == 200 and r.content[:4] == b"%PDF" + + # Einzel-Code-Status: alle frei, dann einer verbraucht + r = client.get(f"/api/partner/my-qr/{batch['id']}/codes", headers=user["headers"]) + codes_list = r.json() + assert len(codes_list) == 3 + assert all(c["registrations"] == 0 and c["scans"] == 0 for c in codes_list) + + token = codes_list[0]["token"] + client.get(f"/q/{token}", follow_redirects=False) + email = f"qrc-{secrets.token_hex(4)}@example.com" + client.post("/api/auth/register", json={ + "email": email, "password": "QrTest1234!", "name": f"qrc{secrets.token_hex(3)}", + "ref_code": code["code"], "qr_token": token, + }) + with db() as conn: + conn.execute("UPDATE users SET email_verified=1 WHERE email=?", (email,)) + + r = client.get(f"/api/partner/my-qr/{batch['id']}/codes", headers=user["headers"]) + first = [c for c in r.json() if c["seq"] == 1][0] + assert first["scans"] == 1 and first["registrations"] == 1 + assert first["first_registration_at"] + r = client.get("/api/partner/my-qr", headers=user["headers"]) + assert r.json()[0]["codes_used"] == 1 From 3d7d5dc1c40dedfedbd2db74731fd52ec85e4120 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 18:51:54 +0200 Subject: [PATCH 10/15] =?UTF-8?q?Feature:=20Dank-Mail=20an=20Partner=20bei?= =?UTF-8?q?=20best=C3=A4tigter=20Registrierung=20=E2=80=94=20mit=20Statist?= =?UTF-8?q?ik?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger ist die E-Mail-Bestätigung des Geworbenen (nicht die rohe Registrierung — konsistent zur Registrierungen/Versuche-Zählung) und nur beim ersten Verify (Doppelklick auf den Link = keine zweite Mail). Inhalt: Dank + Bilanz (bestätigte Registrierungen gesamt + diesen Monat), bei QR-Herkunft der Sticker (#seq, Kontingent-Label), bei Gründer-Codes die offenen Plätze; CTA zur Partner-Statistik. Versand über das partner@-Konto, Fehler landen in failed_emails (context partner_thank_you). Env-Fund dabei: SMTP_PASS fehlte in BEIDEN .env (nur SMTP_SUPPORT_PASS da) — Partner-Konto-Versand wäre fehlgeschlagen; auf der DS ergänzt. Test: Mail-Capture per monkeypatch, prüft Statistik + Sticker-Nr + Einmaligkeit. Suite grün. --- VERSION | 2 +- backend/routes/auth.py | 82 +++++++++++++++++++++++++++++++++++++ backend/static/index.html | 24 +++++------ backend/static/js/app.js | 2 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_partner_qr.py | 46 +++++++++++++++++++++ 7 files changed, 144 insertions(+), 16 deletions(-) diff --git a/VERSION b/VERSION index 6ee69cb..50a143c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1259 \ No newline at end of file +1260 \ No newline at end of file diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 718d6cd..c0e7e5a 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -397,6 +397,85 @@ async def me(user=Depends(get_current_user)): return data +def _notify_partner_registration(user_id: int): + """Dank-Mail an den Partner (Code-Besitzer), wenn ein Geworbener seine + E-Mail bestätigt hat — inkl. kleiner Statistik. Best effort.""" + import html as _html + with db() as conn: + u = conn.execute( + "SELECT referred_by, referred_qr FROM users WHERE id=?", (user_id,) + ).fetchone() + if not u or (u["referred_by"] or 0) >= 0: + return # kein Partner-Code im Spiel + code_id = -u["referred_by"] + pc = conn.execute( + """SELECT pc.code, pc.label, pc.grants_founder, pc.owner_user_id, + o.name AS owner_name, o.email AS owner_email + FROM partner_codes pc + LEFT JOIN users o ON o.id = pc.owner_user_id + WHERE pc.id=?""", + (code_id,) + ).fetchone() + if not pc or not pc["owner_email"]: + return # Code ohne Besitzer → niemand zu benachrichtigen + total = conn.execute( + "SELECT COUNT(*) FROM users WHERE referred_by=? AND email_verified=1", + (-code_id,) + ).fetchone()[0] + month = conn.execute( + """SELECT COUNT(*) FROM users + WHERE referred_by=? AND email_verified=1 + AND strftime('%Y-%m', created_at) = strftime('%Y-%m', 'now')""", + (-code_id,) + ).fetchone()[0] + qr_line = "" + if u["referred_qr"]: + qr = conn.execute( + """SELECT q.seq, b.label FROM partner_qr_codes q + JOIN partner_qr_batches b ON b.id = q.batch_id + WHERE q.token=?""", + (u["referred_qr"],) + ).fetchone() + if qr: + qr_line = f"Gekommen über deinen gedruckten QR-Code #{qr['seq']} (Kontingent „{qr['label']}“)." + founder_line = "" + if pc["grants_founder"]: + founders = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + founder_line = f"Noch {max(0, 100 - founders)} von 100 Gründer-Plätzen frei." + + subject = "🐾 Danke! Neue Registrierung über deinen Partner-Code" + _oname = _html.escape(pc["owner_name"] or "Partner") + stats_html = ( + f"

Deine Bilanz mit dem Code {pc['code']}:
" + f"{total} bestätigte Registrierung{'en' if total != 1 else ''} insgesamt · " + f"{month} in diesem Monat.

" + ) + body_html = f""" +

Hallo {_oname},

+

+ gerade hat ein neuer Hundefreund seine Registrierung über deinen + Partner-Code bestätigt — danke, dass du Ban Yaro weiterträgst! 🎉 +

+ {f'

{_html.escape(qr_line)}

' if qr_line else ''} + {stats_html} + {f'

{_html.escape(founder_line)}

' if founder_line else ''}""" + plain = (f"Hallo {pc['owner_name'] or 'Partner'},\n\n" + f"gerade hat ein neuer Hundefreund seine Registrierung über deinen Partner-Code bestätigt — danke!\n" + + (f"\n{qr_line}\n" if qr_line else "") + + f"\nDeine Bilanz mit dem Code {pc['code']}: {total} bestätigte Registrierungen insgesamt, {month} in diesem Monat.\n" + + (f"{founder_line}\n" if founder_line else "") + + f"\nDeine Statistik: {_APP_URL}/#partner-profil\n") + try: + from routes.outreach import _send_smtp + from mailer import email_html + html = email_html(body_html, cta_url=f"{_APP_URL}/#partner-profil", cta_label="Meine Partner-Statistik") + _send_smtp(pc["owner_email"], subject, plain, "partner", html=html) + except Exception as exc: + _log_smtp_failure(pc["owner_email"], subject, plain, exc, context="partner_thank_you") + + @router.get("/verify-email/{token}") async def verify_email(token: str): with db() as conn: @@ -409,6 +488,9 @@ async def verify_email(token: str): "UPDATE users SET email_verified=1, verification_token=NULL WHERE id=?", (row["id"],) ) + # Dank-Mail an den Partner — nur beim ERSTEN Bestätigen (Link doppelt geklickt = kein Spam) + if not row["email_verified"]: + _notify_partner_registration(row["id"]) return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302) diff --git a/backend/static/index.html b/backend/static/index.html index 08c14dc..4ed0115 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f167267..573ad40 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 = '1259'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1260'; // ← 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/landing.html b/backend/static/landing.html index e989a93..c57180e 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index f513e8a..6057213 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1259'; +const VER = '1260'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_partner_qr.py b/tests/test_partner_qr.py index f02f1d9..84fa51e 100644 --- a/tests/test_partner_qr.py +++ b/tests/test_partner_qr.py @@ -123,6 +123,52 @@ def test_qr_token_must_match_code(client, admin): assert row["referred_qr"] is None +def test_partner_thank_you_mail(client, admin, user, monkeypatch): + """E-Mail-Bestaetigung eines Geworbenen -> Dank-Mail mit Statistik an den Code-Besitzer.""" + from database import db + with db() as conn: + conn.execute("UPDATE users SET is_partner=1 WHERE email=?", (user["email"],)) + uid = conn.execute("SELECT id FROM users WHERE email=?", (user["email"],)).fetchone()["id"] + + code = _create_code(client, admin) + batch = _create_batch(client, admin, code["id"], quantity=1) + token = _batch_tokens(batch["id"])[0] + client.post(f"/api/admin/partner/codes/{code['id']}/owner", + headers=admin["headers"], json={"user_id": uid}) + + sent = [] + import routes.outreach as outreach + monkeypatch.setattr(outreach, "_send_smtp", + lambda to, subject, body, account="partner", html=None: + sent.append({"to": to, "subject": subject, "body": body})) + + email = f"qrm-{secrets.token_hex(4)}@example.com" + r = client.post("/api/auth/register", json={ + "email": email, "password": "QrTest1234!", "name": f"qrm{secrets.token_hex(3)}", + "ref_code": code["code"], "qr_token": token, + }) + assert r.status_code == 200, r.text + with db() as conn: + vtoken = conn.execute( + "SELECT verification_token FROM users WHERE email=?", (email,) + ).fetchone()["verification_token"] + + sent.clear() # Verifikations-Mail an den Neuen ignorieren + r = client.get(f"/api/auth/verify-email/{vtoken}", follow_redirects=False) + assert r.status_code == 302 + + thank = [m for m in sent if m["to"] == user["email"]] + assert len(thank) == 1, f"Dank-Mail fehlt: {sent}" + assert "Danke" in thank[0]["subject"] + assert "1 bestätigte Registrierung" in thank[0]["body"] # Statistik + assert "#1" in thank[0]["body"] # QR-Sticker-Herkunft + + # Doppelt verifizieren -> keine zweite Mail + sent.clear() + client.get(f"/api/auth/verify-email/{vtoken}", follow_redirects=False) + assert not [m for m in sent if m["to"] == user["email"]] + + def test_partner_self_service_qr(client, admin, user): """Code-Besitzer sieht eigene Kontingente + kann PDF laden; Fremde nicht.""" from database import db From 0a262989f35ff7e3a30dfe4389ae930ab374d372 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 19:06:51 +0200 Subject: [PATCH 11/15] =?UTF-8?q?Feature:=20Partner-Dashboard=20(#partner-?= =?UTF-8?q?dashboard)=20=E2=80=94=20operative=20Daten=20raus=20aus=20dem?= =?UTF-8?q?=20Profil-Editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rene: QR-Stats gehören nicht ins öffentliche Profil, eigene Seite fehlte. Neue Seite 'Partner-Bereich' (Welten-Chip 🤝 zwischen Moderation und Admin, role:partner — sichtbar für is_partner + Admin; _mergeDefaults reicht den Chip an bestehende Welt-Configs nach): - Einladungscode groß + Link-kopieren-Button - Kacheln: Registrierungen gesamt / diesen Monat / unbestätigt - QR-Kontingente mit Einzel-Code-Status (aus partner-profil.js hierher verschoben) - Profil-Status-Karte (Entwurf/Prüfung/frei) mit Sprung zum Editor Backend: GET /partner/my-stats (Codes mit Zahlen + Profil-Status). Settings-Partner-Karte: zwei Buttons (Partner-Bereich primär, Profil sekundär); Dank-Mail-CTA zeigt auf #partner-dashboard. Suite: 52 passed. --- VERSION | 2 +- backend/routes/auth.py | 4 +- backend/routes/partner.py | 31 +++ backend/static/index.html | 24 +-- backend/static/js/app.js | 3 +- backend/static/js/pages/partner-dashboard.js | 212 +++++++++++++++++++ backend/static/js/pages/partner-profil.js | 75 +------ backend/static/js/pages/settings.js | 15 +- backend/static/js/worlds.js | 4 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_partner_qr.py | 9 + 12 files changed, 287 insertions(+), 96 deletions(-) create mode 100644 backend/static/js/pages/partner-dashboard.js diff --git a/VERSION b/VERSION index 50a143c..15448f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1260 \ No newline at end of file +1261 \ No newline at end of file diff --git a/backend/routes/auth.py b/backend/routes/auth.py index c0e7e5a..db5683d 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -466,11 +466,11 @@ def _notify_partner_registration(user_id: int): + (f"\n{qr_line}\n" if qr_line else "") + f"\nDeine Bilanz mit dem Code {pc['code']}: {total} bestätigte Registrierungen insgesamt, {month} in diesem Monat.\n" + (f"{founder_line}\n" if founder_line else "") - + f"\nDeine Statistik: {_APP_URL}/#partner-profil\n") + + f"\nDein Partner-Bereich: {_APP_URL}/#partner-dashboard\n") try: from routes.outreach import _send_smtp from mailer import email_html - html = email_html(body_html, cta_url=f"{_APP_URL}/#partner-profil", cta_label="Meine Partner-Statistik") + html = email_html(body_html, cta_url=f"{_APP_URL}/#partner-dashboard", cta_label="Mein Partner-Bereich") _send_smtp(pc["owner_email"], subject, plain, "partner", html=html) except Exception as exc: _log_smtp_failure(pc["owner_email"], subject, plain, exc, context="partner_thank_you") diff --git a/backend/routes/partner.py b/backend/routes/partner.py index 23f0bcd..f7a61df 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -750,6 +750,37 @@ def qr_batch_pdf_admin(batch_id: int, user=Depends(require_admin)): headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'}) +@router.get("/partner/my-stats") +def my_partner_stats(user=Depends(require_partner)): + """Dashboard-Zahlen für den Partner: eigene Codes mit Registrierungen/Versuchen + + Status des öffentlichen Profils.""" + with db() as conn: + codes = conn.execute( + """SELECT pc.id, pc.code, pc.label, pc.uses, pc.grants_founder, + (SELECT COUNT(*) FROM users u + WHERE u.referred_by = -pc.id AND u.email_verified = 1) AS registrations, + (SELECT COUNT(*) FROM users u + WHERE u.referred_by = -pc.id AND u.email_verified = 0) AS attempts, + (SELECT COUNT(*) FROM users u + WHERE u.referred_by = -pc.id AND u.email_verified = 1 + AND strftime('%Y-%m', u.created_at) = strftime('%Y-%m', 'now')) AS registrations_month + FROM partner_codes pc + WHERE pc.owner_user_id = ? + ORDER BY pc.created_at""", + (user["id"],) + ).fetchall() + profile = _pp_get_or_empty(conn, user["id"]) + return { + "codes": [dict(c) for c in codes], + "profile": { + "exists": bool(profile), + "approved": profile.get("approved", 0), + "submitted_at": profile.get("submitted_at"), + "display_name": profile.get("display_name"), + }, + } + + @router.get("/partner/my-qr") def my_qr_batches(user=Depends(require_partner)): """Übergabe/Self-Service: eigene Kontingente mit Stats (Code-Besitzer).""" diff --git a/backend/static/index.html b/backend/static/index.html index 4ed0115..af4939e 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 573ad40..96cee9d 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 = '1260'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1261'; // ← 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; @@ -81,6 +81,7 @@ const App = (() => { gruender: { title: '100 Gründer', module: null }, partner: { title: 'Unsere Partner', module: null }, 'partner-profil': { title: 'Partner-Profil', module: null, requiresAuth: true }, + 'partner-dashboard': { title: 'Partner-Bereich', module: null, requiresAuth: true }, jobs: { title: 'Wir suchen dich', module: null }, expenses: { title: 'Ausgaben', module: null, requiresAuth: true }, recalls: { title: 'Rückrufe', module: null }, diff --git a/backend/static/js/pages/partner-dashboard.js b/backend/static/js/pages/partner-dashboard.js new file mode 100644 index 0000000..22e21aa --- /dev/null +++ b/backend/static/js/pages/partner-dashboard.js @@ -0,0 +1,212 @@ +/* ============================================================ + BAN YARO — Partner-Dashboard + Operative Daten für Partner: Code + Einladungslink, Statistik, + QR-Kontingente mit Einzel-Code-Status, Profil-Status. + (Die öffentliche Präsenz wird in partner-profil.js gepflegt.) + ============================================================ */ + +window.Page_partner_dashboard = (() => { + + let _container = null; + let _stats = null; + let _qrBatches = []; + + async function init(container) { + _container = container; + _render(); + await _load(); + } + + function refresh() { _load(); } + function onDogChange() {} + + function _render() { + _container.innerHTML = ` +
+
+

+ ${UI.icon('handshake')} Partner-Bereich +

+

+ Dein Code, deine Zahlen, deine QR-Kontingente. +

+
+
+
Lade…
+
+
+ `; + } + + async function _load() { + const el = _container.querySelector('#pd-content'); + try { + _stats = await API.get('/partner/my-stats'); + _qrBatches = (await API.get('/partner/my-qr').catch(() => [])) || []; + el.innerHTML = _renderDashboard(); + _bindEvents(el); + } catch (e) { + el.innerHTML = `

${UI.escape(e.message || 'Fehler beim Laden.')}

`; + } + } + + function _renderDashboard() { + const codes = _stats?.codes || []; + return ` + ${codes.length === 0 ? ` +
+

+ Dir ist noch kein Partner-Code zugeordnet.
+ Melde dich bei partner@banyaro.app — wir richten ihn ein. +

+
` : codes.map(c => _renderCodeCard(c)).join('')} + + ${_renderQrSection()} + ${_renderProfileCard()} + `; + } + + function _renderCodeCard(c) { + const link = `https://banyaro.app/?ref=${encodeURIComponent(c.code)}`; + return ` +
+
Dein Einladungscode
+
+ ${UI.escape(c.code)} + +
+
+
+
${c.registrations}
+
Registrierungen
+
+
+
${c.registrations_month}
+
diesen Monat
+
+
+
${c.attempts}
+
unbestätigt
+
+
+
`; + } + + function _renderQrSection() { + if (!_qrBatches.length) return ''; + return ` +
+
Meine QR-Codes
+

+ Deine gedruckten QR-Codes (Sticker, Flyer). Jeder Scan und jede Registrierung + darüber wird gezählt — so siehst du, was wo funktioniert. +

+ ${_qrBatches.map(b => ` +
+
+
+
${UI.escape(b.label)}
+
+ ${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)} · + ${b.codes_used}/${b.quantity} verbraucht +
+
+
+
${b.scans}
+
Scans
+
+
+
${b.registrations}${b.attempts ? ` +${b.attempts}` : ''}
+
Registr.
+
+ + + ${UI.icon('file-pdf')} PDF + +
+ +
`).join('')} +
`; + } + + function _renderProfileCard() { + const p = _stats?.profile || {}; + let badge; + if (p.approved === 1) badge = `✓ Öffentlich sichtbar`; + else if (p.approved === -1) badge = `✗ Abgelehnt`; + else if (p.submitted_at) badge = `⏳ In Prüfung`; + else if (p.exists) badge = `Entwurf`; + else badge = `Noch nicht angelegt`; + return ` +
+
+
+
Öffentliches Profil
+ ${badge} +
+ +
+
`; + } + + function _bindEvents(el) { + // Einladungslink kopieren + el.querySelectorAll('.pd-copy').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(btn.dataset.link); + UI.toast.success('Einladungslink kopiert.'); + } catch { + UI.toast.info(btn.dataset.link); + } + }); + }); + + // Einzel-Code-Status (lazy, .hidden via classList) + el.querySelectorAll('.pd-qr-codes-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const box = el.querySelector(`#pd-qr-codes-${btn.dataset.id}`); + if (!box) return; + box.classList.toggle('hidden'); + if (box.classList.contains('hidden') || box.dataset.loaded === '1') return; + try { + const codes = await API.get(`/partner/my-qr/${btn.dataset.id}/codes`); + box.dataset.loaded = '1'; + box.innerHTML = codes.map(c => { + const used = c.registrations > 0; + const scanned = c.scans > 0; + return ` +
+ #${c.seq} + banyaro.app/q/${UI.escape(c.token)} + ${c.scans} Scan${c.scans === 1 ? '' : 's'} + ${used + ? `● verbraucht` + : scanned + ? `◐ gescannt` + : `○ frei`} +
`; + }).join(''); + } catch (err) { UI.toast.error(err.message); } + }); + }); + + el.querySelector('#pd-edit-profile')?.addEventListener('click', () => App.navigate('partner-profil')); + } + + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/partner-profil.js b/backend/static/js/pages/partner-profil.js index da834ec..a2bf015 100644 --- a/backend/static/js/pages/partner-profil.js +++ b/backend/static/js/pages/partner-profil.js @@ -27,6 +27,8 @@ window.Page_partner_profil = (() => {

Richte deine öffentliche Präsenz auf der Partner-Seite ein. Nach dem Absenden prüfen wir dein Profil und schalten es frei. + Deine Zahlen und QR-Codes findest du im + Partner-Bereich.

@@ -36,8 +38,6 @@ window.Page_partner_profil = (() => { `; } - let _qrBatches = []; - async function _load() { const el = _container.querySelector('#pp-content'); try { @@ -45,7 +45,6 @@ window.Page_partner_profil = (() => { _profile = d.profile || {}; _profile._storage_mb = d.storage_mb || 0; _profile._storage_limit_mb = d.storage_limit_mb || 200; - _qrBatches = (await API.get('/partner/my-qr').catch(() => [])) || []; el.innerHTML = _renderEditor(); _bindEvents(el); } catch (e) { @@ -181,47 +180,6 @@ window.Page_partner_profil = (() => {
- ${_qrBatches.length ? ` - -
-
Meine QR-Codes
-

- Deine gedruckten QR-Codes (Sticker, Flyer). Jeder Scan und jede Registrierung - darüber wird gezählt — so siehst du, was wo funktioniert. -

- ${_qrBatches.map(b => ` -
-
-
-
${UI.escape(b.label)}
-
- ${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)} · - ${b.codes_used}/${b.quantity} verbraucht -
-
-
-
${b.scans}
-
Scans
-
-
-
${b.registrations}${b.attempts ? ` +${b.attempts}` : ''}
-
Registr.
-
- - - ${UI.icon('file-pdf')} PDF - -
- -
`).join('')} -
` : ''} -
+
+ + +
` : ''} @@ -1676,6 +1681,8 @@ window.Page_settings = (() => { _loadReferral(); _loadBreederCard(); + document.getElementById('settings-partner-dashboard-btn') + ?.addEventListener('click', () => App.navigate('partner-dashboard')); document.getElementById('settings-partner-profile-btn') ?.addEventListener('click', () => App.navigate('partner-profil')); } diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 91fe314..e54f867 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -578,6 +578,7 @@ window.Worlds = (() => { { icon:'sparkle', label:'Social', page:'social', role:'social', fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] }, { icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' }, + { icon:'handshake', label:'Partner', page:'partner-dashboard', role:'partner' }, { icon:'gear', label:'Admin', page:'admin', role:'admin' }, // ── NEUE FEATURES ──────────────────────────────────────────── { icon:'fork-knife', label:'Ernährung', page:'ernaehrung', pro: true, @@ -587,7 +588,7 @@ window.Worlds = (() => { ]; const _DEFAULT_CONFIG = { - jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','admin'], + jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','partner-dashboard','admin'], hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse', 'litters','zuchthunde','laeufi','ernaehrung','personality'], welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events', @@ -681,6 +682,7 @@ window.Worlds = (() => { } if (chip.role === 'social') return u?.is_social_media || u?.rolle === 'admin'; if (chip.role === 'mod') return u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator; + if (chip.role === 'partner') return !!u?.is_partner || u?.rolle === 'admin'; if (chip.role === 'admin') return u?.rolle === 'admin'; return true; } diff --git a/backend/static/landing.html b/backend/static/landing.html index c57180e..66378b4 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 6057213..29d104a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1260'; +const VER = '1261'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_partner_qr.py b/tests/test_partner_qr.py index 84fa51e..fe3eed2 100644 --- a/tests/test_partner_qr.py +++ b/tests/test_partner_qr.py @@ -215,3 +215,12 @@ def test_partner_self_service_qr(client, admin, user): assert first["first_registration_at"] r = client.get("/api/partner/my-qr", headers=user["headers"]) assert r.json()[0]["codes_used"] == 1 + + # Dashboard-Stats: eigener Code mit Zahlen + Profil-Status + r = client.get("/api/partner/my-stats", headers=user["headers"]) + assert r.status_code == 200 + d = r.json() + mycode = [c for c in d["codes"] if c["id"] == code["id"]][0] + assert mycode["registrations"] == 1 + assert mycode["registrations_month"] == 1 + assert "approved" in d["profile"] From fe783ef01b6c4cf80c3f28119a9df6e5650f657f Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 19:10:44 +0200 Subject: [PATCH 12/15] =?UTF-8?q?Fix:=20page-Section=20f=C3=BCr=20partner-?= =?UTF-8?q?dashboard=20in=20index.html=20=E2=80=94=20Loader=20fand=20keine?= =?UTF-8?q?n=20Container=20(wei=C3=9Fe=20Seite)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- backend/static/index.html | 28 ++++++++++++++++------------ backend/static/js/app.js | 2 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/VERSION b/VERSION index 15448f1..078cc70 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1261 \ No newline at end of file +1262 \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index af4939e..2c7dc4a 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -507,6 +507,10 @@
+
+
+
+
@@ -612,11 +616,11 @@ - - - - - + + + + + @@ -626,7 +630,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 96cee9d..3111562 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 = '1261'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1262'; // ← 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/landing.html b/backend/static/landing.html index 66378b4..827a807 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 29d104a..583b4f9 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1261'; +const VER = '1262'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 0cca716c3dd29c92b3363c455bd01ed450f434c7 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 19:18:46 +0200 Subject: [PATCH 13/15] =?UTF-8?q?Partner-Dashboard:=20Z=C3=A4hler=20selbst?= =?UTF-8?q?erkl=C3=A4rend=20=E2=80=94=20'X=20von=20Y=20Codes=20erfolgreich?= =?UTF-8?q?'=20statt=20'verbraucht',=20'+N=20unbest.'=20ausgeschrieben,=20?= =?UTF-8?q?Einzel-Code-Badge=20zeigt=20Registrierungszahl=20(ein=20Code=20?= =?UTF-8?q?kann=20mehrere=20Accounts=20bringen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- backend/static/index.html | 24 ++++++++++---------- backend/static/js/app.js | 2 +- backend/static/js/pages/partner-dashboard.js | 7 +++--- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/VERSION b/VERSION index 078cc70..458361b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1262 \ No newline at end of file +1263 \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index 2c7dc4a..1d236b4 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -616,11 +616,11 @@ - - - - - + + + + + @@ -630,7 +630,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3111562..79b63fd 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 = '1262'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1263'; // ← 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/partner-dashboard.js b/backend/static/js/pages/partner-dashboard.js index 22e21aa..24c875f 100644 --- a/backend/static/js/pages/partner-dashboard.js +++ b/backend/static/js/pages/partner-dashboard.js @@ -113,7 +113,8 @@ window.Page_partner_dashboard = (() => {
${UI.escape(b.label)}
${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)} · - ${b.codes_used}/${b.quantity} verbraucht + ${b.codes_used} von ${b.quantity} Codes erfolgreich
@@ -122,7 +123,7 @@ window.Page_partner_dashboard = (() => {
-
${b.registrations}${b.attempts ? ` +${b.attempts}` : ''}
+
${b.registrations}${b.attempts ? ` +${b.attempts} unbest.` : ''}
Registr.
-
+
${c.registrations}
Registrierungen
@@ -87,11 +87,9 @@ window.Page_partner_dashboard = (() => {
${c.registrations_month}
diesen Monat
-
-
${c.attempts}
-
unbestätigt
-
+
+
+ Zählt alle Wege: geteilter Link, eingetippter Code und deine gedruckten QR-Codes.
`; } @@ -103,28 +101,19 @@ window.Page_partner_dashboard = (() => {
Meine QR-Codes

- Deine gedruckten QR-Codes (Sticker, Flyer). Jeder Scan und jede Registrierung - darüber wird gezählt — so siehst du, was wo funktioniert. + Deine gedruckten QR-Codes (Sticker, Flyer) — und wie viele davon schon + neue Hundefreunde gebracht haben.

${_qrBatches.map(b => `
${UI.escape(b.label)}
-
- ${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)} · - ${b.codes_used} von ${b.quantity} Codes erfolgreich -
+
${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)}
-
-
${b.scans}
-
Scans
-
-
-
${b.registrations}${b.attempts ? ` +${b.attempts} unbest.` : ''}
-
Registr.
+
+
${b.codes_used} von ${b.quantity}
+
genutzt
- - + +
{ ${codes.map(c => ` - + ${c.code} + ${c.active ? '' : `
⏸ pausiert
`} ${c.label} @@ -2379,7 +2380,12 @@ window.Page_admin = (() => { ${c.grants_founder ? '✓' : '—'} - + +
`; + // Code pausieren/aktivieren (Notbremse bei geleakten Codes) + el.querySelectorAll('.adm-toggle-code').forEach(btn => { + btn.addEventListener('click', async () => { + try { + const r = await API.post(`/admin/partner/codes/${btn.dataset.id}/toggle`, {}); + UI.toast.success(r.active ? 'Code wieder aktiv.' : 'Code pausiert — Einlösungen sind gesperrt.'); + await _renderPartner(el); + } catch (err) { UI.toast.error(err.message); } + }); + }); + // Code-Besitzer zuordnen (Self-Service-QR-Zugriff für den Partner) el.querySelectorAll('.adm-code-owner').forEach(btn => { btn.addEventListener('click', async () => { diff --git a/backend/static/landing.html b/backend/static/landing.html index 9f3741a..88ec792 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index bd8c2b2..3059fa7 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1264'; +const VER = '1265'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_partner_qr.py b/tests/test_partner_qr.py index fe3eed2..73c2994 100644 --- a/tests/test_partner_qr.py +++ b/tests/test_partner_qr.py @@ -50,7 +50,9 @@ def test_scan_redirects_and_counts(client, admin): r = client.get(f"/q/{token}", follow_redirects=False) assert r.status_code == 302 - assert r.headers["location"] == f"/?ref={code['code']}&qr={token}" + # Bewusst KEIN Klartext-Code in der URL — sonst liest jeder Scanner den Code ab + assert r.headers["location"] == f"/?qr={token}" + assert code["code"] not in r.headers["location"] client.get(f"/q/{token}", follow_redirects=False) r = client.get("/api/admin/partner/qr-batches", headers=admin["headers"]) @@ -104,6 +106,52 @@ def test_registration_attributed_to_qr(client, admin): assert regs[0]["created_at"] +def test_registration_with_qr_only(client, admin): + """Registrierung NUR mit qr_token (ohne ref_code) -> Code wird server-seitig aufgeloest.""" + code = _create_code(client, admin) + batch = _create_batch(client, admin, code["id"], quantity=1) + token = _batch_tokens(batch["id"])[0] + + email = f"qro-{secrets.token_hex(4)}@example.com" + r = client.post("/api/auth/register", json={ + "email": email, "password": "QrTest1234!", "name": f"qro{secrets.token_hex(3)}", + "qr_token": token, + }) + assert r.status_code == 200, r.text + from database import db + with db() as conn: + row = conn.execute("SELECT referred_by, referred_qr FROM users WHERE email=?", (email,)).fetchone() + assert row["referred_by"] == -code["id"] + assert row["referred_qr"] == token + + +def test_paused_code_not_redeemable(client, admin): + """Pausierter Code (Notbremse) -> keine Einloesung, Info-Endpoint 404; reaktivierbar.""" + code = _create_code(client, admin) + r = client.post(f"/api/admin/partner/codes/{code['id']}/toggle", headers=admin["headers"]) + assert r.status_code == 200 and r.json()["active"] == 0 + + # Info-Endpoint: wie nicht existent + assert client.get(f"/api/partner/codes/{code['code']}/info").status_code == 404 + + # Registrierung mit pausiertem Code -> keine Zuordnung + email = f"qrp-{secrets.token_hex(4)}@example.com" + r = client.post("/api/auth/register", json={ + "email": email, "password": "QrTest1234!", "name": f"qrp{secrets.token_hex(3)}", + "ref_code": code["code"], + }) + assert r.status_code == 200, r.text + from database import db + with db() as conn: + row = conn.execute("SELECT referred_by FROM users WHERE email=?", (email,)).fetchone() + assert row["referred_by"] is None + + # Reaktivieren funktioniert + r = client.post(f"/api/admin/partner/codes/{code['id']}/toggle", headers=admin["headers"]) + assert r.json()["active"] == 1 + assert client.get(f"/api/partner/codes/{code['code']}/info").status_code == 200 + + def test_qr_token_must_match_code(client, admin): """QR-Token eines FREMDEN Codes wird nicht zugeordnet (Manipulationsschutz).""" code_a = _create_code(client, admin)