Feature: Tagebuch Ort/POI, Foto/Video-Edit, Modal-UX, iOS-Fixes

Tagebuch — Ort/POI (DayOne-ähnlich):
- diary.location_name Spalte, DiaryCreate/Update mit gps_lat/lon/location_name
- GET /api/dogs/{id}/diary/nearby: Overpass + Nominatim (vor {entry_id}-Route)
- Mini-Karte im Edit-Formular: Leaflet lazy, Edit-Modus, SVG-Pin
- Meilenstein-Toggle: Button statt Checkbox, Filter in Toolbar
- Datenmigration: 97 Ort-Einträge aus text → location_name

Tagebuch — Foto/Video:
- Foto/Video im Edit: Ersetzen + Löschen, DELETE media endpoint
- Media-Picker: Kamera/Mediathek/Datei Buttons
- Video-Wiedergabe (<video controls> in Detail + Edit)

Modal-UX (alle Edit-Karten vereinheitlicht):
- Footer-Pattern: [Speichern vollbreit] / [Löschen][Abbrechen]
- diary, dog-profile, events, health, places, walks, settings, sitting
- Löschen aus Detail-Modal → Edit-Form verschoben

iOS Mobile-Fixes:
- Auto-Zoom: input/select/textarea font-size 16px !important
- Scroll-Through: html.modal-open + touch-action:none auf Overlay
- Kein position:fixed mehr auf body (kein Scroll-Sprung)

PWA & Icons:
- icon-512-any.png + icon-192-any.png (quadratisch, maskable)
- manifest.json: purpose any/maskable getrennt
- Gesundheits-Icon: syringe → first-aid

Import-Fix:
- _HTMLStripper überspringt video/audio/script → kein "Video nicht gefunden" mehr
This commit is contained in:
rene 2026-04-18 11:56:54 +02:00
parent 88912e2746
commit f8d354749d
19 changed files with 963 additions and 198 deletions

View file

@ -1,35 +1,40 @@
"""BAN YARO — Tagebuch Routes"""
import os, uuid, json
import os, uuid, json, math
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
import ki as KI
import httpx
router = APIRouter()
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
location_name: Optional[str] = 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
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen
titel: Optional[str] = None
text: Optional[str] = None
tags: Optional[list] = None
gps_lat: Optional[float] = None
gps_lon: Optional[float] = None
location_name: Optional[str] = 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):
@ -90,26 +95,28 @@ def _entry_dict(row, dog_ids_map: dict) -> dict:
@router.get("/{dog_id}/diary")
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
q: Optional[str] = None,
q: Optional[str] = None, milestone: int = 0,
user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
extra = "AND (d.is_milestone=1 OR d.typ='meilenstein')" if milestone else ""
if q:
pattern = f"%{q}%"
rows = conn.execute(
"""SELECT DISTINCT d.* FROM diary d
f"""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 = ?)
AND (d.titel LIKE ? OR d.text LIKE ? OR d.tags LIKE ?)
{extra}
ORDER BY d.datum DESC, d.created_at DESC
LIMIT ? OFFSET ?""",
(dog_id, dog_id, pattern, pattern, pattern, limit, offset)
).fetchall()
else:
rows = conn.execute(
"""SELECT DISTINCT d.* FROM diary d
f"""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 = ?
WHERE (d.dog_id = ? OR dd.dog_id = ?) {extra}
ORDER BY d.datum DESC, d.created_at DESC
LIMIT ? OFFSET ?""",
(dog_id, dog_id, limit, offset)
@ -139,12 +146,12 @@ async def create_diary(dog_id: int, data: DiaryCreate,
conn.execute(
"""INSERT INTO diary
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone)
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, location_name, is_milestone)
VALUES (?,
COALESCE(?, date('now')),
?,?,?,?,?,?,?)""",
?,?,?,?,?,?,?,?)""",
(dog_id, data.datum, data.typ, data.titel, data.text,
json.dumps(tags), data.gps_lat, data.gps_lon, int(data.is_milestone))
json.dumps(tags), data.gps_lat, data.gps_lon, data.location_name, int(data.is_milestone))
)
entry = conn.execute(
"SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1",
@ -156,6 +163,133 @@ async def create_diary(dog_id: int, data: DiaryCreate,
return _entry_dict(entry, dogs_map)
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
R = 6371
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
* math.sin(dlon / 2) ** 2)
return R * 2 * math.asin(math.sqrt(a))
@router.get("/{dog_id}/diary/nearby")
async def nearby_places(dog_id: int, lat: float, lon: float,
user=Depends(get_current_user)):
results = []
# 1. User-eigene Places
with db() as conn:
_own_dog(dog_id, user["id"], conn)
places = conn.execute(
"SELECT name, typ, lat, lon FROM places WHERE user_id=? AND lat IS NOT NULL",
(user["id"],)
).fetchall()
for p in places:
km = _haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 5:
results.append({"name": p["name"], "type": p["typ"] or "place",
"lat": p["lat"], "lon": p["lon"],
"distance_m": int(km * 1000), "source": "places"})
# 2. Gecachte OSM-POIs (nur wenn vorhanden)
osm = conn.execute(
"SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''"
).fetchall()
for p in osm:
km = _haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 2:
results.append({"name": p["name"], "type": p["type"],
"lat": p["lat"], "lon": p["lon"],
"distance_m": int(km * 1000), "source": "osm"})
async with httpx.AsyncClient(timeout=6) as client:
# 3. Overpass: benannte POIs — 800m, bei leerer Antwort auf 2000m erweitern
try:
def _overpass_q(radius):
return (
f'[out:json][timeout:6];'
f'('
f' node["name"]["tourism"](around:{radius},{lat},{lon});'
f' node["name"]["historic"](around:{radius},{lat},{lon});'
f' node["name"]["leisure"](around:{radius},{lat},{lon});'
f' node["name"]["amenity"](around:{radius},{lat},{lon});'
f' node["name"]["shop"](around:{radius},{lat},{lon});'
f' way["name"]["tourism"](around:{radius},{lat},{lon});'
f' way["name"]["historic"](around:{radius},{lat},{lon});'
f' way["name"]["leisure"](around:{radius},{lat},{lon});'
f');'
f'out center;'
)
overpass_q = _overpass_q(800)
ov = await client.post(
"https://overpass-api.de/api/interpreter",
data={"data": overpass_q},
headers={"User-Agent": "BanYaro/1.0"},
)
elements = ov.json().get("elements", []) if ov.status_code == 200 else []
# Kein Ergebnis → Radius auf 2 km erweitern
if not elements:
ov2 = await client.post(
"https://overpass-api.de/api/interpreter",
data={"data": _overpass_q(2000)},
headers={"User-Agent": "BanYaro/1.0"},
)
elements = ov2.json().get("elements", []) if ov2.status_code == 200 else []
if True:
for el in elements:
n = el.get("tags", {}).get("name")
if not n:
continue
elat = el.get("lat") or el.get("center", {}).get("lat")
elon = el.get("lon") or el.get("center", {}).get("lon")
if elat and elon:
km = _haversine_km(lat, lon, elat, elon)
typ = next((el["tags"].get(k) for k in
["tourism","historic","leisure","amenity","shop"]
if el["tags"].get(k)), "place")
results.append({"name": n, "type": typ,
"lat": elat, "lon": elon,
"distance_m": int(km * 1000), "source": "overpass"})
except Exception:
pass
# 4. Nominatim Reverse-Geocode als Adress-Fallback
try:
r = await client.get(
"https://nominatim.openstreetmap.org/reverse",
params={"lat": lat, "lon": lon, "format": "json",
"zoom": 16, "namedetails": 1, "addressdetails": 1},
headers={"User-Agent": "BanYaro/1.0"},
)
if r.status_code == 200:
d = r.json()
# namedetails kann None sein wenn kein benanntes Feature vorhanden
name = (d.get("name") or
(d.get("namedetails") or {}).get("name"))
if not name:
addr = d.get("address", {})
name = (addr.get("village") or addr.get("hamlet") or
addr.get("town") or addr.get("city") or
addr.get("suburb") or
d.get("display_name", "").split(",")[0].strip())
if name:
results.append({"name": name, "type": "address",
"lat": lat, "lon": lon,
"distance_m": 0, "source": "nominatim"})
except Exception:
pass
seen, unique = set(), []
for r in sorted(results, key=lambda x: x["distance_m"]):
key = r["name"].lower()
if key not in seen:
seen.add(key); unique.append(r)
if len(unique) >= 12:
break
return unique
@router.get("/{dog_id}/diary/{entry_id}")
async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
with db() as conn:
@ -189,13 +323,13 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
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:
# Felder updaten — location_name/gps_* dürfen explizit auf None gesetzt werden
raw = data.model_dump(exclude={"dog_ids"})
NULLABLE = {"location_name", "gps_lat", "gps_lon"}
fields = {k: v for k, v in raw.items() if v is not None or k in NULLABLE}
if "tags" in fields and fields["tags"] is not None:
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=?",
@ -241,6 +375,17 @@ async def upload_media(dog_id: int, entry_id: int,
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
ALLOWED = {
"image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif",
"video/mp4", "video/quicktime", "video/webm", "video/x-m4v",
}
ct = file.content_type or ""
if ct not in ALLOWED:
ext_low = os.path.splitext(file.filename or "")[1].lower()
if ext_low not in {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif",
".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)
@ -249,8 +394,34 @@ async def upload_media(dog_id: int, entry_id: int,
with open(path, "wb") as f:
f.write(await file.read())
media_url = f"/media/diary/{filename}"
# 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))
return {"media_url": media_url}
@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)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
row = conn.execute(
"SELECT media_url FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
if row["media_url"]:
path = os.path.join(MEDIA_DIR, row["media_url"].lstrip("/media/"))
try: os.remove(path)
except OSError: pass
conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,))
return unique