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.scans}
Scans
-
-
${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):