1. QR-URL verrät den Code nicht mehr: /q/{token} → /?qr=TOKEN (vorher stand
der tippbare Code in der Adresszeile jedes Scanners). Registrierung löst
den Code server-seitig aus dem Token auf (auch ohne ref_code).
2. Notbremse: partner_codes.active — Admin kann Codes pausieren (Einlösung
gesperrt, Info-Endpoint 404, Historie/QR-Kontingente bleiben) und
reaktivieren. UI: ⏸/▶-Toggle + pausiert-Badge in der Codes-Tabelle.
3. max_uses im Anlege-Formular standardmäßig 50 statt unbegrenzt.
Tests: QR-only-Registrierung, Pause→keine Einlösung→Reaktivierung,
Redirect ohne Klartext-Code. Suite: 54 passed.
867 lines
35 KiB
Python
867 lines
35 KiB
Python
"""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, 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()
|
||
|
||
|
||
class PartnerCodeCreate(BaseModel):
|
||
code: str = Field(..., min_length=1, max_length=50)
|
||
label: str = Field(..., min_length=1, max_length=200)
|
||
grants_founder: int = 1
|
||
max_uses: Optional[int] = None
|
||
|
||
|
||
class GrantRequest(BaseModel):
|
||
is_founder: Optional[int] = None
|
||
is_partner: Optional[int] = None
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Admin: Partner-Codes verwalten
|
||
# ------------------------------------------------------------------
|
||
|
||
@router.get("/admin/partner/codes")
|
||
def list_partner_codes(user=Depends(require_admin)):
|
||
"""Alle Partner-Codes mit Stats (admin only)."""
|
||
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, 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)."""
|
||
code = data.code.strip().upper()
|
||
if not code:
|
||
raise HTTPException(400, "Code darf nicht leer sein.")
|
||
with db() as conn:
|
||
existing = conn.execute(
|
||
"SELECT id FROM partner_codes WHERE code=?", (code,)
|
||
).fetchone()
|
||
if existing:
|
||
raise HTTPException(400, "Dieser Code existiert bereits.")
|
||
conn.execute(
|
||
"""INSERT INTO partner_codes (code, label, grants_founder, max_uses, created_by)
|
||
VALUES (?, ?, ?, ?, ?)""",
|
||
(code, data.label.strip(), data.grants_founder, data.max_uses, user["id"])
|
||
)
|
||
row = conn.execute(
|
||
"SELECT * FROM partner_codes WHERE code=?", (code,)
|
||
).fetchone()
|
||
return dict(row)
|
||
|
||
|
||
@router.delete("/admin/partner/codes/{code_id}", status_code=204)
|
||
def delete_partner_code(code_id: int, user=Depends(require_admin)):
|
||
"""Partner-Code löschen (admin only)."""
|
||
with db() as conn:
|
||
existing = conn.execute(
|
||
"SELECT id FROM partner_codes WHERE id=?", (code_id,)
|
||
).fetchone()
|
||
if not existing:
|
||
raise HTTPException(404, "Partner-Code nicht gefunden.")
|
||
conn.execute("DELETE FROM partner_codes WHERE id=?", (code_id,))
|
||
return None
|
||
|
||
|
||
@router.post("/admin/partner/users/{user_id}/grant")
|
||
def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_admin)):
|
||
"""Founder- und/oder Partner-Status für einen User setzen (admin only)."""
|
||
updates = {}
|
||
if data.is_founder is not None:
|
||
updates["is_founder"] = data.is_founder
|
||
if data.is_partner is not None:
|
||
updates["is_partner"] = data.is_partner
|
||
if not updates:
|
||
raise HTTPException(400, "Mindestens is_founder oder is_partner muss angegeben werden.")
|
||
with db() as conn:
|
||
target = conn.execute(
|
||
"SELECT id, is_founder, founder_number FROM users WHERE id=?", (user_id,)
|
||
).fetchone()
|
||
if not target:
|
||
raise HTTPException(404, "User nicht gefunden.")
|
||
if updates.get("is_founder") == 1 and not target["founder_number"]:
|
||
# Atomare Gründer-Vergabe — kein TOCTOU mehr zwischen COUNT und UPDATE.
|
||
# Sub-Query wird gegen Snapshot vor dem UPDATE evaluiert (SQL-Spec).
|
||
cur = conn.execute(
|
||
"""UPDATE users
|
||
SET is_founder = 1,
|
||
founder_number = (
|
||
SELECT IFNULL(MAX(founder_number), 0) + 1
|
||
FROM users WHERE is_founder = 1
|
||
)
|
||
WHERE id = ?
|
||
AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < ?
|
||
AND (is_founder IS NULL OR is_founder = 0)""",
|
||
(user_id, FOUNDER_MAX)
|
||
)
|
||
if cur.rowcount == 0:
|
||
raise HTTPException(400, f"Alle {FOUNDER_MAX} Gründer-Plätze sind vergeben.")
|
||
# is_founder + founder_number sind atomar gesetzt — aus updates entfernen
|
||
updates.pop("is_founder", None)
|
||
updates.pop("founder_number", None)
|
||
elif updates.get("is_founder") == 0:
|
||
# Gründer-Status entfernen → founder_number ebenfalls leeren
|
||
updates["founder_number"] = None
|
||
if updates: # nach atomarer Founder-Vergabe ggf. leer
|
||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||
conn.execute(
|
||
f"UPDATE users SET {set_clause} WHERE id=?",
|
||
(*updates.values(), user_id)
|
||
)
|
||
row = conn.execute(
|
||
"SELECT id, name, email, is_founder, is_partner, founder_number FROM users WHERE id=?",
|
||
(user_id,)
|
||
).fetchone()
|
||
return dict(row)
|
||
|
||
|
||
@router.get("/admin/users/search")
|
||
def search_users(q: str, user=Depends(require_admin)):
|
||
"""User-Suche für Admin (Name-Präfix, max. 10 Ergebnisse)."""
|
||
with db() as conn:
|
||
rows = conn.execute(
|
||
"""SELECT id, name, email, is_founder, is_partner, rolle
|
||
FROM users WHERE name LIKE ? COLLATE NOCASE
|
||
ORDER BY name LIMIT 10""",
|
||
(f"{q}%",)
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Öffentlich: Gründer-Leaderboard + Code-Info
|
||
# ------------------------------------------------------------------
|
||
|
||
FOUNDER_MAX = 100
|
||
|
||
@router.get("/partner/founders/stats")
|
||
def founders_stats():
|
||
"""Öffentliches Gründer-Leaderboard: Slots, Partner-Ranking, Gründer-Liste."""
|
||
with db() as conn:
|
||
total = conn.execute(
|
||
"SELECT COUNT(*) FROM users WHERE is_founder=1"
|
||
).fetchone()[0]
|
||
# Partner-Ranking: nach uses absteigend
|
||
partners = conn.execute(
|
||
"""SELECT pc.id, pc.label, pc.uses, pc.code,
|
||
u.name AS partner_username, u.is_partner
|
||
FROM partner_codes pc
|
||
LEFT JOIN users u ON u.referred_by = -pc.id AND u.is_partner=1
|
||
WHERE pc.grants_founder=1
|
||
ORDER BY pc.uses DESC"""
|
||
).fetchall()
|
||
# Erste 100 Gründer (für Galerie)
|
||
founders = conn.execute(
|
||
"""SELECT u.name, u.founder_number,
|
||
pc.label AS via_partner
|
||
FROM users u
|
||
LEFT JOIN partner_codes pc ON u.referred_by = -pc.id
|
||
WHERE u.is_founder=1 AND u.founder_number IS NOT NULL
|
||
ORDER BY u.founder_number ASC
|
||
LIMIT 100"""
|
||
).fetchall()
|
||
return {
|
||
"total": total,
|
||
"max": FOUNDER_MAX,
|
||
"open": max(0, FOUNDER_MAX - total),
|
||
"closed": total >= FOUNDER_MAX,
|
||
"partners": [dict(p) for p in partners],
|
||
"founders": [dict(f) for f in founders],
|
||
}
|
||
|
||
|
||
@router.get("/partner/codes/{code}/info")
|
||
def partner_code_info(code: str):
|
||
"""Gibt zurück ob ein Partner-Code existiert und dessen Label (öffentlich)."""
|
||
with db() as conn:
|
||
row = conn.execute(
|
||
"""SELECT code, label, grants_founder, max_uses, uses
|
||
FROM partner_codes WHERE code=? AND active=1""",
|
||
(code.strip().upper(),)
|
||
).fetchone()
|
||
if not row:
|
||
raise HTTPException(404, "Partner-Code nicht gefunden.")
|
||
r = dict(row)
|
||
if r["grants_founder"]:
|
||
with db() as conn2:
|
||
total = conn2.execute(
|
||
"SELECT COUNT(*) FROM users WHERE is_founder=1"
|
||
).fetchone()[0]
|
||
r["founder_slots_open"] = max(0, FOUNDER_MAX - total)
|
||
r["redeemable"] = (r["max_uses"] is None or r["uses"] < r["max_uses"]) and total < FOUNDER_MAX
|
||
else:
|
||
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}
|