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
114 lines
4.3 KiB
Python
114 lines
4.3 KiB
Python
"""BAN YARO — Hilfe / FAQ Routes"""
|
||
|
||
from fastapi import APIRouter, Depends, Query
|
||
from pydantic import BaseModel, Field
|
||
from typing import Optional
|
||
from database import db
|
||
from auth import get_current_user_optional, require_admin
|
||
from cache import ttl_cache
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Öffentliche, aktive FAQ-Liste – statisch, 1h TTL-Cache.
|
||
# Admin-Pfad (?all=1) wird NICHT gecached.
|
||
# Wird bei jedem schreibenden Admin-Endpoint unten invalidiert.
|
||
# ------------------------------------------------------------------
|
||
@ttl_cache(ttl=3600)
|
||
def _load_active_help_articles() -> list[dict]:
|
||
with db() as conn:
|
||
rows = conn.execute(
|
||
"SELECT id, kategorie, frage, antwort, sort_order, aktiv "
|
||
"FROM help_articles "
|
||
"WHERE aktiv = 1 "
|
||
"ORDER BY kategorie, sort_order, id"
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Schemas
|
||
# ------------------------------------------------------------------
|
||
class ArticleCreate(BaseModel):
|
||
kategorie: str = Field(..., max_length=100)
|
||
frage: str = Field(..., min_length=3, max_length=500)
|
||
antwort: str = Field(..., min_length=3, max_length=10000)
|
||
sort_order: int = 0
|
||
aktiv: int = 1
|
||
|
||
|
||
class ArticleUpdate(BaseModel):
|
||
kategorie: Optional[str] = Field(None, max_length=100)
|
||
frage: Optional[str] = Field(None, max_length=500)
|
||
antwort: Optional[str] = Field(None, max_length=10000)
|
||
sort_order: Optional[int] = None
|
||
aktiv: Optional[int] = None
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# GET /api/help — öffentlich (nur aktive); ?all=1 für Admins
|
||
# ------------------------------------------------------------------
|
||
@router.get("")
|
||
def get_help(
|
||
all: int = Query(0),
|
||
user=Depends(get_current_user_optional),
|
||
):
|
||
is_admin = user and user.get("rolle") == "admin"
|
||
show_all = all == 1 and is_admin
|
||
|
||
if show_all:
|
||
with db() as conn:
|
||
rows = conn.execute(
|
||
"SELECT id, kategorie, frage, antwort, sort_order, aktiv "
|
||
"FROM help_articles "
|
||
"ORDER BY kategorie, sort_order, id"
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
# Öffentliche, aktive Artikel kommen aus dem Cache
|
||
return _load_active_help_articles()
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# POST /api/help — Admin: neuen Artikel anlegen
|
||
# ------------------------------------------------------------------
|
||
@router.post("", status_code=201)
|
||
def create_article(body: ArticleCreate, admin=Depends(require_admin)):
|
||
with db() as conn:
|
||
cur = conn.execute(
|
||
"INSERT INTO help_articles (kategorie, frage, antwort, sort_order, aktiv) "
|
||
"VALUES (?, ?, ?, ?, ?)",
|
||
(body.kategorie, body.frage, body.antwort, body.sort_order, body.aktiv),
|
||
)
|
||
_load_active_help_articles.cache_clear()
|
||
return {"ok": True, "id": cur.lastrowid}
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# PATCH /api/help/{article_id} — Admin: Artikel bearbeiten
|
||
# ------------------------------------------------------------------
|
||
@router.patch("/{article_id}")
|
||
def update_article(article_id: int, body: ArticleUpdate, admin=Depends(require_admin)):
|
||
updates = {k: v for k, v in body.model_dump(exclude_none=True).items()}
|
||
if not updates:
|
||
return {"ok": True}
|
||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||
with db() as conn:
|
||
conn.execute(
|
||
f"UPDATE help_articles SET {set_clause} WHERE id=?",
|
||
(*updates.values(), article_id),
|
||
)
|
||
_load_active_help_articles.cache_clear()
|
||
return {"ok": True}
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# DELETE /api/help/{article_id} — Admin: Artikel löschen
|
||
# ------------------------------------------------------------------
|
||
@router.delete("/{article_id}")
|
||
def delete_article(article_id: int, admin=Depends(require_admin)):
|
||
with db() as conn:
|
||
conn.execute("DELETE FROM help_articles WHERE id=?", (article_id,))
|
||
_load_active_help_articles.cache_clear()
|
||
return {"ok": True}
|