From df2f42f8ac7acdb383628b5915f79ffa0721538b Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 18:46:54 +0200 Subject: [PATCH] =?UTF-8?q?Partner-Self-Service:=20Einzel-Code-Status=20?= =?UTF-8?q?=E2=80=94=20welcher=20Sticker=20ist=20verbraucht=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- VERSION | 2 +- backend/routes/partner.py | 49 ++++++++++++---- backend/static/index.html | 24 ++++---- backend/static/js/app.js | 2 +- backend/static/js/pages/partner-profil.js | 70 ++++++++++++++++++----- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_partner_qr.py | 24 ++++++++ 8 files changed, 134 insertions(+), 41 deletions(-) diff --git a/VERSION b/VERSION index 1e36b91..6ee69cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1258 \ No newline at end of file +1259 \ No newline at end of file diff --git a/backend/routes/partner.py b/backend/routes/partner.py index ba3802e..23f0bcd 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -583,9 +583,12 @@ def _qr_batch_stats(conn, batch_id: int) -> dict: 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 + 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, batch_id, batch_id, batch_id) ).fetchone() return dict(row) @@ -756,18 +759,44 @@ def my_qr_batches(user=Depends(require_partner)): ) +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: - 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.") + _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"'}) diff --git a/backend/static/index.html b/backend/static/index.html index 99aaef9..08c14dc 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 53d3573..f167267 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1258'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1259'; // ← 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; diff --git a/backend/static/js/pages/partner-profil.js b/backend/static/js/pages/partner-profil.js index ca07102..da834ec 100644 --- a/backend/static/js/pages/partner-profil.js +++ b/backend/static/js/pages/partner-profil.js @@ -191,23 +191,34 @@ window.Page_partner_profil = (() => { darüber wird gezählt — so siehst du, was wo funktioniert.

${_qrBatches.map(b => ` -
-
-
${UI.escape(b.label)}
-
${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)}
+
+
+
+
${UI.escape(b.label)}
+
+ ${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)} · + ${b.codes_used}/${b.quantity} verbraucht +
+
+
+
${b.scans}
+
Scans
+
+
+
${b.registrations}${b.attempts ? ` +${b.attempts}` : ''}
+
Registr.
+
+ + + ${UI.icon('file-pdf')} PDF +
-
-
${b.scans}
-
Scans
+ -
-
${b.registrations}${b.attempts ? ` +${b.attempts}` : ''}
-
Registr.
-
- - ${UI.icon('file-pdf')} PDF -
`).join('')}
` : ''} @@ -276,6 +287,35 @@ window.Page_partner_profil = (() => { }); }); + // Einzel-Code-Status eines QR-Kontingents (lazy, .hidden via classList) + el.querySelectorAll('.pp-qr-codes-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const box = el.querySelector(`#pp-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; + const scanned = c.scans > 0; + return ` +
+ #${c.seq} + banyaro.app/q/${UI.escape(c.token)} + ${c.scans} Scan${c.scans === 1 ? '' : 's'} + ${used + ? `● verbraucht` + : scanned + ? `◐ gescannt` + : `○ frei`} +
`; + }).join(''); + } catch (err) { UI.toast.error(err.message); } + }); + }); + // Einreichen el.querySelector('#pp-submit-btn')?.addEventListener('click', async () => { const btn = el.querySelector('#pp-submit-btn'); diff --git a/backend/static/landing.html b/backend/static/landing.html index 2c309e8..e989a93 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index db1b74a..f513e8a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1258'; +const VER = '1259'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_partner_qr.py b/tests/test_partner_qr.py index c9e1376..f02f1d9 100644 --- a/tests/test_partner_qr.py +++ b/tests/test_partner_qr.py @@ -143,5 +143,29 @@ def test_partner_self_service_qr(client, admin, user): 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