Compare commits
15 commits
178aef7fb0
...
2927ae2672
| Author | SHA1 | Date | |
|---|---|---|---|
| 2927ae2672 | |||
| 21bcc6b962 | |||
| 0cca716c3d | |||
| fe783ef01b | |||
| 0a262989f3 | |||
| 3d7d5dc1c4 | |||
| df2f42f8ac | |||
| 970480c1d6 | |||
| f604ab7c4f | |||
| cadfb24a8d | |||
| a40aa183ec | |||
| 73ca66bbf5 | |||
| 8a614eef1a | |||
| 21f54f478b | |||
| ce8aa2b699 |
21 changed files with 1884 additions and 37 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1252
|
||||
1265
|
||||
|
|
@ -265,6 +265,8 @@ def has_pro_access(user: dict) -> bool:
|
|||
return True
|
||||
if user.get("is_moderator") or user.get("is_social_media"):
|
||||
return True
|
||||
if user.get("is_partner"): # Partner (Multiplikatoren) bekommen Pro gratis
|
||||
return True
|
||||
return tier in ("pro", "breeder", "pro_test", "breeder_test")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -623,6 +623,12 @@ def _migrate(conn_factory):
|
|||
("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("users", "founder_number", "INTEGER"),
|
||||
("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"),
|
||||
# QR-Rückverfolgung: über welchen physischen QR-Code (Sticker/Flyer) kam die Registrierung
|
||||
("users", "referred_qr", "TEXT"),
|
||||
# Partner-Code → Besitzer (für Self-Service: eigene QR-Kontingente + Stats einsehen)
|
||||
("partner_codes", "owner_user_id", "INTEGER"),
|
||||
# Notbremse für geleakte Codes: 0 = pausiert (Einlösung gesperrt, Historie bleibt)
|
||||
("partner_codes", "active", "INTEGER NOT NULL DEFAULT 1"),
|
||||
# Passwort-Zurücksetzen
|
||||
("users", "password_reset_token", "TEXT"),
|
||||
("users", "password_reset_expires", "TEXT"),
|
||||
|
|
@ -1630,6 +1636,103 @@ def _migrate(conn_factory):
|
|||
except Exception as e:
|
||||
logger.warning(f"Migration partner_codes: {e}")
|
||||
|
||||
# Partner-Profile (öffentlicher Showcase auf /#partner)
|
||||
# approved: 0=Entwurf/in Prüfung, 1=freigegeben, -1=abgelehnt
|
||||
try:
|
||||
# Alt-Schema aus der verlorenen v1102-Session (photos statt photos_json,
|
||||
# id-Autoincrement-PK) kann auf Staging/Prod noch existieren → umbauen.
|
||||
existing_cols = [r[1] for r in conn.execute(
|
||||
"PRAGMA table_info(partner_profiles)"
|
||||
).fetchall()]
|
||||
if existing_cols and "photos_json" not in existing_cols:
|
||||
conn.executescript("""
|
||||
ALTER TABLE partner_profiles RENAME TO partner_profiles_old;
|
||||
CREATE TABLE partner_profiles (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
display_name TEXT,
|
||||
tagline TEXT,
|
||||
bio TEXT,
|
||||
website TEXT,
|
||||
instagram TEXT,
|
||||
logo_url TEXT,
|
||||
photos_json TEXT NOT NULL DEFAULT '[]',
|
||||
approved INTEGER NOT NULL DEFAULT 0,
|
||||
submitted_at TEXT,
|
||||
updated_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT INTO partner_profiles
|
||||
(user_id, display_name, tagline, bio, website, instagram,
|
||||
logo_url, photos_json, approved, submitted_at, updated_at, created_at)
|
||||
SELECT user_id, display_name, tagline, bio, website, instagram,
|
||||
logo_url, COALESCE(photos, '[]'), COALESCE(approved, 0),
|
||||
submitted_at, NULL, datetime('now')
|
||||
FROM partner_profiles_old;
|
||||
DROP TABLE partner_profiles_old;
|
||||
""")
|
||||
logger.info("Migration: partner_profiles Alt-Schema → neues Schema umgebaut.")
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS partner_profiles (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
display_name TEXT,
|
||||
tagline TEXT,
|
||||
bio TEXT,
|
||||
website TEXT,
|
||||
instagram TEXT,
|
||||
logo_url TEXT,
|
||||
photos_json TEXT NOT NULL DEFAULT '[]',
|
||||
approved INTEGER NOT NULL DEFAULT 0,
|
||||
submitted_at TEXT,
|
||||
updated_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
""")
|
||||
logger.info("Migration: partner_profiles Tabelle bereit.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Migration partner_profiles: {e}")
|
||||
|
||||
# QR-Kontingente für Partner (gedruckte Sticker/Flyer mit Rückverfolgung)
|
||||
# Jeder physische QR-Code hat einen eigenen Token → Scan- und
|
||||
# Registrierungs-Tracking pro Einzelcode und pro Kontingent.
|
||||
try:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS partner_qr_batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partner_code_id INTEGER NOT NULL REFERENCES partner_codes(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS partner_qr_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id INTEGER NOT NULL REFERENCES partner_qr_batches(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
seq INTEGER NOT NULL,
|
||||
scans INTEGER NOT NULL DEFAULT 0,
|
||||
first_scan_at TEXT,
|
||||
last_scan_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pqr_token ON partner_qr_codes(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_pqr_batch ON partner_qr_codes(batch_id);
|
||||
""")
|
||||
logger.info("Migration: partner_qr Tabellen bereit.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Migration partner_qr: {e}")
|
||||
try:
|
||||
# Backfill: Partner, die sich mit ihrem eigenen Code registriert haben,
|
||||
# als Code-Besitzer verknüpfen (für Self-Service-Zugriff auf QR-Stats).
|
||||
# Eigener try-Block: owner_user_id kommt auf frischen DBs erst im 2nd pass.
|
||||
conn.execute("""
|
||||
UPDATE partner_codes SET owner_user_id = (
|
||||
SELECT u.id FROM users u
|
||||
WHERE u.referred_by = -partner_codes.id AND u.is_partner = 1
|
||||
LIMIT 1
|
||||
) WHERE owner_user_id IS NULL
|
||||
""")
|
||||
except Exception as e:
|
||||
logger.debug(f"Backfill partner_codes.owner_user_id übersprungen: {e}")
|
||||
|
||||
# Outreach-Log (Admin-E-Mail-Versand)
|
||||
try:
|
||||
conn.executescript("""
|
||||
|
|
|
|||
|
|
@ -2156,6 +2156,34 @@ setTimeout(() => location.href = '/?_t=' + Date.now() + '&hard=1', 6000);
|
|||
return HTMLResponse(content=html, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
# /q/{token} — Partner-QR-Scan: zählen + auf Registrierung mit Code umleiten
|
||||
# ------------------------------------------------------------------
|
||||
@app.get("/q/{token}")
|
||||
async def partner_qr_scan(token: str):
|
||||
from fastapi.responses import RedirectResponse as _Redirect
|
||||
from database import db as _db
|
||||
token = token.strip()
|
||||
with _db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT token FROM partner_qr_codes WHERE token = ?", (token,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return _Redirect("/", status_code=302)
|
||||
conn.execute(
|
||||
"""UPDATE partner_qr_codes
|
||||
SET scans = scans + 1,
|
||||
first_scan_at = COALESCE(first_scan_at, datetime('now')),
|
||||
last_scan_at = datetime('now')
|
||||
WHERE token = ?""",
|
||||
(token,)
|
||||
)
|
||||
# Bewusst NUR der Token in der URL — der tippbare Partner-Code bleibt verborgen
|
||||
# (sonst könnte jeder Sticker-Scanner den Code ablesen und beliebig weitergeben).
|
||||
# Die Registrierung löst den Code server-seitig aus dem Token auf.
|
||||
return _Redirect(f"/?qr={row['token']}", status_code=302)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /partner — Influencer-Landingpage
|
||||
# ------------------------------------------------------------------
|
||||
@app.get("/partner")
|
||||
|
|
|
|||
|
|
@ -15,5 +15,6 @@ apscheduler==3.10.4
|
|||
odfpy==1.4.1
|
||||
polyline==2.0.2
|
||||
fpdf2==2.8.3
|
||||
segno==1.6.6
|
||||
python-dateutil>=2.9
|
||||
brotli-asgi==1.4.0
|
||||
|
|
|
|||
|
|
@ -152,6 +152,12 @@ async def action_items(user=Depends(require_mod)):
|
|||
).fetchone()[0]
|
||||
except Exception:
|
||||
invoices_unpaid = 0
|
||||
try:
|
||||
partner_profiles_pending = conn.execute(
|
||||
"SELECT COUNT(*) FROM partner_profiles WHERE submitted_at IS NOT NULL AND approved=0"
|
||||
).fetchone()[0]
|
||||
except Exception:
|
||||
partner_profiles_pending = 0
|
||||
return {
|
||||
"jobs_pending": jobs,
|
||||
"breeder_pending": breeders,
|
||||
|
|
@ -161,6 +167,7 @@ async def action_items(user=Depends(require_mod)):
|
|||
"users_today": users_today,
|
||||
"upgrades_pending": upgrades_pending,
|
||||
"invoices_unpaid": invoices_unpaid,
|
||||
"partner_profiles_pending": partner_profiles_pending,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ class RegisterRequest(BaseModel):
|
|||
password: str = Field(..., min_length=8, max_length=200)
|
||||
name: str = Field(..., min_length=2, max_length=40)
|
||||
ref_code: Optional[str] = Field(None, max_length=50)
|
||||
qr_token: Optional[str] = Field(None, max_length=20) # physischer Partner-QR (Sticker/Flyer)
|
||||
|
||||
|
||||
def _gen_referral_code() -> str:
|
||||
|
|
@ -204,11 +205,26 @@ async def register(data: RegisterRequest, response: Response, request: Request):
|
|||
).fetchone()
|
||||
new_user_id = user["id"]
|
||||
|
||||
if data.ref_code:
|
||||
code_upper = data.ref_code.strip().upper()
|
||||
# Zuerst prüfen ob es ein Partner-Code ist
|
||||
# QR-only-Flow: Die Scan-URL trägt bewusst KEINEN Klartext-Code mehr —
|
||||
# der Partner-Code wird hier server-seitig aus dem QR-Token aufgelöst.
|
||||
ref_code_in = data.ref_code
|
||||
if not ref_code_in and data.qr_token:
|
||||
qr_row = conn.execute(
|
||||
"""SELECT pc.code FROM partner_qr_codes q
|
||||
JOIN partner_qr_batches b ON b.id = q.batch_id
|
||||
JOIN partner_codes pc ON pc.id = b.partner_code_id
|
||||
WHERE q.token=?""",
|
||||
(data.qr_token.strip(),)
|
||||
).fetchone()
|
||||
if qr_row:
|
||||
ref_code_in = qr_row["code"]
|
||||
|
||||
if ref_code_in:
|
||||
code_upper = ref_code_in.strip().upper()
|
||||
# Zuerst prüfen ob es ein Partner-Code ist (active=0 = Notbremse bei
|
||||
# geleakten Codes: wird wie nicht existent behandelt, Historie bleibt)
|
||||
partner = conn.execute(
|
||||
"SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=?",
|
||||
"SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=? AND active=1",
|
||||
(code_upper,)
|
||||
).fetchone()
|
||||
if partner:
|
||||
|
|
@ -227,6 +243,16 @@ async def register(data: RegisterRequest, response: Response, request: Request):
|
|||
|
||||
if redeemed:
|
||||
updates = {"referred_by": -partner["id"]}
|
||||
# QR-Rückverfolgung: Token muss zu einem Kontingent DIESES Codes gehören
|
||||
if data.qr_token:
|
||||
qr = conn.execute(
|
||||
"""SELECT q.token FROM partner_qr_codes q
|
||||
JOIN partner_qr_batches b ON b.id = q.batch_id
|
||||
WHERE q.token=? AND b.partner_code_id=?""",
|
||||
(data.qr_token.strip(), partner["id"])
|
||||
).fetchone()
|
||||
if qr:
|
||||
updates["referred_qr"] = qr["token"]
|
||||
if partner["grants_founder"]:
|
||||
total_founders = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE is_founder=1"
|
||||
|
|
@ -386,6 +412,85 @@ async def me(user=Depends(get_current_user)):
|
|||
return data
|
||||
|
||||
|
||||
def _notify_partner_registration(user_id: int):
|
||||
"""Dank-Mail an den Partner (Code-Besitzer), wenn ein Geworbener seine
|
||||
E-Mail bestätigt hat — inkl. kleiner Statistik. Best effort."""
|
||||
import html as _html
|
||||
with db() as conn:
|
||||
u = conn.execute(
|
||||
"SELECT referred_by, referred_qr FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
if not u or (u["referred_by"] or 0) >= 0:
|
||||
return # kein Partner-Code im Spiel
|
||||
code_id = -u["referred_by"]
|
||||
pc = conn.execute(
|
||||
"""SELECT pc.code, pc.label, pc.grants_founder, pc.owner_user_id,
|
||||
o.name AS owner_name, o.email AS owner_email
|
||||
FROM partner_codes pc
|
||||
LEFT JOIN users o ON o.id = pc.owner_user_id
|
||||
WHERE pc.id=?""",
|
||||
(code_id,)
|
||||
).fetchone()
|
||||
if not pc or not pc["owner_email"]:
|
||||
return # Code ohne Besitzer → niemand zu benachrichtigen
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE referred_by=? AND email_verified=1",
|
||||
(-code_id,)
|
||||
).fetchone()[0]
|
||||
month = conn.execute(
|
||||
"""SELECT COUNT(*) FROM users
|
||||
WHERE referred_by=? AND email_verified=1
|
||||
AND strftime('%Y-%m', created_at) = strftime('%Y-%m', 'now')""",
|
||||
(-code_id,)
|
||||
).fetchone()[0]
|
||||
qr_line = ""
|
||||
if u["referred_qr"]:
|
||||
qr = conn.execute(
|
||||
"""SELECT q.seq, b.label FROM partner_qr_codes q
|
||||
JOIN partner_qr_batches b ON b.id = q.batch_id
|
||||
WHERE q.token=?""",
|
||||
(u["referred_qr"],)
|
||||
).fetchone()
|
||||
if qr:
|
||||
qr_line = f"Gekommen über deinen gedruckten QR-Code #{qr['seq']} (Kontingent „{qr['label']}“)."
|
||||
founder_line = ""
|
||||
if pc["grants_founder"]:
|
||||
founders = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE is_founder=1"
|
||||
).fetchone()[0]
|
||||
founder_line = f"Noch {max(0, 100 - founders)} von 100 Gründer-Plätzen frei."
|
||||
|
||||
subject = "🐾 Danke! Neue Registrierung über deinen Partner-Code"
|
||||
_oname = _html.escape(pc["owner_name"] or "Partner")
|
||||
stats_html = (
|
||||
f"<p style='margin:0 0 16px'>Deine Bilanz mit dem Code <b>{pc['code']}</b>:<br>"
|
||||
f"<b>{total}</b> bestätigte Registrierung{'en' if total != 1 else ''} insgesamt · "
|
||||
f"<b>{month}</b> in diesem Monat.</p>"
|
||||
)
|
||||
body_html = f"""
|
||||
<p style="margin:0 0 16px">Hallo <b>{_oname}</b>,</p>
|
||||
<p style="margin:0 0 16px">
|
||||
gerade hat ein neuer Hundefreund seine Registrierung über deinen
|
||||
Partner-Code bestätigt — danke, dass du Ban Yaro weiterträgst! 🎉
|
||||
</p>
|
||||
{f'<p style="margin:0 0 16px">{_html.escape(qr_line)}</p>' if qr_line else ''}
|
||||
{stats_html}
|
||||
{f'<p style="margin:0 0 16px;color:#888">{_html.escape(founder_line)}</p>' if founder_line else ''}"""
|
||||
plain = (f"Hallo {pc['owner_name'] or 'Partner'},\n\n"
|
||||
f"gerade hat ein neuer Hundefreund seine Registrierung über deinen Partner-Code bestätigt — danke!\n"
|
||||
+ (f"\n{qr_line}\n" if qr_line else "")
|
||||
+ f"\nDeine Bilanz mit dem Code {pc['code']}: {total} bestätigte Registrierungen insgesamt, {month} in diesem Monat.\n"
|
||||
+ (f"{founder_line}\n" if founder_line else "")
|
||||
+ f"\nDein Partner-Bereich: {_APP_URL}/#partner-dashboard\n")
|
||||
try:
|
||||
from routes.outreach import _send_smtp
|
||||
from mailer import email_html
|
||||
html = email_html(body_html, cta_url=f"{_APP_URL}/#partner-dashboard", cta_label="Mein Partner-Bereich")
|
||||
_send_smtp(pc["owner_email"], subject, plain, "partner", html=html)
|
||||
except Exception as exc:
|
||||
_log_smtp_failure(pc["owner_email"], subject, plain, exc, context="partner_thank_you")
|
||||
|
||||
|
||||
@router.get("/verify-email/{token}")
|
||||
async def verify_email(token: str):
|
||||
with db() as conn:
|
||||
|
|
@ -398,6 +503,9 @@ async def verify_email(token: str):
|
|||
"UPDATE users SET email_verified=1, verification_token=NULL WHERE id=?",
|
||||
(row["id"],)
|
||||
)
|
||||
# Dank-Mail an den Partner — nur beim ERSTEN Bestätigen (Link doppelt geklickt = kein Spam)
|
||||
if not row["email_verified"]:
|
||||
_notify_partner_registration(row["id"])
|
||||
return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
"""BAN YARO — Partner-Codes + Gründer-Lizenz"""
|
||||
"""BAN YARO — Partner-Codes + Gründer-Lizenz + Partner-Profile (Showcase)"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
|
||||
from pydantic import BaseModel, Field
|
||||
from database import db
|
||||
from auth import require_admin, get_current_user
|
||||
from config import MEDIA_DIR
|
||||
from media_utils import validate_upload, convert_media, safe_media_path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -31,15 +37,32 @@ def list_partner_codes(user=Depends(require_admin)):
|
|||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT pc.id, pc.code, pc.label, pc.grants_founder,
|
||||
pc.max_uses, pc.uses, pc.created_at,
|
||||
u.name AS created_by_name
|
||||
pc.max_uses, pc.uses, pc.created_at, pc.owner_user_id, pc.active,
|
||||
u.name AS created_by_name,
|
||||
o.name AS owner_name
|
||||
FROM partner_codes pc
|
||||
LEFT JOIN users u ON u.id = pc.created_by
|
||||
LEFT JOIN users o ON o.id = pc.owner_user_id
|
||||
ORDER BY pc.created_at DESC"""
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/admin/partner/codes/{code_id}/toggle")
|
||||
def toggle_partner_code(code_id: int, user=Depends(require_admin)):
|
||||
"""Notbremse: Code pausieren/reaktivieren (z. B. wenn er im Internet kursiert).
|
||||
Pausiert = Einlösung gesperrt, Stats und QR-Kontingente bleiben erhalten."""
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT active FROM partner_codes WHERE id=?", (code_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Partner-Code nicht gefunden.")
|
||||
new_state = 0 if row["active"] else 1
|
||||
conn.execute("UPDATE partner_codes SET active=? WHERE id=?", (new_state, code_id))
|
||||
return {"active": new_state}
|
||||
|
||||
|
||||
@router.post("/admin/partner/codes", status_code=201)
|
||||
def create_partner_code(data: PartnerCodeCreate, user=Depends(require_admin)):
|
||||
"""Neuen Partner-Code erstellen (admin only)."""
|
||||
|
|
@ -189,7 +212,7 @@ def partner_code_info(code: str):
|
|||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT code, label, grants_founder, max_uses, uses
|
||||
FROM partner_codes WHERE code=?""",
|
||||
FROM partner_codes WHERE code=? AND active=1""",
|
||||
(code.strip().upper(),)
|
||||
).fetchone()
|
||||
if not row:
|
||||
|
|
@ -206,3 +229,639 @@ def partner_code_info(code: str):
|
|||
r["founder_slots_open"] = None
|
||||
r["redeemable"] = r["max_uses"] is None or r["uses"] < r["max_uses"]
|
||||
return r
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Partner-Profile — Self-Service-Editor + öffentlicher Showcase
|
||||
# Frontend: partner-profil.js (Editor), partner.js (Showcase)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_PP_STORAGE_LIMIT_MB = 200 # Gesamt-Budget pro Partner (Frontend zeigt die Bar dazu)
|
||||
_PP_MAX_PHOTOS = 6
|
||||
_PP_LOGO_MAX_MB = 5
|
||||
_PP_FILE_MAX_MB = 200 # pro Datei (Videos)
|
||||
|
||||
_PP_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".heic", ".heif"}
|
||||
_PP_VIDEO_EXTS = {".mp4", ".webm", ".mov", ".avi", ".m4v"}
|
||||
|
||||
|
||||
def require_partner(user=Depends(get_current_user)):
|
||||
if not (user.get("is_partner") or user.get("rolle") == "admin"):
|
||||
raise HTTPException(403, "Nur für Partner.")
|
||||
return user
|
||||
|
||||
|
||||
def _pp_dir(user_id: int) -> str:
|
||||
path = os.path.join(MEDIA_DIR, "partner", str(user_id))
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _pp_storage_mb(user_id: int) -> float:
|
||||
"""Belegter Speicher des Partners in MB (Logo + Fotos/Videos)."""
|
||||
path = os.path.join(MEDIA_DIR, "partner", str(user_id))
|
||||
if not os.path.isdir(path):
|
||||
return 0.0
|
||||
total = sum(
|
||||
os.path.getsize(os.path.join(path, f))
|
||||
for f in os.listdir(path)
|
||||
if os.path.isfile(os.path.join(path, f))
|
||||
)
|
||||
return round(total / (1024 * 1024), 4)
|
||||
|
||||
|
||||
def _pp_profile_dict(row) -> dict:
|
||||
d = dict(row)
|
||||
try:
|
||||
d["photos"] = json.loads(d.pop("photos_json") or "[]")
|
||||
except (ValueError, TypeError):
|
||||
d["photos"] = []
|
||||
return d
|
||||
|
||||
|
||||
def _pp_get_or_empty(conn, user_id: int) -> dict:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM partner_profiles WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
return _pp_profile_dict(row) if row else {}
|
||||
|
||||
|
||||
class PartnerProfileUpdate(BaseModel):
|
||||
display_name: Optional[str] = Field(None, max_length=60)
|
||||
tagline: Optional[str] = Field(None, max_length=80)
|
||||
bio: Optional[str] = Field(None, max_length=500)
|
||||
website: Optional[str] = Field(None, max_length=300)
|
||||
instagram: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
@router.get("/partner/my-profile")
|
||||
def get_my_partner_profile(user=Depends(require_partner)):
|
||||
with db() as conn:
|
||||
profile = _pp_get_or_empty(conn, user["id"])
|
||||
return {
|
||||
"profile": profile,
|
||||
"storage_mb": _pp_storage_mb(user["id"]),
|
||||
"storage_limit_mb": _PP_STORAGE_LIMIT_MB,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/partner/my-profile")
|
||||
def update_my_partner_profile(data: PartnerProfileUpdate, user=Depends(require_partner)):
|
||||
website = (data.website or "").strip()
|
||||
if website and not website.startswith(("http://", "https://")):
|
||||
website = "https://" + website
|
||||
instagram = (data.instagram or "").strip().lstrip("@")
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO partner_profiles
|
||||
(user_id, display_name, tagline, bio, website, instagram, updated_at)
|
||||
VALUES (?,?,?,?,?,?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name=excluded.display_name, tagline=excluded.tagline,
|
||||
bio=excluded.bio, website=excluded.website,
|
||||
instagram=excluded.instagram, updated_at=datetime('now')""",
|
||||
(user["id"], (data.display_name or "").strip() or None,
|
||||
(data.tagline or "").strip() or None, (data.bio or "").strip() or None,
|
||||
website or None, ("@" + instagram) if instagram else None)
|
||||
)
|
||||
profile = _pp_get_or_empty(conn, user["id"])
|
||||
return {"profile": profile}
|
||||
|
||||
|
||||
@router.post("/partner/my-profile/logo")
|
||||
async def upload_partner_logo(file: UploadFile = File(...), user=Depends(require_partner)):
|
||||
raw = await file.read()
|
||||
filename = file.filename or "logo.png"
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in _PP_IMAGE_EXTS:
|
||||
raise HTTPException(400, "Nur Bilder (PNG, JPG, WebP) als Logo.")
|
||||
if len(raw) > _PP_LOGO_MAX_MB * 1024 * 1024:
|
||||
raise HTTPException(400, f"Logo zu groß (max. {_PP_LOGO_MAX_MB} MB).")
|
||||
try:
|
||||
validate_upload(raw, filename)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
save_dir = _pp_dir(user["id"])
|
||||
new_name = f"logo_{uuid.uuid4().hex[:8]}.webp"
|
||||
new_path = os.path.join(save_dir, new_name)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
# HEIC/HEIF (iPhone) zuerst nach JPEG wandeln — Pillow kann HEIC nicht ohne Opener
|
||||
data, ext = await loop.run_in_executor(None, lambda: convert_media(raw, filename))
|
||||
if ext in (".heic", ".heif"):
|
||||
raise HTTPException(400, "HEIC-Bild konnte nicht konvertiert werden. Bitte als JPG/PNG exportieren.")
|
||||
|
||||
def _save():
|
||||
import io
|
||||
from PIL import Image, ImageOps
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img = ImageOps.exif_transpose(img)
|
||||
# Transparenz erhalten (Logos sind oft PNG mit Alpha)
|
||||
img = img.convert("RGBA" if "A" in (img.mode or "") or img.mode == "P" else "RGB")
|
||||
img.thumbnail((512, 512))
|
||||
img.save(new_path, format="WEBP", quality=85)
|
||||
|
||||
try:
|
||||
await loop.run_in_executor(None, _save)
|
||||
except Exception:
|
||||
raise HTTPException(400, "Bild konnte nicht verarbeitet werden.")
|
||||
|
||||
logo_url = f"/media/partner/{user['id']}/{new_name}"
|
||||
with db() as conn:
|
||||
old = conn.execute(
|
||||
"SELECT logo_url FROM partner_profiles WHERE user_id=?", (user["id"],)
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"""INSERT INTO partner_profiles (user_id, logo_url, updated_at)
|
||||
VALUES (?,?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
logo_url=excluded.logo_url, updated_at=datetime('now')""",
|
||||
(user["id"], logo_url)
|
||||
)
|
||||
# Altes Logo vom Datenträger räumen
|
||||
if old and old["logo_url"]:
|
||||
old_path = safe_media_path(MEDIA_DIR, old["logo_url"])
|
||||
if old_path and os.path.isfile(old_path):
|
||||
try:
|
||||
os.unlink(old_path)
|
||||
except OSError:
|
||||
pass
|
||||
return {"logo_url": logo_url}
|
||||
|
||||
|
||||
@router.post("/partner/my-profile/photos")
|
||||
async def upload_partner_photo(file: UploadFile = File(...), user=Depends(require_partner)):
|
||||
raw = await file.read()
|
||||
filename = file.filename or "upload.jpg"
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in _PP_IMAGE_EXTS | _PP_VIDEO_EXTS:
|
||||
raise HTTPException(400, "Nur Bilder (JPG, PNG, HEIC) oder Videos (MP4, MOV).")
|
||||
if len(raw) > _PP_FILE_MAX_MB * 1024 * 1024:
|
||||
raise HTTPException(400, f"Datei zu groß (max. {_PP_FILE_MAX_MB} MB).")
|
||||
used_mb = _pp_storage_mb(user["id"])
|
||||
if used_mb + len(raw) / (1024 * 1024) > _PP_STORAGE_LIMIT_MB:
|
||||
raise HTTPException(400, f"Speicherlimit erreicht ({_PP_STORAGE_LIMIT_MB} MB). Bitte zuerst Dateien löschen.")
|
||||
try:
|
||||
validate_upload(raw, filename)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
with db() as conn:
|
||||
profile = _pp_get_or_empty(conn, user["id"])
|
||||
photos = profile.get("photos", [])
|
||||
if len(photos) >= _PP_MAX_PHOTOS:
|
||||
raise HTTPException(400, f"Maximal {_PP_MAX_PHOTOS} Fotos/Videos.")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
# HEIC→JPEG bzw. MOV/AVI→MP4 (ffmpeg, komprimiert) — blockierend, daher Threadpool
|
||||
data, ext = await loop.run_in_executor(None, lambda: convert_media(raw, filename))
|
||||
if ext in (".heic", ".heif"):
|
||||
raise HTTPException(400, "HEIC-Bild konnte nicht konvertiert werden. Bitte als JPG/PNG exportieren.")
|
||||
if ext in (".mov", ".avi", ".m4v"):
|
||||
# ffmpeg-Konvertierung fehlgeschlagen — unkonvertiert wäre es im Browser nicht abspielbar
|
||||
raise HTTPException(400, "Video konnte nicht konvertiert werden. Bitte als MP4 hochladen.")
|
||||
|
||||
save_dir = _pp_dir(user["id"])
|
||||
file_id = uuid.uuid4().hex[:12]
|
||||
if ext in _PP_VIDEO_EXTS:
|
||||
new_name = f"media_{file_id}{ext}"
|
||||
new_path = os.path.join(save_dir, new_name)
|
||||
|
||||
def _save_video():
|
||||
with open(new_path, "wb") as f:
|
||||
f.write(data)
|
||||
await loop.run_in_executor(None, _save_video)
|
||||
else:
|
||||
new_name = f"media_{file_id}.webp"
|
||||
new_path = os.path.join(save_dir, new_name)
|
||||
|
||||
def _save_image():
|
||||
import io
|
||||
from PIL import Image, ImageOps
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img = ImageOps.exif_transpose(img)
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail((1600, 1600))
|
||||
img.save(new_path, format="WEBP", quality=85)
|
||||
try:
|
||||
await loop.run_in_executor(None, _save_image)
|
||||
except Exception:
|
||||
raise HTTPException(400, "Bild konnte nicht verarbeitet werden.")
|
||||
|
||||
photos.append(f"/media/partner/{user['id']}/{new_name}")
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO partner_profiles (user_id, photos_json, updated_at)
|
||||
VALUES (?,?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
photos_json=excluded.photos_json, updated_at=datetime('now')""",
|
||||
(user["id"], json.dumps(photos))
|
||||
)
|
||||
return {"photos": photos}
|
||||
|
||||
|
||||
@router.post("/partner/my-profile/photos/{idx}/delete")
|
||||
def delete_partner_photo(idx: int, user=Depends(require_partner)):
|
||||
with db() as conn:
|
||||
profile = _pp_get_or_empty(conn, user["id"])
|
||||
photos = profile.get("photos", [])
|
||||
if not (0 <= idx < len(photos)):
|
||||
raise HTTPException(404, "Foto nicht gefunden.")
|
||||
url = photos.pop(idx)
|
||||
conn.execute(
|
||||
"UPDATE partner_profiles SET photos_json=?, updated_at=datetime('now') WHERE user_id=?",
|
||||
(json.dumps(photos), user["id"])
|
||||
)
|
||||
path = safe_media_path(MEDIA_DIR, url)
|
||||
if path and os.path.isfile(path):
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
return {"photos": photos}
|
||||
|
||||
|
||||
@router.post("/partner/my-profile/submit")
|
||||
def submit_partner_profile(user=Depends(require_partner)):
|
||||
with db() as conn:
|
||||
profile = _pp_get_or_empty(conn, user["id"])
|
||||
if not profile.get("display_name"):
|
||||
raise HTTPException(400, "Bitte zuerst einen Anzeigenamen speichern.")
|
||||
# Abgelehnt → erneutes Einreichen setzt zurück auf 'in Prüfung'
|
||||
conn.execute(
|
||||
"""UPDATE partner_profiles
|
||||
SET submitted_at=datetime('now'),
|
||||
approved=CASE WHEN approved=1 THEN 1 ELSE 0 END,
|
||||
updated_at=datetime('now')
|
||||
WHERE user_id=?""",
|
||||
(user["id"],)
|
||||
)
|
||||
profile = _pp_get_or_empty(conn, user["id"])
|
||||
# Admin benachrichtigen — Fehler landen in failed_emails (Admin-Retry), kein Silent-Skip
|
||||
admin_email = os.getenv("ADMIN_EMAIL", "")
|
||||
if admin_email and profile.get("approved") != 1:
|
||||
subject = f"[Ban Yaro] Partner-Profil eingereicht: {profile.get('display_name')}"
|
||||
body = (f"Partner {user['name']} ({user['email']}) hat sein Profil zur "
|
||||
f"Freigabe eingereicht.\n\nAdmin-Panel: https://banyaro.app/#admin")
|
||||
try:
|
||||
from routes.outreach import _send_smtp
|
||||
_send_smtp(admin_email, subject, body, "support")
|
||||
except Exception as exc:
|
||||
from routes.auth import _log_smtp_failure
|
||||
_log_smtp_failure(admin_email, subject, body, exc, context="partner_profile_submit")
|
||||
return {"profile": profile}
|
||||
|
||||
|
||||
@router.get("/partners/public")
|
||||
def list_public_partners():
|
||||
"""Freigegebene Partner-Profile für die öffentliche Partner-Seite."""
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT pp.user_id, pp.display_name, pp.tagline, pp.bio, pp.website,
|
||||
pp.instagram, pp.logo_url, pp.photos_json,
|
||||
u.name, u.avatar_url
|
||||
FROM partner_profiles pp
|
||||
JOIN users u ON u.id = pp.user_id
|
||||
WHERE pp.approved=1 AND u.is_partner=1
|
||||
ORDER BY pp.submitted_at ASC"""
|
||||
).fetchall()
|
||||
return {"partners": [_pp_profile_dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ---- Admin: Freigabe-Workflow ------------------------------------
|
||||
|
||||
class PartnerProfileReview(BaseModel):
|
||||
approved: int = Field(..., ge=-1, le=1)
|
||||
|
||||
|
||||
@router.get("/admin/partner/profiles")
|
||||
def list_partner_profiles(user=Depends(require_admin)):
|
||||
"""Alle Partner-Profile mit Status für den Admin-Tab."""
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT pp.*, u.name, u.email
|
||||
FROM partner_profiles pp
|
||||
JOIN users u ON u.id = pp.user_id
|
||||
ORDER BY CASE WHEN pp.submitted_at IS NOT NULL AND pp.approved=0 THEN 0 ELSE 1 END,
|
||||
pp.updated_at DESC"""
|
||||
).fetchall()
|
||||
return [_pp_profile_dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/admin/partner/profiles/{user_id}/review")
|
||||
def review_partner_profile(user_id: int, data: PartnerProfileReview, user=Depends(require_admin)):
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM partner_profiles WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Partner-Profil nicht gefunden.")
|
||||
conn.execute(
|
||||
"UPDATE partner_profiles SET approved=?, updated_at=datetime('now') WHERE user_id=?",
|
||||
(data.approved, user_id)
|
||||
)
|
||||
profile = _pp_get_or_empty(conn, user_id)
|
||||
return {"profile": profile}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# QR-Kontingente — gedruckte Sticker/Flyer mit Scan- und
|
||||
# Registrierungs-Rückverfolgung pro Einzelcode und Kontingent.
|
||||
# Bestellung: Admin legt Kontingent für einen Partner-Code an.
|
||||
# Übergabe: PDF-Download (Admin + Partner im eigenen Profil).
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_QR_MAX_QUANTITY = 500
|
||||
# Ohne verwechselbare Zeichen (0/O, 1/l/I) — Tokens landen gedruckt auf Stickern
|
||||
_QR_ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
_QR_BASE_URL = os.getenv("APP_URL", "https://banyaro.app")
|
||||
|
||||
|
||||
def _qr_new_token(conn) -> str:
|
||||
import secrets
|
||||
for _ in range(20):
|
||||
token = "".join(secrets.choice(_QR_ALPHABET) for _ in range(8))
|
||||
if not conn.execute(
|
||||
"SELECT 1 FROM partner_qr_codes WHERE token=?", (token,)
|
||||
).fetchone():
|
||||
return token
|
||||
raise HTTPException(500, "Token-Generierung fehlgeschlagen.")
|
||||
|
||||
|
||||
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 = ? 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,
|
||||
(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)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _qr_list_batches(conn, where_sql: str, params: tuple) -> list:
|
||||
rows = conn.execute(
|
||||
f"""SELECT b.id, b.label, b.quantity, b.created_at,
|
||||
pc.code, pc.label AS code_label, pc.id AS partner_code_id
|
||||
FROM partner_qr_batches b
|
||||
JOIN partner_codes pc ON pc.id = b.partner_code_id
|
||||
{where_sql}
|
||||
ORDER BY b.created_at DESC""",
|
||||
params
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d.update(_qr_batch_stats(conn, r["id"]))
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
def _qr_batch_pdf(conn, batch_id: int) -> bytes:
|
||||
"""Druckfertiges A4-PDF: 3×4 QR-Codes pro Seite mit Kurz-URL + laufender Nummer."""
|
||||
import io as _io
|
||||
import segno
|
||||
from fpdf import FPDF
|
||||
|
||||
batch = conn.execute(
|
||||
"""SELECT b.id, b.label, b.quantity, pc.code, pc.label AS code_label
|
||||
FROM partner_qr_batches b
|
||||
JOIN partner_codes pc ON pc.id = b.partner_code_id
|
||||
WHERE b.id=?""",
|
||||
(batch_id,)
|
||||
).fetchone()
|
||||
if not batch:
|
||||
raise HTTPException(404, "Kontingent nicht gefunden.")
|
||||
codes = conn.execute(
|
||||
"SELECT token, seq FROM partner_qr_codes WHERE batch_id=? ORDER BY seq",
|
||||
(batch_id,)
|
||||
).fetchall()
|
||||
|
||||
pdf = FPDF(format="A4")
|
||||
pdf.set_auto_page_break(False)
|
||||
pdf.set_title(f"Ban Yaro QR-Kontingent — {batch['label']}")
|
||||
|
||||
COLS, ROWS = 3, 4
|
||||
CELL_W, CELL_H = 60, 64 # mm — Zelle inkl. Beschriftung
|
||||
MARGIN_X = (210 - COLS * CELL_W) / 2
|
||||
MARGIN_Y = 18
|
||||
QR_SIZE = 42 # mm
|
||||
|
||||
def _latin1(s: str) -> str:
|
||||
return s.encode("latin-1", "replace").decode("latin-1")
|
||||
|
||||
for i, c in enumerate(codes):
|
||||
pos = i % (COLS * ROWS)
|
||||
if pos == 0:
|
||||
pdf.add_page()
|
||||
pdf.set_font("Helvetica", "B", 11)
|
||||
pdf.set_text_color(60)
|
||||
pdf.cell(0, 6, _latin1(f"Ban Yaro — {batch['code_label']} · Kontingent: {batch['label']} ({batch['quantity']} Stk.)"),
|
||||
align="C", new_x="LMARGIN", new_y="NEXT")
|
||||
col, row_ = pos % COLS, pos // COLS
|
||||
x = MARGIN_X + col * CELL_W
|
||||
y = MARGIN_Y + 10 + row_ * CELL_H
|
||||
|
||||
url = f"{_QR_BASE_URL}/q/{c['token']}"
|
||||
buf = _io.BytesIO()
|
||||
segno.make(url, error="m").save(buf, kind="png", scale=8, border=2)
|
||||
buf.seek(0)
|
||||
pdf.image(buf, x=x + (CELL_W - QR_SIZE) / 2, y=y, w=QR_SIZE, h=QR_SIZE)
|
||||
|
||||
pdf.set_xy(x, y + QR_SIZE + 1)
|
||||
pdf.set_font("Helvetica", "", 8)
|
||||
pdf.set_text_color(90)
|
||||
pdf.cell(CELL_W, 4, _latin1(f"banyaro.app/q/{c['token']}"), align="C", new_x="LEFT", new_y="NEXT")
|
||||
pdf.set_x(x)
|
||||
pdf.set_font("Helvetica", "B", 8)
|
||||
pdf.cell(CELL_W, 4, f"#{c['seq']}", align="C")
|
||||
|
||||
return bytes(pdf.output())
|
||||
|
||||
|
||||
class QrBatchCreate(BaseModel):
|
||||
label: str = Field(..., min_length=1, max_length=100)
|
||||
quantity: int = Field(..., ge=1, le=_QR_MAX_QUANTITY)
|
||||
|
||||
|
||||
@router.post("/admin/partner/codes/{code_id}/qr-batches", status_code=201)
|
||||
def create_qr_batch(code_id: int, data: QrBatchCreate, user=Depends(require_admin)):
|
||||
"""Bestellung: neues QR-Kontingent für einen Partner-Code anlegen."""
|
||||
with db() as conn:
|
||||
code = conn.execute(
|
||||
"SELECT id FROM partner_codes WHERE id=?", (code_id,)
|
||||
).fetchone()
|
||||
if not code:
|
||||
raise HTTPException(404, "Partner-Code nicht gefunden.")
|
||||
conn.execute(
|
||||
"INSERT INTO partner_qr_batches (partner_code_id, label, quantity, created_by) VALUES (?,?,?,?)",
|
||||
(code_id, data.label.strip(), data.quantity, user["id"])
|
||||
)
|
||||
batch_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
for seq in range(1, data.quantity + 1):
|
||||
conn.execute(
|
||||
"INSERT INTO partner_qr_codes (batch_id, token, seq) VALUES (?,?,?)",
|
||||
(batch_id, _qr_new_token(conn), seq)
|
||||
)
|
||||
batches = _qr_list_batches(conn, "WHERE b.id=?", (batch_id,))
|
||||
return batches[0]
|
||||
|
||||
|
||||
@router.get("/admin/partner/qr-batches")
|
||||
def list_qr_batches(user=Depends(require_admin)):
|
||||
"""Alle QR-Kontingente mit Stats (Scans, Registrierungen)."""
|
||||
with db() as conn:
|
||||
return _qr_list_batches(conn, "", ())
|
||||
|
||||
|
||||
@router.delete("/admin/partner/qr-batches/{batch_id}", status_code=204)
|
||||
def delete_qr_batch(batch_id: int, user=Depends(require_admin)):
|
||||
"""Kontingent löschen (z. B. Fehlbestellung) — Codes via CASCADE mit weg."""
|
||||
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.")
|
||||
conn.execute("DELETE FROM partner_qr_batches WHERE id=?", (batch_id,))
|
||||
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
|
||||
with db() as conn:
|
||||
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"'})
|
||||
|
||||
|
||||
@router.get("/partner/my-stats")
|
||||
def my_partner_stats(user=Depends(require_partner)):
|
||||
"""Dashboard-Zahlen für den Partner: eigene Codes mit Registrierungen/Versuchen
|
||||
+ Status des öffentlichen Profils."""
|
||||
with db() as conn:
|
||||
codes = conn.execute(
|
||||
"""SELECT pc.id, pc.code, pc.label, pc.uses, pc.grants_founder,
|
||||
(SELECT COUNT(*) FROM users u
|
||||
WHERE u.referred_by = -pc.id AND u.email_verified = 1) AS registrations,
|
||||
(SELECT COUNT(*) FROM users u
|
||||
WHERE u.referred_by = -pc.id AND u.email_verified = 0) AS attempts,
|
||||
(SELECT COUNT(*) FROM users u
|
||||
WHERE u.referred_by = -pc.id AND u.email_verified = 1
|
||||
AND strftime('%Y-%m', u.created_at) = strftime('%Y-%m', 'now')) AS registrations_month
|
||||
FROM partner_codes pc
|
||||
WHERE pc.owner_user_id = ?
|
||||
ORDER BY pc.created_at""",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
profile = _pp_get_or_empty(conn, user["id"])
|
||||
return {
|
||||
"codes": [dict(c) for c in codes],
|
||||
"profile": {
|
||||
"exists": bool(profile),
|
||||
"approved": profile.get("approved", 0),
|
||||
"submitted_at": profile.get("submitted_at"),
|
||||
"display_name": profile.get("display_name"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/partner/my-qr")
|
||||
def my_qr_batches(user=Depends(require_partner)):
|
||||
"""Übergabe/Self-Service: eigene Kontingente mit Stats (Code-Besitzer)."""
|
||||
with db() as conn:
|
||||
return _qr_list_batches(
|
||||
conn, "WHERE pc.owner_user_id = ?", (user["id"],)
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
_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"'})
|
||||
|
||||
|
||||
class CodeOwnerSet(BaseModel):
|
||||
user_id: int
|
||||
|
||||
|
||||
@router.post("/admin/partner/codes/{code_id}/owner")
|
||||
def set_code_owner(code_id: int, data: CodeOwnerSet, user=Depends(require_admin)):
|
||||
"""Partner-Code einem User zuordnen (für Self-Service-QR-Zugriff)."""
|
||||
with db() as conn:
|
||||
if not conn.execute("SELECT id FROM partner_codes WHERE id=?", (code_id,)).fetchone():
|
||||
raise HTTPException(404, "Partner-Code nicht gefunden.")
|
||||
if not conn.execute("SELECT id FROM users WHERE id=?", (data.user_id,)).fetchone():
|
||||
raise HTTPException(404, "User nicht gefunden.")
|
||||
conn.execute(
|
||||
"UPDATE partner_codes SET owner_user_id=? WHERE id=?",
|
||||
(data.user_id, code_id)
|
||||
)
|
||||
return {"ok": True}
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1252"></script>
|
||||
<script src="/js/boot-early.js?v=1265"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1252">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1252">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1252">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1252">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1252">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1265">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1265">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1265">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1265">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1265">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -507,6 +507,10 @@
|
|||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-partner-dashboard">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-jobs">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
|
@ -612,11 +616,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1252"></script>
|
||||
<script src="/js/ui.js?v=1252"></script>
|
||||
<script src="/js/app.js?v=1252"></script>
|
||||
<script src="/js/worlds.js?v=1252"></script>
|
||||
<script src="/js/offline-indicator.js?v=1252"></script>
|
||||
<script src="/js/api.js?v=1265"></script>
|
||||
<script src="/js/ui.js?v=1265"></script>
|
||||
<script src="/js/app.js?v=1265"></script>
|
||||
<script src="/js/worlds.js?v=1265"></script>
|
||||
<script src="/js/offline-indicator.js?v=1265"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -626,7 +630,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1252"></script>
|
||||
<script src="/js/boot.js?v=1265"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -114,9 +114,10 @@ const API = (() => {
|
|||
login(email, password) {
|
||||
return post('/auth/login', { email, password });
|
||||
},
|
||||
register(email, password, name, ref_code) {
|
||||
register(email, password, name, ref_code, qr_token) {
|
||||
const body = { email, password, name };
|
||||
if (ref_code) body.ref_code = ref_code;
|
||||
if (qr_token) body.qr_token = qr_token; // Partner-QR (Sticker/Flyer) — Rückverfolgung
|
||||
return post('/auth/register', body);
|
||||
},
|
||||
logout() {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1252'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1265'; // ← 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;
|
||||
|
|
@ -81,6 +81,7 @@ const App = (() => {
|
|||
gruender: { title: '100 Gründer', module: null },
|
||||
partner: { title: 'Unsere Partner', module: null },
|
||||
'partner-profil': { title: 'Partner-Profil', module: null, requiresAuth: true },
|
||||
'partner-dashboard': { title: 'Partner-Bereich', module: null, requiresAuth: true },
|
||||
jobs: { title: 'Wir suchen dich', module: null },
|
||||
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
|
||||
recalls: { title: 'Rückrufe', module: null },
|
||||
|
|
@ -104,6 +105,7 @@ const App = (() => {
|
|||
// Normale Prüfung: Admin/Mod/Social bekommen immer Pro
|
||||
if (user.rolle === 'admin' || user.rolle === 'moderator') return true;
|
||||
if (user.is_moderator || user.is_social_media) return true;
|
||||
if (user.is_partner) return true; // Partner (Multiplikatoren) bekommen Pro gratis
|
||||
return ['pro','breeder'].includes(t);
|
||||
}
|
||||
|
||||
|
|
@ -1139,10 +1141,16 @@ const App = (() => {
|
|||
// überlebt App-Schließen, sodass die Zuordnung auch bei späterer Registrierung klappt)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const refCode = urlParams.get('ref');
|
||||
if (refCode) {
|
||||
const qrToken = urlParams.get('qr');
|
||||
if (refCode || qrToken) {
|
||||
try {
|
||||
localStorage.setItem('by_ref_code', refCode.toUpperCase());
|
||||
localStorage.setItem('by_ref_code_ts', String(Date.now()));
|
||||
if (refCode) {
|
||||
localStorage.setItem('by_ref_code', refCode.toUpperCase());
|
||||
localStorage.setItem('by_ref_code_ts', String(Date.now()));
|
||||
}
|
||||
// Partner-QR-Token (Sticker/Flyer): kommt bewusst OHNE Klartext-Code —
|
||||
// die Registrierung löst den Partner-Code server-seitig aus dem Token auf
|
||||
if (qrToken) localStorage.setItem('by_qr_token', qrToken);
|
||||
} catch {}
|
||||
// URL bereinigen ohne Reload
|
||||
history.replaceState({}, '', window.location.pathname + window.location.hash);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
localStorage.setItem('by_ref_code', rc.toUpperCase());
|
||||
localStorage.setItem('by_ref_code_ts', String(Date.now()));
|
||||
}
|
||||
// Partner-QR-Token (?qr= aus /q/{token}-Redirect) — Rückverfolgung pro Sticker/Flyer
|
||||
var qt = new URLSearchParams(location.search).get('qr');
|
||||
if (qt) localStorage.setItem('by_qr_token', qt);
|
||||
// Vektor-Basemap-Feature-Flag aus ?vectormap=1/0 SOFORT sichern (bevor Boot
|
||||
// die URL-Query strippt). Wird in ui.js Map.create ausgewertet.
|
||||
var vm = new URLSearchParams(location.search).get('vectormap');
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ window.Page_admin = (() => {
|
|||
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
|
||||
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
|
||||
{ key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' },
|
||||
{ key: 'partner_profiles_pending', label: 'Partner-Profile', tab: 'partner', icon: 'handshake' },
|
||||
];
|
||||
|
||||
const open = items.filter(i => d[i.key] > 0);
|
||||
|
|
@ -2289,7 +2290,9 @@ window.Page_admin = (() => {
|
|||
// TAB: AUDIT-LOG
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderPartner(el) {
|
||||
const codes = (await API.get('/admin/partner/codes')) || [];
|
||||
const codes = (await API.get('/admin/partner/codes')) || [];
|
||||
const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || [];
|
||||
const qrBatches = (await API.get('/admin/partner/qr-batches').catch(() => [])) || [];
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
|
||||
|
|
@ -2323,8 +2326,8 @@ window.Page_admin = (() => {
|
|||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);align-items:center">
|
||||
<div>
|
||||
<label class="form-label text-xs">Max. Einlösungen <span class="text-muted">(leer = unbegrenzt)</span></label>
|
||||
<input class="form-control" name="max_uses" type="number" min="1" placeholder="∞">
|
||||
<label class="form-label text-xs">Max. Einlösungen <span class="text-muted">(leer = unbegrenzt — Vorsicht, Codes kursieren gern im Netz)</span></label>
|
||||
<input class="form-control" name="max_uses" type="number" min="1" value="50" placeholder="∞">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);padding-top:var(--space-5)">
|
||||
<input type="checkbox" id="adm-grants-founder" name="grants_founder" checked
|
||||
|
|
@ -2358,18 +2361,31 @@ window.Page_admin = (() => {
|
|||
</thead>
|
||||
<tbody>
|
||||
${codes.map(c => `
|
||||
<tr style="border-bottom:1px solid var(--c-border)" data-code-id="${c.id}">
|
||||
<tr style="border-bottom:1px solid var(--c-border);${c.active ? '' : 'opacity:.55'}" data-code-id="${c.id}">
|
||||
<td style="padding:var(--space-2) var(--space-3)">
|
||||
<code style="font-weight:700;color:var(--c-primary);letter-spacing:.08em">${c.code}</code>
|
||||
${c.active ? '' : `<div><span class="badge" style="background:#fee2e2;color:#dc2626;font-size:10px">⏸ pausiert</span></div>`}
|
||||
</td>
|
||||
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text)">
|
||||
${c.label}
|
||||
<div class="text-xs-muted">
|
||||
${c.owner_name
|
||||
? `👤 ${UI.escape(c.owner_name)}`
|
||||
: `<button class="btn btn-ghost btn-sm adm-code-owner" data-id="${c.id}" style="font-size:var(--text-xs);padding:0 4px">👤 Besitzer zuordnen</button>`}
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text)">${c.label}</td>
|
||||
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">
|
||||
${c.uses}${c.max_uses ? `/${c.max_uses}` : ''}
|
||||
</td>
|
||||
<td style="padding:var(--space-2) var(--space-3);text-align:center">
|
||||
${c.grants_founder ? '✓' : '—'}
|
||||
</td>
|
||||
<td style="padding:var(--space-2) var(--space-3)">
|
||||
<td style="padding:var(--space-2) var(--space-3);white-space:nowrap;text-align:right">
|
||||
<button class="btn btn-ghost btn-sm adm-toggle-code" data-id="${c.id}"
|
||||
title="${c.active ? 'Pausieren — Notbremse wenn der Code im Netz kursiert (Einlösung gesperrt, Historie bleibt)' : 'Wieder aktivieren'}"
|
||||
style="font-size:var(--text-xs)">
|
||||
${c.active ? '⏸ Pausieren' : '▶ Aktivieren'}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm adm-del-code" data-id="${c.id}"
|
||||
style="color:var(--c-danger,#dc2626);font-size:var(--text-xs)">
|
||||
${UI.icon('trash')} Löschen
|
||||
|
|
@ -2383,6 +2399,147 @@ window.Page_admin = (() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR-Kontingente (Sticker/Flyer mit Rückverfolgung) -->
|
||||
<div class="by-card p-4">
|
||||
<h3 style="margin:0 0 var(--space-2);font-size:var(--text-base)">QR-Kontingente</h3>
|
||||
<p class="text-xs-muted" style="margin:0 0 var(--space-3)">
|
||||
Druckfertige QR-Codes für Partner (Sticker, Flyer, Visitenkarten). Jeder Code ist einzeln
|
||||
rückverfolgbar: Scans und Registrierungen werden pro Kontingent gezählt.
|
||||
</p>
|
||||
<form id="adm-qr-create" class="flex-col-gap-3" style="margin-bottom:var(--space-4)">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 100px;gap:var(--space-3)">
|
||||
<div>
|
||||
<label class="form-label text-xs">Partner-Code</label>
|
||||
<select class="form-control" name="code_id" required>
|
||||
${codes.map(c => `<option value="${c.id}">${UI.escape(c.code)} — ${UI.escape(c.label)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label text-xs">Bezeichnung</label>
|
||||
<input class="form-control" name="label" placeholder="z. B. Sticker-Bestellung Juni" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label text-xs">Stückzahl</label>
|
||||
<input class="form-control" name="quantity" type="number" min="1" max="500" value="24" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm" style="align-self:flex-start"
|
||||
${codes.length === 0 ? 'disabled title="Zuerst einen Partner-Code anlegen"' : ''}>
|
||||
${UI.icon('qr-code')} Kontingent erstellen
|
||||
</button>
|
||||
</form>
|
||||
${qrBatches.length === 0
|
||||
? `<p class="text-sm-muted">Noch keine Kontingente bestellt.</p>`
|
||||
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--c-border)">
|
||||
<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)">Code</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)">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)" 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${qrBatches.map(b => `
|
||||
<tr style="border-bottom:1px solid var(--c-border)">
|
||||
<td style="padding:var(--space-2) var(--space-3)"><code style="font-weight:700;color:var(--c-primary)">${UI.escape(b.code)}</code></td>
|
||||
<td style="padding:var(--space-2) var(--space-3)">${UI.escape(b.label)}<div class="text-xs-muted">${(b.created_at || '').slice(0, 10)}</div></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;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">
|
||||
${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>
|
||||
${UI.icon('file-pdf')} PDF
|
||||
</a>
|
||||
<button class="btn btn-ghost btn-sm adm-qr-del text-danger" data-id="${b.id}" data-label="${UI.escape(b.label)}">
|
||||
${UI.icon('trash')}
|
||||
</button>
|
||||
</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('')}
|
||||
</tbody>
|
||||
</table>`}
|
||||
</div>
|
||||
|
||||
<!-- Partner-Profil-Freigaben -->
|
||||
<div class="by-card p-4">
|
||||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">
|
||||
Profil-Freigaben
|
||||
${profiles.filter(p => p.submitted_at && p.approved === 0).length
|
||||
? `<span class="badge" style="background:var(--c-warning,#f59e0b);color:#fff;margin-left:var(--space-2)">${profiles.filter(p => p.submitted_at && p.approved === 0).length} offen</span>` : ''}
|
||||
</h3>
|
||||
${profiles.length === 0
|
||||
? `<p class="text-sm-muted">Noch keine Partner-Profile angelegt.</p>`
|
||||
: profiles.map(p => `
|
||||
<div style="border-bottom:1px solid var(--c-border)" data-pp-uid="${p.user_id}">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0">
|
||||
${p.logo_url
|
||||
? `<img src="${UI.escape(p.logo_url)}" style="width:36px;height:36px;border-radius:var(--radius-md);object-fit:contain;background:var(--c-surface-2);flex-shrink:0">`
|
||||
: `<div style="width:36px;height:36px;border-radius:var(--radius-md);background:var(--c-surface-2);flex-shrink:0"></div>`}
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(p.display_name || p.name)}</div>
|
||||
<div class="text-xs-muted">${UI.escape(p.name)} · ${UI.escape(p.email)}${p.photos?.length ? ` · ${p.photos.length} Medien` : ''}</div>
|
||||
</div>
|
||||
${p.approved === 1
|
||||
? `<span class="badge" style="background:#dcfce7;color:#16a34a">✓ Frei</span>`
|
||||
: p.approved === -1
|
||||
? `<span class="badge" style="background:#fee2e2;color:#dc2626">✗ Abgelehnt</span>`
|
||||
: p.submitted_at
|
||||
? `<span class="badge" style="background:#fef9c3;color:#a16207">⏳ Prüfen</span>`
|
||||
: `<span class="badge">Entwurf</span>`}
|
||||
<button class="btn btn-sm btn-secondary adm-pp-preview" data-uid="${p.user_id}">
|
||||
${UI.icon('eye')} Vorschau
|
||||
</button>
|
||||
${p.approved !== 1 ? `<button class="btn btn-sm btn-primary adm-pp-review" data-uid="${p.user_id}" data-val="1">✓ Freigeben</button>` : ''}
|
||||
${p.approved !== -1 ? `<button class="btn btn-sm btn-ghost adm-pp-review text-danger" data-uid="${p.user_id}" data-val="-1">✗</button>` : ''}
|
||||
</div>
|
||||
<!-- Vorschau: so erscheint die Karte auf der öffentlichen Partner-Seite -->
|
||||
<div class="adm-pp-preview-card hidden" id="adm-pp-preview-${p.user_id}" style="margin:0 0 var(--space-3)">
|
||||
<div class="text-xs-muted" style="margin-bottom:var(--space-2)">
|
||||
${UI.icon('eye')} So erscheint die Karte auf der Partner-Seite:
|
||||
</div>
|
||||
<div class="by-card" style="padding:var(--space-4);position:relative;overflow:hidden;max-width:340px">
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(135deg,#7c3aed,#a855f7)"></div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
${p.logo_url
|
||||
? `<img src="${UI.escape(p.logo_url)}" alt="" style="width:56px;height:56px;border-radius:var(--radius-md);object-fit:contain;flex-shrink:0;background:var(--c-surface-2);padding:4px">`
|
||||
: `<div style="width:56px;height:56px;border-radius:50%;flex-shrink:0;background:linear-gradient(135deg,#7c3aed,#a855f7);display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:800;color:#fff">${UI.escape((p.display_name || p.name || '?')[0].toUpperCase())}</div>`}
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:700;font-size:var(--text-base)">${UI.escape(p.display_name || p.name)}</div>
|
||||
${p.tagline ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:1px">${UI.escape(p.tagline)}</div>` : ''}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-1)">
|
||||
${p.website ? `<a href="${UI.escape(p.website)}" target="_blank" rel="noopener" style="font-size:var(--text-xs);color:var(--c-primary)">🌐 ${UI.escape(p.website.replace(/^https?:\/\//, ''))}</a>` : ''}
|
||||
${p.instagram ? `<span class="text-xs-muted">📸 ${UI.escape(p.instagram)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${p.bio ? `<p style="margin:var(--space-3) 0 0;font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">${UI.escape(p.bio)}</p>` : ''}
|
||||
${p.photos?.length ? `
|
||||
<div style="display:grid;grid-template-columns:repeat(${Math.min(p.photos.length, 3)},1fr);gap:var(--space-1);margin-top:var(--space-3);border-radius:var(--radius-md);overflow:hidden">
|
||||
${p.photos.slice(0, 3).map(url => {
|
||||
const isVid = url.endsWith('.mp4') || url.endsWith('.webm');
|
||||
return isVid
|
||||
? `<video src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover" muted playsinline loop autoplay></video>`
|
||||
: `<img src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover">`;
|
||||
}).join('')}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
|
||||
<!-- User-Status vergeben -->
|
||||
<div class="by-card p-4">
|
||||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Nutzer-Status manuell vergeben</h3>
|
||||
|
|
@ -2414,6 +2571,113 @@ window.Page_admin = (() => {
|
|||
</div>
|
||||
`;
|
||||
|
||||
// Code pausieren/aktivieren (Notbremse bei geleakten Codes)
|
||||
el.querySelectorAll('.adm-toggle-code').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
const r = await API.post(`/admin/partner/codes/${btn.dataset.id}/toggle`, {});
|
||||
UI.toast.success(r.active ? 'Code wieder aktiv.' : 'Code pausiert — Einlösungen sind gesperrt.');
|
||||
await _renderPartner(el);
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
});
|
||||
|
||||
// Code-Besitzer zuordnen (Self-Service-QR-Zugriff für den Partner)
|
||||
el.querySelectorAll('.adm-code-owner').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const q = window.prompt('Benutzername des Partners (exakt):');
|
||||
if (!q) return;
|
||||
try {
|
||||
const hits = await API.get(`/admin/users/search?q=${encodeURIComponent(q.trim())}`);
|
||||
const hit = (hits || []).find(u => u.name.toLowerCase() === q.trim().toLowerCase()) || (hits || [])[0];
|
||||
if (!hit) { UI.toast.warning('Kein User gefunden.'); return; }
|
||||
await API.post(`/admin/partner/codes/${btn.dataset.id}/owner`, { user_id: hit.id });
|
||||
UI.toast.success(`Code gehört jetzt ${hit.name} — er sieht seine QR-Kontingente im Partner-Profil.`);
|
||||
await _renderPartner(el);
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
});
|
||||
|
||||
// QR-Kontingent anlegen
|
||||
el.querySelector('#adm-qr-create')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('[type="submit"]');
|
||||
const fd = UI.formData(e.target);
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const b = await API.post(`/admin/partner/codes/${fd.code_id}/qr-batches`, {
|
||||
label: fd.label,
|
||||
quantity: parseInt(fd.quantity),
|
||||
});
|
||||
UI.toast.success(`Kontingent "${b.label}" mit ${b.quantity} QR-Codes erstellt.`);
|
||||
await _renderPartner(el);
|
||||
});
|
||||
});
|
||||
|
||||
// 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)
|
||||
el.querySelectorAll('.adm-qr-del').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (btn.dataset.armed !== '1') {
|
||||
btn.dataset.armed = '1';
|
||||
btn.textContent = 'Wirklich löschen?';
|
||||
setTimeout(() => { btn.dataset.armed = '0'; btn.innerHTML = UI.icon('trash'); }, 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await API.del(`/admin/partner/qr-batches/${btn.dataset.id}`);
|
||||
UI.toast.success(`Kontingent "${btn.dataset.label}" gelöscht.`);
|
||||
await _renderPartner(el);
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
});
|
||||
|
||||
// Partner-Profil-Vorschau auf-/zuklappen (.hidden hat !important → classList)
|
||||
el.querySelectorAll('.adm-pp-preview').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
el.querySelector(`#adm-pp-preview-${btn.dataset.uid}`)?.classList.toggle('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// Partner-Profil freigeben / ablehnen
|
||||
el.querySelectorAll('.adm-pp-review').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
await API.post(`/admin/partner/profiles/${btn.dataset.uid}/review`,
|
||||
{ approved: parseInt(btn.dataset.val) });
|
||||
UI.toast.success(btn.dataset.val === '1' ? 'Profil freigegeben.' : 'Profil abgelehnt.');
|
||||
await _renderPartner(el);
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
});
|
||||
|
||||
// Code erstellen
|
||||
el.querySelector('#adm-partner-create')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
198
backend/static/js/pages/partner-dashboard.js
Normal file
198
backend/static/js/pages/partner-dashboard.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Partner-Dashboard
|
||||
Operative Daten für Partner: Code + Einladungslink, Statistik,
|
||||
QR-Kontingente mit Einzel-Code-Status, Profil-Status.
|
||||
(Die öffentliche Präsenz wird in partner-profil.js gepflegt.)
|
||||
============================================================ */
|
||||
|
||||
window.Page_partner_dashboard = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _stats = null;
|
||||
let _qrBatches = [];
|
||||
|
||||
async function init(container) {
|
||||
_container = container;
|
||||
_render();
|
||||
await _load();
|
||||
}
|
||||
|
||||
function refresh() { _load(); }
|
||||
function onDogChange() {}
|
||||
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
|
||||
<div style="margin-bottom:var(--space-5)">
|
||||
<h1 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-1)">
|
||||
${UI.icon('handshake')} Partner-Bereich
|
||||
</h1>
|
||||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
|
||||
Dein Code, deine Zahlen, deine QR-Kontingente.
|
||||
</p>
|
||||
</div>
|
||||
<div id="pd-content">
|
||||
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">Lade…</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function _load() {
|
||||
const el = _container.querySelector('#pd-content');
|
||||
try {
|
||||
_stats = await API.get('/partner/my-stats');
|
||||
_qrBatches = (await API.get('/partner/my-qr').catch(() => [])) || [];
|
||||
el.innerHTML = _renderDashboard();
|
||||
_bindEvents(el);
|
||||
} catch (e) {
|
||||
el.innerHTML = `<p class="text-danger">${UI.escape(e.message || 'Fehler beim Laden.')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderDashboard() {
|
||||
const codes = _stats?.codes || [];
|
||||
return `
|
||||
${codes.length === 0 ? `
|
||||
<div class="card" style="padding:var(--space-5);text-align:center;margin-bottom:var(--space-3)">
|
||||
<p class="text-sm-secondary" style="margin:0">
|
||||
Dir ist noch kein Partner-Code zugeordnet.<br>
|
||||
Melde dich bei <a href="mailto:partner@banyaro.app" class="text-primary">partner@banyaro.app</a> — wir richten ihn ein.
|
||||
</p>
|
||||
</div>` : codes.map(c => _renderCodeCard(c)).join('')}
|
||||
|
||||
${_renderQrSection()}
|
||||
${_renderProfileCard()}
|
||||
`;
|
||||
}
|
||||
|
||||
function _renderCodeCard(c) {
|
||||
const link = `https://banyaro.app/?ref=${encodeURIComponent(c.code)}`;
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-2)">Dein Einladungscode</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3)">
|
||||
<code style="font-size:var(--text-lg);font-weight:800;letter-spacing:.1em;color:var(--c-primary)">${UI.escape(c.code)}</code>
|
||||
<button class="btn btn-sm btn-secondary pd-copy" data-link="${UI.escape(link)}">
|
||||
${UI.icon('copy')} Link kopieren
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:var(--space-2);text-align:center">
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3)">
|
||||
<div style="font-size:var(--text-xl);font-weight:800;color:${c.registrations > 0 ? 'var(--c-success,#16a34a)' : 'var(--c-text)'}">${c.registrations}</div>
|
||||
<div class="text-xs-muted">Registrierungen</div>
|
||||
</div>
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3)">
|
||||
<div style="font-size:var(--text-xl);font-weight:800">${c.registrations_month}</div>
|
||||
<div class="text-xs-muted">diesen Monat</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs-muted" style="margin-top:var(--space-2)">
|
||||
Zählt alle Wege: geteilter Link, eingetippter Code und deine gedruckten QR-Codes.
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderQrSection() {
|
||||
if (!_qrBatches.length) return '';
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-2)">Meine QR-Codes</div>
|
||||
<p class="text-xs-muted" style="margin:0 0 var(--space-3)">
|
||||
Deine gedruckten QR-Codes (Sticker, Flyer) — und wie viele davon schon
|
||||
neue Hundefreunde gebracht haben.
|
||||
</p>
|
||||
${_qrBatches.map(b => `
|
||||
<div style="border-bottom:1px solid var(--c-border)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0">
|
||||
<div class="flex-1-min">
|
||||
<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>
|
||||
<div style="text-align:right">
|
||||
<div style="font-weight:700;color:${b.codes_used > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.codes_used} von ${b.quantity}</div>
|
||||
<div class="text-xs-muted" title="Codes mit mindestens einer bestätigten Registrierung">genutzt</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-ghost pd-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 class="hidden" id="pd-qr-codes-${b.id}" style="padding:0 0 var(--space-3)">
|
||||
<div class="text-sm-muted">Lädt…</div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderProfileCard() {
|
||||
const p = _stats?.profile || {};
|
||||
let badge;
|
||||
if (p.approved === 1) badge = `<span class="badge" style="background:#dcfce7;color:#16a34a">✓ Öffentlich sichtbar</span>`;
|
||||
else if (p.approved === -1) badge = `<span class="badge" style="background:#fee2e2;color:#dc2626">✗ Abgelehnt</span>`;
|
||||
else if (p.submitted_at) badge = `<span class="badge" style="background:#fef9c3;color:#a16207">⏳ In Prüfung</span>`;
|
||||
else if (p.exists) badge = `<span class="badge">Entwurf</span>`;
|
||||
else badge = `<span class="badge">Noch nicht angelegt</span>`;
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<div class="flex-1-min">
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-1)">Öffentliches Profil</div>
|
||||
${badge}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary" id="pd-edit-profile">
|
||||
${UI.icon('pencil-simple')} Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _bindEvents(el) {
|
||||
// Einladungslink kopieren
|
||||
el.querySelectorAll('.pd-copy').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(btn.dataset.link);
|
||||
UI.toast.success('Einladungslink kopiert.');
|
||||
} catch {
|
||||
UI.toast.info(btn.dataset.link);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Einzel-Code-Status (lazy, .hidden via classList)
|
||||
el.querySelectorAll('.pd-qr-codes-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const box = el.querySelector(`#pd-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;
|
||||
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>
|
||||
${used
|
||||
? `<span class="badge" style="background:#dcfce7;color:#16a34a" title="Erste Registrierung am ${(c.first_registration_at || '').slice(0, 10)}">● genutzt${c.registrations > 1 ? ` (${c.registrations}×)` : ''}</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); }
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelector('#pd-edit-profile')?.addEventListener('click', () => App.navigate('partner-profil'));
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
@ -27,6 +27,8 @@ window.Page_partner_profil = (() => {
|
|||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
|
||||
Richte deine öffentliche Präsenz auf der Partner-Seite ein.
|
||||
Nach dem Absenden prüfen wir dein Profil und schalten es frei.
|
||||
Deine Zahlen und QR-Codes findest du im
|
||||
<a href="#partner-dashboard" class="text-primary">Partner-Bereich</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div id="pp-content">
|
||||
|
|
|
|||
|
|
@ -665,6 +665,26 @@ window.Page_settings = (() => {
|
|||
<!-- Züchter-Profil Slot -->
|
||||
<div id="breeder-card-slot"></div>
|
||||
|
||||
${u.is_partner ? `
|
||||
<!-- Partner-Bereich -->
|
||||
<div class="card mb-4">
|
||||
<div class="by-card-section-header">${UI.icon('handshake')} Partner</div>
|
||||
<div class="p-4">
|
||||
<p class="text-sm-secondary" style="margin:0 0 var(--space-3)">
|
||||
Als Partner hast du vollen Pro-Zugang und eine öffentliche Karte auf der
|
||||
Partner-Seite. Deine Zahlen und QR-Codes findest du im Partner-Bereich.
|
||||
</p>
|
||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" id="settings-partner-dashboard-btn">
|
||||
${UI.icon('handshake')} Partner-Bereich
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-partner-profile-btn">
|
||||
${UI.icon('pencil-simple')} Öffentliches Profil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="by-card-section-header">Trophäen</div>
|
||||
<div id="settings-badges-body" class="p-4">
|
||||
|
|
@ -1660,6 +1680,11 @@ window.Page_settings = (() => {
|
|||
|
||||
_loadReferral();
|
||||
_loadBreederCard();
|
||||
|
||||
document.getElementById('settings-partner-dashboard-btn')
|
||||
?.addEventListener('click', () => App.navigate('partner-dashboard'));
|
||||
document.getElementById('settings-partner-profile-btn')
|
||||
?.addEventListener('click', () => App.navigate('partner-profil'));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -2595,8 +2620,11 @@ window.Page_settings = (() => {
|
|||
const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined;
|
||||
const refCode = _storedRefCode();
|
||||
const finalCode = partnerCode || refCode || undefined;
|
||||
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
|
||||
// QR-Token mitschicken — Backend ordnet ihn nur zu, wenn er zum Code passt
|
||||
const qrToken = (() => { try { return localStorage.getItem('by_qr_token') || undefined; } catch { return undefined; } })();
|
||||
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode, qrToken);
|
||||
if (refCode) _clearRefCode();
|
||||
try { localStorage.removeItem('by_qr_token'); } catch {}
|
||||
|
||||
if (result.pending_verification) {
|
||||
_renderVerifyPending(fd.email);
|
||||
|
|
|
|||
|
|
@ -578,6 +578,7 @@ window.Worlds = (() => {
|
|||
{ icon:'sparkle', label:'Social', page:'social', role:'social',
|
||||
fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] },
|
||||
{ icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' },
|
||||
{ icon:'handshake', label:'Partner', page:'partner-dashboard', role:'partner' },
|
||||
{ icon:'gear', label:'Admin', page:'admin', role:'admin' },
|
||||
// ── NEUE FEATURES ────────────────────────────────────────────
|
||||
{ icon:'fork-knife', label:'Ernährung', page:'ernaehrung', pro: true,
|
||||
|
|
@ -587,7 +588,7 @@ window.Worlds = (() => {
|
|||
];
|
||||
|
||||
const _DEFAULT_CONFIG = {
|
||||
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','admin'],
|
||||
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','partner-dashboard','admin'],
|
||||
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse',
|
||||
'litters','zuchthunde','laeufi','ernaehrung','personality'],
|
||||
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events',
|
||||
|
|
@ -681,6 +682,7 @@ window.Worlds = (() => {
|
|||
}
|
||||
if (chip.role === 'social') return u?.is_social_media || u?.rolle === 'admin';
|
||||
if (chip.role === 'mod') return u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator;
|
||||
if (chip.role === 'partner') return !!u?.is_partner || u?.rolle === 'admin';
|
||||
if (chip.role === 'admin') return u?.rolle === 'admin';
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<script src="/js/landing-init.js?v=1252"></script>
|
||||
<script src="/js/landing-init.js?v=1265"></script>
|
||||
<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="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
|
||||
const VER = '1252';
|
||||
const VER = '1265';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
155
tests/test_partner_profile.py
Normal file
155
tests/test_partner_profile.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""Smoke-Tests fuer Partner-Profile (Editor + Freigabe-Workflow + oeffentlicher Showcase)."""
|
||||
|
||||
import io
|
||||
|
||||
|
||||
def _make_partner(user_email: str):
|
||||
"""Setzt is_partner=1 direkt in der Test-DB."""
|
||||
from database import db
|
||||
with db() as conn:
|
||||
conn.execute("UPDATE users SET is_partner=1 WHERE email=?", (user_email,))
|
||||
|
||||
|
||||
def test_my_profile_requires_partner(client, user):
|
||||
"""GET /api/partner/my-profile -> 403 fuer normale User."""
|
||||
r = client.get("/api/partner/my-profile", headers=user["headers"])
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_partner_profile_full_flow(client, user, admin):
|
||||
"""Texte speichern -> einreichen -> Admin gibt frei -> oeffentlich sichtbar."""
|
||||
_make_partner(user["email"])
|
||||
|
||||
# Leeres Profil mit Storage-Infos
|
||||
r = client.get("/api/partner/my-profile", headers=user["headers"])
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["storage_limit_mb"] == 200
|
||||
|
||||
# Texte speichern (Website ohne Schema wird normalisiert)
|
||||
r = client.put("/api/partner/my-profile", headers=user["headers"], json={
|
||||
"display_name": "Hundeblog Test",
|
||||
"tagline": "Testkanal",
|
||||
"bio": "Wir testen Ban Yaro.",
|
||||
"website": "hundeblog-test.de",
|
||||
"instagram": "@hundeblogtest",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
p = r.json()["profile"]
|
||||
assert p["display_name"] == "Hundeblog Test"
|
||||
assert p["website"] == "https://hundeblog-test.de"
|
||||
|
||||
# Vor Freigabe nicht oeffentlich
|
||||
r = client.get("/api/partners/public")
|
||||
assert all(x.get("display_name") != "Hundeblog Test" for x in r.json()["partners"])
|
||||
|
||||
# Einreichen
|
||||
r = client.post("/api/partner/my-profile/submit", headers=user["headers"], json={})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["profile"]["submitted_at"]
|
||||
|
||||
# Admin sieht das Profil und gibt frei
|
||||
r = client.get("/api/admin/partner/profiles", headers=admin["headers"])
|
||||
assert r.status_code == 200
|
||||
mine = [x for x in r.json() if x.get("display_name") == "Hundeblog Test"]
|
||||
assert mine, "Profil fehlt in der Admin-Liste"
|
||||
uid = mine[0]["user_id"]
|
||||
|
||||
r = client.post(f"/api/admin/partner/profiles/{uid}/review",
|
||||
headers=admin["headers"], json={"approved": 1})
|
||||
assert r.status_code == 200
|
||||
|
||||
# Jetzt oeffentlich (ohne Login)
|
||||
r = client.get("/api/partners/public")
|
||||
names = [x["display_name"] for x in r.json()["partners"]]
|
||||
assert "Hundeblog Test" in names
|
||||
|
||||
# Ablehnen entfernt es wieder von der oeffentlichen Seite
|
||||
r = client.post(f"/api/admin/partner/profiles/{uid}/review",
|
||||
headers=admin["headers"], json={"approved": -1})
|
||||
assert r.status_code == 200
|
||||
r = client.get("/api/partners/public")
|
||||
assert "Hundeblog Test" not in [x["display_name"] for x in r.json()["partners"]]
|
||||
|
||||
|
||||
def test_submit_requires_display_name(client, user):
|
||||
"""Einreichen ohne Anzeigename -> 400."""
|
||||
_make_partner(user["email"])
|
||||
r = client.post("/api/partner/my-profile/submit", headers=user["headers"], json={})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_logo_and_photo_upload(client, user):
|
||||
"""Logo + Foto hochladen, Foto wieder loeschen."""
|
||||
from PIL import Image
|
||||
_make_partner(user["email"])
|
||||
|
||||
def _png(size=(64, 64), color="red"):
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
# Logo
|
||||
r = client.post("/api/partner/my-profile/logo", headers=user["headers"],
|
||||
files={"file": ("logo.png", _png(), "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["logo_url"].startswith("/media/partner/")
|
||||
|
||||
# Foto
|
||||
r = client.post("/api/partner/my-profile/photos", headers=user["headers"],
|
||||
files={"file": ("foto.png", _png(color="blue"), "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
photos = r.json()["photos"]
|
||||
assert len(photos) == 1 and photos[0].endswith(".webp")
|
||||
|
||||
# Speicher belegt
|
||||
r = client.get("/api/partner/my-profile", headers=user["headers"])
|
||||
assert r.json()["storage_mb"] > 0
|
||||
|
||||
# Foto loeschen
|
||||
r = client.post("/api/partner/my-profile/photos/0/delete", headers=user["headers"], json={})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["photos"] == []
|
||||
|
||||
|
||||
def test_heic_uploads_convert(client, user):
|
||||
"""HEIC (iPhone-Format) wird bei Logo UND Foto nach WebP konvertiert."""
|
||||
import pillow_heif
|
||||
from PIL import Image
|
||||
_make_partner(user["email"])
|
||||
|
||||
pillow_heif.register_heif_opener()
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (64, 64), "green").save(buf, format="HEIF")
|
||||
heic_bytes = buf.getvalue()
|
||||
|
||||
# Logo als HEIC
|
||||
r = client.post("/api/partner/my-profile/logo", headers=user["headers"],
|
||||
files={"file": ("IMG_0001.HEIC", io.BytesIO(heic_bytes), "image/heic")})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["logo_url"].endswith(".webp")
|
||||
|
||||
# Foto als HEIC
|
||||
r = client.post("/api/partner/my-profile/photos", headers=user["headers"],
|
||||
files={"file": ("IMG_0002.heic", io.BytesIO(heic_bytes), "image/heic")})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["photos"][0].endswith(".webp")
|
||||
|
||||
|
||||
def test_submit_appears_in_admin_action_items(client, user, admin):
|
||||
"""Eingereichtes Profil taucht im Admin-'Zu erledigen'-Zaehler auf."""
|
||||
_make_partner(user["email"])
|
||||
client.put("/api/partner/my-profile", headers=user["headers"],
|
||||
json={"display_name": "Action-Item-Test"})
|
||||
before = client.get("/api/admin/action-items", headers=admin["headers"]).json()
|
||||
r = client.post("/api/partner/my-profile/submit", headers=user["headers"], json={})
|
||||
assert r.status_code == 200
|
||||
after = client.get("/api/admin/action-items", headers=admin["headers"]).json()
|
||||
assert after["partner_profiles_pending"] == before.get("partner_profiles_pending", 0) + 1
|
||||
|
||||
|
||||
def test_partner_has_pro_access(client, user):
|
||||
"""is_partner=1 -> has_pro_access True (Pro gratis fuer Partner)."""
|
||||
from auth import has_pro_access
|
||||
assert has_pro_access({"rolle": "user", "subscription_tier": "standard", "is_partner": 1})
|
||||
assert not has_pro_access({"rolle": "user", "subscription_tier": "standard", "is_partner": 0})
|
||||
274
tests/test_partner_qr.py
Normal file
274
tests/test_partner_qr.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"""Smoke-Tests fuer Partner-QR-Kontingente (Bestellung, Scan, Registrierungs-Rueckverfolgung)."""
|
||||
|
||||
import secrets
|
||||
|
||||
|
||||
def _create_code(client, admin, code=None):
|
||||
code = code or f"QRTEST{secrets.token_hex(3).upper()}"
|
||||
r = client.post("/api/admin/partner/codes", headers=admin["headers"], json={
|
||||
"code": code, "label": f"Testpartner {code}", "grants_founder": 0,
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _create_batch(client, admin, code_id, quantity=5):
|
||||
r = client.post(f"/api/admin/partner/codes/{code_id}/qr-batches",
|
||||
headers=admin["headers"],
|
||||
json={"label": "Sticker Testlauf", "quantity": quantity})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _batch_tokens(batch_id):
|
||||
from database import db
|
||||
with db() as conn:
|
||||
return [r["token"] for r in conn.execute(
|
||||
"SELECT token FROM partner_qr_codes WHERE batch_id=? ORDER BY seq", (batch_id,)
|
||||
).fetchall()]
|
||||
|
||||
|
||||
def test_batch_create_and_pdf(client, admin):
|
||||
"""Kontingent anlegen -> N eindeutige Tokens + druckfertiges PDF."""
|
||||
code = _create_code(client, admin)
|
||||
batch = _create_batch(client, admin, code["id"], quantity=7)
|
||||
assert batch["quantity"] == 7 and batch["codes"] == 7
|
||||
tokens = _batch_tokens(batch["id"])
|
||||
assert len(set(tokens)) == 7
|
||||
|
||||
r = client.get(f"/api/admin/partner/qr-batches/{batch['id']}/pdf", headers=admin["headers"])
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "application/pdf"
|
||||
assert r.content[:4] == b"%PDF"
|
||||
|
||||
|
||||
def test_scan_redirects_and_counts(client, admin):
|
||||
"""/q/{token} -> 302 mit ref+qr, Scan-Zaehler steigt; unbekannter Token -> /."""
|
||||
code = _create_code(client, admin)
|
||||
batch = _create_batch(client, admin, code["id"], quantity=1)
|
||||
token = _batch_tokens(batch["id"])[0]
|
||||
|
||||
r = client.get(f"/q/{token}", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
# Bewusst KEIN Klartext-Code in der URL — sonst liest jeder Scanner den Code ab
|
||||
assert r.headers["location"] == f"/?qr={token}"
|
||||
assert code["code"] not in r.headers["location"]
|
||||
client.get(f"/q/{token}", follow_redirects=False)
|
||||
|
||||
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["scans"] == 2
|
||||
|
||||
r = client.get("/q/gibtsnich", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/"
|
||||
|
||||
|
||||
def test_registration_attributed_to_qr(client, admin):
|
||||
"""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]
|
||||
|
||||
email = f"qr-{secrets.token_hex(4)}@example.com"
|
||||
r = client.post("/api/auth/register", json={
|
||||
"email": email, "password": "QrTest1234!", "name": f"qru{secrets.token_hex(3)}",
|
||||
"ref_code": code["code"], "qr_token": token,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
from database import db
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT referred_by, referred_qr FROM users WHERE email=?", (email,)).fetchone()
|
||||
assert row["referred_by"] == -code["id"]
|
||||
assert row["referred_qr"] == token
|
||||
|
||||
# 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_registration_with_qr_only(client, admin):
|
||||
"""Registrierung NUR mit qr_token (ohne ref_code) -> Code wird server-seitig aufgeloest."""
|
||||
code = _create_code(client, admin)
|
||||
batch = _create_batch(client, admin, code["id"], quantity=1)
|
||||
token = _batch_tokens(batch["id"])[0]
|
||||
|
||||
email = f"qro-{secrets.token_hex(4)}@example.com"
|
||||
r = client.post("/api/auth/register", json={
|
||||
"email": email, "password": "QrTest1234!", "name": f"qro{secrets.token_hex(3)}",
|
||||
"qr_token": token,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
from database import db
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT referred_by, referred_qr FROM users WHERE email=?", (email,)).fetchone()
|
||||
assert row["referred_by"] == -code["id"]
|
||||
assert row["referred_qr"] == token
|
||||
|
||||
|
||||
def test_paused_code_not_redeemable(client, admin):
|
||||
"""Pausierter Code (Notbremse) -> keine Einloesung, Info-Endpoint 404; reaktivierbar."""
|
||||
code = _create_code(client, admin)
|
||||
r = client.post(f"/api/admin/partner/codes/{code['id']}/toggle", headers=admin["headers"])
|
||||
assert r.status_code == 200 and r.json()["active"] == 0
|
||||
|
||||
# Info-Endpoint: wie nicht existent
|
||||
assert client.get(f"/api/partner/codes/{code['code']}/info").status_code == 404
|
||||
|
||||
# Registrierung mit pausiertem Code -> keine Zuordnung
|
||||
email = f"qrp-{secrets.token_hex(4)}@example.com"
|
||||
r = client.post("/api/auth/register", json={
|
||||
"email": email, "password": "QrTest1234!", "name": f"qrp{secrets.token_hex(3)}",
|
||||
"ref_code": code["code"],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
from database import db
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT referred_by FROM users WHERE email=?", (email,)).fetchone()
|
||||
assert row["referred_by"] is None
|
||||
|
||||
# Reaktivieren funktioniert
|
||||
r = client.post(f"/api/admin/partner/codes/{code['id']}/toggle", headers=admin["headers"])
|
||||
assert r.json()["active"] == 1
|
||||
assert client.get(f"/api/partner/codes/{code['code']}/info").status_code == 200
|
||||
|
||||
|
||||
def test_qr_token_must_match_code(client, admin):
|
||||
"""QR-Token eines FREMDEN Codes wird nicht zugeordnet (Manipulationsschutz)."""
|
||||
code_a = _create_code(client, admin)
|
||||
code_b = _create_code(client, admin)
|
||||
batch_b = _create_batch(client, admin, code_b["id"], quantity=1)
|
||||
token_b = _batch_tokens(batch_b["id"])[0]
|
||||
|
||||
email = f"qrx-{secrets.token_hex(4)}@example.com"
|
||||
r = client.post("/api/auth/register", json={
|
||||
"email": email, "password": "QrTest1234!", "name": f"qrx{secrets.token_hex(3)}",
|
||||
"ref_code": code_a["code"], "qr_token": token_b,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
from database import db
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT referred_qr FROM users WHERE email=?", (email,)).fetchone()
|
||||
assert row["referred_qr"] is None
|
||||
|
||||
|
||||
def test_partner_thank_you_mail(client, admin, user, monkeypatch):
|
||||
"""E-Mail-Bestaetigung eines Geworbenen -> Dank-Mail mit Statistik an den Code-Besitzer."""
|
||||
from database import db
|
||||
with db() as conn:
|
||||
conn.execute("UPDATE users SET is_partner=1 WHERE email=?", (user["email"],))
|
||||
uid = conn.execute("SELECT id FROM users WHERE email=?", (user["email"],)).fetchone()["id"]
|
||||
|
||||
code = _create_code(client, admin)
|
||||
batch = _create_batch(client, admin, code["id"], quantity=1)
|
||||
token = _batch_tokens(batch["id"])[0]
|
||||
client.post(f"/api/admin/partner/codes/{code['id']}/owner",
|
||||
headers=admin["headers"], json={"user_id": uid})
|
||||
|
||||
sent = []
|
||||
import routes.outreach as outreach
|
||||
monkeypatch.setattr(outreach, "_send_smtp",
|
||||
lambda to, subject, body, account="partner", html=None:
|
||||
sent.append({"to": to, "subject": subject, "body": body}))
|
||||
|
||||
email = f"qrm-{secrets.token_hex(4)}@example.com"
|
||||
r = client.post("/api/auth/register", json={
|
||||
"email": email, "password": "QrTest1234!", "name": f"qrm{secrets.token_hex(3)}",
|
||||
"ref_code": code["code"], "qr_token": token,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
with db() as conn:
|
||||
vtoken = conn.execute(
|
||||
"SELECT verification_token FROM users WHERE email=?", (email,)
|
||||
).fetchone()["verification_token"]
|
||||
|
||||
sent.clear() # Verifikations-Mail an den Neuen ignorieren
|
||||
r = client.get(f"/api/auth/verify-email/{vtoken}", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
|
||||
thank = [m for m in sent if m["to"] == user["email"]]
|
||||
assert len(thank) == 1, f"Dank-Mail fehlt: {sent}"
|
||||
assert "Danke" in thank[0]["subject"]
|
||||
assert "1 bestätigte Registrierung" in thank[0]["body"] # Statistik
|
||||
assert "#1" in thank[0]["body"] # QR-Sticker-Herkunft
|
||||
|
||||
# Doppelt verifizieren -> keine zweite Mail
|
||||
sent.clear()
|
||||
client.get(f"/api/auth/verify-email/{vtoken}", follow_redirects=False)
|
||||
assert not [m for m in sent if m["to"] == user["email"]]
|
||||
|
||||
|
||||
def test_partner_self_service_qr(client, admin, user):
|
||||
"""Code-Besitzer sieht eigene Kontingente + kann PDF laden; Fremde nicht."""
|
||||
from database import db
|
||||
with db() as conn:
|
||||
conn.execute("UPDATE users SET is_partner=1 WHERE email=?", (user["email"],))
|
||||
uid = conn.execute("SELECT id FROM users WHERE email=?", (user["email"],)).fetchone()["id"]
|
||||
|
||||
code = _create_code(client, admin)
|
||||
batch = _create_batch(client, admin, code["id"], quantity=3)
|
||||
|
||||
# Ohne Besitzer: leere Liste
|
||||
r = client.get("/api/partner/my-qr", headers=user["headers"])
|
||||
assert r.status_code == 200 and r.json() == []
|
||||
|
||||
# Besitzer zuordnen -> sichtbar + PDF
|
||||
r = client.post(f"/api/admin/partner/codes/{code['id']}/owner",
|
||||
headers=admin["headers"], json={"user_id": uid})
|
||||
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
|
||||
|
||||
# Dashboard-Stats: eigener Code mit Zahlen + Profil-Status
|
||||
r = client.get("/api/partner/my-stats", headers=user["headers"])
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
mycode = [c for c in d["codes"] if c["id"] == code["id"]][0]
|
||||
assert mycode["registrations"] == 1
|
||||
assert mycode["registrations_month"] == 1
|
||||
assert "approved" in d["profile"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue