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

129
tests/test_partner_qr.py Normal file
View file

@ -0,0 +1,129 @@
"""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
assert r.headers["location"] == f"/?ref={code['code']}&qr={token}"
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 -> users.referred_qr gesetzt, Kontingent-Stats zaehlen."""
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
r = client.get("/api/admin/partner/qr-batches", headers=admin["headers"])
mine = [b for b in r.json() if b["id"] == batch["id"]][0]
assert mine["registrations"] == 1
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_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"]]
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"