diff --git a/VERSION b/VERSION
index 94ad64d..1e36b91 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1257
\ No newline at end of file
+1258
\ No newline at end of file
diff --git a/backend/routes/partner.py b/backend/routes/partner.py
index ec0ebc5..ba3802e 100644
--- a/backend/routes/partner.py
+++ b/backend/routes/partner.py
@@ -575,13 +575,17 @@ def _qr_new_token(conn) -> str:
def _qr_batch_stats(conn, batch_id: int) -> dict:
+ """Registrierungen = E-Mail bestätigt; Versuche = registriert, aber (noch) unbestätigt."""
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
+ 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
FROM partner_qr_codes q WHERE q.batch_id = ?""",
- (batch_id, batch_id)
+ (batch_id, batch_id, batch_id)
).fetchone()
return dict(row)
@@ -713,6 +717,27 @@ def delete_qr_batch(batch_id: int, user=Depends(require_admin)):
return None
+@router.get("/admin/partner/qr-batches/{batch_id}/registrations")
+def qr_batch_registrations(batch_id: int, user=Depends(require_admin)):
+ """Accounts, die über dieses Kontingent kamen — inkl. unbestätigter Versuche.
+ Admin-only (personenbezogene Daten)."""
+ 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.")
+ rows = conn.execute(
+ """SELECT u.id, u.name, u.email, u.email_verified, u.created_at,
+ q.seq, q.token
+ FROM users u
+ JOIN partner_qr_codes q ON q.token = u.referred_qr
+ WHERE q.batch_id = ?
+ ORDER BY u.created_at DESC""",
+ (batch_id,)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
@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
diff --git a/backend/static/index.html b/backend/static/index.html
index 1acae3c..99aaef9 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 5ea91f4..53d3573 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 = '1257'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '1258'; // ← 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/admin.js b/backend/static/js/pages/admin.js
index 3f15c6b..c2f5760 100644
--- a/backend/static/js/pages/admin.js
+++ b/backend/static/js/pages/admin.js
@@ -2431,7 +2431,8 @@ window.Page_admin = (() => {
Kontingent |
Stk. |
Scans |
- Registr. |
+ Registr. |
+ Versuche |
|
@@ -2443,7 +2444,12 @@ window.Page_admin = (() => {
${b.quantity} |
${b.scans} |
${b.registrations} |
+ ${b.attempts} |
+ ${b.registrations + b.attempts > 0 ? `
+ ` : ''}
${UI.icon('file-pdf')} PDF
@@ -2451,6 +2457,11 @@ window.Page_admin = (() => {
${UI.icon('trash')}
|
+
+
+ |
+ Lädt…
+ |
`).join('')}
`}
@@ -2585,6 +2596,35 @@ window.Page_admin = (() => {
});
});
+ // QR-Detail: Accounts hinter einem Kontingent (lazy laden, .hidden via classList)
+ el.querySelectorAll('.adm-qr-detail').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const row = el.querySelector(`#adm-qr-detail-${btn.dataset.id}`);
+ if (!row) return;
+ row.classList.toggle('hidden');
+ if (row.classList.contains('hidden') || row.dataset.loaded === '1') return;
+ try {
+ const regs = await API.get(`/admin/partner/qr-batches/${btn.dataset.id}/registrations`);
+ row.dataset.loaded = '1';
+ const cell = row.querySelector('td');
+ cell.innerHTML = !regs.length
+ ? `Keine Accounts.
`
+ : regs.map(u => `
+
+
+ ${UI.escape(u.name)}
+ · ${UI.escape(u.email)}
+
+
#${u.seq}
+
${(u.created_at || '').slice(0, 16).replace(' ', ' · ')}
+ ${u.email_verified
+ ? `
✓ bestätigt`
+ : `
⏳ Versuch`}
+
`).join('');
+ } catch (err) { UI.toast.error(err.message); }
+ });
+ });
+
// QR-Kontingent löschen (Zweiklick-Pattern statt confirm — Memory: kein Modal-in-Modal)
el.querySelectorAll('.adm-qr-del').forEach(btn => {
btn.addEventListener('click', async () => {
diff --git a/backend/static/js/pages/partner-profil.js b/backend/static/js/pages/partner-profil.js
index 0402c36..ca07102 100644
--- a/backend/static/js/pages/partner-profil.js
+++ b/backend/static/js/pages/partner-profil.js
@@ -196,12 +196,13 @@ window.Page_partner_profil = (() => {
${UI.escape(b.label)}
${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)}
-
+
-
-
${b.registrations}
+
+
${b.registrations}${b.attempts ? ` +${b.attempts}` : ''}
Registr.
diff --git a/backend/static/landing.html b/backend/static/landing.html
index 6863a37..2c309e8 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 b2c12ec..db1b74a 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 = '1257';
+const VER = '1258';
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 5c3b677..c9e1376 100644
--- a/tests/test_partner_qr.py
+++ b/tests/test_partner_qr.py
@@ -63,7 +63,7 @@ def test_scan_redirects_and_counts(client, admin):
def test_registration_attributed_to_qr(client, admin):
- """Registrierung mit ref+qr -> users.referred_qr gesetzt, Kontingent-Stats zaehlen."""
+ """Registrierung mit ref+qr -> referred_qr gesetzt; unbestaetigt=Versuch, bestaetigt=Registrierung."""
code = _create_code(client, admin)
batch = _create_batch(client, admin, code["id"], quantity=2)
token = _batch_tokens(batch["id"])[0]
@@ -81,9 +81,27 @@ def test_registration_attributed_to_qr(client, admin):
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
+ # Frisch registriert = E-Mail unbestaetigt -> zaehlt als Versuch
+ def _batch():
+ r = client.get("/api/admin/partner/qr-batches", headers=admin["headers"])
+ return [b for b in r.json() if b["id"] == batch["id"]][0]
+ assert _batch()["attempts"] == 1 and _batch()["registrations"] == 0
+
+ # Nach E-Mail-Bestaetigung -> echte Registrierung
+ with db() as conn:
+ conn.execute("UPDATE users SET email_verified=1 WHERE email=?", (email,))
+ assert _batch()["registrations"] == 1 and _batch()["attempts"] == 0
+
+ # Admin-Detail-Liste: Account mit Datum, Status und Sticker-Nr
+ r = client.get(f"/api/admin/partner/qr-batches/{batch['id']}/registrations",
+ headers=admin["headers"])
+ assert r.status_code == 200
+ regs = r.json()
+ assert len(regs) == 1
+ assert regs[0]["email"] == email
+ assert regs[0]["email_verified"] == 1
+ assert regs[0]["seq"] == 1
+ assert regs[0]["created_at"]
def test_qr_token_must_match_code(client, admin):