QR-Stats: Registrierungen (bestätigt) vs. Versuche (unbestätigt) + Account-Detail-Liste
Rene: Statistik zählte alles in einen Topf (3 statt 2) und zeigte nicht, WER sich registriert hat. Jetzt: - registrations = email_verified=1, attempts = unbestätigte Versuche — Versuche werden bei späterer Bestätigung automatisch zu Registrierungen - Admin: 👥-Button pro Kontingent klappt Account-Liste auf (Name, E-Mail, Datum, ✓ bestätigt/⏳ Versuch, Sticker-Nr #seq) — lazy geladen, admin-only (personenbezogene Daten); Partner sehen weiter nur Zahlen (Registr. +N) - Test deckt Versuch→Bestätigung-Übergang und Detail-Endpoint ab
This commit is contained in:
parent
f604ab7c4f
commit
970480c1d6
9 changed files with 110 additions and 26 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1257
|
1258
|
||||||
|
|
@ -575,13 +575,17 @@ def _qr_new_token(conn) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _qr_batch_stats(conn, batch_id: int) -> dict:
|
def _qr_batch_stats(conn, batch_id: int) -> dict:
|
||||||
|
"""Registrierungen = E-Mail bestätigt; Versuche = registriert, aber (noch) unbestätigt."""
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""SELECT COUNT(*) AS codes, COALESCE(SUM(q.scans),0) AS scans,
|
"""SELECT COUNT(*) AS codes, COALESCE(SUM(q.scans),0) AS scans,
|
||||||
(SELECT COUNT(*) FROM users u
|
(SELECT COUNT(*) FROM users u
|
||||||
JOIN partner_qr_codes q2 ON q2.token = u.referred_qr
|
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 = ?""",
|
FROM partner_qr_codes q WHERE q.batch_id = ?""",
|
||||||
(batch_id, batch_id)
|
(batch_id, batch_id, batch_id)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
@ -713,6 +717,27 @@ def delete_qr_batch(batch_id: int, user=Depends(require_admin)):
|
||||||
return None
|
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")
|
@router.get("/admin/partner/qr-batches/{batch_id}/pdf")
|
||||||
def qr_batch_pdf_admin(batch_id: int, user=Depends(require_admin)):
|
def qr_batch_pdf_admin(batch_id: int, user=Depends(require_admin)):
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,14 @@
|
||||||
<title>Ban Yaro</title>
|
<title>Ban Yaro</title>
|
||||||
|
|
||||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||||
<script src="/js/boot-early.js?v=1257"></script>
|
<script src="/js/boot-early.js?v=1258"></script>
|
||||||
|
|
||||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css?v=1257">
|
<link rel="stylesheet" href="/css/design-system.css?v=1258">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=1257">
|
<link rel="stylesheet" href="/css/layout.css?v=1258">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=1257">
|
<link rel="stylesheet" href="/css/components.css?v=1258">
|
||||||
<link rel="stylesheet" href="/css/utilities.css?v=1257">
|
<link rel="stylesheet" href="/css/utilities.css?v=1258">
|
||||||
<link rel="stylesheet" href="/css/lists.css?v=1257">
|
<link rel="stylesheet" href="/css/lists.css?v=1258">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -612,11 +612,11 @@
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
|
|
||||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||||
<script src="/js/api.js?v=1257"></script>
|
<script src="/js/api.js?v=1258"></script>
|
||||||
<script src="/js/ui.js?v=1257"></script>
|
<script src="/js/ui.js?v=1258"></script>
|
||||||
<script src="/js/app.js?v=1257"></script>
|
<script src="/js/app.js?v=1258"></script>
|
||||||
<script src="/js/worlds.js?v=1257"></script>
|
<script src="/js/worlds.js?v=1258"></script>
|
||||||
<script src="/js/offline-indicator.js?v=1257"></script>
|
<script src="/js/offline-indicator.js?v=1258"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
@ -626,7 +626,7 @@
|
||||||
|
|
||||||
|
|
||||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||||
<script src="/js/boot.js?v=1257"></script>
|
<script src="/js/boot.js?v=1258"></script>
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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
|
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_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
|
||||||
window.APP_VERSION = APP_VERSION;
|
window.APP_VERSION = APP_VERSION;
|
||||||
|
|
|
||||||
|
|
@ -2431,7 +2431,8 @@ window.Page_admin = (() => {
|
||||||
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Kontingent</th>
|
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Kontingent</th>
|
||||||
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Stk.</th>
|
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Stk.</th>
|
||||||
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Scans</th>
|
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Scans</th>
|
||||||
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Registr.</th>
|
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)" title="E-Mail bestätigt">Registr.</th>
|
||||||
|
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)" title="Registriert, aber E-Mail (noch) unbestätigt">Versuche</th>
|
||||||
<th style="padding:var(--space-2) var(--space-3)"></th>
|
<th style="padding:var(--space-2) var(--space-3)"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -2443,7 +2444,12 @@ window.Page_admin = (() => {
|
||||||
<td style="padding:var(--space-2) var(--space-3);text-align:center">${b.quantity}</td>
|
<td style="padding:var(--space-2) var(--space-3);text-align:center">${b.quantity}</td>
|
||||||
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">${b.scans}</td>
|
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">${b.scans}</td>
|
||||||
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600;color:${b.registrations > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.registrations}</td>
|
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600;color:${b.registrations > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.registrations}</td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3);text-align:center;color:${b.attempts > 0 ? 'var(--c-warning,#e65100)' : 'var(--c-text-muted)'}">${b.attempts}</td>
|
||||||
<td style="padding:var(--space-2) var(--space-3);text-align:right;white-space:nowrap">
|
<td style="padding:var(--space-2) var(--space-3);text-align:right;white-space:nowrap">
|
||||||
|
${b.registrations + b.attempts > 0 ? `
|
||||||
|
<button class="btn btn-ghost btn-sm adm-qr-detail" data-id="${b.id}" title="Accounts anzeigen">
|
||||||
|
${UI.icon('users')}
|
||||||
|
</button>` : ''}
|
||||||
<a class="btn btn-sm btn-secondary" href="/api/admin/partner/qr-batches/${b.id}/pdf" download>
|
<a class="btn btn-sm btn-secondary" href="/api/admin/partner/qr-batches/${b.id}/pdf" download>
|
||||||
${UI.icon('file-pdf')} PDF
|
${UI.icon('file-pdf')} PDF
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -2451,6 +2457,11 @@ window.Page_admin = (() => {
|
||||||
${UI.icon('trash')}
|
${UI.icon('trash')}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="hidden" id="adm-qr-detail-${b.id}">
|
||||||
|
<td colspan="7" style="padding:0 var(--space-3) var(--space-3);background:var(--c-surface-2)">
|
||||||
|
<div class="text-sm-muted" style="padding:var(--space-3) 0">Lädt…</div>
|
||||||
|
</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>`}
|
</table>`}
|
||||||
|
|
@ -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
|
||||||
|
? `<div class="text-sm-muted" style="padding:var(--space-3) 0">Keine Accounts.</div>`
|
||||||
|
: regs.map(u => `
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border);font-size:var(--text-sm)">
|
||||||
|
<div class="flex-1-min">
|
||||||
|
<span style="font-weight:600">${UI.escape(u.name)}</span>
|
||||||
|
<span class="text-xs-muted">· ${UI.escape(u.email)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs-muted" title="Über welchen Einzel-Code (Sticker-Nr.)">#${u.seq}</span>
|
||||||
|
<span class="text-xs-muted">${(u.created_at || '').slice(0, 16).replace(' ', ' · ')}</span>
|
||||||
|
${u.email_verified
|
||||||
|
? `<span class="badge" style="background:#dcfce7;color:#16a34a">✓ bestätigt</span>`
|
||||||
|
: `<span class="badge" style="background:#fef9c3;color:#a16207" title="Registriert, E-Mail noch nicht bestätigt">⏳ Versuch</span>`}
|
||||||
|
</div>`).join('');
|
||||||
|
} catch (err) { UI.toast.error(err.message); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// QR-Kontingent löschen (Zweiklick-Pattern statt confirm — Memory: kein Modal-in-Modal)
|
// QR-Kontingent löschen (Zweiklick-Pattern statt confirm — Memory: kein Modal-in-Modal)
|
||||||
el.querySelectorAll('.adm-qr-del').forEach(btn => {
|
el.querySelectorAll('.adm-qr-del').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
|
|
|
||||||
|
|
@ -196,12 +196,13 @@ window.Page_partner_profil = (() => {
|
||||||
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(b.label)}</div>
|
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(b.label)}</div>
|
||||||
<div class="text-xs-muted">${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)}</div>
|
<div class="text-xs-muted">${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;min-width:54px">
|
<div style="text-align:center;min-width:48px">
|
||||||
<div style="font-weight:700">${b.scans}</div>
|
<div style="font-weight:700">${b.scans}</div>
|
||||||
<div class="text-xs-muted">Scans</div>
|
<div class="text-xs-muted">Scans</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;min-width:54px">
|
<div style="text-align:center;min-width:48px"
|
||||||
<div style="font-weight:700;color:${b.registrations > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.registrations}</div>
|
title="Registriert und E-Mail bestätigt${b.attempts ? ` — dazu ${b.attempts} unbestätigte` : ''}">
|
||||||
|
<div style="font-weight:700;color:${b.registrations > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.registrations}${b.attempts ? `<span class="text-xs-muted" style="font-weight:400"> +${b.attempts}</span>` : ''}</div>
|
||||||
<div class="text-xs-muted">Registr.</div>
|
<div class="text-xs-muted">Registr.</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-sm btn-secondary" href="/api/partner/my-qr/${b.id}/pdf" download>
|
<a class="btn btn-sm btn-secondary" href="/api/partner/my-qr/${b.id}/pdf" download>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<script src="/js/landing-init.js?v=1257"></script>
|
<script src="/js/landing-init.js?v=1258"></script>
|
||||||
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
||||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser.">
|
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser.">
|
||||||
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
// ← 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_VERSION = `by-v${VER}`;
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ def test_scan_redirects_and_counts(client, admin):
|
||||||
|
|
||||||
|
|
||||||
def test_registration_attributed_to_qr(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)
|
code = _create_code(client, admin)
|
||||||
batch = _create_batch(client, admin, code["id"], quantity=2)
|
batch = _create_batch(client, admin, code["id"], quantity=2)
|
||||||
token = _batch_tokens(batch["id"])[0]
|
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_by"] == -code["id"]
|
||||||
assert row["referred_qr"] == token
|
assert row["referred_qr"] == token
|
||||||
|
|
||||||
r = client.get("/api/admin/partner/qr-batches", headers=admin["headers"])
|
# Frisch registriert = E-Mail unbestaetigt -> zaehlt als Versuch
|
||||||
mine = [b for b in r.json() if b["id"] == batch["id"]][0]
|
def _batch():
|
||||||
assert mine["registrations"] == 1
|
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):
|
def test_qr_token_must_match_code(client, admin):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue