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:
parent
91340be5a3
commit
c8ae514c01
20 changed files with 2129 additions and 200 deletions
|
|
@ -551,6 +551,17 @@ def _migrate(conn_factory):
|
|||
("users", "notes_ki_enabled", "INTEGER NOT NULL DEFAULT 1"),
|
||||
# Züchter-Rolle
|
||||
("users", "breeder_status", "TEXT"),
|
||||
# Würfe: Verknüpfung mit Zuchtkartei-Hunden + Welfare
|
||||
("litters", "vater_id", "INTEGER"),
|
||||
("litters", "mutter_id", "INTEGER"),
|
||||
("litters", "welfare_level", "TEXT"),
|
||||
("litters", "welfare_acknowledged", "INTEGER NOT NULL DEFAULT 0"),
|
||||
# KI-Züchter-Features (pro User an/abschaltbar, außer Tierschutz)
|
||||
("users", "ki_zucht_wurfankuendigung", "INTEGER NOT NULL DEFAULT 1"),
|
||||
("users", "ki_zucht_genetik", "INTEGER NOT NULL DEFAULT 1"),
|
||||
("users", "ki_zucht_paarung", "INTEGER NOT NULL DEFAULT 1"),
|
||||
("users", "ki_zucht_beschreibung", "INTEGER NOT NULL DEFAULT 1"),
|
||||
("users", "ki_zucht_jahresbericht", "INTEGER NOT NULL DEFAULT 1"),
|
||||
]
|
||||
with conn_factory() as conn:
|
||||
for table, column, col_type in migrations:
|
||||
|
|
|
|||
|
|
@ -82,6 +82,28 @@ def _track_usage(user_id: int | None, source: str) -> None:
|
|||
logger.warning(f"KI-Tracking fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
def _is_cloud_priority_user(user_id: int | None) -> bool:
|
||||
"""Privilegierte Rollen (Admin, Moderator, Züchter, Manager) nutzen Cloud-KI primär."""
|
||||
if not user_id or not ANTHROPIC_KEY:
|
||||
return False
|
||||
try:
|
||||
from database import db
|
||||
with db() as conn:
|
||||
user = conn.execute(
|
||||
"SELECT rolle, is_moderator, is_social_media FROM users WHERE id=?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
if not user:
|
||||
return False
|
||||
return bool(
|
||||
user["rolle"] in ("admin", "breeder", "moderator")
|
||||
or user["is_moderator"]
|
||||
or user["is_social_media"]
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _check_weekly_cloud_limit(user_id: int | None) -> None:
|
||||
"""Wirft KIPremiumRequired wenn user_id das wöchentliche Cloud-Limit erreicht hat."""
|
||||
if user_id is None or CLOUD_WEEKLY_LIMIT <= 0:
|
||||
|
|
@ -92,9 +114,9 @@ def _check_weekly_cloud_limit(user_id: int | None) -> None:
|
|||
user = conn.execute(
|
||||
"SELECT rolle, is_moderator FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
# Admins, Moderatoren und Media Manager haben kein Limit
|
||||
# Admins, Moderatoren, Züchter und Media Manager haben kein Limit
|
||||
if user and (
|
||||
user["rolle"] in ("admin", "moderator", "media_manager")
|
||||
user["rolle"] in ("admin", "breeder", "moderator", "media_manager")
|
||||
or user["is_moderator"]
|
||||
):
|
||||
return
|
||||
|
|
@ -137,8 +159,28 @@ async def complete(
|
|||
if requires_premium and not user_is_premium:
|
||||
raise KIPremiumRequired("Dieses Feature ist Teil von Ban Yaro Premium.")
|
||||
|
||||
# Immer lokal zuerst — Cloud ist Fallback wenn lokal nicht erreichbar
|
||||
if KI_MODE in ("local", "cloud"):
|
||||
# Privilegierte Rollen (Admin, Moderator, Züchter, Manager) → Cloud zuerst
|
||||
if _is_cloud_priority_user(user_id):
|
||||
try:
|
||||
_check_weekly_cloud_limit(user_id)
|
||||
text = await _cloud_complete(prompt, system, max_tokens, json_mode)
|
||||
_track_usage(user_id, "cloud")
|
||||
if return_model:
|
||||
return (text, CLOUD_MODEL)
|
||||
return (text, "cloud") if return_source else text
|
||||
except KIPremiumRequired:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Cloud-KI nicht erreichbar für privilegierten User, Fallback lokal: {e}")
|
||||
# Fallback auf lokales Modell
|
||||
text = await _local_complete(prompt, system, max_tokens, json_mode)
|
||||
_track_usage(user_id, "local")
|
||||
if return_model:
|
||||
return (text, LOCAL_MODEL)
|
||||
return (text, "local") if return_source else text
|
||||
|
||||
# Standard-User → lokal zuerst, Cloud als Fallback
|
||||
try:
|
||||
text = await _local_complete(prompt, system, max_tokens, json_mode)
|
||||
_track_usage(user_id, "local")
|
||||
|
|
|
|||
|
|
@ -160,6 +160,8 @@ from routes.breeder import router as breeder_router
|
|||
from routes.litters import router as litters_router
|
||||
from routes.breeder_photos import router as breeder_photos_router
|
||||
from routes.zucht_hunde import router as zucht_hunde_router
|
||||
from routes.breeder_export import router as breeder_export_router
|
||||
from routes.zucht_ki import router as zucht_ki_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||
|
|
@ -189,6 +191,8 @@ app.include_router(breeder_router, prefix="/api", tags=["Züchter"
|
|||
app.include_router(litters_router, prefix="/api", tags=["Würfe"])
|
||||
app.include_router(breeder_photos_router, prefix="/api", tags=["Züchter-Fotos"])
|
||||
app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"])
|
||||
app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
|
||||
app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"])
|
||||
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
|
||||
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
|
||||
app.include_router(import_router, prefix="/api/import", tags=["Import"])
|
||||
|
|
@ -255,6 +259,7 @@ async def sitemap():
|
|||
("https://banyaro.app/info", "monthly", "0.9"),
|
||||
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
|
||||
("https://banyaro.app/knigge", "monthly", "0.8"),
|
||||
("https://banyaro.app/wurfboerse", "daily", "0.8"),
|
||||
]
|
||||
|
||||
try:
|
||||
|
|
@ -262,8 +267,6 @@ async def sitemap():
|
|||
rassen = conn.execute(
|
||||
"SELECT slug FROM wiki_rassen WHERE slug IS NOT NULL AND slug != '' LIMIT 500"
|
||||
).fetchall()
|
||||
if rassen:
|
||||
urls.append(("https://banyaro.app/wiki/rassen", "weekly", "0.8"))
|
||||
for r in rassen:
|
||||
urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7"))
|
||||
|
||||
|
|
@ -272,6 +275,20 @@ async def sitemap():
|
|||
).fetchall()
|
||||
for e in events:
|
||||
urls.append((f"https://banyaro.app/api/events/{e['id']}", "weekly", "0.5"))
|
||||
|
||||
# Öffentliche Züchter-Profile
|
||||
breeders = conn.execute(
|
||||
"SELECT bp.zwingername FROM breeder_profiles bp "
|
||||
"JOIN users u ON u.id = bp.user_id "
|
||||
"WHERE bp.verified_at IS NOT NULL AND u.rolle = 'breeder'"
|
||||
).fetchall()
|
||||
for b in breeders:
|
||||
if b["zwingername"]:
|
||||
from urllib.parse import quote
|
||||
urls.append((
|
||||
f"https://banyaro.app/breeder/{quote(b['zwingername'])}",
|
||||
"weekly", "0.7"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ openai==1.59.2
|
|||
anthropic==0.49.0
|
||||
pywebpush==2.0.0
|
||||
apscheduler==3.10.4
|
||||
odfpy==1.4.1
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ async def breeder_status(user=Depends(get_current_user)):
|
|||
if not row:
|
||||
raise HTTPException(404, "User nicht gefunden.")
|
||||
profile = None
|
||||
if row["rolle"] == "breeder":
|
||||
if row["rolle"] in ("breeder", "admin"):
|
||||
profile = conn.execute(
|
||||
"SELECT zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at "
|
||||
"FROM breeder_profiles WHERE user_id=?",
|
||||
|
|
@ -318,6 +318,28 @@ async def breeder_public_profile(zwingername: str):
|
|||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/admin/breeder/create-profile — Admin-Schnellprofil
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/admin/breeder/create-profile")
|
||||
async def admin_create_profile(admin=Depends(require_admin)):
|
||||
with db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM breeder_profiles WHERE user_id=?", (admin["id"],)
|
||||
).fetchone()
|
||||
if existing:
|
||||
return {"message": "Profil existiert bereits.", "profile_id": existing["id"]}
|
||||
cur = conn.execute(
|
||||
"INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, stadt, verified_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, datetime('now'))",
|
||||
(admin["id"], "Admin-Zwinger", "Alle Rassen", "Admin", "Überall")
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE users SET breeder_status='approved' WHERE id=?", (admin["id"],)
|
||||
)
|
||||
return {"message": "Admin-Züchterprofil angelegt.", "profile_id": cur.lastrowid}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUT /api/breeder/profile — eigenes Profil bearbeiten
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
497
backend/routes/breeder_export.py
Normal file
497
backend/routes/breeder_export.py
Normal 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 & 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"'},
|
||||
)
|
||||
|
|
@ -29,6 +29,8 @@ def _require_breeder(user=Depends(get_current_user)):
|
|||
class LitterCreate(BaseModel):
|
||||
vater_name: Optional[str] = None
|
||||
mutter_name: Optional[str] = None
|
||||
vater_id: Optional[int] = None # FK zucht_hunde
|
||||
mutter_id: Optional[int] = None # FK zucht_hunde
|
||||
geburt_datum: Optional[str] = None # YYYY-MM-DD
|
||||
erwartetes_datum: Optional[str] = None # YYYY-MM-DD
|
||||
welpen_gesamt: Optional[int] = None
|
||||
|
|
@ -44,6 +46,8 @@ class LitterCreate(BaseModel):
|
|||
class LitterUpdate(BaseModel):
|
||||
vater_name: Optional[str] = None
|
||||
mutter_name: Optional[str] = None
|
||||
vater_id: Optional[int] = None
|
||||
mutter_id: Optional[int] = None
|
||||
geburt_datum: Optional[str] = None
|
||||
erwartetes_datum: Optional[str] = None
|
||||
welpen_gesamt: Optional[int] = None
|
||||
|
|
@ -185,14 +189,17 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
|
|||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO litters
|
||||
(breeder_id, vater_name, mutter_name, geburt_datum, erwartetes_datum,
|
||||
(breeder_id, vater_name, mutter_name, vater_id, mutter_id,
|
||||
geburt_datum, erwartetes_datum,
|
||||
welpen_gesamt, welpen_verfuegbar, beschreibung, gesundheitstests,
|
||||
preis_spanne, status, sichtbar, sichtbar_bis)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
profile["id"],
|
||||
body.vater_name,
|
||||
body.mutter_name,
|
||||
body.vater_id,
|
||||
body.mutter_id,
|
||||
body.geburt_datum,
|
||||
body.erwartetes_datum,
|
||||
body.welpen_gesamt,
|
||||
|
|
@ -205,10 +212,78 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
|
|||
body.sichtbar_bis,
|
||||
)
|
||||
)
|
||||
litter_id = cur.lastrowid
|
||||
row = conn.execute(
|
||||
"SELECT * FROM litters WHERE id=?", (cur.lastrowid,)
|
||||
"SELECT * FROM litters WHERE id=?", (litter_id,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
# Tierschutz-Check
|
||||
from welfare_check import check_welfare
|
||||
welfare = check_welfare(
|
||||
conn, profile["id"],
|
||||
vater_id=body.vater_id,
|
||||
mutter_id=body.mutter_id,
|
||||
)
|
||||
# Welfare-Level speichern
|
||||
conn.execute(
|
||||
"UPDATE litters SET welfare_level=? WHERE id=?",
|
||||
(welfare["level"], litter_id)
|
||||
)
|
||||
|
||||
result = dict(row)
|
||||
result["welfare"] = welfare
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/litters/{id}/welfare-confirm — Tierschutz-Hinweis bestätigt
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/litters/{litter_id}/welfare-confirm")
|
||||
async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
|
||||
from mailer import send_email
|
||||
import os, logging as _log
|
||||
_logger = _log.getLogger(__name__)
|
||||
|
||||
with db() as conn:
|
||||
litter = _check_litter_owner(litter_id, user, conn)
|
||||
conn.execute(
|
||||
"UPDATE litters SET welfare_acknowledged=1 WHERE id=?", (litter_id,)
|
||||
)
|
||||
welfare_level = litter.get("welfare_level", "")
|
||||
|
||||
if welfare_level == "critical":
|
||||
# Admin benachrichtigen
|
||||
profile = conn.execute(
|
||||
"SELECT bp.zwingername, u.name, u.email "
|
||||
"FROM breeder_profiles bp JOIN users u ON u.id=bp.user_id "
|
||||
"WHERE bp.user_id=?", (user["id"],)
|
||||
).fetchone()
|
||||
admin_email = os.getenv("ADMIN_EMAIL", "mail@motocamp.de")
|
||||
app_url = os.getenv("APP_URL", "https://banyaro.app")
|
||||
zuechter = profile["name"] if profile else user.get("name", "Unbekannt")
|
||||
zwinger = profile["zwingername"] if profile else "—"
|
||||
eltern = conn.execute(
|
||||
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
|
||||
).fetchone()
|
||||
html = f"""
|
||||
<h2>Tierschutz-Hinweis bestätigt</h2>
|
||||
<p>Züchter <b>{zuechter}</b> (Zwinger: {zwinger}) hat einen Wurf mit
|
||||
kritischen Tierschutz-Hinweisen trotzdem angelegt.</p>
|
||||
<p>Vater: {eltern['vater_name'] or '—'} · Mutter: {eltern['mutter_name'] or '—'}</p>
|
||||
<p>Wurf-ID: {litter_id}</p>
|
||||
<p><a href="{app_url}/admin">Im Admin-Bereich prüfen</a></p>
|
||||
"""
|
||||
try:
|
||||
await send_email(
|
||||
admin_email,
|
||||
f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}",
|
||||
html,
|
||||
f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.",
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning(f"Tierschutz-Admin-Mail fehlgeschlagen: {e}")
|
||||
|
||||
return {"message": "Bestätigt."}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -356,10 +356,53 @@ async def trial_mating(body: TrialMatingBody, user=Depends(_require_breeder)):
|
|||
|
||||
gemeinsame_vorfahren.sort(key=lambda x: x["gen_vater"] + x["gen_mutter"])
|
||||
|
||||
# Genetische Risiken für Welfare-Check
|
||||
genetic_risks = []
|
||||
try:
|
||||
vater_gen = conn.execute(
|
||||
"SELECT marker_name, ergebnis_klasse FROM dog_genetic_tests WHERE hund_id=?", (body.vater_id,)
|
||||
).fetchall()
|
||||
mutter_gen = conn.execute(
|
||||
"SELECT marker_name, ergebnis_klasse FROM dog_genetic_tests WHERE hund_id=?", (body.mutter_id,)
|
||||
).fetchall()
|
||||
mutter_map = {r["marker_name"]: r["ergebnis_klasse"] for r in mutter_gen}
|
||||
RISIKO = {
|
||||
("carrier","carrier"): "25% betroffen, 50% Träger",
|
||||
("carrier","affected"): "50% betroffen, 50% Träger",
|
||||
("affected","carrier"): "50% betroffen, 50% Träger",
|
||||
("affected","affected"): "100% betroffen",
|
||||
("clear","affected"): "0% betroffen, 100% Träger",
|
||||
("affected","clear"): "0% betroffen, 100% Träger",
|
||||
}
|
||||
for vg in vater_gen:
|
||||
ms = mutter_map.get(vg["marker_name"])
|
||||
if ms:
|
||||
risk = RISIKO.get((vg["ergebnis_klasse"], ms))
|
||||
if risk:
|
||||
genetic_risks.append({"marker": vg["marker_name"], "offspring_risk": risk})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Züchter-Profil für Welfare
|
||||
profile = conn.execute(
|
||||
"SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)
|
||||
).fetchone()
|
||||
bid = profile["id"] if profile else None
|
||||
|
||||
from welfare_check import check_welfare
|
||||
welfare = check_welfare(
|
||||
conn, bid or 0,
|
||||
vater_id=body.vater_id,
|
||||
mutter_id=body.mutter_id,
|
||||
ik_prozent=ik_prozent,
|
||||
genetic_risks=genetic_risks,
|
||||
)
|
||||
|
||||
return {
|
||||
"ik_prozent": ik_prozent,
|
||||
"ik_rating": rating,
|
||||
"gemeinsame_vorfahren": gemeinsame_vorfahren,
|
||||
"welfare": welfare,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
468
backend/routes/zucht_ki.py
Normal file
468
backend/routes/zucht_ki.py
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
"""BAN YARO — KI-Features für Züchter (5 Endpunkte)"""
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Literal
|
||||
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
import ki
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FALLBACK = "KI-Analyse momentan nicht verfügbar. Bitte versuche es später erneut."
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dependency: nur verifizierte Züchter + Admins
|
||||
# ------------------------------------------------------------------
|
||||
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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ------------------------------------------------------------------
|
||||
class WurfankuendigungBody(BaseModel):
|
||||
litter_id: int
|
||||
|
||||
|
||||
class GenetikErklaerungBody(BaseModel):
|
||||
litter_id: int
|
||||
zielgruppe: Literal["kaeufer", "zuechter"] = "kaeufer"
|
||||
|
||||
|
||||
class PaarungAnalyseBody(BaseModel):
|
||||
vater_id: int
|
||||
mutter_id: int
|
||||
ik_prozent: Optional[float] = None
|
||||
welfare_level: Optional[str] = None
|
||||
|
||||
|
||||
class HundBeschreibungBody(BaseModel):
|
||||
hund_id: int
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hilfsfunktionen: DB-Daten laden
|
||||
# ------------------------------------------------------------------
|
||||
def _load_hund(conn, hund_id: int) -> dict:
|
||||
row = conn.execute("SELECT * FROM zucht_hunde WHERE id=?", (hund_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Hund {hund_id} nicht gefunden.")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _load_gesundheitstests(conn, hund_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT test_typ, test_name, ergebnis, untersuch_am FROM dog_health_tests WHERE hund_id=?",
|
||||
(hund_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _load_gentests(conn, hund_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT marker_name, marker_kategorie, genotyp, ergebnis_klasse FROM dog_genetic_tests WHERE hund_id=?",
|
||||
(hund_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _load_titel(conn, hund_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT titel_typ, titel_name, verliehen_am FROM dog_titles WHERE hund_id=?",
|
||||
(hund_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _fmt_gesundheit(tests: list[dict]) -> str:
|
||||
if not tests:
|
||||
return " (keine Einträge)"
|
||||
return "\n".join(f" - {t['test_name'] or t['test_typ']}: {t['ergebnis']} ({t['untersuch_am']})" for t in tests)
|
||||
|
||||
|
||||
def _fmt_gentests(tests: list[dict]) -> str:
|
||||
if not tests:
|
||||
return " (keine Einträge)"
|
||||
return "\n".join(f" - {t['marker_name']} ({t['marker_kategorie'] or ''}): {t['genotyp']} — {t['ergebnis_klasse'] or ''}" for t in tests)
|
||||
|
||||
|
||||
def _fmt_titel(titel: list[dict]) -> str:
|
||||
if not titel:
|
||||
return " (keine Titel)"
|
||||
return "\n".join(f" - {t['titel_name']} ({t['titel_typ']}, {t['verliehen_am']})" for t in titel)
|
||||
|
||||
|
||||
def _hund_block(hund: dict, gesundheit: list, gentests: list, titel: list, label: str) -> str:
|
||||
return (
|
||||
f"=== {label} ===\n"
|
||||
f"Name: {hund.get('name')} (Rufname: {hund.get('rufname') or '—'})\n"
|
||||
f"Geschlecht: {hund.get('geschlecht') or '—'}\n"
|
||||
f"Zuchtbuchnummer: {hund.get('zuchtbuchnummer') or '—'}\n"
|
||||
f"Geburtsdatum: {hund.get('geburtsdatum') or '—'}\n"
|
||||
f"Farbe: {hund.get('farbe') or '—'}\n"
|
||||
f"\nGesundheitstests:\n{_fmt_gesundheit(gesundheit)}\n"
|
||||
f"\nGentests:\n{_fmt_gentests(gentests)}\n"
|
||||
f"\nTitel:\n{_fmt_titel(titel)}"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. POST /api/zucht-ki/wurfankuendigung
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/zucht-ki/wurfankuendigung")
|
||||
async def wurfankuendigung(body: WurfankuendigungBody, user=Depends(_require_breeder)):
|
||||
if not user.get("ki_zucht_wurfankuendigung", 1):
|
||||
raise HTTPException(403, "KI-Feature 'Wurfankündigung' ist für diesen Account deaktiviert.")
|
||||
|
||||
with db() as conn:
|
||||
wurf = conn.execute("SELECT * FROM litters WHERE id=?", (body.litter_id,)).fetchone()
|
||||
if not wurf:
|
||||
raise HTTPException(404, "Wurf nicht gefunden.")
|
||||
wurf = dict(wurf)
|
||||
|
||||
vater = _load_hund(conn, wurf["vater_id"]) if wurf.get("vater_id") else None
|
||||
mutter = _load_hund(conn, wurf["mutter_id"]) if wurf.get("mutter_id") else None
|
||||
|
||||
vater_gesundheit = _load_gesundheitstests(conn, vater["id"]) if vater else []
|
||||
vater_gentests = _load_gentests(conn, vater["id"]) if vater else []
|
||||
vater_titel = _load_titel(conn, vater["id"]) if vater else []
|
||||
|
||||
mutter_gesundheit = _load_gesundheitstests(conn, mutter["id"]) if mutter else []
|
||||
mutter_gentests = _load_gentests(conn, mutter["id"]) if mutter else []
|
||||
mutter_titel = _load_titel(conn, mutter["id"]) if mutter else []
|
||||
|
||||
# Elternteil-Blöcke aufbauen
|
||||
vater_block = (
|
||||
_hund_block(vater, vater_gesundheit, vater_gentests, vater_titel, "VATER")
|
||||
if vater
|
||||
else f"=== VATER ===\nName: {wurf.get('vater_name') or '—'} (nicht in Zuchtkartei)"
|
||||
)
|
||||
mutter_block = (
|
||||
_hund_block(mutter, mutter_gesundheit, mutter_gentests, mutter_titel, "MUTTER")
|
||||
if mutter
|
||||
else f"=== MUTTER ===\nName: {wurf.get('mutter_name') or '—'} (nicht in Zuchtkartei)"
|
||||
)
|
||||
|
||||
system = (
|
||||
"Du bist ein Experte für Hundezucht und hilfst Züchtern dabei, "
|
||||
"professionelle Texte für ihre Wurfbörse zu erstellen. "
|
||||
"Antworte ausschließlich auf Deutsch."
|
||||
)
|
||||
prompt = f"""
|
||||
Schreibe eine professionelle, einladende Wurfankündigung für die Wurfbörse einer Hunde-App.
|
||||
Basiere dich NUR auf den gegebenen Daten. Max. 4 kurze Absätze.
|
||||
Ton: sachlich und herzlich. Keine Übertreibungen. Auf Deutsch.
|
||||
|
||||
=== WURF-DATEN ===
|
||||
Geburtsdatum: {wurf.get('geburt_datum') or wurf.get('erwartetes_datum') or '—'}
|
||||
Welpen gesamt: {wurf.get('welpen_gesamt') or '—'}
|
||||
Preis: {wurf.get('preis_spanne') or '—'}
|
||||
Beschreibung: {wurf.get('beschreibung') or '—'}
|
||||
|
||||
{vater_block}
|
||||
|
||||
{mutter_block}
|
||||
"""
|
||||
try:
|
||||
text = await ki.complete(
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
max_tokens=600,
|
||||
requires_premium=False,
|
||||
user_id=user["id"],
|
||||
)
|
||||
return {"text": text}
|
||||
except Exception as e:
|
||||
logger.warning(f"KI nicht verfügbar: {e}")
|
||||
return {"text": _FALLBACK}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. POST /api/zucht-ki/genetik-erklaerung
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/zucht-ki/genetik-erklaerung")
|
||||
async def genetik_erklaerung(body: GenetikErklaerungBody, user=Depends(_require_breeder)):
|
||||
if not user.get("ki_zucht_genetik", 1):
|
||||
raise HTTPException(403, "KI-Feature 'Genetik-Erklärung' ist für diesen Account deaktiviert.")
|
||||
|
||||
with db() as conn:
|
||||
wurf = conn.execute("SELECT * FROM litters WHERE id=?", (body.litter_id,)).fetchone()
|
||||
if not wurf:
|
||||
raise HTTPException(404, "Wurf nicht gefunden.")
|
||||
wurf = dict(wurf)
|
||||
|
||||
vater = _load_hund(conn, wurf["vater_id"]) if wurf.get("vater_id") else None
|
||||
mutter = _load_hund(conn, wurf["mutter_id"]) if wurf.get("mutter_id") else None
|
||||
|
||||
vater_gentests = _load_gentests(conn, vater["id"]) if vater else []
|
||||
mutter_gentests = _load_gentests(conn, mutter["id"]) if mutter else []
|
||||
|
||||
# Risiko-Marker sammeln (vereinfacht: alles was nicht "frei" ist)
|
||||
risks = []
|
||||
for t in vater_gentests + mutter_gentests:
|
||||
klasse = (t.get("ergebnis_klasse") or "").lower()
|
||||
if klasse and klasse not in ("frei", "clear", "negativ", ""):
|
||||
risks.append(f"{t['marker_name']}: {t['ergebnis_klasse']}")
|
||||
|
||||
vater_gt_block = _fmt_gentests(vater_gentests)
|
||||
mutter_gt_block = _fmt_gentests(mutter_gentests)
|
||||
|
||||
if body.zielgruppe == "kaeufer":
|
||||
anweisung = (
|
||||
"Erkläre diese Gentestergebnisse für einen Welpen-Käufer ohne Fachkenntnisse. "
|
||||
"Beantworte konkret: Was bedeutet das für meinen Welpen im Alltag? "
|
||||
"Welche Vorsichtsmaßnahmen gibt es? Max. 200 Wörter, sehr verständlich."
|
||||
)
|
||||
else:
|
||||
anweisung = (
|
||||
"Analysiere diese Gentestergebnisse aus züchterischer Sicht. "
|
||||
"Bewerte mögliche genetische Kombinationen beim Nachwuchs. "
|
||||
"Welche Marker sind kritisch? Was bedeutet das für künftige Paarungen? "
|
||||
"Max. 200 Wörter, fachlich präzise."
|
||||
)
|
||||
|
||||
system = (
|
||||
"Du bist ein Experte für Hundegenetik und Tierschutz. "
|
||||
"Antworte ausschließlich auf Deutsch."
|
||||
)
|
||||
prompt = f"""
|
||||
{anweisung}
|
||||
|
||||
=== GENTESTS VATER ({(vater or {}).get('name', wurf.get('vater_name', '—'))}) ===
|
||||
{vater_gt_block}
|
||||
|
||||
=== GENTESTS MUTTER ({(mutter or {}).get('name', wurf.get('mutter_name', '—'))}) ===
|
||||
{mutter_gt_block}
|
||||
"""
|
||||
try:
|
||||
text = await ki.complete(
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
max_tokens=500,
|
||||
requires_premium=False,
|
||||
user_id=user["id"],
|
||||
)
|
||||
return {"text": text, "risks": risks}
|
||||
except Exception as e:
|
||||
logger.warning(f"KI nicht verfügbar: {e}")
|
||||
return {"text": _FALLBACK, "risks": risks}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. POST /api/zucht-ki/paarung-analyse
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/zucht-ki/paarung-analyse")
|
||||
async def paarung_analyse(body: PaarungAnalyseBody, user=Depends(_require_breeder)):
|
||||
if not user.get("ki_zucht_paarung", 1):
|
||||
raise HTTPException(403, "KI-Feature 'Paarungs-Analyse' ist für diesen Account deaktiviert.")
|
||||
|
||||
with db() as conn:
|
||||
vater = _load_hund(conn, body.vater_id)
|
||||
mutter = _load_hund(conn, body.mutter_id)
|
||||
|
||||
vater_gesundheit = _load_gesundheitstests(conn, body.vater_id)
|
||||
vater_gentests = _load_gentests(conn, body.vater_id)
|
||||
vater_titel = _load_titel(conn, body.vater_id)
|
||||
|
||||
mutter_gesundheit = _load_gesundheitstests(conn, body.mutter_id)
|
||||
mutter_gentests = _load_gentests(conn, body.mutter_id)
|
||||
mutter_titel = _load_titel(conn, body.mutter_id)
|
||||
|
||||
ik_info = f"{body.ik_prozent:.1f}%" if body.ik_prozent is not None else "nicht berechnet"
|
||||
welfare_info = body.welfare_level or "nicht angegeben"
|
||||
|
||||
system = (
|
||||
"Du bist ein erfahrener Zuchtwart und Experte für verantwortungsvolle Hundezucht. "
|
||||
"Antworte ausschließlich auf Deutsch."
|
||||
)
|
||||
prompt = f"""
|
||||
Bewerte diese Hundepaarung aus züchterischer Sicht.
|
||||
Berücksichtige: Inzuchtkoeffizient, Gesundheitsprofil, Ausstellungserfolge, Stärken und Schwächen.
|
||||
Gib eine konkrete Empfehlung. Max. 150 Wörter. Sachlich und direkt.
|
||||
|
||||
Inzuchtkoeffizient (IK): {ik_info}
|
||||
Welfare-Level: {welfare_info}
|
||||
|
||||
{_hund_block(vater, vater_gesundheit, vater_gentests, vater_titel, "VATER")}
|
||||
|
||||
{_hund_block(mutter, mutter_gesundheit, mutter_gentests, mutter_titel, "MUTTER")}
|
||||
"""
|
||||
try:
|
||||
text = await ki.complete(
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
max_tokens=400,
|
||||
requires_premium=False,
|
||||
user_id=user["id"],
|
||||
)
|
||||
|
||||
# Empfehlung aus Text ableiten
|
||||
text_lower = text.lower()
|
||||
if any(w in text_lower for w in ("nicht empfohlen", "abraten", "nicht zu empfehlen", "nicht empfehle")):
|
||||
empfehlung = "nicht_empfohlen"
|
||||
elif any(w in text_lower for w in ("bedingt", "vorbehalt", "einschränkung", "vorsicht")):
|
||||
empfehlung = "bedingt"
|
||||
else:
|
||||
empfehlung = "empfohlen"
|
||||
|
||||
return {"text": text, "empfehlung": empfehlung}
|
||||
except Exception as e:
|
||||
logger.warning(f"KI nicht verfügbar: {e}")
|
||||
return {"text": _FALLBACK, "empfehlung": "bedingt"}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. POST /api/zucht-ki/hund-beschreibung
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/zucht-ki/hund-beschreibung")
|
||||
async def hund_beschreibung(body: HundBeschreibungBody, user=Depends(_require_breeder)):
|
||||
if not user.get("ki_zucht_beschreibung", 1):
|
||||
raise HTTPException(403, "KI-Feature 'Hund-Beschreibung' ist für diesen Account deaktiviert.")
|
||||
|
||||
with db() as conn:
|
||||
hund = _load_hund(conn, body.hund_id)
|
||||
gesundheit = _load_gesundheitstests(conn, body.hund_id)
|
||||
gentests = _load_gentests(conn, body.hund_id)
|
||||
titel = _load_titel(conn, body.hund_id)
|
||||
|
||||
system = (
|
||||
"Du bist ein Experte für Hundezucht und hilfst Züchtern, "
|
||||
"ansprechende Profilbeschreibungen für ihre Hunde zu schreiben. "
|
||||
"Antworte ausschließlich auf Deutsch."
|
||||
)
|
||||
prompt = f"""
|
||||
Schreibe eine ansprechende Beschreibung für das öffentliche Hunde-Profil.
|
||||
Hebe Stärken hervor (Gesundheit, Titel, Charakter falls beschrieben).
|
||||
Max. 3 kurze Absätze. Professionell aber warmherzig. Keine Erfindungen.
|
||||
|
||||
{_hund_block(hund, gesundheit, gentests, titel, "HUND")}
|
||||
Notiz des Züchters: {hund.get('notiz') or '—'}
|
||||
"""
|
||||
try:
|
||||
text = await ki.complete(
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
max_tokens=500,
|
||||
requires_premium=False,
|
||||
user_id=user["id"],
|
||||
)
|
||||
return {"text": text}
|
||||
except Exception as e:
|
||||
logger.warning(f"KI nicht verfügbar: {e}")
|
||||
return {"text": _FALLBACK}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. POST /api/zucht-ki/jahresbericht
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/zucht-ki/jahresbericht")
|
||||
async def jahresbericht(user=Depends(_require_breeder)):
|
||||
if not user.get("ki_zucht_jahresbericht", 1):
|
||||
raise HTTPException(403, "KI-Feature 'Jahresbericht' ist für diesen Account deaktiviert.")
|
||||
|
||||
zwei_jahre_ago = (date.today() - timedelta(days=730)).isoformat()
|
||||
|
||||
with db() as conn:
|
||||
# Breeder-Profil
|
||||
bp = conn.execute(
|
||||
"SELECT id, zwingername, rasse_text FROM breeder_profiles WHERE user_id=?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if not bp:
|
||||
raise HTTPException(404, "Kein Züchter-Profil gefunden.")
|
||||
breeder_id = bp["id"]
|
||||
zwingername = bp["zwingername"] or "Unbekannt"
|
||||
rasse_text = bp["rasse_text"] or "—"
|
||||
|
||||
# Würfe der letzten 2 Jahre
|
||||
wuerfe = conn.execute(
|
||||
"""SELECT geburt_datum, welpen_gesamt, welpen_verfuegbar, status, welfare_level
|
||||
FROM litters
|
||||
WHERE breeder_id=? AND geburt_datum >= ?
|
||||
ORDER BY geburt_datum DESC""",
|
||||
(breeder_id, zwei_jahre_ago),
|
||||
).fetchall()
|
||||
wuerfe = [dict(w) for w in wuerfe]
|
||||
|
||||
# Eigene Zuchthunde
|
||||
hunde = conn.execute(
|
||||
"SELECT id, name, geschlecht FROM zucht_hunde WHERE breeder_id=?",
|
||||
(breeder_id,),
|
||||
).fetchall()
|
||||
hunde = [dict(h) for h in hunde]
|
||||
hund_ids = [h["id"] for h in hunde]
|
||||
|
||||
# Gesundheitstests aller Hunde aggregieren
|
||||
alle_gesundheitstests: list[dict] = []
|
||||
for hid in hund_ids:
|
||||
tests = _load_gesundheitstests(conn, hid)
|
||||
for t in tests:
|
||||
t["hund_id"] = hid
|
||||
alle_gesundheitstests.extend(tests)
|
||||
|
||||
# Zusammenfassung für den Prompt aufbauen
|
||||
wurf_zeilen = []
|
||||
for w in wuerfe:
|
||||
zeile = f" - {w['geburt_datum'] or '?'}: {w['welpen_gesamt'] or '?'} Welpen, Status: {w['status']}"
|
||||
if w.get("welfare_level"):
|
||||
zeile += f", Welfare: {w['welfare_level']}"
|
||||
wurf_zeilen.append(zeile)
|
||||
wuerfe_text = "\n".join(wurf_zeilen) if wurf_zeilen else " (keine Würfe im Zeitraum)"
|
||||
|
||||
# Gesundheitstrend: welche Tests wurden gemacht?
|
||||
test_typen: dict[str, int] = {}
|
||||
for t in alle_gesundheitstests:
|
||||
key = t.get("test_name") or t.get("test_typ") or "Unbekannt"
|
||||
test_typen[key] = test_typen.get(key, 0) + 1
|
||||
gesundheit_text = (
|
||||
"\n".join(f" - {name}: {anz}x" for name, anz in sorted(test_typen.items(), key=lambda x: -x[1]))
|
||||
if test_typen else " (keine Gesundheitstests erfasst)"
|
||||
)
|
||||
|
||||
hunde_text = (
|
||||
"\n".join(f" - {h['name']} ({h['geschlecht'] or '—'})" for h in hunde)
|
||||
if hunde else " (keine Hunde in der Zuchtkartei)"
|
||||
)
|
||||
|
||||
system = (
|
||||
"Du bist ein erfahrener Zuchtwart und Berater für verantwortungsvolle Hundezucht. "
|
||||
"Erstelle konstruktive, sachliche Auswertungen für Züchter auf Deutsch."
|
||||
)
|
||||
prompt = f"""
|
||||
Erstelle eine kurze züchterische Jahresauswertung.
|
||||
Analysiere: Anzahl Würfe, Gesundheitstrends, Stärken und Verbesserungspotenziale.
|
||||
Max. 200 Wörter. Konstruktiv und sachlich. Mit 2-3 konkreten Empfehlungen.
|
||||
|
||||
Zwingername: {zwingername}
|
||||
Rasse: {rasse_text}
|
||||
Zeitraum: letzte 2 Jahre (bis {date.today().isoformat()})
|
||||
|
||||
=== WÜRFE ===
|
||||
{wuerfe_text}
|
||||
|
||||
=== ZUCHTHUNDE ===
|
||||
{hunde_text}
|
||||
|
||||
=== GESUNDHEITSTESTS (Häufigkeit) ===
|
||||
{gesundheit_text}
|
||||
"""
|
||||
try:
|
||||
text = await ki.complete(
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
max_tokens=600,
|
||||
requires_premium=False,
|
||||
user_id=user["id"],
|
||||
)
|
||||
return {"text": text}
|
||||
except Exception as e:
|
||||
logger.warning(f"KI nicht verfügbar: {e}")
|
||||
return {"text": _FALLBACK}
|
||||
|
|
@ -177,6 +177,9 @@
|
|||
<rect width="256" height="256" fill="none"/>
|
||||
<path d="M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM152,160H136v16a8,8,0,0,1-16,0V160H104a8,8,0,0,1,0-16h16V128a8,8,0,0,1,16,0v16h16a8,8,0,0,1,0,16ZM48,80V48H72v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24V80Z"/>
|
||||
</symbol>
|
||||
<symbol id="heart-fill" viewBox="0 0 256 256">
|
||||
<path d="M240,102c0,70-103.79,126.66-108.21,129a8,8,0,0,1-7.58,0C119.79,228.66,16,172,16,102A62.07,62.07,0,0,1,78,40c20.65,0,38.73,8.88,50,23.89C139.27,48.88,157.35,40,178,40A62.07,62.07,0,0,1,240,102Z"/>
|
||||
</symbol>
|
||||
<symbol id="tree-structure" viewBox="0 0 256 256">
|
||||
<path d="M144,96V80H128a8,8,0,0,0-8,8v80a8,8,0,0,0,8,8h16V160a16,16,0,0,1,16-16h48a16,16,0,0,1,16,16v48a16,16,0,0,1-16,16H160a16,16,0,0,1-16-16V192H128a24,24,0,0,1-24-24V136H72v8a16,16,0,0,1-16,16H24A16,16,0,0,1,8,144V112A16,16,0,0,1,24,96H56a16,16,0,0,1,16,16v8h32V88a24,24,0,0,1,24-24h16V48a16,16,0,0,1,16-16h48a16,16,0,0,1,16,16V96a16,16,0,0,1-16,16H160A16,16,0,0,1,144,96Z"/>
|
||||
</symbol>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
|
@ -615,6 +615,7 @@ const API = (() => {
|
|||
profile(zwingername) { return get(`/breeder/profil/${encodeURIComponent(zwingername)}`); },
|
||||
mapMarkers() { return get('/breeder/map'); },
|
||||
updateProfile(data) { return put('/breeder/profile', data); },
|
||||
adminCreateProfile() { return post('/admin/breeder/create-profile', {}); },
|
||||
pendingList() { return get('/admin/breeders/pending'); },
|
||||
documents(userId) { return get(`/admin/breeder/${userId}/documents`); },
|
||||
documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; },
|
||||
|
|
@ -631,6 +632,7 @@ const API = (() => {
|
|||
create(data) { return post('/litters', data); },
|
||||
update(id, data) { return put(`/litters/${id}`, data); },
|
||||
remove(id) { return del(`/litters/${id}`); },
|
||||
welfareConfirm(id) { return post(`/litters/${id}/welfare-confirm`, {}); },
|
||||
// Welpen
|
||||
puppies(id) { return get(`/litters/${id}/puppies`); },
|
||||
addPuppy(id, data) { return post(`/litters/${id}/puppies`, data); },
|
||||
|
|
@ -653,43 +655,51 @@ const API = (() => {
|
|||
remove(id) { return del(`/breeder/photos/${id}`); },
|
||||
};
|
||||
|
||||
// Öffentliche API
|
||||
return {
|
||||
get, post, put, patch, del, upload,
|
||||
auth, dogs, diary, health, tieraerzte, poison,
|
||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
||||
// ----------------------------------------------------------
|
||||
// ZUCHTKARTEI (Hunde-Stammdaten, Gesundheit, Genetik, Titel)
|
||||
// ----------------------------------------------------------
|
||||
const zuchthunde = {
|
||||
// Hunde
|
||||
list() { return get('/zuchthunde'); },
|
||||
get(id) { return get(`/zuchthunde/${id}`); },
|
||||
create(data) { return post('/zuchthunde', data); },
|
||||
update(id, data) { return put(`/zuchthunde/${id}`, data); },
|
||||
remove(id) { return del(`/zuchthunde/${id}`); },
|
||||
pedigree(id, gen=4) { return get(`/zuchthunde/${id}/pedigree?generations=${gen}`); },
|
||||
// Gesundheitstests
|
||||
healthTests(id) { return get(`/zuchthunde/${id}/health-tests`); },
|
||||
addHealthTest(id, data) { return post(`/zuchthunde/${id}/health-tests`, data); },
|
||||
updateHealthTest(tid, data) { return put(`/zuchthunde/health-tests/${tid}`, data); },
|
||||
deleteHealthTest(tid) { return del(`/zuchthunde/health-tests/${tid}`); },
|
||||
// Gentests
|
||||
geneticTests(id) { return get(`/zuchthunde/${id}/genetic-tests`); },
|
||||
addGeneticTest(id, data) { return post(`/zuchthunde/${id}/genetic-tests`, data); },
|
||||
updateGeneticTest(tid, data) { return put(`/zuchthunde/genetic-tests/${tid}`, data); },
|
||||
deleteGeneticTest(tid) { return del(`/zuchthunde/genetic-tests/${tid}`); },
|
||||
// Titel
|
||||
titles(id) { return get(`/zuchthunde/${id}/titles`); },
|
||||
addTitle(id, data) { return post(`/zuchthunde/${id}/titles`, data); },
|
||||
updateTitle(tid, data) { return put(`/zuchthunde/titles/${tid}`, data); },
|
||||
deleteTitle(tid) { return del(`/zuchthunde/titles/${tid}`); },
|
||||
// Probeverpaarung
|
||||
trialMating(vaterId, mutterId) { return post('/zuchthunde/trial-mating', { vater_id: vaterId, mutter_id: mutterId }); },
|
||||
};
|
||||
|
||||
breeder, litters, breederPhotos, zuchthunde,
|
||||
// ----------------------------------------------------------
|
||||
// ZÜCHTER-KI
|
||||
// ----------------------------------------------------------
|
||||
const zuchtKi = {
|
||||
wurfankuendigung(litterId) { return post('/zucht-ki/wurfankuendigung', { litter_id: litterId }); },
|
||||
genetikErklaerung(litterId, ziel) { return post('/zucht-ki/genetik-erklaerung', { litter_id: litterId, zielgruppe: ziel }); },
|
||||
paarungAnalyse(vaterId, mutterId, ik, welfareLevel) {
|
||||
return post('/zucht-ki/paarung-analyse', { vater_id: vaterId, mutter_id: mutterId, ik_prozent: ik, welfare_level: welfareLevel });
|
||||
},
|
||||
hundBeschreibung(hundId) { return post('/zucht-ki/hund-beschreibung', { hund_id: hundId }); },
|
||||
jahresbericht() { return post('/zucht-ki/jahresbericht', {}); },
|
||||
};
|
||||
|
||||
// Öffentliche API
|
||||
return {
|
||||
get, post, put, patch, del, upload,
|
||||
auth, dogs, diary, health, tieraerzte, poison,
|
||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
||||
breeder, litters, breederPhotos, zuchthunde, zuchtKi,
|
||||
subscribeToPush, getLocation, clientNow,
|
||||
APIError,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '444'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '451'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
|
||||
const App = (() => {
|
||||
|
||||
|
|
|
|||
|
|
@ -192,6 +192,13 @@ window.Page_litters = (() => {
|
|||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.litters-ki-announce-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.dataset.id);
|
||||
_showKiAnnouncement(id);
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.litters-add-puppy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.dataset.id);
|
||||
|
|
@ -249,6 +256,11 @@ window.Page_litters = (() => {
|
|||
title="Elterntier-Fotos verwalten">
|
||||
${UI.icon('users')} Eltern
|
||||
</button>
|
||||
${_appState.user?.ki_zucht_wurfankuendigung !== 0 ? `
|
||||
<button class="btn btn-ghost btn-sm litters-ki-announce-btn" data-id="${l.id}"
|
||||
title="KI: Wurfankündigung schreiben">
|
||||
${UI.icon('sparkle')} Ankündigung
|
||||
</button>` : ''}
|
||||
<button class="btn btn-ghost btn-sm litters-edit-btn" data-id="${l.id}"
|
||||
title="Bearbeiten">
|
||||
${UI.icon('pencil-simple')}
|
||||
|
|
@ -477,24 +489,42 @@ window.Page_litters = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// Wurf-Formular (neu / bearbeiten)
|
||||
// ----------------------------------------------------------
|
||||
function _showLitterForm(litter) {
|
||||
async function _showLitterForm(litter) {
|
||||
const isEdit = !!litter;
|
||||
const v = litter || {};
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Zuchtkartei laden für Elterntier-Auswahl
|
||||
let zuchthunde = [];
|
||||
try { zuchthunde = await API.zuchthunde.list(); } catch {}
|
||||
const maennlich = zuchthunde.filter(h => h.geschlecht !== 'weiblich');
|
||||
const weiblich = zuchthunde.filter(h => h.geschlecht !== 'maennlich');
|
||||
|
||||
const buildSelect = (name, idName, list, currentId, currentName, placeholder) => {
|
||||
const opts = list.map(h => {
|
||||
const label = h.name + (h.rufname ? ` (${h.rufname})` : '') + (h.zuchtbuchnummer ? ` · ${h.zuchtbuchnummer}` : '');
|
||||
return `<option value="${h.id}" data-name="${_esc(h.name)}" ${currentId == h.id ? 'selected' : ''}>${_esc(label)}</option>`;
|
||||
}).join('');
|
||||
return `
|
||||
<select class="form-control" name="${idName}" id="${idName}-sel" style="margin-bottom:var(--space-2)">
|
||||
<option value="">— ${placeholder} —</option>
|
||||
${opts}
|
||||
</select>
|
||||
<input class="form-control" type="text" name="${name}" id="${name}-txt"
|
||||
value="${_esc(currentName || '')}" placeholder="oder Namen frei eingeben">`;
|
||||
};
|
||||
|
||||
const body = `
|
||||
<form id="litter-form" autocomplete="off">
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Vatername</label>
|
||||
<input class="form-control" type="text" name="vater_name"
|
||||
value="${_esc(v.vater_name || '')}" placeholder="Name des Vaters">
|
||||
<label class="form-label">Vater</label>
|
||||
${buildSelect('vater_name', 'vater_id', maennlich, v.vater_id, v.vater_name, 'Aus Zuchtkartei')}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Muttername</label>
|
||||
<input class="form-control" type="text" name="mutter_name"
|
||||
value="${_esc(v.mutter_name || '')}" placeholder="Name der Mutter">
|
||||
<label class="form-label">Mutter</label>
|
||||
${buildSelect('mutter_name', 'mutter_id', weiblich, v.mutter_id, v.mutter_name, 'Aus Zuchtkartei')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -583,6 +613,16 @@ window.Page_litters = (() => {
|
|||
|
||||
document.getElementById('lf-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
// Auto-Fill: Dropdown → Namenfeld befüllen
|
||||
['vater', 'mutter'].forEach(role => {
|
||||
document.getElementById(`${role}_id-sel`)?.addEventListener('change', e => {
|
||||
const sel = e.target;
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
const txt = document.getElementById(`${role}_name-txt`);
|
||||
if (txt) txt.value = opt.value ? (opt.dataset.name || '') : '';
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('litter-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('lf-submit');
|
||||
|
|
@ -591,6 +631,8 @@ window.Page_litters = (() => {
|
|||
const payload = {
|
||||
vater_name: fd.get('vater_name')?.trim() || null,
|
||||
mutter_name: fd.get('mutter_name')?.trim() || null,
|
||||
vater_id: fd.get('vater_id') ? parseInt(fd.get('vater_id')) : null,
|
||||
mutter_id: fd.get('mutter_id') ? parseInt(fd.get('mutter_id')) : null,
|
||||
geburt_datum: fd.get('geburt_datum') || null,
|
||||
erwartetes_datum: fd.get('erwartetes_datum') || null,
|
||||
welpen_gesamt: fd.get('welpen_gesamt') ? parseInt(fd.get('welpen_gesamt')) : null,
|
||||
|
|
@ -608,14 +650,16 @@ window.Page_litters = (() => {
|
|||
const updated = await API.litters.update(litter.id, payload);
|
||||
const idx = _litters.findIndex(l => l.id === litter.id);
|
||||
if (idx !== -1) _litters[idx] = updated;
|
||||
UI.modal.close();
|
||||
UI.toast.success('Wurf aktualisiert.');
|
||||
_renderList();
|
||||
} else {
|
||||
const created = await API.litters.create(payload);
|
||||
_litters.unshift(created);
|
||||
UI.toast.success('Wurf angelegt.');
|
||||
}
|
||||
UI.modal.close();
|
||||
_renderList();
|
||||
_showWelfareModal(created.welfare, created.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -967,6 +1011,133 @@ window.Page_litters = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Tierschutz-Check Modal
|
||||
// ----------------------------------------------------------
|
||||
function _showWelfareModal(welfare, litterId) {
|
||||
if (!welfare) return;
|
||||
|
||||
const color = { ok: '#16a34a', info: '#3b82f6', warning: '#f59e0b', critical: '#dc2626' }[welfare.level] || '#6b7280';
|
||||
const title = { ok: 'Alles prima', info: 'Hinweis', warning: 'Bitte beachten', critical: 'Kritischer Hinweis' }[welfare.level];
|
||||
const icon = { ok: 'check-circle', info: 'info', warning: 'warning', critical: 'warning-circle' }[welfare.level];
|
||||
|
||||
const issueHTML = (welfare.issues || []).map(i => `
|
||||
<div style="display:flex;gap:8px;padding:8px 0;border-bottom:1px solid rgba(0,0,0,.06)">
|
||||
<span style="color:${color};flex-shrink:0">${UI.icon('warning')}</span>
|
||||
<span style="font-size:var(--text-sm)">${_esc(i.text)}</span>
|
||||
</div>`).join('');
|
||||
|
||||
const okHTML = (welfare.ok_points || []).map(p => `
|
||||
<div style="display:flex;gap:8px;padding:4px 0">
|
||||
<span style="color:#16a34a;flex-shrink:0">${UI.icon('check')}</span>
|
||||
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(p)}</span>
|
||||
</div>`).join('');
|
||||
|
||||
const isProblematic = welfare.level === 'warning' || welfare.level === 'critical';
|
||||
|
||||
UI.modal.open({
|
||||
title: `<span style="color:${color}">${UI.icon(icon)} Tierschutz-Check: ${title}</span>`,
|
||||
body: `
|
||||
<div style="background:${color}18;border:1.5px solid ${color}40;border-radius:var(--radius-md);
|
||||
padding:var(--space-4);margin-bottom:var(--space-4)">
|
||||
${issueHTML || ''}
|
||||
${okHTML}
|
||||
</div>
|
||||
${welfare.level === 'critical' ? `
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
background:var(--c-surface-2);border-radius:var(--radius-sm);
|
||||
padding:var(--space-3)">
|
||||
${UI.icon('info')} Wenn du fortfährst, wird der Administrator informiert.
|
||||
</div>` : ''}
|
||||
`,
|
||||
footer: isProblematic ? `
|
||||
<div style="display:flex;gap:var(--space-2);width:100%">
|
||||
<button class="btn btn-secondary flex-1" id="welfare-back-btn">
|
||||
${UI.icon('arrow-left')} Zurück
|
||||
</button>
|
||||
<button class="btn btn-ghost flex-1" id="welfare-confirm-btn"
|
||||
style="color:${color}">
|
||||
Trotzdem fortfahren
|
||||
</button>
|
||||
</div>` : `
|
||||
<button class="btn btn-primary" data-modal-close style="width:100%">
|
||||
${UI.icon('check')} Verstanden
|
||||
</button>`,
|
||||
});
|
||||
|
||||
document.getElementById('welfare-back-btn')?.addEventListener('click', () => {
|
||||
UI.modal.close?.();
|
||||
const litter = _litters.find(l => l.id === litterId);
|
||||
API.litters.remove(litterId).catch(() => {});
|
||||
_litters = _litters.filter(l => l.id !== litterId);
|
||||
_renderList();
|
||||
setTimeout(() => _showLitterForm(null), 150);
|
||||
});
|
||||
|
||||
document.getElementById('welfare-confirm-btn')?.addEventListener('click', async () => {
|
||||
await API.litters.welfareConfirm(litterId).catch(() => {});
|
||||
UI.modal.close?.();
|
||||
UI.toast.info('Wurf gespeichert.');
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// KI: Wurfankündigung
|
||||
// ----------------------------------------------------------
|
||||
async function _showKiAnnouncement(litterId) {
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
|
||||
body: `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">
|
||||
KI schreibt Wurfankündigung…
|
||||
</p>`,
|
||||
footer: '',
|
||||
});
|
||||
|
||||
let text = '';
|
||||
try {
|
||||
const result = await API.zuchtKi.wurfankuendigung(litterId);
|
||||
text = result.text || result.content || result.ankuendigung || JSON.stringify(result);
|
||||
} catch (err) {
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
|
||||
body: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
|
||||
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
|
||||
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary flex-1" id="ki-announce-copy">
|
||||
${UI.icon('clipboard-text')} Kopieren
|
||||
</button>
|
||||
<button class="btn btn-primary flex-1" id="ki-announce-use">
|
||||
${UI.icon('check')} In Beschreibung übernehmen
|
||||
</button>`,
|
||||
});
|
||||
|
||||
document.getElementById('ki-announce-copy')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
UI.toast.success('Text kopiert.');
|
||||
} catch {
|
||||
UI.toast.error('Kopieren nicht möglich.');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('ki-announce-use')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('ki-announce-use');
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.litters.update(litterId, { beschreibung: text });
|
||||
UI.modal.close();
|
||||
UI.toast.success('Beschreibung aktualisiert.');
|
||||
await _loadLitters();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -685,6 +685,30 @@ window.Page_settings = (() => {
|
|||
_loadBreederCard();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 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>${_esc(label)}</span>
|
||||
<button class="by-toggle ki-toggle-btn" data-key="${_esc(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
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -722,7 +746,30 @@ window.Page_settings = (() => {
|
|||
${rolle === 'breeder' && profile ? `
|
||||
<button class="btn btn-secondary btn-sm" id="breeder-edit-profile-btn" style="margin-top:var(--space-3)">
|
||||
${UI.icon('pencil-simple')} Profil bearbeiten
|
||||
</button>` : ''}`;
|
||||
</button>` : ''}
|
||||
${rolle === 'admin' && !profile ? `
|
||||
<button class="btn btn-primary btn-sm" id="breeder-admin-create-btn" style="margin-top:var(--space-3)">
|
||||
${UI.icon('plus')} Admin-Züchterprofil anlegen
|
||||
</button>` : ''}
|
||||
${rolle === 'admin' && profile ? `
|
||||
<button class="btn btn-secondary btn-sm" id="breeder-edit-profile-btn" style="margin-top:var(--space-3)">
|
||||
${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>` : ''}`;
|
||||
} else if (breeder_status === 'pending') {
|
||||
statusBadge = `<span class="badge" style="background:#f59e0b;color:#fff">
|
||||
${UI.icon('hourglass')} Antrag wird geprüft
|
||||
|
|
@ -770,6 +817,48 @@ window.Page_settings = (() => {
|
|||
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.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -108,8 +108,16 @@ window.Page_zuchthunde = (() => {
|
|||
${UI.icon('plus')} Hund anlegen
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" id="zh-trial-btn">
|
||||
${UI.icon('dna')} Probeverpaarung
|
||||
${UI.icon('heart-fill')} Probeverpaarung
|
||||
</button>
|
||||
<a href="/api/breeder/export" download class="btn btn-ghost btn-sm" id="zh-export-btn"
|
||||
title="Alle Daten herunterladen (HTML + ODS)">
|
||||
${UI.icon('download-simple')} Export
|
||||
</a>
|
||||
${_appState?.user?.ki_zucht_jahresbericht !== 0 ? `
|
||||
<a class="btn btn-ghost btn-sm" id="zh-jahresbericht-btn">
|
||||
${UI.icon('chart-bar')} Jahresbericht
|
||||
</a>` : ''}
|
||||
</div>
|
||||
<div style="padding:0 0 var(--space-3)">
|
||||
<input class="form-control" id="zh-search" type="search"
|
||||
|
|
@ -123,6 +131,7 @@ window.Page_zuchthunde = (() => {
|
|||
|
||||
document.getElementById('zh-new-btn')?.addEventListener('click', () => _showHundForm(null));
|
||||
document.getElementById('zh-trial-btn')?.addEventListener('click', () => _showTrialMatingModal());
|
||||
document.getElementById('zh-jahresbericht-btn')?.addEventListener('click', () => _showJahresbericht());
|
||||
|
||||
document.getElementById('zh-search')?.addEventListener('input', e => {
|
||||
_query = e.target.value.toLowerCase().trim();
|
||||
|
|
@ -215,6 +224,13 @@ window.Page_zuchthunde = (() => {
|
|||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.zh-ki-desc-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.dataset.id);
|
||||
_showKiDesc(id);
|
||||
});
|
||||
});
|
||||
|
||||
// Offene Sektionen wiederherstellen
|
||||
Object.entries(_openSections).forEach(([id, sec]) => {
|
||||
if (sec) _openSection(parseInt(id), sec);
|
||||
|
|
@ -259,6 +275,10 @@ window.Page_zuchthunde = (() => {
|
|||
title="Stammbaum">
|
||||
${UI.icon('tree-structure')} Stammbaum
|
||||
</button>
|
||||
${_appState.user?.ki_zucht_beschreibung !== 0 ? `
|
||||
<button class="btn btn-ghost btn-sm zh-ki-desc-btn" data-id="${h.id}">
|
||||
${UI.icon('sparkle')} Beschreibung
|
||||
</button>` : ''}
|
||||
<button class="btn btn-ghost btn-sm zh-link-btn" data-id="${h.id}"
|
||||
title="Profil-Link kopieren">
|
||||
${UI.icon('link-simple')}
|
||||
|
|
@ -1134,6 +1154,39 @@ window.Page_zuchthunde = (() => {
|
|||
}).join('')
|
||||
: `<li style="color:var(--c-text-muted)">Keine gemeinsamen Vorfahren gefunden.</li>`;
|
||||
|
||||
const welfare = result.welfare;
|
||||
let welfareHTML = '';
|
||||
if (welfare) {
|
||||
const wColor = { ok: '#16a34a', info: '#3b82f6', warning: '#f59e0b', critical: '#dc2626' }[welfare.level] || '#6b7280';
|
||||
const wTitle = { ok: 'Alles prima', info: 'Hinweis', warning: 'Bitte beachten', critical: 'Kritischer Hinweis' }[welfare.level];
|
||||
const wIcon = { ok: 'check-circle', info: 'info', warning: 'warning', critical: 'warning-circle' }[welfare.level];
|
||||
|
||||
const wIssueHTML = (welfare.issues || []).map(i => `
|
||||
<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid rgba(0,0,0,.06)">
|
||||
<span style="color:${wColor};flex-shrink:0">${UI.icon('warning')}</span>
|
||||
<span style="font-size:var(--text-sm)">${_esc(i.text)}</span>
|
||||
</div>`).join('');
|
||||
|
||||
const wOkHTML = (welfare.ok_points || []).map(p => `
|
||||
<div style="display:flex;gap:8px;padding:4px 0">
|
||||
<span style="color:#16a34a;flex-shrink:0">${UI.icon('check')}</span>
|
||||
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(p)}</span>
|
||||
</div>`).join('');
|
||||
|
||||
welfareHTML = `
|
||||
<div>
|
||||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);margin-bottom:var(--space-2);
|
||||
color:${wColor}">
|
||||
${UI.icon(wIcon)} Tierschutz-Check: ${wTitle}
|
||||
</div>
|
||||
<div style="background:${wColor}18;border:1.5px solid ${wColor}40;border-radius:var(--radius-md);
|
||||
padding:var(--space-3)">
|
||||
${wIssueHTML || ''}
|
||||
${wOkHTML}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const body = `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);
|
||||
|
|
@ -1149,6 +1202,7 @@ window.Page_zuchthunde = (() => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${welfareHTML}
|
||||
<div>
|
||||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);margin-bottom:var(--space-2)">
|
||||
Gemeinsame Vorfahren
|
||||
|
|
@ -1159,11 +1213,22 @@ window.Page_zuchthunde = (() => {
|
|||
</div>
|
||||
</div>`;
|
||||
|
||||
const kiPaarungBtn = _appState?.user?.ki_zucht_paarung !== 0
|
||||
? `<button type="button" class="btn btn-secondary btn-sm" id="trial-ki-btn">
|
||||
${UI.icon('sparkle')} KI-Analyse anfordern
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
const footer = `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
${kiPaarungBtn}
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<button type="button" class="btn btn-secondary flex-1" id="zhresult-back">
|
||||
${UI.icon('arrow-left')} Zurück
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary flex-1" id="zhresult-close">Schließen</button>`;
|
||||
<button type="button" class="btn btn-primary flex-1" id="zhresult-close">Schließen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('dna')} Ergebnis Probeverpaarung`,
|
||||
|
|
@ -1173,6 +1238,164 @@ window.Page_zuchthunde = (() => {
|
|||
|
||||
document.getElementById('zhresult-close')?.addEventListener('click', UI.modal.close);
|
||||
document.getElementById('zhresult-back')?.addEventListener('click', () => _showTrialMatingModal());
|
||||
document.getElementById('trial-ki-btn')?.addEventListener('click', () => _showKiPaarung(result));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// KI: Hund-Beschreibung
|
||||
// ----------------------------------------------------------
|
||||
async function _showKiDesc(hundId) {
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('sparkle')} KI-Hunde-Beschreibung`,
|
||||
body: `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">KI erstellt Beschreibung…</p>`,
|
||||
footer: '',
|
||||
});
|
||||
|
||||
let text = '';
|
||||
try {
|
||||
const result = await API.zuchtKi.hundBeschreibung(hundId);
|
||||
text = result.text || result.content || result.beschreibung || JSON.stringify(result);
|
||||
} catch (err) {
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('sparkle')} KI-Hunde-Beschreibung`,
|
||||
body: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
|
||||
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('sparkle')} KI-Hunde-Beschreibung`,
|
||||
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary flex-1" id="ki-desc-copy">
|
||||
${UI.icon('clipboard-text')} Kopieren
|
||||
</button>
|
||||
<button class="btn btn-primary flex-1" data-modal-close>Schließen</button>`,
|
||||
});
|
||||
|
||||
document.getElementById('ki-desc-copy')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
UI.toast.success('Text kopiert.');
|
||||
} catch {
|
||||
UI.toast.error('Kopieren nicht möglich.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// KI: Jahresbericht
|
||||
// ----------------------------------------------------------
|
||||
async function _showJahresbericht() {
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('chart-bar')} KI-Jahresbericht`,
|
||||
body: `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">KI analysiert deine Zuchtkartei…</p>`,
|
||||
footer: '',
|
||||
});
|
||||
|
||||
let text = '';
|
||||
try {
|
||||
const result = await API.zuchtKi.jahresbericht();
|
||||
text = result.text || result.content || result.bericht || JSON.stringify(result);
|
||||
} catch (err) {
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('chart-bar')} KI-Jahresbericht`,
|
||||
body: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
|
||||
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('chart-bar')} KI-Jahresbericht`,
|
||||
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary flex-1" id="ki-bericht-copy">
|
||||
${UI.icon('clipboard-text')} Kopieren
|
||||
</button>
|
||||
<button class="btn btn-primary flex-1" data-modal-close>Schließen</button>`,
|
||||
});
|
||||
|
||||
document.getElementById('ki-bericht-copy')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
UI.toast.success('Bericht kopiert.');
|
||||
} catch {
|
||||
UI.toast.error('Kopieren nicht möglich.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// KI: Paarungsanalyse
|
||||
// ----------------------------------------------------------
|
||||
async function _showKiPaarung(trialResult) {
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('sparkle')} KI-Paarungsanalyse`,
|
||||
body: `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">KI analysiert die Verpaarung…</p>`,
|
||||
footer: '',
|
||||
});
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await API.zuchtKi.paarungAnalyse(
|
||||
trialResult.vater_id,
|
||||
trialResult.mutter_id,
|
||||
trialResult.ik_prozent,
|
||||
trialResult.welfare?.level
|
||||
);
|
||||
} catch (err) {
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('sparkle')} KI-Paarungsanalyse`,
|
||||
body: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
|
||||
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const empfehlung = result.empfehlung || result.recommendation || '';
|
||||
const text = result.text || result.content || result.analyse || JSON.stringify(result);
|
||||
|
||||
const empfehlungColor = {
|
||||
empfohlen: '#16a34a',
|
||||
bedingt: '#f59e0b',
|
||||
nicht_empfohlen: '#dc2626',
|
||||
}[empfehlung] || '#6b7280';
|
||||
|
||||
const empfehlungLabel = {
|
||||
empfohlen: 'Empfohlen',
|
||||
bedingt: 'Bedingt empfohlen',
|
||||
nicht_empfohlen: 'Nicht empfohlen',
|
||||
}[empfehlung] || empfehlung;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('sparkle')} KI-Paarungsanalyse`,
|
||||
body: `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||||
${empfehlung ? `
|
||||
<div style="padding:var(--space-3);border-radius:var(--radius-md);
|
||||
background:${empfehlungColor}18;border:1.5px solid ${empfehlungColor}40;
|
||||
font-weight:var(--weight-semibold);color:${empfehlungColor}">
|
||||
${UI.icon('check-circle')} ${_esc(empfehlungLabel)}
|
||||
</div>` : ''}
|
||||
<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>
|
||||
</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary flex-1" id="ki-paarung-copy">
|
||||
${UI.icon('clipboard-text')} Kopieren
|
||||
</button>
|
||||
<button class="btn btn-primary flex-1" data-modal-close>Schließen</button>`,
|
||||
});
|
||||
|
||||
document.getElementById('ki-paarung-copy')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
UI.toast.success('Analyse kopiert.');
|
||||
} catch {
|
||||
UI.toast.error('Kopieren nicht möglich.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ban Yaro — Die deutschsprachige Hunde-Plattform</title>
|
||||
<meta name="description" content="Ban Yaro ist die kostenlose All-in-One Hunde-App für Deutschland, Österreich und die Schweiz. Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting, Trainings-Tracker — DSGVO-konform, ohne App Store.">
|
||||
<title>Ban Yaro — Hunde-App für Besitzer, Züchter & Welpen-Käufer</title>
|
||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für Deutschland, Österreich und die Schweiz. Tagebuch, Impfpass, Wurfbörse, Stammbaum, Inzucht-Check, Giftköder-Alarm — DSGVO-konform, ohne App Store.">
|
||||
<meta name="keywords" content="Hunde App, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="https://banyaro.app/info">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Ban Yaro — Die deutschsprachige Hunde-Plattform">
|
||||
<meta property="og:description" content="Alles rund um deinen Hund — Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting. Kostenlos, DSGVO-konform, ohne App Store.">
|
||||
<meta property="og:title" content="Ban Yaro — Hunde-App für Besitzer, Züchter & Welpen-Käufer">
|
||||
<meta property="og:description" content="Tagebuch, Impfpass, Wurfbörse, Stammbaum, Inzucht-Koeffizient, Giftköder-Alarm, Gassi-Community — alles in einer DSGVO-konformen App ohne App Store.">
|
||||
<meta property="og:url" content="https://banyaro.app/info">
|
||||
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
|
||||
<meta property="og:locale" content="de_DE">
|
||||
|
|
@ -19,8 +20,8 @@
|
|||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Ban Yaro — Die deutschsprachige Hunde-Plattform">
|
||||
<meta name="twitter:description" content="Alles rund um deinen Hund — Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community. Kostenlos, DSGVO-konform.">
|
||||
<meta name="twitter:title" content="Ban Yaro — Hunde-App für Besitzer, Züchter & Welpen-Käufer">
|
||||
<meta name="twitter:description" content="Wurfbörse, Stammbaum, Inzucht-Koeffizient, Tierschutz-Check — und alles rund um deinen Hund. Kostenlos, DSGVO-konform.">
|
||||
<meta name="twitter:image" content="https://banyaro.app/icons/icon-512.png">
|
||||
|
||||
<!-- Structured Data -->
|
||||
|
|
@ -30,7 +31,7 @@
|
|||
"@type": "MobileApplication",
|
||||
"name": "Ban Yaro",
|
||||
"alternateName": "Ban Yaro — Die Hunde-Plattform",
|
||||
"description": "Ban Yaro ist die kostenlose, deutschsprachige All-in-One Hunde-App. Digitales Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting und mehr — DSGVO-konform, ohne App Store.",
|
||||
"description": "Ban Yaro ist die kostenlose, deutschsprachige All-in-One Hunde-App für Hundebesitzer und Züchter. Tagebuch, Impfpass, Wurfbörse, Stammbaum, Inzucht-Koeffizient, Tierschutz-Check, Giftköder-Alarm, Gassi-Community — DSGVO-konform, ohne App Store.",
|
||||
"url": "https://banyaro.app",
|
||||
"applicationCategory": "LifestyleApplication",
|
||||
"applicationSubCategory": "PetApplication",
|
||||
|
|
@ -69,11 +70,19 @@
|
|||
"Virtueller KI-Trainer mit täglichen Übungsempfehlungen und Fortschrittsprognose",
|
||||
"Wöchentlicher KI-Lober — jeden Montag 2-3 Sätze Lob für die Vorwoche",
|
||||
"Trainings-Gamification: Streaks, Abzeichen, Trainingskalender",
|
||||
"Kommandos & Fähigkeiten im Hundeprofil — praktisch für Hundesitter"
|
||||
"Kommandos & Fähigkeiten im Hundeprofil — praktisch für Hundesitter",
|
||||
"Wurfbörse — öffentliche Wurfankündigungen mit Filtersuche nach Rasse und Status",
|
||||
"Züchter-Profile mit verifizierten Gesundheitstests und Gentests",
|
||||
"Stammbaum-Visualisierung bis 4 Generationen",
|
||||
"Inzucht-Koeffizient nach Wright's Formel mit Ampel-Bewertung",
|
||||
"Probeverpaarung mit IK-Simulation und genetischer Risikoanalyse",
|
||||
"Tierschutz-Check automatisch bei jeder Verpaarung — nicht abschaltbar",
|
||||
"KI-Züchter-Assistenz: Wurfankündigungen, Genetik-Erklärung, Paarungsanalyse",
|
||||
"Datenexport als HTML und ODS — keine Datenfalle"
|
||||
],
|
||||
"screenshot": "https://banyaro.app/icons/icon-512.png",
|
||||
"softwareVersion": "2.0",
|
||||
"datePublished": "2026-04-25",
|
||||
"softwareVersion": "2.1",
|
||||
"datePublished": "2026-04-28",
|
||||
"areaServed": ["DE", "AT", "CH"],
|
||||
"audience": {
|
||||
"@type": "Audience",
|
||||
|
|
@ -386,6 +395,7 @@
|
|||
<div class="container">
|
||||
<span class="nav-brand">Ban Yaro</span>
|
||||
<a href="#funktionen">Funktionen</a>
|
||||
<a href="#zuechter">Züchter</a>
|
||||
<a href="#vergleich">Vergleich</a>
|
||||
<a href="#preise">Preise</a>
|
||||
<a href="#warum">Warum Ban Yaro?</a>
|
||||
|
|
@ -516,6 +526,67 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-group">
|
||||
<div class="feature-group-label">Für Züchter</div>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🐾</span>
|
||||
<div><h3>Wurfbörse</h3><p>Öffentliche Wurfankündigungen mit Filter nach Rasse und Status. Interessenten schreiben direkt per Nachricht an. Für Käufer kostenlos.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🌳</span>
|
||||
<div><h3>Stammbaum</h3><p>4 Generationen visuell dargestellt. Klickbare Knoten öffnen das Hunde-Profil. Teilen per Link für Käufer-Dokumentation.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🧬</span>
|
||||
<div><h3>Inzucht-Koeffizient</h3><p>Automatische Berechnung nach Wright's Formel. Ampel-Bewertung: optimal unter 2,5%, kritisch ab 12,5%. Probeverpaarung simuliert jeden beliebigen Anpaarungspartner.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🩺</span>
|
||||
<div><h3>Gesundheitsdokumentation</h3><p>HD, ED, Augen, Herz, DNA-Tests — alle Nachweise strukturiert erfasst. Farbcodierte Ergebnis-Badges auf einen Blick.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🛡️</span>
|
||||
<div><h3>Tierschutz-Check</h3><p>Automatische Prüfung bei jeder Verpaarung: Alter, Wurfhäufigkeit, Deckpause, genetische Risiken. Nicht abschaltbar — weil die Tiere zählen.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🤖</span>
|
||||
<div><h3>KI-Assistenz</h3><p>Wurfankündigungen schreiben, Genetik-Erklärungen für Käufer formulieren, Paarungsanalyse mit Empfehlung, Jahresauswertung. Nutzt Claude Sonnet direkt.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">📊</span>
|
||||
<div><h3>Datenexport</h3><p>Alle Zuchtkartei-Daten als HTML-Dossier (druckbar, mit Stammbaum-Visualisierung) und ODS-Tabelle (editierbar in LibreOffice/Excel). Keine Datenfalle.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">📄</span>
|
||||
<div><h3>Kaufvertrag</h3><p>Automatisch ausgefüllter Kaufvertrag pro Welpe als druckbares Dokument — mit Chip-Nummer, Geburtsdatum, Käufer- und Züchterdaten.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="zuechter" style="background: linear-gradient(135deg, #7c3aed08 0%, #a78bfa10 100%); border-top: 1px solid #ede9fe; border-bottom: 1px solid #ede9fe;">
|
||||
<div class="container">
|
||||
<h2>Die Plattform für verantwortungsvolle Züchter</h2>
|
||||
<p class="section-intro">Ban Yaro ist die erste Hunde-App die Zucht-Management, Tierschutz-Checks und KI-Assistenz in einer Plattform verbindet — gedacht für Züchter die ihre Tiere ernst nehmen.</p>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 2rem; margin-top: 2rem;">
|
||||
|
||||
<div style="background: white; border-radius: 12px; padding: 1.5rem; border: 1px solid #ede9fe;">
|
||||
<div style="font-size: 2rem; margin-bottom: 0.75rem;">🐕🦺</div>
|
||||
<h3 style="color: #7c3aed; margin-bottom: 0.5rem;">Für Käufer</h3>
|
||||
<p style="color: #4b5563; font-size: 0.95rem; line-height: 1.6;">Finde deinen Welpen in der Wurfbörse mit vollem Einblick in Gesundheitstests, Gentests und Stammbaum der Eltern. Schreibe direkt mit dem Züchter. Keine versteckten Händler.</p>
|
||||
</div>
|
||||
|
||||
<div style="background: white; border-radius: 12px; padding: 1.5rem; border: 1px solid #ede9fe;">
|
||||
<div style="font-size: 2rem; margin-bottom: 0.75rem;">✅</div>
|
||||
<h3 style="color: #7c3aed; margin-bottom: 0.5rem;">Transparenz als Standard</h3>
|
||||
<p style="color: #4b5563; font-size: 0.95rem; line-height: 1.6;">Verifizierte Züchter-Profile mit öffentlich sichtbaren Gesundheitsdaten. Der Tierschutz-Check läuft bei jeder Verpaarung automatisch — Ergebnisse gehen direkt an den Admin wenn kritische Grenzen überschritten werden.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Ban Yaro — Die deutschsprachige Hunde-Plattform
|
||||
# https://banyaro.app
|
||||
# Letzte Aktualisierung: 2026-04-25
|
||||
# Letzte Aktualisierung: 2026-04-28
|
||||
|
||||
## Was ist Ban Yaro?
|
||||
|
||||
|
|
@ -15,11 +15,12 @@ Ban Yaro ist kostenlos nutzbar (Freemium-Modell). Die App ist auf allen Smartpho
|
|||
## Zielgruppe
|
||||
|
||||
- Deutschsprachige Hundebesitzer (Deutschland, Österreich, Schweiz)
|
||||
- Verantwortungsvolle Hundezüchter (VDH und andere Verbände)
|
||||
- Welpen-Interessenten und Käufer
|
||||
- Hundeschulen und Hundetrainer
|
||||
- Tierärzte und Praxen
|
||||
- Züchter
|
||||
|
||||
## Funktionen (aktuell verfügbar)
|
||||
## Funktionen
|
||||
|
||||
### Hunde-Profil & Tagebuch
|
||||
- Digitales Hunde-Profil (Name, Rasse, Geburtstag, Foto, Chip-Nummer)
|
||||
|
|
@ -37,175 +38,148 @@ Ban Yaro ist kostenlos nutzbar (Freemium-Modell). Die App ist auf allen Smartpho
|
|||
- Printbarer Heimtierausweis (PDF)
|
||||
|
||||
### Pflege-System
|
||||
- 43 rassenspezifische Pflegetipps in 10 Kategorien: Fell, Krallen, Zähne, Ohren, Augen, Pfoten, Parasiten, Saisonal, Gesundheitsvorsorge, Welpen-Pflege
|
||||
- Fell-Typ-Unterscheidung: kurz / lang / lockig / Doppelmantel
|
||||
- Unterscheidung Schneiden vs. Trimmen
|
||||
- Tipp des Tages automatisch nach Rasse und Fell-Typ ausgewählt
|
||||
- Rassen-Autocomplete im Profil verknüpft mit Pflege-Tipps
|
||||
- 43 rassenspezifische Pflegetipps in 10 Kategorien
|
||||
- Fell-Typ-Unterscheidung, Schneiden vs. Trimmen
|
||||
- Tipp des Tages automatisch nach Rasse ausgewählt
|
||||
|
||||
### Training & KI-Trainer
|
||||
- Tägliches Trainings-Tagebuch (Wiederholungen, Erfolgsquote 0–100%, Hundestimmung, Zufriedenheit)
|
||||
- Übungsfortschritt in 5 Stufen — von "noch nicht gezeigt" bis "sitzt sicher"
|
||||
- Virtueller KI-Trainer: analysiert letzte 20 Sessions, empfiehlt täglich welche Übungen anstehen
|
||||
- Fortschrittsprognose bis zur Meisterschaft (Trendanalyse)
|
||||
- 104 Übungen in 7 Kategorien
|
||||
- KI-Trainingsplan erstellen (Plus-Feature)
|
||||
- Trainingskalender im Habit-Tracker-Stil
|
||||
- Gamification: Streaks, Abzeichen, XP
|
||||
- Kommandos & Fähigkeiten sichtbar im Hunde-Profil (für Hundesitter)
|
||||
- Tägliches Trainings-Tagebuch (Wiederholungen, Erfolgsquote, Hundestimmung)
|
||||
- Übungsfortschritt in 5 Stufen, 104 Übungen in 7 Kategorien
|
||||
- Virtueller KI-Trainer: analysiert letzte 20 Sessions, tägliche Empfehlung
|
||||
- Fortschrittsprognose bis zur Meisterschaft
|
||||
- Gamification: Streaks, Abzeichen, Trainingskalender
|
||||
|
||||
### Wöchentlicher Lober (KI)
|
||||
- Jeden Montag schreibt die KI automatisch 2-3 Sätze Lob für die Trainingsvorwoche
|
||||
- Nur Lob, kein Rat, kein Druck — positive Bestärkung
|
||||
- Basiert auf den geloggten Trainingseinheiten der Vorwoche
|
||||
### Züchter-Plattform (vollständig)
|
||||
|
||||
### Wetter & Zecken-Warnung
|
||||
- Wetter-Chip direkt in der App (Open-Meteo API, ohne API-Key)
|
||||
- Zecken-Warnung regelbasiert: aktiv März–Oktober bei Temperatur >7°C
|
||||
- Push-Benachrichtigungen für Zecken-Saison
|
||||
Ban Yaro ist die erste Hunde-App mit vollständiger Züchter-Unterstützung:
|
||||
|
||||
### Giftköder-Alarm
|
||||
- Giftköder-Meldungen mit GPS-Koordinaten und Foto
|
||||
- Push-Benachrichtigung für alle Nutzer im konfigurierbaren Umkreis
|
||||
- Interaktive Karte (OpenStreetMap/Leaflet)
|
||||
- Automatisches Ablaufdatum nach 7 Tagen
|
||||
**Züchter-Verifizierung:**
|
||||
- Antrag mit Dokumenten-Upload (VDH-Ausweis, Zuchtzulassung)
|
||||
- Admin-Prüfung und Freischaltung
|
||||
- Verifiziertes Züchter-Profil mit öffentlicher Seite (banyaro.app/breeder/{zwingername})
|
||||
|
||||
### Sicherheit & Community-Alerts
|
||||
- Verlorener Hund: Alert mit Foto und letzter GPS-Position
|
||||
- Nearby-Alerts: Push-Benachrichtigungen für Ereignisse in der Nähe
|
||||
**Wurfbörse:**
|
||||
- Öffentliche Wurfankündigungen für alle Nutzer zugänglich (banyaro.app/wurfboerse)
|
||||
- Filtersuche nach Rasse und Status (geplant / verfügbar / geboren)
|
||||
- Käufer schreiben direkt per integriertem Chat an den Züchter
|
||||
- Vollständige Eltern-Dokumentation sichtbar: Gesundheitstests, Gentests, Stammbaum
|
||||
|
||||
### NFC-Halsband-Tags
|
||||
- Jeder Hund hat eine öffentliche URL (ohne Login sichtbar)
|
||||
- "Ich habe diesen Hund gefunden"-Button → Besitzer bekommt Push-Benachrichtigung
|
||||
- Notfallkontakt ohne Telefonnummer preiszugeben
|
||||
- Physische NFC-Tags erhältlich (Shop)
|
||||
**Wurfverwaltung:**
|
||||
- CRUD für Würfe und einzelne Welpen
|
||||
- Gewichtsverlauf pro Welpe
|
||||
- Foto-System mit Sichtbarkeits-Stufen: öffentlich / nach Anfrage / privat
|
||||
- Automatisch ausgefüllter Kaufvertrag als druckbares HTML-Dokument
|
||||
|
||||
### Gassi-Community
|
||||
- Gassi-Treffen erstellen und beitreten
|
||||
- GPS-Routen aufzeichnen und teilen (mit Anti-Cheat-Validierung)
|
||||
- Routen bewerten (Untergrund, Schatten, Leinenpflicht, Sicherheit)
|
||||
- Beliebte Routen entdecken
|
||||
**Zuchtkartei:**
|
||||
- Hunde-Stammdaten: Name, Rufname, Chip, Zuchtbuchnummer, Eltern (Vater/Mutter-Verknüpfung)
|
||||
- Gesundheitstests: HD, ED, OCD, Augen, Herz, Patella, ZTP — mit farbigen Ergebnis-Badges
|
||||
- Genetische Tests: MDR1, PRA, DM, vWD und weitere DNA-Marker (clear/carrier/affected)
|
||||
- Titel & Auszeichnungen: CAC, CACIB, BOB, IPO, BH — chronologisch mit Richter und Ort
|
||||
|
||||
### Hundesitting-Netzwerk
|
||||
- Sitter-Profile mit Erfahrung und Bewertungen
|
||||
- Buchungsanfragen und Kalender
|
||||
- Nur 8% Provision (vs. 20% bei Rover/Pawshake)
|
||||
- Bewertungen verifizierter Buchungen
|
||||
**Stammbaum:**
|
||||
- Visualisierung bis 4 Generationen als horizontales CSS-Grid
|
||||
- Klickbare Knoten navigieren zum jeweiligen Hunde-Profil
|
||||
- Teilen-Link für Käufer-Dokumentation
|
||||
- Öffentliches Hunde-Profil (banyaro.app/zucht-profil?id={id})
|
||||
|
||||
### Forum
|
||||
- Rassen-basierte Foren
|
||||
- KI-Zusammenfassung langer Threads
|
||||
- Experten-Badge (Tierarzt, Trainer)
|
||||
**Inzucht-Koeffizient:**
|
||||
- Automatische Berechnung nach Wright's Formel (bis 8 Generationen)
|
||||
- Ampel-Bewertung: optimal <2,5% / akzeptabel <6,25% / erhöht <12,5% / kritisch ≥12,5%
|
||||
- Probeverpaarung: simuliert beliebige Anpaarung ohne Speicherung
|
||||
|
||||
### Hunde-Wiki — Rassendatenbank
|
||||
- 1003 Hunderassen, 97,6% KI-angereichert via Wikipedia-grounded Recherche
|
||||
- Inhalte: Charakter, Größe, Aktivität, Eignung, Lebensdauer, Temperament
|
||||
- Community-Fotos im Wiki: User können Fotos einreichen (mit Bildrechte-Bestätigung)
|
||||
- Moderatoren geben Community-Fotos frei, anschließend Galerie-Ansicht
|
||||
- Wiki-Foto-Badge als Gamification-Belohnung für Foto-Einreicher
|
||||
- "Passt diese Rasse zu mir?" Quiz für angehende Hundebesitzer
|
||||
**Tierschutz-Check (immer aktiv, nicht abschaltbar):**
|
||||
- Läuft automatisch bei jeder Verpaarung und jedem neuen Wurf
|
||||
- Prüft: IK, Alter der Zuchthündin (min. 18 Monate), Deckpause (min. 12 Monate),
|
||||
Wurfanzahl (max. 4 empfohlen, kritisch ab 6), genetische Risiken
|
||||
- Farbcodierte Rückmeldung: grün (alles ok) / gelb (Hinweis) / rot (kritisch)
|
||||
- Bei "trotzdem fortfahren" auf rotem Befund: automatische Admin-Benachrichtigung
|
||||
- Philosophie: informieren statt blockieren, aber volle Transparenz und Accountability
|
||||
|
||||
### Hunde-Knigge
|
||||
- Ratgeber für Begegnungen (fremder Hund, Kinder, Radfahrer)
|
||||
- Regeln in ÖPNV und öffentlichen Orten
|
||||
- Haftpflicht-Ratgeber
|
||||
**KI-Züchter-Assistenz:**
|
||||
- Wurfankündigungen schreiben (KI generiert Text aus Eltern-Profilen)
|
||||
- Genetik-Erklärung für Käufer (verständliche Sprache) und Züchter (fachlich)
|
||||
- Paarungsanalyse mit Empfehlung (empfohlen / bedingt / nicht empfohlen)
|
||||
- Hunde-Beschreibungen für öffentliche Profile
|
||||
- Jahresbericht mit Trends und Empfehlungen
|
||||
- Privilegierte Rollen (Züchter, Moderatoren, Admins) nutzen Claude Sonnet direkt
|
||||
|
||||
### Events & Kultur
|
||||
- Agility-Turniere und Hundeausstellungen (VDH-Import)
|
||||
**Datenexport:**
|
||||
- Vollständiger Export als ZIP: HTML-Dossier (druckbar, Stammbaum-Visualisierung)
|
||||
und ODS-Tabelle (editierbar in LibreOffice/Excel)
|
||||
- 7 Tabellenblätter: Hunde, Gesundheitstests, Gentests, Titel, Würfe, Welpen, Gewichte
|
||||
- Keine Datenfalle: Züchter können jederzeit alle eigenen Daten exportieren
|
||||
|
||||
### Community-Features
|
||||
- Giftköder-Alarm mit Push-Benachrichtigungen
|
||||
- Verlorener Hund Alarm
|
||||
- Gassi-Treffen organisieren und finden
|
||||
- GPS-Routen aufzeichnen, teilen, bewerten
|
||||
- Hundesitting-Netzwerk (nur 8% Provision vs. 20% bei Rover/Pawshake)
|
||||
- Forum mit Rassen-basierten Unterforen
|
||||
- Direktnachrichten / Chat
|
||||
- Freundschaften und Nutzer-Profile
|
||||
|
||||
### Wissen
|
||||
- Hunde-Wiki: 1003 Hunderassen, Wikipedia-grounded, KI-angereichert
|
||||
- Community-Fotos mit Bildrechte-Bestätigung und Moderation
|
||||
- Hunde-Knigge (Begegnungen, ÖPNV, Haftpflicht)
|
||||
- Hundefilme-Datenbank mit "Stirbt der Hund?"-Rubrik
|
||||
- Veranstaltungskalender
|
||||
|
||||
### Hundefreundliche Orte
|
||||
- Crowd-sourced Datenbank hundefreundlicher Orte
|
||||
- Restaurants, Parks, Geschäfte
|
||||
- Detaillierte Bewertungen
|
||||
|
||||
### Gamification & Push
|
||||
- Badges, Streaks, XP — trägt zur Nutzerbindung bei
|
||||
- Wiki-Foto-Badge für Community-Foto-Beiträge
|
||||
- Push-Notifications für Alerts, Erinnerungen, Wöchentlicher Lober
|
||||
- Offline-Modus via Service Worker
|
||||
- Erste Hilfe Notfallratgeber
|
||||
|
||||
## KI-Integration
|
||||
|
||||
Ban Yaro nutzt KI an mehreren Stellen der Plattform:
|
||||
|
||||
- **Lokale KI**: LM Studio (Gemma-4-31B) auf eigenem Server — für datenschutzkritische Anfragen
|
||||
- **Cloud-KI**: Claude (Anthropic, Modell: claude-sonnet-4-6) als Fallback und für rechenintensive Aufgaben
|
||||
- **Symptom-Checker**: KI-gestützte Ersteinschätzung (kostenlos)
|
||||
- **Virtueller KI-Trainer**: Analysiert letzte 20 Trainings-Sessions, erstellt täglich priorisierte Übungsempfehlung
|
||||
- **Wöchentlicher Lober**: Vollautomatisch jeden Montag per APScheduler, lobt die Vorwoche in 2-3 Sätzen
|
||||
- **Breed-Enricher**: Wikipedia-grounded Anreicherung von 1003 Rassen-Datensätzen (97,6% abgeschlossen)
|
||||
- **KI-Trainingsplan** (Plus-Feature): Erstellt individuellen Trainingsplan auf Basis von Hund und Fortschritt
|
||||
Ban Yaro nutzt KI an mehreren Stellen:
|
||||
- **Privilegierte Nutzer** (Züchter, Moderatoren, Admins): Claude Sonnet (Anthropic) primär
|
||||
- **Standard-Nutzer**: Lokales LLM (LM Studio, Gemma-4-31B) primär, Claude als Fallback
|
||||
- **Tierschutz-Check**: Regelbasiert, keine KI — läuft immer zuverlässig
|
||||
- **Symptom-Checker, KI-Trainer, Lober**: Für alle kostenfrei
|
||||
- **Züchter-KI**: Wurfankündigungen, Genetik-Erklärungen, Paarungsanalyse, Jahresbericht
|
||||
|
||||
## Technologie
|
||||
|
||||
- Progressive Web App (PWA) — installierbar ohne App Store
|
||||
- Offline-fähig via Service Worker (Cache-Strategie mit Versionierung)
|
||||
- Backend: Python/FastAPI + SQLite
|
||||
- Frontend: Vanilla JS, kein Framework
|
||||
- Karten: Leaflet.js + OpenStreetMap (kein Google Maps, kein API-Key)
|
||||
- Wetter: Open-Meteo (kein API-Key erforderlich)
|
||||
- Karten: Leaflet.js + OpenStreetMap
|
||||
- Hosting: Deutschland (DSGVO-konform)
|
||||
- Analytics: Umami v2 (cookieless, DSGVO-konform)
|
||||
- KI lokal: LM Studio (Gemma-4-31B) auf eigenem Server
|
||||
- KI lokal: LM Studio (Gemma-4-31B)
|
||||
- KI Cloud: Claude API (claude-sonnet-4-6, Anthropic)
|
||||
- Push-Notifications: Web Push (VAPID)
|
||||
|
||||
## Monetarisierung
|
||||
|
||||
**Kostenlos (immer):**
|
||||
- Hunde-Profile
|
||||
- Tagebuch (unbegrenzte Einträge)
|
||||
- Pflege-System (43 rassenspezifische Tipps)
|
||||
- Symptom-Checker (KI)
|
||||
- Giftköder-Alarm & Zecken-Warnung
|
||||
- Verlorener Hund Alarm
|
||||
- Wiki & Knigge (1003 Rassen)
|
||||
- Training-Logging & KI-Trainer
|
||||
- Wöchentlicher Lober
|
||||
- Forum & Community
|
||||
- Gassi-Treffen & Routen
|
||||
- NFC-Halsband-Profil
|
||||
- Heimtierausweis (Druck)
|
||||
**Kostenlos:**
|
||||
- Alle Basis-Features inkl. Züchter-Antrag, Wurfverwaltung, Stammbaum, Tierschutz-Check
|
||||
|
||||
**Ban Yaro Plus (ca. 4,99 €/Monat) — in Entwicklung:**
|
||||
- Alles aus Kostenlos
|
||||
- KI-Trainingsplan erstellen
|
||||
- Erweiterte Statistiken & Fortschrittsanalyse
|
||||
**Züchter-Provision** (geplant): Wurfbörse bleibt für Käufer kostenlos
|
||||
|
||||
**Provisionen:**
|
||||
- Hundesitting: 8% Provision (Rover/Pawshake: 20%)
|
||||
**Ban Yaro Plus** (ca. 4,99 €/Monat, in Entwicklung):
|
||||
- KI-Trainingsplan, erweiterte Statistiken
|
||||
|
||||
**Physische Produkte:**
|
||||
- NFC-Halsband-Tags (ab ca. 6 €)
|
||||
**Hundesitting**: 8% Provision
|
||||
|
||||
## Community-Features
|
||||
## Öffentliche Seiten (ohne Login)
|
||||
|
||||
- Forum mit Rassen-basierten Unterforen
|
||||
- Community-Fotos im Rassen-Wiki (Einreichung, Moderation, Freigabe)
|
||||
- Gassi-Treffen organisieren und finden
|
||||
- GPS-Routen teilen und bewerten
|
||||
- Hundesitting-Netzwerk (Bewertungen, verifizierte Buchungen)
|
||||
- Gamification: Badges, Streaks, XP, Wiki-Foto-Badge
|
||||
- https://banyaro.app — Landing Page
|
||||
- https://banyaro.app/info — Landing Page (Alias)
|
||||
- https://banyaro.app/wiki/rassen — Alle Hunderassen
|
||||
- https://banyaro.app/wiki/rasse/{slug} — Rassen-Detail
|
||||
- https://banyaro.app/wurfboerse — Öffentliche Wurfbörse (Welpen suchen)
|
||||
- https://banyaro.app/breeder/{zwingername} — Öffentliches Züchter-Profil
|
||||
- https://banyaro.app/knigge — Hunde-Knigge
|
||||
- https://banyaro.app/hund/{id} — Öffentliches Hunde-Profil (NFC-Tag)
|
||||
|
||||
## Vergleich mit Konkurrenz
|
||||
## Öffentliche APIs
|
||||
|
||||
| Funktion | Ban Yaro | Dogorama | PetDesk | Tractive |
|
||||
|----------|----------|----------|---------|----------|
|
||||
| Kostenlos nutzbar | Ja | Begrenzt | Nein | Nein |
|
||||
| DSGVO / EU-Hosting | Ja | Nein | Nein | Teilweise |
|
||||
| Giftköder-Alarm | Ja | Nein | Nein | Nein |
|
||||
| Gassi-Community | Ja | Ja | Nein | Nein |
|
||||
| Hundesitting | Ja (8%) | Nein | Nein | Nein |
|
||||
| Digitaler Impfpass | Ja | Nein | Ja | Nein |
|
||||
| NFC-Halsband-Tag | Ja | Nein | Nein | Nein |
|
||||
| Pflege-Tipps rassenspezifisch | Ja | Nein | Nein | Nein |
|
||||
| Rassen-Wiki (1003, KI-angereichert) | Ja | Nein | Nein | Nein |
|
||||
| Symptom-Checker (kostenlos) | Ja | Nein | Nein | Nein |
|
||||
| Offline-Modus | Ja | Nein | Nein | Nein |
|
||||
| Kein App Store | Ja | Nein | Nein | Nein |
|
||||
| Sitting-Provision | 8% | – | – | – |
|
||||
- GET https://banyaro.app/api/wiki/rassen — Liste aller Hunderassen
|
||||
- GET https://banyaro.app/api/wiki/rassen/{slug} — Rassen-Detail
|
||||
- GET https://banyaro.app/api/litters — Öffentliche Wurfankündigungen
|
||||
- GET https://banyaro.app/api/breeder/profil/{zwingername} — Züchter-Profil
|
||||
- GET https://banyaro.app/api/events — Aktuelle Hundeevents
|
||||
- GET https://banyaro.app/api/poison — Aktuelle Giftköder-Meldungen
|
||||
- GET https://banyaro.app/api/lost — Aktuelle Vermisst-Meldungen
|
||||
- GET https://banyaro.app/api/knigge/articles — Hunde-Knigge Artikel
|
||||
- GET https://banyaro.app/api/stats — Community-Statistiken
|
||||
|
||||
## Domains
|
||||
|
||||
|
|
@ -216,14 +190,3 @@ Ban Yaro nutzt KI an mehreren Stellen der Plattform:
|
|||
|
||||
Website: https://banyaro.app
|
||||
E-Mail: Über das Kontaktformular in der App
|
||||
|
||||
## Öffentliche Daten-APIs (keine Authentifizierung nötig)
|
||||
|
||||
- GET https://banyaro.app/api/wiki/rassen — Liste aller Hunderassen (1003 Einträge)
|
||||
- GET https://banyaro.app/api/wiki/rassen/{slug} — Details zu einer Rasse
|
||||
- GET https://banyaro.app/api/events — Aktuelle Hundeevents
|
||||
- GET https://banyaro.app/api/poison — Aktuelle Giftköder-Meldungen
|
||||
- GET https://banyaro.app/api/lost — Aktuelle Vermisst-Meldungen
|
||||
- GET https://banyaro.app/api/knigge/articles — Hunde-Knigge Artikel
|
||||
- GET https://banyaro.app/api/movies/list — Hundefilme-Datenbank
|
||||
- GET https://banyaro.app/api/stats — Community-Statistiken
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ Allow: /info
|
|||
Allow: /wiki/rassen
|
||||
Allow: /wiki/rasse/
|
||||
Allow: /hund/
|
||||
Allow: /breeder/
|
||||
Allow: /wurfboerse
|
||||
Allow: /knigge
|
||||
Disallow: /api/
|
||||
Disallow: /ausweis/
|
||||
Disallow: /teilen/
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v465';
|
||||
const CACHE_VERSION = 'by-v474';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
|
|
|
|||
220
backend/welfare_check.py
Normal file
220
backend/welfare_check.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""BAN YARO — Tierschutz-Check für Züchter
|
||||
|
||||
Regelbasierte Prüfung bei Verpaarungen und Wurfanlage.
|
||||
Läuft immer automatisch, ist nicht abschaltbar.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# VDH-Richtwerte (Orientierung, nicht Gesetz)
|
||||
MIN_ALTER_MONATE = 18 # Mindestalter Zuchthündin
|
||||
MAX_ALTER_JAHRE = 8 # Empfohlenes Höchstalter
|
||||
MAX_WUERFE_LIFETIME = 4 # VDH-Empfehlung
|
||||
MIN_PAUSE_TAGE_WARN = 365 # 12 Monate Mindestpause (Warnung)
|
||||
MIN_PAUSE_TAGE_KRIT = 270 # 9 Monate (Kritisch)
|
||||
IK_WARN_PROZENT = 6.25
|
||||
IK_KRIT_PROZENT = 12.5
|
||||
|
||||
|
||||
def _level_max(a, b):
|
||||
order = {"ok": 0, "info": 1, "warning": 2, "critical": 3}
|
||||
return a if order.get(a, 0) >= order.get(b, 0) else b
|
||||
|
||||
|
||||
def check_welfare(conn, breeder_id: int,
|
||||
vater_id: int = None,
|
||||
mutter_id: int = None,
|
||||
ik_prozent: float = None,
|
||||
genetic_risks: list = None) -> dict:
|
||||
"""
|
||||
Gibt zurück:
|
||||
{
|
||||
"level": "ok" | "info" | "warning" | "critical",
|
||||
"issues": [{"code": str, "level": str, "text": str}],
|
||||
"ok_points": [str] # Positive Punkte
|
||||
}
|
||||
"""
|
||||
issues = []
|
||||
ok_pts = []
|
||||
level = "ok"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Inzuchtkoeffizient
|
||||
# ------------------------------------------------------------------
|
||||
if ik_prozent is not None:
|
||||
if ik_prozent >= IK_KRIT_PROZENT:
|
||||
issues.append({
|
||||
"code": "IK_KRITISCH",
|
||||
"level": "critical",
|
||||
"text": f"Inzuchtkoeffizient {ik_prozent:.1f}% — kritisch (≥{IK_KRIT_PROZENT}%). "
|
||||
"Das erhöht das Risiko für erbliche Erkrankungen und Vitalitätsverlust erheblich.",
|
||||
})
|
||||
elif ik_prozent >= IK_WARN_PROZENT:
|
||||
issues.append({
|
||||
"code": "IK_ERHOEHT",
|
||||
"level": "warning",
|
||||
"text": f"Inzuchtkoeffizient {ik_prozent:.1f}% — erhöht (≥{IK_WARN_PROZENT}%). "
|
||||
"VDH-Empfehlung liegt unter 6,25%.",
|
||||
})
|
||||
elif ik_prozent < 2.5:
|
||||
ok_pts.append(f"Sehr niedriger Inzuchtkoeffizient ({ik_prozent:.1f}%) — genetische Vielfalt ist gut.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Genetische Risiken (Träger × Träger / Affected)
|
||||
# ------------------------------------------------------------------
|
||||
if genetic_risks:
|
||||
for r in genetic_risks:
|
||||
if r.get("offspring_risk") and "betroffen" in r.get("offspring_risk", ""):
|
||||
pct_text = r["offspring_risk"]
|
||||
if "100%" in pct_text:
|
||||
issues.append({
|
||||
"code": f"GENETIK_KRITISCH_{r['marker']}",
|
||||
"level": "critical",
|
||||
"text": f"Genetisches Risiko {r['marker']}: {pct_text}. "
|
||||
"Alle Nachkommen wären betroffen.",
|
||||
})
|
||||
elif "50%" in pct_text:
|
||||
issues.append({
|
||||
"code": f"GENETIK_HOCH_{r['marker']}",
|
||||
"level": "critical",
|
||||
"text": f"Genetisches Risiko {r['marker']}: {pct_text}.",
|
||||
})
|
||||
elif "25%" in pct_text:
|
||||
issues.append({
|
||||
"code": f"GENETIK_WARN_{r['marker']}",
|
||||
"level": "warning",
|
||||
"text": f"Genetisches Risiko {r['marker']}: {pct_text}. "
|
||||
"Jeder 4. Welpe könnte betroffen sein.",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Zuchthündin-Checks
|
||||
# ------------------------------------------------------------------
|
||||
if mutter_id:
|
||||
try:
|
||||
hund = conn.execute(
|
||||
"SELECT name, geburtsdatum FROM zucht_hunde WHERE id=?", (mutter_id,)
|
||||
).fetchone()
|
||||
|
||||
if hund:
|
||||
hund_name = hund["name"] or "Zuchthündin"
|
||||
|
||||
# Alterscheck
|
||||
if hund["geburtsdatum"]:
|
||||
try:
|
||||
geb = date.fromisoformat(str(hund["geburtsdatum"])[:10])
|
||||
alter_tage = (date.today() - geb).days
|
||||
alter_monate = alter_tage / 30.44
|
||||
|
||||
if alter_monate < MIN_ALTER_MONATE:
|
||||
issues.append({
|
||||
"code": "MUTTER_ZU_JUNG",
|
||||
"level": "critical",
|
||||
"text": f"{hund_name} ist erst {int(alter_monate)} Monate alt. "
|
||||
f"Mindestalter für Erstzucht: {MIN_ALTER_MONATE} Monate. "
|
||||
"Frühzucht belastet Körper und Psyche des Tieres erheblich.",
|
||||
})
|
||||
elif alter_monate > MAX_ALTER_JAHRE * 12:
|
||||
issues.append({
|
||||
"code": "MUTTER_ZU_ALT",
|
||||
"level": "warning",
|
||||
"text": f"{hund_name} ist {int(alter_monate / 12)} Jahre alt "
|
||||
f"(empfohlenes Höchstalter: {MAX_ALTER_JAHRE} Jahre). "
|
||||
"Ältere Hündinnen tragen ein höheres Geburtsrisiko.",
|
||||
})
|
||||
else:
|
||||
ok_pts.append(f"{hund_name} ist in einem geeigneten Zuchtalter ({int(alter_monate)} Monate).")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Wurfanzahl
|
||||
wuerfe_gesamt = conn.execute(
|
||||
"SELECT COUNT(*) FROM litters WHERE breeder_id=? AND mutter_id=?",
|
||||
(breeder_id, mutter_id)
|
||||
).fetchone()[0]
|
||||
|
||||
if wuerfe_gesamt > 5:
|
||||
issues.append({
|
||||
"code": "ZU_VIELE_WUERFE",
|
||||
"level": "critical",
|
||||
"text": f"{hund_name} hatte bereits {wuerfe_gesamt} Würfe. "
|
||||
f"Die VDH-Empfehlung liegt bei maximal {MAX_WUERFE_LIFETIME} Würfen pro Hündin.",
|
||||
})
|
||||
elif wuerfe_gesamt >= MAX_WUERFE_LIFETIME:
|
||||
issues.append({
|
||||
"code": "WUERFE_GRENZE",
|
||||
"level": "warning",
|
||||
"text": f"{hund_name} hat bereits {wuerfe_gesamt} Würfe — "
|
||||
f"das entspricht der VDH-Empfehlung von max. {MAX_WUERFE_LIFETIME} Würfen.",
|
||||
})
|
||||
elif wuerfe_gesamt == 0:
|
||||
ok_pts.append(f"{hund_name} ist noch ohne Vorwürfe.")
|
||||
|
||||
# Pause seit letztem Wurf
|
||||
letzter = conn.execute(
|
||||
"SELECT MAX(geburt_datum) FROM litters "
|
||||
"WHERE breeder_id=? AND mutter_id=? AND geburt_datum IS NOT NULL",
|
||||
(breeder_id, mutter_id)
|
||||
).fetchone()[0]
|
||||
|
||||
if letzter:
|
||||
try:
|
||||
letzter_date = date.fromisoformat(str(letzter)[:10])
|
||||
abstand = (date.today() - letzter_date).days
|
||||
if abstand < MIN_PAUSE_TAGE_KRIT:
|
||||
issues.append({
|
||||
"code": "PAUSE_KRITISCH",
|
||||
"level": "critical",
|
||||
"text": f"Letzter Wurf von {hund_name} liegt erst {abstand} Tage zurück "
|
||||
f"({abstand // 30} Monate). "
|
||||
"Für die Erholung von Körper und Hormonhaushalt werden "
|
||||
"mindestens 12–15 Monate empfohlen.",
|
||||
})
|
||||
elif abstand < MIN_PAUSE_TAGE_WARN:
|
||||
issues.append({
|
||||
"code": "PAUSE_KURZ",
|
||||
"level": "warning",
|
||||
"text": f"Letzter Wurf von {hund_name}: {abstand // 30} Monate her. "
|
||||
"VDH empfiehlt mindestens 12–15 Monate Pause zwischen Würfen.",
|
||||
})
|
||||
else:
|
||||
ok_pts.append(f"Ausreichende Pause seit letztem Wurf von {hund_name} ({abstand // 30} Monate).")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Welfare-Check Mutter fehlgeschlagen: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Vater-Alterscheck (weniger kritisch, aber der Vollständigkeit halber)
|
||||
# ------------------------------------------------------------------
|
||||
if vater_id:
|
||||
try:
|
||||
rüde = conn.execute(
|
||||
"SELECT name, geburtsdatum FROM zucht_hunde WHERE id=?", (vater_id,)
|
||||
).fetchone()
|
||||
if rüde and rüde["geburtsdatum"]:
|
||||
geb = date.fromisoformat(str(rüde["geburtsdatum"])[:10])
|
||||
alter_monate = (date.today() - geb).days / 30.44
|
||||
if alter_monate < 12:
|
||||
issues.append({
|
||||
"code": "VATER_ZU_JUNG",
|
||||
"level": "warning",
|
||||
"text": f"Deckrüde {rüde['name']} ist erst {int(alter_monate)} Monate alt. "
|
||||
"Empfohlenes Mindestalter: 12 Monate.",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gesamtlevel
|
||||
# ------------------------------------------------------------------
|
||||
for issue in issues:
|
||||
level = _level_max(level, issue["level"])
|
||||
|
||||
if level == "ok" and not ok_pts:
|
||||
ok_pts.append("Alle geprüften Tierschutz-Kriterien sind erfüllt.")
|
||||
|
||||
return {"level": level, "issues": issues, "ok_points": ok_pts}
|
||||
Loading…
Add table
Add a link
Reference in a new issue