Compare commits
6 commits
2927ae2672
...
5f01abc590
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f01abc590 | |||
| dfffd07a96 | |||
| 8c76263ea0 | |||
| 487dacc7c7 | |||
| ed7c469c6a | |||
| 6a6a09d879 |
19 changed files with 700 additions and 329 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1265
|
||||
1270
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1265"></script>
|
||||
<script src="/js/boot-early.js?v=1270"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1265">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1265">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1265">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1265">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1265">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1270">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -511,6 +511,10 @@
|
|||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-breeder-dashboard">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-jobs">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
|
@ -616,11 +620,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1265"></script>
|
||||
<script src="/js/ui.js?v=1265"></script>
|
||||
<script src="/js/app.js?v=1265"></script>
|
||||
<script src="/js/worlds.js?v=1265"></script>
|
||||
<script src="/js/offline-indicator.js?v=1265"></script>
|
||||
<script src="/js/api.js?v=1270"></script>
|
||||
<script src="/js/ui.js?v=1270"></script>
|
||||
<script src="/js/app.js?v=1270"></script>
|
||||
<script src="/js/worlds.js?v=1270"></script>
|
||||
<script src="/js/offline-indicator.js?v=1270"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -630,7 +634,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1265"></script>
|
||||
<script src="/js/boot.js?v=1270"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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}`); },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -2381,6 +2381,10 @@ window.Page_admin = (() => {
|
|||
${c.grants_founder ? '✓' : '—'}
|
||||
</td>
|
||||
<td style="padding:var(--space-2) var(--space-3);white-space:nowrap;text-align:right">
|
||||
${c.uses > 0 ? `
|
||||
<button class="btn btn-ghost btn-sm adm-code-regs" data-id="${c.id}" title="Alle Einlösungen anzeigen (inkl. Kanal)">
|
||||
${UI.icon('users')}
|
||||
</button>` : ''}
|
||||
<button class="btn btn-ghost btn-sm adm-toggle-code" data-id="${c.id}"
|
||||
title="${c.active ? 'Pausieren — Notbremse wenn der Code im Netz kursiert (Einlösung gesperrt, Historie bleibt)' : 'Wieder aktivieren'}"
|
||||
style="font-size:var(--text-xs)">
|
||||
|
|
@ -2392,6 +2396,11 @@ window.Page_admin = (() => {
|
|||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hidden" id="adm-code-regs-${c.id}">
|
||||
<td colspan="5" style="padding:0 var(--space-3) var(--space-3);background:var(--c-surface-2)">
|
||||
<div class="text-sm-muted" style="padding:var(--space-3) 0">Lädt…</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`
|
||||
|
|
@ -2571,6 +2580,38 @@ window.Page_admin = (() => {
|
|||
</div>
|
||||
`;
|
||||
|
||||
// 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
|
||||
? `<div class="text-sm-muted" style="padding:var(--space-3) 0">Keine Accounts.</div>`
|
||||
: regs.map(u => `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border);font-size:var(--text-sm)">
|
||||
<div class="flex-1-min">
|
||||
<span style="font-weight:600">${UI.escape(u.name)}</span>
|
||||
<span class="text-xs-muted">· ${UI.escape(u.email)}</span>
|
||||
</div>
|
||||
<span class="badge" style="background:${u.qr_seq ? 'rgba(139,92,246,.12)' : 'var(--c-surface)'};color:${u.qr_seq ? '#8B5CF6' : 'var(--c-text-muted)'}"
|
||||
title="${u.qr_seq ? `Gedruckter QR-Code aus Kontingent „${UI.escape(u.qr_batch_label || '')}"` : 'Code eingetippt oder ?ref-Link'}">
|
||||
${u.qr_seq ? `QR #${u.qr_seq}` : 'Link/manuell'}
|
||||
</span>
|
||||
<span class="text-xs-muted">${(u.created_at || '').slice(0, 16).replace(' ', ' · ')}</span>
|
||||
${u.email_verified
|
||||
? `<span class="badge" style="background:#dcfce7;color:#16a34a">✓ bestätigt</span>`
|
||||
: `<span class="badge" style="background:#fef9c3;color:#a16207">⏳ unbestätigt</span>`}
|
||||
</div>`).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 () => {
|
||||
|
|
|
|||
226
backend/static/js/pages/breeder-dashboard.js
Normal file
226
backend/static/js/pages/breeder-dashboard.js
Normal file
|
|
@ -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 = `
|
||||
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
|
||||
<div style="margin-bottom:var(--space-5)">
|
||||
<h1 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-1)">
|
||||
${UI.icon('certificate')} Züchter-Bereich
|
||||
</h1>
|
||||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
|
||||
Dein Zwinger, deine Würfe, deine Zuchthunde.
|
||||
</p>
|
||||
</div>
|
||||
<div id="bd-content">
|
||||
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">Lade…</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `<p class="text-danger">${UI.escape(e.message || 'Fehler beim Laden.')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderHub(status, litters, hunde) {
|
||||
const profile = status?.profile;
|
||||
const isBreeder = status?.rolle === 'breeder' || status?.rolle === 'admin';
|
||||
if (!isBreeder) {
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-5);text-align:center">
|
||||
<p class="text-sm-secondary" style="margin:0">
|
||||
Der Züchter-Bereich ist für verifizierte Züchter.
|
||||
Den Antrag findest du in den <a href="#settings" class="text-primary">Einstellungen</a>.
|
||||
</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<!-- Zwinger / Profil -->
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<div class="flex-1-min">
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-1)">Mein Zwinger</div>
|
||||
<div style="font-weight:700">${UI.escape(profile?.zwingername || 'Noch kein Profil angelegt')}</div>
|
||||
${profile?.rasse_text ? `<div class="text-xs-muted">${UI.escape(profile.rasse_text)}</div>` : ''}
|
||||
<span class="badge" style="background:#dcfce7;color:#16a34a;margin-top:var(--space-1)">
|
||||
${UI.icon('check-circle')} ${status?.rolle === 'admin' ? 'Admin — alle Züchter-Features' : 'Verifizierter Züchter'}
|
||||
</span>
|
||||
</div>
|
||||
${profile
|
||||
? `<button class="btn btn-sm btn-secondary" data-bd-nav="breeder-editor">
|
||||
${UI.icon('pencil-simple')} Profil
|
||||
</button>`
|
||||
: status?.rolle === 'admin'
|
||||
? `<button class="btn btn-sm btn-primary" id="bd-admin-create">
|
||||
${UI.icon('plus')} Profil anlegen
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${profile ? `
|
||||
<!-- KI-Züchter-Assistenz (aus den Einstellungen hierher umgezogen) -->
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-2)">KI-Züchter-Assistenz</div>
|
||||
${_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')}
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
|
||||
${UI.icon('info')} Der Tierschutz-Check läuft immer automatisch und ist nicht abschaltbar.
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Wurfverwaltung -->
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<div style="width:44px;height:44px;border-radius:var(--radius-md);background:rgba(16,185,129,.12);
|
||||
display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||||
<svg class="ph-icon" style="width:22px;height:22px;color:#10B981"><use href="/icons/phosphor.svg#notebook"></use></svg>
|
||||
</div>
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:700">Wurfverwaltung</div>
|
||||
<div class="text-xs-muted">${litters.length} ${litters.length === 1 ? 'Wurf' : 'Würfe'} · Welpen, Gewichte, Kaufverträge</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary" data-bd-nav="litters">Öffnen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zuchtkartei -->
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<div style="width:44px;height:44px;border-radius:var(--radius-md);background:rgba(139,92,246,.12);
|
||||
display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||||
<svg class="ph-icon" style="width:22px;height:22px;color:#8B5CF6"><use href="/icons/phosphor.svg#tree-structure"></use></svg>
|
||||
</div>
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:700">Zuchtkartei</div>
|
||||
<div class="text-xs-muted">${hunde.length} ${hunde.length === 1 ? 'Zuchthund' : 'Zuchthunde'} · Stammbaum, Genetik, Titel</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary" data-bd-nav="zuchthunde">Öffnen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Läufigkeit & Trächtigkeit -->
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<div style="width:44px;height:44px;border-radius:var(--radius-md);background:rgba(236,72,153,.12);
|
||||
display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||||
<svg class="ph-icon" style="width:22px;height:22px;color:#EC4899"><use href="/icons/phosphor.svg#thermometer"></use></svg>
|
||||
</div>
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:700">Läufigkeit & Trächtigkeit</div>
|
||||
<div class="text-xs-muted">Zyklen, Progesterontests, Deckdaten, Meilensteine</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary" data-bd-nav="laeufi">Öffnen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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 `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;
|
||||
padding:var(--space-2) 0;font-size:var(--text-sm)">
|
||||
<span>${UI.escape(label)}</span>
|
||||
<button class="by-toggle bd-ki-toggle" data-key="${UI.escape(key)}"
|
||||
data-active="${active ? '1' : '0'}"
|
||||
style="position:relative;display:inline-block;width:44px;height:24px;
|
||||
border:none;border-radius:12px;cursor:pointer;flex-shrink:0;
|
||||
background:${active ? 'var(--c-primary)' : 'var(--c-border)'};
|
||||
transition:background .2s">
|
||||
<span class="by-toggle-thumb"
|
||||
style="position:absolute;top:2px;left:${active ? '22px' : '2px'};
|
||||
width:20px;height:20px;border-radius:50%;
|
||||
background:#fff;transition:left .2s;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
})();
|
||||
|
|
@ -138,6 +138,34 @@ window.Page_breeder_editor = (() => {
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Mitgliedschaften & Zertifikate -->
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-2)">
|
||||
Mitgliedschaften & Zertifikate
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
|
||||
Vereins-Logos, VDH-Mitgliedschaft, Urkunden — werden auf deiner öffentlichen
|
||||
Profilseite in einer eigenen Sektion gezeigt.
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2);margin:var(--space-3) 0">
|
||||
${(p.certificates || []).map(c => `
|
||||
<div style="position:relative;border:1px solid var(--c-border);border-radius:var(--radius-md);overflow:hidden;background:var(--c-surface-2)">
|
||||
<img src="${UI.escape(c.thumbnail_url || c.url)}" style="width:100%;aspect-ratio:1;object-fit:contain;padding:6px;box-sizing:border-box">
|
||||
<div style="font-size:10px;text-align:center;padding:2px 4px 5px;color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${UI.escape(c.caption || '—')}</div>
|
||||
<button class="be-cert-del" data-id="${c.id}"
|
||||
style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,.6);
|
||||
border:none;border-radius:50%;width:24px;height:24px;cursor:pointer;
|
||||
color:#fff;font-size:14px;display:flex;align-items:center;justify-content:center">×</button>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
|
||||
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#plus"></use></svg>
|
||||
Logo / Urkunde hinzufügen
|
||||
<input type="file" id="be-cert-input" accept="image/*" class="hidden">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Würfe — Schnellupload -->
|
||||
${litters.length ? `
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
|
|
@ -148,6 +176,10 @@ window.Page_breeder_editor = (() => {
|
|||
<div class="flex-col-gap-3">
|
||||
${litters.map(l => _renderLitterCard(l)).join('')}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
|
||||
${UI.icon('info')} Wurf-Rang (A-, B-Wurf …) und Wurfnamen vergibst du in der
|
||||
<a href="#litters" class="text-primary">Wurfverwaltung</a>.
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
</div>
|
||||
|
|
@ -179,12 +211,14 @@ window.Page_breeder_editor = (() => {
|
|||
}
|
||||
|
||||
function _renderLitterCard(l) {
|
||||
const label = l.geburtsdatum
|
||||
? `Wurf vom ${new Date(l.geburtsdatum).toLocaleDateString('de-DE')}`
|
||||
: `Wurf #${l.id}`;
|
||||
// Wurf-Rang/-Name (aus der Wurfverwaltung) zuerst, dann Datum, dann #id
|
||||
const name = [l.wurf_rang ? `${l.wurf_rang}-Wurf` : null, l.wurf_name]
|
||||
.filter(Boolean).join(' · ');
|
||||
const label = name
|
||||
|| (l.geburt_datum ? `Wurf vom ${new Date(l.geburt_datum).toLocaleDateString('de-DE')}` : `Wurf #${l.id}`);
|
||||
const info = [
|
||||
l.welpen_gesamt ? `${l.welpen_gesamt} Welpen` : null,
|
||||
`${l.foto_count} Medien`,
|
||||
`${l.foto_count ?? 0} Medien`,
|
||||
].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div style="border:1px solid var(--c-border);border-radius:var(--radius-md);padding:var(--space-3)">
|
||||
|
|
@ -287,6 +321,36 @@ window.Page_breeder_editor = (() => {
|
|||
});
|
||||
|
||||
// Wurf-Upload
|
||||
// Zertifikat/Mitgliedschaft hochladen — Bezeichnung wird als Caption gespeichert
|
||||
el.querySelector('#be-cert-input')?.addEventListener('change', async e => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const caption = (window.prompt('Bezeichnung (z. B. „VDH-Mitglied", „Club für Britische Hütehunde"):') || '').trim();
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('entity_type', 'certificate');
|
||||
fd.append('entity_id', String(_data.profile.id));
|
||||
fd.append('visibility', 'public');
|
||||
if (caption) fd.append('caption', caption);
|
||||
try {
|
||||
const ph = await API.breederPhotos.upload(fd);
|
||||
(_data.profile.certificates ||= []).push(ph);
|
||||
UI.toast.success('Zertifikat hinzugefügt — erscheint auf deiner Profilseite.');
|
||||
_render();
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
|
||||
// Zertifikat löschen
|
||||
el.querySelectorAll('.be-cert-del').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
await API.breederPhotos.remove(parseInt(btn.dataset.id));
|
||||
_data.profile.certificates = (_data.profile.certificates || []).filter(c => String(c.id) !== btn.dataset.id);
|
||||
_render();
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.be-litter-input').forEach(input => {
|
||||
input.addEventListener('change', async e => {
|
||||
const file = e.target.files[0];
|
||||
|
|
|
|||
|
|
@ -160,6 +160,25 @@ window.Page_breeder = (() => {
|
|||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Mitgliedschaften & Zertifikate -->
|
||||
${p.zertifikate?.length ? `
|
||||
<div style="margin-bottom:var(--space-5)">
|
||||
<h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700;
|
||||
display:flex;align-items:center;gap:var(--space-2)">
|
||||
${UI.icon('seal-check')} Mitgliedschaften & Zertifikate
|
||||
</h2>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:var(--space-3)">
|
||||
${p.zertifikate.map(z => `
|
||||
<a href="${UI.escape(z.url)}" target="_blank" rel="noopener"
|
||||
style="display:block;background:var(--c-surface);border:1px solid var(--c-border);
|
||||
border-radius:var(--radius-md);padding:var(--space-2);text-decoration:none;text-align:center">
|
||||
<img src="${UI.escape(z.thumbnail_url || z.url)}" alt="${UI.escape(z.caption || 'Zertifikat')}"
|
||||
loading="lazy" style="width:100%;aspect-ratio:1;object-fit:contain">
|
||||
${z.caption ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-1);overflow:hidden;text-overflow:ellipsis">${UI.escape(z.caption)}</div>` : ''}
|
||||
</a>`).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Gesundheits-Transparenz -->
|
||||
${(p.hd_stats?.length || p.ed_stats?.length) ? `
|
||||
<div style="margin-bottom:var(--space-5)">
|
||||
|
|
@ -306,6 +325,8 @@ window.Page_breeder = (() => {
|
|||
const _STATUS_COLOR = { geplant: '#6b7280', geboren: '#3b82f6', verfuegbar: '#16a34a', abgeschlossen: '#9ca3af' };
|
||||
|
||||
function _wurfCard(w) {
|
||||
const wurfTitel = [w.wurf_rang ? `${w.wurf_rang}-Wurf` : null, w.wurf_name]
|
||||
.filter(Boolean).join(' · ');
|
||||
const eltern = [w.vater_name, w.mutter_name].filter(Boolean).join(' × ') || '—';
|
||||
const datum = w.geburt_datum
|
||||
? `Geburt: ${_fmtDate(w.geburt_datum)}`
|
||||
|
|
@ -316,6 +337,7 @@ window.Page_breeder = (() => {
|
|||
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
|
||||
padding:var(--space-3) var(--space-4)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
|
||||
${wurfTitel ? `<span style="background:var(--c-primary);color:#fff;border-radius:999px;padding:1px 10px;font-size:var(--text-xs);font-weight:700">${UI.escape(wurfTitel)}</span>` : ''}
|
||||
<span style="font-weight:700;font-size:var(--text-sm)">${UI.escape(eltern)}</span>
|
||||
<span style="background:${sc}1a;color:${sc};border:1px solid ${sc}40;
|
||||
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${sl}</span>
|
||||
|
|
|
|||
|
|
@ -665,25 +665,6 @@ window.Page_settings = (() => {
|
|||
<!-- Züchter-Profil Slot -->
|
||||
<div id="breeder-card-slot"></div>
|
||||
|
||||
${u.is_partner ? `
|
||||
<!-- Partner-Bereich -->
|
||||
<div class="card mb-4">
|
||||
<div class="by-card-section-header">${UI.icon('handshake')} Partner</div>
|
||||
<div class="p-4">
|
||||
<p class="text-sm-secondary" style="margin:0 0 var(--space-3)">
|
||||
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.
|
||||
</p>
|
||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" id="settings-partner-dashboard-btn">
|
||||
${UI.icon('handshake')} Partner-Bereich
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-partner-profile-btn">
|
||||
${UI.icon('pencil-simple')} Öffentliches Profil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="by-card-section-header">Trophäen</div>
|
||||
|
|
@ -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 `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;
|
||||
padding:var(--space-2) 0;font-size:var(--text-sm)">
|
||||
<span>${UI.escape(label)}</span>
|
||||
<button class="by-toggle ki-toggle-btn" data-key="${UI.escape(key)}"
|
||||
data-active="${active ? '1' : '0'}"
|
||||
style="position:relative;display:inline-block;width:44px;height:24px;
|
||||
border:none;border-radius:12px;cursor:pointer;flex-shrink:0;
|
||||
background:${active ? 'var(--c-primary)' : 'var(--c-border)'};
|
||||
transition:background .2s">
|
||||
<span class="by-toggle-thumb"
|
||||
style="position:absolute;top:2px;left:${active ? '22px' : '2px'};
|
||||
width:20px;height:20px;border-radius:50%;
|
||||
background:#fff;transition:left .2s;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ZÜCHTER-CARD — asynchron laden und in Slot rendern
|
||||
|
|
@ -1737,41 +1691,10 @@ window.Page_settings = (() => {
|
|||
let actionBlock = '';
|
||||
|
||||
if (rolle === 'breeder' || rolle === 'admin') {
|
||||
statusBadge = `<span class="badge badge-primary" style="background:var(--c-success);color:#fff">
|
||||
${UI.icon('check-circle')} ${rolle === 'admin' ? 'Admin — alle Züchter-Features verfügbar' : 'Verifizierter Züchter'}
|
||||
</span>`;
|
||||
actionBlock = `
|
||||
<div style="margin-top:var(--space-3);font-size:var(--text-sm);display:flex;flex-direction:column;gap:var(--space-1)">
|
||||
${profile?.zwingername ? `<div class="text-secondary">Zwinger: <strong>${UI.escape(profile.zwingername)}</strong></div>` : ''}
|
||||
${profile?.rasse_text ? `<div class="text-secondary">Rasse: <strong>${UI.escape(profile.rasse_text)}</strong></div>` : ''}
|
||||
</div>
|
||||
${rolle === 'breeder' && profile ? `
|
||||
<button class="btn btn-secondary btn-sm mt-3" id="breeder-edit-profile-btn">
|
||||
${UI.icon('pencil-simple')} Profil bearbeiten
|
||||
</button>` : ''}
|
||||
${rolle === 'admin' && !profile ? `
|
||||
<button class="btn btn-primary btn-sm mt-3" id="breeder-admin-create-btn">
|
||||
${UI.icon('plus')} Admin-Züchterprofil anlegen
|
||||
</button>` : ''}
|
||||
${rolle === 'admin' && profile ? `
|
||||
<button class="btn btn-secondary btn-sm mt-3" id="breeder-edit-profile-btn">
|
||||
${UI.icon('pencil-simple')} Profil bearbeiten
|
||||
</button>` : ''}
|
||||
${profile ? `
|
||||
<div style="margin-top:var(--space-4);padding-top:var(--space-3);border-top:1px solid var(--c-border)">
|
||||
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
|
||||
text-transform:uppercase;letter-spacing:0.05em;margin-bottom:var(--space-3)">
|
||||
KI-Züchter-Assistenz
|
||||
</div>
|
||||
${_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 || {})}
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
|
||||
${UI.icon('info')} Der Tierschutz-Check läuft immer automatisch und ist nicht abschaltbar.
|
||||
</div>
|
||||
</div>` : ''}`;
|
||||
// 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 = `<span class="badge" style="background:#f59e0b;color:#fff">
|
||||
${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: `
|
||||
<form id="breeder-edit-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Zwingername</label>
|
||||
<input name="zwingername" type="text" maxlength="100" style="${inputStyle}"
|
||||
value="${UI.escape(profile?.zwingername || '')}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Rasse</label>
|
||||
<input name="rasse_text" type="text" maxlength="100" style="${inputStyle}"
|
||||
value="${UI.escape(profile?.rasse_text || '')}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Zuchtverein</label>
|
||||
<input name="verein" type="text" maxlength="100" style="${inputStyle}"
|
||||
value="${UI.escape(profile?.verein || '')}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Stadt</label>
|
||||
<input name="stadt" type="text" maxlength="80" style="${inputStyle}"
|
||||
value="${UI.escape(profile?.stadt || '')}">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<input name="vdh_mitglied" type="checkbox" id="edit-breeder-vdh"
|
||||
style="width:18px;height:18px;cursor:pointer;flex-shrink:0"
|
||||
${profile?.vdh_mitglied ? 'checked' : ''}>
|
||||
<label for="edit-breeder-vdh" style="font-size:var(--text-sm);cursor:pointer">VDH-Mitglied</label>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Website (optional)</label>
|
||||
<input name="website" type="url" maxlength="200" style="${inputStyle}"
|
||||
value="${UI.escape(profile?.website || '')}" placeholder="https://mein-zwinger.de">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Beschreibung (optional)</label>
|
||||
<textarea name="beschreibung" maxlength="500" rows="3"
|
||||
style="${inputStyle};resize:vertical">${UI.escape(profile?.beschreibung || '')}</textarea>
|
||||
</div>
|
||||
</form>`,
|
||||
footer: `
|
||||
<div style="display:flex;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="breeder-edit-form" class="btn btn-primary flex-1" id="breeder-edit-submit">Speichern</button>
|
||||
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
</div>`,
|
||||
});
|
||||
|
||||
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: `
|
||||
<form id="breeder-apply-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Zwingername <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input name="zwingername" type="text" maxlength="100" required
|
||||
placeholder="z. B. vom Sonnenfeld"
|
||||
style="${inputStyle}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Rasse <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input name="rasse_text" type="text" maxlength="100" required
|
||||
placeholder="z. B. Labrador Retriever"
|
||||
style="${inputStyle}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Zuchtverein <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input name="verein" type="text" maxlength="100" required
|
||||
placeholder="z. B. DLRG, VDH, BCD"
|
||||
style="${inputStyle}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Stadt <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input name="stadt" type="text" maxlength="80" required
|
||||
placeholder="z. B. München"
|
||||
style="${inputStyle}">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<input name="vdh_mitglied" type="checkbox" id="breeder-vdh"
|
||||
style="width:18px;height:18px;cursor:pointer;flex-shrink:0">
|
||||
<label for="breeder-vdh" style="font-size:var(--text-sm);cursor:pointer">
|
||||
VDH-Mitglied
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Website (optional)
|
||||
</label>
|
||||
<input name="website" type="url" maxlength="200"
|
||||
placeholder="https://mein-zwinger.de"
|
||||
style="${inputStyle}">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<textarea name="beschreibung" maxlength="500" rows="3"
|
||||
placeholder="Kurze Beschreibung deines Zwingers"
|
||||
style="${inputStyle};resize:vertical"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||
Dokument hochladen <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input name="dokument" type="file" id="breeder-doc-input" required
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
style="font-size:var(--text-sm);width:100%;box-sizing:border-box">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
|
||||
Zuchtbuch-Eintrag, Vereinsmitgliedschaft o.ä. (PDF, JPG, PNG, WebP)
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<div class="w3-btn-stack">
|
||||
<button type="submit" form="breeder-apply-form" class="btn btn-primary w-full" id="breeder-apply-submit"
|
||||
>Antrag einreichen</button>
|
||||
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
document.getElementById('breeder-apply-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<script src="/js/landing-init.js?v=1265"></script>
|
||||
<script src="/js/landing-init.js?v=1270"></script>
|
||||
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser.">
|
||||
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
114
tests/test_api_surface.py
Normal file
114
tests/test_api_surface.py
Normal file
|
|
@ -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)))
|
||||
)
|
||||
78
tests/test_breeder_editor.py
Normal file
78
tests/test_breeder_editor.py
Normal file
|
|
@ -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/")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue