diff --git a/backend/database.py b/backend/database.py index 3d9757f..5c5516a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2265,6 +2265,82 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration behavior_log: {e}") + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS laeufi_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE, + beginn TEXT NOT NULL, + ende TEXT, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS progesteron_tests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + laeufi_id INTEGER NOT NULL REFERENCES laeufi_log(id) ON DELETE CASCADE, + datum TEXT NOT NULL, + wert REAL, + einheit TEXT NOT NULL DEFAULT 'ng/ml', + labor TEXT, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS deckdaten ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE, + laeufi_id INTEGER REFERENCES laeufi_log(id) ON DELETE SET NULL, + deckdatum TEXT NOT NULL, + ruede_id INTEGER REFERENCES zucht_hunde(id) ON DELETE SET NULL, + ruede_name TEXT, + deckart TEXT NOT NULL DEFAULT 'natuerlich', + traechtig INTEGER NOT NULL DEFAULT 0, + ultraschall_datum TEXT, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_laeufi_hund ON laeufi_log(hund_id, beginn DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_prog_laeufi ON progesteron_tests(laeufi_id, datum)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_deck_hund ON deckdaten(hund_id, deckdatum DESC)") + logger.info("Migration: laeufi_log, progesteron_tests, deckdaten bereit.") + except Exception as e: + logger.warning(f"Migration laeufi: {e}") + + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS litter_waitlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + litter_id INTEGER NOT NULL REFERENCES litters(id) ON DELETE CASCADE, + name TEXT NOT NULL, + email TEXT, + telefon TEXT, + nachricht TEXT, + wunsch_geschlecht TEXT DEFAULT 'egal', + wunsch_farbe TEXT, + prioritaet INTEGER DEFAULT 0, + status TEXT DEFAULT 'anfrage', + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_waitlist_litter ON litter_waitlist(litter_id, prioritaet)") + logger.info("Migration: litter_waitlist bereit.") + except Exception as e: + logger.warning(f"Migration litter_waitlist: {e}") + + try: + conn.execute("ALTER TABLE litters ADD COLUMN wurf_rang TEXT") + except Exception: + pass + try: + conn.execute("ALTER TABLE litters ADD COLUMN wurf_name TEXT") + except Exception: + pass + # route_dogs: bestehende Routen allen Hunden des Users zuweisen try: existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0] diff --git a/backend/main.py b/backend/main.py index 456dc43..4bcd9ba 100644 --- a/backend/main.py +++ b/backend/main.py @@ -156,17 +156,15 @@ app.add_middleware(_AppVersionMiddleware) class _CacheControlMiddleware(BaseHTTPMiddleware): """Setzt Cache-Control-Header für statische Assets. - CSS/JS: no-cache (ETag-Validierung) — iOS cached sonst ewig ohne Ablaufdatum. - Versioned Assets (?v=…): immutable — URL ändert sich bei Updates. + JS/CSS: immer no-cache — SW übernimmt Caching. Immutable wäre gefährlich, + weil Browser-HTTP-Cache nach force-update nicht geleert wird und veraltete + app.js mit falschem APP_VER eine Update-Dauerschleife verursacht. """ async def dispatch(self, request: Request, call_next): response = await call_next(request) path = request.url.path if path.startswith(("/css/", "/js/", "/icons/phosphor.svg")): - if "v=" in str(request.url.query): - response.headers["Cache-Control"] = "public, max-age=31536000, immutable" - else: - response.headers["Cache-Control"] = "no-cache, must-revalidate" + response.headers["Cache-Control"] = "no-cache, must-revalidate" return response app.add_middleware(_CacheControlMiddleware) @@ -235,6 +233,7 @@ 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.laeufi import router as laeufi_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 @@ -281,6 +280,7 @@ 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(laeufi_router, prefix="/api", tags=["Läufigkeit"]) 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"]) @@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "885" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "918" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): @@ -523,6 +523,11 @@ async def info_page(): return FileResponse(f"{STATIC_DIR}/landing.html", headers={"Cache-Control": "max-age=3600"}) +@app.get("/zuechter") +async def zuechter_landing(): + return FileResponse(f"{STATIC_DIR}/zuechter.html", headers={"Cache-Control": "max-age=3600"}) + + # ------------------------------------------------------------------ # SEO: Server-gerenderete Wiki-Rassen-Übersicht /wiki/rassen # ------------------------------------------------------------------ diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index 355a575..1afe535 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -54,15 +54,25 @@ async def breeder_status(user=Depends(get_current_user)): profile = None if row["rolle"] in ("breeder", "admin"): profile = conn.execute( - "SELECT zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at " + "SELECT id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at " "FROM breeder_profiles WHERE user_id=?", (user["id"],) ).fetchone() - return { + if profile: + logo = conn.execute( + """SELECT file_path FROM breeder_photos + WHERE breeder_id=? AND entity_type='breeder' + ORDER BY is_primary DESC, id LIMIT 1""", + (profile["id"],) + ).fetchone() + result = { "rolle": row["rolle"], "breeder_status": row["breeder_status"], "profile": dict(profile) if profile else None, } + if profile: + result["profile"]["logo_url"] = f"/media/{logo['file_path']}" if logo else None + return result # ------------------------------------------------------------------ @@ -301,7 +311,7 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req # ------------------------------------------------------------------ -# GET /api/breeder/profil/{zwingername} — öffentliches Profil +# GET /api/breeder/profil/{zwingername} — öffentliches Profil (angereichert) # ------------------------------------------------------------------ @router.get("/breeder/profil/{zwingername}") async def breeder_public_profile(zwingername: str): @@ -315,12 +325,114 @@ async def breeder_public_profile(zwingername: str): 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' + AND u.rolle IN ('breeder', 'admin') + AND (u.breeder_status = 'approved' OR u.rolle = 'admin') """, (zwingername,)).fetchone() - if not row: - raise HTTPException(404, "Züchter nicht gefunden.") - return dict(row) + if not row: + raise HTTPException(404, "Züchter nicht gefunden.") + + breeder_id = row["id"] + result = dict(row) + + # Öffentliche Zuchthunde + ihre wichtigsten Gesundheitstests + Titel + hunde_rows = conn.execute(""" + SELECT id, name, rufname, geschlecht, geburtsdatum, farbe, zuchtbuchnummer, foto_url + FROM zucht_hunde + WHERE breeder_id=? AND is_public=1 AND (sterbedatum IS NULL OR sterbedatum='') + ORDER BY geschlecht, name + """, (breeder_id,)).fetchall() + + hunde = [] + for h in hunde_rows: + hund = dict(h) + # Gesundheitstests (nur öffentliche, nur HD/ED/Augen/Herz) + tests = conn.execute(""" + SELECT test_typ, ergebnis, test_name, untersuch_am + FROM dog_health_tests + WHERE hund_id=? AND is_public=1 + AND test_typ IN ('HD','ED','augen','herz','OCD','patella','ZTP') + ORDER BY test_typ, untersuch_am DESC + """, (h["id"],)).fetchall() + seen = set() + hund["health_tests"] = [] + for t in tests: + if t["test_typ"] not in seen: + seen.add(t["test_typ"]) + hund["health_tests"].append(dict(t)) + # Gentests (nur öffentliche, Zusammenfassung) + gentests = conn.execute(""" + SELECT COUNT(*) as total, + SUM(CASE WHEN ergebnis_klasse='clear' THEN 1 ELSE 0 END) as clear_cnt + FROM dog_genetic_tests WHERE hund_id=? AND is_public=1 + """, (h["id"],)).fetchone() + hund["gentests_total"] = gentests["total"] or 0 + hund["gentests_clear"] = gentests["clear_cnt"] or 0 + # Auszeichnungen (nur Zucht/Champion) + titles = conn.execute(""" + SELECT titel_name FROM dog_titles + WHERE hund_id=? AND titel_typ IN ('champion','zucht','ausstellung') + ORDER BY verliehen_am DESC LIMIT 3 + """, (h["id"],)).fetchall() + hund["titel"] = [t["titel_name"] for t in titles] + hunde.append(hund) + + result["hunde"] = hunde + + # Sichtbare Würfe + wuerfe = conn.execute(""" + SELECT id, vater_name, mutter_name, geburt_datum, erwartetes_datum, + status, welpen_gesamt, welpen_verfuegbar, preis_spanne, beschreibung + FROM litters + WHERE breeder_id=? AND sichtbar=1 AND status != 'abgeschlossen' + ORDER BY COALESCE(geburt_datum, erwartetes_datum) DESC + """, (breeder_id,)).fetchall() + result["wuerfe"] = [dict(w) for w in wuerfe] + + # Gesundheits-Statistik (aggregiert über alle öffentlichen Hunde) + hd_stats = conn.execute(""" + SELECT ergebnis, COUNT(*) as cnt FROM dog_health_tests + WHERE hund_id IN (SELECT id FROM zucht_hunde WHERE breeder_id=? AND is_public=1) + AND test_typ='HD' AND is_public=1 + GROUP BY ergebnis + """, (breeder_id,)).fetchall() + result["hd_stats"] = [dict(r) for r in hd_stats] + + ed_stats = conn.execute(""" + SELECT ergebnis, COUNT(*) as cnt FROM dog_health_tests + WHERE hund_id IN (SELECT id FROM zucht_hunde WHERE breeder_id=? AND is_public=1) + AND test_typ='ED' AND is_public=1 + GROUP BY ergebnis + """, (breeder_id,)).fetchall() + result["ed_stats"] = [dict(r) for r in ed_stats] + + # Logo = primäres Bild der entity_type='breeder' Fotos + logo = conn.execute(""" + SELECT file_path FROM breeder_photos + WHERE breeder_id=? AND entity_type='breeder' AND is_primary=1 + LIMIT 1 + """, (breeder_id,)).fetchone() + if not logo: + logo = conn.execute(""" + SELECT file_path FROM breeder_photos + WHERE breeder_id=? AND entity_type='breeder' + ORDER BY sort_order, id LIMIT 1 + """, (breeder_id,)).fetchone() + result["logo_url"] = f"/media/{logo['file_path']}" if logo else None + + # Öffentliche Fotos für die Gallery (alle entity_type='breeder', max. 12) + photos = conn.execute(""" + SELECT file_path, thumbnail_path, caption, is_primary FROM breeder_photos + WHERE breeder_id=? AND entity_type='breeder' AND visibility IN ('public','inquiry') + ORDER BY is_primary DESC, sort_order, id LIMIT 12 + """, (breeder_id,)).fetchall() + result["fotos"] = [{ + "url": f"/media/{p['file_path']}", + "thumb": f"/media/{p['thumbnail_path']}" if p['thumbnail_path'] else f"/media/{p['file_path']}", + "caption": p["caption"] or "", + "primary": bool(p["is_primary"]), + } for p in photos] + + return result # ------------------------------------------------------------------ diff --git a/backend/routes/laeufi.py b/backend/routes/laeufi.py new file mode 100644 index 0000000..22189bd --- /dev/null +++ b/backend/routes/laeufi.py @@ -0,0 +1,307 @@ +"""BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from datetime import date, timedelta + +from database import db +from auth import get_current_user + +router = APIRouter() + + +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 _check_hund_owner(hund_id: int, user: dict, conn): + row = conn.execute( + """SELECT zh.id, bp.user_id AS owner_user_id FROM zucht_hunde zh + 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 row + + +def _check_laeufi_owner(laeufi_id: int, user: dict, conn): + row = conn.execute( + """SELECT l.id, bp.user_id AS owner_user_id FROM laeufi_log l + JOIN zucht_hunde zh ON zh.id = l.hund_id + JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE l.id=?""", (laeufi_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Läufigkeit nicht gefunden.") + if user["rolle"] != "admin" and row["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + return row + + +# ------------------------------------------------------------------ +# Meilensteine berechnen +# ------------------------------------------------------------------ +_MEILENSTEINE = [ + (21, "Frühester Ultraschall möglich"), + (25, "Ultraschall empfohlen (Welpen erkennbar)"), + (35, "Bauch wird sichtbar"), + (45, "Röntgen möglich (Skelettanzahl)"), + (56, "Wurfbox aufstellen"), + (58, "Tägliche Temperaturmessung beginnen"), + (63, "Erwarteter Geburtstermin"), + (65, "Tierarzt konsultieren wenn keine Geburt"), +] + +def _calc_meilensteine(deckdatum_str: str) -> list: + try: + deck = date.fromisoformat(deckdatum_str) + except Exception: + return [] + return [ + { + "tag": tag, + "datum": (deck + timedelta(days=tag)).isoformat(), + "label": label, + "vorbei": (deck + timedelta(days=tag)) < date.today(), + } + for tag, label in _MEILENSTEINE + ] + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class LaeufiCreate(BaseModel): + beginn: str + ende: Optional[str] = None + notiz: Optional[str] = None + +class LaeufiUpdate(BaseModel): + beginn: Optional[str] = None + ende: Optional[str] = None + notiz: Optional[str] = None + +class ProgestCreate(BaseModel): + datum: str + wert: Optional[float] = None + einheit: str = "ng/ml" + labor: Optional[str] = None + notiz: Optional[str] = None + +class ProgestUpdate(BaseModel): + datum: Optional[str] = None + wert: Optional[float] = None + einheit: Optional[str] = None + labor: Optional[str] = None + notiz: Optional[str] = None + +class DeckCreate(BaseModel): + deckdatum: str + laeufi_id: Optional[int] = None + ruede_id: Optional[int] = None + ruede_name: Optional[str] = None + deckart: str = "natuerlich" + traechtig: int = 0 + ultraschall_datum: Optional[str] = None + notiz: Optional[str] = None + +class DeckUpdate(BaseModel): + deckdatum: Optional[str] = None + ruede_id: Optional[int] = None + ruede_name: Optional[str] = None + deckart: Optional[str] = None + traechtig: Optional[int] = None + ultraschall_datum: Optional[str] = None + notiz: Optional[str] = None + + +# ------------------------------------------------------------------ +# Läufigkeit +# ------------------------------------------------------------------ +@router.get("/laeufi/{hund_id}") +async def list_laeufi(hund_id: int, user=Depends(_require_breeder)): + with db() as conn: + _check_hund_owner(hund_id, user, conn) + rows = conn.execute( + "SELECT * FROM laeufi_log WHERE hund_id=? ORDER BY beginn DESC", + (hund_id,) + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("/laeufi/{hund_id}", status_code=201) +async def add_laeufi(hund_id: int, body: LaeufiCreate, user=Depends(_require_breeder)): + with db() as conn: + _check_hund_owner(hund_id, user, conn) + cur = conn.execute( + "INSERT INTO laeufi_log (hund_id, beginn, ende, notiz) VALUES (?,?,?,?)", + (hund_id, body.beginn, body.ende, body.notiz) + ) + row = conn.execute("SELECT * FROM laeufi_log WHERE id=?", (cur.lastrowid,)).fetchone() + return dict(row) + + +@router.put("/laeufi/entry/{laeufi_id}") +async def update_laeufi(laeufi_id: int, body: LaeufiUpdate, user=Depends(_require_breeder)): + with db() as conn: + _check_laeufi_owner(laeufi_id, user, conn) + fields = {k: v for k, v in body.model_dump().items() if v is not None} + if fields: + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute(f"UPDATE laeufi_log SET {sets} WHERE id=?", (*fields.values(), laeufi_id)) + row = conn.execute("SELECT * FROM laeufi_log WHERE id=?", (laeufi_id,)).fetchone() + return dict(row) + + +@router.delete("/laeufi/entry/{laeufi_id}", status_code=204) +async def delete_laeufi(laeufi_id: int, user=Depends(_require_breeder)): + with db() as conn: + _check_laeufi_owner(laeufi_id, user, conn) + conn.execute("DELETE FROM laeufi_log WHERE id=?", (laeufi_id,)) + + +# ------------------------------------------------------------------ +# Progesterontests +# ------------------------------------------------------------------ +@router.get("/laeufi/entry/{laeufi_id}/prog") +async def list_prog(laeufi_id: int, user=Depends(_require_breeder)): + with db() as conn: + _check_laeufi_owner(laeufi_id, user, conn) + rows = conn.execute( + "SELECT * FROM progesteron_tests WHERE laeufi_id=? ORDER BY datum", + (laeufi_id,) + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("/laeufi/entry/{laeufi_id}/prog", status_code=201) +async def add_prog(laeufi_id: int, body: ProgestCreate, user=Depends(_require_breeder)): + with db() as conn: + _check_laeufi_owner(laeufi_id, user, conn) + cur = conn.execute( + "INSERT INTO progesteron_tests (laeufi_id, datum, wert, einheit, labor, notiz) VALUES (?,?,?,?,?,?)", + (laeufi_id, body.datum, body.wert, body.einheit, body.labor, body.notiz) + ) + row = conn.execute("SELECT * FROM progesteron_tests WHERE id=?", (cur.lastrowid,)).fetchone() + return dict(row) + + +@router.put("/laeufi/prog/{prog_id}") +async def update_prog(prog_id: int, body: ProgestUpdate, user=Depends(_require_breeder)): + with db() as conn: + pt = conn.execute( + """SELECT pt.id, bp.user_id AS owner_user_id FROM progesteron_tests pt + JOIN laeufi_log l ON l.id = pt.laeufi_id + JOIN zucht_hunde zh ON zh.id = l.hund_id + JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE pt.id=?""", (prog_id,) + ).fetchone() + if not pt: + raise HTTPException(404) + if user["rolle"] != "admin" and pt["owner_user_id"] != user["id"]: + raise HTTPException(403) + fields = {k: v for k, v in body.model_dump().items() if v is not None} + if fields: + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute(f"UPDATE progesteron_tests SET {sets} WHERE id=?", (*fields.values(), prog_id)) + row = conn.execute("SELECT * FROM progesteron_tests WHERE id=?", (prog_id,)).fetchone() + return dict(row) + + +@router.delete("/laeufi/prog/{prog_id}", status_code=204) +async def delete_prog(prog_id: int, user=Depends(_require_breeder)): + with db() as conn: + pt = conn.execute( + """SELECT pt.id, bp.user_id AS owner_user_id FROM progesteron_tests pt + JOIN laeufi_log l ON l.id = pt.laeufi_id + JOIN zucht_hunde zh ON zh.id = l.hund_id + JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE pt.id=?""", (prog_id,) + ).fetchone() + if not pt: + raise HTTPException(404) + if user["rolle"] != "admin" and pt["owner_user_id"] != user["id"]: + raise HTTPException(403) + conn.execute("DELETE FROM progesteron_tests WHERE id=?", (prog_id,)) + + +# ------------------------------------------------------------------ +# Deckdaten & Trächtigkeit +# ------------------------------------------------------------------ +@router.get("/laeufi/deck/{hund_id}") +async def list_deck(hund_id: int, user=Depends(_require_breeder)): + with db() as conn: + _check_hund_owner(hund_id, user, conn) + rows = conn.execute( + "SELECT * FROM deckdaten WHERE hund_id=? ORDER BY deckdatum DESC", + (hund_id,) + ).fetchall() + result = [] + for r in rows: + d = dict(r) + d["meilensteine"] = _calc_meilensteine(d["deckdatum"]) + result.append(d) + return result + + +@router.post("/laeufi/deck/{hund_id}", status_code=201) +async def add_deck(hund_id: int, body: DeckCreate, user=Depends(_require_breeder)): + with db() as conn: + _check_hund_owner(hund_id, user, conn) + cur = conn.execute( + """INSERT INTO deckdaten + (hund_id, laeufi_id, deckdatum, ruede_id, ruede_name, deckart, + traechtig, ultraschall_datum, notiz) + VALUES (?,?,?,?,?,?,?,?,?)""", + (hund_id, body.laeufi_id, body.deckdatum, body.ruede_id, body.ruede_name, + body.deckart, body.traechtig, body.ultraschall_datum, body.notiz) + ) + row = conn.execute("SELECT * FROM deckdaten WHERE id=?", (cur.lastrowid,)).fetchone() + d = dict(row) + d["meilensteine"] = _calc_meilensteine(d["deckdatum"]) + return d + + +@router.put("/laeufi/deck/entry/{deck_id}") +async def update_deck(deck_id: int, body: DeckUpdate, user=Depends(_require_breeder)): + with db() as conn: + dk = conn.execute( + """SELECT d.id, bp.user_id AS owner_user_id FROM deckdaten d + JOIN zucht_hunde zh ON zh.id = d.hund_id + JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE d.id=?""", (deck_id,) + ).fetchone() + if not dk: + raise HTTPException(404) + if user["rolle"] != "admin" and dk["owner_user_id"] != user["id"]: + raise HTTPException(403) + fields = {k: v for k, v in body.model_dump().items() if v is not None} + if fields: + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute(f"UPDATE deckdaten SET {sets} WHERE id=?", (*fields.values(), deck_id)) + row = conn.execute("SELECT * FROM deckdaten WHERE id=?", (deck_id,)).fetchone() + d = dict(row) + d["meilensteine"] = _calc_meilensteine(d["deckdatum"]) + return d + + +@router.delete("/laeufi/deck/entry/{deck_id}", status_code=204) +async def delete_deck(deck_id: int, user=Depends(_require_breeder)): + with db() as conn: + dk = conn.execute( + """SELECT d.id, bp.user_id AS owner_user_id FROM deckdaten d + JOIN zucht_hunde zh ON zh.id = d.hund_id + JOIN breeder_profiles bp ON bp.id = zh.breeder_id + WHERE d.id=?""", (deck_id,) + ).fetchone() + if not dk: + raise HTTPException(404) + if user["rolle"] != "admin" and dk["owner_user_id"] != user["id"]: + raise HTTPException(403) + conn.execute("DELETE FROM deckdaten WHERE id=?", (deck_id,)) diff --git a/backend/routes/litters.py b/backend/routes/litters.py index ddc810c..09250d8 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -27,23 +27,27 @@ def _require_breeder(user=Depends(get_current_user)): # Schemas # ------------------------------------------------------------------ class LitterCreate(BaseModel): + wurf_rang: Optional[str] = None # A, B, C … + wurf_name: Optional[str] = None # z.B. "Vatertags-Wurf" 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 + 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 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 + status: str = "geplant" sichtbar: int = 0 sichtbar_bis: Optional[str] = None class LitterUpdate(BaseModel): + wurf_rang: Optional[str] = None + wurf_name: Optional[str] = None vater_name: Optional[str] = None mutter_name: Optional[str] = None vater_id: Optional[int] = None @@ -189,13 +193,16 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)): cur = conn.execute( """INSERT INTO litters - (breeder_id, vater_name, mutter_name, vater_id, mutter_id, + (breeder_id, wurf_rang, wurf_name, + 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.wurf_rang, + body.wurf_name, body.vater_name, body.mutter_name, body.vater_id, @@ -650,3 +657,98 @@ async def generate_contract( """ return HTMLResponse(content=html) + + +# ------------------------------------------------------------------ +# Warteliste +# ------------------------------------------------------------------ +class WaitlistEntry(BaseModel): + name: str + email: Optional[str] = None + telefon: Optional[str] = None + nachricht: Optional[str] = None + wunsch_geschlecht: str = "egal" + wunsch_farbe: Optional[str] = None + prioritaet: int = 0 + status: str = "anfrage" + notiz: Optional[str] = None + + +class WaitlistUpdate(BaseModel): + name: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + nachricht: Optional[str] = None + wunsch_geschlecht: Optional[str] = None + wunsch_farbe: Optional[str] = None + prioritaet: Optional[int] = None + status: Optional[str] = None + notiz: Optional[str] = None + + +@router.get("/litters/{litter_id}/waitlist") +async def get_waitlist(litter_id: int, user=Depends(_require_breeder)): + with db() as conn: + _check_litter_owner(litter_id, user, conn) + rows = conn.execute( + "SELECT * FROM litter_waitlist WHERE litter_id=? ORDER BY prioritaet, created_at", + (litter_id,) + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("/litters/{litter_id}/waitlist", status_code=201) +async def add_waitlist_entry(litter_id: int, body: WaitlistEntry, user=Depends(_require_breeder)): + with db() as conn: + _check_litter_owner(litter_id, user, conn) + cur = conn.execute( + """INSERT INTO litter_waitlist + (litter_id, name, email, telefon, nachricht, wunsch_geschlecht, wunsch_farbe, + prioritaet, status, notiz) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + (litter_id, body.name, body.email, body.telefon, body.nachricht, + body.wunsch_geschlecht, body.wunsch_farbe, body.prioritaet, body.status, body.notiz) + ) + row = conn.execute("SELECT * FROM litter_waitlist WHERE id=?", (cur.lastrowid,)).fetchone() + return dict(row) + + +@router.put("/litters/waitlist/{entry_id}") +async def update_waitlist_entry(entry_id: int, body: WaitlistUpdate, user=Depends(_require_breeder)): + with db() as conn: + entry = conn.execute( + """SELECT w.*, bp.user_id AS owner_user_id FROM litter_waitlist w + JOIN litters l ON l.id = w.litter_id + JOIN breeder_profiles bp ON bp.id = l.breeder_id + WHERE w.id=?""", + (entry_id,) + ).fetchone() + if not entry: + raise HTTPException(404, "Eintrag nicht gefunden.") + if user["rolle"] != "admin" and entry["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + + fields = {k: v for k, v in body.model_dump().items() if v is not None} + if not fields: + return dict(entry) + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute(f"UPDATE litter_waitlist SET {sets} WHERE id=?", (*fields.values(), entry_id)) + row = conn.execute("SELECT * FROM litter_waitlist WHERE id=?", (entry_id,)).fetchone() + return dict(row) + + +@router.delete("/litters/waitlist/{entry_id}", status_code=204) +async def delete_waitlist_entry(entry_id: int, user=Depends(_require_breeder)): + with db() as conn: + entry = conn.execute( + """SELECT w.id, bp.user_id AS owner_user_id FROM litter_waitlist w + JOIN litters l ON l.id = w.litter_id + JOIN breeder_profiles bp ON bp.id = l.breeder_id + WHERE w.id=?""", + (entry_id,) + ).fetchone() + if not entry: + raise HTTPException(404, "Eintrag nicht gefunden.") + if user["rolle"] != "admin" and entry["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + conn.execute("DELETE FROM litter_waitlist WHERE id=?", (entry_id,)) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 0a6c754..976aef5 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -6977,18 +6977,10 @@ svg.empty-state-icon { .wb-cards { display: grid; - grid-template-columns: 1fr; + grid-template-columns: repeat(auto-fill, minmax(340px, 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); diff --git a/backend/static/index.html b/backend/static/index.html index cc215cf..0e13659 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -248,13 +248,25 @@ Erste Hilfe - -