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.
This commit is contained in:
rene 2026-06-07 18:20:23 +02:00
parent cadfb24a8d
commit f604ab7c4f
16 changed files with 621 additions and 23 deletions

View file

@ -2156,6 +2156,37 @@ setTimeout(() => location.href = '/?_t=' + Date.now() + '&hard=1', 6000);
return HTMLResponse(content=html, headers={"Cache-Control": "no-store"})
# /q/{token} — Partner-QR-Scan: zählen + auf Registrierung mit Code umleiten
# ------------------------------------------------------------------
@app.get("/q/{token}")
async def partner_qr_scan(token: str):
from fastapi.responses import RedirectResponse as _Redirect
from database import db as _db
token = token.strip()
with _db() as conn:
row = conn.execute(
"""SELECT q.token, pc.code
FROM partner_qr_codes q
JOIN partner_qr_batches b ON b.id = q.batch_id
JOIN partner_codes pc ON pc.id = b.partner_code_id
WHERE q.token = ?""",
(token,)
).fetchone()
if not row:
return _Redirect("/", status_code=302)
conn.execute(
"""UPDATE partner_qr_codes
SET scans = scans + 1,
first_scan_at = COALESCE(first_scan_at, datetime('now')),
last_scan_at = datetime('now')
WHERE token = ?""",
(token,)
)
# ?ref= nutzt den bestehenden Partner-Code-Flow, ?qr= ergänzt die Einzelcode-Zuordnung
return _Redirect(f"/?ref={row['code']}&qr={row['token']}", status_code=302)
# ------------------------------------------------------------------
# /partner — Influencer-Landingpage
# ------------------------------------------------------------------
@app.get("/partner")