diff --git a/VERSION b/VERSION index 03ce6df..3420149 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1252 \ No newline at end of file +1265 \ 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..dba33c8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -623,6 +623,12 @@ 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"), + # Notbremse für geleakte Codes: 0 = pausiert (Einlösung gesperrt, Historie bleibt) + ("partner_codes", "active", "INTEGER NOT NULL DEFAULT 1"), # Passwort-Zurücksetzen ("users", "password_reset_token", "TEXT"), ("users", "password_reset_expires", "TEXT"), @@ -1630,6 +1636,103 @@ 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: + # 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, + 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}") + + # 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..4f35a98 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2156,6 +2156,34 @@ 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 token FROM partner_qr_codes WHERE 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,) + ) + # Bewusst NUR der Token in der URL — der tippbare Partner-Code bleibt verborgen + # (sonst könnte jeder Sticker-Scanner den Code ablesen und beliebig weitergeben). + # Die Registrierung löst den Code server-seitig aus dem Token auf. + return _Redirect(f"/?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/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/routes/auth.py b/backend/routes/auth.py index 18b092b..403eae8 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: @@ -204,11 +205,26 @@ async def register(data: RegisterRequest, response: Response, request: Request): ).fetchone() new_user_id = user["id"] - if data.ref_code: - code_upper = data.ref_code.strip().upper() - # Zuerst prüfen ob es ein Partner-Code ist + # QR-only-Flow: Die Scan-URL trägt bewusst KEINEN Klartext-Code mehr — + # der Partner-Code wird hier server-seitig aus dem QR-Token aufgelöst. + ref_code_in = data.ref_code + if not ref_code_in and data.qr_token: + qr_row = conn.execute( + """SELECT 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=?""", + (data.qr_token.strip(),) + ).fetchone() + if qr_row: + ref_code_in = qr_row["code"] + + if ref_code_in: + code_upper = ref_code_in.strip().upper() + # Zuerst prüfen ob es ein Partner-Code ist (active=0 = Notbremse bei + # geleakten Codes: wird wie nicht existent behandelt, Historie bleibt) partner = conn.execute( - "SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=?", + "SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=? AND active=1", (code_upper,) ).fetchone() if partner: @@ -227,6 +243,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" @@ -386,6 +412,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.
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"\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-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") + + @router.get("/verify-email/{token}") async def verify_email(token: str): with db() as conn: @@ -398,6 +503,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/routes/partner.py b/backend/routes/partner.py index 16caafb..115517b 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() @@ -31,15 +37,32 @@ 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, pc.active, + 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] +@router.post("/admin/partner/codes/{code_id}/toggle") +def toggle_partner_code(code_id: int, user=Depends(require_admin)): + """Notbremse: Code pausieren/reaktivieren (z. B. wenn er im Internet kursiert). + Pausiert = Einlösung gesperrt, Stats und QR-Kontingente bleiben erhalten.""" + with db() as conn: + row = conn.execute( + "SELECT active FROM partner_codes WHERE id=?", (code_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Partner-Code nicht gefunden.") + new_state = 0 if row["active"] else 1 + conn.execute("UPDATE partner_codes SET active=? WHERE id=?", (new_state, code_id)) + return {"active": new_state} + + @router.post("/admin/partner/codes", status_code=201) def create_partner_code(data: PartnerCodeCreate, user=Depends(require_admin)): """Neuen Partner-Code erstellen (admin only).""" @@ -189,7 +212,7 @@ def partner_code_info(code: str): with db() as conn: row = conn.execute( """SELECT code, label, grants_founder, max_uses, uses - FROM partner_codes WHERE code=?""", + FROM partner_codes WHERE code=? AND active=1""", (code.strip().upper(),) ).fetchone() if not row: @@ -206,3 +229,639 @@ 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) + + 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(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) + + 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)) + 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] + 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 — 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, 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} + + +@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} + + +# ------------------------------------------------------------------ +# 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: + """Registrierungen = E-Mail bestätigt; Versuche = registriert, aber (noch) unbestätigt.""" + 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 = ? 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, + (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) + ).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}/registrations") +def qr_batch_registrations(batch_id: int, user=Depends(require_admin)): + """Accounts, die über dieses Kontingent kamen — inkl. unbestätigter Versuche. + Admin-only (personenbezogene Daten).""" + 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.") + rows = conn.execute( + """SELECT u.id, u.name, u.email, u.email_verified, u.created_at, + q.seq, q.token + FROM users u + JOIN partner_qr_codes q ON q.token = u.referred_qr + WHERE q.batch_id = ? + ORDER BY u.created_at DESC""", + (batch_id,) + ).fetchall() + return [dict(r) for r in rows] + + +@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-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).""" + with db() as conn: + return _qr_list_batches( + conn, "WHERE pc.owner_user_id = ?", (user["id"],) + ) + + +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: + _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"'}) + + +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 5a330e3..4d30aec 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@${c.code}
+ ${c.active ? '' : `