Züchter-Editor: Wurfnamen sichtbar, 'undefined Medien' gefixt, Mitgliedschaften & Zertifikate

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.
This commit is contained in:
rene 2026-06-07 21:00:14 +02:00
parent dfffd07a96
commit 5f01abc590
10 changed files with 186 additions and 28 deletions

View file

@ -1 +1 @@
1269
1270

View file

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

View file

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

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1269"></script>
<script src="/js/boot-early.js?v=1270"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1269">
<link rel="stylesheet" href="/css/layout.css?v=1269">
<link rel="stylesheet" href="/css/components.css?v=1269">
<link rel="stylesheet" href="/css/utilities.css?v=1269">
<link rel="stylesheet" href="/css/lists.css?v=1269">
<link rel="stylesheet" href="/css/design-system.css?v=1270">
<link rel="stylesheet" href="/css/layout.css?v=1270">
<link rel="stylesheet" href="/css/components.css?v=1270">
<link rel="stylesheet" href="/css/utilities.css?v=1270">
<link rel="stylesheet" href="/css/lists.css?v=1270">
</head>
<body>
@ -620,11 +620,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1269"></script>
<script src="/js/ui.js?v=1269"></script>
<script src="/js/app.js?v=1269"></script>
<script src="/js/worlds.js?v=1269"></script>
<script src="/js/offline-indicator.js?v=1269"></script>
<script src="/js/api.js?v=1270"></script>
<script src="/js/ui.js?v=1270"></script>
<script src="/js/app.js?v=1270"></script>
<script src="/js/worlds.js?v=1270"></script>
<script src="/js/offline-indicator.js?v=1270"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -634,7 +634,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1269"></script>
<script src="/js/boot.js?v=1270"></script>
</body>

View file

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

View file

@ -138,6 +138,34 @@ window.Page_breeder_editor = (() => {
</label>
</div>
<!-- Mitgliedschaften & Zertifikate -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-2)">
Mitgliedschaften &amp; Zertifikate
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
Vereins-Logos, VDH-Mitgliedschaft, Urkunden werden auf deiner öffentlichen
Profilseite in einer eigenen Sektion gezeigt.
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2);margin:var(--space-3) 0">
${(p.certificates || []).map(c => `
<div style="position:relative;border:1px solid var(--c-border);border-radius:var(--radius-md);overflow:hidden;background:var(--c-surface-2)">
<img src="${UI.escape(c.thumbnail_url || c.url)}" style="width:100%;aspect-ratio:1;object-fit:contain;padding:6px;box-sizing:border-box">
<div style="font-size:10px;text-align:center;padding:2px 4px 5px;color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${UI.escape(c.caption || '—')}</div>
<button class="be-cert-del" data-id="${c.id}"
style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,.6);
border:none;border-radius:50%;width:24px;height:24px;cursor:pointer;
color:#fff;font-size:14px;display:flex;align-items:center;justify-content:center">×</button>
</div>`).join('')}
</div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#plus"></use></svg>
Logo / Urkunde hinzufügen
<input type="file" id="be-cert-input" accept="image/*" class="hidden">
</label>
</div>
<!-- Würfe Schnellupload -->
${litters.length ? `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
@ -148,6 +176,10 @@ window.Page_breeder_editor = (() => {
<div class="flex-col-gap-3">
${litters.map(l => _renderLitterCard(l)).join('')}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
${UI.icon('info')} Wurf-Rang (A-, B-Wurf ) und Wurfnamen vergibst du in der
<a href="#litters" class="text-primary">Wurfverwaltung</a>.
</div>
</div>` : ''}
</div>
@ -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 `
<div style="border:1px solid var(--c-border);border-radius:var(--radius-md);padding:var(--space-3)">
@ -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];

View file

@ -160,6 +160,25 @@ window.Page_breeder = (() => {
</div>
</div>` : ''}
<!-- Mitgliedschaften & Zertifikate -->
${p.zertifikate?.length ? `
<div style="margin-bottom:var(--space-5)">
<h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700;
display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('seal-check')} Mitgliedschaften &amp; Zertifikate
</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:var(--space-3)">
${p.zertifikate.map(z => `
<a href="${UI.escape(z.url)}" target="_blank" rel="noopener"
style="display:block;background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-2);text-decoration:none;text-align:center">
<img src="${UI.escape(z.thumbnail_url || z.url)}" alt="${UI.escape(z.caption || 'Zertifikat')}"
loading="lazy" style="width:100%;aspect-ratio:1;object-fit:contain">
${z.caption ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-1);overflow:hidden;text-overflow:ellipsis">${UI.escape(z.caption)}</div>` : ''}
</a>`).join('')}
</div>
</div>` : ''}
<!-- Gesundheits-Transparenz -->
${(p.hd_stats?.length || p.ed_stats?.length) ? `
<div style="margin-bottom:var(--space-5)">
@ -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 = (() => {
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-3) var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
${wurfTitel ? `<span style="background:var(--c-primary);color:#fff;border-radius:999px;padding:1px 10px;font-size:var(--text-xs);font-weight:700">${UI.escape(wurfTitel)}</span>` : ''}
<span style="font-weight:700;font-size:var(--text-sm)">${UI.escape(eltern)}</span>
<span style="background:${sc}1a;color:${sc};border:1px solid ${sc}40;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${sl}</span>

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=1269"></script>
<script src="/js/landing-init.js?v=1270"></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 = '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

View file

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