Partner-Self-Service: Einzel-Code-Status — welcher Sticker ist verbraucht?
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)
This commit is contained in:
parent
970480c1d6
commit
df2f42f8ac
8 changed files with 134 additions and 41 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1258
|
1259
|
||||||
|
|
@ -583,9 +583,12 @@ def _qr_batch_stats(conn, batch_id: int) -> dict:
|
||||||
WHERE q2.batch_id = ? AND u.email_verified = 1) AS registrations,
|
WHERE q2.batch_id = ? AND u.email_verified = 1) AS registrations,
|
||||||
(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 = ? 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 = ?""",
|
FROM partner_qr_codes q WHERE q.batch_id = ?""",
|
||||||
(batch_id, batch_id, batch_id)
|
(batch_id, batch_id, batch_id, batch_id)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row)
|
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")
|
@router.get("/partner/my-qr/{batch_id}/pdf")
|
||||||
def qr_batch_pdf_partner(batch_id: int, user=Depends(require_partner)):
|
def qr_batch_pdf_partner(batch_id: int, user=Depends(require_partner)):
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
own = conn.execute(
|
_require_own_batch(conn, batch_id, user)
|
||||||
"""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.")
|
|
||||||
pdf = _qr_batch_pdf(conn, batch_id)
|
pdf = _qr_batch_pdf(conn, batch_id)
|
||||||
return Response(content=pdf, media_type="application/pdf",
|
return Response(content=pdf, media_type="application/pdf",
|
||||||
headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'})
|
headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'})
|
||||||
|
|
|
||||||
|
|
@ -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=1258"></script>
|
<script src="/js/boot-early.js?v=1259"></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=1258">
|
<link rel="stylesheet" href="/css/design-system.css?v=1259">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=1258">
|
<link rel="stylesheet" href="/css/layout.css?v=1259">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=1258">
|
<link rel="stylesheet" href="/css/components.css?v=1259">
|
||||||
<link rel="stylesheet" href="/css/utilities.css?v=1258">
|
<link rel="stylesheet" href="/css/utilities.css?v=1259">
|
||||||
<link rel="stylesheet" href="/css/lists.css?v=1258">
|
<link rel="stylesheet" href="/css/lists.css?v=1259">
|
||||||
</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=1258"></script>
|
<script src="/js/api.js?v=1259"></script>
|
||||||
<script src="/js/ui.js?v=1258"></script>
|
<script src="/js/ui.js?v=1259"></script>
|
||||||
<script src="/js/app.js?v=1258"></script>
|
<script src="/js/app.js?v=1259"></script>
|
||||||
<script src="/js/worlds.js?v=1258"></script>
|
<script src="/js/worlds.js?v=1259"></script>
|
||||||
<script src="/js/offline-indicator.js?v=1258"></script>
|
<script src="/js/offline-indicator.js?v=1259"></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=1258"></script>
|
<script src="/js/boot.js?v=1259"></script>
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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
|
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;
|
||||||
|
|
|
||||||
|
|
@ -191,23 +191,34 @@ window.Page_partner_profil = (() => {
|
||||||
darüber wird gezählt — so siehst du, was wo funktioniert.
|
darüber wird gezählt — so siehst du, was wo funktioniert.
|
||||||
</p>
|
</p>
|
||||||
${_qrBatches.map(b => `
|
${_qrBatches.map(b => `
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border)">
|
<div style="border-bottom:1px solid var(--c-border)">
|
||||||
<div class="flex-1-min">
|
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0">
|
||||||
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(b.label)}</div>
|
<div class="flex-1-min">
|
||||||
<div class="text-xs-muted">${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)}</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)} ·
|
||||||
|
<span style="color:${b.codes_used > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.codes_used}/${b.quantity} verbraucht</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;min-width:48px">
|
||||||
|
<div style="font-weight:700">${b.scans}</div>
|
||||||
|
<div class="text-xs-muted">Scans</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;min-width:48px"
|
||||||
|
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>
|
||||||
|
<button class="btn btn-sm btn-ghost pp-qr-codes-btn" data-id="${b.id}" title="Einzel-Codes anzeigen">
|
||||||
|
${UI.icon('list')}
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-sm btn-secondary" href="/api/partner/my-qr/${b.id}/pdf" download>
|
||||||
|
${UI.icon('file-pdf')} PDF
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;min-width:48px">
|
<div class="hidden" id="pp-qr-codes-${b.id}" style="padding:0 0 var(--space-3)">
|
||||||
<div style="font-weight:700">${b.scans}</div>
|
<div class="text-sm-muted">Lädt…</div>
|
||||||
<div class="text-xs-muted">Scans</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;min-width:48px"
|
|
||||||
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>
|
|
||||||
<a class="btn btn-sm btn-secondary" href="/api/partner/my-qr/${b.id}/pdf" download>
|
|
||||||
${UI.icon('file-pdf')} PDF
|
|
||||||
</a>
|
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
||||||
|
|
@ -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 `
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-2);padding:3px 0;font-size:var(--text-xs);border-bottom:1px dashed var(--c-border)">
|
||||||
|
<span style="font-weight:700;min-width:34px">#${c.seq}</span>
|
||||||
|
<code class="flex-1-min" style="color:var(--c-text-muted)">banyaro.app/q/${UI.escape(c.token)}</code>
|
||||||
|
<span class="text-xs-muted" style="min-width:60px;text-align:right">${c.scans} Scan${c.scans === 1 ? '' : 's'}</span>
|
||||||
|
${used
|
||||||
|
? `<span class="badge" style="background:#dcfce7;color:#16a34a" title="Registrierung am ${(c.first_registration_at || '').slice(0, 10)}">● verbraucht</span>`
|
||||||
|
: scanned
|
||||||
|
? `<span class="badge" style="background:#fef9c3;color:#a16207" title="Gescannt${c.last_scan_at ? ' am ' + c.last_scan_at.slice(0, 10) : ''}, noch keine bestätigte Registrierung">◐ gescannt</span>`
|
||||||
|
: `<span class="badge" style="background:var(--c-surface-2);color:var(--c-text-muted)">○ frei</span>`}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (err) { UI.toast.error(err.message); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Einreichen
|
// Einreichen
|
||||||
el.querySelector('#pp-submit-btn')?.addEventListener('click', async () => {
|
el.querySelector('#pp-submit-btn')?.addEventListener('click', async () => {
|
||||||
const btn = el.querySelector('#pp-submit-btn');
|
const btn = el.querySelector('#pp-submit-btn');
|
||||||
|
|
|
||||||
|
|
@ -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=1258"></script>
|
<script src="/js/landing-init.js?v=1259"></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 = '1258';
|
const VER = '1259';
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -143,5 +143,29 @@ def test_partner_self_service_qr(client, admin, user):
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
r = client.get("/api/partner/my-qr", headers=user["headers"])
|
r = client.get("/api/partner/my-qr", headers=user["headers"])
|
||||||
assert [b["id"] for b in r.json()] == [batch["id"]]
|
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"])
|
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"
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue