From 5f01abc59061bba16889b9c8474ac6a44491470a Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 21:00:14 +0200 Subject: [PATCH] =?UTF-8?q?Z=C3=BCchter-Editor:=20Wurfnamen=20sichtbar,=20?= =?UTF-8?q?'undefined=20Medien'=20gefixt,=20Mitgliedschaften=20&=20Zertifi?= =?UTF-8?q?kate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rene: 'Züchter sollten mehr Einfluss haben — Wurfnamen (B-Wurf), Mitglied- schaften und Zertifikate fürs Profil.' - Wurfnamen: Infrastruktur existierte komplett (wurf_rang/wurf_name in DB, Backend, Wurfverwaltungs-Formular) — war nur im Editor und auf der öffentlichen Seite unsichtbar. Editor-Karten zeigen jetzt 'B-Wurf · Name' (Feldname-Bug geburtsdatum→geburt_datum), öffentliche Wurf-Karten bekommen den Rang/Namen als Badge, Hinweis im Editor verlinkt zur Vergabe. - 'undefined Medien': my-editor lieferte kein foto_count (+photos fürs Profil-Grid) — ergänzt. - NEU Mitgliedschaften & Zertifikate: entity_type 'certificate' im Foto- System (Ownership wie breeder), Editor-Sektion mit Upload (Bezeichnung als Caption) + Löschen, öffentliche Profilseite zeigt eigene Sektion mit Logos/Urkunden (klickbar, lazy). Public-Endpoint liefert result.zertifikate. Tests: my-editor inkl. Wurfname/foto_count, Zertifikat-Roundtrip bis zur öffentlichen Seite. Suite: 61 passed. --- VERSION | 2 +- backend/routes/breeder.py | 31 +++++++++- backend/routes/breeder_photos.py | 6 +- backend/static/index.html | 24 ++++---- backend/static/js/app.js | 2 +- backend/static/js/pages/breeder-editor.js | 72 +++++++++++++++++++++-- backend/static/js/pages/breeder.js | 22 +++++++ backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_breeder_editor.py | 51 +++++++++++++++- 10 files changed, 186 insertions(+), 28 deletions(-) diff --git a/VERSION b/VERSION index d3e6945..a01282d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1269 \ No newline at end of file +1270 \ No newline at end of file diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index 8279229..457766e 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -402,7 +402,8 @@ async def breeder_public_profile(zwingername: str): # Sichtbare Würfe wuerfe = conn.execute(""" - SELECT id, vater_name, mutter_name, geburt_datum, erwartetes_datum, + SELECT id, wurf_rang, wurf_name, vater_name, mutter_name, + geburt_datum, erwartetes_datum, status, welpen_gesamt, welpen_verfuegbar, preis_spanne, beschreibung FROM litters WHERE breeder_id=? AND sichtbar=1 AND status != 'abgeschlossen' @@ -410,6 +411,19 @@ async def breeder_public_profile(zwingername: str): """, (breeder_id,)).fetchall() result["wuerfe"] = [dict(w) for w in wuerfe] + # Mitgliedschaften & Zertifikate (öffentliche Logos/Badges mit Caption) + certs = conn.execute(""" + SELECT id, file_path, thumbnail_path, caption FROM breeder_photos + WHERE breeder_id=? AND entity_type='certificate' AND visibility='public' + ORDER BY sort_order + """, (breeder_id,)).fetchall() + result["zertifikate"] = [{ + "id": c["id"], + "url": f"/media/{c['file_path']}", + "thumbnail_url": f"/media/{c['thumbnail_path']}" if c["thumbnail_path"] else f"/media/{c['file_path']}", + "caption": c["caption"], + } for c in certs] + # Gesundheits-Statistik (aggregiert über alle öffentlichen Hunde) hd_stats = conn.execute(""" SELECT ergebnis, COUNT(*) as cnt FROM dog_health_tests @@ -496,6 +510,7 @@ async def breeder_my_editor(user=Depends(require_breeder)): """Daten für den Profil-Editor: Profil + eigene Würfe + Speicherverbrauch. (Frontend breeder-editor.js stammt aus 459cd42 — dieser Lese-Endpoint ging damals im Worktree-Merge verloren, wie /partner/my-profile.)""" + from routes.breeder_photos import _photo_dict with db() as conn: profile = conn.execute( "SELECT * FROM breeder_profiles WHERE user_id=?", (user["id"],) @@ -503,8 +518,20 @@ async def breeder_my_editor(user=Depends(require_breeder)): if not profile: raise HTTPException(404, "Noch kein Züchter-Profil angelegt.") profile = dict(profile) + profile["photos"] = [_photo_dict(r) for r in conn.execute( + "SELECT * FROM breeder_photos WHERE breeder_id=? AND entity_type='breeder' ORDER BY sort_order", + (profile["id"],) + ).fetchall()] + # Mitgliedschaften & Zertifikate (Logos/Badges fürs öffentliche Profil) + profile["certificates"] = [_photo_dict(r) for r in conn.execute( + "SELECT * FROM breeder_photos WHERE breeder_id=? AND entity_type='certificate' ORDER BY sort_order", + (profile["id"],) + ).fetchall()] litters = [dict(r) for r in conn.execute( - "SELECT * FROM litters WHERE breeder_id=? ORDER BY created_at DESC", + """SELECT l.*, + (SELECT COUNT(*) FROM breeder_photos p + WHERE p.entity_type='litter' AND p.entity_id=l.id) AS foto_count + FROM litters l WHERE l.breeder_id=? ORDER BY l.created_at DESC""", (profile["id"],) ).fetchall()] diff --git a/backend/routes/breeder_photos.py b/backend/routes/breeder_photos.py index 802440f..a080e9b 100644 --- a/backend/routes/breeder_photos.py +++ b/backend/routes/breeder_photos.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") -_VALID_ENTITY_TYPES = {"breeder", "litter", "puppy", "parent"} +_VALID_ENTITY_TYPES = {"breeder", "litter", "puppy", "parent", "certificate"} # ------------------------------------------------------------------ @@ -100,7 +100,7 @@ async def upload_photo( elif entity_type == "parent": # parent kann frei hochgeladen werden solange breeder stimmt pass - elif entity_type == "breeder": + elif entity_type in ("breeder", "certificate"): # entity_id muss das eigene Profil sein if entity_id != breeder_id and user["rolle"] != "admin": raise HTTPException(403, "Kein Zugriff auf dieses Züchter-Profil.") @@ -200,7 +200,7 @@ async def get_photos( ).fetchone() if bp: # Besitzer wenn entity dem Züchter gehört - if entity_type == "breeder": + if entity_type in ("breeder", "certificate"): is_owner = (bp["id"] == entity_id) elif entity_type == "litter": row = conn.execute( diff --git a/backend/static/index.html b/backend/static/index.html index 8c68db2..bedfe18 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -620,11 +620,11 @@ - - - - - + + + + + @@ -634,7 +634,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3820110..5bfdfc1 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 = '1269'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1270'; // ← 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; diff --git a/backend/static/js/pages/breeder-editor.js b/backend/static/js/pages/breeder-editor.js index eec5525..0da8054 100644 --- a/backend/static/js/pages/breeder-editor.js +++ b/backend/static/js/pages/breeder-editor.js @@ -138,6 +138,34 @@ window.Page_breeder_editor = (() => { + +
+
+ Mitgliedschaften & Zertifikate +
+
+ Vereins-Logos, VDH-Mitgliedschaft, Urkunden — werden auf deiner öffentlichen + Profilseite in einer eigenen Sektion gezeigt. +
+
+ ${(p.certificates || []).map(c => ` +
+ +
${UI.escape(c.caption || '—')}
+ +
`).join('')} +
+ +
+ ${litters.length ? `
@@ -148,6 +176,10 @@ window.Page_breeder_editor = (() => {
${litters.map(l => _renderLitterCard(l)).join('')}
+
+ ${UI.icon('info')} Wurf-Rang (A-, B-Wurf …) und Wurfnamen vergibst du in der + Wurfverwaltung. +
` : ''} @@ -179,12 +211,14 @@ window.Page_breeder_editor = (() => { } function _renderLitterCard(l) { - const label = l.geburtsdatum - ? `Wurf vom ${new Date(l.geburtsdatum).toLocaleDateString('de-DE')}` - : `Wurf #${l.id}`; + // Wurf-Rang/-Name (aus der Wurfverwaltung) zuerst, dann Datum, dann #id + const name = [l.wurf_rang ? `${l.wurf_rang}-Wurf` : null, l.wurf_name] + .filter(Boolean).join(' · '); + const label = name + || (l.geburt_datum ? `Wurf vom ${new Date(l.geburt_datum).toLocaleDateString('de-DE')}` : `Wurf #${l.id}`); const info = [ l.welpen_gesamt ? `${l.welpen_gesamt} Welpen` : null, - `${l.foto_count} Medien`, + `${l.foto_count ?? 0} Medien`, ].filter(Boolean).join(' · '); return `
@@ -287,6 +321,36 @@ window.Page_breeder_editor = (() => { }); // Wurf-Upload + // Zertifikat/Mitgliedschaft hochladen — Bezeichnung wird als Caption gespeichert + el.querySelector('#be-cert-input')?.addEventListener('change', async e => { + const file = e.target.files[0]; + if (!file) return; + const caption = (window.prompt('Bezeichnung (z. B. „VDH-Mitglied", „Club für Britische Hütehunde"):') || '').trim(); + const fd = new FormData(); + fd.append('file', file); + fd.append('entity_type', 'certificate'); + fd.append('entity_id', String(_data.profile.id)); + fd.append('visibility', 'public'); + if (caption) fd.append('caption', caption); + try { + const ph = await API.breederPhotos.upload(fd); + (_data.profile.certificates ||= []).push(ph); + UI.toast.success('Zertifikat hinzugefügt — erscheint auf deiner Profilseite.'); + _render(); + } catch (err) { UI.toast.error(err.message); } + }); + + // Zertifikat löschen + el.querySelectorAll('.be-cert-del').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await API.breederPhotos.remove(parseInt(btn.dataset.id)); + _data.profile.certificates = (_data.profile.certificates || []).filter(c => String(c.id) !== btn.dataset.id); + _render(); + } catch (err) { UI.toast.error(err.message); } + }); + }); + el.querySelectorAll('.be-litter-input').forEach(input => { input.addEventListener('change', async e => { const file = e.target.files[0]; diff --git a/backend/static/js/pages/breeder.js b/backend/static/js/pages/breeder.js index 917bcaa..a1cc75b 100644 --- a/backend/static/js/pages/breeder.js +++ b/backend/static/js/pages/breeder.js @@ -160,6 +160,25 @@ window.Page_breeder = (() => {
` : ''} + + ${p.zertifikate?.length ? ` +
+

+ ${UI.icon('seal-check')} Mitgliedschaften & Zertifikate +

+
+ ${p.zertifikate.map(z => ` + + ${UI.escape(z.caption || 'Zertifikat')} + ${z.caption ? `
${UI.escape(z.caption)}
` : ''} +
`).join('')} +
+
` : ''} + ${(p.hd_stats?.length || p.ed_stats?.length) ? `
@@ -306,6 +325,8 @@ window.Page_breeder = (() => { const _STATUS_COLOR = { geplant: '#6b7280', geboren: '#3b82f6', verfuegbar: '#16a34a', abgeschlossen: '#9ca3af' }; function _wurfCard(w) { + const wurfTitel = [w.wurf_rang ? `${w.wurf_rang}-Wurf` : null, w.wurf_name] + .filter(Boolean).join(' · '); const eltern = [w.vater_name, w.mutter_name].filter(Boolean).join(' × ') || '—'; const datum = w.geburt_datum ? `Geburt: ${_fmtDate(w.geburt_datum)}` @@ -316,6 +337,7 @@ window.Page_breeder = (() => {
+ ${wurfTitel ? `${UI.escape(wurfTitel)}` : ''} ${UI.escape(eltern)} ${sl} diff --git a/backend/static/landing.html b/backend/static/landing.html index 891c8f2..cfed93e 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 fd4c73b..7998404 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 = '1269'; +const VER = '1270'; 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_breeder_editor.py b/tests/test_breeder_editor.py index bd520d8..f58fd2c 100644 --- a/tests/test_breeder_editor.py +++ b/tests/test_breeder_editor.py @@ -14,20 +14,65 @@ def test_my_editor_without_profile_404(client, admin): def test_my_editor_with_profile(client, user): - """Zuechter mit Profil -> profile + litters + storage.""" + """Zuechter mit Profil -> profile (inkl. photos/certificates) + litters mit Namen + storage.""" from database import db with db() as conn: uid = conn.execute("SELECT id FROM users WHERE email=?", (user["email"],)).fetchone()["id"] - conn.execute("UPDATE users SET rolle='breeder' WHERE id=?", (uid,)) + conn.execute("UPDATE users SET rolle='breeder', breeder_status='approved' WHERE id=?", (uid,)) conn.execute( """INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, stadt) VALUES (?,?,?,?,?)""", (uid, "Vom Teststall", "Labrador", "VDH", "Ebersberg") ) + bid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute( + "INSERT INTO litters (breeder_id, wurf_rang, wurf_name, welpen_gesamt) VALUES (?,?,?,?)", + (bid, "B", "Bergfest-Wurf", 6) + ) r = client.get("/api/breeder/my-editor", headers=user["headers"]) assert r.status_code == 200, r.text d = r.json() assert d["profile"]["zwingername"] == "Vom Teststall" - assert isinstance(d["litters"], list) + assert isinstance(d["profile"]["photos"], list) + assert isinstance(d["profile"]["certificates"], list) + assert d["litters"][0]["wurf_rang"] == "B" + assert d["litters"][0]["foto_count"] == 0 # kein 'undefined Medien' mehr assert d["storage_limit_mb"] == 200 assert d["storage_mb"] >= 0 + + +def test_certificate_upload_and_public_profile(client, user): + """Zertifikat hochladen -> erscheint im Editor UND auf der oeffentlichen Profilseite.""" + import io + from PIL import Image + from database import db + with db() as conn: + uid = conn.execute("SELECT id FROM users WHERE email=?", (user["email"],)).fetchone()["id"] + conn.execute("UPDATE users SET rolle='breeder', breeder_status='approved' WHERE id=?", (uid,)) + conn.execute( + """INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, stadt) + VALUES (?,?,?,?,?)""", + (uid, "Zertifikat-Zwinger", "Collie", "VDH", "Ebersberg") + ) + bid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + + buf = io.BytesIO() + Image.new("RGB", (64, 64), "gold").save(buf, format="PNG") + buf.seek(0) + r = client.post("/api/breeder/photos/upload", headers=user["headers"], + data={"entity_type": "certificate", "entity_id": str(bid), + "visibility": "public", "caption": "VDH-Mitglied"}, + files={"file": ("vdh.png", buf, "image/png")}) + assert r.status_code == 200, r.text + + # Editor liefert das Zertifikat + r = client.get("/api/breeder/my-editor", headers=user["headers"]) + certs = r.json()["profile"]["certificates"] + assert len(certs) == 1 and certs[0]["caption"] == "VDH-Mitglied" + + # Oeffentliche Profilseite (ohne Login) zeigt es + r = client.get("/api/breeder/profil/Zertifikat-Zwinger") + assert r.status_code == 200, r.text + z = r.json()["zertifikate"] + assert len(z) == 1 and z[0]["caption"] == "VDH-Mitglied" + assert z[0]["url"].startswith("/media/")