diff --git a/VERSION b/VERSION index 832d4ca..94ad64d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1256 \ No newline at end of file +1257 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 1ddfc8e..1169b5d 100644 --- a/backend/database.py +++ b/backend/database.py @@ -623,6 +623,10 @@ 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"), # Passwort-Zurücksetzen ("users", "password_reset_token", "TEXT"), ("users", "password_reset_expires", "TEXT"), @@ -1685,6 +1689,48 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration partner_profiles: {e}") + # QR-Kontingente für Partner (gedruckte Sticker/Flyer mit Rückverfolgung) + # Jeder physische QR-Code hat einen eigenen Token → Scan- und + # Registrierungs-Tracking pro Einzelcode und pro Kontingent. + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS partner_qr_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + partner_code_id INTEGER NOT NULL REFERENCES partner_codes(id) ON DELETE CASCADE, + label TEXT NOT NULL, + quantity INTEGER NOT NULL, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS partner_qr_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id INTEGER NOT NULL REFERENCES partner_qr_batches(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + seq INTEGER NOT NULL, + scans INTEGER NOT NULL DEFAULT 0, + first_scan_at TEXT, + last_scan_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_pqr_token ON partner_qr_codes(token); + CREATE INDEX IF NOT EXISTS idx_pqr_batch ON partner_qr_codes(batch_id); + """) + logger.info("Migration: partner_qr Tabellen bereit.") + except Exception as e: + logger.warning(f"Migration partner_qr: {e}") + try: + # Backfill: Partner, die sich mit ihrem eigenen Code registriert haben, + # als Code-Besitzer verknüpfen (für Self-Service-Zugriff auf QR-Stats). + # Eigener try-Block: owner_user_id kommt auf frischen DBs erst im 2nd pass. + conn.execute(""" + UPDATE partner_codes SET owner_user_id = ( + SELECT u.id FROM users u + WHERE u.referred_by = -partner_codes.id AND u.is_partner = 1 + LIMIT 1 + ) WHERE owner_user_id IS NULL + """) + except Exception as e: + logger.debug(f"Backfill partner_codes.owner_user_id übersprungen: {e}") + # Outreach-Log (Admin-E-Mail-Versand) try: conn.executescript(""" diff --git a/backend/main.py b/backend/main.py index b473156..309e184 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") diff --git a/backend/requirements.txt b/backend/requirements.txt index d45b6f8..6508529 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,5 +15,6 @@ apscheduler==3.10.4 odfpy==1.4.1 polyline==2.0.2 fpdf2==2.8.3 +segno==1.6.6 python-dateutil>=2.9 brotli-asgi==1.4.0 diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 18b092b..718d6cd 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -153,6 +153,7 @@ class RegisterRequest(BaseModel): password: str = Field(..., min_length=8, max_length=200) name: str = Field(..., min_length=2, max_length=40) ref_code: Optional[str] = Field(None, max_length=50) + qr_token: Optional[str] = Field(None, max_length=20) # physischer Partner-QR (Sticker/Flyer) def _gen_referral_code() -> str: @@ -227,6 +228,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" diff --git a/backend/routes/partner.py b/backend/routes/partner.py index dff79c9..ec0ebc5 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -37,10 +37,12 @@ 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, + 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] @@ -546,3 +548,220 @@ def review_partner_profile(user_id: int, data: PartnerProfileReview, user=Depend ) 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: + 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 = ?) AS registrations + FROM partner_qr_codes q WHERE q.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}/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-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"],) + ) + + +@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: + 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.") + pdf = _qr_batch_pdf(conn, batch_id) + return Response(content=pdf, media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'}) + + +class CodeOwnerSet(BaseModel): + user_id: int + + +@router.post("/admin/partner/codes/{code_id}/owner") +def set_code_owner(code_id: int, data: CodeOwnerSet, user=Depends(require_admin)): + """Partner-Code einem User zuordnen (für Self-Service-QR-Zugriff).""" + with db() as conn: + if not conn.execute("SELECT id FROM partner_codes WHERE id=?", (code_id,)).fetchone(): + raise HTTPException(404, "Partner-Code nicht gefunden.") + if not conn.execute("SELECT id FROM users WHERE id=?", (data.user_id,)).fetchone(): + raise HTTPException(404, "User nicht gefunden.") + conn.execute( + "UPDATE partner_codes SET owner_user_id=? WHERE id=?", + (data.user_id, code_id) + ) + return {"ok": True} diff --git a/backend/static/index.html b/backend/static/index.html index ac806cb..1acae3c 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@
${c.code}
+ Druckfertige QR-Codes für Partner (Sticker, Flyer, Visitenkarten). Jeder Code ist einzeln + rückverfolgbar: Scans und Registrierungen werden pro Kontingent gezählt. +
+ + ${qrBatches.length === 0 + ? `Noch keine Kontingente bestellt.
` + : `| Code | +Kontingent | +Stk. | +Scans | +Registr. | ++ |
|---|---|---|---|---|---|
${UI.escape(b.code)} |
+ ${UI.escape(b.label)} ${(b.created_at || '').slice(0, 10)} |
+ ${b.quantity} | +${b.scans} | +${b.registrations} | ++ + ${UI.icon('file-pdf')} PDF + + + | +
+ Deine gedruckten QR-Codes (Sticker, Flyer). Jeder Scan und jede Registrierung + darüber wird gezählt — so siehst du, was wo funktioniert. +
+ ${_qrBatches.map(b => ` +