diff --git a/.gitignore b/.gitignore index 696ee7a..e51a866 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,3 @@ __pycache__/ *.db *.db-wal *.db-shm - -# Design-Quell-Dateien (nicht für Server) -/icons/ diff --git a/Makefile b/Makefile index 6eb84e9..ea0c151 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/banyaro -CONTAINER := banyaro # container_name (für docker logs/exec) +DS_PATH := /volume1/docker/ban-yaro +CONTAINER := ban-yaro # 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 d268de4..209239f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -88,14 +88,6 @@ 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, @@ -194,15 +186,6 @@ 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, @@ -251,48 +234,4 @@ 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 c4f6ec2..6eb0f1f 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -289,71 +289,6 @@ 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 88c7e1e..96d0236 100644 --- a/backend/main.py +++ b/backend/main.py @@ -89,10 +89,6 @@ 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 29e57bc..904fb5d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,4 @@ 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 ba59f6d..56f348d 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -13,23 +13,20 @@ 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 - dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary - + 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 class DiaryUpdate(BaseModel): - 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 + titel: Optional[str] = None + text: Optional[str] = None + tags: Optional[list] = None + is_milestone: Optional[bool] = None def _own_dog(dog_id: int, user_id: int, conn): @@ -41,63 +38,23 @@ 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 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 + """SELECT * FROM diary WHERE dog_id=? + ORDER BY datum DESC, created_at DESC LIMIT ? OFFSET ?""", - (dog_id, dog_id, limit, offset) + (dog_id, limit, offset) ).fetchall() - ids = [r["id"] for r in rows] - dogs_map = _fetch_dog_ids(conn, ids) - - return [_entry_dict(r, dogs_map) for r in rows] + entries = [] + for r in rows: + e = dict(r) + e["tags"] = json.loads(e["tags"]) if e["tags"] else [] + entries.append(e) + return entries @router.post("/{dog_id}/diary", status_code=201) @@ -115,8 +72,6 @@ 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) @@ -130,10 +85,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"]]) - return _entry_dict(entry, dogs_map) + e = dict(entry) + e["tags"] = json.loads(e["tags"]) if e["tags"] else [] + return e @router.get("/{dog_id}/diary/{entry_id}") @@ -141,16 +96,13 @@ 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 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) + "SELECT * FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id) ).fetchone() - if not row: - raise HTTPException(404, "Eintrag nicht gefunden.") - dogs_map = _fetch_dog_ids(conn, [entry_id]) - - return _entry_dict(row, dogs_map) + if not row: + raise HTTPException(404, "Eintrag nicht gefunden.") + e = dict(row) + e["tags"] = json.loads(e["tags"]) if e["tags"] else [] + return e @router.patch("/{dog_id}/diary/{entry_id}") @@ -158,43 +110,20 @@ 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) - - # 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} + fields = {k: v for k, v in data.model_dump().items() if v is not None} if "tags" in fields: fields["tags"] = json.dumps(fields["tags"]) - 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) + 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 @router.delete("/{dog_id}/diary/{entry_id}", status_code=204) @@ -213,10 +142,7 @@ 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 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) + "SELECT id FROM diary WHERE id=? AND dog_id=?", (entry_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 e59b01d..ca63968 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -113,28 +113,13 @@ async def upload_photo( if not dog: raise HTTPException(404, "Hund nicht gefunden.") - # 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" + # Datei speichern + ext = os.path.splitext(file.filename or "")[1] or ".jpg" + filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}{ext}" 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 8911d99..db2c27d 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -1,283 +1,3 @@ -"""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)) +"""BAN YARO — health Routes (Stub, wird ausgebaut)""" +from fastapi import APIRouter +router = APIRouter() diff --git a/backend/routes/poison.py b/backend/routes/poison.py index 9459c61..4916a6f 100644 --- a/backend/routes/poison.py +++ b/backend/routes/poison.py @@ -35,10 +35,6 @@ 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) # ------------------------------------------------------------------ @@ -118,11 +114,7 @@ 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, - data: PoisonResolve = PoisonResolve(), - user=Depends(get_current_user) -): +async def resolve_poison(poison_id: int, user=Depends(get_current_user)): with db() as conn: entry = conn.execute( "SELECT * FROM poison WHERE id=?", (poison_id,) @@ -132,16 +124,7 @@ async def resolve_poison( e = dict(entry) if e["user_id"] != user["id"] and user.get("rolle") != "admin": raise HTTPException(403, "Keine Berechtigung.") - # 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) - ) + conn.execute("UPDATE poison SET geloest=1 WHERE id=?", (poison_id,)) return {"ok": True} diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 750fc4a..afdcb80 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -441,7 +441,6 @@ 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; } @@ -813,409 +812,3 @@ 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 2682530..88fe171 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -58,16 +58,6 @@ 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; @@ -211,7 +201,7 @@ background: var(--c-surface); border-right: 1px solid var(--c-border-light); flex-direction: column; - overflow: hidden; /* Sidebar selbst scrollt nicht */ + overflow-y: auto; box-shadow: var(--shadow-sm); } @@ -239,30 +229,13 @@ color: var(--c-text); } -.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); + flex: 1; + padding: var(--space-4) var(--space-2); + display: flex; + flex-direction: column; + gap: var(--space-1); } -.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); @@ -297,12 +270,6 @@ 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; @@ -324,7 +291,11 @@ padding: 0 var(--space-1); } -/* sidebar-footer entfernt — Einstellungen/Konto sind jetzt Teil der scrollbaren Nav */ +.sidebar-footer { + padding: var(--space-4) var(--space-2); + border-top: 1px solid var(--c-border-light); + flex-shrink: 0; +} /* ------------------------------------------------------------ 5. PAGE WRAPPER (inneres Layout der Seiten) diff --git a/backend/static/icons/favicon-16.png b/backend/static/icons/favicon-16.png deleted file mode 100644 index 40b9c1f..0000000 Binary files a/backend/static/icons/favicon-16.png and /dev/null differ diff --git a/backend/static/icons/favicon-32.png b/backend/static/icons/favicon-32.png deleted file mode 100644 index 7667efc..0000000 Binary files a/backend/static/icons/favicon-32.png and /dev/null differ diff --git a/backend/static/icons/favicon.ico b/backend/static/icons/favicon.ico deleted file mode 100644 index 9c22a1c..0000000 Binary files a/backend/static/icons/favicon.ico and /dev/null differ diff --git a/backend/static/icons/icon-180.png b/backend/static/icons/icon-180.png deleted file mode 100644 index 57a5fa6..0000000 Binary files a/backend/static/icons/icon-180.png and /dev/null differ diff --git a/backend/static/icons/icon-192.png b/backend/static/icons/icon-192.png deleted file mode 100644 index 2554fff..0000000 Binary files a/backend/static/icons/icon-192.png and /dev/null differ diff --git a/backend/static/icons/icon-512.png b/backend/static/icons/icon-512.png deleted file mode 100644 index 58dc5fe..0000000 Binary files a/backend/static/icons/icon-512.png and /dev/null differ diff --git a/backend/static/index.html b/backend/static/index.html index f0fd842..d5c5e22 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -6,14 +6,9 @@ - - - - - - + @@ -35,23 +30,17 @@
-
- Ban Yaro -
+ Ban Yaro