Feature: Tagebuch Multi-Medien (beliebig viele Fotos/Videos pro Eintrag)
- Backend: neue Tabelle diary_media (Migration), upload_media schreibt
jetzt in diary_media statt media_url; neuer DELETE-Endpoint
/diary/{id}/media/{media_id}; alle GET-Endpoints liefern media_items[].
- Frontend: Multi-Upload-Grid im Formular mit Vorschau und X-Button
zum Entfernen vor dem Speichern; bestehende Medien im Edit-Modus
einzeln löschbar; Detail-Ansicht zeigt horizontale Scroll-Galerie
bei mehreren Medien; Karten-Badge zeigt Anzahl bei > 1 Medium.
- Rückwärtskompatibilität: Einträge mit media_url werden weiterhin
korrekt angezeigt.
- SW by-v211, APP_VER 181
This commit is contained in:
parent
6581a9a88c
commit
63ab092f5e
7 changed files with 367 additions and 165 deletions
|
|
@ -86,10 +86,30 @@ def _set_dog_ids(conn, entry_id: int, dog_ids: list[int]):
|
|||
)
|
||||
|
||||
|
||||
def _entry_dict(row, dog_ids_map: dict) -> dict:
|
||||
def _fetch_media_items(conn, entry_ids: list[int]) -> dict:
|
||||
"""Gibt {entry_id: [{url, media_type, sort_order, id}, ...]} zurück."""
|
||||
if not entry_ids:
|
||||
return {}
|
||||
ph = ",".join("?" * len(entry_ids))
|
||||
rows = conn.execute(
|
||||
f"SELECT id, diary_id, url, media_type, sort_order FROM diary_media "
|
||||
f"WHERE diary_id IN ({ph}) ORDER BY diary_id, sort_order",
|
||||
entry_ids
|
||||
).fetchall()
|
||||
result = {}
|
||||
for r in rows:
|
||||
result.setdefault(r["diary_id"], []).append({
|
||||
"id": r["id"], "url": r["url"],
|
||||
"media_type": r["media_type"], "sort_order": r["sort_order"]
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _entry_dict(row, dog_ids_map: dict, media_map: dict = None) -> 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"]])
|
||||
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
|
||||
e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]])
|
||||
e["media_items"] = (media_map or {}).get(e["id"], [])
|
||||
return e
|
||||
|
||||
|
||||
|
|
@ -122,9 +142,10 @@ async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
|
|||
(dog_id, dog_id, limit, offset)
|
||||
).fetchall()
|
||||
ids = [r["id"] for r in rows]
|
||||
dogs_map = _fetch_dog_ids(conn, ids)
|
||||
dogs_map = _fetch_dog_ids(conn, ids)
|
||||
media_map = _fetch_media_items(conn, ids)
|
||||
|
||||
return [_entry_dict(r, dogs_map) for r in rows]
|
||||
return [_entry_dict(r, dogs_map, media_map) for r in rows]
|
||||
|
||||
|
||||
@router.post("/{dog_id}/diary", status_code=201)
|
||||
|
|
@ -158,9 +179,10 @@ async def create_diary(dog_id: int, data: DiaryCreate,
|
|||
(dog_id,)
|
||||
).fetchone()
|
||||
_set_dog_ids(conn, entry["id"], all_dogs)
|
||||
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
|
||||
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
|
||||
media_map = _fetch_media_items(conn, [entry["id"]])
|
||||
|
||||
return _entry_dict(entry, dogs_map)
|
||||
return _entry_dict(entry, dogs_map, media_map)
|
||||
|
||||
|
||||
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
||||
|
|
@ -302,9 +324,10 @@ async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
|||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden.")
|
||||
dogs_map = _fetch_dog_ids(conn, [entry_id])
|
||||
dogs_map = _fetch_dog_ids(conn, [entry_id])
|
||||
media_map = _fetch_media_items(conn, [entry_id])
|
||||
|
||||
return _entry_dict(row, dogs_map)
|
||||
return _entry_dict(row, dogs_map, media_map)
|
||||
|
||||
|
||||
@router.patch("/{dog_id}/diary/{entry_id}")
|
||||
|
|
@ -345,10 +368,11 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
|
|||
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])
|
||||
row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
|
||||
dogs_map = _fetch_dog_ids(conn, [entry_id])
|
||||
media_map = _fetch_media_items(conn, [entry_id])
|
||||
|
||||
return _entry_dict(row, dogs_map)
|
||||
return _entry_dict(row, dogs_map, media_map)
|
||||
|
||||
|
||||
@router.delete("/{dog_id}/diary/{entry_id}", status_code=204)
|
||||
|
|
@ -360,6 +384,16 @@ async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user
|
|||
)
|
||||
|
||||
|
||||
def _guess_media_type(content_type: str, filename: str) -> str:
|
||||
ct = (content_type or "").lower()
|
||||
if ct.startswith("video/"):
|
||||
return "video"
|
||||
ext = os.path.splitext(filename or "")[1].lower()
|
||||
if ext in {".mp4", ".mov", ".webm", ".m4v", ".avi"}:
|
||||
return "video"
|
||||
return "image"
|
||||
|
||||
|
||||
@router.post("/{dog_id}/diary/{entry_id}/media")
|
||||
async def upload_media(dog_id: int, entry_id: int,
|
||||
file: UploadFile = File(...),
|
||||
|
|
@ -386,29 +420,58 @@ async def upload_media(dog_id: int, entry_id: int,
|
|||
".mp4",".mov",".webm",".m4v"}:
|
||||
raise HTTPException(415, "Nur Bilder und Videos erlaubt.")
|
||||
|
||||
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
||||
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
path = os.path.join(MEDIA_DIR, "diary", filename)
|
||||
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
||||
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
path = os.path.join(MEDIA_DIR, "diary", filename)
|
||||
media_type = _guess_media_type(ct, file.filename or "")
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
|
||||
with open(path, "wb") as f:
|
||||
f.write(await file.read())
|
||||
|
||||
# Altes Medium von Disk löschen wenn vorhanden
|
||||
with db() as conn:
|
||||
old = conn.execute("SELECT media_url FROM diary WHERE id=?", (entry_id,)).fetchone()
|
||||
if old and old["media_url"]:
|
||||
old_path = os.path.join(MEDIA_DIR, old["media_url"].lstrip("/media/"))
|
||||
try: os.remove(old_path)
|
||||
except OSError: pass
|
||||
media_url = f"/media/diary/{filename}"
|
||||
conn.execute("UPDATE diary SET media_url=? WHERE id=?", (media_url, entry_id))
|
||||
media_url = f"/media/diary/{filename}"
|
||||
|
||||
return {"media_url": media_url}
|
||||
with db() as conn:
|
||||
# sort_order = nächste freie Position
|
||||
max_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) FROM diary_media WHERE diary_id=?",
|
||||
(entry_id,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO diary_media (diary_id, url, media_type, sort_order) VALUES (?,?,?,?)",
|
||||
(entry_id, media_url, media_type, max_order + 1)
|
||||
)
|
||||
new_id = conn.execute(
|
||||
"SELECT id FROM diary_media WHERE diary_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}
|
||||
|
||||
|
||||
@router.delete("/{dog_id}/diary/{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:
|
||||
_own_dog(dog_id, user["id"], conn)
|
||||
row = conn.execute(
|
||||
"SELECT dm.id, dm.url FROM diary_media dm "
|
||||
"JOIN diary d ON d.id = dm.diary_id "
|
||||
"LEFT JOIN diary_dogs dd ON dd.diary_id = d.id "
|
||||
"WHERE dm.id=? AND dm.diary_id=? AND (d.dog_id=? OR dd.dog_id=?)",
|
||||
(media_id, entry_id, dog_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 diary_media WHERE id=?", (media_id,))
|
||||
|
||||
|
||||
@router.delete("/{dog_id}/diary/{entry_id}/media", status_code=204)
|
||||
async def delete_media(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
||||
async def delete_media_legacy(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
||||
"""Legacy-Endpoint: löscht media_url aus dem diary-Datensatz (Rückwärtskompatibilität)."""
|
||||
with db() as conn:
|
||||
_own_dog(dog_id, user["id"], conn)
|
||||
row = conn.execute(
|
||||
|
|
@ -421,7 +484,3 @@ async def delete_media(dog_id: int, entry_id: int, user=Depends(get_current_user
|
|||
try: os.remove(path)
|
||||
except OSError: pass
|
||||
conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,))
|
||||
|
||||
|
||||
|
||||
return unique
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue