diff --git a/MARKETING.md b/MARKETING.md index 72078eb..d232fe2 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -14,8 +14,7 @@ _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 | -| 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 | +| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern | | 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) | diff --git a/VERSION b/VERSION index a01282d..3420149 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1270 \ No newline at end of file +1265 \ No newline at end of file diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index 457766e..e53a1d4 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -402,8 +402,7 @@ async def breeder_public_profile(zwingername: str): # Sichtbare Würfe wuerfe = conn.execute(""" - SELECT id, wurf_rang, wurf_name, vater_name, mutter_name, - geburt_datum, erwartetes_datum, + SELECT id, 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' @@ -411,19 +410,6 @@ 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 @@ -505,56 +491,6 @@ 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.)""" - from routes.breeder_photos import _photo_dict - 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) - 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 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()] - - # 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/routes/breeder_photos.py b/backend/routes/breeder_photos.py index a080e9b..802440f 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", "certificate"} +_VALID_ENTITY_TYPES = {"breeder", "litter", "puppy", "parent"} # ------------------------------------------------------------------ @@ -100,7 +100,7 @@ async def upload_photo( elif entity_type == "parent": # parent kann frei hochgeladen werden solange breeder stimmt pass - elif entity_type in ("breeder", "certificate"): + elif entity_type == "breeder": # 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 in ("breeder", "certificate"): + if entity_type == "breeder": is_owner = (bp["id"] == entity_id) elif entity_type == "litter": row = conn.execute( diff --git a/backend/routes/partner.py b/backend/routes/partner.py index 7690352..115517b 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -48,28 +48,6 @@ 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 bedfe18..4d30aec 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -511,10 +511,6 @@
-
-
-
-
@@ -620,11 +616,11 @@ - - - - - + + + + + @@ -634,7 +630,7 @@ - + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 7b0e5d8..b5022e2 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -492,7 +492,7 @@ const API = (() => { // WETTER // ---------------------------------------------------------- const weather = { - // alerts() entfernt — /weather/alerts existierte im Backend nie (459cd42), kein Aufrufer + alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); }, 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 5bfdfc1..1903e4a 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 = '1270'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1265'; // ← 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,7 +73,6 @@ 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 21120e7..b07b51f 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2381,10 +2381,6 @@ window.Page_admin = (() => { ${c.grants_founder ? '✓' : '—'} - ${c.uses > 0 ? ` - ` : ''} - - -
Lädt…
- - `).join('')} ` @@ -2580,38 +2571,6 @@ 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 deleted file mode 100644 index d8788b6..0000000 --- a/backend/static/js/pages/breeder-dashboard.js +++ /dev/null @@ -1,226 +0,0 @@ -/* ============================================================ - 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; - let _appState = null; - - async function init(container, appState) { - _container = container; - _appState = appState; - _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')} ${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. -
-
` : ''} - - -
-
-
- -
-
-
Wurfverwaltung
-
${litters.length} ${litters.length === 1 ? 'Wurf' : 'Würfe'} · Welpen, Gewichte, Kaufverträge
-
- -
-
- - -
-
-
- -
-
-
Zuchtkartei
-
${hunde.length} ${hunde.length === 1 ? 'Zuchthund' : 'Zuchthunde'} · Stammbaum, Genetik, Titel
-
- -
-
- - -
-
-
- -
-
-
Läufigkeit & Trächtigkeit
-
Zyklen, Progesterontests, Deckdaten, Meilensteine
-
- -
-
- `; - } - - // 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/breeder-editor.js b/backend/static/js/pages/breeder-editor.js index 0da8054..eec5525 100644 --- a/backend/static/js/pages/breeder-editor.js +++ b/backend/static/js/pages/breeder-editor.js @@ -138,34 +138,6 @@ 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 ? `
@@ -176,10 +148,6 @@ 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. -
` : ''} @@ -211,14 +179,12 @@ window.Page_breeder_editor = (() => { } function _renderLitterCard(l) { - // 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 label = l.geburtsdatum + ? `Wurf vom ${new Date(l.geburtsdatum).toLocaleDateString('de-DE')}` + : `Wurf #${l.id}`; const info = [ l.welpen_gesamt ? `${l.welpen_gesamt} Welpen` : null, - `${l.foto_count ?? 0} Medien`, + `${l.foto_count} Medien`, ].filter(Boolean).join(' · '); return `
@@ -321,36 +287,6 @@ 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 a1cc75b..917bcaa 100644 --- a/backend/static/js/pages/breeder.js +++ b/backend/static/js/pages/breeder.js @@ -160,25 +160,6 @@ 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) ? `
@@ -325,8 +306,6 @@ 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)}` @@ -337,7 +316,6 @@ window.Page_breeder = (() => {
- ${wurfTitel ? `${UI.escape(wurfTitel)}` : ''} ${UI.escape(eltern)} ${sl} diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 6a36d26..66d9af0 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -665,6 +665,25 @@ 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
@@ -1662,8 +1681,35 @@ 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')); } + // ---------------------------------------------------------- + // 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 @@ -1691,10 +1737,41 @@ window.Page_settings = (() => { let actionBlock = ''; if (rolle === 'breeder' || rolle === 'admin') { - // Verifizierte Züchter/Admins: alles Inhaltliche (Profil, KI-Assistenz, - // Würfe, Zuchtkartei) lebt im Züchter-Bereich — hier nur der Verweis. - slot.innerHTML = ''; - return; + 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. +
+
` : ''}`; } else if (breeder_status === 'pending') { statusBadge = ` ${UI.icon('hourglass')} Antrag wird geprüft @@ -1730,7 +1807,221 @@ 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/js/worlds.js b/backend/static/js/worlds.js index da1e422..e54f867 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -570,9 +570,11 @@ window.Worlds = (() => { { icon:'sparkle', label:'Jobs', page:'jobs' }, { icon:'book-open', label:'Knigge', page:'knigge' }, { icon:'film-slate', label:'Filme', page:'movies' }, - { 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:'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:'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' }, @@ -588,7 +590,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','ernaehrung','personality'], + 'litters','zuchthunde','laeufi','ernaehrung','personality'], welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events', 'jobs','knigge','movies','reise'], }; @@ -1857,12 +1859,9 @@ 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?lat=${pos.lat}&lon=${pos.lon}&radius=5000`).catch(() => []), - API.get(`/lost?lat=${pos.lat}&lon=${pos.lon}&radius_km=20`).catch(() => []), + 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(() => []), ]); 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 cfed93e..88ec792 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 7998404..3059fa7 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 = '1270'; +const VER = '1265'; 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 deleted file mode 100644 index d69fc5f..0000000 --- a/tests/test_api_surface.py +++ /dev/null @@ -1,114 +0,0 @@ -"""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))) - ) diff --git a/tests/test_breeder_editor.py b/tests/test_breeder_editor.py deleted file mode 100644 index f58fd2c..0000000 --- a/tests/test_breeder_editor.py +++ /dev/null @@ -1,78 +0,0 @@ -"""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 (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', 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["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/") diff --git a/tests/test_partner_qr.py b/tests/test_partner_qr.py index 6dfbac7..73c2994 100644 --- a/tests/test_partner_qr.py +++ b/tests/test_partner_qr.py @@ -125,30 +125,6 @@ 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)