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:
rene 2026-04-25 20:44:46 +02:00
parent 95f91fdc00
commit 553e9e7854
35 changed files with 4558 additions and 370 deletions

View file

@ -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}

View file

@ -155,9 +155,9 @@ async def import_notestation(
(entry_id, dog_id),
)
# Erstes Bild speichern
# Anhänge in diary_media speichern (statt veraltetem media_url-Feld)
attachments = note.get("attachment") or {}
media_url = None
first = True
for att in attachments.values():
md5 = att.get("md5", "")
mime = att.get("type", "image/jpeg")
@ -165,13 +165,11 @@ async def import_notestation(
continue
media_url = _save_image_from_zip(zf, md5, mime)
if media_url:
break
if media_url:
conn.execute(
"UPDATE diary SET media_url=? WHERE id=?",
(media_url, entry_id),
)
conn.execute(
"INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)",
(entry_id, media_url, "image", 0 if first else 1, 1 if first else 0),
)
first = False
imported += 1

263
backend/routes/notes.py Normal file
View file

@ -0,0 +1,263 @@
"""BAN YARO — Notizen Routes"""
import json
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, Any, List
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class NoteCreate(BaseModel):
text: str
meta_json: Optional[Any] = None
location_name: Optional[str] = None
parent_label: Optional[str] = None
class NoteUpdate(BaseModel):
text: Optional[str] = None
meta_json: Optional[Any] = None
location_name: Optional[str] = None
parent_label: Optional[str] = None
# ------------------------------------------------------------------
# Hilfsfunktionen
# ------------------------------------------------------------------
def _serialize(row) -> dict:
d = dict(row)
if d.get("meta_json") and isinstance(d["meta_json"], str):
try:
d["meta_json"] = json.loads(d["meta_json"])
except Exception:
pass
return d
# ------------------------------------------------------------------
# GET /api/notes — Gesamt-Notizblock mit Filtern
# Alias: GET /api/notes/all/0 (Rückwärtskompatibilität)
# WICHTIG: Diese Route muss VOR /{parent_type}/{parent_id} stehen!
# ------------------------------------------------------------------
@router.get("")
async def list_all_notes_filtered(
parent_type: Optional[List[str]] = Query(default=None),
date_from: Optional[str] = Query(default=None),
date_to: Optional[str] = Query(default=None),
q: Optional[str] = Query(default=None),
sort: Optional[str] = Query(default="date_desc"),
user=Depends(get_current_user),
):
"""Alle Notizen des Users mit optionalen Filtern."""
conditions = ["user_id=?"]
params: list = [user["id"]]
if parent_type:
placeholders = ",".join("?" * len(parent_type))
conditions.append(f"parent_type IN ({placeholders})")
params.extend(parent_type)
if date_from:
conditions.append("DATE(created_at) >= ?")
params.append(date_from)
if date_to:
conditions.append("DATE(created_at) <= ?")
params.append(date_to)
if q:
conditions.append("(text LIKE ? OR COALESCE(parent_label,'') LIKE ?)")
like = f"%{q}%"
params.extend([like, like])
where = " AND ".join(conditions)
if sort == "rubrik":
order = "parent_type ASC, created_at DESC"
elif sort == "ort":
order = "CASE WHEN location_name IS NULL OR location_name='' THEN 1 ELSE 0 END ASC, location_name ASC, created_at DESC"
elif sort == "date_asc":
order = "created_at ASC"
else:
order = "created_at DESC"
with db() as conn:
rows = conn.execute(
f"SELECT * FROM notes WHERE {where} ORDER BY {order}",
params
).fetchall()
return [_serialize(r) for r in rows]
@router.get("/all/0")
async def list_all_notes(user=Depends(get_current_user)):
"""Alias für Rückwärtskompatibilität."""
with db() as conn:
rows = conn.execute(
"SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC",
(user["id"],)
).fetchall()
return [_serialize(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/notes/ki-analyse
# WICHTIG: Fixe Route MUSS vor /{parent_type}/{parent_id} stehen!
# ------------------------------------------------------------------
@router.post("/ki-analyse")
async def ki_analyse(user=Depends(get_current_user)):
"""KI analysiert die Notizen des Users und gibt Muster/Vorschläge zurück."""
with db() as conn:
# User-Setting prüfen
setting = conn.execute(
"SELECT notes_ki_enabled FROM users WHERE id=?",
(user["id"],)
).fetchone()
if not setting or not setting["notes_ki_enabled"]:
raise HTTPException(403, "KI-Assistent ist deaktiviert.")
with db() as conn:
rows = conn.execute(
"""SELECT text, parent_type, parent_label, location_name, created_at
FROM notes
WHERE user_id=?
ORDER BY created_at DESC
LIMIT 50""",
(user["id"],)
).fetchall()
note_count = len(rows)
if note_count == 0:
return {"suggestions": "", "note_count": 0}
notes_data = [dict(r) for r in rows]
prompt = (
"Du bist ein freundlicher Assistent für Hundebesitzer. "
"Analysiere diese Notizen und erkenne Muster (Gesundheit, Training, Verhalten, "
"Lieblingsrouten, saisonale Besonderheiten). "
"Gib 2-4 kurze, konkrete Vorschläge auf Deutsch. "
"Keine langen Texte, bullet points. "
f"Daten: {json.dumps(notes_data, ensure_ascii=False)}"
)
try:
import ki as ki_module
suggestions, _ = await ki_module.complete(
prompt,
requires_premium=False,
user_is_premium=False,
)
except Exception as e:
logger.warning("KI-Analyse fehlgeschlagen: %s", e)
suggestions = ""
return {"suggestions": suggestions, "note_count": note_count}
# ------------------------------------------------------------------
# GET /api/notes/{parent_type}/{parent_id}
# parent_id kann ein Integer oder ein String-Schlüssel sein.
# SQLite ist dynamisch getypt — wir übergeben den Wert als Text.
# ------------------------------------------------------------------
@router.get("/{parent_type}/{parent_id}")
async def list_notes(parent_type: str, parent_id: str,
user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute(
"""SELECT * FROM notes
WHERE user_id=? AND parent_type=? AND CAST(parent_id AS TEXT)=?
ORDER BY created_at DESC""",
(user["id"], parent_type, parent_id)
).fetchall()
return [_serialize(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/notes/{parent_type}/{parent_id}
# ------------------------------------------------------------------
@router.post("/{parent_type}/{parent_id}", status_code=201)
async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
user=Depends(get_current_user)):
if not data.text.strip():
raise HTTPException(400, "Notiz darf nicht leer sein.")
meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
with db() as conn:
conn.execute(
"""INSERT INTO notes
(user_id, parent_type, parent_id, text, meta_json,
location_name, parent_label, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(user["id"], parent_type, parent_id, data.text.strip(), meta_str,
data.location_name, data.parent_label, now, now)
)
row = conn.execute(
"SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1",
(user["id"], parent_type, parent_id)
).fetchone()
return _serialize(row)
# ------------------------------------------------------------------
# PATCH /api/notes/{id}
# ------------------------------------------------------------------
@router.patch("/{note_id}")
async def update_note(note_id: int, data: NoteUpdate,
user=Depends(get_current_user)):
with db() as conn:
note = conn.execute(
"SELECT * FROM notes WHERE id=? AND user_id=?", (note_id, user["id"])
).fetchone()
if not note:
raise HTTPException(404, "Notiz nicht gefunden.")
updates = {}
if data.text is not None:
if not data.text.strip():
raise HTTPException(400, "Notiz darf nicht leer sein.")
updates["text"] = data.text.strip()
if data.meta_json is not None:
updates["meta_json"] = json.dumps(data.meta_json)
if data.location_name is not None:
updates["location_name"] = data.location_name
if data.parent_label is not None:
updates["parent_label"] = data.parent_label
if not updates:
return _serialize(note)
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
set_clause = ", ".join(f"{k}=?" for k in updates)
values = list(updates.values()) + [note_id]
conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values)
row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone()
return _serialize(row)
# ------------------------------------------------------------------
# DELETE /api/notes/{id}
# ------------------------------------------------------------------
@router.delete("/{note_id}", status_code=204)
async def delete_note(note_id: int, user=Depends(get_current_user)):
with db() as conn:
note = conn.execute(
"SELECT id FROM notes WHERE id=? AND user_id=?", (note_id, user["id"])
).fetchone()
if not note:
raise HTTPException(404, "Notiz nicht gefunden.")
conn.execute("DELETE FROM notes WHERE id=?", (note_id,))
return None

View file

@ -25,6 +25,7 @@ class ProfileUpdate(BaseModel):
erfahrung: Optional[str] = None
social_link: Optional[str] = None
profil_sichtbarkeit: Optional[str] = None
notes_ki_enabled: Optional[int] = None
def _load_user(user_id: int) -> dict: