diff --git a/VERSION b/VERSION index 03ce6df..b0a0271 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1252 \ No newline at end of file +1253 \ No newline at end of file diff --git a/backend/auth.py b/backend/auth.py index 1b5f126..f5cabd7 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -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") diff --git a/backend/database.py b/backend/database.py index 457f5a3..5b5b0c8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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(""" diff --git a/backend/routes/partner.py b/backend/routes/partner.py index 16caafb..b405e8a 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -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} diff --git a/backend/static/index.html b/backend/static/index.html index 5a330e3..3657214 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 9a5e14c..92a040a 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -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); } diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 294b532..717677c 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -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 = `
@@ -2383,6 +2384,36 @@ window.Page_admin = (() => {
+ +
+

+ Profil-Freigaben + ${profiles.filter(p => p.submitted_at && p.approved === 0).length + ? `${profiles.filter(p => p.submitted_at && p.approved === 0).length} offen` : ''} +

+ ${profiles.length === 0 + ? `

Noch keine Partner-Profile angelegt.

` + : profiles.map(p => ` +
+ ${p.logo_url + ? `` + : `
`} +
+
${UI.escape(p.display_name || p.name)}
+
${UI.escape(p.name)} · ${UI.escape(p.email)}${p.photos?.length ? ` · ${p.photos.length} Medien` : ''}
+
+ ${p.approved === 1 + ? `✓ Frei` + : p.approved === -1 + ? `✗ Abgelehnt` + : p.submitted_at + ? `⏳ Prüfen` + : `Entwurf`} + ${p.approved !== 1 ? `` : ''} + ${p.approved !== -1 ? `` : ''} +
`).join('')} +
+

Nutzer-Status manuell vergeben

@@ -2414,6 +2445,18 @@ window.Page_admin = (() => {
`; + // 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(); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 20d857e..331cbdb 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -665,6 +665,21 @@ window.Page_settings = (() => {
+ ${u.is_partner ? ` + +
+
${UI.icon('handshake')} Partner
+
+

+ 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. +

+ +
+
` : ''} +
Trophäen
@@ -1660,6 +1675,9 @@ window.Page_settings = (() => { _loadReferral(); _loadBreederCard(); + + document.getElementById('settings-partner-profile-btn') + ?.addEventListener('click', () => App.navigate('partner-profil')); } // ---------------------------------------------------------- diff --git a/backend/static/landing.html b/backend/static/landing.html index f513edc..75f1905 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index d3a5f27..449fd49 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -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 diff --git a/tests/test_partner_profile.py b/tests/test_partner_profile.py new file mode 100644 index 0000000..d26c26f --- /dev/null +++ b/tests/test_partner_profile.py @@ -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})