From f3308a6a942d22b1388e57d2c9744a6dc019a0cd Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 13 May 2026 17:09:02 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20L=C3=A4ufigkeit=20&=20Tr=C3=A4chtigk?= =?UTF-8?q?eit=20=E2=80=94=20Zyklen,=20Progesterontests,=20Deckdaten,=20Me?= =?UTF-8?q?ilensteine=20(SW=20by-v894)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 45 +++ backend/main.py | 4 +- backend/routes/laeufi.py | 307 +++++++++++++++ backend/static/index.html | 16 +- backend/static/js/api.js | 22 +- backend/static/js/app.js | 5 +- backend/static/js/pages/laeufi.js | 604 ++++++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 8 files changed, 997 insertions(+), 8 deletions(-) create mode 100644 backend/routes/laeufi.py create mode 100644 backend/static/js/pages/laeufi.js diff --git a/backend/database.py b/backend/database.py index af5298b..73aa307 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2265,6 +2265,51 @@ 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 ( diff --git a/backend/main.py b/backend/main.py index 5b1f761..33bf56d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -233,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 @@ -279,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"]) @@ -404,7 +406,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "893" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "894" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): 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/static/index.html b/backend/static/index.html index cc05416..fb4cbe7 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -256,6 +256,10 @@ style="display:none;color:var(--c-primary,#7c3aed)"> Wurfverwaltung +