diff --git a/backend/database.py b/backend/database.py index c1b3f9f..2f8182d 100644 --- a/backend/database.py +++ b/backend/database.py @@ -549,6 +549,8 @@ def _migrate(conn_factory): ("notes", "location_name", "TEXT"), ("notes", "parent_label", "TEXT"), ("users", "notes_ki_enabled", "INTEGER NOT NULL DEFAULT 1"), + # Züchter-Rolle + ("users", "breeder_status", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1223,3 +1225,178 @@ def _migrate(conn_factory): conn.execute("CREATE INDEX IF NOT EXISTS idx_diary_created ON diary(dog_id, created_at DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(user_id, created_at DESC)") logger.info("Migration: Performance-Indizes bereit.") + + # Züchter-Tabellen + conn.executescript(""" + CREATE TABLE IF NOT EXISTS breeder_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + zwingername TEXT NOT NULL, + rasse_text TEXT NOT NULL, + verein TEXT NOT NULL, + vdh_mitglied INTEGER NOT NULL DEFAULT 0, + stadt TEXT NOT NULL, + website TEXT, + beschreibung TEXT, + location_lat REAL, + location_lng REAL, + verified_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS breeder_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dokument_typ TEXT NOT NULL, + file_path TEXT NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + logger.info("Migration: breeder_profiles + breeder_documents bereit.") + + # Würfe + Welpen + conn.executescript(""" + CREATE TABLE IF NOT EXISTS litters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + breeder_id INTEGER NOT NULL REFERENCES breeder_profiles(id) ON DELETE CASCADE, + vater_name TEXT, + mutter_name TEXT, + geburt_datum TEXT, + erwartetes_datum TEXT, + welpen_gesamt INTEGER, + welpen_verfuegbar INTEGER, + beschreibung TEXT, + gesundheitstests TEXT, + preis_spanne TEXT, + status TEXT NOT NULL DEFAULT 'geplant', + sichtbar INTEGER NOT NULL DEFAULT 1, + sichtbar_bis TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_litters_breeder ON litters(breeder_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_litters_status ON litters(status, sichtbar); + + CREATE TABLE IF NOT EXISTS puppies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wurf_id INTEGER NOT NULL REFERENCES litters(id) ON DELETE CASCADE, + name TEXT, + geschlecht TEXT, + farbe TEXT, + chip_nr TEXT, + geburtsgewicht REAL, + status TEXT NOT NULL DEFAULT 'verfuegbar', + status_sichtbar INTEGER NOT NULL DEFAULT 1, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_puppies_wurf ON puppies(wurf_id); + + CREATE TABLE IF NOT EXISTS puppy_weights ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + welpe_id INTEGER NOT NULL REFERENCES puppies(id) ON DELETE CASCADE, + gewicht_g REAL NOT NULL, + gemessen_am TEXT NOT NULL + ); + """) + logger.info("Migration: litters + puppies + puppy_weights bereit.") + + # Züchter-Fotos + conn.executescript(""" + CREATE TABLE IF NOT EXISTS breeder_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + breeder_id INTEGER NOT NULL REFERENCES breeder_profiles(id) ON DELETE CASCADE, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + thumbnail_path TEXT, + caption TEXT, + is_primary INTEGER NOT NULL DEFAULT 0, + visibility TEXT NOT NULL DEFAULT 'public', + sort_order INTEGER NOT NULL DEFAULT 0, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_breeder_photos_entity + ON breeder_photos(entity_type, entity_id); + """) + logger.info("Migration: breeder_photos bereit.") + + # Züchter-Hunde-Stammdaten (Stammbaum, Gesundheit, Genetik, Titel) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS zucht_hunde ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + breeder_id INTEGER REFERENCES breeder_profiles(id) ON DELETE SET NULL, + name TEXT NOT NULL, + rufname TEXT, + geschlecht TEXT, + geburtsdatum TEXT, + sterbedatum TEXT, + chip_nr TEXT, + taetowiernummer TEXT, + zuchtbuchnummer TEXT, + farbe TEXT, + vater_id INTEGER REFERENCES zucht_hunde(id), + mutter_id INTEGER REFERENCES zucht_hunde(id), + zuechter_name TEXT, + eigentuemer_name TEXT, + is_public INTEGER NOT NULL DEFAULT 1, + notiz TEXT, + foto_url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_zucht_hunde_breeder ON zucht_hunde(breeder_id); + CREATE INDEX IF NOT EXISTS idx_zucht_hunde_eltern ON zucht_hunde(vater_id, mutter_id); + + CREATE TABLE IF NOT EXISTS dog_health_tests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE, + test_typ TEXT NOT NULL, + test_name TEXT, + ergebnis TEXT NOT NULL, + untersuch_am TEXT NOT NULL, + gueltig_bis TEXT, + untersucher TEXT, + labor TEXT, + zertifikat_nr TEXT, + is_public INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_dog_health_tests_hund ON dog_health_tests(hund_id); + + CREATE TABLE IF NOT EXISTS dog_genetic_tests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE, + marker_name TEXT NOT NULL, + marker_kategorie TEXT, + genotyp TEXT, + ergebnis_klasse TEXT, + getestet_am TEXT NOT NULL, + labor TEXT, + zertifikat_nr TEXT, + is_public INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_dog_genetic_tests_hund ON dog_genetic_tests(hund_id); + + CREATE TABLE IF NOT EXISTS dog_titles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE, + titel_typ TEXT NOT NULL, + titel_name TEXT NOT NULL, + verliehen_am TEXT NOT NULL, + ort TEXT, + richter TEXT, + ausstellung TEXT, + formwert TEXT, + is_public INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_dog_titles_hund ON dog_titles(hund_id); + """) + logger.info("Migration: zucht_hunde + dog_health_tests + dog_genetic_tests + dog_titles bereit.") + + # Läufigkeit: Deckdatum + Wurftermin + existing_h = [row[1] for row in conn.execute("PRAGMA table_info(health)").fetchall()] + for col, typedef in [("deckdatum", "TEXT"), ("wurftermin", "TEXT")]: + if col not in existing_h: + conn.execute(f"ALTER TABLE health ADD COLUMN {col} {typedef}") + logger.info(f"Migration: health.{col} hinzugefügt.") diff --git a/backend/ki.py b/backend/ki.py index ab776ef..2c5fcaa 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -1,18 +1,17 @@ """ BAN YARO — KI-Abstraktions-Layer -Drei Modi: - - "off" → kein KI, Feature deaktiviert (Free-User ohne lokales Modell) - - "local" → LM Studio auf DS1621 (OpenAI-kompatibler Endpunkt, kostenlos) - - "cloud" → Claude API (nur für Premium-User, kostet Geld) +Routing-Logik: + 1. Immer lokal (LM Studio) zuerst versuchen + 2. Falls lokal nicht erreichbar → Fallback auf Cloud (Claude), wenn ANTHROPIC_API_KEY gesetzt + 3. Falls beides nicht geht → KIUnavailableError -Wird über KI_MODE Umgebungsvariable gesteuert: - KI_MODE=local → Entwicklung + Free-User auf DS - KI_MODE=cloud → Production + Premium-User +Modi (KI_MODE Umgebungsvariable): + KI_MODE=local → lokal + Cloud-Fallback wenn Key vorhanden + KI_MODE=cloud → lokal + Cloud-Fallback (gleiche Logik, anderer Label) KI_MODE=off → kein KI verfügbar -Wichtig: cloud-Aufrufe IMMER mit requires_premium=True schützen. - Kein API-Geld ohne zahlenden User. +requires_premium=True schützt Features vor Free-Usern, ändert aber nicht das Routing. """ import os @@ -138,16 +137,7 @@ async def complete( if requires_premium and not user_is_premium: raise KIPremiumRequired("Dieses Feature ist Teil von Ban Yaro Premium.") - # Cloud-Aufruf: Premium UND cloud-Modus - if requires_premium and user_is_premium and KI_MODE == "cloud": - _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 - - # Lokaler Aufruf + Cloud-Fallback + # Immer lokal zuerst — Cloud ist Fallback wenn lokal nicht erreichbar if KI_MODE in ("local", "cloud"): try: text = await _local_complete(prompt, system, max_tokens, json_mode) @@ -157,7 +147,7 @@ async def complete( return (text, "local") if return_source else text except Exception as e: logger.warning(f"Lokales KI-Modell nicht erreichbar: {e}") - if ANTHROPIC_KEY and (KI_MODE == "cloud" or (requires_premium and user_is_premium)): + if ANTHROPIC_KEY: logger.info("Fallback auf Cloud-KI.") _check_weekly_cloud_limit(user_id) text = await _cloud_complete(prompt, system, max_tokens, json_mode) diff --git a/backend/main.py b/backend/main.py index c6181d1..92936ed 100644 --- a/backend/main.py +++ b/backend/main.py @@ -156,6 +156,10 @@ from routes.weather import router as weather_router from routes.social import router as social_router from routes.moderation import router as moderation_router from routes.notes import router as notes_router +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 app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -181,6 +185,10 @@ app.include_router(movies_router, prefix="/api/movies", tags=["Filme"]) app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"]) app.include_router(chat_router, prefix="/api/chat", tags=["Chat"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) +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(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"]) @@ -1019,6 +1027,16 @@ async def invite_page(token: str): return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"}) +@app.get("/breeder/{zwingername}") +async def breeder_profile_page(zwingername: str): + return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"}) + + +@app.get("/litters") +async def litters_page(): + return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"}) + + # ------------------------------------------------------------------ # Widget-Vorschau /widget # ------------------------------------------------------------------ diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 141b82d..43e944a 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -534,6 +534,40 @@ async def scheduler_trigger(job_id: str, user=Depends(require_admin)): return {"ok": True, "job_id": job_id} +# ------------------------------------------------------------------ +# GET /api/admin/ki/status — lokale LLM-Erreichbarkeit prüfen +# ------------------------------------------------------------------ +@router.get("/ki/status") +async def ki_status(user=Depends(require_mod)): + import httpx + from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY + + result = { + "mode": KI_MODE, + "local_url": LOCAL_BASE_URL if KI_MODE != "off" else None, + "local_model_config": LOCAL_MODEL, + "local_reachable": False, + "local_model_loaded": None, + "cloud_model": CLOUD_MODEL, + "cloud_key_set": bool(ANTHROPIC_KEY), + } + + if KI_MODE != "off": + try: + async with httpx.AsyncClient(timeout=3.0) as client: + resp = await client.get(f"{LOCAL_BASE_URL}/models") + if resp.status_code == 200: + data = resp.json() + models = data.get("data", []) + result["local_reachable"] = True + if models: + result["local_model_loaded"] = models[0].get("id") + except Exception: + pass + + return result + + # ------------------------------------------------------------------ # GET /api/admin/system # ------------------------------------------------------------------ diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py new file mode 100644 index 0000000..d3ea366 --- /dev/null +++ b/backend/routes/breeder.py @@ -0,0 +1,366 @@ +"""BAN YARO — Züchter-Verwaltung (Antrag, Admin-Prüfung)""" +import os +import logging +from datetime import datetime +from zoneinfo import ZoneInfo + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi.responses import FileResponse +from pydantic import BaseModel +from typing import Optional + +from database import db +from auth import get_current_user, require_premium +from mailer import send_email + +router = APIRouter() +logger = logging.getLogger(__name__) + +_TZ = ZoneInfo("Europe/Berlin") + +BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs") +os.makedirs(BREEDER_DOCS_DIR, exist_ok=True) + +ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "mail@motocamp.de") +APP_URL = os.getenv("APP_URL", "https://banyaro.app") + + +# ------------------------------------------------------------------ +# 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 verifizierte Züchter.") + return user + +def require_admin(user=Depends(get_current_user)): + if user["rolle"] != "admin": + raise HTTPException(403, "Nur für Admins.") + return user + + +# ------------------------------------------------------------------ +# GET /api/breeder/status — eigener Antragsstatus +# ------------------------------------------------------------------ +@router.get("/breeder/status") +async def breeder_status(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT rolle, breeder_status FROM users WHERE id=?", + (user["id"],) + ).fetchone() + if not row: + raise HTTPException(404, "User nicht gefunden.") + profile = None + if row["rolle"] == "breeder": + profile = conn.execute( + "SELECT zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at " + "FROM breeder_profiles WHERE user_id=?", + (user["id"],) + ).fetchone() + return { + "rolle": row["rolle"], + "breeder_status": row["breeder_status"], + "profile": dict(profile) if profile else None, + } + + +# ------------------------------------------------------------------ +# POST /api/breeder/apply — Antrag stellen +# ------------------------------------------------------------------ +@router.post("/breeder/apply") +async def breeder_apply( + zwingername: str = Form(...), + rasse_text: str = Form(...), + verein: str = Form(...), + vdh_mitglied: int = Form(0), + stadt: str = Form(...), + website: str = Form(""), + beschreibung: str = Form(""), + dokument: UploadFile = File(...), + user=Depends(get_current_user), +): + with db() as conn: + row = conn.execute( + "SELECT rolle, breeder_status FROM users WHERE id=?", + (user["id"],) + ).fetchone() + + if not row: + raise HTTPException(404, "User nicht gefunden.") + if row["rolle"] == "breeder": + raise HTTPException(400, "Du bist bereits verifizierter Züchter.") + if row["breeder_status"] == "pending": + raise HTTPException(400, "Du hast bereits einen offenen Antrag.") + + # Dokument validieren und speichern + data = await dokument.read() + if len(data) > 10 * 1024 * 1024: + raise HTTPException(400, "Dokument zu groß (max. 10 MB).") + ext = os.path.splitext(dokument.filename or "")[1].lower() + if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp"): + raise HTTPException(400, "Nur PDF, JPG, PNG oder WebP erlaubt.") + + user_doc_dir = os.path.join(BREEDER_DOCS_DIR, str(user["id"])) + os.makedirs(user_doc_dir, exist_ok=True) + + filename = f"antrag_{datetime.now(_TZ).strftime('%Y%m%d_%H%M%S')}{ext}" + filepath = os.path.join(user_doc_dir, filename) + with open(filepath, "wb") as f: + f.write(data) + + with db() as conn: + conn.execute( + "UPDATE users SET breeder_status='pending' WHERE id=?", + (user["id"],) + ) + # Profil-Entwurf anlegen (oder überschreiben wenn rejected) + conn.execute( + "INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) " + "VALUES (?,?,?,?,?,?,?,?) " + "ON CONFLICT(user_id) DO UPDATE SET " + "zwingername=excluded.zwingername, rasse_text=excluded.rasse_text, " + "verein=excluded.verein, vdh_mitglied=excluded.vdh_mitglied, " + "stadt=excluded.stadt, website=excluded.website, beschreibung=excluded.beschreibung, " + "verified_at=NULL", + (user["id"], zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) + ) + conn.execute( + "INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)", + (user["id"], "antrag", filepath) + ) + + # Admin benachrichtigen + admin_html = f""" +

Neuer Züchter-Antrag

+

Von: {user['name']} ({user['email']})

+

Zwingername: {zwingername}

+

Rasse: {rasse_text}

+

Verein: {verein}

+

VDH: {'Ja' if vdh_mitglied else 'Nein'}

+

Stadt: {stadt}

+

Im Admin-Bereich prüfen

+ """ + try: + await send_email( + ADMIN_EMAIL, + f"[Banyaro] Neuer Züchter-Antrag — {zwingername}", + admin_html, + f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}", + ) + except Exception as e: + logger.warning(f"Admin-Mail nicht gesendet: {e}") + + return {"message": "Antrag eingereicht. Du wirst per E-Mail benachrichtigt."} + + +# ------------------------------------------------------------------ +# GET /api/admin/breeders/pending — offene Anträge +# ------------------------------------------------------------------ +@router.get("/admin/breeders/pending") +async def admin_pending_breeders(admin=Depends(require_admin)): + with db() as conn: + rows = conn.execute(""" + SELECT u.id, u.name, u.email, u.created_at, u.breeder_status, + bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied, + bp.stadt, bp.website, bp.beschreibung, bp.created_at AS antrag_at, + (SELECT COUNT(*) FROM breeder_documents WHERE user_id=u.id) AS dok_count + FROM users u + JOIN breeder_profiles bp ON bp.user_id = u.id + WHERE u.breeder_status = 'pending' + ORDER BY bp.created_at ASC + """).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# GET /api/admin/breeder/{user_id}/documents — Dokumente eines Antrags +# ------------------------------------------------------------------ +@router.get("/admin/breeder/{user_id}/documents") +async def admin_breeder_documents(user_id: int, admin=Depends(require_admin)): + with db() as conn: + docs = conn.execute( + "SELECT id, dokument_typ, file_path, uploaded_at FROM breeder_documents WHERE user_id=?", + (user_id,) + ).fetchall() + return [dict(d) for d in docs] + + +# ------------------------------------------------------------------ +# GET /api/admin/breeder/{user_id}/document/{doc_id} — Datei herunterladen +# ------------------------------------------------------------------ +@router.get("/admin/breeder/{user_id}/document/{doc_id}") +async def admin_download_document(user_id: int, doc_id: int, admin=Depends(require_admin)): + with db() as conn: + row = conn.execute( + "SELECT file_path FROM breeder_documents WHERE id=? AND user_id=?", + (doc_id, user_id) + ).fetchone() + if not row: + raise HTTPException(404, "Dokument nicht gefunden.") + path = row["file_path"] + if not os.path.exists(path): + raise HTTPException(404, "Datei nicht auf Datenträger.") + return FileResponse(path) + + +class RejectBody(BaseModel): + grund: str + + +# ------------------------------------------------------------------ +# POST /api/admin/breeder/{user_id}/approve — Freischalten +# ------------------------------------------------------------------ +@router.post("/admin/breeder/{user_id}/approve") +async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)): + with db() as conn: + user = conn.execute( + "SELECT id, name, email, breeder_status FROM users WHERE id=?", + (user_id,) + ).fetchone() + if not user: + raise HTTPException(404, "User nicht gefunden.") + if user["breeder_status"] != "pending": + raise HTTPException(400, "Kein offener Antrag.") + + conn.execute( + "UPDATE users SET rolle='breeder', breeder_status='approved' WHERE id=?", + (user_id,) + ) + conn.execute( + "UPDATE breeder_profiles SET verified_at=datetime('now') WHERE user_id=?", + (user_id,) + ) + + # Bestätigungs-Mail + html = f""" +

Willkommen als Züchter bei Banyaro!

+

Hallo {user['name']},

+

dein Züchter-Profil wurde erfolgreich verifiziert.

+

Ab sofort hast du Zugang zu allen Züchter-Features.

+

Zur App

+ """ + try: + await send_email( + user["email"], + "Willkommen als Züchter bei Banyaro!", + html, + f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.", + ) + except Exception as e: + logger.warning(f"Bestätigungs-Mail nicht gesendet: {e}") + + return {"message": f"{user['name']} als Züchter freigeschaltet."} + + +# ------------------------------------------------------------------ +# POST /api/admin/breeder/{user_id}/reject — Ablehnen +# ------------------------------------------------------------------ +@router.post("/admin/breeder/{user_id}/reject") +async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(require_admin)): + with db() as conn: + user = conn.execute( + "SELECT id, name, email, breeder_status FROM users WHERE id=?", + (user_id,) + ).fetchone() + if not user: + raise HTTPException(404, "User nicht gefunden.") + if user["breeder_status"] != "pending": + raise HTTPException(400, "Kein offener Antrag.") + + conn.execute( + "UPDATE users SET breeder_status='rejected' WHERE id=?", + (user_id,) + ) + + # Ablehnungs-Mail + html = f""" +

Dein Züchter-Antrag bei Banyaro

+

Hallo {user['name']},

+

leider konnten wir deinen Antrag aktuell nicht bestätigen.

+

Grund: {body.grund}

+

Du kannst jederzeit einen neuen Antrag stellen.

+

Bei Fragen: {ADMIN_EMAIL}

+ """ + try: + await send_email( + user["email"], + "Dein Züchter-Antrag bei Banyaro", + html, + f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}", + ) + except Exception as e: + logger.warning(f"Ablehnungs-Mail nicht gesendet: {e}") + + return {"message": f"Antrag von {user['name']} abgelehnt."} + + +# ------------------------------------------------------------------ +# GET /api/breeder/profil/{zwingername} — öffentliches Profil +# ------------------------------------------------------------------ +@router.get("/breeder/profil/{zwingername}") +async def breeder_public_profile(zwingername: str): + with db() as conn: + row = conn.execute(""" + SELECT bp.id, bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied, + bp.stadt, bp.website, bp.beschreibung, + bp.location_lat, bp.location_lng, bp.verified_at, bp.created_at, + u.id AS zuechter_user_id, + u.name AS zuechter_name + FROM breeder_profiles bp + JOIN users u ON u.id = bp.user_id + WHERE LOWER(bp.zwingername) = LOWER(?) + AND u.rolle = 'breeder' + AND u.breeder_status = 'approved' + """, (zwingername,)).fetchone() + if not row: + raise HTTPException(404, "Züchter nicht gefunden.") + return dict(row) + + +# ------------------------------------------------------------------ +# PUT /api/breeder/profile — eigenes Profil bearbeiten +# ------------------------------------------------------------------ +class BreederProfileUpdate(BaseModel): + zwingername: Optional[str] = None + rasse_text: Optional[str] = None + verein: Optional[str] = None + vdh_mitglied: Optional[int] = None + stadt: Optional[str] = None + website: Optional[str] = None + beschreibung: Optional[str] = None + +@router.put("/breeder/profile") +async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)): + with db() as conn: + profile = conn.execute( + "SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],) + ).fetchone() + if not profile: + raise HTTPException(404, "Kein Züchter-Profil vorhanden.") + fields = {k: v for k, v in body.model_dump().items() if v is not None} + if not fields: + return {"message": "Keine Änderungen."} + set_clause = ", ".join(f"{k}=?" for k in fields) + conn.execute( + f"UPDATE breeder_profiles SET {set_clause} WHERE id=?", + (*fields.values(), profile["id"]) + ) + return {"message": "Profil aktualisiert."} + + +# ------------------------------------------------------------------ +# GET /api/breeder/map — alle Züchter für Karte +# ------------------------------------------------------------------ +@router.get("/breeder/map") +async def breeder_map_markers(): + with db() as conn: + rows = conn.execute(""" + SELECT bp.id, bp.zwingername, bp.rasse_text, bp.stadt, + bp.location_lat, bp.location_lng + 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() + return [dict(r) for r in rows] diff --git a/backend/routes/breeder_photos.py b/backend/routes/breeder_photos.py new file mode 100644 index 0000000..554cb91 --- /dev/null +++ b/backend/routes/breeder_photos.py @@ -0,0 +1,356 @@ +"""BAN YARO — Züchter-Fotos (Upload, Verwaltung, öffentliche Ansicht)""" +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi.responses import FileResponse +from pydantic import BaseModel +from typing import Optional +import os, logging +from database import db +from auth import get_current_user, get_current_user_optional +from media_utils import validate_upload, generate_preview +import uuid + +router = APIRouter() +logger = logging.getLogger(__name__) + +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + +_VALID_ENTITY_TYPES = {"breeder", "litter", "puppy", "parent"} + + +# ------------------------------------------------------------------ +# 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 + + +# ------------------------------------------------------------------ +# Modelle +# ------------------------------------------------------------------ +class VisibilityBody(BaseModel): + visibility: str + +class CaptionBody(BaseModel): + caption: Optional[str] = None + + +# ------------------------------------------------------------------ +# Hilfsfunktion: Züchter-Profil für User laden +# ------------------------------------------------------------------ +def _get_breeder_profile(conn, user_id: int): + row = conn.execute( + "SELECT id FROM breeder_profiles WHERE user_id=?", (user_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Züchter-Profil nicht gefunden.") + return row["id"] + + +# ------------------------------------------------------------------ +# POST /api/breeder/photos/upload — Foto hochladen +# ------------------------------------------------------------------ +@router.post("/breeder/photos/upload") +async def upload_photo( + entity_type: str = Form(...), + entity_id: int = Form(...), + visibility: str = Form("public"), + caption: str = Form(""), + is_primary: int = Form(0), + file: UploadFile = File(...), + user=Depends(_require_breeder), +): + if entity_type not in _VALID_ENTITY_TYPES: + raise HTTPException(400, f"Ungültiger entity_type. Erlaubt: {', '.join(_VALID_ENTITY_TYPES)}") + + if visibility not in ("public", "inquiry", "private"): + raise HTTPException(400, "Ungültige Sichtbarkeit.") + + raw_data = await file.read() + filename = file.filename or "upload.jpg" + + try: + validate_upload(raw_data, filename) + except ValueError as e: + raise HTTPException(400, str(e)) + + ext = os.path.splitext(filename)[1].lower() or ".jpg" + + with db() as conn: + breeder_id = _get_breeder_profile(conn, user["id"]) + + # Ownership prüfen (für entity_type != 'breeder') + if entity_type == "litter": + row = conn.execute( + "SELECT id FROM litters WHERE id=? AND breeder_id=?", + (entity_id, breeder_id) + ).fetchone() + if not row and user["rolle"] != "admin": + raise HTTPException(403, "Kein Zugriff auf diesen Wurf.") + elif entity_type == "puppy": + row = conn.execute( + """SELECT p.id FROM litter_puppies p + JOIN litters l ON l.id=p.litter_id + WHERE p.id=? AND l.breeder_id=?""", + (entity_id, breeder_id) + ).fetchone() + if not row and user["rolle"] != "admin": + raise HTTPException(403, "Kein Zugriff auf diesen Welpen.") + elif entity_type == "parent": + # parent kann frei hochgeladen werden solange breeder stimmt + pass + elif entity_type == "breeder": + # entity_id muss das eigene Profil sein + if entity_id != breeder_id and user["rolle"] != "admin": + raise HTTPException(403, "Kein Zugriff auf dieses Züchter-Profil.") + + # Speicherpfad anlegen + save_dir = os.path.join(MEDIA_DIR, "breeders", str(breeder_id), entity_type) + os.makedirs(save_dir, exist_ok=True) + + file_uuid = str(uuid.uuid4()) + file_path = os.path.join(save_dir, f"{file_uuid}.webp") + + # Thumbnail erzeugen + thumb_bytes = generate_preview(raw_data, ext) + thumb_path = None + if thumb_bytes: + thumb_path = os.path.join(save_dir, f"{file_uuid}_thumb.webp") + with open(thumb_path, "wb") as f: + f.write(thumb_bytes) + + # Originalbild konvertieren und speichern + # generate_preview liefert WebP, für das Original nehmen wir Pillow direkt + try: + import io + from PIL import Image, ImageOps + img = Image.open(io.BytesIO(raw_data)) + img = ImageOps.exif_transpose(img) + img = img.convert("RGB") + img.save(file_path, format="WEBP", quality=85) + except Exception: + # Fallback: Rohdaten speichern + with open(file_path, "wb") as f: + f.write(raw_data) + + # Relative Pfade für DB (relativ zu MEDIA_DIR) + rel_file = os.path.relpath(file_path, MEDIA_DIR) + rel_thumb = os.path.relpath(thumb_path, MEDIA_DIR) if thumb_path else None + + # Falls is_primary: alle anderen auf 0 setzen + if is_primary: + conn.execute( + "UPDATE breeder_photos SET is_primary=0 WHERE breeder_id=? AND entity_type=? AND entity_id=?", + (breeder_id, entity_type, entity_id) + ) + + conn.execute( + """INSERT INTO breeder_photos + (breeder_id, entity_type, entity_id, file_path, thumbnail_path, + caption, is_primary, visibility, sort_order, uploaded_at) + VALUES (?,?,?,?,?,?,?,?, + (SELECT COALESCE(MAX(sort_order),0)+1 FROM breeder_photos + WHERE breeder_id=? AND entity_type=? AND entity_id=?), + datetime('now'))""", + (breeder_id, entity_type, entity_id, rel_file, rel_thumb, + caption.strip() or None, 1 if is_primary else 0, + visibility, + breeder_id, entity_type, entity_id) + ) + photo_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + photo = conn.execute( + "SELECT * FROM breeder_photos WHERE id=?", (photo_id,) + ).fetchone() + + return _photo_dict(photo) + + +# ------------------------------------------------------------------ +# GET /api/photos/{entity_type}/{entity_id} — Fotos abrufen +# ------------------------------------------------------------------ +@router.get("/photos/{entity_type}/{entity_id}") +async def get_photos( + entity_type: str, + entity_id: int, + user=Depends(get_current_user_optional), +): + if entity_type not in _VALID_ENTITY_TYPES: + raise HTTPException(400, f"Ungültiger entity_type.") + + with db() as conn: + # Prüfen ob anfragender User Besitzer oder Admin ist + is_owner = False + if user: + if user["rolle"] == "admin": + is_owner = True + else: + bp = conn.execute( + "SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],) + ).fetchone() + if bp: + # Besitzer wenn entity dem Züchter gehört + if entity_type == "breeder": + is_owner = (bp["id"] == entity_id) + elif entity_type == "litter": + row = conn.execute( + "SELECT id FROM litters WHERE id=? AND breeder_id=?", + (entity_id, bp["id"]) + ).fetchone() + is_owner = bool(row) + elif entity_type == "puppy": + row = conn.execute( + """SELECT p.id FROM litter_puppies p + JOIN litters l ON l.id=p.litter_id + WHERE p.id=? AND l.breeder_id=?""", + (entity_id, bp["id"]) + ).fetchone() + is_owner = bool(row) + elif entity_type == "parent": + row = conn.execute( + "SELECT id FROM breeder_photos WHERE entity_type='parent' AND entity_id=? AND breeder_id=?", + (entity_id, bp["id"]) + ).fetchone() + is_owner = bool(row) + + if is_owner: + photos = conn.execute( + "SELECT * FROM breeder_photos WHERE entity_type=? AND entity_id=? ORDER BY sort_order, id", + (entity_type, entity_id) + ).fetchall() + else: + photos = conn.execute( + "SELECT * FROM breeder_photos WHERE entity_type=? AND entity_id=? AND visibility='public' ORDER BY sort_order, id", + (entity_type, entity_id) + ).fetchall() + + return [_photo_dict(p) for p in photos] + + +# ------------------------------------------------------------------ +# PATCH /api/breeder/photos/{id}/visibility +# ------------------------------------------------------------------ +@router.patch("/breeder/photos/{photo_id}/visibility") +async def update_visibility( + photo_id: int, + body: VisibilityBody, + user=Depends(_require_breeder), +): + if body.visibility not in ("public", "inquiry", "private"): + raise HTTPException(400, "Ungültige Sichtbarkeit.") + + with db() as conn: + photo = _get_own_photo(conn, photo_id, user) + conn.execute( + "UPDATE breeder_photos SET visibility=? WHERE id=?", + (body.visibility, photo_id) + ) + updated = conn.execute("SELECT * FROM breeder_photos WHERE id=?", (photo_id,)).fetchone() + return _photo_dict(updated) + + +# ------------------------------------------------------------------ +# PATCH /api/breeder/photos/{id}/primary +# ------------------------------------------------------------------ +@router.patch("/breeder/photos/{photo_id}/primary") +async def set_primary( + photo_id: int, + user=Depends(_require_breeder), +): + with db() as conn: + photo = _get_own_photo(conn, photo_id, user) + # Alle anderen auf 0 + conn.execute( + "UPDATE breeder_photos SET is_primary=0 WHERE breeder_id=? AND entity_type=? AND entity_id=?", + (photo["breeder_id"], photo["entity_type"], photo["entity_id"]) + ) + conn.execute( + "UPDATE breeder_photos SET is_primary=1 WHERE id=?", (photo_id,) + ) + updated = conn.execute("SELECT * FROM breeder_photos WHERE id=?", (photo_id,)).fetchone() + return _photo_dict(updated) + + +# ------------------------------------------------------------------ +# PATCH /api/breeder/photos/{id}/caption +# ------------------------------------------------------------------ +@router.patch("/breeder/photos/{photo_id}/caption") +async def update_caption( + photo_id: int, + body: CaptionBody, + user=Depends(_require_breeder), +): + with db() as conn: + _get_own_photo(conn, photo_id, user) + conn.execute( + "UPDATE breeder_photos SET caption=? WHERE id=?", + (body.caption, photo_id) + ) + updated = conn.execute("SELECT * FROM breeder_photos WHERE id=?", (photo_id,)).fetchone() + return _photo_dict(updated) + + +# ------------------------------------------------------------------ +# DELETE /api/breeder/photos/{id} +# ------------------------------------------------------------------ +@router.delete("/breeder/photos/{photo_id}") +async def delete_photo( + photo_id: int, + user=Depends(_require_breeder), +): + with db() as conn: + photo = _get_own_photo(conn, photo_id, user) + + # Dateien löschen + for rel in (photo["file_path"], photo["thumbnail_path"]): + if rel: + abs_path = os.path.join(MEDIA_DIR, rel) + try: + if os.path.isfile(abs_path): + os.unlink(abs_path) + except OSError as e: + logger.warning("Konnte Datei nicht löschen: %s — %s", abs_path, e) + + conn.execute("DELETE FROM breeder_photos WHERE id=?", (photo_id,)) + + return {"ok": True} + + +# ------------------------------------------------------------------ +# Hilfsfunktionen +# ------------------------------------------------------------------ +def _get_own_photo(conn, photo_id: int, user: dict): + """Lädt das Foto und prüft Ownership. Wirft 403/404 bei Fehler.""" + photo = conn.execute( + "SELECT * FROM breeder_photos WHERE id=?", (photo_id,) + ).fetchone() + if not photo: + raise HTTPException(404, "Foto nicht gefunden.") + + if user["rolle"] == "admin": + return photo + + # Prüfe ob Züchter-Profil dem User gehört + bp = conn.execute( + "SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],) + ).fetchone() + if not bp or bp["id"] != photo["breeder_id"]: + raise HTTPException(403, "Kein Zugriff auf dieses Foto.") + return photo + + +def _photo_dict(row) -> dict: + """Konvertiert DB-Zeile in API-Response-Dict mit öffentlichen URLs.""" + if row is None: + return {} + d = dict(row) + # Öffentliche URLs ableiten + if d.get("file_path"): + d["url"] = "/media/" + d["file_path"].replace("\\", "/") + else: + d["url"] = None + if d.get("thumbnail_path"): + d["thumbnail_url"] = "/media/" + d["thumbnail_path"].replace("\\", "/") + else: + d["thumbnail_url"] = d.get("url") + return d diff --git a/backend/routes/health.py b/backend/routes/health.py index bad2d01..96743e0 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -48,6 +48,9 @@ class HealthCreate(BaseModel): intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage # Tierarzt-Verknüpfung tierarzt_id: Optional[int] = None + # Züchter + deckdatum: Optional[str] = None + wurftermin: Optional[str] = None class HealthUpdate(BaseModel): @@ -70,6 +73,8 @@ class HealthUpdate(BaseModel): erinnerung: Optional[int] = None intervall_tage: Optional[int] = None tierarzt_id: Optional[int] = None + deckdatum: Optional[str] = None + wurftermin: Optional[str] = None # ------------------------------------------------------------------ @@ -159,13 +164,15 @@ async def create_health(dog_id: int, data: HealthCreate, (dog_id, typ, bezeichnung, datum, naechstes, notiz, wert, einheit, charge_nr, tierarzt_name, kosten, diagnose, dosierung, haeufigkeit, aktiv, bis_datum, - schweregrad, reaktion, erinnerung, tierarzt_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + schweregrad, reaktion, erinnerung, tierarzt_id, + deckdatum, wurftermin) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (dog_id, data.typ, data.bezeichnung, data.datum, data.naechstes, data.notiz, data.wert, data.einheit, data.charge_nr, data.tierarzt_name, data.kosten, data.diagnose, data.dosierung, data.haeufigkeit, data.aktiv, data.bis_datum, - data.schweregrad, data.reaktion, data.erinnerung, data.tierarzt_id) + data.schweregrad, data.reaktion, data.erinnerung, data.tierarzt_id, + data.deckdatum, data.wurftermin) ) row = conn.execute( "SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1", diff --git a/backend/routes/litters.py b/backend/routes/litters.py new file mode 100644 index 0000000..4ea557b --- /dev/null +++ b/backend/routes/litters.py @@ -0,0 +1,575 @@ +"""BAN YARO — Wurfverwaltung (Züchter: Würfe & Welpen)""" + +import logging +from datetime import date +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse +from pydantic import BaseModel +from typing import Optional + +from database import db +from auth import get_current_user, get_current_user_optional + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------ +# 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 verifizierte Züchter.") + return user + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class LitterCreate(BaseModel): + vater_name: Optional[str] = None + mutter_name: Optional[str] = None + geburt_datum: Optional[str] = None # YYYY-MM-DD + erwartetes_datum: Optional[str] = None # YYYY-MM-DD + welpen_gesamt: Optional[int] = None + welpen_verfuegbar: Optional[int] = None + beschreibung: Optional[str] = None + gesundheitstests: Optional[str] = None + preis_spanne: Optional[str] = None + status: str = "geplant" # geplant|geboren|verfuegbar|abgeschlossen + sichtbar: int = 0 + sichtbar_bis: Optional[str] = None + + +class LitterUpdate(BaseModel): + vater_name: Optional[str] = None + mutter_name: Optional[str] = None + geburt_datum: Optional[str] = None + erwartetes_datum: Optional[str] = None + welpen_gesamt: Optional[int] = None + welpen_verfuegbar: Optional[int] = None + beschreibung: Optional[str] = None + gesundheitstests: Optional[str] = None + preis_spanne: Optional[str] = None + status: Optional[str] = None + sichtbar: Optional[int] = None + sichtbar_bis: Optional[str] = None + + +class PuppyCreate(BaseModel): + name: Optional[str] = None + geschlecht: Optional[str] = None # maennlich|weiblich + farbe: Optional[str] = None + chip_nr: Optional[str] = None + geburtsgewicht: Optional[float] = None # Gramm + status: str = "verfuegbar" # verfuegbar|reserviert|abgegeben + status_sichtbar: int = 1 + notiz: Optional[str] = None + + +class PuppyUpdate(BaseModel): + name: Optional[str] = None + geschlecht: Optional[str] = None + farbe: Optional[str] = None + chip_nr: Optional[str] = None + geburtsgewicht: Optional[float] = None + status: Optional[str] = None + status_sichtbar: Optional[int] = None + notiz: Optional[str] = None + + +class WeightEntry(BaseModel): + gewicht_g: float + gemessen_am: str # YYYY-MM-DD + + +# ------------------------------------------------------------------ +# Hilfsfunktion: Züchter-Profil des Users ermitteln +# ------------------------------------------------------------------ +def _get_breeder_profile(user_id: int, conn): + row = conn.execute( + "SELECT id FROM breeder_profiles WHERE user_id=?", (user_id,) + ).fetchone() + return row + + +def _check_litter_owner(litter_id: int, user, conn): + """Gibt den Wurf zurück wenn der User Eigentümer oder Admin ist.""" + litter = conn.execute( + "SELECT l.*, bp.user_id AS owner_user_id " + "FROM litters l JOIN breeder_profiles bp ON bp.id = l.breeder_id " + "WHERE l.id=?", + (litter_id,) + ).fetchone() + if not litter: + raise HTTPException(404, "Wurf nicht gefunden.") + if user["rolle"] != "admin" and litter["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + return litter + + +# ------------------------------------------------------------------ +# GET /api/litters — öffentliche Übersicht +# ------------------------------------------------------------------ +@router.get("/litters") +async def list_public_litters( + rasse: Optional[str] = None, + status: Optional[str] = None, +): + today = date.today().isoformat() + with db() as conn: + q = """ + SELECT l.*, + bp.zwingername, bp.rasse_text, bp.stadt, + bp.user_id AS breeder_user_id, + u.name AS zuechter_name + FROM litters l + JOIN breeder_profiles bp ON bp.id = l.breeder_id + JOIN users u ON u.id = bp.user_id + WHERE l.sichtbar = 1 + AND (l.sichtbar_bis IS NULL OR l.sichtbar_bis >= ?) + """ + params = [today] + + if status: + q += " AND l.status = ?" + params.append(status) + else: + q += " AND l.status IN ('geplant', 'geboren', 'verfuegbar')" + + if rasse: + q += " AND LOWER(bp.rasse_text) LIKE LOWER(?)" + params.append(f"%{rasse}%") + + q += " ORDER BY l.created_at DESC" + rows = conn.execute(q, params).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# GET /api/litters/my — eigene Würfe (Züchter) +# ------------------------------------------------------------------ +@router.get("/litters/my") +async def my_litters(user=Depends(_require_breeder)): + with db() as conn: + if user["rolle"] == "admin": + # Admin ohne eigenes Profil sieht alle Würfe aller Züchter + profile = _get_breeder_profile(user["id"], conn) + if not profile: + rows = conn.execute( + "SELECT l.*, bp.zwingername FROM litters l " + "JOIN breeder_profiles bp ON bp.id = l.breeder_id " + "ORDER BY l.created_at DESC" + ).fetchall() + return [dict(r) for r in rows] + else: + profile = _get_breeder_profile(user["id"], conn) + if not profile: + raise HTTPException(404, "Kein Züchter-Profil vorhanden.") + rows = conn.execute( + "SELECT * FROM litters WHERE breeder_id=? ORDER BY created_at DESC", + (profile["id"],) + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/litters — neuen Wurf anlegen +# ------------------------------------------------------------------ +@router.post("/litters", status_code=201) +async def create_litter(body: LitterCreate, user=Depends(_require_breeder)): + with db() as conn: + profile = _get_breeder_profile(user["id"], conn) + if not profile: + raise HTTPException(404, "Züchter-Profil nicht gefunden.") + + cur = conn.execute( + """INSERT INTO litters + (breeder_id, vater_name, mutter_name, geburt_datum, erwartetes_datum, + welpen_gesamt, welpen_verfuegbar, beschreibung, gesundheitstests, + preis_spanne, status, sichtbar, sichtbar_bis) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + profile["id"], + body.vater_name, + body.mutter_name, + body.geburt_datum, + body.erwartetes_datum, + body.welpen_gesamt, + body.welpen_verfuegbar, + body.beschreibung, + body.gesundheitstests, + body.preis_spanne, + body.status, + body.sichtbar, + body.sichtbar_bis, + ) + ) + row = conn.execute( + "SELECT * FROM litters WHERE id=?", (cur.lastrowid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# GET /api/litters/{id} — Wurf-Detail (öffentlich wenn sichtbar=1) +# ------------------------------------------------------------------ +@router.get("/litters/{litter_id}") +async def get_litter(litter_id: int, user=Depends(get_current_user_optional)): + today = date.today().isoformat() + with db() as conn: + row = conn.execute( + """SELECT l.*, + bp.zwingername, bp.rasse_text, bp.stadt, + bp.user_id AS owner_user_id, + u.name AS zuechter_name + FROM litters l + JOIN breeder_profiles bp ON bp.id = l.breeder_id + JOIN users u ON u.id = bp.user_id + WHERE l.id=?""", + (litter_id,) + ).fetchone() + + if not row: + raise HTTPException(404, "Wurf nicht gefunden.") + + is_owner = user and ( + user["rolle"] == "admin" or row["owner_user_id"] == user["id"] + ) + + # Nicht-öffentliche Würfe nur für Züchter/Admin + if not row["sichtbar"] and not is_owner: + raise HTTPException(404, "Wurf nicht gefunden.") + + # Abgelaufene Würfe + if row["sichtbar_bis"] and row["sichtbar_bis"] < today and not is_owner: + raise HTTPException(404, "Wurf nicht mehr verfügbar.") + + return dict(row) + + +# ------------------------------------------------------------------ +# PUT /api/litters/{id} — Wurf bearbeiten +# ------------------------------------------------------------------ +@router.put("/litters/{litter_id}") +async def update_litter(litter_id: int, body: LitterUpdate, user=Depends(_require_breeder)): + with db() as conn: + _check_litter_owner(litter_id, user, conn) + + fields, params = [], [] + for field, value in body.model_dump(exclude_none=True).items(): + fields.append(f"{field}=?") + params.append(value) + + if not fields: + raise HTTPException(400, "Keine Felder zum Aktualisieren.") + + params.append(litter_id) + conn.execute( + f"UPDATE litters SET {', '.join(fields)} WHERE id=?", params + ) + row = conn.execute("SELECT * FROM litters WHERE id=?", (litter_id,)).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/litters/{id} — Wurf löschen +# ------------------------------------------------------------------ +@router.delete("/litters/{litter_id}", status_code=204) +async def delete_litter(litter_id: int, user=Depends(_require_breeder)): + with db() as conn: + _check_litter_owner(litter_id, user, conn) + conn.execute("DELETE FROM puppy_weights WHERE welpe_id IN (SELECT id FROM puppies WHERE wurf_id=?)", (litter_id,)) + conn.execute("DELETE FROM puppies WHERE wurf_id=?", (litter_id,)) + conn.execute("DELETE FROM litters WHERE id=?", (litter_id,)) + return None + + +# ------------------------------------------------------------------ +# GET /api/litters/{id}/puppies — Welpen eines Wurfs +# ------------------------------------------------------------------ +@router.get("/litters/{litter_id}/puppies") +async def list_puppies(litter_id: int, user=Depends(get_current_user_optional)): + with db() as conn: + litter = conn.execute( + """SELECT l.sichtbar, l.sichtbar_bis, bp.user_id AS owner_user_id + FROM litters l JOIN breeder_profiles bp ON bp.id = l.breeder_id + WHERE l.id=?""", + (litter_id,) + ).fetchone() + if not litter: + raise HTTPException(404, "Wurf nicht gefunden.") + + is_owner = user and ( + user["rolle"] == "admin" or litter["owner_user_id"] == user["id"] + ) + + q = "SELECT * FROM puppies WHERE wurf_id=?" + params = [litter_id] + if not is_owner: + q += " AND status_sichtbar=1" + + rows = conn.execute(q + " ORDER BY created_at ASC", params).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/litters/{id}/puppies — Welpe anlegen +# ------------------------------------------------------------------ +@router.post("/litters/{litter_id}/puppies", status_code=201) +async def add_puppy(litter_id: int, body: PuppyCreate, user=Depends(_require_breeder)): + with db() as conn: + _check_litter_owner(litter_id, user, conn) + + cur = conn.execute( + """INSERT INTO puppies + (wurf_id, name, geschlecht, farbe, chip_nr, geburtsgewicht, + status, status_sichtbar, notiz) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + litter_id, + body.name, + body.geschlecht, + body.farbe, + body.chip_nr, + body.geburtsgewicht, + body.status, + body.status_sichtbar, + body.notiz, + ) + ) + row = conn.execute( + "SELECT * FROM puppies WHERE id=?", (cur.lastrowid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# PUT /api/litters/puppies/{id} — Welpe bearbeiten +# ------------------------------------------------------------------ +@router.put("/litters/puppies/{puppy_id}") +async def update_puppy(puppy_id: int, body: PuppyUpdate, user=Depends(_require_breeder)): + with db() as conn: + puppy = conn.execute( + """SELECT p.*, l.id AS litter_id, bp.user_id AS owner_user_id + FROM puppies p + JOIN litters l ON l.id = p.wurf_id + JOIN breeder_profiles bp ON bp.id = l.breeder_id + WHERE p.id=?""", + (puppy_id,) + ).fetchone() + if not puppy: + raise HTTPException(404, "Welpe nicht gefunden.") + if user["rolle"] != "admin" and puppy["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + + fields, params = [], [] + for field, value in body.model_dump(exclude_none=True).items(): + fields.append(f"{field}=?") + params.append(value) + + if not fields: + raise HTTPException(400, "Keine Felder zum Aktualisieren.") + + params.append(puppy_id) + conn.execute( + f"UPDATE puppies SET {', '.join(fields)} WHERE id=?", params + ) + row = conn.execute("SELECT * FROM puppies WHERE id=?", (puppy_id,)).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# GET /api/litters/puppies/{id}/weights — Gewichtsverlauf laden +# ------------------------------------------------------------------ +@router.get("/litters/puppies/{puppy_id}/weights") +async def get_weights(puppy_id: int, user=Depends(get_current_user_optional)): + with db() as conn: + rows = conn.execute( + "SELECT id, gewicht_g, gemessen_am FROM puppy_weights WHERE welpe_id=? ORDER BY gemessen_am DESC", + (puppy_id,) + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/litters/puppies/{id}/weight — Gewicht erfassen +# ------------------------------------------------------------------ +@router.post("/litters/puppies/{puppy_id}/weight", status_code=201) +async def add_weight(puppy_id: int, body: WeightEntry, user=Depends(_require_breeder)): + with db() as conn: + puppy = conn.execute( + """SELECT p.id, bp.user_id AS owner_user_id + FROM puppies p + JOIN litters l ON l.id = p.wurf_id + JOIN breeder_profiles bp ON bp.id = l.breeder_id + WHERE p.id=?""", + (puppy_id,) + ).fetchone() + if not puppy: + raise HTTPException(404, "Welpe nicht gefunden.") + if user["rolle"] != "admin" and puppy["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + + cur = conn.execute( + "INSERT INTO puppy_weights (welpe_id, gewicht_g, gemessen_am) VALUES (?,?,?)", + (puppy_id, body.gewicht_g, body.gemessen_am) + ) + row = conn.execute( + "SELECT * FROM puppy_weights WHERE id=?", (cur.lastrowid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# GET /api/litters/puppies/{id}/contract — Kaufvertrag als HTML +# ------------------------------------------------------------------ +@router.get("/litters/puppies/{puppy_id}/contract") +async def generate_contract( + puppy_id: int, + kaeufer_name: str, + kaeufer_adresse: str, + kaeufer_email: str = "", + preis: str = "", + user=Depends(_require_breeder), +): + with db() as conn: + puppy = conn.execute( + """SELECT p.*, l.geburt_datum, l.id AS litter_id, + bp.user_id AS owner_user_id, + bp.zwingername, bp.rasse_text, bp.stadt, + u.name AS zuechter_name, u.email AS zuechter_email + FROM puppies p + JOIN litters l ON l.id = p.wurf_id + JOIN breeder_profiles bp ON bp.id = l.breeder_id + JOIN users u ON u.id = bp.user_id + WHERE p.id=?""", + (puppy_id,) + ).fetchone() + + if not puppy: + raise HTTPException(404, "Welpe nicht gefunden.") + if user["rolle"] != "admin" and puppy["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + + def esc(s): + if not s: + return "" + return (str(s) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """)) + + heute = date.today().strftime("%d.%m.%Y") + + geschlecht_label = ( + "Rüde" if puppy["geschlecht"] == "maennlich" else + "Hündin" if puppy["geschlecht"] == "weiblich" else "—" + ) + + geburtsdatum = "" + if puppy["geburt_datum"]: + try: + from datetime import date as _date + gd = _date.fromisoformat(puppy["geburt_datum"]) + geburtsdatum = gd.strftime("%d.%m.%Y") + except Exception: + geburtsdatum = esc(puppy["geburt_datum"]) + + html = f""" + + + + Kaufvertrag — {esc(puppy['name'] or 'Welpe')} + + + + +

Datum: {heute}

+

Kaufvertrag über einen Welpen

+

+ Rassehund · {esc(puppy['rasse_text'] or '')} +

+ +

Verkäufer (Züchter)

+ + + + + +
Zwingername{esc(puppy['zwingername'] or '—')}
Name{esc(puppy['zuechter_name'] or '—')}
Ort{esc(puppy['stadt'] or '—')}
E-Mail{esc(puppy['zuechter_email'] or '—')}
+ +

Käufer

+ + + + +
Name{esc(kaeufer_name)}
Adresse{esc(kaeufer_adresse)}
E-Mail{esc(kaeufer_email) if kaeufer_email else '—'}
+ +

Welpe

+ + + + + + + +
Name{esc(puppy['name'] or '—')}
Geschlecht{geschlecht_label}
Rasse{esc(puppy['rasse_text'] or '—')}
Geburtsdatum{geburtsdatum or '—'}
Chip-Nr.{esc(puppy['chip_nr'] or '—')}
Farbe / Fell{esc(puppy['farbe'] or '—')}
+ +

Kaufpreis

+ + +
Vereinbarter Preis{esc(preis) if preis else '—'}
+ +
+

Allgemeine Vereinbarungen

+

Der Käufer bestätigt, den Welpen in einem einwandfreien Gesundheitszustand entgegengenommen zu haben. + Der Verkäufer sichert zu, dass der Welpe nach bestem Wissen und Gewissen aufgezogen wurde und die + angegebenen Gesundheitsinformationen der Wahrheit entsprechen. Der Käufer verpflichtet sich, den + Welpen artgerecht zu halten und tierärztlich versorgen zu lassen.

+
+ +
+
+
+

Ort, Datum & Unterschrift Verkäufer

+
+
+
+

Ort, Datum & Unterschrift Käufer

+
+
+ +

+ +

+ + +""" + + return HTMLResponse(content=html) diff --git a/backend/routes/zucht_hunde.py b/backend/routes/zucht_hunde.py new file mode 100644 index 0000000..3945737 --- /dev/null +++ b/backend/routes/zucht_hunde.py @@ -0,0 +1,779 @@ +"""BAN YARO — Zuchtkartei (Hunde, Gesundheitstests, Gentests, Titel, Stammbaum, IK)""" + +import logging +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from typing import Optional + +from database import db +from auth import get_current_user, get_current_user_optional + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------ +# 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 + + +# ------------------------------------------------------------------ +# Hilfsfunktionen: Ownership +# ------------------------------------------------------------------ +def _get_breeder_profile_id(user_id: int, conn) -> Optional[int]: + """Gibt die breeder_profiles.id des Users zurück, oder None.""" + row = conn.execute( + "SELECT id FROM breeder_profiles WHERE user_id=?", (user_id,) + ).fetchone() + return row["id"] if row else None + + +def _check_hund_owner(hund_id: int, user: dict, conn) -> dict: + """Gibt den Hund zurück wenn der User Eigentümer oder Admin ist.""" + row = conn.execute( + """SELECT zh.*, bp.user_id AS owner_user_id + FROM zucht_hunde zh + LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE zh.id=?""", + (hund_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Hund nicht gefunden.") + if user["rolle"] != "admin" and row["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + return dict(row) + + +def _check_hund_access(hund_id: int, user: Optional[dict], conn) -> dict: + """Zugriff auf Hund: öffentlich wenn is_public=1, sonst nur Owner/Admin.""" + row = conn.execute( + """SELECT zh.*, bp.user_id AS owner_user_id + FROM zucht_hunde zh + LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE zh.id=?""", + (hund_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Hund nicht gefunden.") + + is_owner = user and ( + user["rolle"] == "admin" or row["owner_user_id"] == user["id"] + ) + + if not row["is_public"] and not is_owner: + raise HTTPException(404, "Hund nicht gefunden.") + + return dict(row) + + +# ------------------------------------------------------------------ +# Stammbaum-Algorithmus +# ------------------------------------------------------------------ +def _build_tree(conn, hund_id, depth: int): + if depth == 0 or hund_id is None: + return None + row = conn.execute( + "SELECT * FROM zucht_hunde WHERE id=?", (hund_id,) + ).fetchone() + if not row: + return None + d = dict(row) + d["vater"] = _build_tree(conn, d["vater_id"], depth - 1) + d["mutter"] = _build_tree(conn, d["mutter_id"], depth - 1) + return d + + +# ------------------------------------------------------------------ +# Inzucht-Koeffizient (Wright's Formel) +# ------------------------------------------------------------------ +def _get_ancestors(conn, hund_id, depth: int, path: list) -> dict: + """Gibt {ancestor_id: [paths]} zurück.""" + if depth == 0 or hund_id is None: + return {} + row = conn.execute( + "SELECT vater_id, mutter_id, name FROM zucht_hunde WHERE id=?", (hund_id,) + ).fetchone() + if not row: + return {} + result = {hund_id: [path]} + for parent_id in [row["vater_id"], row["mutter_id"]]: + if parent_id: + sub = _get_ancestors(conn, parent_id, depth - 1, path + [hund_id]) + for aid, paths in sub.items(): + result.setdefault(aid, []).extend(paths) + return result + + +def _calculate_ik(conn, vater_id, mutter_id, generations: int = 8) -> float: + fa = _get_ancestors(conn, vater_id, generations, []) + ma = _get_ancestors(conn, mutter_id, generations, []) + common = set(fa.keys()) & set(ma.keys()) + ik = 0.0 + for aid in common: + for pf in fa[aid]: + for pm in ma[aid]: + ik += 0.5 ** (len(pf) + len(pm) + 1) + return round(ik * 100, 2) + + +def _ik_rating(ik: float) -> str: + if ik < 2.5: + return "optimal" + if ik < 6.25: + return "akzeptabel" + if ik < 12.5: + return "erhoeht" + return "kritisch" + + +# ------------------------------------------------------------------ +# Pydantic-Schemas +# ------------------------------------------------------------------ +class HundCreate(BaseModel): + name: str + rufname: Optional[str] = None + geschlecht: str # maennlich|weiblich + geburtsdatum: Optional[str] = None + sterbedatum: Optional[str] = None + chip_nr: Optional[str] = None + taetowiernummer: Optional[str] = None + zuchtbuchnummer: Optional[str] = None + farbe: Optional[str] = None + vater_id: Optional[int] = None + mutter_id: Optional[int] = None + zuechter_name: Optional[str] = None + eigentuemer_name: Optional[str] = None + is_public: int = 1 + notiz: Optional[str] = None + foto_url: Optional[str] = None + + +class HundUpdate(BaseModel): + name: Optional[str] = None + rufname: Optional[str] = None + geschlecht: Optional[str] = None + geburtsdatum: Optional[str] = None + sterbedatum: Optional[str] = None + chip_nr: Optional[str] = None + taetowiernummer: Optional[str] = None + zuchtbuchnummer: Optional[str] = None + farbe: Optional[str] = None + vater_id: Optional[int] = None + mutter_id: Optional[int] = None + zuechter_name: Optional[str] = None + eigentuemer_name: Optional[str] = None + is_public: Optional[int] = None + notiz: Optional[str] = None + foto_url: Optional[str] = None + + +class HealthTestCreate(BaseModel): + test_typ: str # HD|ED|OCD|augen|herz|patella|ZTP|custom + test_name: Optional[str] = None + ergebnis: Optional[str] = None + untersuch_am: Optional[str] = None + gueltig_bis: Optional[str] = None + untersucher: Optional[str] = None + labor: Optional[str] = None + zertifikat_nr: Optional[str] = None + is_public: int = 1 + + +class HealthTestUpdate(BaseModel): + test_typ: Optional[str] = None + test_name: Optional[str] = None + ergebnis: Optional[str] = None + untersuch_am: Optional[str] = None + gueltig_bis: Optional[str] = None + untersucher: Optional[str] = None + labor: Optional[str] = None + zertifikat_nr: Optional[str] = None + is_public: Optional[int] = None + + +class GeneticTestCreate(BaseModel): + marker_name: str # MDR1|PRA-prcd|DM|vWD|HUU etc. + marker_kategorie: Optional[str] = None # krankheit|farbe|eigenschaft + genotyp: Optional[str] = None # +/+|+/-|-/- + ergebnis_klasse: Optional[str] = None # clear|carrier|affected + getestet_am: Optional[str] = None + labor: Optional[str] = None + zertifikat_nr: Optional[str] = None + is_public: int = 1 + + +class GeneticTestUpdate(BaseModel): + marker_name: Optional[str] = None + marker_kategorie: Optional[str] = None + genotyp: Optional[str] = None + ergebnis_klasse: Optional[str] = None + getestet_am: Optional[str] = None + labor: Optional[str] = None + zertifikat_nr: Optional[str] = None + is_public: Optional[int] = None + + +class TitelCreate(BaseModel): + titel_typ: str # ausstellung|arbeit|sport|zucht|champion|custom + titel_name: str + verliehen_am: Optional[str] = None + ort: Optional[str] = None + richter: Optional[str] = None + ausstellung: Optional[str] = None + formwert: Optional[str] = None + is_public: int = 1 + + +class TitelUpdate(BaseModel): + titel_typ: Optional[str] = None + titel_name: Optional[str] = None + verliehen_am: Optional[str] = None + ort: Optional[str] = None + richter: Optional[str] = None + ausstellung: Optional[str] = None + formwert: Optional[str] = None + is_public: Optional[int] = None + + +class TrialMatingBody(BaseModel): + vater_id: int + mutter_id: int + + +# ================================================================== +# HUNDE CRUD +# ================================================================== + +# ------------------------------------------------------------------ +# GET /api/zuchthunde — eigene Hunde +# ------------------------------------------------------------------ +@router.get("/zuchthunde") +async def list_eigene_hunde(user=Depends(_require_breeder)): + with db() as conn: + if user["rolle"] == "admin": + profile_id = _get_breeder_profile_id(user["id"], conn) + if profile_id is None: + # Admin ohne Profil sieht alle Hunde + rows = conn.execute( + "SELECT * FROM zucht_hunde ORDER BY name ASC" + ).fetchall() + return [dict(r) for r in rows] + else: + profile_id = _get_breeder_profile_id(user["id"], conn) + if profile_id is None: + raise HTTPException(404, "Kein Züchter-Profil vorhanden.") + + rows = conn.execute( + "SELECT * FROM zucht_hunde WHERE breeder_id=? ORDER BY name ASC", + (profile_id,) + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/zuchthunde — Hund anlegen +# ------------------------------------------------------------------ +@router.post("/zuchthunde", status_code=201) +async def create_hund(body: HundCreate, user=Depends(_require_breeder)): + with db() as conn: + profile_id = _get_breeder_profile_id(user["id"], conn) + if profile_id is None and user["rolle"] != "admin": + raise HTTPException(404, "Kein Züchter-Profil vorhanden.") + + cur = conn.execute( + """INSERT INTO zucht_hunde + (breeder_id, name, rufname, geschlecht, geburtsdatum, sterbedatum, + chip_nr, taetowiernummer, zuchtbuchnummer, farbe, + vater_id, mutter_id, zuechter_name, eigentuemer_name, + is_public, notiz, foto_url) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + profile_id, + body.name, body.rufname, body.geschlecht, + body.geburtsdatum, body.sterbedatum, + body.chip_nr, body.taetowiernummer, body.zuchtbuchnummer, + body.farbe, body.vater_id, body.mutter_id, + body.zuechter_name, body.eigentuemer_name, + body.is_public, body.notiz, body.foto_url, + ) + ) + row = conn.execute( + "SELECT * FROM zucht_hunde WHERE id=?", (cur.lastrowid,) + ).fetchone() + return dict(row) + + +# ================================================================== +# FIXE ROUTEN vor {id} — Route-Reihenfolge kritisch! +# ================================================================== + +# ------------------------------------------------------------------ +# POST /api/zuchthunde/trial-mating — Probeverpaarung / IK-Berechnung +# ------------------------------------------------------------------ +@router.post("/zuchthunde/trial-mating") +async def trial_mating(body: TrialMatingBody, user=Depends(_require_breeder)): + with db() as conn: + vater = conn.execute( + "SELECT id, name FROM zucht_hunde WHERE id=?", (body.vater_id,) + ).fetchone() + if not vater: + raise HTTPException(404, "Vater nicht gefunden.") + + mutter = conn.execute( + "SELECT id, name FROM zucht_hunde WHERE id=?", (body.mutter_id,) + ).fetchone() + if not mutter: + raise HTTPException(404, "Mutter nicht gefunden.") + + ik_prozent = _calculate_ik(conn, body.vater_id, body.mutter_id, generations=8) + rating = _ik_rating(ik_prozent) + + # Gemeinsame Vorfahren mit Namen ermitteln + fa = _get_ancestors(conn, body.vater_id, 8, []) + ma = _get_ancestors(conn, body.mutter_id, 8, []) + common_ids = set(fa.keys()) & set(ma.keys()) + + gemeinsame_vorfahren = [] + for aid in common_ids: + anc = conn.execute( + "SELECT id, name FROM zucht_hunde WHERE id=?", (aid,) + ).fetchone() + if not anc: + continue + # Minimale Pfadlängen für Anzeige + min_gen_vater = min(len(p) for p in fa[aid]) + min_gen_mutter = min(len(p) for p in ma[aid]) + gemeinsame_vorfahren.append({ + "id": anc["id"], + "name": anc["name"], + "gen_vater": min_gen_vater, + "gen_mutter": min_gen_mutter, + }) + + gemeinsame_vorfahren.sort(key=lambda x: x["gen_vater"] + x["gen_mutter"]) + + return { + "ik_prozent": ik_prozent, + "ik_rating": rating, + "gemeinsame_vorfahren": gemeinsame_vorfahren, + } + + +# ------------------------------------------------------------------ +# PUT /api/zuchthunde/health-tests/{tid} +# ------------------------------------------------------------------ +@router.put("/zuchthunde/health-tests/{tid}") +async def update_health_test(tid: int, body: HealthTestUpdate, user=Depends(_require_breeder)): + with db() as conn: + test = conn.execute( + """SELECT ht.*, bp.user_id AS owner_user_id + FROM dog_health_tests ht + JOIN zucht_hunde zh ON zh.id = ht.hund_id + LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE ht.id=?""", + (tid,) + ).fetchone() + if not test: + raise HTTPException(404, "Gesundheitstest nicht gefunden.") + if user["rolle"] != "admin" and test["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + + fields, params = [], [] + for field, value in body.model_dump(exclude_none=True).items(): + fields.append(f"{field}=?") + params.append(value) + + if not fields: + raise HTTPException(400, "Keine Felder zum Aktualisieren.") + + params.append(tid) + conn.execute( + f"UPDATE dog_health_tests SET {', '.join(fields)} WHERE id=?", params + ) + row = conn.execute( + "SELECT * FROM dog_health_tests WHERE id=?", (tid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/zuchthunde/health-tests/{tid} +# ------------------------------------------------------------------ +@router.delete("/zuchthunde/health-tests/{tid}", status_code=204) +async def delete_health_test(tid: int, user=Depends(_require_breeder)): + with db() as conn: + test = conn.execute( + """SELECT ht.id, bp.user_id AS owner_user_id + FROM dog_health_tests ht + JOIN zucht_hunde zh ON zh.id = ht.hund_id + LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE ht.id=?""", + (tid,) + ).fetchone() + if not test: + raise HTTPException(404, "Gesundheitstest nicht gefunden.") + if user["rolle"] != "admin" and test["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + conn.execute("DELETE FROM dog_health_tests WHERE id=?", (tid,)) + return None + + +# ------------------------------------------------------------------ +# PUT /api/zuchthunde/genetic-tests/{tid} +# ------------------------------------------------------------------ +@router.put("/zuchthunde/genetic-tests/{tid}") +async def update_genetic_test(tid: int, body: GeneticTestUpdate, user=Depends(_require_breeder)): + with db() as conn: + test = conn.execute( + """SELECT gt.*, bp.user_id AS owner_user_id + FROM dog_genetic_tests gt + JOIN zucht_hunde zh ON zh.id = gt.hund_id + LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE gt.id=?""", + (tid,) + ).fetchone() + if not test: + raise HTTPException(404, "Gentest nicht gefunden.") + if user["rolle"] != "admin" and test["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + + fields, params = [], [] + for field, value in body.model_dump(exclude_none=True).items(): + fields.append(f"{field}=?") + params.append(value) + + if not fields: + raise HTTPException(400, "Keine Felder zum Aktualisieren.") + + params.append(tid) + conn.execute( + f"UPDATE dog_genetic_tests SET {', '.join(fields)} WHERE id=?", params + ) + row = conn.execute( + "SELECT * FROM dog_genetic_tests WHERE id=?", (tid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/zuchthunde/genetic-tests/{tid} +# ------------------------------------------------------------------ +@router.delete("/zuchthunde/genetic-tests/{tid}", status_code=204) +async def delete_genetic_test(tid: int, user=Depends(_require_breeder)): + with db() as conn: + test = conn.execute( + """SELECT gt.id, bp.user_id AS owner_user_id + FROM dog_genetic_tests gt + JOIN zucht_hunde zh ON zh.id = gt.hund_id + LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE gt.id=?""", + (tid,) + ).fetchone() + if not test: + raise HTTPException(404, "Gentest nicht gefunden.") + if user["rolle"] != "admin" and test["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + conn.execute("DELETE FROM dog_genetic_tests WHERE id=?", (tid,)) + return None + + +# ------------------------------------------------------------------ +# PUT /api/zuchthunde/titles/{tid} +# ------------------------------------------------------------------ +@router.put("/zuchthunde/titles/{tid}") +async def update_titel(tid: int, body: TitelUpdate, user=Depends(_require_breeder)): + with db() as conn: + titel = conn.execute( + """SELECT dt.*, bp.user_id AS owner_user_id + FROM dog_titles dt + JOIN zucht_hunde zh ON zh.id = dt.hund_id + LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE dt.id=?""", + (tid,) + ).fetchone() + if not titel: + raise HTTPException(404, "Titel nicht gefunden.") + if user["rolle"] != "admin" and titel["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + + fields, params = [], [] + for field, value in body.model_dump(exclude_none=True).items(): + fields.append(f"{field}=?") + params.append(value) + + if not fields: + raise HTTPException(400, "Keine Felder zum Aktualisieren.") + + params.append(tid) + conn.execute( + f"UPDATE dog_titles SET {', '.join(fields)} WHERE id=?", params + ) + row = conn.execute( + "SELECT * FROM dog_titles WHERE id=?", (tid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/zuchthunde/titles/{tid} +# ------------------------------------------------------------------ +@router.delete("/zuchthunde/titles/{tid}", status_code=204) +async def delete_titel(tid: int, user=Depends(_require_breeder)): + with db() as conn: + titel = conn.execute( + """SELECT dt.id, bp.user_id AS owner_user_id + FROM dog_titles dt + JOIN zucht_hunde zh ON zh.id = dt.hund_id + LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE dt.id=?""", + (tid,) + ).fetchone() + if not titel: + raise HTTPException(404, "Titel nicht gefunden.") + if user["rolle"] != "admin" and titel["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + conn.execute("DELETE FROM dog_titles WHERE id=?", (tid,)) + return None + + +# ================================================================== +# {id}-ROUTEN — nach den fixen Pfaden! +# ================================================================== + +# ------------------------------------------------------------------ +# GET /api/zuchthunde/{id} — Hund-Detail +# ------------------------------------------------------------------ +@router.get("/zuchthunde/{hund_id}") +async def get_hund(hund_id: int, user=Depends(get_current_user_optional)): + with db() as conn: + hund = _check_hund_access(hund_id, user, conn) + return hund + + +# ------------------------------------------------------------------ +# PUT /api/zuchthunde/{id} — bearbeiten +# ------------------------------------------------------------------ +@router.put("/zuchthunde/{hund_id}") +async def update_hund(hund_id: int, body: HundUpdate, user=Depends(_require_breeder)): + with db() as conn: + _check_hund_owner(hund_id, user, conn) + + fields, params = [], [] + for field, value in body.model_dump(exclude_none=True).items(): + fields.append(f"{field}=?") + params.append(value) + + if not fields: + raise HTTPException(400, "Keine Felder zum Aktualisieren.") + + params.append(hund_id) + conn.execute( + f"UPDATE zucht_hunde SET {', '.join(fields)} WHERE id=?", params + ) + row = conn.execute( + "SELECT * FROM zucht_hunde WHERE id=?", (hund_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/zuchthunde/{id} — löschen (cascade) +# ------------------------------------------------------------------ +@router.delete("/zuchthunde/{hund_id}", status_code=204) +async def delete_hund(hund_id: int, user=Depends(_require_breeder)): + with db() as conn: + _check_hund_owner(hund_id, user, conn) + conn.execute("DELETE FROM dog_health_tests WHERE hund_id=?", (hund_id,)) + conn.execute("DELETE FROM dog_genetic_tests WHERE hund_id=?", (hund_id,)) + conn.execute("DELETE FROM dog_titles WHERE hund_id=?", (hund_id,)) + conn.execute("DELETE FROM zucht_hunde WHERE id=?", (hund_id,)) + return None + + +# ------------------------------------------------------------------ +# GET /api/zuchthunde/{id}/pedigree — Stammbaum +# ------------------------------------------------------------------ +@router.get("/zuchthunde/{hund_id}/pedigree") +async def get_pedigree( + hund_id: int, + generations: int = Query(default=4, ge=1, le=8), + user=Depends(get_current_user_optional), +): + with db() as conn: + _check_hund_access(hund_id, user, conn) + tree = _build_tree(conn, hund_id, generations) + if not tree: + raise HTTPException(404, "Hund nicht gefunden.") + return tree + + +# ================================================================== +# GESUNDHEITSTESTS +# ================================================================== + +# ------------------------------------------------------------------ +# GET /api/zuchthunde/{id}/health-tests +# ------------------------------------------------------------------ +@router.get("/zuchthunde/{hund_id}/health-tests") +async def list_health_tests(hund_id: int, user=Depends(get_current_user_optional)): + with db() as conn: + _check_hund_access(hund_id, user, conn) + + is_owner = user and ( + user["rolle"] == "admin" + or conn.execute( + """SELECT 1 FROM zucht_hunde zh + LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE zh.id=? AND bp.user_id=?""", + (hund_id, user["id"]) + ).fetchone() is not None + ) + + q = "SELECT * FROM dog_health_tests WHERE hund_id=?" + params = [hund_id] + if not is_owner: + q += " AND is_public=1" + rows = conn.execute(q + " ORDER BY untersuch_am DESC", params).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/zuchthunde/{id}/health-tests +# ------------------------------------------------------------------ +@router.post("/zuchthunde/{hund_id}/health-tests", status_code=201) +async def create_health_test( + hund_id: int, body: HealthTestCreate, user=Depends(_require_breeder) +): + with db() as conn: + _check_hund_owner(hund_id, user, conn) + cur = conn.execute( + """INSERT INTO dog_health_tests + (hund_id, test_typ, test_name, ergebnis, untersuch_am, gueltig_bis, + untersucher, labor, zertifikat_nr, is_public) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + ( + hund_id, body.test_typ, body.test_name, body.ergebnis, + body.untersuch_am, body.gueltig_bis, body.untersucher, + body.labor, body.zertifikat_nr, body.is_public, + ) + ) + row = conn.execute( + "SELECT * FROM dog_health_tests WHERE id=?", (cur.lastrowid,) + ).fetchone() + return dict(row) + + +# ================================================================== +# GENTESTS +# ================================================================== + +# ------------------------------------------------------------------ +# GET /api/zuchthunde/{id}/genetic-tests +# ------------------------------------------------------------------ +@router.get("/zuchthunde/{hund_id}/genetic-tests") +async def list_genetic_tests(hund_id: int, user=Depends(get_current_user_optional)): + with db() as conn: + _check_hund_access(hund_id, user, conn) + + is_owner = user and ( + user["rolle"] == "admin" + or conn.execute( + """SELECT 1 FROM zucht_hunde zh + LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE zh.id=? AND bp.user_id=?""", + (hund_id, user["id"]) + ).fetchone() is not None + ) + + q = "SELECT * FROM dog_genetic_tests WHERE hund_id=?" + params = [hund_id] + if not is_owner: + q += " AND is_public=1" + rows = conn.execute(q + " ORDER BY getestet_am DESC", params).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/zuchthunde/{id}/genetic-tests +# ------------------------------------------------------------------ +@router.post("/zuchthunde/{hund_id}/genetic-tests", status_code=201) +async def create_genetic_test( + hund_id: int, body: GeneticTestCreate, user=Depends(_require_breeder) +): + with db() as conn: + _check_hund_owner(hund_id, user, conn) + cur = conn.execute( + """INSERT INTO dog_genetic_tests + (hund_id, marker_name, marker_kategorie, genotyp, ergebnis_klasse, + getestet_am, labor, zertifikat_nr, is_public) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + hund_id, body.marker_name, body.marker_kategorie, body.genotyp, + body.ergebnis_klasse, body.getestet_am, body.labor, + body.zertifikat_nr, body.is_public, + ) + ) + row = conn.execute( + "SELECT * FROM dog_genetic_tests WHERE id=?", (cur.lastrowid,) + ).fetchone() + return dict(row) + + +# ================================================================== +# TITEL +# ================================================================== + +# ------------------------------------------------------------------ +# GET /api/zuchthunde/{id}/titles +# ------------------------------------------------------------------ +@router.get("/zuchthunde/{hund_id}/titles") +async def list_titles(hund_id: int, user=Depends(get_current_user_optional)): + with db() as conn: + _check_hund_access(hund_id, user, conn) + + is_owner = user and ( + user["rolle"] == "admin" + or conn.execute( + """SELECT 1 FROM zucht_hunde zh + LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE zh.id=? AND bp.user_id=?""", + (hund_id, user["id"]) + ).fetchone() is not None + ) + + q = "SELECT * FROM dog_titles WHERE hund_id=?" + params = [hund_id] + if not is_owner: + q += " AND is_public=1" + rows = conn.execute(q + " ORDER BY verliehen_am DESC", params).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/zuchthunde/{id}/titles +# ------------------------------------------------------------------ +@router.post("/zuchthunde/{hund_id}/titles", status_code=201) +async def create_titel( + hund_id: int, body: TitelCreate, user=Depends(_require_breeder) +): + with db() as conn: + _check_hund_owner(hund_id, user, conn) + cur = conn.execute( + """INSERT INTO dog_titles + (hund_id, titel_typ, titel_name, verliehen_am, ort, + richter, ausstellung, formwert, is_public) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + hund_id, body.titel_typ, body.titel_name, body.verliehen_am, + body.ort, body.richter, body.ausstellung, body.formwert, + body.is_public, + ) + ) + row = conn.execute( + "SELECT * FROM dog_titles WHERE id=?", (cur.lastrowid,) + ).fetchone() + return dict(row) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 7c1d1e6..073821a 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -6551,6 +6551,132 @@ svg.empty-state-icon { font-size: var(--text-sm); } +/* ------------------------------------------------------------ + WURFBÖRSE + ------------------------------------------------------------ */ +.wb-layout { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.wb-filter-bar { + display: flex; + gap: var(--space-2); + align-items: flex-end; + flex-wrap: wrap; +} + +.wb-filter-fields { + display: flex; + gap: var(--space-2); + flex: 1; + flex-wrap: wrap; +} + +.wb-filter-rasse { flex: 2; min-width: 160px; } +.wb-filter-status { flex: 1; min-width: 130px; } + +.wb-filter-btn { white-space: nowrap; } + +.wb-cards { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); +} + +@media (min-width: 768px) { + .wb-cards { grid-template-columns: repeat(2, 1fr); } +} + +@media (min-width: 1200px) { + .wb-cards { grid-template-columns: repeat(3, 1fr); } +} + +.wb-card { + background: var(--c-surface); + border: 1px solid var(--c-border-light); + border-radius: var(--radius-lg); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-2); + transition: box-shadow .15s; +} + +.wb-card:hover { box-shadow: var(--shadow-md); } + +.wb-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-2); +} + +.wb-card-zuechter { + font-weight: var(--weight-semibold); + font-size: var(--text-sm); + color: var(--c-text); + line-height: 1.3; +} + +.wb-card-rasse { + font-size: var(--text-base); + font-weight: var(--weight-semibold); + color: var(--c-primary); + display: flex; + align-items: center; + gap: var(--space-1); +} + +.wb-card-details { + display: flex; + flex-direction: column; + gap: var(--space-1); + font-size: var(--text-sm); + color: var(--c-text-secondary); + flex: 1; +} + +.wb-card-eltern, +.wb-card-datum, +.wb-card-welpen, +.wb-card-preis, +.wb-card-gesundheit { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.wb-card-beschreibung { + margin-top: var(--space-1); + line-height: 1.4; + color: var(--c-text-secondary); +} + +.wb-card-footer { + margin-top: var(--space-2); + padding-top: var(--space-2); + border-top: 1px solid var(--c-border-light); +} + +/* Status-Badges */ +.wb-badge { + display: inline-block; + padding: 2px 10px; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: #fff; + white-space: nowrap; + flex-shrink: 0; +} + +.wb-badge--geplant { background: #6B7280; } +.wb-badge--geboren { background: #3B82F6; } +.wb-badge--verfuegbar { background: #22C55E; } +.wb-badge--abgeschlossen { background: #374151; } + /* ------------------------------------------------------------ OFFLINE-BANNER ------------------------------------------------------------ */ diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index e8fb003..7bc767e 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -177,4 +177,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index 9c55546..ce53a42 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -173,6 +173,9 @@ + @@ -183,6 +186,15 @@ Erste Hilfe + + +