Compare commits

...

15 commits

Author SHA1 Message Date
2927ae2672 Schutz gegen kursierende Partner-Codes (Rene: 'Bonus-Codes kursieren gerne das Internet')
1. QR-URL verrät den Code nicht mehr: /q/{token} → /?qr=TOKEN (vorher stand
   der tippbare Code in der Adresszeile jedes Scanners). Registrierung löst
   den Code server-seitig aus dem Token auf (auch ohne ref_code).
2. Notbremse: partner_codes.active — Admin kann Codes pausieren (Einlösung
   gesperrt, Info-Endpoint 404, Historie/QR-Kontingente bleiben) und
   reaktivieren. UI: ⏸/▶-Toggle + pausiert-Badge in der Codes-Tabelle.
3. max_uses im Anlege-Formular standardmäßig 50 statt unbegrenzt.

Tests: QR-only-Registrierung, Pause→keine Einlösung→Reaktivierung,
Redirect ohne Klartext-Code. Suite: 54 passed.
2026-06-07 19:35:31 +02:00
21bcc6b962 Partner-Dashboard vereinfacht: Scans + 'unbestätigt' raus (Rene: verwirrt)
Code-Karte: nur noch Registrierungen gesamt + diesen Monat, mit Hinweis dass
alle Wege zählen (Link, eingetippter Code, QR) — erklärt warum Code-Zahl >
QR-Zahl sein kann. QR-Kontingente: 'X von Y genutzt' statt Scans/Registr./
Versuche; Einzel-Code-Liste nur noch ● genutzt (N×) / ○ frei.
Admin behält die Detail-Sicht (Scans, Versuche, Account-Liste).
2026-06-07 19:25:18 +02:00
0cca716c3d Partner-Dashboard: Zähler selbsterklärend — 'X von Y Codes erfolgreich' statt 'verbraucht', '+N unbest.' ausgeschrieben, Einzel-Code-Badge zeigt Registrierungszahl (ein Code kann mehrere Accounts bringen) 2026-06-07 19:18:46 +02:00
fe783ef01b Fix: page-Section für partner-dashboard in index.html — Loader fand keinen Container (weiße Seite) 2026-06-07 19:10:44 +02:00
0a262989f3 Feature: Partner-Dashboard (#partner-dashboard) — operative Daten raus aus dem Profil-Editor
Rene: QR-Stats gehören nicht ins öffentliche Profil, eigene Seite fehlte.
Neue Seite 'Partner-Bereich' (Welten-Chip 🤝 zwischen Moderation und Admin,
role:partner — sichtbar für is_partner + Admin; _mergeDefaults reicht den
Chip an bestehende Welt-Configs nach):
- Einladungscode groß + Link-kopieren-Button
- Kacheln: Registrierungen gesamt / diesen Monat / unbestätigt
- QR-Kontingente mit Einzel-Code-Status (aus partner-profil.js hierher verschoben)
- Profil-Status-Karte (Entwurf/Prüfung/frei) mit Sprung zum Editor

Backend: GET /partner/my-stats (Codes mit Zahlen + Profil-Status).
Settings-Partner-Karte: zwei Buttons (Partner-Bereich primär, Profil sekundär);
Dank-Mail-CTA zeigt auf #partner-dashboard. Suite: 52 passed.
2026-06-07 19:06:51 +02:00
3d7d5dc1c4 Feature: Dank-Mail an Partner bei bestätigter Registrierung — mit Statistik
Trigger ist die E-Mail-Bestätigung des Geworbenen (nicht die rohe
Registrierung — konsistent zur Registrierungen/Versuche-Zählung) und nur
beim ersten Verify (Doppelklick auf den Link = keine zweite Mail).

Inhalt: Dank + Bilanz (bestätigte Registrierungen gesamt + diesen Monat),
bei QR-Herkunft der Sticker (#seq, Kontingent-Label), bei Gründer-Codes
die offenen Plätze; CTA zur Partner-Statistik. Versand über das
partner@-Konto, Fehler landen in failed_emails (context partner_thank_you).

Env-Fund dabei: SMTP_PASS fehlte in BEIDEN .env (nur SMTP_SUPPORT_PASS da)
— Partner-Konto-Versand wäre fehlgeschlagen; auf der DS ergänzt.
Test: Mail-Capture per monkeypatch, prüft Statistik + Sticker-Nr +
Einmaligkeit. Suite grün.
2026-06-07 18:51:54 +02:00
df2f42f8ac Partner-Self-Service: Einzel-Code-Status — welcher Sticker ist verbraucht?
Rene: 'wo sieht der Partner welche QR-Codes er hat und wieviele verbraucht
sind?' Neu in 'Meine QR-Codes':
- Kontingent-Zeile zeigt 'X/Y verbraucht' (Codes mit ≥1 bestätigter Registrierung)
- Listen-Button klappt Einzel-Codes auf: #Nr, Kurz-URL, Scans und Status
  ● verbraucht (mit Registrierungs-Datum) / ◐ gescannt / ○ frei
- Endpoint /partner/my-qr/{id}/codes (owner-gated, keine personenbezogenen
  Daten — nur Zähler + Zeitstempel)
2026-06-07 18:46:54 +02:00
970480c1d6 QR-Stats: Registrierungen (bestätigt) vs. Versuche (unbestätigt) + Account-Detail-Liste
Rene: Statistik zählte alles in einen Topf (3 statt 2) und zeigte nicht,
WER sich registriert hat. Jetzt:
- registrations = email_verified=1, attempts = unbestätigte Versuche —
  Versuche werden bei späterer Bestätigung automatisch zu Registrierungen
- Admin: 👥-Button pro Kontingent klappt Account-Liste auf (Name, E-Mail,
  Datum, ✓ bestätigt/ Versuch, Sticker-Nr #seq) — lazy geladen, admin-only
  (personenbezogene Daten); Partner sehen weiter nur Zahlen (Registr. +N)
- Test deckt Versuch→Bestätigung-Übergang und Detail-Endpoint ab
2026-06-07 18:43:18 +02:00
f604ab7c4f Feature: QR-Kontingente für Partner — Bestellung, Übergabe, Rückverfolgung
Partner verteilen gedruckte QR-Codes (Sticker/Flyer); jeder physische Code
ist einzeln rückverfolgbar von Scan bis Registrierung.

Backend:
- partner_qr_batches + partner_qr_codes (Token 8-stellig, ohne 0/O/1/l/I),
  users.referred_qr, partner_codes.owner_user_id (+Backfill über referred_by)
- /q/{token}: Scan zählen (scans, first/last_scan_at) → Redirect
  /?ref=CODE&qr=TOKEN — dockt am bestehenden Referral-Flow an
- Registrierung: qr_token wird nur zugeordnet, wenn er zum eingelösten
  Partner-Code gehört (Manipulationsschutz)
- Admin: Kontingent bestellen (max 500), Liste mit Scans/Registrierungen,
  Löschen (Zweiklick), druckfertiges A4-PDF (segno+fpdf2, 3×4 Grid mit
  Kurz-URL + laufender Nummer), Code-Besitzer zuordnen
- Partner-Self-Service: /partner/my-qr (+PDF) für Code-Besitzer

Frontend:
- Admin-Partner-Tab: Karte 'QR-Kontingente' (Bestellung, Stats, PDF, Besitzer)
- Partner-Profil: 'Meine QR-Codes' mit Scans/Registrierungen + PDF-Download
- boot.js/app.js speichern ?qr=, Registrierung schickt qr_token mit

Neu: segno==1.6.6 (pure-python QR). Tests: 5 neue (PDF, Scan-Zählung,
Attribution, Fremd-Token-Schutz, Self-Service). Suite: 51 passed.
2026-06-07 18:20:23 +02:00
cadfb24a8d Partner-Freigabe: Live-Vorschau im Admin + Mail-Fehler sichtbar machen
Rene: 'kann nichts prüfen — ich würde den Output erwarten, der auf der
Partner-Seite zu sehen sein wird'. Freigabe-Zeile hat jetzt einen
Vorschau-Toggle, der die Karte 1:1 wie auf #partner rendert (Logo/Initial,
Slogan, Website, Instagram, Bio, Medien-Grid).

Mail-Ursache gefunden: Staging-.env fehlte SMTP_SUPPORT_USER → Code-Default
support@banyaro.de → 535 Auth-Fehler, vom Silent-Catch verschluckt.
.env ergänzt (partner@banyaro.app wie Prod); Submit loggt SMTP-Fehler jetzt
über _log_smtp_failure in failed_emails statt still zu schlucken.
2026-06-07 17:43:42 +02:00
a40aa183ec Admin: offene Partner-Profil-Freigaben in 'Zu erledigen'-Leiste + ADMIN_EMAIL-Befund
Rene reichte ein Partner-Profil ein und sah als Admin nirgends einen Hinweis:
1. Action-Items kannten Partner-Profile nicht — partner_profiles_pending
   (submitted_at gesetzt, approved=0) jetzt im Endpoint + Chip im Admin-Kopf
   (Klick -> Partner-Tab). Test ergänzt (7 passed).
2. ADMIN_EMAIL fehlte in BEIDEN .env auf der DS (Prod+Staging) — damit wurden
   auch Upgrade-Anfragen-Mails still verschluckt (bekanntes Silent-Skip-Muster).
   Auf der DS nachgetragen; greift je beim nächsten Deploy.
2026-06-07 17:34:56 +02:00
73ca66bbf5 bump v1254 2026-06-07 17:30:07 +02:00
8a614eef1a Fix: HEIC/MOV-Konvertierung bei Partner-Uploads
Logo-Pfad akzeptierte .heic, öffnete aber direkt mit Pillow (kein HEIF-Opener)
— iPhone-Fotos schlugen fehl. Jetzt convert_media-Vorstufe wie im Foto-Pfad.
Fehlgeschlagene Konvertierungen (HEIC→JPEG, MOV→MP4) brechen mit klarer
Meldung ab statt rohe Dateien zu speichern (MOV wäre als <img> kaputt gerendert).
Test: echter HEIC-Roundtrip (pillow-heif) für Logo + Foto.
2026-06-07 17:29:59 +02:00
21f54f478b Fix: partner_profiles Alt-Schema-Migration — Tabelle aus verlorener v1102-Session existiert auf Staging/Prod (photos statt photos_json); Umbau mit Datenübernahme 2026-06-07 17:22:59 +02:00
ce8aa2b699 Feature: Partner-Profile Backend + Pro-Zugang für Partner
Die Partner-Showcase-Seite (#partner) und der Profil-Editor (#partner-profil)
existierten seit v1102 nur als Frontend — /api/partners/public und
/api/partner/my-profile gab es nie (vermutlich Worktree-Merge-Verlust).

Backend neu:
- partner_profiles-Tabelle (user_id PK, ON DELETE CASCADE → DSGVO-Delete greift)
- GET/PUT /partner/my-profile (Texte, Website-Normalisierung, @-Instagram)
- Logo-Upload (≤5 MB → WebP 512px, altes Logo wird geräumt)
- Foto/Video-Upload (max 6, 200-MB-Budget, HEIC→JPEG, MOV→MP4 via ffmpeg,
  Bilder→WebP 1600px) + Lösch-Endpoint
- Submit-Workflow (approved 0/1/-1) + Admin-Mail (best effort)
- GET /partners/public (nur freigegebene, JOIN users für Name/Avatar)
- Admin: GET /admin/partner/profiles + POST .../review

Pro für Partner: has_pro_access() + App._hasPro() prüfen jetzt is_partner —
Multiplikatoren bekommen Pro gratis (mehrere Hunde, KI-Trainer etc.).

UI: Admin-Partner-Tab mit Freigabe-Sektion (offen-Badge, ✓/✗),
Settings zeigt Partnern eine Karte mit Link zum Profil-Editor.

Tests: tests/test_partner_profile.py — 5 Smoke-Tests (403, Voll-Flow
inkl. Freigabe/Ablehnung, Pflicht-Anzeigename, Logo+Foto-Upload, Pro-Zugang).
Suite: 44 passed.
2026-06-07 17:20:20 +02:00
21 changed files with 1884 additions and 37 deletions

View file

@ -1 +1 @@
1252
1265

View file

@ -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")

View file

@ -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("""

View file

@ -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")

View file

@ -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

View file

@ -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,
}

View file

@ -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"<p style='margin:0 0 16px'>Deine Bilanz mit dem Code <b>{pc['code']}</b>:<br>"
f"<b>{total}</b> bestätigte Registrierung{'en' if total != 1 else ''} insgesamt · "
f"<b>{month}</b> in diesem Monat.</p>"
)
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{_oname}</b>,</p>
<p style="margin:0 0 16px">
gerade hat ein neuer Hundefreund seine Registrierung über deinen
Partner-Code bestätigt danke, dass du Ban Yaro weiterträgst! 🎉
</p>
{f'<p style="margin:0 0 16px">{_html.escape(qr_line)}</p>' if qr_line else ''}
{stats_html}
{f'<p style="margin:0 0 16px;color:#888">{_html.escape(founder_line)}</p>' 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)

View file

@ -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}

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1252"></script>
<script src="/js/boot-early.js?v=1265"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1252">
<link rel="stylesheet" href="/css/layout.css?v=1252">
<link rel="stylesheet" href="/css/components.css?v=1252">
<link rel="stylesheet" href="/css/utilities.css?v=1252">
<link rel="stylesheet" href="/css/lists.css?v=1252">
<link rel="stylesheet" href="/css/design-system.css?v=1265">
<link rel="stylesheet" href="/css/layout.css?v=1265">
<link rel="stylesheet" href="/css/components.css?v=1265">
<link rel="stylesheet" href="/css/utilities.css?v=1265">
<link rel="stylesheet" href="/css/lists.css?v=1265">
</head>
<body>
@ -507,6 +507,10 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-partner-dashboard">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-jobs">
<div class="page-body page-container"></div>
</section>
@ -612,11 +616,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1252"></script>
<script src="/js/ui.js?v=1252"></script>
<script src="/js/app.js?v=1252"></script>
<script src="/js/worlds.js?v=1252"></script>
<script src="/js/offline-indicator.js?v=1252"></script>
<script src="/js/api.js?v=1265"></script>
<script src="/js/ui.js?v=1265"></script>
<script src="/js/app.js?v=1265"></script>
<script src="/js/worlds.js?v=1265"></script>
<script src="/js/offline-indicator.js?v=1265"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -626,7 +630,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1252"></script>
<script src="/js/boot.js?v=1265"></script>
</body>

View file

@ -114,9 +114,10 @@ const API = (() => {
login(email, password) {
return post('/auth/login', { email, password });
},
register(email, password, name, ref_code) {
register(email, password, name, ref_code, qr_token) {
const body = { email, password, name };
if (ref_code) body.ref_code = ref_code;
if (qr_token) body.qr_token = qr_token; // Partner-QR (Sticker/Flyer) — Rückverfolgung
return post('/auth/register', body);
},
logout() {

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1252'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1265'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;
@ -81,6 +81,7 @@ const App = (() => {
gruender: { title: '100 Gründer', module: null },
partner: { title: 'Unsere Partner', module: null },
'partner-profil': { title: 'Partner-Profil', module: null, requiresAuth: true },
'partner-dashboard': { title: 'Partner-Bereich', module: null, requiresAuth: true },
jobs: { title: 'Wir suchen dich', module: null },
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
recalls: { title: 'Rückrufe', module: null },
@ -104,6 +105,7 @@ const App = (() => {
// Normale Prüfung: Admin/Mod/Social bekommen immer Pro
if (user.rolle === 'admin' || user.rolle === 'moderator') return true;
if (user.is_moderator || user.is_social_media) return true;
if (user.is_partner) return true; // Partner (Multiplikatoren) bekommen Pro gratis
return ['pro','breeder'].includes(t);
}
@ -1139,10 +1141,16 @@ 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');
if (refCode) {
const qrToken = urlParams.get('qr');
if (refCode || qrToken) {
try {
localStorage.setItem('by_ref_code', refCode.toUpperCase());
localStorage.setItem('by_ref_code_ts', String(Date.now()));
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);
} catch {}
// URL bereinigen ohne Reload
history.replaceState({}, '', window.location.pathname + window.location.hash);

View file

@ -17,6 +17,9 @@
localStorage.setItem('by_ref_code', rc.toUpperCase());
localStorage.setItem('by_ref_code_ts', String(Date.now()));
}
// Partner-QR-Token (?qr= aus /q/{token}-Redirect) — Rückverfolgung pro Sticker/Flyer
var qt = new URLSearchParams(location.search).get('qr');
if (qt) localStorage.setItem('by_qr_token', qt);
// Vektor-Basemap-Feature-Flag aus ?vectormap=1/0 SOFORT sichern (bevor Boot
// die URL-Query strippt). Wird in ui.js Map.create ausgewertet.
var vm = new URLSearchParams(location.search).get('vectormap');

View file

@ -123,6 +123,7 @@ window.Page_admin = (() => {
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
{ key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' },
{ key: 'partner_profiles_pending', label: 'Partner-Profile', tab: 'partner', icon: 'handshake' },
];
const open = items.filter(i => d[i.key] > 0);
@ -2289,7 +2290,9 @@ window.Page_admin = (() => {
// TAB: AUDIT-LOG
// ------------------------------------------------------------------
async function _renderPartner(el) {
const codes = (await API.get('/admin/partner/codes')) || [];
const codes = (await API.get('/admin/partner/codes')) || [];
const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || [];
const qrBatches = (await API.get('/admin/partner/qr-batches').catch(() => [])) || [];
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
@ -2323,8 +2326,8 @@ window.Page_admin = (() => {
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);align-items:center">
<div>
<label class="form-label text-xs">Max. Einlösungen <span class="text-muted">(leer = unbegrenzt)</span></label>
<input class="form-control" name="max_uses" type="number" min="1" placeholder="∞">
<label class="form-label text-xs">Max. Einlösungen <span class="text-muted">(leer = unbegrenzt Vorsicht, Codes kursieren gern im Netz)</span></label>
<input class="form-control" name="max_uses" type="number" min="1" value="50" placeholder="∞">
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);padding-top:var(--space-5)">
<input type="checkbox" id="adm-grants-founder" name="grants_founder" checked
@ -2358,18 +2361,31 @@ window.Page_admin = (() => {
</thead>
<tbody>
${codes.map(c => `
<tr style="border-bottom:1px solid var(--c-border)" data-code-id="${c.id}">
<tr style="border-bottom:1px solid var(--c-border);${c.active ? '' : 'opacity:.55'}" data-code-id="${c.id}">
<td style="padding:var(--space-2) var(--space-3)">
<code style="font-weight:700;color:var(--c-primary);letter-spacing:.08em">${c.code}</code>
${c.active ? '' : `<div><span class="badge" style="background:#fee2e2;color:#dc2626;font-size:10px">⏸ pausiert</span></div>`}
</td>
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text)">
${c.label}
<div class="text-xs-muted">
${c.owner_name
? `👤 ${UI.escape(c.owner_name)}`
: `<button class="btn btn-ghost btn-sm adm-code-owner" data-id="${c.id}" style="font-size:var(--text-xs);padding:0 4px">👤 Besitzer zuordnen</button>`}
</div>
</td>
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text)">${c.label}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">
${c.uses}${c.max_uses ? `/${c.max_uses}` : ''}
</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center">
${c.grants_founder ? '✓' : '—'}
</td>
<td style="padding:var(--space-2) var(--space-3)">
<td style="padding:var(--space-2) var(--space-3);white-space:nowrap;text-align:right">
<button class="btn btn-ghost btn-sm adm-toggle-code" data-id="${c.id}"
title="${c.active ? 'Pausieren — Notbremse wenn der Code im Netz kursiert (Einlösung gesperrt, Historie bleibt)' : 'Wieder aktivieren'}"
style="font-size:var(--text-xs)">
${c.active ? '⏸ Pausieren' : '▶ Aktivieren'}
</button>
<button class="btn btn-ghost btn-sm adm-del-code" data-id="${c.id}"
style="color:var(--c-danger,#dc2626);font-size:var(--text-xs)">
${UI.icon('trash')} Löschen
@ -2383,6 +2399,147 @@ window.Page_admin = (() => {
</div>
</div>
<!-- QR-Kontingente (Sticker/Flyer mit Rückverfolgung) -->
<div class="by-card p-4">
<h3 style="margin:0 0 var(--space-2);font-size:var(--text-base)">QR-Kontingente</h3>
<p class="text-xs-muted" style="margin:0 0 var(--space-3)">
Druckfertige QR-Codes für Partner (Sticker, Flyer, Visitenkarten). Jeder Code ist einzeln
rückverfolgbar: Scans und Registrierungen werden pro Kontingent gezählt.
</p>
<form id="adm-qr-create" class="flex-col-gap-3" style="margin-bottom:var(--space-4)">
<div style="display:grid;grid-template-columns:1fr 1fr 100px;gap:var(--space-3)">
<div>
<label class="form-label text-xs">Partner-Code</label>
<select class="form-control" name="code_id" required>
${codes.map(c => `<option value="${c.id}">${UI.escape(c.code)}${UI.escape(c.label)}</option>`).join('')}
</select>
</div>
<div>
<label class="form-label text-xs">Bezeichnung</label>
<input class="form-control" name="label" placeholder="z. B. Sticker-Bestellung Juni" required>
</div>
<div>
<label class="form-label text-xs">Stückzahl</label>
<input class="form-control" name="quantity" type="number" min="1" max="500" value="24" required>
</div>
</div>
<button type="submit" class="btn btn-primary btn-sm" style="align-self:flex-start"
${codes.length === 0 ? 'disabled title="Zuerst einen Partner-Code anlegen"' : ''}>
${UI.icon('qr-code')} Kontingent erstellen
</button>
</form>
${qrBatches.length === 0
? `<p class="text-sm-muted">Noch keine Kontingente bestellt.</p>`
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Code</th>
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Kontingent</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Stk.</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Scans</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)" title="E-Mail bestätigt">Registr.</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)" title="Registriert, aber E-Mail (noch) unbestätigt">Versuche</th>
<th style="padding:var(--space-2) var(--space-3)"></th>
</tr>
</thead>
<tbody>
${qrBatches.map(b => `
<tr style="border-bottom:1px solid var(--c-border)">
<td style="padding:var(--space-2) var(--space-3)"><code style="font-weight:700;color:var(--c-primary)">${UI.escape(b.code)}</code></td>
<td style="padding:var(--space-2) var(--space-3)">${UI.escape(b.label)}<div class="text-xs-muted">${(b.created_at || '').slice(0, 10)}</div></td>
<td style="padding:var(--space-2) var(--space-3);text-align:center">${b.quantity}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">${b.scans}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600;color:${b.registrations > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.registrations}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;color:${b.attempts > 0 ? 'var(--c-warning,#e65100)' : 'var(--c-text-muted)'}">${b.attempts}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:right;white-space:nowrap">
${b.registrations + b.attempts > 0 ? `
<button class="btn btn-ghost btn-sm adm-qr-detail" data-id="${b.id}" title="Accounts anzeigen">
${UI.icon('users')}
</button>` : ''}
<a class="btn btn-sm btn-secondary" href="/api/admin/partner/qr-batches/${b.id}/pdf" download>
${UI.icon('file-pdf')} PDF
</a>
<button class="btn btn-ghost btn-sm adm-qr-del text-danger" data-id="${b.id}" data-label="${UI.escape(b.label)}">
${UI.icon('trash')}
</button>
</td>
</tr>
<tr class="hidden" id="adm-qr-detail-${b.id}">
<td colspan="7" style="padding:0 var(--space-3) var(--space-3);background:var(--c-surface-2)">
<div class="text-sm-muted" style="padding:var(--space-3) 0">Lädt</div>
</td>
</tr>`).join('')}
</tbody>
</table>`}
</div>
<!-- Partner-Profil-Freigaben -->
<div class="by-card p-4">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">
Profil-Freigaben
${profiles.filter(p => p.submitted_at && p.approved === 0).length
? `<span class="badge" style="background:var(--c-warning,#f59e0b);color:#fff;margin-left:var(--space-2)">${profiles.filter(p => p.submitted_at && p.approved === 0).length} offen</span>` : ''}
</h3>
${profiles.length === 0
? `<p class="text-sm-muted">Noch keine Partner-Profile angelegt.</p>`
: profiles.map(p => `
<div style="border-bottom:1px solid var(--c-border)" data-pp-uid="${p.user_id}">
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0">
${p.logo_url
? `<img src="${UI.escape(p.logo_url)}" style="width:36px;height:36px;border-radius:var(--radius-md);object-fit:contain;background:var(--c-surface-2);flex-shrink:0">`
: `<div style="width:36px;height:36px;border-radius:var(--radius-md);background:var(--c-surface-2);flex-shrink:0"></div>`}
<div class="flex-1-min">
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(p.display_name || p.name)}</div>
<div class="text-xs-muted">${UI.escape(p.name)} · ${UI.escape(p.email)}${p.photos?.length ? ` · ${p.photos.length} Medien` : ''}</div>
</div>
${p.approved === 1
? `<span class="badge" style="background:#dcfce7;color:#16a34a">✓ Frei</span>`
: p.approved === -1
? `<span class="badge" style="background:#fee2e2;color:#dc2626">✗ Abgelehnt</span>`
: p.submitted_at
? `<span class="badge" style="background:#fef9c3;color:#a16207">⏳ Prüfen</span>`
: `<span class="badge">Entwurf</span>`}
<button class="btn btn-sm btn-secondary adm-pp-preview" data-uid="${p.user_id}">
${UI.icon('eye')} Vorschau
</button>
${p.approved !== 1 ? `<button class="btn btn-sm btn-primary adm-pp-review" data-uid="${p.user_id}" data-val="1">✓ Freigeben</button>` : ''}
${p.approved !== -1 ? `<button class="btn btn-sm btn-ghost adm-pp-review text-danger" data-uid="${p.user_id}" data-val="-1">✗</button>` : ''}
</div>
<!-- Vorschau: so erscheint die Karte auf der öffentlichen Partner-Seite -->
<div class="adm-pp-preview-card hidden" id="adm-pp-preview-${p.user_id}" style="margin:0 0 var(--space-3)">
<div class="text-xs-muted" style="margin-bottom:var(--space-2)">
${UI.icon('eye')} So erscheint die Karte auf der Partner-Seite:
</div>
<div class="by-card" style="padding:var(--space-4);position:relative;overflow:hidden;max-width:340px">
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(135deg,#7c3aed,#a855f7)"></div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
${p.logo_url
? `<img src="${UI.escape(p.logo_url)}" alt="" style="width:56px;height:56px;border-radius:var(--radius-md);object-fit:contain;flex-shrink:0;background:var(--c-surface-2);padding:4px">`
: `<div style="width:56px;height:56px;border-radius:50%;flex-shrink:0;background:linear-gradient(135deg,#7c3aed,#a855f7);display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:800;color:#fff">${UI.escape((p.display_name || p.name || '?')[0].toUpperCase())}</div>`}
<div class="flex-1-min">
<div style="font-weight:700;font-size:var(--text-base)">${UI.escape(p.display_name || p.name)}</div>
${p.tagline ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:1px">${UI.escape(p.tagline)}</div>` : ''}
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-1)">
${p.website ? `<a href="${UI.escape(p.website)}" target="_blank" rel="noopener" style="font-size:var(--text-xs);color:var(--c-primary)">🌐 ${UI.escape(p.website.replace(/^https?:\/\//, ''))}</a>` : ''}
${p.instagram ? `<span class="text-xs-muted">📸 ${UI.escape(p.instagram)}</span>` : ''}
</div>
</div>
</div>
${p.bio ? `<p style="margin:var(--space-3) 0 0;font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">${UI.escape(p.bio)}</p>` : ''}
${p.photos?.length ? `
<div style="display:grid;grid-template-columns:repeat(${Math.min(p.photos.length, 3)},1fr);gap:var(--space-1);margin-top:var(--space-3);border-radius:var(--radius-md);overflow:hidden">
${p.photos.slice(0, 3).map(url => {
const isVid = url.endsWith('.mp4') || url.endsWith('.webm');
return isVid
? `<video src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover" muted playsinline loop autoplay></video>`
: `<img src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover">`;
}).join('')}
</div>` : ''}
</div>
</div>
</div>`).join('')}
</div>
<!-- User-Status vergeben -->
<div class="by-card p-4">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Nutzer-Status manuell vergeben</h3>
@ -2414,6 +2571,113 @@ window.Page_admin = (() => {
</div>
`;
// 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
? `<div class="text-sm-muted" style="padding:var(--space-3) 0">Keine Accounts.</div>`
: regs.map(u => `
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border);font-size:var(--text-sm)">
<div class="flex-1-min">
<span style="font-weight:600">${UI.escape(u.name)}</span>
<span class="text-xs-muted">· ${UI.escape(u.email)}</span>
</div>
<span class="text-xs-muted" title="Über welchen Einzel-Code (Sticker-Nr.)">#${u.seq}</span>
<span class="text-xs-muted">${(u.created_at || '').slice(0, 16).replace(' ', ' · ')}</span>
${u.email_verified
? `<span class="badge" style="background:#dcfce7;color:#16a34a">✓ bestätigt</span>`
: `<span class="badge" style="background:#fef9c3;color:#a16207" title="Registriert, E-Mail noch nicht bestätigt">⏳ Versuch</span>`}
</div>`).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();

View file

@ -0,0 +1,198 @@
/* ============================================================
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 = `
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
<div style="margin-bottom:var(--space-5)">
<h1 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-1)">
${UI.icon('handshake')} Partner-Bereich
</h1>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
Dein Code, deine Zahlen, deine QR-Kontingente.
</p>
</div>
<div id="pd-content">
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">Lade</div>
</div>
</div>
`;
}
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 = `<p class="text-danger">${UI.escape(e.message || 'Fehler beim Laden.')}</p>`;
}
}
function _renderDashboard() {
const codes = _stats?.codes || [];
return `
${codes.length === 0 ? `
<div class="card" style="padding:var(--space-5);text-align:center;margin-bottom:var(--space-3)">
<p class="text-sm-secondary" style="margin:0">
Dir ist noch kein Partner-Code zugeordnet.<br>
Melde dich bei <a href="mailto:partner@banyaro.app" class="text-primary">partner@banyaro.app</a> wir richten ihn ein.
</p>
</div>` : codes.map(c => _renderCodeCard(c)).join('')}
${_renderQrSection()}
${_renderProfileCard()}
`;
}
function _renderCodeCard(c) {
const link = `https://banyaro.app/?ref=${encodeURIComponent(c.code)}`;
return `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-2)">Dein Einladungscode</div>
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3)">
<code style="font-size:var(--text-lg);font-weight:800;letter-spacing:.1em;color:var(--c-primary)">${UI.escape(c.code)}</code>
<button class="btn btn-sm btn-secondary pd-copy" data-link="${UI.escape(link)}">
${UI.icon('copy')} Link kopieren
</button>
</div>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:var(--space-2);text-align:center">
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3)">
<div style="font-size:var(--text-xl);font-weight:800;color:${c.registrations > 0 ? 'var(--c-success,#16a34a)' : 'var(--c-text)'}">${c.registrations}</div>
<div class="text-xs-muted">Registrierungen</div>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3)">
<div style="font-size:var(--text-xl);font-weight:800">${c.registrations_month}</div>
<div class="text-xs-muted">diesen Monat</div>
</div>
</div>
<div class="text-xs-muted" style="margin-top:var(--space-2)">
Zählt alle Wege: geteilter Link, eingetippter Code und deine gedruckten QR-Codes.
</div>
</div>`;
}
function _renderQrSection() {
if (!_qrBatches.length) return '';
return `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-2)">Meine QR-Codes</div>
<p class="text-xs-muted" style="margin:0 0 var(--space-3)">
Deine gedruckten QR-Codes (Sticker, Flyer) und wie viele davon schon
neue Hundefreunde gebracht haben.
</p>
${_qrBatches.map(b => `
<div style="border-bottom:1px solid var(--c-border)">
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0">
<div class="flex-1-min">
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(b.label)}</div>
<div class="text-xs-muted">${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)}</div>
</div>
<div style="text-align:right">
<div style="font-weight:700;color:${b.codes_used > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.codes_used} von ${b.quantity}</div>
<div class="text-xs-muted" title="Codes mit mindestens einer bestätigten Registrierung">genutzt</div>
</div>
<button class="btn btn-sm btn-ghost pd-qr-codes-btn" data-id="${b.id}" title="Einzel-Codes anzeigen">
${UI.icon('list')}
</button>
<a class="btn btn-sm btn-secondary" href="/api/partner/my-qr/${b.id}/pdf" download>
${UI.icon('file-pdf')} PDF
</a>
</div>
<div class="hidden" id="pd-qr-codes-${b.id}" style="padding:0 0 var(--space-3)">
<div class="text-sm-muted">Lädt</div>
</div>
</div>`).join('')}
</div>`;
}
function _renderProfileCard() {
const p = _stats?.profile || {};
let badge;
if (p.approved === 1) badge = `<span class="badge" style="background:#dcfce7;color:#16a34a">✓ Öffentlich sichtbar</span>`;
else if (p.approved === -1) badge = `<span class="badge" style="background:#fee2e2;color:#dc2626">✗ Abgelehnt</span>`;
else if (p.submitted_at) badge = `<span class="badge" style="background:#fef9c3;color:#a16207">⏳ In Prüfung</span>`;
else if (p.exists) badge = `<span class="badge">Entwurf</span>`;
else badge = `<span class="badge">Noch nicht angelegt</span>`;
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div class="flex-1-min">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-1)">Öffentliches Profil</div>
${badge}
</div>
<button class="btn btn-sm btn-secondary" id="pd-edit-profile">
${UI.icon('pencil-simple')} Bearbeiten
</button>
</div>
</div>`;
}
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 `
<div style="display:flex;align-items:center;gap:var(--space-2);padding:3px 0;font-size:var(--text-xs);border-bottom:1px dashed var(--c-border)">
<span style="font-weight:700;min-width:34px">#${c.seq}</span>
<code class="flex-1-min" style="color:var(--c-text-muted)">banyaro.app/q/${UI.escape(c.token)}</code>
${used
? `<span class="badge" style="background:#dcfce7;color:#16a34a" title="Erste Registrierung am ${(c.first_registration_at || '').slice(0, 10)}">● genutzt${c.registrations > 1 ? ` (${c.registrations}×)` : ''}</span>`
: `<span class="badge" style="background:var(--c-surface-2);color:var(--c-text-muted)">○ frei</span>`}
</div>`;
}).join('');
} catch (err) { UI.toast.error(err.message); }
});
});
el.querySelector('#pd-edit-profile')?.addEventListener('click', () => App.navigate('partner-profil'));
}
return { init, refresh, onDogChange };
})();

View file

@ -27,6 +27,8 @@ window.Page_partner_profil = (() => {
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
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
<a href="#partner-dashboard" class="text-primary">Partner-Bereich</a>.
</p>
</div>
<div id="pp-content">

View file

@ -665,6 +665,26 @@ window.Page_settings = (() => {
<!-- Züchter-Profil Slot -->
<div id="breeder-card-slot"></div>
${u.is_partner ? `
<!-- Partner-Bereich -->
<div class="card mb-4">
<div class="by-card-section-header">${UI.icon('handshake')} Partner</div>
<div class="p-4">
<p class="text-sm-secondary" style="margin:0 0 var(--space-3)">
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.
</p>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
<button class="btn btn-primary btn-sm" id="settings-partner-dashboard-btn">
${UI.icon('handshake')} Partner-Bereich
</button>
<button class="btn btn-secondary btn-sm" id="settings-partner-profile-btn">
${UI.icon('pencil-simple')} Öffentliches Profil
</button>
</div>
</div>
</div>` : ''}
<div class="card mb-4">
<div class="by-card-section-header">Trophäen</div>
<div id="settings-badges-body" class="p-4">
@ -1660,6 +1680,11 @@ 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'));
}
// ----------------------------------------------------------
@ -2595,8 +2620,11 @@ window.Page_settings = (() => {
const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined;
const refCode = _storedRefCode();
const finalCode = partnerCode || refCode || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
// 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);
if (refCode) _clearRefCode();
try { localStorage.removeItem('by_qr_token'); } catch {}
if (result.pending_verification) {
_renderVerifyPending(fd.email);

View file

@ -578,6 +578,7 @@ window.Worlds = (() => {
{ icon:'sparkle', label:'Social', page:'social', role:'social',
fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] },
{ icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' },
{ icon:'handshake', label:'Partner', page:'partner-dashboard', role:'partner' },
{ icon:'gear', label:'Admin', page:'admin', role:'admin' },
// ── NEUE FEATURES ────────────────────────────────────────────
{ icon:'fork-knife', label:'Ernährung', page:'ernaehrung', pro: true,
@ -587,7 +588,7 @@ window.Worlds = (() => {
];
const _DEFAULT_CONFIG = {
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','admin'],
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','partner-dashboard','admin'],
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse',
'litters','zuchthunde','laeufi','ernaehrung','personality'],
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events',
@ -681,6 +682,7 @@ window.Worlds = (() => {
}
if (chip.role === 'social') return u?.is_social_media || u?.rolle === 'admin';
if (chip.role === 'mod') return u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator;
if (chip.role === 'partner') return !!u?.is_partner || u?.rolle === 'admin';
if (chip.role === 'admin') return u?.rolle === 'admin';
return true;
}

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1252"></script>
<script src="/js/landing-init.js?v=1265"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -4,7 +4,7 @@
============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1252';
const VER = '1265';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

View file

@ -0,0 +1,155 @@
"""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})

274
tests/test_partner_qr.py Normal file
View file

@ -0,0 +1,274 @@
"""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"]