diff --git a/VERSION b/VERSION index 3420149..03ce6df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1265 \ No newline at end of file +1252 \ No newline at end of file diff --git a/backend/auth.py b/backend/auth.py index f5cabd7..1b5f126 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -265,8 +265,6 @@ 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 dba33c8..457f5a3 100644 --- a/backend/database.py +++ b/backend/database.py @@ -623,12 +623,6 @@ 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"), @@ -1636,103 +1630,6 @@ 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 4f35a98..b473156 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2156,34 +2156,6 @@ 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 6508529..d45b6f8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,6 +15,5 @@ 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 715addd..e709b53 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -152,12 +152,6 @@ 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, @@ -167,7 +161,6 @@ 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 403eae8..18b092b 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -153,7 +153,6 @@ 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: @@ -205,26 +204,11 @@ async def register(data: RegisterRequest, response: Response, request: Request): ).fetchone() new_user_id = user["id"] - # 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) + if data.ref_code: + code_upper = data.ref_code.strip().upper() + # Zuerst prüfen ob es ein Partner-Code ist partner = conn.execute( - "SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=? AND active=1", + "SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=?", (code_upper,) ).fetchone() if partner: @@ -243,16 +227,6 @@ 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" @@ -412,85 +386,6 @@ 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"\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: @@ -503,9 +398,6 @@ 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 115517b..16caafb 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -1,16 +1,10 @@ -"""BAN YARO — Partner-Codes + Gründer-Lizenz + Partner-Profile (Showcase)""" +"""BAN YARO — Partner-Codes + Gründer-Lizenz""" -import asyncio -import json -import os -import uuid from typing import Optional -from fastapi import APIRouter, HTTPException, Depends, UploadFile, File +from fastapi import APIRouter, HTTPException, Depends 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() @@ -37,32 +31,15 @@ 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, pc.owner_user_id, pc.active, - u.name AS created_by_name, - o.name AS owner_name + pc.max_uses, pc.uses, pc.created_at, + u.name AS created_by_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).""" @@ -212,7 +189,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=? AND active=1""", + FROM partner_codes WHERE code=?""", (code.strip().upper(),) ).fetchone() if not row: @@ -229,639 +206,3 @@ 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 4d30aec..5a330e3 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -507,10 +507,6 @@
-
-
-
-
@@ -616,11 +612,11 @@ - - - - - + + + + + @@ -630,7 +626,7 @@ - + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index b5022e2..7fd420e 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -114,10 +114,9 @@ const API = (() => { login(email, password) { return post('/auth/login', { email, password }); }, - register(email, password, name, ref_code, qr_token) { + register(email, password, name, ref_code) { 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 1903e4a..9a5e14c 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 = '1265'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1252'; // ← 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,7 +81,6 @@ 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 }, @@ -105,7 +104,6 @@ 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); } @@ -1141,16 +1139,10 @@ 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 || qrToken) { + if (refCode) { try { - if (refCode) { - localStorage.setItem('by_ref_code', refCode.toUpperCase()); - localStorage.setItem('by_ref_code_ts', String(Date.now())); - } - // Partner-QR-Token (Sticker/Flyer): kommt bewusst OHNE Klartext-Code — - // die Registrierung löst den Partner-Code server-seitig aus dem Token auf - if (qrToken) localStorage.setItem('by_qr_token', qrToken); + localStorage.setItem('by_ref_code', refCode.toUpperCase()); + localStorage.setItem('by_ref_code_ts', String(Date.now())); } 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 8165c64..09e1624 100644 --- a/backend/static/js/boot.js +++ b/backend/static/js/boot.js @@ -17,9 +17,6 @@ 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 b07b51f..294b532 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -123,7 +123,6 @@ 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); @@ -2290,9 +2289,7 @@ 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 qrBatches = (await API.get('/admin/partner/qr-batches').catch(() => [])) || []; + const codes = (await API.get('/admin/partner/codes')) || []; el.innerHTML = `
@@ -2326,8 +2323,8 @@ window.Page_admin = (() => {
- - + +
{ ${codes.map(c => ` - + ${c.code} - ${c.active ? '' : `
⏸ pausiert
`} - - - ${c.label} -
- ${c.owner_name - ? `👤 ${UI.escape(c.owner_name)}` - : ``} -
+ ${c.label} ${c.uses}${c.max_uses ? `/${c.max_uses}` : ''} ${c.grants_founder ? '✓' : '—'} - - +
- -
-

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.Versuche
${UI.escape(b.code)}${UI.escape(b.label)}
${(b.created_at || '').slice(0, 10)}
${b.quantity}${b.scans}${b.registrations}${b.attempts} - ${b.registrations + b.attempts > 0 ? ` - ` : ''} - - ${UI.icon('file-pdf')} PDF - - -
`} -
- - -
-

- 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

@@ -2571,113 +2414,6 @@ window.Page_admin = (() => {
`; - // 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 () => { - 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-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 () => { - 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', () => { - 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 () => { - 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/partner-dashboard.js b/backend/static/js/pages/partner-dashboard.js deleted file mode 100644 index 3ab07c9..0000000 --- a/backend/static/js/pages/partner-dashboard.js +++ /dev/null @@ -1,198 +0,0 @@ -/* ============================================================ - 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
-
-
-
- Zählt alle Wege: geteilter Link, eingetippter Code und deine gedruckten QR-Codes. -
-
`; - } - - function _renderQrSection() { - if (!_qrBatches.length) return ''; - return ` -
-
Meine QR-Codes
-

- 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}
-
genutzt
-
- - - ${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; - return ` -
- #${c.seq} - banyaro.app/q/${UI.escape(c.token)} - ${used - ? `● genutzt${c.registrations > 1 ? ` (${c.registrations}×)` : ''}` - : `○ 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 a2bf015..aafd6a5 100644 --- a/backend/static/js/pages/partner-profil.js +++ b/backend/static/js/pages/partner-profil.js @@ -27,8 +27,6 @@ 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.

diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 66d9af0..20d857e 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -665,26 +665,6 @@ 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. Deine Zahlen und QR-Codes findest du im Partner-Bereich. -

-
- - -
-
-
` : ''} -
Trophäen
@@ -1680,11 +1660,6 @@ 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')); } // ---------------------------------------------------------- @@ -2620,11 +2595,8 @@ window.Page_settings = (() => { const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined; const refCode = _storedRefCode(); const finalCode = partnerCode || refCode || undefined; - // QR-Token mitschicken — Backend ordnet ihn nur zu, wenn er zum Code passt - const qrToken = (() => { try { return localStorage.getItem('by_qr_token') || undefined; } catch { return undefined; } })(); - const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode, qrToken); + const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode); if (refCode) _clearRefCode(); - try { localStorage.removeItem('by_qr_token'); } catch {} if (result.pending_verification) { _renderVerifyPending(fd.email); diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index e54f867..91fe314 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -578,7 +578,6 @@ 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, @@ -588,7 +587,7 @@ window.Worlds = (() => { ]; const _DEFAULT_CONFIG = { - jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','partner-dashboard','admin'], + jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','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', @@ -682,7 +681,6 @@ 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 88ec792..f513edc 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 3059fa7..d3a5f27 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 = '1265'; +const VER = '1252'; 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 deleted file mode 100644 index f62d256..0000000 --- a/tests/test_partner_profile.py +++ /dev/null @@ -1,155 +0,0 @@ -"""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_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_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 - 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}) diff --git a/tests/test_partner_qr.py b/tests/test_partner_qr.py deleted file mode 100644 index 73c2994..0000000 --- a/tests/test_partner_qr.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Smoke-Tests fuer Partner-QR-Kontingente (Bestellung, Scan, Registrierungs-Rueckverfolgung).""" - -import secrets - - -def _create_code(client, admin, code=None): - code = code or f"QRTEST{secrets.token_hex(3).upper()}" - r = client.post("/api/admin/partner/codes", headers=admin["headers"], json={ - "code": code, "label": f"Testpartner {code}", "grants_founder": 0, - }) - assert r.status_code == 201, r.text - return r.json() - - -def _create_batch(client, admin, code_id, quantity=5): - r = client.post(f"/api/admin/partner/codes/{code_id}/qr-batches", - headers=admin["headers"], - json={"label": "Sticker Testlauf", "quantity": quantity}) - assert r.status_code == 201, r.text - return r.json() - - -def _batch_tokens(batch_id): - from database import db - with db() as conn: - return [r["token"] for r in conn.execute( - "SELECT token FROM partner_qr_codes WHERE batch_id=? ORDER BY seq", (batch_id,) - ).fetchall()] - - -def test_batch_create_and_pdf(client, admin): - """Kontingent anlegen -> N eindeutige Tokens + druckfertiges PDF.""" - code = _create_code(client, admin) - batch = _create_batch(client, admin, code["id"], quantity=7) - assert batch["quantity"] == 7 and batch["codes"] == 7 - tokens = _batch_tokens(batch["id"]) - assert len(set(tokens)) == 7 - - r = client.get(f"/api/admin/partner/qr-batches/{batch['id']}/pdf", headers=admin["headers"]) - assert r.status_code == 200 - assert r.headers["content-type"] == "application/pdf" - assert r.content[:4] == b"%PDF" - - -def test_scan_redirects_and_counts(client, admin): - """/q/{token} -> 302 mit ref+qr, Scan-Zaehler steigt; unbekannter Token -> /.""" - code = _create_code(client, admin) - batch = _create_batch(client, admin, code["id"], quantity=1) - token = _batch_tokens(batch["id"])[0] - - r = client.get(f"/q/{token}", follow_redirects=False) - assert r.status_code == 302 - # 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"]) - mine = [b for b in r.json() if b["id"] == batch["id"]][0] - assert mine["scans"] == 2 - - r = client.get("/q/gibtsnich", follow_redirects=False) - assert r.status_code == 302 - assert r.headers["location"] == "/" - - -def test_registration_attributed_to_qr(client, admin): - """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] - - email = f"qr-{secrets.token_hex(4)}@example.com" - r = client.post("/api/auth/register", json={ - "email": email, "password": "QrTest1234!", "name": f"qru{secrets.token_hex(3)}", - "ref_code": code["code"], "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 - - # 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_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) - code_b = _create_code(client, admin) - batch_b = _create_batch(client, admin, code_b["id"], quantity=1) - token_b = _batch_tokens(batch_b["id"])[0] - - email = f"qrx-{secrets.token_hex(4)}@example.com" - r = client.post("/api/auth/register", json={ - "email": email, "password": "QrTest1234!", "name": f"qrx{secrets.token_hex(3)}", - "ref_code": code_a["code"], "qr_token": token_b, - }) - assert r.status_code == 200, r.text - from database import db - with db() as conn: - row = conn.execute("SELECT referred_qr FROM users WHERE email=?", (email,)).fetchone() - 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 - 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=3) - - # Ohne Besitzer: leere Liste - r = client.get("/api/partner/my-qr", headers=user["headers"]) - assert r.status_code == 200 and r.json() == [] - - # Besitzer zuordnen -> sichtbar + PDF - r = client.post(f"/api/admin/partner/codes/{code['id']}/owner", - headers=admin["headers"], json={"user_id": uid}) - 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 - - # 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"]