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
263
backend/routes/notes.py
Normal file
263
backend/routes/notes.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue