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
This commit is contained in:
rene 2026-04-28 19:49:54 +02:00
parent 91340be5a3
commit c8ae514c01
20 changed files with 2129 additions and 200 deletions

View file

@ -0,0 +1,497 @@
"""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"'},
)