Sprint 12+13: Tagebuch Day-One-Redesign, Notiz-Feature, Icon-Fixes, SW by-v405
Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations
Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil
Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware
Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
This commit is contained in:
parent
95f91fdc00
commit
553e9e7854
35 changed files with 4558 additions and 370 deletions
|
|
@ -1,14 +1,17 @@
|
|||
"""BAN YARO — Tagebuch Routes"""
|
||||
|
||||
import os, uuid, json, math
|
||||
import os, uuid, json, math, logging
|
||||
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
|
||||
from auth import get_current_user, require_admin
|
||||
import ki as KI
|
||||
import httpx
|
||||
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload
|
||||
import weather as weather_mod
|
||||
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||
|
|
@ -142,6 +145,69 @@ def _entry_dict(row, dog_ids_map: dict, media_map: dict = None) -> dict:
|
|||
return e
|
||||
|
||||
|
||||
@router.get("/{dog_id}/diary/stats")
|
||||
async def diary_stats(dog_id: int, user=Depends(get_current_user)):
|
||||
"""Gesamtstatistik für das Tagebuch (unabhängig von Pagination)."""
|
||||
with db() as conn:
|
||||
_can_read_dog(dog_id, user["id"], conn)
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
|
||||
"WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
|
||||
).fetchone()[0]
|
||||
photos = conn.execute(
|
||||
"SELECT COUNT(*) 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 (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
|
||||
).fetchone()[0]
|
||||
days = conn.execute(
|
||||
"SELECT COUNT(DISTINCT d.datum) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
|
||||
"WHERE d.datum IS NOT NULL AND (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
|
||||
).fetchone()[0]
|
||||
return {"entries": total, "photos": photos, "days": days}
|
||||
|
||||
|
||||
@router.get("/{dog_id}/diary/calendar")
|
||||
async def diary_calendar(dog_id: int, user=Depends(get_current_user)):
|
||||
"""Alle Einträge minimal für Kalenderansicht: id, datum, cover_url."""
|
||||
with db() as conn:
|
||||
_can_read_dog(dog_id, user["id"], conn)
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT d.id, d.datum,
|
||||
(SELECT dm.url FROM diary_media dm
|
||||
WHERE dm.diary_id=d.id AND dm.media_type='image'
|
||||
ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url
|
||||
FROM diary d
|
||||
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
||||
WHERE (d.dog_id=? OR dd.dog_id=?)
|
||||
AND d.datum IS NOT NULL
|
||||
ORDER BY d.datum DESC""",
|
||||
(dog_id, dog_id)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{dog_id}/diary/locations")
|
||||
async def diary_locations(dog_id: int, user=Depends(get_current_user)):
|
||||
"""Alle Tagebucheinträge mit GPS — minimal für Karten-Ansicht."""
|
||||
with db() as conn:
|
||||
_can_read_dog(dog_id, user["id"], conn)
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT d.id, d.datum, d.titel, d.gps_lat, d.gps_lon,
|
||||
d.location_name, d.weather_json,
|
||||
(SELECT dm.url FROM diary_media dm
|
||||
WHERE dm.diary_id=d.id AND dm.media_type='image'
|
||||
ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url,
|
||||
(SELECT COUNT(*) FROM diary_media dm WHERE dm.diary_id=d.id) AS media_count
|
||||
FROM diary d
|
||||
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
||||
WHERE (d.dog_id=? OR dd.dog_id=?)
|
||||
AND d.gps_lat IS NOT NULL AND d.gps_lon IS NOT NULL
|
||||
ORDER BY d.datum DESC""",
|
||||
(dog_id, dog_id)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{dog_id}/diary")
|
||||
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
|
||||
q: Optional[str] = None, milestone: int = 0,
|
||||
|
|
@ -226,10 +292,95 @@ async def create_diary(dog_id: int, data: DiaryCreate,
|
|||
_set_dog_ids(conn, entry["id"], all_dogs)
|
||||
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
|
||||
media_map = _fetch_media_items(conn, [entry["id"]])
|
||||
entry_id = entry["id"]
|
||||
|
||||
# Wetter + POIs asynchron nach dem DB-Commit holen (außerhalb des with-Blocks)
|
||||
if data.gps_lat is not None and data.gps_lon is not None:
|
||||
weather_json = None
|
||||
poi_json = None
|
||||
|
||||
# Wetter holen
|
||||
try:
|
||||
wd = await weather_mod.get_weather_for_location(data.gps_lat, data.gps_lon)
|
||||
weather_json = json.dumps(wd)
|
||||
except Exception as exc:
|
||||
logger.warning("Wetter-Abfrage beim Diary-Create fehlgeschlagen: %s", exc)
|
||||
|
||||
# POIs holen
|
||||
try:
|
||||
pois = await _fetch_pois_for_coords(data.gps_lat, data.gps_lon, limit=5)
|
||||
if pois:
|
||||
poi_json = json.dumps(pois)
|
||||
except Exception as exc:
|
||||
logger.warning("POI-Abfrage beim Diary-Create fehlgeschlagen: %s", exc)
|
||||
|
||||
# In DB speichern und Entry aktualisieren
|
||||
if weather_json is not None or poi_json is not None:
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE diary SET weather_json=?, poi_json=? WHERE id=?",
|
||||
(weather_json, poi_json, entry_id)
|
||||
)
|
||||
entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
|
||||
|
||||
return _entry_dict(entry, dogs_map, media_map)
|
||||
|
||||
|
||||
async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list:
|
||||
"""Holt POIs für Koordinaten via Overpass (analog zu nearby_places, aber ohne DB/Auth)."""
|
||||
results = []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=6) as client:
|
||||
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;'
|
||||
)
|
||||
ov = await client.post(
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
data={"data": _overpass_q(800)},
|
||||
headers={"User-Agent": "BanYaro/1.0"},
|
||||
)
|
||||
elements = ov.json().get("elements", []) if ov.status_code == 200 else []
|
||||
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 []
|
||||
seen = set()
|
||||
for el in elements:
|
||||
n = el.get("tags", {}).get("name")
|
||||
if not n or n.lower() in seen:
|
||||
continue
|
||||
seen.add(n.lower())
|
||||
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,
|
||||
"distance_m": int(km * 1000)})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug("_fetch_pois_for_coords Fehler: %s", exc)
|
||||
return results[:limit]
|
||||
|
||||
|
||||
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
||||
R = 6371
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
|
|
@ -508,6 +659,11 @@ async def upload_media(dog_id: int, entry_id: int,
|
|||
|
||||
media_url = f"/media/diary/{filename}"
|
||||
|
||||
# EXIF-GPS aus Bild extrahieren (nur bei Bilddateien)
|
||||
exif_gps = None
|
||||
if media_type == "image":
|
||||
exif_gps = extract_gps_from_exif(raw_data)
|
||||
|
||||
with db() as conn:
|
||||
# sort_order = nächste freie Position
|
||||
max_order = conn.execute(
|
||||
|
|
@ -525,8 +681,38 @@ async def upload_media(dog_id: int, entry_id: int,
|
|||
(entry_id,)
|
||||
).fetchone()["id"]
|
||||
|
||||
return {"id": new_id, "url": media_url, "media_type": media_type,
|
||||
# GPS aus EXIF in den Eintrag schreiben, wenn noch keine Koordinaten vorhanden
|
||||
gps_written = False
|
||||
if exif_gps:
|
||||
existing = conn.execute(
|
||||
"SELECT gps_lat FROM diary WHERE id=?", (entry_id,)
|
||||
).fetchone()
|
||||
if existing and existing["gps_lat"] is None:
|
||||
conn.execute(
|
||||
"UPDATE diary SET gps_lat=?, gps_lon=? WHERE id=?",
|
||||
(exif_gps[0], exif_gps[1], entry_id)
|
||||
)
|
||||
gps_written = True
|
||||
|
||||
# Wetter + POI nachladen wenn GPS frisch gesetzt
|
||||
if gps_written and exif_gps:
|
||||
try:
|
||||
wd = await weather_mod.get_weather_for_location(exif_gps[0], exif_gps[1])
|
||||
pois = await _fetch_pois_for_coords(exif_gps[0], exif_gps[1], limit=5)
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?",
|
||||
(json.dumps(wd) if wd else None, json.dumps(pois) if pois else None, entry_id)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("EXIF-GPS Wetter/POI Fehler: %s", e)
|
||||
|
||||
resp = {"id": new_id, "url": media_url, "media_type": media_type,
|
||||
"sort_order": max_order + 1, "is_cover": is_cover}
|
||||
if exif_gps:
|
||||
resp["exif_lat"] = exif_gps[0]
|
||||
resp["exif_lon"] = exif_gps[1]
|
||||
return resp
|
||||
|
||||
|
||||
@router.delete("/{dog_id}/diary/{entry_id}/media/{media_id}", status_code=204)
|
||||
|
|
@ -587,3 +773,55 @@ async def set_cover_media(dog_id: int, entry_id: int, media_id: int,
|
|||
conn.execute("UPDATE diary_media SET is_cover=0 WHERE diary_id=?", (entry_id,))
|
||||
conn.execute("UPDATE diary_media SET is_cover=1 WHERE id=?", (media_id,))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Admin: retroaktive Metadaten-Anreicherung bestehender Einträge
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/admin/enrich-metadata", status_code=200)
|
||||
async def admin_enrich_diary_metadata(limit: int = 20, _=Depends(require_admin)):
|
||||
"""Reichert bestehende Tagebucheinträge mit GPS-Koordinaten mit Wetter + POI nach."""
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, gps_lat, gps_lon FROM diary
|
||||
WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL
|
||||
AND (weather_json IS NULL OR poi_json IS NULL)
|
||||
LIMIT ?""",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
|
||||
enriched = 0
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
entry_id, lat, lon = row["id"], row["gps_lat"], row["gps_lon"]
|
||||
weather_json = None
|
||||
poi_json = None
|
||||
try:
|
||||
wd = await weather_mod.get_weather_for_location(lat, lon)
|
||||
weather_json = json.dumps(wd)
|
||||
except Exception as e:
|
||||
logger.warning("enrich-metadata Wetter id=%s: %s", entry_id, e)
|
||||
try:
|
||||
pois = await _fetch_pois_for_coords(lat, lon, limit=5)
|
||||
if pois:
|
||||
poi_json = json.dumps(pois)
|
||||
except Exception as e:
|
||||
logger.warning("enrich-metadata POI id=%s: %s", entry_id, e)
|
||||
|
||||
if weather_json is not None or poi_json is not None:
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?",
|
||||
(weather_json, poi_json, entry_id)
|
||||
)
|
||||
enriched += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
with db() as conn:
|
||||
remaining = conn.execute(
|
||||
"""SELECT COUNT(*) FROM diary
|
||||
WHERE gps_lat IS NOT NULL AND (weather_json IS NULL OR poi_json IS NULL)"""
|
||||
).fetchone()[0]
|
||||
|
||||
return {"enriched": enriched, "skipped": skipped, "remaining": remaining}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue