diff --git a/backend/database.py b/backend/database.py index d6b0dfe..a98cda8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1951,6 +1951,21 @@ def _migrate(conn_factory): conn.execute("ALTER TABLE users ADD COLUMN gassi_stunde_push INTEGER NOT NULL DEFAULT 0") logger.info("Migration: users.gassi_stunde_push bereit.") + # Futter-Profil + conn.executescript(""" + CREATE TABLE IF NOT EXISTS futter_profil ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE UNIQUE, + futter_typ TEXT, + marke TEXT, + kcal_tag INTEGER, + portionen INTEGER DEFAULT 2, + notizen TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + logger.info("Migration: futter_profil bereit.") + # Wiederkehrende Ausgaben (Daueraufträge) conn.executescript(""" CREATE TABLE IF NOT EXISTS recurring_expenses ( diff --git a/backend/main.py b/backend/main.py index 229a856..1d23aef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,9 +6,10 @@ import os import html import logging from collections import deque +import httpx from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, Response from starlette.middleware.base import BaseHTTPMiddleware from fastapi.middleware.gzip import GZipMiddleware from brotli_asgi import BrotliMiddleware @@ -43,10 +44,43 @@ logger = logging.getLogger(__name__) # ------------------------------------------------------------------ # Startup / Shutdown # ------------------------------------------------------------------ +def _backfill_image_sizes(): + """Füllt img_width/img_height für alle diary_media-Bilder ohne Maße nach.""" + import io + from database import db + from media_utils import get_image_size + MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + with db() as conn: + rows = conn.execute( + "SELECT id, url FROM diary_media WHERE media_type='image' AND img_width IS NULL" + ).fetchall() + if not rows: + return + logger.info("Backfill Bildmaße: %d Einträge...", len(rows)) + updated = 0 + for row in rows: + # url ist z.B. /media/diary/xxx.jpg → Pfad: MEDIA_DIR/diary/xxx.jpg + rel = row["url"].removeprefix("/media/") + path = os.path.join(MEDIA_DIR, rel) + try: + with open(path, "rb") as f: + data = f.read() + size = get_image_size(data) + if size: + with db() as conn: + conn.execute( + "UPDATE diary_media SET img_width=?, img_height=? WHERE id=?", + (size[0], size[1], row["id"]) + ) + updated += 1 + except Exception: + pass + logger.info("Backfill Bildmaße abgeschlossen: %d/%d aktualisiert.", updated, len(rows)) @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Ban Yaro startet...") init_db() + _backfill_image_sizes() from routes.movies import seed_movies seed_movies() logger.info(f"KI-Modus: {ki.KI_MODE}") @@ -76,7 +110,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: blob: https:; " "connect-src 'self' https:; " @@ -198,6 +232,7 @@ from routes.adoption import router as adoption_router from routes.health_docs import router as health_docs_router from routes.passport import router as passport_router from routes.playdate import router as playdate_router +from routes.ernaehrung import router as ernaehrung_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -256,6 +291,7 @@ app.include_router(adoption_router, prefix="/api/adoption", ta app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"]) app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"]) app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"]) +app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"]) # ------------------------------------------------------------------ @@ -285,6 +321,27 @@ 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("/stats/script.js") +async def umami_script_proxy(): + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get("https://umami.motocamp.de/script.js") + return Response(content=r.content, media_type="application/javascript", + headers={"Cache-Control": "public, max-age=86400"}) + +@app.post("/stats/api/send") +async def umami_send_proxy(request: Request): + body = await request.body() + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + "https://umami.motocamp.de/api/send", + content=body, + headers={"Content-Type": "application/json", + "User-Agent": request.headers.get("user-agent", "")}, + ) + return Response(content=r.content, status_code=r.status_code, + media_type="application/json") + + @app.get("/robots.txt") async def robots(): return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain") diff --git a/backend/routes/ernaehrung.py b/backend/routes/ernaehrung.py new file mode 100644 index 0000000..c1f850e --- /dev/null +++ b/backend/routes/ernaehrung.py @@ -0,0 +1,145 @@ +"""BAN YARO — Ernährungs-Routes""" + +import logging +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user +import ki as ki_module + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class FutterProfilUpdate(BaseModel): + futter_typ: Optional[str] = None # trocken|nass|barf|mix + marke: Optional[str] = None + kcal_tag: Optional[int] = None + portionen: Optional[int] = None + notizen: Optional[str] = None + + +class KiBeratungRequest(BaseModel): + frage: str + dog_name: Optional[str] = None + rasse: Optional[str] = None + alter: Optional[str] = None + gewicht: Optional[float] = None + aktiv: Optional[bool] = None + + +# ------------------------------------------------------------------ +# Hilfsfunktion: Zugriffsprüfung +# ------------------------------------------------------------------ +def _check_dog_access(conn, dog_id: int, user_id: int): + row = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) + ).fetchone() + if not row: + raise HTTPException(404, "Hund nicht gefunden.") + + +# ------------------------------------------------------------------ +# GET /dogs/{dog_id}/ernaehrung +# ------------------------------------------------------------------ +@router.get("/{dog_id}/ernaehrung") +async def get_ernaehrung(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + row = conn.execute( + "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,) + ).fetchone() + if not row: + return {} + return dict(row) + + +# ------------------------------------------------------------------ +# PUT /dogs/{dog_id}/ernaehrung +# ------------------------------------------------------------------ +@router.put("/{dog_id}/ernaehrung") +async def put_ernaehrung(dog_id: int, body: FutterProfilUpdate, + user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + existing = conn.execute( + "SELECT id FROM futter_profil WHERE dog_id=?", (dog_id,) + ).fetchone() + if existing: + conn.execute(""" + UPDATE futter_profil + SET futter_typ=COALESCE(?, futter_typ), + marke=COALESCE(?, marke), + kcal_tag=COALESCE(?, kcal_tag), + portionen=COALESCE(?, portionen), + notizen=COALESCE(?, notizen), + updated_at=datetime('now') + WHERE dog_id=? + """, (body.futter_typ, body.marke, body.kcal_tag, + body.portionen, body.notizen, dog_id)) + else: + conn.execute(""" + INSERT INTO futter_profil + (dog_id, futter_typ, marke, kcal_tag, portionen, notizen) + VALUES (?, ?, ?, ?, ?, ?) + """, (dog_id, body.futter_typ, body.marke, body.kcal_tag, + body.portionen or 2, body.notizen)) + row = conn.execute( + "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# POST /dogs/{dog_id}/ernaehrung/ki-beratung +# ------------------------------------------------------------------ +@router.post("/{dog_id}/ernaehrung/ki-beratung") +async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest, + request: Request, + user=Depends(get_current_user)): + if not body.frage or len(body.frage.strip()) < 3: + raise HTTPException(400, "Bitte stelle eine Frage.") + if len(body.frage) > 800: + raise HTTPException(400, "Frage zu lang (max. 800 Zeichen).") + + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + + dog_name = body.dog_name or "unbekannt" + rasse = body.rasse or "unbekannt" + alter = body.alter or "unbekannt" + gewicht = f"{body.gewicht} kg" if body.gewicht else "unbekannt" + aktiv_str = "aktiv" if body.aktiv else "normal aktiv" + + system = ( + "Du bist Ernährungsberater für Hunde. " + "Antworte immer auf Deutsch, kurz und praktisch. " + "Keine unnötigen Füllsätze. " + "Weise bei ernsthaften Gesundheitsfragen immer auf den Tierarzt hin. " + "Stelle keine medizinischen Diagnosen." + ) + + prompt = ( + f"Hund: {dog_name}, Rasse: {rasse}, Alter: {alter}, " + f"Gewicht: {gewicht}, Aktivität: {aktiv_str}.\n\n" + f"Frage: {body.frage.strip()}\n\n" + "Antworte konkret und praktisch, maximal 200 Wörter." + ) + + try: + antwort = await ki_module.complete( + prompt=prompt, + system=system, + max_tokens=500, + requires_premium=False, + user_id=user["id"], + ) + return {"antwort": antwort} + except ki_module.KIUnavailableError as e: + raise HTTPException(503, str(e)) + except Exception: + raise HTTPException(500, "KI momentan nicht verfügbar.") diff --git a/backend/static/index.html b/backend/static/index.html index 837f2eb..37d6fcd 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + +
@@ -499,6 +499,14 @@ ++ Berechne den täglichen Kalorienbedarf deines Hundes. +
+ ++ Richtwert nach Nationaler Forschungsratsformel (NRC). Immer den Körperzustand beobachten. +
+ `; + + // Profil-Speichern einblenden und kcal vorbelegen + const profilSection = el.querySelector('#ern-profil-speichern'); + profilSection.style.display = ''; + + // kcal für Speichern merken + profilSection.dataset.kcal = kcal; + + el.querySelector('#ern-prof-save-btn').onclick = () => _speichereProfil(el, kcal); + } + + async function _speichereProfil(el, kcal) { + const dog = _appState.activeDog; + const futter_typ = el.querySelector('#ern-prof-typ').value || null; + const marke = el.querySelector('#ern-prof-marke').value.trim() || null; + const portionen = parseInt(el.querySelector('#ern-prof-portionen').value) || 2; + const notizen = el.querySelector('#ern-prof-notizen').value.trim() || null; + + const btn = el.querySelector('#ern-prof-save-btn'); + await UI.asyncButton(btn, async () => { + try { + _profil = await API.put(`/dogs/${dog.id}/ernaehrung`, { + futter_typ, marke, kcal_tag: kcal, portionen, notizen, + }); + UI.toast.success('Profil gespeichert.'); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Speichern.'); + } + }); + } + + // ------------------------------------------------------------------ + // TAB 2: FUTTER-GUIDE + // ------------------------------------------------------------------ + function _renderGuide(el) { + const cards = [ + { + id: 'barf', + emoji: '🥩', + titel: 'BARF (Rohfütterung)', + inhalt: ` +Zusammensetzung: 70 % Muskelfleisch, 10 % rohe Knochen, 10 % Organe, 10 % Gemüse & Obst
+Vorteile: Naturnahste Ernährungsform, glänzendes Fell, weniger Kot, keine Zusatzstoffe
+Risiken: Keimbelastung durch rohes Fleisch, Calcium-Phosphor-Balance muss stimmen, zeitaufwändig und teurer
+Tipp: Niemals BARF und Trockenfutter in derselben Mahlzeit mischen — unterschiedliche Verdauungszeiten können zu Problemen führen.
+ `, + }, + { + id: 'nass', + emoji: '🥫', + titel: 'Nassfutter', + inhalt: ` +Zusammensetzung: 70–80 % Wasseranteil, meist höherer Fleischanteil als Trockenfutter
+Vorteile: Hunde trinken automatisch mehr (gut für die Niere), schmackhafter, gut für wählerische Hunde
+Worauf achten: Erste Zutat auf der Liste = Fleisch (nicht „Tierische Nebenerzeugnisse"), kein Zucker, kein Karamell
+Zähne: Schlechter für die Zahngesundheit als Trockenfutter — öfter Zähne putzen oder Kauartikel geben.
+ `, + }, + { + id: 'trocken', + emoji: '🌾', + titel: 'Trockenfutter', + inhalt: ` +Zusammensetzung: 6–10 % Wasser, ca. 350–400 kcal/100 g, konzentrierte Nährstoffe
+Gute Zutaten: Benanntes Fleisch an erster Stelle (Huhn, Lachs), mind. 40 % Tierprotein, kein Getreide als Hauptzutat
+Schlechte Zutaten: „Getreide" als erste Zutat, Zucker, Karamell, Konservierungsstoffe E320 / E321
+Wichtig: Immer frisches Wasser bereitstellen — Trockenfutter enthält kaum Feuchtigkeit.
+ `, + }, + ]; + + el.innerHTML = ` ++ Klicke auf eine Karte für Details. +
+ ${cards.map(c => ` + + `).join('')} ++ Diese Liste ist nicht vollständig. Im Zweifel gilt: lieber weglassen. +
+')
+ .replace(/\n/g, '
');
+
+ const placeholder = document.getElementById(placeholderId);
+ if (placeholder) {
+ placeholder.innerHTML = `
+
${antwortHtml}
+