PYDANTIC max_length (38 Routen, ~400 Field-Constraints): Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.). Pragmatische Limits: - Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000 - Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100 - Hund-Name/Rasse: 80 · Hund-Bio: 2000 Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py, notes.py, auth.py, profile.py. Manuelle len()-Checks in profile, chat, ki entfernt (jetzt durch Field abgedeckt). PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail): - test_security.py: require_owner (Places GET/PATCH/DELETE mit Fremduser → 403), JWT-Blacklist (Logout invalidiert Token), Login-Lockout (5 Fehlversuche → 429 + Retry-After Header) - test_race.py: Invoice-Counter (20 parallele Threads, alle unique), Founder-Number (atomare Vergabe, voll bei 100) - test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text 50k → 422 (verifiziert Pydantic max_length-Sweep) A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast): - #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44 - dog-profile Wrapped-Slider Prev/Next 40→44 - forum-Lightbox Close 40→44 - --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS) - --c-text-muted Dark: #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS) - Branding-Farben unangetastet
266 lines
9.7 KiB
Python
266 lines
9.7 KiB
Python
"""BAN YARO — Notizen Routes"""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from typing import Optional, Any, List
|
|
from database import db
|
|
from auth import get_current_user
|
|
from timeutils import safe_client_time
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Schemas
|
|
# ------------------------------------------------------------------
|
|
class NoteCreate(BaseModel):
|
|
text: str = Field(..., min_length=1, max_length=5000)
|
|
meta_json: Optional[Any] = None
|
|
location_name: Optional[str] = Field(None, max_length=300)
|
|
parent_label: Optional[str] = Field(None, max_length=200)
|
|
client_time: Optional[str] = Field(None, max_length=64)
|
|
|
|
|
|
class NoteUpdate(BaseModel):
|
|
text: Optional[str] = Field(None, max_length=5000)
|
|
meta_json: Optional[Any] = None
|
|
location_name: Optional[str] = Field(None, max_length=300)
|
|
parent_label: Optional[str] = Field(None, max_length=200)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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,
|
|
user_id=user["id"],
|
|
)
|
|
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 = safe_client_time(data.client_time)
|
|
|
|
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
|