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