banyaro/backend/routes/notes.py
rene 1ff66a7083 Sicherheit + Tests + A11y, SW by-v1118
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
2026-05-27 13:40:30 +02:00

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