diff --git a/.gitignore b/.gitignore index e51a866..696ee7a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ __pycache__/ *.db *.db-wal *.db-shm + +# Design-Quell-Dateien (nicht für Server) +/icons/ diff --git a/Makefile b/Makefile index ea0c151..6eb84e9 100644 --- a/Makefile +++ b/Makefile @@ -8,8 +8,8 @@ DS_HOST := ds DS_IP := 10.47.11.10 # Hinweis: NPM braucht 10.47.11.99 als Forward-IP (Macvlan-Shim), nicht .10 DS_SSH_PORT := 22 -DS_PATH := /volume1/docker/ban-yaro -CONTAINER := ban-yaro # container_name (für docker logs/exec) +DS_PATH := /volume1/docker/banyaro +CONTAINER := banyaro # container_name (für docker logs/exec) SERVICE := banyaro # service-name in docker-compose.yml (für docker compose restart) GIT_REMOTE := origin DOCKER := sudo /usr/local/bin/docker diff --git a/backend/database.py b/backend/database.py index 209239f..d268de4 100644 --- a/backend/database.py +++ b/backend/database.py @@ -88,6 +88,14 @@ def init_db(): ); CREATE INDEX IF NOT EXISTS idx_diary_dog ON diary(dog_id, datum DESC); + -- TAGEBUCH ↔ HUNDE (n:m — ein Eintrag kann mehrere Hunde betreffen) + CREATE TABLE IF NOT EXISTS diary_dogs ( + diary_id INTEGER NOT NULL REFERENCES diary(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + PRIMARY KEY (diary_id, dog_id) + ); + CREATE INDEX IF NOT EXISTS idx_diary_dogs_dog ON diary_dogs(dog_id); + -- GESUNDHEIT CREATE TABLE IF NOT EXISTS health ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -186,6 +194,15 @@ def init_db(): PRIMARY KEY (walk_id, user_id) ); + -- GASSI-TREFFEN ↔ HUNDE (n:m — Teilnehmer kann mehrere Hunde mitbringen) + CREATE TABLE IF NOT EXISTS walk_participant_dogs ( + walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + PRIMARY KEY (walk_id, user_id, dog_id) + ); + CREATE INDEX IF NOT EXISTS idx_wpd_dog ON walk_participant_dogs(dog_id); + -- FORUM CREATE TABLE IF NOT EXISTS forum_threads ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -234,4 +251,48 @@ def init_db(): """) + # Migrations: neue Spalten zu bestehenden Tabellen hinzufügen (idempotent) + _migrate(conn_factory=db) + logger.info("Datenbank initialisiert.") + + +def _migrate(conn_factory): + """Fügt fehlende Spalten hinzu und führt Datenmigration durch (idempotent).""" + migrations = [ + # Giftköder: Auflösungs-Details für spätere KI-Analyse + ("poison", "geloest_von", "INTEGER"), + ("poison", "geloest_at", "TEXT"), + ("poison", "geloest_grund", "TEXT"), + # Gesundheit: erweiterte Felder je nach Eintragstyp + ("health", "charge_nr", "TEXT"), + ("health", "tierarzt_name", "TEXT"), + ("health", "kosten", "REAL"), + ("health", "diagnose", "TEXT"), + ("health", "dosierung", "TEXT"), + ("health", "haeufigkeit", "TEXT"), + ("health", "aktiv", "INTEGER NOT NULL DEFAULT 1"), + ("health", "bis_datum", "TEXT"), + ("health", "schweregrad", "TEXT"), + ("health", "reaktion", "TEXT"), + ("health", "datei_url", "TEXT"), + ("health", "datei_typ", "TEXT"), + ] + with conn_factory() as conn: + for table, column, col_type in migrations: + existing = [ + row[1] for row in + conn.execute(f"PRAGMA table_info({table})").fetchall() + ] + if column not in existing: + conn.execute( + f"ALTER TABLE {table} ADD COLUMN {column} {col_type}" + ) + logger.info(f"Migration: {table}.{column} hinzugefügt.") + + # Datenmigration: diary_dogs für bestehende Einträge befüllen + conn.execute(""" + INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) + SELECT id, dog_id FROM diary + """) + logger.info("Migration: diary_dogs Backfill abgeschlossen.") diff --git a/backend/ki.py b/backend/ki.py index 6eb0f1f..c4f6ec2 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -289,6 +289,71 @@ Bewerte als JSON: return {"plausibel": True, "typ": "unbekannt", "hinweis": description} +async def health_summary(health_data: list, dog_info: dict, + user_is_premium: bool = False) -> str: + """ + Tierärztliche Zusammenfassung aller Gesundheitsdaten — lokal für alle. + Fasst Impfungen, Besuche, Medikamente etc. in einem lesbaren Bericht zusammen. + """ + system = ( + "Du bist ein erfahrener Veterinär-Assistent. " + "Erstelle präzise, strukturierte Gesundheitsberichte für Hunde auf Deutsch. " + "Weise auf fällige Impfungen und wichtige Termine hin." + ) + + # Daten nach Typ gruppieren für den Prompt + def _fmt(entries, typ): + subset = [e for e in entries if e.get("typ") == typ] + if not subset: + return " (keine Einträge)" + lines = [] + for e in subset[:10]: # maximal 10 pro Typ + line = f" - {e.get('datum', '?')}: {e.get('bezeichnung', '?')}" + if e.get("naechstes"): + line += f" (nächste Fälligkeit: {e['naechstes']})" + if e.get("notiz"): + line += f" — {e['notiz']}" + lines.append(line) + return "\n".join(lines) + + heute = __import__("datetime").date.today().isoformat() + prompt = f""" +Hund: {dog_info.get('name')}, {dog_info.get('rasse', 'unbekannt')}, +Geburtstag: {dog_info.get('geburtstag', 'unbekannt')}, Gewicht: {dog_info.get('gewicht_kg', '?')} kg + +Heutiges Datum: {heute} + +=== IMPFUNGEN === +{_fmt(health_data, 'impfung')} + +=== ENTWURMUNG === +{_fmt(health_data, 'entwurmung')} + +=== TIERARZTBESUCHE === +{_fmt(health_data, 'tierarzt')} + +=== MEDIKAMENTE === +{_fmt(health_data, 'medikament')} + +=== ALLERGIEN === +{_fmt(health_data, 'allergie')} + +Erstelle einen strukturierten Gesundheitsbericht mit: +1. Aktueller Status (2-3 Sätze) +2. Fällige / überfällige Impfungen und Termine +3. Wichtige Hinweise für den nächsten Tierarztbesuch +4. Allgemeine Empfehlungen + +Schreibe klar und verständlich für den Hundebesitzer. +""" + return await complete( + prompt, system, + max_tokens=700, + requires_premium=False, # Kostenlos für alle + user_is_premium=user_is_premium, + ) + + # ------------------------------------------------------------------ # Status-Endpoint (für Admin/Debug) # ------------------------------------------------------------------ diff --git a/backend/main.py b/backend/main.py index 96d0236..88c7e1e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -89,6 +89,10 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") +@app.get("/favicon.ico") +async def favicon(): + return FileResponse(f"{STATIC_DIR}/icons/favicon.ico") + @app.get("/manifest.json") async def manifest(): return FileResponse(f"{STATIC_DIR}/manifest.json") diff --git a/backend/requirements.txt b/backend/requirements.txt index 904fb5d..29e57bc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,6 @@ fastapi==0.115.0 +Pillow==11.2.1 +pillow-heif==0.22.0 uvicorn[standard]==0.30.6 python-multipart==0.0.9 pydantic[email]==2.8.2 diff --git a/backend/routes/diary.py b/backend/routes/diary.py index 56f348d..ba59f6d 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -13,20 +13,23 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") class DiaryCreate(BaseModel): - datum: Optional[str] = None # ISO date, default heute - typ: str = "eintrag" - titel: Optional[str] = None - text: Optional[str] = None - tags: Optional[list] = None - gps_lat: Optional[float] = None - gps_lon: Optional[float] = None - is_milestone: bool = False + datum: Optional[str] = None # ISO date, default heute + typ: str = "eintrag" + titel: Optional[str] = None + text: Optional[str] = None + tags: Optional[list] = None + gps_lat: Optional[float] = None + gps_lon: Optional[float] = None + is_milestone: bool = False + dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary + class DiaryUpdate(BaseModel): - titel: Optional[str] = None - text: Optional[str] = None - tags: Optional[list] = None - is_milestone: Optional[bool] = None + titel: Optional[str] = None + text: Optional[str] = None + tags: Optional[list] = None + is_milestone: Optional[bool] = None + dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen def _own_dog(dog_id: int, user_id: int, conn): @@ -38,23 +41,63 @@ def _own_dog(dog_id: int, user_id: int, conn): return dog +def _validate_dog_ids(dog_ids: list[int], primary: int, user_id: int, conn) -> list[int]: + """Stellt sicher dass alle IDs dem User gehören. Gibt die bereinigte Liste zurück.""" + all_ids = list({primary} | set(dog_ids)) + for did in all_ids: + _own_dog(did, user_id, conn) + return all_ids + + +def _fetch_dog_ids(conn, entry_ids: list[int]) -> dict: + """Gibt {entry_id: [dog_id, ...]} zurück.""" + if not entry_ids: + return {} + ph = ",".join("?" * len(entry_ids)) + rows = conn.execute( + f"SELECT diary_id, dog_id FROM diary_dogs WHERE diary_id IN ({ph})", + entry_ids + ).fetchall() + result = {} + for r in rows: + result.setdefault(r["diary_id"], []).append(r["dog_id"]) + return result + + +def _set_dog_ids(conn, entry_id: int, dog_ids: list[int]): + conn.execute("DELETE FROM diary_dogs WHERE diary_id=?", (entry_id,)) + for did in dog_ids: + conn.execute( + "INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)", + (entry_id, did) + ) + + +def _entry_dict(row, dog_ids_map: dict) -> dict: + e = dict(row) + e["tags"] = json.loads(e["tags"]) if e["tags"] else [] + e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]]) + return e + + @router.get("/{dog_id}/diary") async def list_diary(dog_id: int, limit: int = 20, offset: int = 0, user=Depends(get_current_user)): with db() as conn: _own_dog(dog_id, user["id"], conn) + # Einträge des primären Hundes SOWIE Einträge wo der Hund als weiterer zugeordnet ist rows = conn.execute( - """SELECT * FROM diary WHERE dog_id=? - ORDER BY datum DESC, created_at DESC + """SELECT DISTINCT d.* FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE d.dog_id = ? OR dd.dog_id = ? + ORDER BY d.datum DESC, d.created_at DESC LIMIT ? OFFSET ?""", - (dog_id, limit, offset) + (dog_id, dog_id, limit, offset) ).fetchall() - entries = [] - for r in rows: - e = dict(r) - e["tags"] = json.loads(e["tags"]) if e["tags"] else [] - entries.append(e) - return entries + ids = [r["id"] for r in rows] + dogs_map = _fetch_dog_ids(conn, ids) + + return [_entry_dict(r, dogs_map) for r in rows] @router.post("/{dog_id}/diary", status_code=201) @@ -72,6 +115,8 @@ async def create_diary(dog_id: int, data: DiaryCreate, with db() as conn: _own_dog(dog_id, user["id"], conn) + all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn) + conn.execute( """INSERT INTO diary (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone) @@ -85,10 +130,10 @@ async def create_diary(dog_id: int, data: DiaryCreate, "SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,) ).fetchone() + _set_dog_ids(conn, entry["id"], all_dogs) + dogs_map = _fetch_dog_ids(conn, [entry["id"]]) - e = dict(entry) - e["tags"] = json.loads(e["tags"]) if e["tags"] else [] - return e + return _entry_dict(entry, dogs_map) @router.get("/{dog_id}/diary/{entry_id}") @@ -96,13 +141,16 @@ async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)): with db() as conn: _own_dog(dog_id, user["id"], conn) row = conn.execute( - "SELECT * FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id) + """SELECT DISTINCT d.* FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""", + (entry_id, dog_id, dog_id) ).fetchone() - if not row: - raise HTTPException(404, "Eintrag nicht gefunden.") - e = dict(row) - e["tags"] = json.loads(e["tags"]) if e["tags"] else [] - return e + if not row: + raise HTTPException(404, "Eintrag nicht gefunden.") + dogs_map = _fetch_dog_ids(conn, [entry_id]) + + return _entry_dict(row, dogs_map) @router.patch("/{dog_id}/diary/{entry_id}") @@ -110,20 +158,43 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate, user=Depends(get_current_user)): with db() as conn: _own_dog(dog_id, user["id"], conn) - fields = {k: v for k, v in data.model_dump().items() if v is not None} + + # Prüfen ob Eintrag diesem Hund gehört (direkt oder via diary_dogs) + exists = conn.execute( + """SELECT 1 FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""", + (entry_id, dog_id, dog_id) + ).fetchone() + if not exists: + raise HTTPException(404, "Eintrag nicht gefunden.") + + # Felder updaten + fields = {k: v for k, v in data.model_dump(exclude={"dog_ids"}).items() + if v is not None} if "tags" in fields: fields["tags"] = json.dumps(fields["tags"]) - if not fields: - raise HTTPException(400, "Keine Änderungen.") - set_clause = ", ".join(f"{k}=?" for k in fields) - conn.execute( - f"UPDATE diary SET {set_clause} WHERE id=? AND dog_id=?", - list(fields.values()) + [entry_id, dog_id] - ) - row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone() - e = dict(row) - e["tags"] = json.loads(e["tags"]) if e["tags"] else [] - return e + if fields: + # primary dog_id für SET-Clause ermitteln (der Eintrag bleibt dem Erstell-Hund) + set_clause = ", ".join(f"{k}=?" for k in fields) + conn.execute( + f"UPDATE diary SET {set_clause} WHERE id=?", + list(fields.values()) + [entry_id] + ) + + # Hunde-Zuweisung aktualisieren + if data.dog_ids is not None: + # primary dog des Eintrags ermitteln + primary = conn.execute( + "SELECT dog_id FROM diary WHERE id=?", (entry_id,) + ).fetchone()["dog_id"] + all_dogs = _validate_dog_ids(data.dog_ids, primary, user["id"], conn) + _set_dog_ids(conn, entry_id, all_dogs) + + row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone() + dogs_map = _fetch_dog_ids(conn, [entry_id]) + + return _entry_dict(row, dogs_map) @router.delete("/{dog_id}/diary/{entry_id}", status_code=204) @@ -142,7 +213,10 @@ async def upload_media(dog_id: int, entry_id: int, with db() as conn: _own_dog(dog_id, user["id"], conn) entry = conn.execute( - "SELECT id FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id) + """SELECT d.id FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""", + (entry_id, dog_id, dog_id) ).fetchone() if not entry: raise HTTPException(404, "Eintrag nicht gefunden.") diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index ca63968..e59b01d 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -113,13 +113,28 @@ async def upload_photo( if not dog: raise HTTPException(404, "Hund nicht gefunden.") - # Datei speichern - ext = os.path.splitext(file.filename or "")[1] or ".jpg" - filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}{ext}" + # Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser) + import io + from PIL import Image + try: + import pillow_heif + pillow_heif.register_heif_opener() + except ImportError: + pass + + content = await file.read() + try: + img = Image.open(io.BytesIO(content)).convert("RGB") + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=90) + content = buf.getvalue() + except Exception: + pass # Fallback: Originaldaten speichern + + filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}.jpg" path = os.path.join(MEDIA_DIR, "dogs", filename) os.makedirs(os.path.dirname(path), exist_ok=True) - content = await file.read() with open(path, "wb") as f: f.write(content) diff --git a/backend/routes/health.py b/backend/routes/health.py index db2c27d..8911d99 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -1,3 +1,283 @@ -"""BAN YARO — health Routes (Stub, wird ausgebaut)""" -from fastapi import APIRouter -router = APIRouter() +"""BAN YARO — Gesundheit & Impfpass Routes""" + +import os, uuid +from datetime import date, datetime +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + +# Erlaubte Typen +TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument"} + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class HealthCreate(BaseModel): + typ: str + bezeichnung: str + datum: str + naechstes: Optional[str] = None + notiz: Optional[str] = None + # Gewicht + wert: Optional[float] = None + einheit: Optional[str] = "kg" + # Impfung + charge_nr: Optional[str] = None + tierarzt_name: Optional[str] = None + # Tierarztbesuch + kosten: Optional[float] = None + diagnose: Optional[str] = None + # Medikament + dosierung: Optional[str] = None + haeufigkeit: Optional[str] = None + aktiv: Optional[int] = 1 + bis_datum: Optional[str] = None + # Allergie + schweregrad: Optional[str] = None # leicht | mittel | schwer + reaktion: Optional[str] = None + erinnerung: Optional[int] = 1 + + +class HealthUpdate(BaseModel): + bezeichnung: Optional[str] = None + datum: Optional[str] = None + naechstes: Optional[str] = None + notiz: Optional[str] = None + wert: Optional[float] = None + einheit: Optional[str] = None + charge_nr: Optional[str] = None + tierarzt_name: Optional[str] = None + kosten: Optional[float] = None + diagnose: Optional[str] = None + dosierung: Optional[str] = None + haeufigkeit: Optional[str] = None + aktiv: Optional[int] = None + bis_datum: Optional[str] = None + schweregrad: Optional[str] = None + reaktion: Optional[str] = None + erinnerung: Optional[int] = None + + +# ------------------------------------------------------------------ +# Hilfsfunktion: Zugriffscheck Dog → User +# ------------------------------------------------------------------ +def _check_dog_owner(conn, dog_id: int, user_id: int): + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + return dog + + +# ------------------------------------------------------------------ +# GET /api/dogs/{dog_id}/health +# ------------------------------------------------------------------ +@router.get("/{dog_id}/health") +async def list_health(dog_id: int, typ: Optional[str] = None, + user=Depends(get_current_user)): + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + if typ: + rows = conn.execute( + "SELECT * FROM health WHERE dog_id=? AND typ=? ORDER BY datum DESC", + (dog_id, typ) + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC", + (dog_id,) + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/dogs/{dog_id}/health +# ------------------------------------------------------------------ +@router.post("/{dog_id}/health", status_code=201) +async def create_health(dog_id: int, data: HealthCreate, + user=Depends(get_current_user)): + if data.typ not in TYPEN: + raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(TYPEN)}") + + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + conn.execute( + """INSERT INTO health + (dog_id, typ, bezeichnung, datum, naechstes, notiz, + wert, einheit, charge_nr, tierarzt_name, kosten, diagnose, + dosierung, haeufigkeit, aktiv, bis_datum, + schweregrad, reaktion, erinnerung) + 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) + ) + row = conn.execute( + "SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1", + (dog_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# PATCH /api/dogs/{dog_id}/health/{id} +# ------------------------------------------------------------------ +@router.patch("/{dog_id}/health/{entry_id}") +async def update_health(dog_id: int, entry_id: int, data: HealthUpdate, + user=Depends(get_current_user)): + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + entry = conn.execute( + "SELECT * FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id) + ).fetchone() + if not entry: + raise HTTPException(404, "Eintrag nicht gefunden.") + + updates = {k: v for k, v in data.model_dump().items() if v is not None} + if not updates: + return dict(entry) + + set_clause = ", ".join(f"{k}=?" for k in updates) + values = list(updates.values()) + [entry_id] + conn.execute(f"UPDATE health SET {set_clause} WHERE id=?", values) + row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/dogs/{dog_id}/health/{id} +# ------------------------------------------------------------------ +@router.delete("/{dog_id}/health/{entry_id}", status_code=204) +async def delete_health(dog_id: int, entry_id: int, user=Depends(get_current_user)): + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + entry = conn.execute( + "SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id) + ).fetchone() + if not entry: + raise HTTPException(404, "Eintrag nicht gefunden.") + conn.execute("DELETE FROM health WHERE id=?", (entry_id,)) + return None + + +# ------------------------------------------------------------------ +# POST /api/dogs/{dog_id}/health/{id}/dokument — Datei-Upload +# ------------------------------------------------------------------ +@router.post("/{dog_id}/health/{entry_id}/dokument") +async def upload_dokument( + dog_id: int, + entry_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user), +): + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + entry = conn.execute( + "SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id) + ).fetchone() + if not entry: + raise HTTPException(404, "Eintrag nicht gefunden.") + + ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg" + if ext not in {".jpg", ".jpeg", ".png", ".pdf", ".webp"}: + raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.") + + filename = f"health_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" + path = os.path.join(MEDIA_DIR, "health", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + + with open(path, "wb") as f: + f.write(await file.read()) + + datei_url = f"/media/health/{filename}" + datei_typ = "pdf" if ext == ".pdf" else "image" + + with db() as conn: + conn.execute( + "UPDATE health SET datei_url=?, datei_typ=? WHERE id=?", + (datei_url, datei_typ, entry_id) + ) + + return {"datei_url": datei_url, "datei_typ": datei_typ} + + +# ------------------------------------------------------------------ +# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung +# ------------------------------------------------------------------ +class SymptomCheckRequest(BaseModel): + symptoms: str + + +@router.post("/{dog_id}/health/symptom-check") +async def symptom_check(dog_id: int, data: SymptomCheckRequest, + user=Depends(get_current_user)): + from ki import symptom_check as ki_symptom_check, KIUnavailableError + + with db() as conn: + dog = conn.execute( + "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + dog_info = dict(dog) + if dog_info.get("geburtstag"): + try: + from datetime import date + geb = date.fromisoformat(dog_info["geburtstag"]) + dog_info["alter_jahre"] = round((date.today() - geb).days / 365.25, 1) + except Exception: + dog_info["alter_jahre"] = "unbekannt" + + try: + result = await ki_symptom_check( + symptoms=data.symptoms, + dog_info=dog_info, + user_is_premium=bool(user.get("is_premium")), + ) + return result + except KIUnavailableError as e: + raise HTTPException(503, str(e)) + + +# ------------------------------------------------------------------ +# POST /api/dogs/{dog_id}/health/ki-zusammenfassung +# ------------------------------------------------------------------ +@router.post("/{dog_id}/health/ki-zusammenfassung") +async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)): + from ki import health_summary, KIUnavailableError, KIPremiumRequired + + with db() as conn: + dog = conn.execute( + "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + rows = conn.execute( + "SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC", + (dog_id,) + ).fetchall() + + health_data = [dict(r) for r in rows] + + try: + result = await health_summary( + health_data=health_data, + dog_info=dict(dog), + user_is_premium=bool(user.get("is_premium")), + ) + return {"zusammenfassung": result} + except KIPremiumRequired as e: + raise HTTPException(402, str(e)) + except KIUnavailableError as e: + raise HTTPException(503, str(e)) diff --git a/backend/routes/poison.py b/backend/routes/poison.py index 4916a6f..9459c61 100644 --- a/backend/routes/poison.py +++ b/backend/routes/poison.py @@ -35,6 +35,10 @@ class PoisonCreate(BaseModel): typ: str = "unbekannt" +class PoisonResolve(BaseModel): + grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes + + # ------------------------------------------------------------------ # GET /api/poison — aktive Meldungen im Umkreis (kein Login nötig) # ------------------------------------------------------------------ @@ -114,7 +118,11 @@ async def confirm_poison(poison_id: int, user=Depends(get_current_user)): # Nur der Melder selbst oder ein Admin # ------------------------------------------------------------------ @router.post("/{poison_id}/resolve") -async def resolve_poison(poison_id: int, user=Depends(get_current_user)): +async def resolve_poison( + poison_id: int, + data: PoisonResolve = PoisonResolve(), + user=Depends(get_current_user) +): with db() as conn: entry = conn.execute( "SELECT * FROM poison WHERE id=?", (poison_id,) @@ -124,7 +132,16 @@ async def resolve_poison(poison_id: int, user=Depends(get_current_user)): e = dict(entry) if e["user_id"] != user["id"] and user.get("rolle") != "admin": raise HTTPException(403, "Keine Berechtigung.") - conn.execute("UPDATE poison SET geloest=1 WHERE id=?", (poison_id,)) + # Soft-Delete: Eintrag bleibt für spätere KI-Musteranalyse erhalten + conn.execute( + """UPDATE poison + SET geloest=1, + geloest_von=?, + geloest_at=datetime('now'), + geloest_grund=? + WHERE id=?""", + (user["id"], data.grund, poison_id) + ) return {"ok": True} diff --git a/backend/static/css/components.css b/backend/static/css/components.css index afdcb80..750fc4a 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -441,6 +441,7 @@ textarea.form-control { padding: var(--space-4); backdrop-filter: blur(2px); animation: overlay-in var(--transition-normal) ease; + touch-action: manipulation; } @media (min-width: 768px) { .modal-overlay { align-items: center; } @@ -812,3 +813,409 @@ textarea.form-control { /* Leaflet-Attribution ausblenden */ .leaflet-control-attribution { display: none !important; } + +/* ============================================================ + GESUNDHEIT + ============================================================ */ + +/* Header mit KI-Button */ +.health-header { + display: flex; + justify-content: flex-end; + padding: var(--space-3) 0 var(--space-2); +} + +/* Tab-Leiste — Mobile: horizontal scrollbar, Desktop: umbrechen */ +.health-tabs { + display: flex; + flex-wrap: wrap; + gap: var(--space-1) var(--space-1); + padding-bottom: var(--space-2); + margin-bottom: var(--space-3); +} +/* Auf sehr kleinen Screens: scrollen statt umbrechen */ +@media (max-width: 480px) { + .health-tabs { + flex-wrap: nowrap; + overflow-x: auto; + padding-right: var(--space-4); + scrollbar-width: none; + } + .health-tabs::-webkit-scrollbar { display: none; } +} + +.health-tab { + flex-shrink: 0; + padding: var(--space-2) var(--space-3); + border: 2px solid var(--c-border); + border-radius: var(--radius-full); + background: var(--c-surface); + color: var(--c-text-secondary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + cursor: pointer; + white-space: nowrap; + transition: all var(--transition-fast); + touch-action: manipulation; +} +.health-tab.active { + background: var(--c-primary); + border-color: var(--c-primary); + color: var(--c-text-inverse); +} + +/* Karten-Liste */ +.health-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +/* Einzelne Karte */ +.health-card { + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-md); + padding: var(--space-4); + cursor: pointer; + display: flex; + gap: var(--space-3); + align-items: flex-start; + transition: box-shadow var(--transition-fast), transform var(--transition-fast); +} +.health-card:active { transform: scale(0.985); } +.health-card--inactive { opacity: 0.55; } + +.health-card-body { flex: 1; min-width: 0; } +.health-card-title { font-weight: var(--weight-semibold); margin-bottom: var(--space-1); } +.health-card-meta { font-size: var(--text-sm); color: var(--c-text-secondary); } +.health-card-next { font-size: var(--text-sm); font-weight: var(--weight-medium); margin-top: var(--space-1); } +.health-card-note { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: var(--space-1); } + +/* Ampel-Punkt (links an der Karte) */ +.health-card-ampel { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + margin-top: 5px; +} +.ampel-green { background: #22c55e; } +.ampel-yellow { background: #f59e0b; } +.ampel-red { background: #ef4444; } +.ampel-grey { background: var(--c-border); } + +.ampel-text-green { color: #16a34a; } +.ampel-text-yellow { color: #d97706; } +.ampel-text-red { color: #dc2626; } + +/* Gruppen-Label (z.B. "Aktuelle Medikamente") */ +.health-group-label { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: var(--space-3) 0 var(--space-1); +} + +/* Gewicht-Diagramm-Wrapper */ +.health-chart-wrap { + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-md); + padding: var(--space-4); + margin-bottom: var(--space-4); + overflow: hidden; +} + +/* Dokument-Thumbnail und Icon */ +.health-doc-thumb { + width: 56px; + height: 56px; + object-fit: cover; + border-radius: var(--radius-sm); + flex-shrink: 0; +} +.health-doc-icon { + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + background: var(--c-surface-2); + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +/* Detail-Dialog DL */ +.health-detail-dl { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--space-1) var(--space-4); + font-size: var(--text-sm); +} +.health-detail-dl dt { + color: var(--c-text-secondary); + font-weight: var(--weight-medium); + white-space: nowrap; +} +.health-detail-dl dd { margin: 0; } + +/* ------------------------------------------------------------ + DOG SWITCHER + Avatar-Leiste in Header (Mobile) + Sidebar-Logo (Desktop) + ------------------------------------------------------------ */ + +/* Aktiver (linker) Hund — primärer Ring */ +.dog-sw-active { + width: 34px; + height: 34px; + border-radius: var(--radius-full); + overflow: hidden; + background: var(--c-surface-2); + flex-shrink: 0; + cursor: pointer; + border: 2.5px solid var(--c-primary); + transition: transform var(--transition-fast), + box-shadow var(--transition-fast); +} +.dog-sw-active:hover { + transform: scale(1.06); + box-shadow: 0 0 0 3px var(--c-primary-subtle); +} +.dog-sw-active img { width:100%; height:100%; object-fit:cover; display:block; } +.dog-sw-active span { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + font-size: 17px; +} + +/* "Ban Yaro" Label — füllt den Zwischenraum */ +.dog-sw-title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Gruppe der inaktiven Hunde (rechts) */ +.dog-sw-others { + display: flex; + align-items: center; + position: relative; /* Anker für Quickpicker-Dropdown */ + flex-shrink: 0; +} + +/* Inaktiver Hund-Avatar */ +.dog-sw-other { + width: 28px; + height: 28px; + border-radius: var(--radius-full); + overflow: hidden; + background: var(--c-surface-2); + border: 2px solid var(--c-surface); + cursor: pointer; + flex-shrink: 0; + transition: transform var(--transition-fast); + position: relative; +} +.dog-sw-other:hover { transform: scale(1.12); } +.dog-sw-other img { width:100%; height:100%; object-fit:cover; display:block; } +.dog-sw-other span { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + font-size: 14px; +} + +/* Gestapelter Avatar-Stack (3+ Hunde) */ +.dog-sw-stack { + display: flex; + align-items: center; + cursor: pointer; +} +.dog-sw-stack .dog-sw-other { margin-left: -9px; } +.dog-sw-stack .dog-sw-other:first-child { margin-left: 0; } +.dog-sw-stack .dog-sw-other--0 { z-index: 3; } +.dog-sw-stack .dog-sw-other--1 { z-index: 2; } +.dog-sw-stack .dog-sw-other--2 { z-index: 1; } +.dog-sw-stack:hover .dog-sw-other { filter: brightness(1.05); } + +/* +N Überlauf-Badge */ +.dog-sw-more { + width: 28px; + height: 28px; + border-radius: var(--radius-full); + background: var(--c-surface-2); + border: 2px solid var(--c-surface); + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: var(--weight-bold); + color: var(--c-text-secondary); + margin-left: -9px; + position: relative; + z-index: 0; +} + +/* Quickpicker-Dropdown */ +.dog-quickpick { + position: absolute; + top: calc(100% + 10px); + right: 0; + background: var(--c-surface); + border: 1px solid var(--c-border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: var(--space-2); + min-width: 170px; + z-index: 600; +} +.dog-quickpick.hidden { display: none; } + +.dog-qp-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + cursor: pointer; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--c-text); + transition: background var(--transition-fast); + -webkit-tap-highlight-color: transparent; +} +.dog-qp-item:hover { background: var(--c-bg); } + +.dog-qp-av { + width: 32px; + height: 32px; + border-radius: var(--radius-full); + overflow: hidden; + background: var(--c-surface-2); + flex-shrink: 0; +} +.dog-qp-av img { width:100%; height:100%; object-fit:cover; display:block; } +.dog-qp-av span { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + font-size: 16px; +} + +/* Sidebar: größerer aktiver Avatar */ +#sidebar-dog-switcher .dog-sw-active { + width: 36px; + height: 36px; +} + +/* ------------------------------------------------------------ + DIARY — Multi-Dog (Hunde-Auswahl im Formular + Karten-Anzeige) + ------------------------------------------------------------ */ + +/* Avatar-Reihe in der Tagebuch-Karte */ +.diary-dog-row { + display: flex; + align-items: center; + gap: -4px; /* überlappend via margin */ + margin-top: var(--space-2); + flex-wrap: wrap; + gap: var(--space-1); +} + +.diary-dog-av { + width: 22px; + height: 22px; + border-radius: var(--radius-full); + overflow: hidden; + background: var(--c-surface-2); + border: 1.5px solid var(--c-surface); + flex-shrink: 0; +} +.diary-dog-av img { width:100%; height:100%; object-fit:cover; display:block; } +.diary-dog-av span { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + font-size: 12px; +} + +/* Hunde-Chip in der Detail-Ansicht */ +.diary-detail-dogs { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.diary-dog-chip { + display: flex; + align-items: center; + gap: var(--space-1); + padding: 3px 8px 3px 4px; + background: var(--c-surface-2); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + color: var(--c-text-secondary); +} +.diary-dog-chip .diary-dog-av { + width: 20px; + height: 20px; +} + +/* Hunde-Picker im Formular */ +.diary-dog-picker { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.diary-dog-pick-item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border: 1.5px solid var(--c-border-light); + border-radius: var(--radius-full); + cursor: pointer; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--c-text-secondary); + transition: border-color var(--transition-fast), + background var(--transition-fast), + color var(--transition-fast); + -webkit-tap-highlight-color: transparent; + user-select: none; +} +.diary-dog-pick-item input { display: none; } + +.diary-dog-pick-item .diary-dog-av { + width: 26px; + height: 26px; +} + +.diary-dog-pick-item:hover { + border-color: var(--c-primary); + color: var(--c-text); +} + +.diary-dog-pick-item.checked { + border-color: var(--c-primary); + background: var(--c-primary-subtle); + color: var(--c-primary-dark); + font-weight: var(--weight-semibold); +} diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css index 88fe171..2682530 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -58,6 +58,16 @@ flex: 1; } +/* Dog Switcher Container im Header */ +#header-dog-switcher { + flex: 1; + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; + overflow: visible; +} + .header-back { display: flex; align-items: center; @@ -201,7 +211,7 @@ background: var(--c-surface); border-right: 1px solid var(--c-border-light); flex-direction: column; - overflow-y: auto; + overflow: hidden; /* Sidebar selbst scrollt nicht */ box-shadow: var(--shadow-sm); } @@ -229,14 +239,31 @@ color: var(--c-text); } -.sidebar-nav { - flex: 1; - padding: var(--space-4) var(--space-2); - display: flex; - flex-direction: column; - gap: var(--space-1); +.sidebar-add { + padding: var(--space-4) var(--space-4) var(--space-2); + flex-shrink: 0; } +.sidebar-nav { + flex: 1; + padding: var(--space-2) var(--space-2) var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-1); + overflow-y: auto; + min-height: 0; /* wichtig: flex-child darf kleiner werden als Inhalt */ + /* Firefox */ + scrollbar-width: thin; + scrollbar-color: var(--c-primary) var(--c-surface); +} +.sidebar-nav::-webkit-scrollbar { width: 6px; } +.sidebar-nav::-webkit-scrollbar-track { background: var(--c-surface); } +.sidebar-nav::-webkit-scrollbar-thumb { + background: var(--c-primary); + border-radius: 3px; +} +.sidebar-nav::-webkit-scrollbar-thumb:hover { background: var(--c-primary-dark); } + .sidebar-section-label { font-size: var(--text-xs); font-weight: var(--weight-semibold); @@ -270,6 +297,12 @@ color: var(--c-primary-dark); font-weight: var(--weight-semibold); } +/* User-Eintrag bekommt nie den Active-Stil */ +.sidebar-item--user.active { + background: transparent; + color: var(--c-text-secondary); + font-weight: var(--weight-medium); +} .sidebar-item-icon { font-size: 18px; width: 24px; @@ -291,11 +324,7 @@ padding: 0 var(--space-1); } -.sidebar-footer { - padding: var(--space-4) var(--space-2); - border-top: 1px solid var(--c-border-light); - flex-shrink: 0; -} +/* sidebar-footer entfernt — Einstellungen/Konto sind jetzt Teil der scrollbaren Nav */ /* ------------------------------------------------------------ 5. PAGE WRAPPER (inneres Layout der Seiten) diff --git a/backend/static/icons/favicon-16.png b/backend/static/icons/favicon-16.png new file mode 100644 index 0000000..40b9c1f Binary files /dev/null and b/backend/static/icons/favicon-16.png differ diff --git a/backend/static/icons/favicon-32.png b/backend/static/icons/favicon-32.png new file mode 100644 index 0000000..7667efc Binary files /dev/null and b/backend/static/icons/favicon-32.png differ diff --git a/backend/static/icons/favicon.ico b/backend/static/icons/favicon.ico new file mode 100644 index 0000000..9c22a1c Binary files /dev/null and b/backend/static/icons/favicon.ico differ diff --git a/backend/static/icons/icon-180.png b/backend/static/icons/icon-180.png new file mode 100644 index 0000000..57a5fa6 Binary files /dev/null and b/backend/static/icons/icon-180.png differ diff --git a/backend/static/icons/icon-192.png b/backend/static/icons/icon-192.png new file mode 100644 index 0000000..2554fff Binary files /dev/null and b/backend/static/icons/icon-192.png differ diff --git a/backend/static/icons/icon-512.png b/backend/static/icons/icon-512.png new file mode 100644 index 0000000..58dc5fe Binary files /dev/null and b/backend/static/icons/icon-512.png differ diff --git a/backend/static/index.html b/backend/static/index.html index d5c5e22..f0fd842 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -6,9 +6,14 @@ + + + + + - + @@ -30,17 +35,23 @@
- Ban Yaro +
+ Ban Yaro +