Feature: Partner-Profile Backend + Pro-Zugang für Partner
Die Partner-Showcase-Seite (#partner) und der Profil-Editor (#partner-profil) existierten seit v1102 nur als Frontend — /api/partners/public und /api/partner/my-profile gab es nie (vermutlich Worktree-Merge-Verlust). Backend neu: - partner_profiles-Tabelle (user_id PK, ON DELETE CASCADE → DSGVO-Delete greift) - GET/PUT /partner/my-profile (Texte, Website-Normalisierung, @-Instagram) - Logo-Upload (≤5 MB → WebP 512px, altes Logo wird geräumt) - Foto/Video-Upload (max 6, 200-MB-Budget, HEIC→JPEG, MOV→MP4 via ffmpeg, Bilder→WebP 1600px) + Lösch-Endpoint - Submit-Workflow (approved 0/1/-1) + Admin-Mail (best effort) - GET /partners/public (nur freigegebene, JOIN users für Name/Avatar) - Admin: GET /admin/partner/profiles + POST .../review Pro für Partner: has_pro_access() + App._hasPro() prüfen jetzt is_partner — Multiplikatoren bekommen Pro gratis (mehrere Hunde, KI-Trainer etc.). UI: Admin-Partner-Tab mit Freigabe-Sektion (offen-Badge, ✓/✗), Settings zeigt Partnern eine Karte mit Link zum Profil-Editor. Tests: tests/test_partner_profile.py — 5 Smoke-Tests (403, Voll-Flow inkl. Freigabe/Ablehnung, Pflicht-Anzeigename, Logo+Foto-Upload, Pro-Zugang). Suite: 44 passed.
This commit is contained in:
parent
178aef7fb0
commit
ce8aa2b699
11 changed files with 557 additions and 19 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1252
|
||||
1253
|
||||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1630,6 +1630,29 @@ 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:
|
||||
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}")
|
||||
|
||||
# Outreach-Log (Admin-E-Mail-Versand)
|
||||
try:
|
||||
conn.executescript("""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
@ -206,3 +212,329 @@ 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)
|
||||
|
||||
def _save():
|
||||
import io
|
||||
from PIL import Image, ImageOps
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
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)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
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))
|
||||
|
||||
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 (best effort — Silent-Skip ohne ADMIN_EMAIL)
|
||||
admin_email = os.getenv("ADMIN_EMAIL", "")
|
||||
if admin_email and profile.get("approved") != 1:
|
||||
try:
|
||||
from routes.outreach import _send_smtp
|
||||
_send_smtp(
|
||||
admin_email,
|
||||
f"[Ban Yaro] Partner-Profil eingereicht: {profile.get('display_name')}",
|
||||
(f"Partner {user['name']} ({user['email']}) hat sein Profil zur "
|
||||
f"Freigabe eingereicht.\n\nAdmin-Panel: https://banyaro.app/#admin"),
|
||||
"support",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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=1253"></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=1253">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1253">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1253">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1253">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1253">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -612,11 +612,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=1253"></script>
|
||||
<script src="/js/ui.js?v=1253"></script>
|
||||
<script src="/js/app.js?v=1253"></script>
|
||||
<script src="/js/worlds.js?v=1253"></script>
|
||||
<script src="/js/offline-indicator.js?v=1253"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -626,7 +626,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1252"></script>
|
||||
<script src="/js/boot.js?v=1253"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1252'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1253'; // ← 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;
|
||||
|
|
@ -104,6 +104,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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2289,7 +2289,8 @@ 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(() => [])) || [];
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
|
||||
|
|
@ -2383,6 +2384,36 @@ window.Page_admin = (() => {
|
|||
</div>
|
||||
</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="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border)" data-pp-uid="${p.user_id}">
|
||||
${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>`}
|
||||
${p.approved !== 1 ? `<button class="btn btn-sm btn-primary adm-pp-review" data-uid="${p.user_id}" data-val="1">✓</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>`).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 +2445,18 @@ window.Page_admin = (() => {
|
|||
</div>
|
||||
`;
|
||||
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -665,6 +665,21 @@ 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. Richte dein Profil ein — nach der Freigabe ist es für alle sichtbar.
|
||||
</p>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-partner-profile-btn">
|
||||
${UI.icon('pencil-simple')} Mein Partner-Profil
|
||||
</button>
|
||||
</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 +1675,9 @@ window.Page_settings = (() => {
|
|||
|
||||
_loadReferral();
|
||||
_loadBreederCard();
|
||||
|
||||
document.getElementById('settings-partner-profile-btn')
|
||||
?.addEventListener('click', () => App.navigate('partner-profil'));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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=1253"></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 = '1253';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
119
tests/test_partner_profile.py
Normal file
119
tests/test_partner_profile.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""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_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})
|
||||
Loading…
Add table
Add a link
Reference in a new issue