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) |
diff --git a/VERSION b/VERSION
index 3420149..a01282d 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1265
\ 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 e53a1d4..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
@@ -491,6 +505,56 @@ 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 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/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..bedfe18 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/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 1903e4a..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 = '1265'; // ← 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;
@@ -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..d8788b6
--- /dev/null
+++ b/backend/static/js/pages/breeder-dashboard.js
@@ -0,0 +1,226 @@
+/* ============================================================
+ 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.
+
+
+
+
+ `;
+ }
+
+ 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 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.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/js/pages/settings.js b/backend/static/js/pages/settings.js
index 66d9af0..6a36d26 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 ? `
-
-
-
-
-
- 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.
-
-
-
-
-
-
-
` : ''}
@@ -1681,35 +1662,8 @@ 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
@@ -1737,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
@@ -1807,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: `
-
- `,
- 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 e54f867..da1e422 100644
--- a/backend/static/js/worlds.js
+++ b/backend/static/js/worlds.js
@@ -570,11 +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:'thermometer', label:'Läufigkeit', page:'laeufi', role:'breeder' },
+ { 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:'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' },
@@ -590,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',
- 'litters','zuchthunde','laeufi','ernaehrung','personality'],
+ 'breeder-dashboard','ernaehrung','personality'],
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events',
'jobs','knigge','movies','reise'],
};
@@ -1859,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 88ec792..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 3059fa7..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 = '1265';
+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_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)))
+ )
diff --git a/tests/test_breeder_editor.py b/tests/test_breeder_editor.py
new file mode 100644
index 0000000..f58fd2c
--- /dev/null
+++ b/tests/test_breeder_editor.py
@@ -0,0 +1,78 @@
+"""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 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)