"""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'{ergebnis}' def _pedigree_node_html(node, gen): if not node: return '
Unbekannt
' 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'
' f'
{node["name"]}
' + (f'
{nr}
' if nr else "") + (f'
{geb}
' if geb else "") + "
" ) 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'
' f'
' ) for c in cells: node, gen, row, span = c["node"], c["gen"], c["row"], c["span"] html += ( f'
' + _pedigree_node_html(node, gen) + "
" ) html += "
" 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'{r["test_typ"]}{_health_badge(r["test_typ"], r["ergebnis"])}' f'{_fmt_date(r["untersuch_am"])}{r.get("labor","") or ""}' f'{r.get("untersucher","") or ""}' for r in h_health ) or "Keine Einträge" genetic_rows = "".join( f'{r["marker_name"]}{_health_badge("DNA", r["ergebnis_klasse"])}' f'{_fmt_date(r["getestet_am"])}{r.get("labor","") or ""}' for r in h_genetic ) or "Keine Einträge" title_rows = "".join( f'{r["titel_name"]}{r["titel_typ"]}' f'{_fmt_date(r["verliehen_am"])}{r.get("ort","") or ""}' f'{r.get("richter","") or ""}' for r in sorted(h_titles, key=lambda x: x["verliehen_am"] or "", reverse=True) ) or "Keine Einträge" geschlecht = {"maennlich": "Rüde", "weiblich": "Hündin"}.get(h.get("geschlecht",""), "") hunde_html += f"""

{h['name']}

{f'
{h["rufname"]}
' if h.get('rufname') else ''}
Geschlecht{geschlecht}Geburtsdatum{_fmt_date(h.get('geburtsdatum'))}
Chip-Nr.{h.get('chip_nr') or '—'}Zuchtbuchnr.{h.get('zuchtbuchnummer') or '—'}
Farbe{h.get('farbe') or '—'}Tätowierung{h.get('taetowiernummer') or '—'}
Züchter{h.get('zuechter_name') or '—'}Eigentümer{h.get('eigentuemer_name') or '—'}

Stammbaum

{_pedigree_html(tree) if tree else '

Keine Elterntiere eingetragen.

'}

Gesundheitstests

{health_rows}
TestErgebnisDatumLaborUntersucher

Genetische Tests

{genetic_rows}
MarkerErgebnisDatumLabor

Titel & Auszeichnungen

{title_rows}
TitelTypDatumOrtRichter
""" 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'{p.get("name") or "—"}' f'{"Rüde" if p.get("geschlecht")=="maennlich" else "Hündin" if p.get("geschlecht")=="weiblich" else "—"}' f'{p.get("farbe") or "—"}{p.get("chip_nr") or "—"}' f'{p.get("status") or "—"}' for p in l_puppies ) or "Keine Welpen eingetragen" 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"""

{eltern}

{datum} · {status_label}
{f'

{l["beschreibung"]}

' if l.get("beschreibung") else ''} {puppy_rows}
NameGeschlechtFarbeChip-Nr.Status
""" return f""" Zuchtkartei — {p.get('zwingername','')}

{p.get('zwingername','Mein Zwinger')}

Exportiert am {today} · banyaro.app
{f'' if p.get('website') else ''}
Rasse{p.get('rasse_text','—')}Verein{p.get('verein','—')}
Stadt{p.get('stadt','—')}VDH{'Ja' if p.get('vdh_mitglied') else 'Nein'}
Website{p["website"]}
{f'

{p["beschreibung"]}

' if p.get('beschreibung') else ''}

Hunde ({len(data['hunde'])})

{hunde_html or '

Keine Hunde eingetragen.

'}

Würfe ({len(data['litters'])})

{litters_html or '

Keine Würfe eingetragen.

'} """ # ------------------------------------------------------------------ # 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"'}, )