banyaro/backend/routes/breeder_export.py
rene c8ae514c01 Feature: Tierschutz-Check, KI-Züchter-Features, Export, SEO-Update
Tierschutz-System (immer aktiv, nicht abschaltbar):
- welfare_check.py: regelbasierte Prüfung IK, Alter, Deckpause, Wurfanzahl, Genetik
- Grün/Gelb/Rot-Modal bei Wurf anlegen + Probeverpaarung
- Bei kritischem Befund + "Trotzdem fortfahren" → automatische Admin-Mail
- Tierschutz-Check nie durch Nutzer deaktivierbar

KI-Züchter-Features (pro User an/abschaltbar außer Tierschutz):
- routes/zucht_ki.py: 5 Endpunkte — Wurfankündigung, Genetik-Erklärung,
  Paarungsanalyse, Hund-Beschreibung, Jahresbericht
- Toggles in Einstellungen (ki_zucht_* Felder)
- KI-Buttons in litters.js + zuchthunde.js

KI-Routing: Privilegierte Rollen (Admin, Züchter, Moderator, Manager)
nutzen Claude Sonnet primär, lokales LLM als Fallback

Datenexport: routes/breeder_export.py — ZIP mit HTML-Dossier + ODS
(odfpy hinzugefügt in requirements.txt)

Admin-Profil: POST /admin/breeder/create-profile für Schnellprofil ohne
Antragsprozess; Admin-Rolle bleibt erhalten

Wurfformular: Dropdown aus Zuchtkartei für Vater/Mutter mit Auto-Fill;
litters.vater_id + mutter_id als FK auf zucht_hunde

Probeverpaarung: heart-fill Icon + Welfare-Block im Ergebnis

Landing Page: Züchter-Section + Feature-Gruppe, Meta-Tags, JSON-LD,
keywords, softwareVersion 2.1

SEO: llms.txt vollständig überarbeitet, robots.txt Züchter-Pfade,
sitemap.xml um Wurfbörse + Züchter-Profile erweitert

SW by-v474, APP_VER 451
2026-04-28 19:49:54 +02:00

497 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""BAN YARO — Züchter-Datenexport (HTML + ODS)"""
import io
import zipfile
import logging
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
def _require_breeder(user=Depends(get_current_user)):
if user["rolle"] not in ("breeder", "admin"):
raise HTTPException(403, "Nur für Züchter.")
return user
# ------------------------------------------------------------------
# Datenabruf
# ------------------------------------------------------------------
def _collect_data(user_id: int) -> dict:
with db() as conn:
profile = conn.execute(
"SELECT bp.*, u.name AS user_name, u.email "
"FROM breeder_profiles bp JOIN users u ON u.id = bp.user_id "
"WHERE bp.user_id=?", (user_id,)
).fetchone()
if not profile:
return None
bid = profile["id"]
hunde = conn.execute(
"SELECT * FROM zucht_hunde WHERE breeder_id=? ORDER BY name", (bid,)
).fetchall()
hund_ids = [h["id"] for h in hunde]
def rows_for(table, id_col):
if not hund_ids:
return []
placeholders = ",".join("?" * len(hund_ids))
return conn.execute(
f"SELECT * FROM {table} WHERE {id_col} IN ({placeholders}) ORDER BY {id_col}",
hund_ids
).fetchall()
health = rows_for("dog_health_tests", "hund_id")
genetic = rows_for("dog_genetic_tests", "hund_id")
titles = rows_for("dog_titles", "hund_id")
litters = conn.execute(
"SELECT * FROM litters WHERE breeder_id=? ORDER BY created_at DESC", (bid,)
).fetchall()
litter_ids = [l["id"] for l in litters]
puppies, weights = [], []
if litter_ids:
placeholders = ",".join("?" * len(litter_ids))
puppies = conn.execute(
f"SELECT * FROM puppies WHERE wurf_id IN ({placeholders}) ORDER BY wurf_id",
litter_ids
).fetchall()
puppy_ids = [p["id"] for p in puppies]
if puppy_ids:
ph2 = ",".join("?" * len(puppy_ids))
weights = conn.execute(
f"SELECT * FROM puppy_weights WHERE welpe_id IN ({ph2}) ORDER BY welpe_id, gemessen_am",
puppy_ids
).fetchall()
# Stammbaum pro Hund (3 Generationen, flach)
def pedigree_flat(hund_id, depth=3):
if not hund_id or depth == 0:
return {}
row = conn.execute("SELECT id, name, zuchtbuchnummer, geburtsdatum FROM zucht_hunde WHERE id=?", (hund_id,)).fetchone()
if not row:
return {}
result = {"name": row["name"], "nr": row["zuchtbuchnummer"] or "", "geb": row["geburtsdatum"] or ""}
row2 = conn.execute("SELECT vater_id, mutter_id FROM zucht_hunde WHERE id=?", (hund_id,)).fetchone()
if row2:
result["vater"] = pedigree_flat(row2["vater_id"], depth - 1)
result["mutter"] = pedigree_flat(row2["mutter_id"], depth - 1)
return result
pedigrees = {h["id"]: pedigree_flat(h["id"]) for h in hunde}
return {
"profile": dict(profile),
"hunde": [dict(h) for h in hunde],
"health": [dict(r) for r in health],
"genetic": [dict(r) for r in genetic],
"titles": [dict(r) for r in titles],
"litters": [dict(l) for l in litters],
"puppies": [dict(p) for p in puppies],
"weights": [dict(w) for w in weights],
"pedigrees": pedigrees,
}
# ------------------------------------------------------------------
# HTML-Export
# ------------------------------------------------------------------
def _fmt_date(val):
if not val:
return ""
try:
return datetime.strptime(str(val)[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return str(val)
def _health_badge(test_typ, ergebnis):
color = "#6B7280"
e = (ergebnis or "").upper()
if test_typ == "HD":
color = {"A1": "#16a34a", "A2": "#16a34a", "B1": "#86efac", "B2": "#86efac",
"C": "#eab308", "C1": "#eab308", "C2": "#eab308",
"D": "#f97316", "D1": "#f97316", "D2": "#f97316",
"E": "#dc2626", "E1": "#dc2626", "E2": "#dc2626"}.get(e, "#6B7280")
elif test_typ == "ED":
color = {"0": "#16a34a", "1": "#eab308", "2": "#f97316", "3": "#dc2626"}.get(e, "#6B7280")
elif ergebnis in ("clear", "frei"):
color = "#16a34a"
elif ergebnis == "carrier":
color = "#eab308"
elif ergebnis == "affected":
color = "#dc2626"
return f'<span style="background:{color};color:#fff;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">{ergebnis}</span>'
def _pedigree_node_html(node, gen):
if not node:
return '<div style="background:#f3f4f6;border:1px dashed #d1d5db;border-radius:6px;padding:6px 8px;color:#9ca3af;font-size:11px;height:100%;box-sizing:border-box">Unbekannt</div>'
geb = f"*{node['geb'][:4]}" if node.get("geb") else ""
nr = node.get("nr", "")
bg = "#7c3aed" if gen == 1 else ("#ede9fe" if gen == 2 else ("#f5f3ff" if gen == 3 else "#faf9ff"))
col = "#fff" if gen == 1 else "#1f2937"
return (
f'<div style="background:{bg};color:{col};border-radius:6px;padding:6px 8px;'
f'font-size:11px;height:100%;box-sizing:border-box;border:1px solid #e5e7eb">'
f'<div style="font-weight:700;margin-bottom:2px">{node["name"]}</div>'
+ (f'<div style="opacity:.7">{nr}</div>' if nr else "")
+ (f'<div style="opacity:.7">{geb}</div>' if geb else "")
+ "</div>"
)
def _pedigree_html(tree):
# 4-Gen horizontaler Stammbaum als Tabelle
def nodes(t, gen, max_gen=4):
if gen > max_gen:
return []
item = [{"node": t, "gen": gen}]
item += nodes(t.get("vater") if t else None, gen + 1, max_gen)
item += nodes(t.get("mutter") if t else None, gen + 1, max_gen)
return item
rows = 8 # 2^3 für 4 Generationen
cols = 4
def collect(node, gen, row_start, row_span):
if gen > cols:
return []
items = [{"node": node, "gen": gen, "row": row_start, "span": row_span}]
half = row_span // 2
items += collect(node.get("vater") if node else None, gen + 1, row_start, half)
items += collect(node.get("mutter") if node else None, gen + 1, row_start + half, half)
return items
cells = collect(tree, 1, 0, rows)
gen_labels = {1: "Proband", 2: "Eltern", 3: "Großeltern", 4: "Urgroßeltern"}
# Grid als Tabelle (row_height = 50px)
row_h = 52
total_h = rows * row_h
html = (
f'<div style="overflow-x:auto;margin:12px 0">'
f'<div style="display:grid;grid-template-columns:repeat({cols},1fr);'
f'grid-template-rows:repeat({rows},{row_h}px);gap:4px;min-width:{cols*160}px">'
)
for c in cells:
node, gen, row, span = c["node"], c["gen"], c["row"], c["span"]
html += (
f'<div style="grid-column:{gen};grid-row:{row+1}/span {span};padding:2px">'
+ _pedigree_node_html(node, gen)
+ "</div>"
)
html += "</div></div>"
return html
def _generate_html(data: dict) -> str:
p = data["profile"]
today = date.today().strftime("%d.%m.%Y")
hunde_html = ""
for h in data["hunde"]:
hid = h["id"]
h_health = [r for r in data["health"] if r["hund_id"] == hid]
h_genetic = [r for r in data["genetic"] if r["hund_id"] == hid]
h_titles = [r for r in data["titles"] if r["hund_id"] == hid]
tree = data["pedigrees"].get(hid, {})
health_rows = "".join(
f'<tr><td>{r["test_typ"]}</td><td>{_health_badge(r["test_typ"], r["ergebnis"])}</td>'
f'<td>{_fmt_date(r["untersuch_am"])}</td><td>{r.get("labor","") or ""}</td>'
f'<td>{r.get("untersucher","") or ""}</td></tr>'
for r in h_health
) or "<tr><td colspan=5 style='color:#9ca3af'>Keine Einträge</td></tr>"
genetic_rows = "".join(
f'<tr><td>{r["marker_name"]}</td><td>{_health_badge("DNA", r["ergebnis_klasse"])}</td>'
f'<td>{_fmt_date(r["getestet_am"])}</td><td>{r.get("labor","") or ""}</td></tr>'
for r in h_genetic
) or "<tr><td colspan=4 style='color:#9ca3af'>Keine Einträge</td></tr>"
title_rows = "".join(
f'<tr><td><b>{r["titel_name"]}</b></td><td>{r["titel_typ"]}</td>'
f'<td>{_fmt_date(r["verliehen_am"])}</td><td>{r.get("ort","") or ""}</td>'
f'<td>{r.get("richter","") or ""}</td></tr>'
for r in sorted(h_titles, key=lambda x: x["verliehen_am"] or "", reverse=True)
) or "<tr><td colspan=5 style='color:#9ca3af'>Keine Einträge</td></tr>"
geschlecht = {"maennlich": "Rüde", "weiblich": "Hündin"}.get(h.get("geschlecht",""), "")
hunde_html += f"""
<div class="dog-card">
<h2 style="color:#7c3aed;margin:0 0 4px">{h['name']}</h2>
{f'<div style="color:#6b7280;margin-bottom:12px"><em>{h["rufname"]}</em></div>' if h.get('rufname') else ''}
<table class="info-table">
<tr><th>Geschlecht</th><td>{geschlecht}</td><th>Geburtsdatum</th><td>{_fmt_date(h.get('geburtsdatum'))}</td></tr>
<tr><th>Chip-Nr.</th><td>{h.get('chip_nr') or ''}</td><th>Zuchtbuchnr.</th><td>{h.get('zuchtbuchnummer') or ''}</td></tr>
<tr><th>Farbe</th><td>{h.get('farbe') or ''}</td><th>Tätowierung</th><td>{h.get('taetowiernummer') or ''}</td></tr>
<tr><th>Züchter</th><td>{h.get('zuechter_name') or ''}</td><th>Eigentümer</th><td>{h.get('eigentuemer_name') or ''}</td></tr>
</table>
<h3>Stammbaum</h3>
{_pedigree_html(tree) if tree else '<p style="color:#9ca3af">Keine Elterntiere eingetragen.</p>'}
<h3>Gesundheitstests</h3>
<table class="data-table">
<tr><th>Test</th><th>Ergebnis</th><th>Datum</th><th>Labor</th><th>Untersucher</th></tr>
{health_rows}
</table>
<h3>Genetische Tests</h3>
<table class="data-table">
<tr><th>Marker</th><th>Ergebnis</th><th>Datum</th><th>Labor</th></tr>
{genetic_rows}
</table>
<h3>Titel &amp; Auszeichnungen</h3>
<table class="data-table">
<tr><th>Titel</th><th>Typ</th><th>Datum</th><th>Ort</th><th>Richter</th></tr>
{title_rows}
</table>
</div>"""
litters_html = ""
for l in data["litters"]:
lid = l["id"]
l_puppies = [p for p in data["puppies"] if p["wurf_id"] == lid]
puppy_rows = "".join(
f'<tr><td>{p.get("name") or ""}</td>'
f'<td>{"Rüde" if p.get("geschlecht")=="maennlich" else "Hündin" if p.get("geschlecht")=="weiblich" else ""}</td>'
f'<td>{p.get("farbe") or ""}</td><td>{p.get("chip_nr") or ""}</td>'
f'<td>{p.get("status") or ""}</td></tr>'
for p in l_puppies
) or "<tr><td colspan=5 style='color:#9ca3af'>Keine Welpen eingetragen</td></tr>"
eltern = " × ".join(filter(None, [l.get("vater_name"), l.get("mutter_name")])) or ""
datum = _fmt_date(l.get("geburt_datum") or l.get("erwartetes_datum"))
status_label = {"geplant": "Geplant", "geboren": "Geboren", "verfuegbar": "Verfügbar", "abgeschlossen": "Abgeschlossen"}.get(l.get("status",""), l.get("status",""))
litters_html += f"""
<div class="litter-card">
<div style="display:flex;align-items:baseline;gap:12px;margin-bottom:8px">
<h3 style="margin:0">{eltern}</h3>
<span style="color:#6b7280;font-size:13px">{datum} · {status_label}</span>
</div>
{f'<p style="margin:0 0 12px;color:#374151">{l["beschreibung"]}</p>' if l.get("beschreibung") else ''}
<table class="data-table">
<tr><th>Name</th><th>Geschlecht</th><th>Farbe</th><th>Chip-Nr.</th><th>Status</th></tr>
{puppy_rows}
</table>
</div>"""
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Zuchtkartei — {p.get('zwingername','')}</title>
<style>
@media print {{
.no-print {{ display:none }}
body {{ font-size:11pt }}
.dog-card, .litter-card {{ page-break-inside:avoid }}
}}
* {{ box-sizing:border-box }}
body {{ font-family:'Segoe UI',Arial,sans-serif;color:#1f2937;margin:0;padding:24px;max-width:1100px;margin:0 auto }}
h1 {{ color:#7c3aed;margin:0 0 4px }}
h2 {{ color:#7c3aed;border-bottom:2px solid #ede9fe;padding-bottom:6px }}
h3 {{ color:#374151;font-size:14px;margin:16px 0 8px }}
.header {{ background:#f5f3ff;border:1px solid #ddd6fe;border-radius:8px;padding:20px 24px;margin-bottom:24px }}
.dog-card, .litter-card {{
border:1px solid #e5e7eb;border-radius:8px;padding:20px 24px;margin-bottom:20px
}}
.info-table {{ border-collapse:collapse;width:100%;margin-bottom:8px }}
.info-table th {{ text-align:left;color:#6b7280;font-size:12px;padding:3px 8px 3px 0;width:120px }}
.info-table td {{ padding:3px 8px 3px 0;font-size:13px }}
.data-table {{ border-collapse:collapse;width:100%;font-size:12px }}
.data-table th {{ background:#f9fafb;padding:6px 10px;text-align:left;border-bottom:2px solid #e5e7eb;color:#374151 }}
.data-table td {{ padding:5px 10px;border-bottom:1px solid #f3f4f6 }}
.data-table tr:last-child td {{ border-bottom:none }}
.badge-group {{ display:flex;gap:4px;flex-wrap:wrap }}
footer {{ color:#9ca3af;font-size:11px;margin-top:32px;border-top:1px solid #e5e7eb;padding-top:12px }}
</style>
</head>
<body>
<div class="header">
<h1>{p.get('zwingername','Mein Zwinger')}</h1>
<div style="color:#6b7280;margin-bottom:12px">Exportiert am {today} · banyaro.app</div>
<table class="info-table">
<tr><th>Rasse</th><td>{p.get('rasse_text','')}</td><th>Verein</th><td>{p.get('verein','')}</td></tr>
<tr><th>Stadt</th><td>{p.get('stadt','')}</td><th>VDH</th><td>{'Ja' if p.get('vdh_mitglied') else 'Nein'}</td></tr>
{f'<tr><th>Website</th><td colspan=3><a href="{p["website"]}">{p["website"]}</a></td></tr>' if p.get('website') else ''}
</table>
{f'<p style="margin:12px 0 0;color:#374151">{p["beschreibung"]}</p>' if p.get('beschreibung') else ''}
</div>
<h2>Hunde ({len(data['hunde'])})</h2>
{hunde_html or '<p style="color:#9ca3af">Keine Hunde eingetragen.</p>'}
<h2>Würfe ({len(data['litters'])})</h2>
{litters_html or '<p style="color:#9ca3af">Keine Würfe eingetragen.</p>'}
<footer>Erstellt mit Banyaro — Die App für Hunde und ihre Menschen · banyaro.app</footer>
</body>
</html>"""
# ------------------------------------------------------------------
# ODS-Export
# ------------------------------------------------------------------
def _generate_ods(data: dict) -> bytes:
from odf.opendocument import OpenDocumentSpreadsheet
from odf.style import Style, TextProperties, TableColumnProperties
from odf.text import P
from odf.table import Table, TableColumn, TableRow, TableCell
doc = OpenDocumentSpreadsheet()
def add_style(name, bold=False, bg=None, color=None):
style = Style(name=name, family="table-cell")
tp = TextProperties()
if bold:
tp.setAttribute("fo:font-weight", "bold")
if color:
tp.setAttribute("fo:color", color)
style.addElement(tp)
doc.styles.addElement(style)
return style
header_style = add_style("Header", bold=True, color="#ffffff")
normal_style = add_style("Normal")
def cell(value, style=None):
tc = TableCell()
if style:
tc.setAttribute("table:style-name", style.getAttribute("style:name"))
tc.addElement(P(text=str(value) if value is not None else ""))
return tc
def header_row(sheet, cols):
tr = TableRow()
for c in cols:
tr.addElement(cell(c, header_style))
sheet.addElement(tr)
def data_row(sheet, vals):
tr = TableRow()
for v in vals:
tr.addElement(cell(v))
sheet.addElement(tr)
# Sheet: Hunde
sheet_hunde = Table(name="Hunde")
header_row(sheet_hunde, ["Name", "Rufname", "Geschlecht", "Geburtsdatum", "Chip-Nr.", "Zuchtbuchnummer", "Farbe", "Tätowierung", "Züchter", "Eigentümer", "Notiz"])
for h in data["hunde"]:
g = {"maennlich": "Rüde", "weiblich": "Hündin"}.get(h.get("geschlecht",""), "")
data_row(sheet_hunde, [h["name"], h.get("rufname",""), g, h.get("geburtsdatum",""),
h.get("chip_nr",""), h.get("zuchtbuchnummer",""), h.get("farbe",""),
h.get("taetowiernummer",""), h.get("zuechter_name",""),
h.get("eigentuemer_name",""), h.get("notiz","")])
doc.spreadsheet.addElement(sheet_hunde)
# Sheet: Gesundheitstests
sheet_health = Table(name="Gesundheitstests")
header_row(sheet_health, ["Hund", "Test-Typ", "Test-Name", "Ergebnis", "Datum", "Gültig bis", "Untersucher", "Labor", "Zertifikat-Nr."])
hund_map = {h["id"]: h["name"] for h in data["hunde"]}
for r in data["health"]:
data_row(sheet_health, [hund_map.get(r["hund_id"],""), r["test_typ"], r.get("test_name",""),
r["ergebnis"], r.get("untersuch_am",""), r.get("gueltig_bis",""),
r.get("untersucher",""), r.get("labor",""), r.get("zertifikat_nr","")])
doc.spreadsheet.addElement(sheet_health)
# Sheet: Gentests
sheet_gen = Table(name="Gentests")
header_row(sheet_gen, ["Hund", "Marker", "Kategorie", "Genotyp", "Ergebnis", "Getestet am", "Labor", "Zertifikat-Nr."])
for r in data["genetic"]:
data_row(sheet_gen, [hund_map.get(r["hund_id"],""), r["marker_name"], r.get("marker_kategorie",""),
r.get("genotyp",""), r.get("ergebnis_klasse",""), r.get("getestet_am",""),
r.get("labor",""), r.get("zertifikat_nr","")])
doc.spreadsheet.addElement(sheet_gen)
# Sheet: Titel
sheet_titles = Table(name="Titel")
header_row(sheet_titles, ["Hund", "Titel", "Typ", "Verliehen am", "Ort", "Ausstellung", "Richter", "Formwert"])
for r in data["titles"]:
data_row(sheet_titles, [hund_map.get(r["hund_id"],""), r["titel_name"], r["titel_typ"],
r.get("verliehen_am",""), r.get("ort",""), r.get("ausstellung",""),
r.get("richter",""), r.get("formwert","")])
doc.spreadsheet.addElement(sheet_titles)
# Sheet: Würfe
sheet_litters = Table(name="Würfe")
header_row(sheet_litters, ["Vater", "Mutter", "Geburtsdatum", "Erwartetes Datum", "Status", "Welpen gesamt", "Welpen verfügbar", "Preisspanne", "Gesundheitstests", "Beschreibung"])
for l in data["litters"]:
status = {"geplant":"Geplant","geboren":"Geboren","verfuegbar":"Verfügbar","abgeschlossen":"Abgeschlossen"}.get(l.get("status",""), l.get("status",""))
data_row(sheet_litters, [l.get("vater_name",""), l.get("mutter_name",""),
l.get("geburt_datum",""), l.get("erwartetes_datum",""),
status, l.get("welpen_gesamt",""), l.get("welpen_verfuegbar",""),
l.get("preis_spanne",""), l.get("gesundheitstests",""),
l.get("beschreibung","")])
doc.spreadsheet.addElement(sheet_litters)
# Sheet: Welpen
sheet_puppies = Table(name="Welpen")
header_row(sheet_puppies, ["Wurf (Vater × Mutter)", "Name", "Geschlecht", "Farbe", "Chip-Nr.", "Geburtsgewicht (g)", "Status", "Notiz"])
litter_map = {l["id"]: f"{l.get('vater_name','?')} × {l.get('mutter_name','?')}" for l in data["litters"]}
for p in data["puppies"]:
g = {"maennlich":"Rüde","weiblich":"Hündin"}.get(p.get("geschlecht",""),"")
data_row(sheet_puppies, [litter_map.get(p["wurf_id"],""), p.get("name",""), g,
p.get("farbe",""), p.get("chip_nr",""),
p.get("geburtsgewicht",""), p.get("status",""), p.get("notiz","")])
doc.spreadsheet.addElement(sheet_puppies)
# Sheet: Gewichte
sheet_weights = Table(name="Gewichtsverlauf")
header_row(sheet_weights, ["Welpe", "Gewicht (g)", "Gemessen am"])
puppy_map = {p["id"]: p.get("name","") or f"Welpe #{p['id']}" for p in data["puppies"]}
for w in data["weights"]:
data_row(sheet_weights, [puppy_map.get(w["welpe_id"],""), w["gewicht_g"], w.get("gemessen_am","")])
doc.spreadsheet.addElement(sheet_weights)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
# ------------------------------------------------------------------
# GET /api/breeder/export — ZIP mit HTML + ODS
# ------------------------------------------------------------------
@router.get("/breeder/export")
async def export_breeder_data(user=Depends(_require_breeder)):
data = _collect_data(user["id"])
if not data:
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
zwinger = data["profile"].get("zwingername", "export").replace(" ", "_")
today = date.today().strftime("%Y-%m-%d")
name = f"banyaro_{zwinger}_{today}"
try:
html_bytes = _generate_html(data).encode("utf-8")
ods_bytes = _generate_ods(data)
except Exception as e:
logger.error(f"Export-Fehler: {e}", exc_info=True)
raise HTTPException(500, "Fehler beim Erstellen des Exports.")
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f"{name}.html", html_bytes)
zf.writestr(f"{name}.ods", ods_bytes)
zip_buf.seek(0)
return StreamingResponse(
zip_buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{name}.zip"'},
)