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:
parent
88912e2746
commit
f8d354749d
19 changed files with 963 additions and 198 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue