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:
rene 2026-06-07 17:20:20 +02:00
parent 178aef7fb0
commit ce8aa2b699
11 changed files with 557 additions and 19 deletions

View file

@ -1 +1 @@
1252
1253

View file

@ -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")

View file

@ -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("""

View file

@ -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}

View file

@ -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>

View file

@ -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);
}

View file

@ -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();

View file

@ -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'));
}
// ----------------------------------------------------------

View file

@ -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">

View file

@ -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

View 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})