diff --git a/backend/routes/health.py b/backend/routes/health.py index e758cf8..185c363 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -11,6 +11,8 @@ from auth import get_current_user router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"} + # Erlaubte Typen TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument", "laeufigkeit"} @@ -70,7 +72,7 @@ class HealthUpdate(BaseModel): # ------------------------------------------------------------------ -# Hilfsfunktion: Zugriffscheck Dog → User +# Hilfsfunktionen # ------------------------------------------------------------------ def _check_dog_owner(conn, dog_id: int, user_id: int): dog = conn.execute( @@ -81,6 +83,30 @@ def _check_dog_owner(conn, dog_id: int, user_id: int): return dog +def _fetch_media_items(conn, entry_ids: list) -> dict: + """Gibt {health_id: [{id, url, media_type}, ...]} zurück.""" + if not entry_ids: + return {} + ph = ",".join("?" * len(entry_ids)) + rows = conn.execute( + f"SELECT id, health_id, url, media_type FROM health_media " + f"WHERE health_id IN ({ph}) ORDER BY health_id, sort_order", + entry_ids + ).fetchall() + result = {} + for r in rows: + result.setdefault(r["health_id"], []).append({ + "id": r["id"], "url": r["url"], "media_type": r["media_type"] + }) + return result + + +def _entry_with_media(row, media_map: dict) -> dict: + e = dict(row) + e["media_items"] = media_map.get(e["id"], []) + return e + + # ------------------------------------------------------------------ # GET /api/dogs/{dog_id}/health # ------------------------------------------------------------------ @@ -99,7 +125,9 @@ async def list_health(dog_id: int, typ: Optional[str] = None, "SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC", (dog_id,) ).fetchall() - return [dict(r) for r in rows] + ids = [r["id"] for r in rows] + media_map = _fetch_media_items(conn, ids) + return [_entry_with_media(r, media_map) for r in rows] # ------------------------------------------------------------------ @@ -130,7 +158,8 @@ async def create_health(dog_id: int, data: HealthCreate, "SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,) ).fetchone() - return dict(row) + media_map = _fetch_media_items(conn, [row["id"]]) + return _entry_with_media(row, media_map) # ------------------------------------------------------------------ @@ -155,7 +184,8 @@ async def update_health(dog_id: int, entry_id: int, data: HealthUpdate, 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) + media_map = _fetch_media_items(conn, [entry_id]) + return _entry_with_media(row, media_map) # ------------------------------------------------------------------ @@ -243,6 +273,77 @@ async def upload_dokument( return {"datei_url": datei_url, "datei_typ": datei_typ} +# ------------------------------------------------------------------ +# POST /api/dogs/{dog_id}/health/{entry_id}/media — Datei-Upload (Multi) +# ------------------------------------------------------------------ +@router.post("/{dog_id}/health/{entry_id}/media") +async def upload_media( + 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 ALLOWED_EXTENSIONS: + 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()) + + media_url = f"/media/health/{filename}" + media_type = "pdf" if ext == ".pdf" else "image" + + with db() as conn: + max_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) FROM health_media WHERE health_id=?", + (entry_id,) + ).fetchone()[0] + conn.execute( + "INSERT INTO health_media (health_id, url, media_type, sort_order) VALUES (?,?,?,?)", + (entry_id, media_url, media_type, max_order + 1) + ) + new_id = conn.execute( + "SELECT id FROM health_media WHERE health_id=? ORDER BY id DESC LIMIT 1", + (entry_id,) + ).fetchone()["id"] + + return {"id": new_id, "url": media_url, "media_type": media_type, "sort_order": max_order + 1} + + +# ------------------------------------------------------------------ +# DELETE /api/dogs/{dog_id}/health/{entry_id}/media/{media_id} +# ------------------------------------------------------------------ +@router.delete("/{dog_id}/health/{entry_id}/media/{media_id}", status_code=204) +async def delete_media_item(dog_id: int, entry_id: int, media_id: int, + user=Depends(get_current_user)): + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + row = conn.execute( + "SELECT hm.id, hm.url FROM health_media hm " + "JOIN health h ON h.id = hm.health_id " + "WHERE hm.id=? AND hm.health_id=? AND h.dog_id=?", + (media_id, entry_id, dog_id) + ).fetchone() + if not row: + raise HTTPException(404, "Medium nicht gefunden.") + file_path = os.path.join(MEDIA_DIR, row["url"].lstrip("/media/")) + try: os.remove(file_path) + except OSError: pass + conn.execute("DELETE FROM health_media WHERE id=?", (media_id,)) + + # ------------------------------------------------------------------ # GET /api/dogs/{dog_id}/health/gewicht — Gewichtsverlauf # ------------------------------------------------------------------ diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 2659f1f..610cd46 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1419,6 +1419,104 @@ html.modal-open { flex-shrink: 0; } +/* Health Multi-Media — Upload-Grid im Formular */ +.health-media-grid { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-1); +} +.health-media-thumb { + position: relative; + width: 72px; + height: 72px; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--c-surface-2); + flex-shrink: 0; + border: 1px solid var(--c-border-light); +} +.health-media-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.health-media-thumb-pdf { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + gap: var(--space-1); + font-size: 1.4rem; + color: var(--c-text-secondary); +} +.health-media-thumb-pdf span { + font-size: var(--text-xs); + font-weight: var(--weight-semibold); +} +.health-media-remove { + position: absolute; + top: 2px; + right: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba(0,0,0,0.55); + color: #fff; + border: none; + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.health-media-thumb--pending { + opacity: 0.75; + border-style: dashed; +} +.health-media-thumb--pending small { + position: absolute; + bottom: 2px; + left: 2px; + right: 2px; + font-size: 9px; + text-align: center; + color: var(--c-text-muted); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Health Media-Galerie in Detail-Modal */ +.health-media-gallery { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +.health-media-gallery-img { + display: block; + width: calc(50% - var(--space-1)); + max-width: 160px; + border-radius: var(--radius-md); + overflow: hidden; + flex-shrink: 0; +} +.health-media-gallery-img img { + width: 100%; + height: 100px; + object-fit: cover; + display: block; + border-radius: var(--radius-md); +} +.health-media-gallery-pdf { + align-self: flex-start; +} + /* Detail-Dialog DL */ .health-detail-dl { display: grid; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index dbe8642..4605477 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '186'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '187'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 36c55c0..c266981 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -813,30 +813,42 @@ window.Page_health = (() => { }); const items = entries.map(e => { - const isPdf = e.datei_typ === 'pdf'; - const hasFile = !!e.datei_url; + // media_items bevorzugen, legacy datei_url als Fallback + const mediaList = e.media_items?.length + ? e.media_items + : (e.datei_url ? [{ id: null, url: e.datei_url, media_type: e.datei_typ || 'image' }] : []); + const firstImg = mediaList.find(m => m.media_type !== 'pdf'); + const hasPdf = mediaList.some(m => m.media_type === 'pdf'); + const count = mediaList.length; + return `