From 6a6a09d879033e6aef5596227c041c44f3172bcf Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 19:44:57 +0200 Subject: [PATCH 1/6] =?UTF-8?q?Marketing-Cockpit:=20Partner-Programm=20?= =?UTF-8?q?=F0=9F=9F=A2=20(v1265)=20=E2=80=94=20Infra=20komplett,=20Influe?= =?UTF-8?q?ncer-Runde=203=20mit=20Partner-Paket=20als=20Angebot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MARKETING.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MARKETING.md b/MARKETING.md index d232fe2..72078eb 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -14,7 +14,8 @@ _Stand: 2026-06-03_ | Lokal (Ebersberg) | ⬜ offen | Tierärzte, Hundeschulen, Futterläden, Tierheim | | Online-Communities | ⬜ offen | FB-Gruppen Landkreis EBE + nebenan.de | | Empfehlung / Referral | 🟡 Infra da (`referral_code`) | Empfehlungs-QR + Tracking sichtbar machen | -| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern | +| Partner-Programm | 🟢 Infra komplett (v1265, 07.06.) | Partner einladen! Showcase `#partner`, Pro gratis, Partner-Dashboard, QR-Kontingente (Druck-PDF) mit Einzel-Code-Tracking, Dank-Mails mit Statistik, Pause-Notbremse für geleakte Codes. Onboarding: Admin → Code anlegen → Partner-Badge → Besitzer zuordnen | +| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern — jetzt mit Partner-Paket als konkretem Angebot | | Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst | | Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE | | SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Backlinks (Blog-Testberichte) | From ed7c469c6ac4e52c37747d232586cd80df7f6028 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 19:55:51 +0200 Subject: [PATCH 2/6] =?UTF-8?q?Z=C3=BCchter-Bereich=20(Hub)=20+=20Settings?= =?UTF-8?q?-Partner-Karte=20raus=20+=20Admin:=20alle=20Code-Einl=C3=B6sung?= =?UTF-8?q?en=20mit=20Kanal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Seite #breeder-dashboard (Welten-Chip 'Züchter' role:breeder in HUND, ersetzt die Einzel-Chips Zuchtkartei + Wurfverw.; beide FABs wandern an den neuen Chip; Läufigkeit bleibt eigener Chip in HUND, Rene-Vorgabe): Zwinger-Karte (Name, verifiziert-Badge, Profil-Editor), Wurfverwaltung mit Wurf-Anzahl, Zuchtkartei mit Hunde-Anzahl. Einzelseiten bleiben erreichbar. - Settings: Partner-Karte entfernt — der 🤝-Welten-Chip ist der Einstieg. - Admin 'Aktive Codes': 👥 zeigt jetzt ALLE Einlösungen eines Codes mit Kanal-Badge (QR #seq aus Kontingent vs. Link/manuell), Datum und Bestätigt-Status — Endpoint /admin/partner/codes/{id}/registrations. Suite: 55 passed. --- VERSION | 2 +- backend/routes/partner.py | 22 ++++ backend/static/index.html | 28 ++-- backend/static/js/app.js | 3 +- backend/static/js/pages/admin.js | 41 ++++++ backend/static/js/pages/breeder-dashboard.js | 129 +++++++++++++++++++ backend/static/js/pages/settings.js | 23 ---- backend/static/js/worlds.js | 9 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_partner_qr.py | 24 ++++ 11 files changed, 241 insertions(+), 44 deletions(-) create mode 100644 backend/static/js/pages/breeder-dashboard.js diff --git a/VERSION b/VERSION index 3420149..4c8735e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1265 \ No newline at end of file +1266 \ No newline at end of file diff --git a/backend/routes/partner.py b/backend/routes/partner.py index 115517b..7690352 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -48,6 +48,28 @@ def list_partner_codes(user=Depends(require_admin)): return [dict(r) for r in rows] +@router.get("/admin/partner/codes/{code_id}/registrations") +def code_registrations(code_id: int, user=Depends(require_admin)): + """ALLE Einlösungen eines Partner-Codes — mit Kanal (QR-Sticker vs. Link/manuell). + Admin-only (personenbezogene Daten).""" + with db() as conn: + if not conn.execute( + "SELECT id FROM partner_codes WHERE id=?", (code_id,) + ).fetchone(): + raise HTTPException(404, "Partner-Code nicht gefunden.") + rows = conn.execute( + """SELECT u.id, u.name, u.email, u.email_verified, u.created_at, + q.seq AS qr_seq, b.label AS qr_batch_label + FROM users u + LEFT JOIN partner_qr_codes q ON q.token = u.referred_qr + LEFT JOIN partner_qr_batches b ON b.id = q.batch_id + WHERE u.referred_by = ? + ORDER BY u.created_at DESC""", + (-code_id,) + ).fetchall() + return [dict(r) for r in rows] + + @router.post("/admin/partner/codes/{code_id}/toggle") def toggle_partner_code(code_id: int, user=Depends(require_admin)): """Notbremse: Code pausieren/reaktivieren (z. B. wenn er im Internet kursiert). diff --git a/backend/static/index.html b/backend/static/index.html index 4d30aec..80ab2df 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -511,6 +511,10 @@
+
+
+
+
@@ -616,11 +620,11 @@ - - - - - + + + + + @@ -630,7 +634,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 1903e4a..9b433b3 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 = '1265'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1266'; // ← 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; @@ -73,6 +73,7 @@ const App = (() => { notifications: { title: 'Aktuelles', module: null, requiresAuth: true }, breeder: { title: 'Züchter-Profil', module: null }, 'breeder-editor': { title: 'Profil bearbeiten', module: null, requiresAuth: true }, + 'breeder-dashboard': { title: 'Züchter-Bereich', module: null, requiresAuth: true }, litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true }, wurfboerse: { title: 'Wurfbörse', module: null }, zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true }, diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index b07b51f..21120e7 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2381,6 +2381,10 @@ window.Page_admin = (() => { ${c.grants_founder ? '✓' : '—'} + ${c.uses > 0 ? ` + ` : ''} + + +
Lädt…
+ + `).join('')} ` @@ -2571,6 +2580,38 @@ window.Page_admin = (() => { `; + // Alle Einlösungen eines Codes (lazy, .hidden via classList) — mit Kanal-Spalte + el.querySelectorAll('.adm-code-regs').forEach(btn => { + btn.addEventListener('click', async () => { + const row = el.querySelector(`#adm-code-regs-${btn.dataset.id}`); + if (!row) return; + row.classList.toggle('hidden'); + if (row.classList.contains('hidden') || row.dataset.loaded === '1') return; + try { + const regs = await API.get(`/admin/partner/codes/${btn.dataset.id}/registrations`); + row.dataset.loaded = '1'; + const cell = row.querySelector('td'); + cell.innerHTML = !regs.length + ? `
Keine Accounts.
` + : regs.map(u => ` +
+
+ ${UI.escape(u.name)} + · ${UI.escape(u.email)} +
+ + ${u.qr_seq ? `QR #${u.qr_seq}` : 'Link/manuell'} + + ${(u.created_at || '').slice(0, 16).replace(' ', ' · ')} + ${u.email_verified + ? `✓ bestätigt` + : `⏳ unbestätigt`} +
`).join(''); + } catch (err) { UI.toast.error(err.message); } + }); + }); + // Code pausieren/aktivieren (Notbremse bei geleakten Codes) el.querySelectorAll('.adm-toggle-code').forEach(btn => { btn.addEventListener('click', async () => { diff --git a/backend/static/js/pages/breeder-dashboard.js b/backend/static/js/pages/breeder-dashboard.js new file mode 100644 index 0000000..4fde179 --- /dev/null +++ b/backend/static/js/pages/breeder-dashboard.js @@ -0,0 +1,129 @@ +/* ============================================================ + BAN YARO — Züchter-Bereich + Hub für Züchter: Profil-Status, Wurfverwaltung, Zuchtkartei. + (Läufigkeit bleibt bewusst als eigener Chip in der HUND-Welt.) + ============================================================ */ + +window.Page_breeder_dashboard = (() => { + + let _container = null; + + async function init(container) { + _container = container; + _render(); + await _load(); + } + + function refresh() { _load(); } + function onDogChange() {} + + function _render() { + _container.innerHTML = ` +
+
+

+ ${UI.icon('certificate')} Züchter-Bereich +

+

+ Dein Zwinger, deine Würfe, deine Zuchthunde. +

+
+
+
Lade…
+
+
+ `; + } + + async function _load() { + const el = _container.querySelector('#bd-content'); + try { + const [status, litters, hunde] = await Promise.all([ + API.breeder.status().catch(() => null), + API.litters.myList().catch(() => []), + API.zuchthunde.list().catch(() => []), + ]); + el.innerHTML = _renderHub(status, litters || [], hunde || []); + _bindEvents(el); + } catch (e) { + el.innerHTML = `

${UI.escape(e.message || 'Fehler beim Laden.')}

`; + } + } + + function _renderHub(status, litters, hunde) { + const profile = status?.profile; + const isBreeder = status?.rolle === 'breeder' || status?.rolle === 'admin'; + if (!isBreeder) { + return ` +
+

+ Der Züchter-Bereich ist für verifizierte Züchter. + Den Antrag findest du in den Einstellungen. +

+
`; + } + + return ` + +
+
+
+
Mein Zwinger
+
${UI.escape(profile?.zwingername || 'Noch kein Profil angelegt')}
+ ${profile?.rasse_text ? `
${UI.escape(profile.rasse_text)}
` : ''} + + ${UI.icon('check-circle')} Verifizierter Züchter + +
+ +
+
+ + +
+
+
+ +
+
+
Wurfverwaltung
+
${litters.length} ${litters.length === 1 ? 'Wurf' : 'Würfe'} · Welpen, Gewichte, Kaufverträge
+
+ +
+
+ + +
+
+
+ +
+
+
Zuchtkartei
+
${hunde.length} ${hunde.length === 1 ? 'Zuchthund' : 'Zuchthunde'} · Stammbaum, Genetik, Titel
+
+ +
+
+ +
+ ${UI.icon('info')} Läufigkeit & Trächtigkeit findest du wie gewohnt in der HUND-Welt. +
+ `; + } + + function _bindEvents(el) { + el.querySelectorAll('[data-bd-nav]').forEach(btn => { + btn.addEventListener('click', () => App.navigate(btn.dataset.bdNav)); + }); + } + + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 66d9af0..f4bc01d 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -665,25 +665,6 @@ 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. Deine Zahlen und QR-Codes findest du im Partner-Bereich. -

-
- - -
-
-
` : ''}
Trophäen
@@ -1681,10 +1662,6 @@ window.Page_settings = (() => { _loadReferral(); _loadBreederCard(); - document.getElementById('settings-partner-dashboard-btn') - ?.addEventListener('click', () => App.navigate('partner-dashboard')); - document.getElementById('settings-partner-profile-btn') - ?.addEventListener('click', () => App.navigate('partner-profil')); } // ---------------------------------------------------------- diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index e54f867..936b348 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -570,10 +570,9 @@ window.Worlds = (() => { { icon:'sparkle', label:'Jobs', page:'jobs' }, { icon:'book-open', label:'Knigge', page:'knigge' }, { icon:'film-slate', label:'Filme', page:'movies' }, - { icon:'tree-structure', label:'Zuchtkartei', page:'zuchthunde', role:'breeder', - fab:[{ icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] }, - { icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder', - fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }] }, + { icon:'certificate', label:'Züchter', page:'breeder-dashboard', role:'breeder', + fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }, + { icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] }, { icon:'thermometer', label:'Läufigkeit', page:'laeufi', role:'breeder' }, { icon:'sparkle', label:'Social', page:'social', role:'social', fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] }, @@ -590,7 +589,7 @@ window.Worlds = (() => { const _DEFAULT_CONFIG = { jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','partner-dashboard','admin'], hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse', - 'litters','zuchthunde','laeufi','ernaehrung','personality'], + 'breeder-dashboard','laeufi','ernaehrung','personality'], welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events', 'jobs','knigge','movies','reise'], }; diff --git a/backend/static/landing.html b/backend/static/landing.html index 88ec792..9ead496 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 3059fa7..46913ce 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 = '1265'; +const VER = '1266'; 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_qr.py b/tests/test_partner_qr.py index 73c2994..6dfbac7 100644 --- a/tests/test_partner_qr.py +++ b/tests/test_partner_qr.py @@ -125,6 +125,30 @@ def test_registration_with_qr_only(client, admin): assert row["referred_qr"] == token +def test_code_registrations_with_channel(client, admin): + """Admin-Liste aller Code-Einloesungen unterscheidet QR-Sticker und Link/manuell.""" + code = _create_code(client, admin) + batch = _create_batch(client, admin, code["id"], quantity=1) + token = _batch_tokens(batch["id"])[0] + + # 1x via QR, 1x via Code direkt + client.post("/api/auth/register", json={ + "email": f"ch1-{secrets.token_hex(4)}@example.com", "password": "QrTest1234!", + "name": f"ch1{secrets.token_hex(3)}", "ref_code": code["code"], "qr_token": token, + }) + client.post("/api/auth/register", json={ + "email": f"ch2-{secrets.token_hex(4)}@example.com", "password": "QrTest1234!", + "name": f"ch2{secrets.token_hex(3)}", "ref_code": code["code"], + }) + + r = client.get(f"/api/admin/partner/codes/{code['id']}/registrations", headers=admin["headers"]) + assert r.status_code == 200 + regs = r.json() + assert len(regs) == 2 + channels = {(x["qr_seq"] or 0) for x in regs} + assert channels == {0, 1} # einer ohne QR (None), einer über Sticker #1 + + def test_paused_code_not_redeemable(client, admin): """Pausierter Code (Notbremse) -> keine Einloesung, Info-Endpoint 404; reaktivierbar.""" code = _create_code(client, admin) From 487dacc7c74b5b11300098e96cfdaa000d669ac7 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 20:04:43 +0200 Subject: [PATCH 3/6] =?UTF-8?q?Fix:=20/breeder/my-editor=20Endpoint=20(Cra?= =?UTF-8?q?sh=20'Cannot=20destructure=20profile')=20+=20L=C3=A4ufigkeit=20?= =?UTF-8?q?in=20Z=C3=BCchter-Bereich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit breeder-editor.js (aus 459cd42) rief /api/breeder/my-editor auf — Endpoint existierte nie (gleicher Worktree-Verlust wie /partner/my-profile). Jetzt gebaut: profile + litters + storage_mb/limit; ohne Profil klare 404 statt Destrukturierungs-Crash. 3 Tests. Läufigkeit (Rene): eigener HUND-Chip entfällt, stattdessen vierte Karte im Züchter-Bereich (Zyklen, Progesterontests, Deckdaten). Suite: 58 passed. --- VERSION | 2 +- backend/routes/breeder.py | 37 ++++++++++++++++++++ backend/static/index.html | 24 ++++++------- backend/static/js/app.js | 2 +- backend/static/js/pages/breeder-dashboard.js | 15 ++++++-- backend/static/js/worlds.js | 3 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_breeder_editor.py | 33 +++++++++++++++++ 9 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 tests/test_breeder_editor.py diff --git a/VERSION b/VERSION index 4c8735e..f845c1c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1266 \ No newline at end of file +1267 \ No newline at end of file diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index e53a1d4..8279229 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -491,6 +491,43 @@ class BreederProfileUpdate(BaseModel): website: Optional[str] = Field(None, max_length=500) beschreibung: Optional[str] = Field(None, max_length=10000) +@router.get("/breeder/my-editor") +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.)""" + with db() as conn: + profile = conn.execute( + "SELECT * FROM breeder_profiles WHERE user_id=?", (user["id"],) + ).fetchone() + if not profile: + raise HTTPException(404, "Noch kein Züchter-Profil angelegt.") + profile = dict(profile) + litters = [dict(r) for r in conn.execute( + "SELECT * FROM litters WHERE breeder_id=? ORDER BY created_at DESC", + (profile["id"],) + ).fetchall()] + + # Speicherverbrauch der Züchter-Medien (MEDIA_DIR/breeders/{breeder_id}/**) + media_dir = os.getenv("MEDIA_DIR", "/data/media") + base = os.path.join(media_dir, "breeders", str(profile["id"])) + total = 0 + if os.path.isdir(base): + for root, _dirs, files in os.walk(base): + for f in files: + try: + total += os.path.getsize(os.path.join(root, f)) + except OSError: + pass + + return { + "profile": profile, + "litters": litters, + "storage_mb": round(total / (1024 * 1024), 4), + "storage_limit_mb": 200, + } + + @router.put("/breeder/profile") async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)): with db() as conn: diff --git a/backend/static/index.html b/backend/static/index.html index 80ab2df..207852f 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 9b433b3..aa3b099 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 = '1266'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1267'; // ← 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-dashboard.js b/backend/static/js/pages/breeder-dashboard.js index 4fde179..ac66f30 100644 --- a/backend/static/js/pages/breeder-dashboard.js +++ b/backend/static/js/pages/breeder-dashboard.js @@ -112,8 +112,19 @@ window.Page_breeder_dashboard = (() => {
-
- ${UI.icon('info')} Läufigkeit & Trächtigkeit findest du wie gewohnt in der HUND-Welt. + +
+
+
+ +
+
+
Läufigkeit & Trächtigkeit
+
Zyklen, Progesterontests, Deckdaten, Meilensteine
+
+ +
`; } diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 936b348..84830ec 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -573,7 +573,6 @@ window.Worlds = (() => { { icon:'certificate', label:'Züchter', page:'breeder-dashboard', role:'breeder', fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }, { icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] }, - { icon:'thermometer', label:'Läufigkeit', page:'laeufi', role:'breeder' }, { icon:'sparkle', label:'Social', page:'social', role:'social', fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] }, { icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' }, @@ -589,7 +588,7 @@ window.Worlds = (() => { const _DEFAULT_CONFIG = { jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','partner-dashboard','admin'], hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse', - 'breeder-dashboard','laeufi','ernaehrung','personality'], + 'breeder-dashboard','ernaehrung','personality'], welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events', 'jobs','knigge','movies','reise'], }; diff --git a/backend/static/landing.html b/backend/static/landing.html index 9ead496..654b373 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 46913ce..24b1305 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 = '1266'; +const VER = '1267'; 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 new file mode 100644 index 0000000..bd520d8 --- /dev/null +++ b/tests/test_breeder_editor.py @@ -0,0 +1,33 @@ +"""Smoke-Tests fuer den Zuechter-Profil-Editor-Endpoint (/breeder/my-editor).""" + + +def test_my_editor_requires_breeder(client, user): + r = client.get("/api/breeder/my-editor", headers=user["headers"]) + assert r.status_code == 403 + + +def test_my_editor_without_profile_404(client, admin): + """Admin ohne Zuechterprofil -> klare 404-Meldung statt Frontend-Crash.""" + r = client.get("/api/breeder/my-editor", headers=admin["headers"]) + assert r.status_code == 404 + assert "Profil" in r.json()["detail"] + + +def test_my_editor_with_profile(client, user): + """Zuechter mit Profil -> profile + litters + 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( + """INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, stadt) + VALUES (?,?,?,?,?)""", + (uid, "Vom Teststall", "Labrador", "VDH", "Ebersberg") + ) + 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 d["storage_limit_mb"] == 200 + assert d["storage_mb"] >= 0 From 8c76263ea09a248fc41c0958ec2c9d153db0e92f Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 20:22:20 +0200 Subject: [PATCH 4/6] Worktree-Verlust-Audit: alle Geister-Endpoints gefunden + Regressions-Test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Systematischer Abgleich aller 528 Frontend-API-Aufrufe gegen 576 Backend- Routen (methodengenau, Wildcard-Matching für Parameter): Echte Opfer (zusätzlich zu partner/* und breeder/my-editor): - worlds.js Nearby-Alerts riefen /poison/nearby + /lost/nearby auf — beide existierten NIE; doppeltes catch verschluckte die 404s → die Welten zeigten seit jeher keine Giftköder-/Vermisst-Warnungen. Fix: bestehende Listen- Endpoints mit Geo-Filter nutzen (/poison?radius=Meter, /lost?radius_km). - API.weather.alerts → /weather/alerts existierte nie, hatte aber auch keinen Aufrufer — toter Wrapper entfernt. False Positives geprüft: invoices (Router bringt eigenen Prefix mit, alle 9 Routen ok), Seiten↔Dateien↔window.Page_*↔index.html-Sections alle konsistent. Neu: tests/test_api_surface.py — statischer API-Oberflächen-Abgleich als Dauertest; Geister-Aufrufe sind ab jetzt Build-Fehler. Suite: 59 passed. --- VERSION | 2 +- backend/static/index.html | 24 ++++---- backend/static/js/api.js | 2 +- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 7 ++- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_api_surface.py | 114 ++++++++++++++++++++++++++++++++++++ 8 files changed, 136 insertions(+), 19 deletions(-) create mode 100644 tests/test_api_surface.py diff --git a/VERSION b/VERSION index f845c1c..1080e3f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1267 \ No newline at end of file +1268 \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index 207852f..e5a2150 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/api.js b/backend/static/js/api.js index b5022e2..7b0e5d8 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -492,7 +492,7 @@ const API = (() => { // WETTER // ---------------------------------------------------------- const weather = { - alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); }, + // alerts() entfernt — /weather/alerts existierte im Backend nie (459cd42), kein Aufrufer get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); }, forecast(lat, lon) { return get(`/weather/forecast?lat=${lat}&lon=${lon}`); }, }; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index aa3b099..a8bf102 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 = '1267'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1268'; // ← 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/worlds.js b/backend/static/js/worlds.js index 84830ec..da1e422 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -1857,9 +1857,12 @@ window.Worlds = (() => { const out = []; try { const pos = await API.getLocation({ timeout: 4000, maximumAge: 600_000 }); + // /poison + /lost filtern per lat/lon serverseitig (poison: Radius in METERN, + // lost: radius_km). Die früheren /nearby-Pfade existierten nie (459cd42-Verlust) + // — das doppelte catch hat den 404 jahrelang verschluckt. const [p, l] = await Promise.allSettled([ - API.get(`/poison/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []), - API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=20`).catch(() => []), + API.get(`/poison?lat=${pos.lat}&lon=${pos.lon}&radius=5000`).catch(() => []), + API.get(`/lost?lat=${pos.lat}&lon=${pos.lon}&radius_km=20`).catch(() => []), ]); if (p.value?.length) out.push({ icon:'skull', color:'#EF4444', title:'Giftköder in der Nähe', sub:`${p.value.length} Meldung${p.value.length>1?'en':''}`, page:'poison' }); if (l.value?.length) out.push({ icon:'dog', color:'#3B82F6', title:'Verlorener Hund', sub:`${l.value.length} Meldung${l.value.length>1?'en':''}`, page:'lost' }); diff --git a/backend/static/landing.html b/backend/static/landing.html index 654b373..fc24dda 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 24b1305..e299457 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 = '1267'; +const VER = '1268'; 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_api_surface.py b/tests/test_api_surface.py new file mode 100644 index 0000000..d69fc5f --- /dev/null +++ b/tests/test_api_surface.py @@ -0,0 +1,114 @@ +"""API-Oberflaechen-Abgleich: Jeder Frontend-API-Aufruf muss eine Backend-Route haben. + +Hintergrund: Der Worktree-Merge-Verlust um 459cd42 (v1102) hinterliess Frontend-Code, +der nie existierende Endpoints aufrief (/partner/my-profile, /breeder/my-editor, +/poison/nearby, /lost/nearby) — teils jahrelang unbemerkt, weil catch() die Fehler +verschluckte. Dieser Test macht solche Geister-Aufrufe zum Build-Fehler. +""" + +import re +import glob +import os +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +STATIC = ROOT / "backend" / "static" + +METHOD = {"get": "GET", "post": "POST", "put": "PUT", "patch": "PATCH", + "del": "DELETE", "delete": "DELETE", "upload": "POST"} + + +def _backend_routes(): + main = (ROOT / "backend" / "main.py").read_text() + + mod_of_var = {} + for m in re.finditer(r"from routes\.(\w+)\s+import\s+([^\n]+)", main): + mod, rest = m.group(1), m.group(2) + for part in rest.split(","): + part = part.strip() + am = re.match(r"(\w+)\s+as\s+(\w+)", part) + if am: + mod_of_var[am.group(2)] = (mod, am.group(1)) + elif re.match(r"^\w+$", part): + mod_of_var[part] = (mod, part) + + routes = set() + + def norm(p): + return re.sub(r"\{[^}]+\}", "*", p).rstrip("/") or "/" + + for m in re.finditer(r"app\.include_router\((\w+)(?:,\s*prefix=\"([^\"]*)\")?", main): + var, prefix = m.group(1), m.group(2) or "" + info = mod_of_var.get(var) + if not info: + continue + mod, routername = info + fn = ROOT / "backend" / "routes" / f"{mod}.py" + if not fn.exists(): + continue + src = fn.read_text() + # Router kann eigenen Prefix mitbringen (z.B. invoices) + pm = re.search(rf"{routername}\s*=\s*APIRouter\(\s*prefix=\"([^\"]*)\"", src) or \ + re.search(r"router\s*=\s*APIRouter\(\s*prefix=\"([^\"]*)\"", src) + own_prefix = pm.group(1) if pm and routername == "router" or pm and f"{routername} =" in src else (pm.group(1) if pm else "") + base = prefix + (own_prefix if not prefix else "") + if not base and pm: + base = pm.group(1) + for rm in re.finditer(rf"@{routername}\.(get|post|put|patch|delete)\(\s*[\"']([^\"']*)[\"']", src): + routes.add((rm.group(1).upper(), norm(base + rm.group(2)))) + + for m in re.finditer(r"@app\.(get|post|put|patch|delete)\(\s*[\"']([^\"']*)[\"']", main): + routes.add((m.group(1).upper(), norm(m.group(2)))) + for m in re.finditer(r"@app\.api_route\(\s*[\"']([^\"']*)[\"'][^)]*methods=\[([^\]]*)\]", main): + for meth in re.findall(r"\"(\w+)\"", m.group(2)): + routes.add((meth.upper(), norm(m.group(1)))) + return routes + + +def _frontend_calls(): + calls = [] + + def norm(p): + p = p.split("?")[0] + p = re.sub(r"\$\{[^}]+\}", "*", p) + return (p if p.startswith("/api") else "/api" + p).rstrip("/") + + for fn in glob.glob(str(STATIC / "js" / "**" / "*.js"), recursive=True): + if any(s in fn for s in ("vendor", "leaflet", "qrcode.min", "maplibre")): + continue + src = open(fn, encoding="utf-8", errors="replace").read() + for m in re.finditer( + r"\b(?:API\.)?(get|post|put|patch|del|delete|upload)\(\s*[`'\"](/[^`'\"\s]*)[`'\"]", + src, re.S, + ): + raw = m.group(2) + if raw.startswith(("/js/", "/css/", "/icons/", "/img/", "/media/", "/data/", "/q/")): + continue + line = src[: m.start()].count("\n") + 1 + calls.append((METHOD[m.group(1)], norm(raw), f"{os.path.relpath(fn, ROOT)}:{line}")) + return calls + + +def _matches(call_path, route_path): + c, r = call_path.split("/"), route_path.split("/") + return len(c) == len(r) and all(a == b or a == "*" or b == "*" for a, b in zip(c, r)) + + +def test_no_ghost_api_calls(): + routes = _backend_routes() + assert len(routes) > 400, f"Routen-Parser kaputt? Nur {len(routes)} Routen gefunden." + calls = _frontend_calls() + assert len(calls) > 300, f"Frontend-Parser kaputt? Nur {len(calls)} Aufrufe gefunden." + + ghosts = [] + for meth, path, loc in calls: + if not path.startswith("/api"): + continue + if any(bm == meth and _matches(path, bp) for bm, bp in routes): + continue + ghosts.append(f"{meth} {path} ({loc})") + + assert not ghosts, ( + "Frontend ruft Endpoints auf, die im Backend nicht existieren " + "(Worktree-Verlust-Muster!):\n " + "\n ".join(sorted(set(ghosts))) + ) From dfffd07a96665f3aab3c9988b82c5941ff45b9f7 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 20:36:19 +0200 Subject: [PATCH 5/6] =?UTF-8?q?Settings=20entr=C3=BCmpelt:=20Z=C3=BCchter-?= =?UTF-8?q?Block=20komplett=20in=20den=20Z=C3=BCchter-Bereich=20umgezogen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rene: 'Dieser Bereich könnte auch in den Züchter-Bereich, dann ist alles sauber.' - Settings zeigt für verifizierte Züchter/Admins KEINE Züchter-Karte mehr — der Welten-Chip ist der Einstieg (wie beim Partner). Antrag/Prüfung/Abgelehnt bleiben in Settings (Nicht-Züchter sehen den Chip nicht). - Züchter-Bereich neu: KI-Züchter-Assistenz-Karte (5 Toggles, optimistisches Update + Revert), Status-Badge unterscheidet Admin/Züchter, Admin ohne Profil bekommt 'Profil anlegen' direkt im Hub. - Toter Code raus: _openBreederEditModal (75 Z., Settings-Doppel-Editor — breeder-editor-Seite ist der vollwertige), _kiToggleRow + 3 verwaiste Bindings aus settings.js. --- VERSION | 2 +- backend/static/index.html | 24 +- backend/static/js/app.js | 2 +- backend/static/js/pages/breeder-dashboard.js | 96 ++++++- backend/static/js/pages/settings.js | 276 +------------------ backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 7 files changed, 111 insertions(+), 293 deletions(-) diff --git a/VERSION b/VERSION index 1080e3f..d3e6945 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1268 \ No newline at end of file +1269 \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index e5a2150..8c68db2 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 a8bf102..3820110 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 = '1268'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1269'; // ← 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-dashboard.js b/backend/static/js/pages/breeder-dashboard.js index ac66f30..d8788b6 100644 --- a/backend/static/js/pages/breeder-dashboard.js +++ b/backend/static/js/pages/breeder-dashboard.js @@ -7,9 +7,11 @@ window.Page_breeder_dashboard = (() => { let _container = null; + let _appState = null; - async function init(container) { + async function init(container, appState) { _container = container; + _appState = appState; _render(); await _load(); } @@ -73,15 +75,35 @@ window.Page_breeder_dashboard = (() => {
${UI.escape(profile?.zwingername || 'Noch kein Profil angelegt')}
${profile?.rasse_text ? `
${UI.escape(profile.rasse_text)}
` : ''} - ${UI.icon('check-circle')} Verifizierter Züchter + ${UI.icon('check-circle')} ${status?.rolle === 'admin' ? 'Admin — alle Züchter-Features' : 'Verifizierter Züchter'}
- + ${profile + ? `` + : status?.rolle === 'admin' + ? `` : ''} + ${profile ? ` + +
+
KI-Züchter-Assistenz
+ ${_kiToggleRow('ki_zucht_wurfankuendigung', 'Wurfankündigungen schreiben')} + ${_kiToggleRow('ki_zucht_genetik', 'Genetik-Erklärung für Käufer')} + ${_kiToggleRow('ki_zucht_paarung', 'Paarungsanalyse')} + ${_kiToggleRow('ki_zucht_beschreibung', 'Hunde-Beschreibungen')} + ${_kiToggleRow('ki_zucht_jahresbericht', 'Jahresauswertung')} +
+ ${UI.icon('info')} Der Tierschutz-Check läuft immer automatisch und ist nicht abschaltbar. +
+
` : ''} +
@@ -129,10 +151,74 @@ window.Page_breeder_dashboard = (() => { `; } + // KI-Toggle-Zeile (aus settings.js umgezogen — Zustand kommt aus _appState.user) + function _kiToggleRow(key, label) { + const user = _appState?.user || {}; + const active = user[key] !== 0; + return ` +
+ ${UI.escape(label)} + +
`; + } + function _bindEvents(el) { el.querySelectorAll('[data-bd-nav]').forEach(btn => { btn.addEventListener('click', () => App.navigate(btn.dataset.bdNav)); }); + + // Admin ohne Profil: Züchterprofil anlegen + el.querySelector('#bd-admin-create')?.addEventListener('click', async e => { + const btn = e.currentTarget; + btn.disabled = true; + btn.textContent = 'Wird angelegt…'; + try { + await API.breeder.adminCreateProfile(); + UI.toast.success('Admin-Züchterprofil angelegt.'); + await _load(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Anlegen.'); + btn.disabled = false; + btn.innerHTML = `${UI.icon('plus')} Profil anlegen`; + } + }); + + // KI-Toggles — optimistisches Update mit Revert bei Fehler + el.querySelectorAll('.bd-ki-toggle').forEach(btn => { + btn.addEventListener('click', async () => { + const key = btn.dataset.key; + const active = btn.dataset.active === '1'; + const newVal = active ? 0 : 1; + const thumb = btn.querySelector('.by-toggle-thumb'); + + btn.dataset.active = newVal ? '1' : '0'; + btn.style.background = newVal ? 'var(--c-primary)' : 'var(--c-border)'; + if (thumb) thumb.style.left = newVal ? '22px' : '2px'; + + try { + await API.patch('/profile', { [key]: newVal }); + if (_appState?.user) _appState.user[key] = newVal; + UI.toast.success(newVal ? 'KI-Feature aktiviert.' : 'KI-Feature deaktiviert.'); + } catch (err) { + btn.dataset.active = active ? '1' : '0'; + btn.style.background = active ? 'var(--c-primary)' : 'var(--c-border)'; + if (thumb) thumb.style.left = active ? '22px' : '2px'; + UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.'); + } + }); + }); } return { init, refresh, onDogChange }; diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index f4bc01d..6a36d26 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -1664,29 +1664,6 @@ window.Page_settings = (() => { } - // ---------------------------------------------------------- - // KI-Toggle-Zeile (Hilfsfunktion für Züchter-Card) - // ---------------------------------------------------------- - function _kiToggleRow(key, label, user) { - const active = user[key] !== 0; - return ` -
- ${UI.escape(label)} - -
`; - } // ---------------------------------------------------------- // ZÜCHTER-CARD — asynchron laden und in Slot rendern @@ -1714,41 +1691,10 @@ window.Page_settings = (() => { let actionBlock = ''; if (rolle === 'breeder' || rolle === 'admin') { - statusBadge = ` - ${UI.icon('check-circle')} ${rolle === 'admin' ? 'Admin — alle Züchter-Features verfügbar' : 'Verifizierter Züchter'} - `; - actionBlock = ` -
- ${profile?.zwingername ? `
Zwinger: ${UI.escape(profile.zwingername)}
` : ''} - ${profile?.rasse_text ? `
Rasse: ${UI.escape(profile.rasse_text)}
` : ''} -
- ${rolle === 'breeder' && profile ? ` - ` : ''} - ${rolle === 'admin' && !profile ? ` - ` : ''} - ${rolle === 'admin' && profile ? ` - ` : ''} - ${profile ? ` -
-
- KI-Züchter-Assistenz -
- ${_kiToggleRow('ki_zucht_wurfankuendigung', 'Wurfankündigungen schreiben', _appState.user || {})} - ${_kiToggleRow('ki_zucht_genetik', 'Genetik-Erklärung für Käufer', _appState.user || {})} - ${_kiToggleRow('ki_zucht_paarung', 'Paarungsanalyse', _appState.user || {})} - ${_kiToggleRow('ki_zucht_beschreibung', 'Hunde-Beschreibungen', _appState.user || {})} - ${_kiToggleRow('ki_zucht_jahresbericht', 'Jahresauswertung', _appState.user || {})} -
- ${UI.icon('info')} Der Tierschutz-Check läuft immer automatisch und ist nicht abschaltbar. -
-
` : ''}`; + // Verifizierte Züchter/Admins: alles Inhaltliche (Profil, KI-Assistenz, + // Würfe, Zuchtkartei) lebt im Züchter-Bereich — hier nur der Verweis. + slot.innerHTML = ''; + return; } else if (breeder_status === 'pending') { statusBadge = ` ${UI.icon('hourglass')} Antrag wird geprüft @@ -1784,221 +1730,7 @@ window.Page_settings = (() => { // Button-Handler binden slot.querySelector('#breeder-reapply-btn')?.addEventListener('click', () => _showUpgradeModal('breeder')); - slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () => - _openBreederEditModal(profile) - ); - slot.querySelector('#breeder-admin-create-btn')?.addEventListener('click', async (e) => { - const btn = e.currentTarget; - btn.disabled = true; - btn.textContent = 'Wird angelegt…'; - try { - await API.breeder.adminCreateProfile(); - UI.toast.success('Admin-Züchterprofil angelegt. Bitte Seite neu laden.'); - _loadBreederCard(); - } catch (err) { - UI.toast.error(err.message || 'Fehler beim Anlegen.'); - btn.disabled = false; - btn.innerHTML = `${UI.icon('plus')} Admin-Züchterprofil anlegen`; - } - }); - - // KI-Toggle-Handler - slot.querySelectorAll('.ki-toggle-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const key = btn.dataset.key; - const active = btn.dataset.active === '1'; - const newVal = active ? 0 : 1; - - // Optimistisches UI-Update - btn.dataset.active = newVal ? '1' : '0'; - btn.style.background = newVal ? 'var(--c-primary)' : 'var(--c-border)'; - const thumb = btn.querySelector('.by-toggle-thumb'); - if (thumb) thumb.style.left = newVal ? '22px' : '2px'; - - try { - const updated = await API.patch('/profile', { [key]: newVal }); - if (_appState?.user) _appState.user[key] = newVal; - UI.toast.success(newVal ? 'KI-Feature aktiviert.' : 'KI-Feature deaktiviert.'); - } catch (err) { - // Revert - btn.dataset.active = active ? '1' : '0'; - btn.style.background = active ? 'var(--c-primary)' : 'var(--c-border)'; - if (thumb) thumb.style.left = active ? '22px' : '2px'; - UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.'); - } - }); - }); - } - - // ---------------------------------------------------------- - // ZÜCHTER-PROFIL BEARBEITEN MODAL - // ---------------------------------------------------------- - function _openBreederEditModal(profile) { - const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3); - border:1.5px solid var(--c-border);border-radius:var(--radius-md); - font-size:var(--text-sm);font-family:inherit; - background:var(--c-surface);color:var(--c-text)`; - - UI.modal.open({ - title: `${UI.icon('pencil-simple')} Züchter-Profil bearbeiten`, - body: ` -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
`, - footer: ` -
- - -
`, - }); - - document.getElementById('breeder-edit-form')?.addEventListener('submit', async e => { - e.preventDefault(); - const btn = document.getElementById('breeder-edit-submit'); - await UI.asyncButton(btn, async () => { - const form = e.target; - const data = { - zwingername: form.zwingername.value.trim() || undefined, - rasse_text: form.rasse_text.value.trim() || undefined, - verein: form.verein.value.trim() || undefined, - stadt: form.stadt.value.trim() || undefined, - vdh_mitglied: form.vdh_mitglied.checked ? 1 : 0, - website: form.website.value.trim() || undefined, - beschreibung: form.beschreibung.value.trim() || undefined, - }; - await API.breeder.updateProfile(data); - UI.modal.close?.(); - UI.toast.success('Profil aktualisiert.'); - _loadBreederCard(); - }); - }); - } - - // ---------------------------------------------------------- - // ZÜCHTER-ANTRAG MODAL - // ---------------------------------------------------------- - function _openBreederApplyModal() { - const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3); - border:1.5px solid var(--c-border);border-radius:var(--radius-md); - font-size:var(--text-sm);font-family:inherit; - background:var(--c-surface);color:var(--c-text)`; - - UI.modal.open({ - title: `${UI.icon('certificate')} Züchter-Antrag stellen`, - body: ` -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- Zuchtbuch-Eintrag, Vereinsmitgliedschaft o.ä. (PDF, JPG, PNG, WebP) -
-
-
- `, - footer: ` -
- - -
- `, - }); document.getElementById('breeder-apply-form')?.addEventListener('submit', async e => { e.preventDefault(); diff --git a/backend/static/landing.html b/backend/static/landing.html index fc24dda..891c8f2 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 e299457..fd4c73b 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 = '1268'; +const VER = '1269'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 5f01abc59061bba16889b9c8474ac6a44491470a Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 21:00:14 +0200 Subject: [PATCH 6/6] =?UTF-8?q?Z=C3=BCchter-Editor:=20Wurfnamen=20sichtbar?= =?UTF-8?q?,=20'undefined=20Medien'=20gefixt,=20Mitgliedschaften=20&=20Zer?= =?UTF-8?q?tifikate?= 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/")