From 1ff66a7083da283f7b6f3e1bb412d177bee8b5c6 Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 27 May 2026 13:40:30 +0200 Subject: [PATCH] Sicherheit + Tests + A11y, SW by-v1118 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- VERSION | 2 +- backend/routes/admin.py | 10 +- backend/routes/adoption.py | 6 +- backend/routes/auth.py | 18 +- backend/routes/breeder.py | 16 +- backend/routes/breeder_photos.py | 6 +- backend/routes/chat.py | 6 +- backend/routes/diary.py | 22 +- backend/routes/dogs.py | 38 +-- backend/routes/ernaehrung.py | 34 +-- backend/routes/events.py | 32 +-- backend/routes/expenses.py | 30 +-- backend/routes/forum.py | 30 +-- backend/routes/gassi_zeiten.py | 12 +- backend/routes/health.py | 98 ++++---- backend/routes/help.py | 18 +- backend/routes/invoices.py | 22 +- backend/routes/ki.py | 30 +-- backend/routes/knigge.py | 8 +- backend/routes/laeufi.py | 50 ++-- backend/routes/litters.py | 88 +++---- backend/routes/lost.py | 10 +- backend/routes/movies.py | 38 +-- backend/routes/notes.py | 18 +- backend/routes/osm.py | 18 +- backend/routes/outreach.py | 28 +-- backend/routes/partner.py | 6 +- backend/routes/passport.py | 28 +-- backend/routes/places.py | 22 +- backend/routes/playdate.py | 10 +- backend/routes/poison.py | 8 +- backend/routes/profile.py | 27 +-- backend/routes/push.py | 4 +- backend/routes/ratings.py | 6 +- backend/routes/routen.py | 26 +- backend/routes/services.py | 6 +- backend/routes/sharing.py | 4 +- backend/routes/sitting.py | 14 +- backend/routes/sitting_access.py | 4 +- backend/routes/social.py | 24 +- backend/routes/tieraerzte.py | 48 ++-- backend/routes/training.py | 36 +-- backend/routes/walks.py | 24 +- backend/routes/wiki.py | 32 +-- backend/routes/zucht_hunde.py | 142 +++++------ backend/routes/zucht_ki.py | 4 +- backend/static/css/design-system.css | 4 +- backend/static/css/layout.css | 8 +- backend/static/index.html | 26 +- backend/static/js/app.js | 2 +- backend/static/js/pages/dog-profile.js | 4 +- backend/static/js/pages/forum.js | 2 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_race.py | 233 ++++++++++++++++++ tests/test_security.py | 315 +++++++++++++++++++++++++ tests/test_validation.py | 104 ++++++++ 57 files changed, 1253 insertions(+), 612 deletions(-) create mode 100644 tests/test_race.py create mode 100644 tests/test_security.py create mode 100644 tests/test_validation.py diff --git a/VERSION b/VERSION index 109da83..5568a07 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1117 \ No newline at end of file +1118 \ No newline at end of file diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 713a055..fac5a1c 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -12,7 +12,7 @@ from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from database import db, DB_PATH from auth import get_current_user @@ -92,15 +92,15 @@ _VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "bree class QuarterlyReportBody(BaseModel): year: int quarter: int - email: str + email: str = Field(..., max_length=254) class UserPatch(BaseModel): - rolle: Optional[str] = None # user | moderator | admin + rolle: Optional[str] = Field(None, max_length=30) # user | moderator | admin is_moderator: Optional[int] = None is_banned: Optional[int] = None - ban_reason: Optional[str] = None + ban_reason: Optional[str] = Field(None, max_length=1000) is_social_media: Optional[int] = None - subscription_tier: Optional[str] = None + subscription_tier: Optional[str] = Field(None, max_length=50) class WikiEnrichBody(BaseModel): limit: int = 10 diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py index 96888c7..d353520 100644 --- a/backend/routes/adoption.py +++ b/backend/routes/adoption.py @@ -16,7 +16,7 @@ import uuid import httpx from datetime import datetime, timedelta from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -292,7 +292,7 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)): # ================================================================== class InterestBody(BaseModel): - nachricht: Optional[str] = None + nachricht: Optional[str] = Field(None, max_length=5000) # ------------------------------------------------------------------ @@ -422,7 +422,7 @@ async def community_create( # PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer) # ------------------------------------------------------------------ class _StatusBody(BaseModel): - status: str + status: str = Field(..., max_length=50) @router.patch("/community/{listing_id}") def community_update_status( diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 4a9def8..18b092b 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -10,7 +10,7 @@ from typing import Optional import jwt as _pyjwt from fastapi import APIRouter, HTTPException, Request, Response, Depends from fastapi.responses import RedirectResponse -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, Field from database import db from auth import ( hash_password, verify_password, create_token, @@ -146,13 +146,13 @@ def _send_verification_email(email: str, name: str, token: str): class LoginRequest(BaseModel): email: EmailStr - password: str + password: str = Field(..., min_length=1, max_length=200) class RegisterRequest(BaseModel): email: EmailStr - password: str - name: str - ref_code: Optional[str] = None + password: str = Field(..., min_length=8, max_length=200) + name: str = Field(..., min_length=2, max_length=40) + ref_code: Optional[str] = Field(None, max_length=50) def _gen_referral_code() -> str: @@ -426,8 +426,8 @@ class ForgotPasswordRequest(BaseModel): email: EmailStr class ResetPasswordRequest(BaseModel): - token: str - password: str + token: str = Field(..., min_length=10, max_length=200) + password: str = Field(..., min_length=8, max_length=200) @router.post("/forgot-password") async def forgot_password(data: ForgotPasswordRequest, request: Request): @@ -471,8 +471,8 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request): class UpgradeRequestBody(BaseModel): - tier: str - message: Optional[str] = None + tier: str = Field(..., max_length=50) + message: Optional[str] = Field(None, max_length=2000) @router.post("/upgrade-request") async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)): diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index fe4028b..e53a1d4 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -6,7 +6,7 @@ from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi.responses import FileResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db @@ -237,7 +237,7 @@ async def admin_download_document(user_id: int, doc_id: int, admin=Depends(requi class RejectBody(BaseModel): - grund: str + grund: str = Field(..., min_length=3, max_length=2000) # ------------------------------------------------------------------ @@ -483,13 +483,13 @@ async def admin_create_profile(admin=Depends(require_admin)): # PUT /api/breeder/profile — eigenes Profil bearbeiten # ------------------------------------------------------------------ class BreederProfileUpdate(BaseModel): - zwingername: Optional[str] = None - rasse_text: Optional[str] = None - verein: Optional[str] = None + zwingername: Optional[str] = Field(None, max_length=200) + rasse_text: Optional[str] = Field(None, max_length=200) + verein: Optional[str] = Field(None, max_length=200) vdh_mitglied: Optional[int] = None - stadt: Optional[str] = None - website: Optional[str] = None - beschreibung: Optional[str] = None + stadt: Optional[str] = Field(None, max_length=200) + website: Optional[str] = Field(None, max_length=500) + beschreibung: Optional[str] = Field(None, max_length=10000) @router.put("/breeder/profile") async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)): diff --git a/backend/routes/breeder_photos.py b/backend/routes/breeder_photos.py index 18eb085..802440f 100644 --- a/backend/routes/breeder_photos.py +++ b/backend/routes/breeder_photos.py @@ -1,7 +1,7 @@ """BAN YARO — Züchter-Fotos (Upload, Verwaltung, öffentliche Ansicht)""" from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi.responses import FileResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional import os, logging, asyncio from database import db @@ -30,10 +30,10 @@ def _require_breeder(user=Depends(get_current_user)): # Modelle # ------------------------------------------------------------------ class VisibilityBody(BaseModel): - visibility: str + visibility: str = Field(..., max_length=30) class CaptionBody(BaseModel): - caption: Optional[str] = None + caption: Optional[str] = Field(None, max_length=500) # ------------------------------------------------------------------ diff --git a/backend/routes/chat.py b/backend/routes/chat.py index 0874303..7147315 100644 --- a/backend/routes/chat.py +++ b/backend/routes/chat.py @@ -4,7 +4,7 @@ import os import uuid from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import get_current_user @@ -142,7 +142,7 @@ async def get_messages(conv_id: int, offset: int = 0, limit: int = 50, class SendMsgModel(BaseModel): - text: str + text: str = Field(..., min_length=1, max_length=2000) @router.post("/conversations/{conv_id}/messages", status_code=201) @@ -151,8 +151,6 @@ async def send_message(conv_id: int, data: SendMsgModel, user=Depends(get_curren text = data.text.strip() if not text: raise HTTPException(400, "Nachricht darf nicht leer sein.") - if len(text) > 2000: - raise HTTPException(400, "Nachricht zu lang (max. 2000 Zeichen).") with db() as conn: conv = conn.execute( diff --git a/backend/routes/diary.py b/backend/routes/diary.py index a17bf4d..8d85a84 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -2,7 +2,7 @@ import os, uuid, json, logging, asyncio from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user, require_admin @@ -20,27 +20,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") class DiaryCreate(BaseModel): - datum: Optional[str] = None # ISO date, default heute - client_time: Optional[str] = None # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS) - typ: str = "eintrag" - titel: Optional[str] = None - text: Optional[str] = None + datum: Optional[str] = Field(None, max_length=32) # ISO date, default heute + client_time: Optional[str] = Field(None, max_length=64) # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS) + typ: str = Field("eintrag", max_length=50) + titel: Optional[str] = Field(None, max_length=200) + text: Optional[str] = Field(None, max_length=10000) tags: Optional[list] = None gps_lat: Optional[float] = None gps_lon: Optional[float] = None - location_name: Optional[str] = None + location_name: Optional[str] = Field(None, max_length=300) is_milestone: bool = False dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary - weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS) + weather_json: Optional[str] = Field(None, max_length=5000) # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS) class DiaryUpdate(BaseModel): - titel: Optional[str] = None - text: Optional[str] = None + titel: Optional[str] = Field(None, max_length=200) + text: Optional[str] = Field(None, max_length=10000) tags: Optional[list] = None gps_lat: Optional[float] = None gps_lon: Optional[float] = None - location_name: Optional[str] = None + location_name: Optional[str] = Field(None, max_length=300) is_milestone: Optional[bool] = None dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 8004df3..1c37b99 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -3,7 +3,7 @@ import os import uuid from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user, has_pro_access @@ -29,28 +29,28 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") class DogCreate(BaseModel): - name: str - rasse: Optional[str] = None - geburtstag: Optional[str] = None - geschlecht: Optional[str] = None + name: str = Field(..., min_length=1, max_length=80) + rasse: Optional[str] = Field(None, max_length=80) + geburtstag: Optional[str] = Field(None, max_length=32) + geschlecht: Optional[str] = Field(None, max_length=20) gewicht_kg: Optional[float] = None widerrist_cm: Optional[float] = None - chip_nr: Optional[str] = None - bio: Optional[str] = None - is_public: bool = False + chip_nr: Optional[str] = Field(None, max_length=50) + bio: Optional[str] = Field(None, max_length=2000) + is_public: bool = False class DogUpdate(BaseModel): - name: Optional[str] = None - rasse: Optional[str] = None - rasse_id: Optional[int] = None - geburtstag: Optional[str] = None - geschlecht: Optional[str] = None + name: Optional[str] = Field(None, max_length=80) + rasse: Optional[str] = Field(None, max_length=80) + rasse_id: Optional[int] = None + geburtstag: Optional[str] = Field(None, max_length=32) + geschlecht: Optional[str] = Field(None, max_length=20) gewicht_kg: Optional[float] = None widerrist_cm: Optional[float] = None - chip_nr: Optional[str] = None - bio: Optional[str] = None - is_public: Optional[bool] = None + chip_nr: Optional[str] = Field(None, max_length=50) + bio: Optional[str] = Field(None, max_length=2000) + is_public: Optional[bool] = None @router.get("") @@ -1033,8 +1033,8 @@ async def public_dog_profile(dog_id: int): class FoundReport(BaseModel): - message: Optional[str] = None - kontakt: Optional[str] = None + message: Optional[str] = Field(None, max_length=1000) + kontakt: Optional[str] = Field(None, max_length=300) # Gefunden-Meldung (kein Login nötig) @@ -1319,7 +1319,7 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)): # POST /api/dogs/{id}/gedenken — Hund als verstorben markieren # ------------------------------------------------------------------ class GedenkenData(BaseModel): - verstorben_am: str # YYYY-MM-DD + verstorben_am: str = Field(..., max_length=32) # YYYY-MM-DD @router.post("/{dog_id}/gedenken") async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)): diff --git a/backend/routes/ernaehrung.py b/backend/routes/ernaehrung.py index 2aa4760..d485c80 100644 --- a/backend/routes/ernaehrung.py +++ b/backend/routes/ernaehrung.py @@ -2,7 +2,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -16,18 +16,18 @@ logger = logging.getLogger(__name__) # Schemas # ------------------------------------------------------------------ class FutterProfilUpdate(BaseModel): - futter_typ: Optional[str] = None # trocken|nass|barf|mix - marke: Optional[str] = None + futter_typ: Optional[str] = Field(None, max_length=50) # trocken|nass|barf|mix + marke: Optional[str] = Field(None, max_length=200) kcal_tag: Optional[int] = None portionen: Optional[int] = None - notizen: Optional[str] = None + notizen: Optional[str] = Field(None, max_length=5000) class KiBeratungRequest(BaseModel): - frage: str - dog_name: Optional[str] = None - rasse: Optional[str] = None - alter: Optional[str] = None + frage: str = Field(..., min_length=3, max_length=2000) + dog_name: Optional[str] = Field(None, max_length=80) + rasse: Optional[str] = Field(None, max_length=80) + alter: Optional[str] = Field(None, max_length=50) gewicht: Optional[float] = None aktiv: Optional[bool] = None @@ -183,20 +183,20 @@ _GASTRO_HINWEIS = "Magen-Darm-Symptome wie {label} treten meist innerhalb wenige class FutterEintragCreate(BaseModel): - datum: str - uhrzeit: str - futter_name: str - futter_typ: Optional[str] = "trockenfutter" + datum: str = Field(..., max_length=32) + uhrzeit: str = Field(..., max_length=20) + futter_name: str = Field(..., max_length=200) + futter_typ: Optional[str] = Field("trockenfutter", max_length=50) menge_g: Optional[int] = None - notiz: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=2000) class ReaktionCreate(BaseModel): - datum: str - uhrzeit: str - reaktion_typ: str + datum: str = Field(..., max_length=32) + uhrzeit: str = Field(..., max_length=20) + reaktion_typ: str = Field(..., max_length=100) intensitaet: Optional[int] = 3 - notiz: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=2000) # ------------------------------------------------------------------ diff --git a/backend/routes/events.py b/backend/routes/events.py index 0b6de27..f2ca8a6 100644 --- a/backend/routes/events.py +++ b/backend/routes/events.py @@ -2,7 +2,7 @@ from datetime import date from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -17,29 +17,29 @@ TYPEN = {'ausstellung', 'training', 'treffen', 'markt', 'wettkampf', 'sonstiges' # Schemas # ------------------------------------------------------------------ class RsvpCreate(BaseModel): - status: str = 'going' # 'going' | 'maybe' + status: str = Field('going', max_length=20) # 'going' | 'maybe' class EventCreate(BaseModel): - titel: str - datum: str # YYYY-MM-DD - uhrzeit: Optional[str] = None + titel: str = Field(..., min_length=3, max_length=200) + datum: str = Field(..., max_length=32) # YYYY-MM-DD + uhrzeit: Optional[str] = Field(None, max_length=20) lat: Optional[float] = None lon: Optional[float] = None - ort_name: Optional[str] = None - typ: str = 'sonstiges' - beschreibung: Optional[str] = None - link: Optional[str] = None + ort_name: Optional[str] = Field(None, max_length=300) + typ: str = Field('sonstiges', max_length=50) + beschreibung: Optional[str] = Field(None, max_length=10000) + link: Optional[str] = Field(None, max_length=500) class EventUpdate(BaseModel): - titel: Optional[str] = None - datum: Optional[str] = None - uhrzeit: Optional[str] = None + titel: Optional[str] = Field(None, max_length=200) + datum: Optional[str] = Field(None, max_length=32) + uhrzeit: Optional[str] = Field(None, max_length=20) lat: Optional[float] = None lon: Optional[float] = None - ort_name: Optional[str] = None - typ: Optional[str] = None - beschreibung: Optional[str] = None - link: Optional[str] = None + ort_name: Optional[str] = Field(None, max_length=300) + typ: Optional[str] = Field(None, max_length=50) + beschreibung: Optional[str] = Field(None, max_length=10000) + link: Optional[str] = Field(None, max_length=500) # ------------------------------------------------------------------ diff --git a/backend/routes/expenses.py b/backend/routes/expenses.py index 9c93475..9a34f27 100644 --- a/backend/routes/expenses.py +++ b/backend/routes/expenses.py @@ -4,7 +4,7 @@ import logging from datetime import date, timedelta from dateutil.relativedelta import relativedelta from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -20,35 +20,35 @@ KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonst # ------------------------------------------------------------------ class ExpenseCreate(BaseModel): dog_id: Optional[int] = None - kategorie: str + kategorie: str = Field(..., max_length=50) betrag: float - datum: str - notiz: Optional[str] = None + datum: str = Field(..., max_length=32) + notiz: Optional[str] = Field(None, max_length=1000) class ExpenseUpdate(BaseModel): dog_id: Optional[int] = None - kategorie: Optional[str] = None + kategorie: Optional[str] = Field(None, max_length=50) betrag: Optional[float] = None - datum: Optional[str] = None - notiz: Optional[str] = None + datum: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=1000) class RecurringCreate(BaseModel): dog_id: Optional[int] = None - kategorie: str + kategorie: str = Field(..., max_length=50) betrag: float - haeufigkeit: str # monatlich | quartalsweise | jaehrlich - startdatum: str # ISO date - notiz: Optional[str] = None + haeufigkeit: str = Field(..., max_length=30) # monatlich | quartalsweise | jaehrlich + startdatum: str = Field(..., max_length=32) # ISO date + notiz: Optional[str] = Field(None, max_length=1000) class RecurringUpdate(BaseModel): dog_id: Optional[int] = None - kategorie: Optional[str] = None + kategorie: Optional[str] = Field(None, max_length=50) betrag: Optional[float] = None - haeufigkeit: Optional[str] = None - startdatum: Optional[str] = None - notiz: Optional[str] = None + haeufigkeit: Optional[str] = Field(None, max_length=30) + startdatum: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=1000) aktiv: Optional[bool] = None diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 2834ab0..f8ee5e0 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -2,7 +2,7 @@ import os, uuid, json, logging from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user, get_current_user_optional @@ -27,40 +27,40 @@ KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung', # Schemas # ------------------------------------------------------------------ class ThreadCreate(BaseModel): - kategorie: str = 'allgemein' - titel: str - text: str + kategorie: str = Field('allgemein', max_length=100) + titel: str = Field(..., min_length=3, max_length=200) + text: str = Field(..., min_length=1, max_length=10000) thread_lat: Optional[float] = None thread_lon: Optional[float] = None - thread_ort: Optional[str] = None - client_time: Optional[str] = None + thread_ort: Optional[str] = Field(None, max_length=300) + client_time: Optional[str] = Field(None, max_length=64) class PostCreate(BaseModel): - text: str - client_time: Optional[str] = None + text: str = Field(..., min_length=1, max_length=10000) + client_time: Optional[str] = Field(None, max_length=64) class ThreadPatch(BaseModel): is_pinned: Optional[int] = None is_locked: Optional[int] = None class ThreadUpdate(BaseModel): - titel: Optional[str] = None - text: Optional[str] = None + titel: Optional[str] = Field(None, max_length=200) + text: Optional[str] = Field(None, max_length=10000) thread_lat: Optional[float] = None thread_lon: Optional[float] = None - thread_ort: Optional[str] = None + thread_ort: Optional[str] = Field(None, max_length=300) class PostUpdate(BaseModel): - text: str + text: str = Field(..., min_length=1, max_length=10000) class LikeBody(BaseModel): - target_type: str # 'thread' | 'post' + target_type: str = Field(..., max_length=20) # 'thread' | 'post' target_id: int class ReportBody(BaseModel): - target_type: str + target_type: str = Field(..., max_length=20) target_id: int - grund: str + grund: str = Field(..., min_length=3, max_length=1000) class LocationBody(BaseModel): lat: Optional[float] = None diff --git a/backend/routes/gassi_zeiten.py b/backend/routes/gassi_zeiten.py index 536e1a1..455aa15 100644 --- a/backend/routes/gassi_zeiten.py +++ b/backend/routes/gassi_zeiten.py @@ -3,7 +3,7 @@ import json import logging from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from database import db from auth import get_current_user @@ -15,14 +15,14 @@ router = APIRouter() class GassiZeitCreate(BaseModel): - dog_id: Optional[int] = None - wochentage: List[str] # ["mo", "mi", "fr"] - uhrzeit: str # "17:00" - ort_name: Optional[str] = None + dog_id: Optional[int] = None + wochentage: List[str] # ["mo", "mi", "fr"] + uhrzeit: str = Field(..., max_length=20) # "17:00" + ort_name: Optional[str] = Field(None, max_length=300) lat: Optional[float] = None lon: Optional[float] = None radius_m: int = 500 - notiz: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=2000) class GassiZeitUpdate(BaseModel): diff --git a/backend/routes/health.py b/backend/routes/health.py index 34ec5ec..7b9ed35 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -3,7 +3,7 @@ import os, uuid from datetime import date, datetime from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -22,59 +22,59 @@ TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie # Schemas # ------------------------------------------------------------------ class HealthCreate(BaseModel): - typ: str - bezeichnung: Optional[str] = None - datum: str - naechstes: Optional[str] = None - notiz: Optional[str] = None + typ: str = Field(..., max_length=50) + bezeichnung: Optional[str] = Field(None, max_length=200) + datum: str = Field(..., max_length=32) + naechstes: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=5000) # Gewicht wert: Optional[float] = None - einheit: Optional[str] = "kg" + einheit: Optional[str] = Field("kg", max_length=20) # Impfung - charge_nr: Optional[str] = None - tierarzt_name: Optional[str] = None + charge_nr: Optional[str] = Field(None, max_length=100) + tierarzt_name: Optional[str] = Field(None, max_length=200) # Tierarztbesuch kosten: Optional[float] = None - diagnose: Optional[str] = None + diagnose: Optional[str] = Field(None, max_length=2000) # Medikament - dosierung: Optional[str] = None - haeufigkeit: Optional[str] = None + dosierung: Optional[str] = Field(None, max_length=200) + haeufigkeit: Optional[str] = Field(None, max_length=200) aktiv: Optional[int] = 1 - bis_datum: Optional[str] = None + bis_datum: Optional[str] = Field(None, max_length=32) # Allergie - schweregrad: Optional[str] = None # leicht | mittel | schwer - reaktion: Optional[str] = None + schweregrad: Optional[str] = Field(None, max_length=50) # leicht | mittel | schwer + reaktion: Optional[str] = Field(None, max_length=1000) erinnerung: Optional[int] = 1 intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage # Tierarzt-Verknüpfung tierarzt_id: Optional[int] = None # Züchter - deckdatum: Optional[str] = None - wurftermin: Optional[str] = None + deckdatum: Optional[str] = Field(None, max_length=32) + wurftermin: Optional[str] = Field(None, max_length=32) class HealthUpdate(BaseModel): - bezeichnung: Optional[str] = None - datum: Optional[str] = None - naechstes: Optional[str] = None - notiz: Optional[str] = None + bezeichnung: Optional[str] = Field(None, max_length=200) + datum: Optional[str] = Field(None, max_length=32) + naechstes: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=5000) wert: Optional[float] = None - einheit: Optional[str] = None - charge_nr: Optional[str] = None - tierarzt_name: Optional[str] = None + einheit: Optional[str] = Field(None, max_length=20) + charge_nr: Optional[str] = Field(None, max_length=100) + tierarzt_name: Optional[str] = Field(None, max_length=200) kosten: Optional[float] = None - diagnose: Optional[str] = None - dosierung: Optional[str] = None - haeufigkeit: Optional[str] = None + diagnose: Optional[str] = Field(None, max_length=2000) + dosierung: Optional[str] = Field(None, max_length=200) + haeufigkeit: Optional[str] = Field(None, max_length=200) aktiv: Optional[int] = None - bis_datum: Optional[str] = None - schweregrad: Optional[str] = None - reaktion: Optional[str] = None + bis_datum: Optional[str] = Field(None, max_length=32) + schweregrad: Optional[str] = Field(None, max_length=50) + reaktion: Optional[str] = Field(None, max_length=1000) erinnerung: Optional[int] = None intervall_tage: Optional[int] = None tierarzt_id: Optional[int] = None - deckdatum: Optional[str] = None - wurftermin: Optional[str] = None + deckdatum: Optional[str] = Field(None, max_length=32) + wurftermin: Optional[str] = Field(None, max_length=32) # ------------------------------------------------------------------ @@ -390,7 +390,7 @@ async def list_gewicht(dog_id: int, user=Depends(get_current_user)): # POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung # ------------------------------------------------------------------ class SymptomCheckRequest(BaseModel): - symptoms: str + symptoms: str = Field(..., min_length=3, max_length=5000) @router.post("/{dog_id}/health/symptom-check") @@ -576,20 +576,20 @@ async def terminvorschlaege(dog_id: int, user=Depends(get_current_user)): # ================================================================== class InsuranceCreate(BaseModel): - anbieter: str - police_nr: Optional[str] = None + anbieter: str = Field(..., min_length=1, max_length=200) + police_nr: Optional[str] = Field(None, max_length=100) jahresbeitrag: Optional[float] = None - kontakt: Optional[str] = None - ablaufdatum: Optional[str] = None - notizen: Optional[str] = None + kontakt: Optional[str] = Field(None, max_length=500) + ablaufdatum: Optional[str] = Field(None, max_length=32) + notizen: Optional[str] = Field(None, max_length=5000) class InsuranceUpdate(BaseModel): - anbieter: Optional[str] = None - police_nr: Optional[str] = None + anbieter: Optional[str] = Field(None, max_length=200) + police_nr: Optional[str] = Field(None, max_length=100) jahresbeitrag: Optional[float] = None - kontakt: Optional[str] = None - ablaufdatum: Optional[str] = None - notizen: Optional[str] = None + kontakt: Optional[str] = Field(None, max_length=500) + ablaufdatum: Optional[str] = Field(None, max_length=32) + notizen: Optional[str] = Field(None, max_length=5000) @router.get("/{dog_id}/insurance") @@ -674,12 +674,12 @@ TRIGGER_LABELS = { class BehaviorCreate(BaseModel): - datum: str - uhrzeit: Optional[str] = None - kategorie: str - intensitaet: int = 3 - trigger: Optional[str] = None - notiz: Optional[str] = None + datum: str = Field(..., max_length=32) + uhrzeit: Optional[str] = Field(None, max_length=20) + kategorie: str = Field(..., max_length=50) + intensitaet: int = 3 + trigger: Optional[str] = Field(None, max_length=200) + notiz: Optional[str] = Field(None, max_length=5000) @router.get("/{dog_id}/behavior") diff --git a/backend/routes/help.py b/backend/routes/help.py index 6551e4d..05803f0 100644 --- a/backend/routes/help.py +++ b/backend/routes/help.py @@ -1,7 +1,7 @@ """BAN YARO — Hilfe / FAQ Routes""" from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user_optional, require_admin @@ -31,17 +31,17 @@ def _load_active_help_articles() -> list[dict]: # Schemas # ------------------------------------------------------------------ class ArticleCreate(BaseModel): - kategorie: str - frage: str - antwort: str - sort_order: int = 0 - aktiv: int = 1 + 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] = None - frage: Optional[str] = None - antwort: Optional[str] = None + 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 diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 1f27759..87d104d 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import require_admin import mailer @@ -19,30 +19,30 @@ logger = logging.getLogger(__name__) # Schemas # ------------------------------------------------------------------ class InvoiceItem(BaseModel): - description: str - quantity: float = 1.0 + description: str = Field(..., max_length=500) + quantity: float = 1.0 unit_price: float class InvoiceCreate(BaseModel): user_id: Optional[int] = None - recipient_name: str - recipient_email: str - recipient_address: Optional[str] = None + recipient_name: str = Field(..., max_length=200) + recipient_email: str = Field(..., max_length=254) + recipient_address: Optional[str] = Field(None, max_length=500) items: List[InvoiceItem] discount_pct: Optional[float] = 0.0 - service_period: Optional[str] = None - notes: Optional[str] = None + service_period: Optional[str] = Field(None, max_length=200) + notes: Optional[str] = Field(None, max_length=5000) class PayBody(BaseModel): - paid_at: str + paid_at: str = Field(..., max_length=32) paid_amount: float - notes: Optional[str] = None + notes: Optional[str] = Field(None, max_length=2000) class CancelBody(BaseModel): - reason: str + reason: str = Field(..., min_length=3, max_length=1000) # ------------------------------------------------------------------ diff --git a/backend/routes/ki.py b/backend/routes/ki.py index 2b16cbe..5169634 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -1,6 +1,6 @@ """BAN YARO — KI Routes""" from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional import ki as ki_module from auth import get_current_user @@ -11,9 +11,9 @@ router = APIRouter() class TrainingRequest(BaseModel): - problem: str - rasse: Optional[str] = None - alter: Optional[str] = None + problem: str = Field(..., min_length=10, max_length=1000) + rasse: Optional[str] = Field(None, max_length=80) + alter: Optional[str] = Field(None, max_length=50) @router.post("/training") @@ -23,8 +23,6 @@ async def ki_training(req: TrainingRequest, request: Request, rl_check(request, max_requests=10, window_seconds=3600, key="ki_training") if not req.problem or len(req.problem.strip()) < 10: raise HTTPException(400, "Bitte beschreibe das Problem genauer.") - if len(req.problem) > 1000: - raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).") rasse = req.rasse or "unbekannt" alter = req.alter or "unbekannt" @@ -69,10 +67,10 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon.""" # POST /ki/tierarzt — KI-Tierarztfragen # ------------------------------------------------------------------ class TierarztRequest(BaseModel): - symptom: str + symptom: str = Field(..., min_length=5, max_length=1000) dog_id: Optional[int] = None - dog_name: Optional[str] = None - rasse: Optional[str] = None + dog_name: Optional[str] = Field(None, max_length=80) + rasse: Optional[str] = Field(None, max_length=80) @router.post("/tierarzt") @@ -81,8 +79,6 @@ async def ki_tierarzt(req: TierarztRequest, request: Request, """KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung.""" if not req.symptom or len(req.symptom.strip()) < 5: raise HTTPException(400, "Bitte beschreibe das Symptom genauer.") - if len(req.symptom) > 1000: - raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).") # Rate-Limit: max 5 Anfragen pro User pro Tag with db() as conn: @@ -173,10 +169,10 @@ def _log_rasse_request(user_id: int): class BirthdayRequest(BaseModel): dog_id: int - name: str - rasse: Optional[str] = None + name: str = Field(..., max_length=80) + rasse: Optional[str] = Field(None, max_length=80) alter: Optional[int] = None - mode: str = "tomorrow" # "tomorrow" | "today" + mode: str = Field("tomorrow", max_length=20) # "tomorrow" | "today" @router.post("/geburtstag") async def ki_geburtstag(req: BirthdayRequest, request: Request, @@ -368,12 +364,12 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array.""" # ------------------------------------------------------------------ class AbschiedRequest(BaseModel): dog_id: int - name: str - rasse: Optional[str] = None + name: str = Field(..., max_length=80) + rasse: Optional[str] = Field(None, max_length=80) km_total: Optional[float] = None diary_count: Optional[int] = None gemeinsam_tage: Optional[int] = None - last_entry_titel: Optional[str] = None + last_entry_titel: Optional[str] = Field(None, max_length=200) @router.post("/abschied") async def ki_abschied(req: AbschiedRequest, request: Request, diff --git a/backend/routes/knigge.py b/backend/routes/knigge.py index 779d023..d824f56 100644 --- a/backend/routes/knigge.py +++ b/backend/routes/knigge.py @@ -1,7 +1,7 @@ """BAN YARO — Hunde-Knigge Routes""" from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user, get_current_user_optional @@ -13,12 +13,12 @@ router = APIRouter() # Schemas # ------------------------------------------------------------------ class VoteRequest(BaseModel): - szenario_id: str - answer: str + szenario_id: str = Field(..., max_length=100) + answer: str = Field(..., max_length=100) class KiRatRequest(BaseModel): - situation: str + situation: str = Field(..., min_length=3, max_length=2000) # ------------------------------------------------------------------ diff --git a/backend/routes/laeufi.py b/backend/routes/laeufi.py index 22189bd..5ca7e22 100644 --- a/backend/routes/laeufi.py +++ b/backend/routes/laeufi.py @@ -1,7 +1,7 @@ """BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)""" from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from datetime import date, timedelta @@ -78,47 +78,47 @@ def _calc_meilensteine(deckdatum_str: str) -> list: # Schemas # ------------------------------------------------------------------ class LaeufiCreate(BaseModel): - beginn: str - ende: Optional[str] = None - notiz: Optional[str] = None + beginn: str = Field(..., max_length=32) + ende: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=2000) class LaeufiUpdate(BaseModel): - beginn: Optional[str] = None - ende: Optional[str] = None - notiz: Optional[str] = None + beginn: Optional[str] = Field(None, max_length=32) + ende: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=2000) class ProgestCreate(BaseModel): - datum: str + datum: str = Field(..., max_length=32) wert: Optional[float] = None - einheit: str = "ng/ml" - labor: Optional[str] = None - notiz: Optional[str] = None + einheit: str = Field("ng/ml", max_length=20) + labor: Optional[str] = Field(None, max_length=200) + notiz: Optional[str] = Field(None, max_length=2000) class ProgestUpdate(BaseModel): - datum: Optional[str] = None + datum: Optional[str] = Field(None, max_length=32) wert: Optional[float] = None - einheit: Optional[str] = None - labor: Optional[str] = None - notiz: Optional[str] = None + einheit: Optional[str] = Field(None, max_length=20) + labor: Optional[str] = Field(None, max_length=200) + notiz: Optional[str] = Field(None, max_length=2000) class DeckCreate(BaseModel): - deckdatum: str + deckdatum: str = Field(..., max_length=32) laeufi_id: Optional[int] = None ruede_id: Optional[int] = None - ruede_name: Optional[str] = None - deckart: str = "natuerlich" + ruede_name: Optional[str] = Field(None, max_length=200) + deckart: str = Field("natuerlich", max_length=50) traechtig: int = 0 - ultraschall_datum: Optional[str] = None - notiz: Optional[str] = None + ultraschall_datum: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=2000) class DeckUpdate(BaseModel): - deckdatum: Optional[str] = None + deckdatum: Optional[str] = Field(None, max_length=32) ruede_id: Optional[int] = None - ruede_name: Optional[str] = None - deckart: Optional[str] = None + ruede_name: Optional[str] = Field(None, max_length=200) + deckart: Optional[str] = Field(None, max_length=50) traechtig: Optional[int] = None - ultraschall_datum: Optional[str] = None - notiz: Optional[str] = None + ultraschall_datum: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=2000) # ------------------------------------------------------------------ diff --git a/backend/routes/litters.py b/backend/routes/litters.py index 09250d8..3294641 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -4,7 +4,7 @@ import logging from datetime import date from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import HTMLResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db @@ -27,68 +27,68 @@ def _require_breeder(user=Depends(get_current_user)): # Schemas # ------------------------------------------------------------------ class LitterCreate(BaseModel): - wurf_rang: Optional[str] = None # A, B, C … - wurf_name: Optional[str] = None # z.B. "Vatertags-Wurf" - vater_name: Optional[str] = None - mutter_name: Optional[str] = None + wurf_rang: Optional[str] = Field(None, max_length=10) # A, B, C … + wurf_name: Optional[str] = Field(None, max_length=200) # z.B. "Vatertags-Wurf" + vater_name: Optional[str] = Field(None, max_length=200) + mutter_name: Optional[str] = Field(None, max_length=200) vater_id: Optional[int] = None mutter_id: Optional[int] = None - geburt_datum: Optional[str] = None - erwartetes_datum: Optional[str] = None + geburt_datum: Optional[str] = Field(None, max_length=32) + erwartetes_datum: Optional[str] = Field(None, max_length=32) welpen_gesamt: Optional[int] = None welpen_verfuegbar: Optional[int] = None - beschreibung: Optional[str] = None - gesundheitstests: Optional[str] = None - preis_spanne: Optional[str] = None - status: str = "geplant" + beschreibung: Optional[str] = Field(None, max_length=10000) + gesundheitstests: Optional[str] = Field(None, max_length=5000) + preis_spanne: Optional[str] = Field(None, max_length=100) + status: str = Field("geplant", max_length=30) sichtbar: int = 0 - sichtbar_bis: Optional[str] = None + sichtbar_bis: Optional[str] = Field(None, max_length=32) class LitterUpdate(BaseModel): - wurf_rang: Optional[str] = None - wurf_name: Optional[str] = None - vater_name: Optional[str] = None - mutter_name: Optional[str] = None + wurf_rang: Optional[str] = Field(None, max_length=10) + wurf_name: Optional[str] = Field(None, max_length=200) + vater_name: Optional[str] = Field(None, max_length=200) + mutter_name: Optional[str] = Field(None, max_length=200) vater_id: Optional[int] = None mutter_id: Optional[int] = None - geburt_datum: Optional[str] = None - erwartetes_datum: Optional[str] = None + geburt_datum: Optional[str] = Field(None, max_length=32) + erwartetes_datum: Optional[str] = Field(None, max_length=32) welpen_gesamt: Optional[int] = None welpen_verfuegbar: Optional[int] = None - beschreibung: Optional[str] = None - gesundheitstests: Optional[str] = None - preis_spanne: Optional[str] = None - status: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=10000) + gesundheitstests: Optional[str] = Field(None, max_length=5000) + preis_spanne: Optional[str] = Field(None, max_length=100) + status: Optional[str] = Field(None, max_length=30) sichtbar: Optional[int] = None - sichtbar_bis: Optional[str] = None + sichtbar_bis: Optional[str] = Field(None, max_length=32) class PuppyCreate(BaseModel): - name: Optional[str] = None - geschlecht: Optional[str] = None # maennlich|weiblich - farbe: Optional[str] = None - chip_nr: Optional[str] = None + name: Optional[str] = Field(None, max_length=80) + geschlecht: Optional[str] = Field(None, max_length=20) # maennlich|weiblich + farbe: Optional[str] = Field(None, max_length=100) + chip_nr: Optional[str] = Field(None, max_length=50) geburtsgewicht: Optional[float] = None # Gramm - status: str = "verfuegbar" # verfuegbar|reserviert|abgegeben + status: str = Field("verfuegbar", max_length=30) # verfuegbar|reserviert|abgegeben status_sichtbar: int = 1 - notiz: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=2000) class PuppyUpdate(BaseModel): - name: Optional[str] = None - geschlecht: Optional[str] = None - farbe: Optional[str] = None - chip_nr: Optional[str] = None + name: Optional[str] = Field(None, max_length=80) + geschlecht: Optional[str] = Field(None, max_length=20) + farbe: Optional[str] = Field(None, max_length=100) + chip_nr: Optional[str] = Field(None, max_length=50) geburtsgewicht: Optional[float] = None - status: Optional[str] = None + status: Optional[str] = Field(None, max_length=30) status_sichtbar: Optional[int] = None - notiz: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=2000) class WeightEntry(BaseModel): gewicht_g: float - gemessen_am: str # YYYY-MM-DD + gemessen_am: str = Field(..., max_length=32) # YYYY-MM-DD # ------------------------------------------------------------------ @@ -663,15 +663,15 @@ async def generate_contract( # Warteliste # ------------------------------------------------------------------ class WaitlistEntry(BaseModel): - name: str - email: Optional[str] = None - telefon: Optional[str] = None - nachricht: Optional[str] = None - wunsch_geschlecht: str = "egal" - wunsch_farbe: Optional[str] = None + name: str = Field(..., min_length=1, max_length=200) + email: Optional[str] = Field(None, max_length=254) + telefon: Optional[str] = Field(None, max_length=30) + nachricht: Optional[str] = Field(None, max_length=5000) + wunsch_geschlecht: str = Field("egal", max_length=20) + wunsch_farbe: Optional[str] = Field(None, max_length=100) prioritaet: int = 0 - status: str = "anfrage" - notiz: Optional[str] = None + status: str = Field("anfrage", max_length=30) + notiz: Optional[str] = Field(None, max_length=2000) class WaitlistUpdate(BaseModel): diff --git a/backend/routes/lost.py b/backend/routes/lost.py index 1522944..065145f 100644 --- a/backend/routes/lost.py +++ b/backend/routes/lost.py @@ -3,7 +3,7 @@ import os, uuid from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -20,13 +20,13 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") # Schemas # ------------------------------------------------------------------ class LostDogCreate(BaseModel): - name: str - rasse: Optional[str] = None - beschreibung: str + name: str = Field(..., min_length=1, max_length=80) + rasse: Optional[str] = Field(None, max_length=80) + beschreibung: str = Field(..., min_length=3, max_length=5000) lat: float lon: float dog_id: Optional[int] = None - client_time: Optional[str] = None + client_time: Optional[str] = Field(None, max_length=64) # ------------------------------------------------------------------ diff --git a/backend/routes/movies.py b/backend/routes/movies.py index da6c682..79b3d95 100644 --- a/backend/routes/movies.py +++ b/backend/routes/movies.py @@ -1,7 +1,7 @@ """BAN YARO — Hunde-Filme Routes""" from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from datetime import datetime from database import db @@ -207,31 +207,31 @@ class HundDesMonatsVoteRequest(BaseModel): dog_id: int class MovieCreate(BaseModel): - id: str - titel: str - originaltitel: Optional[str] = None + id: str = Field(..., max_length=100) + titel: str = Field(..., min_length=1, max_length=200) + originaltitel: Optional[str] = Field(None, max_length=200) jahr: Optional[int] = None - genre: Optional[str] = None - typ: str = "film" - hund_rasse: Optional[str] = None - stirbt_der_hund: bool = False - beschreibung: Optional[str] = None - bild_emoji: str = "🐾" + genre: Optional[str] = Field(None, max_length=100) + typ: str = Field("film", max_length=30) + hund_rasse: Optional[str] = Field(None, max_length=200) + stirbt_der_hund: bool = False + beschreibung: Optional[str] = Field(None, max_length=5000) + bild_emoji: str = Field("🐾", max_length=10) imdb_rating: Optional[float] = None - streaming: Optional[str] = None + streaming: Optional[str] = Field(None, max_length=500) class MovieUpdate(BaseModel): - titel: Optional[str] = None - originaltitel: Optional[str] = None + titel: Optional[str] = Field(None, max_length=200) + originaltitel: Optional[str] = Field(None, max_length=200) jahr: Optional[int] = None - genre: Optional[str] = None - typ: Optional[str] = None - hund_rasse: Optional[str] = None + genre: Optional[str] = Field(None, max_length=100) + typ: Optional[str] = Field(None, max_length=30) + hund_rasse: Optional[str] = Field(None, max_length=200) stirbt_der_hund: Optional[bool] = None - beschreibung: Optional[str] = None - bild_emoji: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=5000) + bild_emoji: Optional[str] = Field(None, max_length=10) imdb_rating: Optional[float] = None - streaming: Optional[str] = None + streaming: Optional[str] = Field(None, max_length=500) # ------------------------------------------------------------------ diff --git a/backend/routes/notes.py b/backend/routes/notes.py index 6901f62..5a70c2b 100644 --- a/backend/routes/notes.py +++ b/backend/routes/notes.py @@ -4,7 +4,7 @@ import json import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, Any, List from database import db from auth import get_current_user @@ -18,18 +18,18 @@ 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 - client_time: Optional[str] = None + 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] = None + text: Optional[str] = Field(None, max_length=5000) meta_json: Optional[Any] = None - location_name: Optional[str] = None - parent_label: Optional[str] = None + location_name: Optional[str] = Field(None, max_length=300) + parent_label: Optional[str] = Field(None, max_length=200) # ------------------------------------------------------------------ diff --git a/backend/routes/osm.py b/backend/routes/osm.py index 4d1f36f..5fc22b9 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -9,7 +9,7 @@ import httpx import logging from typing import Optional from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import get_current_user, get_current_user_optional as get_optional_user @@ -273,11 +273,11 @@ async def get_pois( # POST /user-poi — Community-Marker setzen # ------------------------------------------------------------------ class UserPoiIn(BaseModel): - type: str + type: str = Field(..., max_length=200) lat: float lon: float - name: Optional[str] = None - notiz: Optional[str] = None + name: Optional[str] = Field(None, max_length=300) + notiz: Optional[str] = Field(None, max_length=2000) ALLOWED_TYPES = { 'waste_basket', 'drinking_water', 'dog_park', @@ -331,8 +331,8 @@ async def delete_user_poi(poi_id: int, user = Depends(get_current_user)): # POST /report — Marker als ungültig melden # ------------------------------------------------------------------ class ReportIn(BaseModel): - type: str - grund: str + type: str = Field(..., max_length=100) + grund: str = Field(..., max_length=200) osm_id: Optional[int] = None user_poi_id: Optional[int] = None @@ -388,9 +388,9 @@ async def analyze_region( # POST /pois/{osm_id}/edit — Nutzer schlägt Korrektur vor # ------------------------------------------------------------------ class PoiEditCreate(BaseModel): - poi_name: str - field: str = 'opening_hours' - new_value: str + poi_name: str = Field(..., max_length=300) + field: str = Field('opening_hours', max_length=50) + new_value: str = Field(..., max_length=1000) @router.post('/pois/{osm_id}/edit', status_code=201) diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index d738998..22139e9 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -13,7 +13,7 @@ from typing import List, Optional import logging from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from auth import require_admin from database import db @@ -135,26 +135,26 @@ def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: # ------------------------------------------------------------------ class TemplateIn(BaseModel): - key: str - label: str - subject: str - body: str - from_account: str = "partner" + key: str = Field(..., max_length=100) + label: str = Field(..., max_length=200) + subject: str = Field(..., max_length=500) + body: str = Field(..., max_length=50000) + from_account: str = Field("partner", max_length=50) class TemplateUpdate(BaseModel): - label: str - subject: str - body: str - from_account: str = "partner" + label: str = Field(..., max_length=200) + subject: str = Field(..., max_length=500) + body: str = Field(..., max_length=50000) + from_account: str = Field("partner", max_length=50) class SendRequest(BaseModel): to: List[str] - subject: str - body: str - from_account: str = "partner" - template_id: Optional[int] = None + subject: str = Field(..., max_length=500) + body: str = Field(..., max_length=50000) + from_account: str = Field("partner", max_length=50) + template_id: Optional[int] = None # ------------------------------------------------------------------ diff --git a/backend/routes/partner.py b/backend/routes/partner.py index b2d21f0..16caafb 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -2,7 +2,7 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Depends -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import require_admin, get_current_user @@ -10,8 +10,8 @@ router = APIRouter() class PartnerCodeCreate(BaseModel): - code: str - label: str + code: str = Field(..., min_length=1, max_length=50) + label: str = Field(..., min_length=1, max_length=200) grants_founder: int = 1 max_uses: Optional[int] = None diff --git a/backend/routes/passport.py b/backend/routes/passport.py index 884e8d3..733b367 100644 --- a/backend/routes/passport.py +++ b/backend/routes/passport.py @@ -5,7 +5,7 @@ import secrets from datetime import date, datetime, timedelta from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -17,25 +17,25 @@ router = APIRouter() # Schemas # ------------------------------------------------------------------ class PassportMeta(BaseModel): - blutgruppe: Optional[str] = None - allergien: Optional[str] = None - besonderheiten: Optional[str] = None + blutgruppe: Optional[str] = Field(None, max_length=50) + allergien: Optional[str] = Field(None, max_length=2000) + besonderheiten: Optional[str] = Field(None, max_length=2000) class VaccinationCreate(BaseModel): - krankheit: str - datum: str - naechste: Optional[str] = None - tierarzt: Optional[str] = None - charge_nr: Optional[str] = None + krankheit: str = Field(..., max_length=200) + datum: str = Field(..., max_length=32) + naechste: Optional[str] = Field(None, max_length=32) + tierarzt: Optional[str] = Field(None, max_length=200) + charge_nr: Optional[str] = Field(None, max_length=100) class MedicationCreate(BaseModel): - name: str - dosierung: Optional[str] = None - von: Optional[str] = None - bis: Optional[str] = None - notiz: Optional[str] = None + name: str = Field(..., max_length=200) + dosierung: Optional[str] = Field(None, max_length=200) + von: Optional[str] = Field(None, max_length=32) + bis: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=2000) # ------------------------------------------------------------------ diff --git a/backend/routes/places.py b/backend/routes/places.py index 3dfc0a8..12570c0 100644 --- a/backend/routes/places.py +++ b/backend/routes/places.py @@ -1,7 +1,7 @@ """BAN YARO — Hundefreundliche Orte""" from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user, require_owner @@ -16,25 +16,25 @@ TYPEN = {'restaurant', 'shop', 'freilauf', 'kotbeutel', 'tierarzt', 'hundesalon' # Schemas # ------------------------------------------------------------------ class PlaceCreate(BaseModel): - name: str - typ: str + name: str = Field(..., min_length=1, max_length=200) + typ: str = Field(..., max_length=50) lat: float lon: float - adresse: Optional[str] = None - website: Optional[str] = None - telefon: Optional[str] = None + adresse: Optional[str] = Field(None, max_length=300) + website: Optional[str] = Field(None, max_length=500) + telefon: Optional[str] = Field(None, max_length=30) hund_rein: Optional[bool] = None leine_pflicht: Optional[bool] = None wasser_fuer_hunde: Optional[bool] = None class PlaceUpdate(BaseModel): - name: Optional[str] = None - typ: Optional[str] = None + name: Optional[str] = Field(None, max_length=200) + typ: Optional[str] = Field(None, max_length=50) lat: Optional[float]= None lon: Optional[float]= None - adresse: Optional[str] = None - website: Optional[str] = None - telefon: Optional[str] = None + adresse: Optional[str] = Field(None, max_length=300) + website: Optional[str] = Field(None, max_length=500) + telefon: Optional[str] = Field(None, max_length=30) hund_rein: Optional[bool] = None leine_pflicht: Optional[bool] = None wasser_fuer_hunde: Optional[bool] = None diff --git a/backend/routes/playdate.py b/backend/routes/playdate.py index 60f1d2a..2f7ebdd 100644 --- a/backend/routes/playdate.py +++ b/backend/routes/playdate.py @@ -2,7 +2,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -40,18 +40,18 @@ class ListingUpsert(BaseModel): dog_id: int lat: float lon: float - ort_name: Optional[str] = None + ort_name: Optional[str] = Field(None, max_length=300) radius_km: int = 10 - beschreibung: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=2000) class RequestCreate(BaseModel): to_dog_id: int - nachricht: Optional[str] = None + nachricht: Optional[str] = Field(None, max_length=2000) class RequestPatch(BaseModel): - status: str # accepted | declined + status: str = Field(..., max_length=30) # accepted | declined # ------------------------------------------------------------------ diff --git a/backend/routes/poison.py b/backend/routes/poison.py index 15fe392..97d0ed2 100644 --- a/backend/routes/poison.py +++ b/backend/routes/poison.py @@ -3,7 +3,7 @@ import os, uuid from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -22,12 +22,12 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") class PoisonCreate(BaseModel): lat: float lon: float - beschreibung: Optional[str] = None - typ: str = "unbekannt" + beschreibung: Optional[str] = Field(None, max_length=2000) + typ: str = Field("unbekannt", max_length=50) class PoisonResolve(BaseModel): - grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes + grund: str = Field("beseitigt", max_length=50) # beseitigt | fehlerhaft | anderes # ------------------------------------------------------------------ diff --git a/backend/routes/profile.py b/backend/routes/profile.py index baa196c..2fe0e1d 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -7,7 +7,7 @@ import uuid from typing import Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from auth import get_current_user from database import db @@ -20,17 +20,17 @@ VALID_SICHTBARKEIT = {"public", "friends", "private"} class ProfileUpdate(BaseModel): - real_name: Optional[str] = None - bio: Optional[str] = None - wohnort: Optional[str] = None - erfahrung: Optional[str] = None - social_link: Optional[str] = None - profil_sichtbarkeit: Optional[str] = None + real_name: Optional[str] = Field(None, max_length=100) + bio: Optional[str] = Field(None, max_length=300) + wohnort: Optional[str] = Field(None, max_length=60) + erfahrung: Optional[str] = Field(None, max_length=30) + social_link: Optional[str] = Field(None, max_length=120) + profil_sichtbarkeit: Optional[str] = Field(None, max_length=30) notes_ki_enabled: Optional[int] = None gassi_stunde_push: Optional[int] = None - preferred_theme: Optional[str] = None - billing_address: Optional[str] = None - geburtstag: Optional[str] = None + preferred_theme: Optional[str] = Field(None, max_length=20) + billing_address: Optional[str] = Field(None, max_length=500) + geburtstag: Optional[str] = Field(None, max_length=10) def _load_user(user_id: int) -> dict: @@ -61,12 +61,7 @@ async def update_profile(data: ProfileUpdate, user=Depends(get_current_user)): raise HTTPException(400, f"profil_sichtbarkeit muss eines von {sorted(VALID_SICHTBARKEIT)} sein.") if "preferred_theme" in fields and fields["preferred_theme"] not in ("system", "light", "dark"): raise HTTPException(400, "preferred_theme muss 'system', 'light' oder 'dark' sein.") - if "bio" in fields and len(fields["bio"]) > 300: - raise HTTPException(400, "bio darf maximal 300 Zeichen lang sein.") - if "wohnort" in fields and len(fields["wohnort"]) > 60: - raise HTTPException(400, "wohnort darf maximal 60 Zeichen lang sein.") - if "social_link" in fields and len(fields["social_link"]) > 120: - raise HTTPException(400, "social_link darf maximal 120 Zeichen lang sein.") + # Längen-Begrenzungen sind jetzt via Field max_length im Schema abgedeckt. if "geburtstag" in fields and fields["geburtstag"]: if not re.fullmatch(r"\d{2}\.\d{2}", fields["geburtstag"]): raise HTTPException(400, "geburtstag muss im Format TT.MM sein (z.B. 16.05).") diff --git a/backend/routes/push.py b/backend/routes/push.py index 3ee73d9..19dbb32 100644 --- a/backend/routes/push.py +++ b/backend/routes/push.py @@ -4,7 +4,7 @@ import os import json import logging from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from pywebpush import webpush, WebPushException @@ -33,7 +33,7 @@ async def get_vapid_key(): # POST /api/push/subscribe — Subscription speichern # ------------------------------------------------------------------ class PushSubscription(BaseModel): - endpoint: str + endpoint: str = Field(..., max_length=2000) keys: dict # { p256dh, auth } expirationTime: Optional[int] = None diff --git a/backend/routes/ratings.py b/backend/routes/ratings.py index dba63f5..cf8a733 100644 --- a/backend/routes/ratings.py +++ b/backend/routes/ratings.py @@ -1,7 +1,7 @@ """BAN YARO — Bewertungssystem (Ratings)""" from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -23,10 +23,10 @@ TABLE_MAP = { # Schemas # ------------------------------------------------------------------ class RatingCreate(BaseModel): - target_type: str + target_type: str = Field(..., max_length=50) target_id: int stars: int - kommentar: Optional[str] = None + kommentar: Optional[str] = Field(None, max_length=5000) # ------------------------------------------------------------------ diff --git a/backend/routes/routen.py b/backend/routes/routen.py index 57c345d..0e81704 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -5,7 +5,7 @@ import json, os, uuid import httpx import polyline as _polyline from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from database import db from auth import get_current_user, get_current_user_optional @@ -37,29 +37,29 @@ class GPSPoint(BaseModel): alt: Optional[float] = None class RouteCreate(BaseModel): - name: str - beschreibung: Optional[str] = None + name: str = Field(..., min_length=1, max_length=200) + beschreibung: Optional[str] = Field(None, max_length=5000) gps_track: List[GPSPoint] distanz_km: Optional[float] = None dauer_min: Optional[int] = None - schwierigkeit: Optional[str] = "leicht" # leicht | mittel | anspruchsvoll - untergrund: Optional[str] = None # wald | asphalt | wiese | mix + schwierigkeit: Optional[str] = Field("leicht", max_length=30) # leicht | mittel | anspruchsvoll + untergrund: Optional[str] = Field(None, max_length=50) # wald | asphalt | wiese | mix schatten: Optional[bool] = None leine_empfohlen: Optional[bool] = None is_public: Optional[bool] = False - hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium - client_time: Optional[str] = None + hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) # eingeschränkt | gut | sehr_gut | premium + client_time: Optional[str] = Field(None, max_length=64) dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind class RouteUpdate(BaseModel): - name: Optional[str] = None - beschreibung: Optional[str] = None - schwierigkeit: Optional[str] = None - untergrund: Optional[str] = None + name: Optional[str] = Field(None, max_length=200) + beschreibung: Optional[str] = Field(None, max_length=5000) + schwierigkeit: Optional[str] = Field(None, max_length=30) + untergrund: Optional[str] = Field(None, max_length=50) schatten: Optional[bool] = None leine_empfohlen: Optional[bool] = None is_public: Optional[bool] = None - hunde_tauglichkeit: Optional[str] = None + hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) class RouteDogs(BaseModel): dog_ids: List[int] @@ -553,7 +553,7 @@ async def add_route_photo( # POST /api/routes/{id}/feedback — Feedback an Route-Ersteller # ------------------------------------------------------------------ class RouteFeedback(BaseModel): - text: str + text: str = Field(..., min_length=5, max_length=2000) @router.post("/{route_id}/feedback", status_code=201) async def route_feedback(route_id: int, data: RouteFeedback, user=Depends(get_current_user)): diff --git a/backend/routes/services.py b/backend/routes/services.py index a0409e9..2d1d0fc 100644 --- a/backend/routes/services.py +++ b/backend/routes/services.py @@ -1,7 +1,7 @@ """BAN YARO — Service-Angebote (Sitting & Walks Matching)""" from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -16,8 +16,8 @@ ALLOWED_TYPES = {'sitting', 'walks'} # Schemas # ------------------------------------------------------------------ class ServiceCreate(BaseModel): - type: str - beschreibung: Optional[str] = None + type: str = Field(..., max_length=30) + beschreibung: Optional[str] = Field(None, max_length=5000) preis_pro_tag: Optional[float] = None lat: Optional[float] = None lon: Optional[float] = None diff --git a/backend/routes/sharing.py b/backend/routes/sharing.py index eb2aa58..762c8cd 100644 --- a/backend/routes/sharing.py +++ b/backend/routes/sharing.py @@ -2,7 +2,7 @@ import secrets from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import get_current_user @@ -14,7 +14,7 @@ share_router = APIRouter() class ShareInvite(BaseModel): - role: str = "editor" # viewer | editor + role: str = Field("editor", max_length=20) # viewer | editor # ------------------------------------------------------------------ diff --git a/backend/routes/sitting.py b/backend/routes/sitting.py index dc2e96c..760d942 100644 --- a/backend/routes/sitting.py +++ b/backend/routes/sitting.py @@ -2,7 +2,7 @@ import json from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from database import db from auth import get_current_user @@ -17,7 +17,7 @@ SERVICES = {'tagesbetreuung', 'uebernachtung', 'gassi', 'hausbesuch'} # Schemas # ------------------------------------------------------------------ class SitterCreate(BaseModel): - beschreibung: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=5000) preis_pro_tag: float = 0 max_hunde: int = 1 lat: Optional[float] = None @@ -26,7 +26,7 @@ class SitterCreate(BaseModel): services: List[str] = [] class SitterUpdate(BaseModel): - beschreibung: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=5000) preis_pro_tag: Optional[float] = None max_hunde: Optional[int] = None lat: Optional[float] = None @@ -38,12 +38,12 @@ class SitterUpdate(BaseModel): class RequestCreate(BaseModel): sitter_id: int dog_ids: List[int] = [] - von: str # YYYY-MM-DD - bis: str - nachricht: Optional[str] = None + von: str = Field(..., max_length=32) # YYYY-MM-DD + bis: str = Field(..., max_length=32) + nachricht: Optional[str] = Field(None, max_length=2000) class RequestUpdate(BaseModel): - status: str # angenommen | abgelehnt | abgebrochen + status: str = Field(..., max_length=30) # angenommen | abgelehnt | abgebrochen # ------------------------------------------------------------------ diff --git a/backend/routes/sitting_access.py b/backend/routes/sitting_access.py index b49e681..7bbc6da 100644 --- a/backend/routes/sitting_access.py +++ b/backend/routes/sitting_access.py @@ -1,7 +1,7 @@ """BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)""" from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import get_current_user @@ -11,7 +11,7 @@ router = APIRouter() class AccessCreate(BaseModel): dog_id: int sitter_id: int - valid_until: str # 'YYYY-MM-DD' + valid_until: str = Field(..., max_length=32) # 'YYYY-MM-DD' @router.post("", status_code=201) diff --git a/backend/routes/social.py b/backend/routes/social.py index 1cf204d..10db2d9 100644 --- a/backend/routes/social.py +++ b/backend/routes/social.py @@ -9,7 +9,7 @@ import random from typing import Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from auth import get_current_user, require_social_media from database import db @@ -849,24 +849,24 @@ Antworte NUR mit einem JSON-Objekt: class GenerateRequest(BaseModel): - platform: str = "both" - format: str = "post" - topic: str + platform: str = Field("both", max_length=30) + format: str = Field("post", max_length=30) + topic: str = Field(..., min_length=2, max_length=500) breed_id: Optional[int] = None class EvaluateRequest(BaseModel): - platform: str = "instagram" - format: str = "post" - draft: str + platform: str = Field("instagram", max_length=30) + format: str = Field("post", max_length=30) + draft: str = Field(..., min_length=1, max_length=10000) class StatusUpdate(BaseModel): - status: Optional[str] = None - scheduled_at: Optional[str] = None - published_at: Optional[str] = None - notes: Optional[str] = None - post_url: Optional[str] = None + status: Optional[str] = Field(None, max_length=50) + scheduled_at: Optional[str] = Field(None, max_length=64) + published_at: Optional[str] = Field(None, max_length=64) + notes: Optional[str] = Field(None, max_length=5000) + post_url: Optional[str] = Field(None, max_length=500) def _used_topics(limit: int = 30) -> str: diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py index 8448478..65b1c5e 100644 --- a/backend/routes/tieraerzte.py +++ b/backend/routes/tieraerzte.py @@ -2,7 +2,7 @@ import math from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -11,20 +11,20 @@ router = APIRouter() class TierarztCreate(BaseModel): - name: str - strasse: Optional[str] = None - plz: Optional[str] = None - ort: Optional[str] = None - telefon: Optional[str] = None - notfall_telefon: Optional[str] = None - email: Optional[str] = None - website: Optional[str] = None - notizen: Optional[str] = None + name: str = Field(..., min_length=1, max_length=200) + strasse: Optional[str] = Field(None, max_length=300) + plz: Optional[str] = Field(None, max_length=20) + ort: Optional[str] = Field(None, max_length=200) + telefon: Optional[str] = Field(None, max_length=30) + notfall_telefon: Optional[str] = Field(None, max_length=30) + email: Optional[str] = Field(None, max_length=254) + website: Optional[str] = Field(None, max_length=500) + notizen: Optional[str] = Field(None, max_length=5000) ist_notfallpraxis: bool = False - opening_hours: Optional[str] = None + opening_hours: Optional[str] = Field(None, max_length=500) lat: Optional[float] = None lon: Optional[float] = None - osm_id: Optional[str] = None + osm_id: Optional[str] = Field(None, max_length=100) class BewertungCreate(BaseModel): @@ -32,25 +32,25 @@ class BewertungCreate(BaseModel): wartezeit: Optional[int] = None freundlichkeit: Optional[int] = None kompetenz: Optional[int] = None - text: Optional[str] = None + text: Optional[str] = Field(None, max_length=5000) class TierarztUpdate(BaseModel): - name: Optional[str] = None - strasse: Optional[str] = None - plz: Optional[str] = None - ort: Optional[str] = None - telefon: Optional[str] = None - notfall_telefon: Optional[str] = None - email: Optional[str] = None - website: Optional[str] = None - notizen: Optional[str] = None + name: Optional[str] = Field(None, max_length=200) + strasse: Optional[str] = Field(None, max_length=300) + plz: Optional[str] = Field(None, max_length=20) + ort: Optional[str] = Field(None, max_length=200) + telefon: Optional[str] = Field(None, max_length=30) + notfall_telefon: Optional[str] = Field(None, max_length=30) + email: Optional[str] = Field(None, max_length=254) + website: Optional[str] = Field(None, max_length=500) + notizen: Optional[str] = Field(None, max_length=5000) ist_notfallpraxis: Optional[bool] = None aktiv: Optional[bool] = None - opening_hours: Optional[str] = None + opening_hours: Optional[str] = Field(None, max_length=500) lat: Optional[float] = None lon: Optional[float] = None - osm_id: Optional[str] = None + osm_id: Optional[str] = Field(None, max_length=100) def _fmt_opening_hours(raw: str | None) -> str | None: diff --git a/backend/routes/training.py b/backend/routes/training.py index 078ceef..5903b6f 100644 --- a/backend/routes/training.py +++ b/backend/routes/training.py @@ -1,7 +1,7 @@ """BAN YARO — Übungs- & Trainingsfortschritt""" from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional import datetime import ki @@ -61,9 +61,9 @@ async def get_exercises(): # Admin: Übung bearbeiten (beschreibung / schritte / tipp) # ------------------------------------------------------------------ class ExerciseUpdate(BaseModel): - beschreibung: Optional[str] = None - schritte: Optional[str] = None # JSON-String: '["Schritt 1", ...]' - tipp: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=10000) + schritte: Optional[str] = Field(None, max_length=10000) # JSON-String: '["Schritt 1", ...]' + tipp: Optional[str] = Field(None, max_length=5000) @router.put("/exercises/{exercise_id}") async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(require_admin)): @@ -93,9 +93,9 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ # Übungs-Status # ------------------------------------------------------------------ class ProgressUpdate(BaseModel): - exercise_id: str - status: Optional[str] = None - dog_id: Optional[int] = None + exercise_id: str = Field(..., max_length=200) + status: Optional[str] = Field(None, max_length=50) + dog_id: Optional[int] = None @router.get("/progress") async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)): @@ -137,9 +137,9 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)): # Trainingsplan-Checkboxen # ------------------------------------------------------------------ class PlanProgress(BaseModel): - item_key: str - checked: bool - dog_id: Optional[int] = None + item_key: str = Field(..., max_length=200) + checked: bool + dog_id: Optional[int] = None @router.get("/plan-progress") async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)): @@ -327,15 +327,15 @@ def _check_badges(conn, user_id: int, dog_name: str) -> list: class SessionCreate(BaseModel): dog_id: int - exercise_id: str - exercise_name: str - datum: Optional[str] = None - wiederholungen: int = 1 - erfolgsquote: int = 50 - hund_stimmung: Optional[str] = "aufmerksam" + exercise_id: str = Field(..., max_length=200) + exercise_name: str = Field(..., max_length=200) + datum: Optional[str] = Field(None, max_length=32) + wiederholungen: int = 1 + erfolgsquote: int = 50 + hund_stimmung: Optional[str] = Field("aufmerksam", max_length=50) zufriedenheit: Optional[int] = 3 - notiz: Optional[str] = None - tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll + notiz: Optional[str] = Field(None, max_length=2000) + tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll @router.post("/sessions") diff --git a/backend/routes/walks.py b/backend/routes/walks.py index 83f6221..07dbada 100644 --- a/backend/routes/walks.py +++ b/backend/routes/walks.py @@ -4,7 +4,7 @@ import os, uuid import httpx from datetime import date from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from database import db from auth import get_current_user @@ -20,24 +20,24 @@ router = APIRouter() # Schemas # ------------------------------------------------------------------ class WalkCreate(BaseModel): - titel: str - datum: str # YYYY-MM-DD - uhrzeit: str # HH:MM + titel: str = Field(..., min_length=1, max_length=200) + datum: str = Field(..., max_length=32) # YYYY-MM-DD + uhrzeit: str = Field(..., max_length=20) # HH:MM lat: float lon: float - ort_name: Optional[str] = None + ort_name: Optional[str] = Field(None, max_length=300) max_teilnehmer: int = 10 - beschreibung: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=5000) class WalkUpdate(BaseModel): - titel: Optional[str] = None - datum: Optional[str] = None - uhrzeit: Optional[str] = None + titel: Optional[str] = Field(None, max_length=200) + datum: Optional[str] = Field(None, max_length=32) + uhrzeit: Optional[str] = Field(None, max_length=20) lat: Optional[float] = None lon: Optional[float] = None - ort_name: Optional[str] = None + ort_name: Optional[str] = Field(None, max_length=300) max_teilnehmer: Optional[int] = None - beschreibung: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=5000) class JoinRequest(BaseModel): dog_ids: List[int] = [] # leere Liste = ohne Hund (selten) @@ -46,7 +46,7 @@ class InviteRequest(BaseModel): friend_id: int class RsvpRequest(BaseModel): - status: str # 'yes' | 'maybe' | 'no' + status: str = Field(..., max_length=20) # 'yes' | 'maybe' | 'no' # ------------------------------------------------------------------ diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index a05bb1b..7af856c 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -6,7 +6,7 @@ import time import logging from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File from fastapi.responses import JSONResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import get_current_user, get_current_user_optional from ratelimit import check as rl_check, block_ip @@ -36,9 +36,9 @@ async def honeypot(request: Request): # Schemas # ------------------------------------------------------------------ class BerichtCreate(BaseModel): - rasse: str - titel: str - text: str + rasse: str = Field(..., max_length=100) + titel: str = Field(..., min_length=3, max_length=200) + text: str = Field(..., min_length=10, max_length=10000) # ------------------------------------------------------------------ @@ -411,8 +411,8 @@ async def list_submissions(user=Depends(get_current_user)): # PATCH /api/wiki/foto-submissions/{id} — genehmigen oder ablehnen # ------------------------------------------------------------------ class ReviewModel(BaseModel): - action: str # "approve" | "reject" - reject_reason: str = "" + action: str = Field(..., max_length=30) # "approve" | "reject" + reject_reason: str = Field("", max_length=2000) @router.patch("/foto-submissions/{sub_id}") @@ -575,19 +575,19 @@ async def get_rasse_stats(slug: str, user=Depends(get_current_user_optional)): # Schemas für Interesse und Züchter # ------------------------------------------------------------------ class InteresseCreate(BaseModel): - typ: str # "hat" oder "will" + typ: str = Field(..., max_length=30) # "hat" oder "will" class ZuchterCreate(BaseModel): - rasse_slug: str - name: str - zwingername: str = "" - ort: str = "" - plz: str = "" - bundesland: str = "" + rasse_slug: str = Field(..., max_length=100) + name: str = Field(..., min_length=1, max_length=200) + zwingername: str = Field("", max_length=200) + ort: str = Field("", max_length=200) + plz: str = Field("", max_length=20) + bundesland: str = Field("", max_length=100) vdh_mitglied: int = 0 - website: str = "" - telefon: str = "" - beschreibung: str = "" + website: str = Field("", max_length=500) + telefon: str = Field("", max_length=30) + beschreibung: str = Field("", max_length=10000) # ------------------------------------------------------------------ diff --git a/backend/routes/zucht_hunde.py b/backend/routes/zucht_hunde.py index 8ef8c72..45aed56 100644 --- a/backend/routes/zucht_hunde.py +++ b/backend/routes/zucht_hunde.py @@ -2,7 +2,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db @@ -134,108 +134,108 @@ def _ik_rating(ik: float) -> str: # Pydantic-Schemas # ------------------------------------------------------------------ class HundCreate(BaseModel): - name: str - rufname: Optional[str] = None - geschlecht: str # maennlich|weiblich - geburtsdatum: Optional[str] = None - sterbedatum: Optional[str] = None - chip_nr: Optional[str] = None - taetowiernummer: Optional[str] = None - zuchtbuchnummer: Optional[str] = None - farbe: Optional[str] = None + name: str = Field(..., min_length=1, max_length=200) + rufname: Optional[str] = Field(None, max_length=80) + geschlecht: str = Field(..., max_length=20) # maennlich|weiblich + geburtsdatum: Optional[str] = Field(None, max_length=32) + sterbedatum: Optional[str] = Field(None, max_length=32) + chip_nr: Optional[str] = Field(None, max_length=50) + taetowiernummer: Optional[str] = Field(None, max_length=50) + zuchtbuchnummer: Optional[str] = Field(None, max_length=100) + farbe: Optional[str] = Field(None, max_length=100) vater_id: Optional[int] = None mutter_id: Optional[int] = None - zuechter_name: Optional[str] = None - eigentuemer_name: Optional[str] = None + zuechter_name: Optional[str] = Field(None, max_length=200) + eigentuemer_name: Optional[str] = Field(None, max_length=200) is_public: int = 1 - notiz: Optional[str] = None - foto_url: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=5000) + foto_url: Optional[str] = Field(None, max_length=500) class HundUpdate(BaseModel): - name: Optional[str] = None - rufname: Optional[str] = None - geschlecht: Optional[str] = None - geburtsdatum: Optional[str] = None - sterbedatum: Optional[str] = None - chip_nr: Optional[str] = None - taetowiernummer: Optional[str] = None - zuchtbuchnummer: Optional[str] = None - farbe: Optional[str] = None + name: Optional[str] = Field(None, max_length=200) + rufname: Optional[str] = Field(None, max_length=80) + geschlecht: Optional[str] = Field(None, max_length=20) + geburtsdatum: Optional[str] = Field(None, max_length=32) + sterbedatum: Optional[str] = Field(None, max_length=32) + chip_nr: Optional[str] = Field(None, max_length=50) + taetowiernummer: Optional[str] = Field(None, max_length=50) + zuchtbuchnummer: Optional[str] = Field(None, max_length=100) + farbe: Optional[str] = Field(None, max_length=100) vater_id: Optional[int] = None mutter_id: Optional[int] = None - zuechter_name: Optional[str] = None - eigentuemer_name: Optional[str] = None + zuechter_name: Optional[str] = Field(None, max_length=200) + eigentuemer_name: Optional[str] = Field(None, max_length=200) is_public: Optional[int] = None - notiz: Optional[str] = None - foto_url: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=5000) + foto_url: Optional[str] = Field(None, max_length=500) class HealthTestCreate(BaseModel): - test_typ: str # HD|ED|OCD|augen|herz|patella|ZTP|custom - test_name: Optional[str] = None - ergebnis: Optional[str] = None - untersuch_am: Optional[str] = None - gueltig_bis: Optional[str] = None - untersucher: Optional[str] = None - labor: Optional[str] = None - zertifikat_nr: Optional[str] = None + test_typ: str = Field(..., max_length=50) # HD|ED|OCD|augen|herz|patella|ZTP|custom + test_name: Optional[str] = Field(None, max_length=200) + ergebnis: Optional[str] = Field(None, max_length=500) + untersuch_am: Optional[str] = Field(None, max_length=32) + gueltig_bis: Optional[str] = Field(None, max_length=32) + untersucher: Optional[str] = Field(None, max_length=200) + labor: Optional[str] = Field(None, max_length=200) + zertifikat_nr: Optional[str] = Field(None, max_length=100) is_public: int = 1 class HealthTestUpdate(BaseModel): - test_typ: Optional[str] = None - test_name: Optional[str] = None - ergebnis: Optional[str] = None - untersuch_am: Optional[str] = None - gueltig_bis: Optional[str] = None - untersucher: Optional[str] = None - labor: Optional[str] = None - zertifikat_nr: Optional[str] = None + test_typ: Optional[str] = Field(None, max_length=50) + test_name: Optional[str] = Field(None, max_length=200) + ergebnis: Optional[str] = Field(None, max_length=500) + untersuch_am: Optional[str] = Field(None, max_length=32) + gueltig_bis: Optional[str] = Field(None, max_length=32) + untersucher: Optional[str] = Field(None, max_length=200) + labor: Optional[str] = Field(None, max_length=200) + zertifikat_nr: Optional[str] = Field(None, max_length=100) is_public: Optional[int] = None class GeneticTestCreate(BaseModel): - marker_name: str # MDR1|PRA-prcd|DM|vWD|HUU etc. - marker_kategorie: Optional[str] = None # krankheit|farbe|eigenschaft - genotyp: Optional[str] = None # +/+|+/-|-/- - ergebnis_klasse: Optional[str] = None # clear|carrier|affected - getestet_am: Optional[str] = None - labor: Optional[str] = None - zertifikat_nr: Optional[str] = None + marker_name: str = Field(..., max_length=100) # MDR1|PRA-prcd|DM|vWD|HUU etc. + marker_kategorie: Optional[str] = Field(None, max_length=50) # krankheit|farbe|eigenschaft + genotyp: Optional[str] = Field(None, max_length=20) # +/+|+/-|-/- + ergebnis_klasse: Optional[str] = Field(None, max_length=50) # clear|carrier|affected + getestet_am: Optional[str] = Field(None, max_length=32) + labor: Optional[str] = Field(None, max_length=200) + zertifikat_nr: Optional[str] = Field(None, max_length=100) is_public: int = 1 class GeneticTestUpdate(BaseModel): - marker_name: Optional[str] = None - marker_kategorie: Optional[str] = None - genotyp: Optional[str] = None - ergebnis_klasse: Optional[str] = None - getestet_am: Optional[str] = None - labor: Optional[str] = None - zertifikat_nr: Optional[str] = None + marker_name: Optional[str] = Field(None, max_length=100) + marker_kategorie: Optional[str] = Field(None, max_length=50) + genotyp: Optional[str] = Field(None, max_length=20) + ergebnis_klasse: Optional[str] = Field(None, max_length=50) + getestet_am: Optional[str] = Field(None, max_length=32) + labor: Optional[str] = Field(None, max_length=200) + zertifikat_nr: Optional[str] = Field(None, max_length=100) is_public: Optional[int] = None class TitelCreate(BaseModel): - titel_typ: str # ausstellung|arbeit|sport|zucht|champion|custom - titel_name: str - verliehen_am: Optional[str] = None - ort: Optional[str] = None - richter: Optional[str] = None - ausstellung: Optional[str] = None - formwert: Optional[str] = None + titel_typ: str = Field(..., max_length=50) # ausstellung|arbeit|sport|zucht|champion|custom + titel_name: str = Field(..., min_length=1, max_length=200) + verliehen_am: Optional[str] = Field(None, max_length=32) + ort: Optional[str] = Field(None, max_length=200) + richter: Optional[str] = Field(None, max_length=200) + ausstellung: Optional[str] = Field(None, max_length=200) + formwert: Optional[str] = Field(None, max_length=100) is_public: int = 1 class TitelUpdate(BaseModel): - titel_typ: Optional[str] = None - titel_name: Optional[str] = None - verliehen_am: Optional[str] = None - ort: Optional[str] = None - richter: Optional[str] = None - ausstellung: Optional[str] = None - formwert: Optional[str] = None + titel_typ: Optional[str] = Field(None, max_length=50) + titel_name: Optional[str] = Field(None, max_length=200) + verliehen_am: Optional[str] = Field(None, max_length=32) + ort: Optional[str] = Field(None, max_length=200) + richter: Optional[str] = Field(None, max_length=200) + ausstellung: Optional[str] = Field(None, max_length=200) + formwert: Optional[str] = Field(None, max_length=100) is_public: Optional[int] = None diff --git a/backend/routes/zucht_ki.py b/backend/routes/zucht_ki.py index e25c49c..3f1fd43 100644 --- a/backend/routes/zucht_ki.py +++ b/backend/routes/zucht_ki.py @@ -3,7 +3,7 @@ import logging from datetime import date, timedelta from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, Literal from database import db @@ -41,7 +41,7 @@ class PaarungAnalyseBody(BaseModel): vater_id: int mutter_id: int ik_prozent: Optional[float] = None - welfare_level: Optional[str] = None + welfare_level: Optional[str] = Field(None, max_length=50) class HundBeschreibungBody(BaseModel): diff --git a/backend/static/css/design-system.css b/backend/static/css/design-system.css index d206049..4af159f 100644 --- a/backend/static/css/design-system.css +++ b/backend/static/css/design-system.css @@ -34,7 +34,7 @@ /* Text — Warmbraun aus dem Halsband */ --c-text: #2A1F14; --c-text-secondary: #7A6A58; - --c-text-muted: #B0A090; + --c-text-muted: #7F6B58; /* a11y: WCAG AA 4.74:1 auf --c-bg #FAF7F2 (vorher #B0A090 = 2.37:1) */ --c-text-inverse: #FAF7F2; /* Funktionsfarben */ @@ -179,7 +179,7 @@ --c-text: #F0EAE0; --c-text-secondary: #C0B0A0; - --c-text-muted: #806A58; + --c-text-muted: #A08878; /* a11y: WCAG AA 5.46:1 auf --c-bg #1A1410 (vorher #806A58 = 3.58:1) */ --c-text-inverse: #2A1F14; --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30); diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css index ac91631..a54eedc 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -86,8 +86,8 @@ display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; + width: 44px; + height: 44px; border-radius: var(--radius-md); color: var(--c-text-secondary); cursor: pointer; @@ -99,8 +99,8 @@ /* Hamburger-Button (nur Mobile) */ .header-menu-btn { - width: 40px; - height: 40px; + width: 44px; + height: 44px; display: flex; align-items: center; justify-content: center; diff --git a/backend/static/index.html b/backend/static/index.html index 8d4f016..b1e995d 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -318,7 +318,7 @@
- + +
${renderDots()}
`; diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index a6fe6d8..e2dfe19 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -1421,7 +1421,7 @@ function _fmtDate(iso) { const lb = document.createElement('div'); lb.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:zoom-out'; lb.innerHTML = ` - `; + `; lb.addEventListener('click', () => lb.remove()); document.body.appendChild(lb); } diff --git a/backend/static/landing.html b/backend/static/landing.html index 2b132eb..478aa8d 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 5778dc3..8d8b530 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1117'; +const VER = '1118'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_race.py b/tests/test_race.py new file mode 100644 index 0000000..1ff7b5b --- /dev/null +++ b/tests/test_race.py @@ -0,0 +1,233 @@ +"""Race-Condition-Tests fuer atomare Counter (invoice_number, founder_number). + +Diese Tests pruefen, dass die in Sprint60 eingefuehrten Race-Schutze +(invoice_counters-Tabelle + atomare SQL-UPDATEs) tatsaechlich eindeutige +Werte vergeben, wenn mehrere Threads gleichzeitig zugreifen. +""" + +from __future__ import annotations + +import concurrent.futures +import secrets +import threading + + +# ================================================================== +# 1) Invoice-Counter — atomare Rechnungsnummern-Vergabe +# ================================================================== +class TestInvoiceCounterRace: + """_next_invoice_number() in routes/invoices.py nutzt: + - dedizierte invoice_counters-Tabelle + - BEGIN IMMEDIATE + busy_timeout + - SQLite serialisiert Writer + + Test: 20 parallele Aufrufe -> 20 EINDEUTIGE Nummern.""" + + def test_parallel_invoice_numbers_are_unique(self): + from database import db + from routes.invoices import _next_invoice_number + + N = 20 + + # Counter fuer Pruefung zuruecksetzen — sonst koennten andere Tests + # die Sequence schon hochgezaehlt haben (Konflikt vermeiden wir + # ueber einen frischen, einzigartigen Prefix pro Testlauf). + prefix = "TEST-" + secrets.token_hex(3).upper() + + results: list[str] = [] + errors: list[str] = [] + lock = threading.Lock() + + def worker(): + try: + with db() as conn: + n = _next_invoice_number(conn, prefix=prefix) + with lock: + results.append(n) + except Exception as exc: + with lock: + errors.append(repr(exc)) + + with concurrent.futures.ThreadPoolExecutor(max_workers=N) as pool: + futures = [pool.submit(worker) for _ in range(N)] + concurrent.futures.wait(futures) + + assert not errors, f"Fehler in Threads: {errors}" + assert len(results) == N, f"Erwartete {N} Ergebnisse, bekam {len(results)}" + # Duplikate? + assert len(set(results)) == N, ( + f"DUPLIKATE in vergebenen Nummern! {len(results)-len(set(results))} " + f"Kollisionen. Beispiele: {results}" + ) + + def test_invoice_counter_increments_monotonically(self): + """Ohne Parallelitaet muss next_num strikt um 1 steigen.""" + from database import db + from routes.invoices import _next_invoice_number + + prefix = "MONO-" + secrets.token_hex(3).upper() + + nums = [] + for _ in range(5): + with db() as conn: + nums.append(_next_invoice_number(conn, prefix=prefix)) + + # letzte Komponente aus z. B. "MONO-XYZ-2026-0001" + tails = [int(n.split("-")[-1]) for n in nums] + assert tails == [1, 2, 3, 4, 5], ( + f"Counter steigt nicht monoton: {tails}" + ) + + +# ================================================================== +# 2) Founder-Number — atomare Gruender-Vergabe via partner.py +# ================================================================== +class TestFounderNumberAtomic: + """partner.py setzt is_founder + founder_number in EINEM UPDATE, + routes/dogs.py macht dasselbe via atomarem Sub-Query. + + Wir testen die SQL-Logik direkt (ohne HTTP), weil das partner- + Endpunkt-Aufruf-Trace fuer Race-Tests zu komplex ist.""" + + def _make_pending_user(self, email_prefix: str = "fp") -> int: + """Legt einen User direkt in der DB an, der bereits + is_founder_pending=1 hat.""" + from database import db + email = f"{email_prefix}-{secrets.token_hex(4)}@example.com" + name = f"founder{secrets.token_hex(3)}" + with db() as conn: + cur = conn.execute( + """INSERT INTO users + (email, pw_hash, name, referral_code, email_verified, is_founder_pending) + VALUES (?, ?, ?, ?, 1, 1)""", + (email, "x", name, secrets.token_hex(4)) + ) + return cur.lastrowid + + def _atomic_founder_update(self, user_id: int): + """Reproduziert das atomare UPDATE aus routes/dogs.py.""" + from database import db + with db() as conn: + conn.execute( + """UPDATE users + SET is_founder = 1, + founder_number = ( + SELECT IFNULL(MAX(founder_number), 0) + 1 + FROM users WHERE is_founder = 1 + ), + is_founder_pending = 0 + WHERE id = ? + AND is_founder_pending = 1 + AND (is_founder IS NULL OR is_founder = 0) + AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < 100""", + (user_id,) + ) + + def test_parallel_founder_assignments_get_unique_numbers(self): + """Zwei parallele Aufrufe fuer zwei verschiedene Pending-User + muessen unterschiedliche founder_numbers bekommen.""" + from database import db + + ids = [self._make_pending_user("race") for _ in range(2)] + + threads = [] + errors: list[str] = [] + lock = threading.Lock() + + def worker(uid): + try: + self._atomic_founder_update(uid) + except Exception as exc: + with lock: + errors.append(repr(exc)) + + for uid in ids: + t = threading.Thread(target=worker, args=(uid,)) + threads.append(t) + t.start() + for t in threads: + t.join() + + assert not errors, f"Fehler: {errors}" + + with db() as conn: + rows = conn.execute( + f"SELECT id, founder_number FROM users WHERE id IN ({','.join('?'*len(ids))})", + ids + ).fetchall() + + numbers = [r["founder_number"] for r in rows] + assert all(n is not None for n in numbers), ( + f"Mindestens eine founder_number ist NULL: {numbers}" + ) + assert len(set(numbers)) == len(numbers), ( + f"DUPLIKATE bei founder_number: {numbers}" + ) + + def test_founder_full_means_no_more_numbers(self, monkeypatch): + """Wenn bereits 100 Founder existieren, vergibt die atomare + Logik KEINE neue Nummer mehr (rowcount = 0).""" + from database import db + + # Wir koennen nicht einfach 100 Founder anlegen — stattdessen + # mocken wir die Limit-Logik durch einen kleinen Limit-Test: + # statt < 100 -> < N, in dem wir einen eigenen Test-User + # einfuegen und davor genau N existierende Founder anlegen. + N_LIMIT = 3 + + # Test-Schwelle waehlen: vorhandene Founder im System zaehlen, + # dann auf N_LIMIT auffuellen. + with db() as conn: + existing = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + + # Wir muessen auf den HARDCODED 100er-Wert in der SQL-Query + # vertrauen — also auf 100 auffuellen. Das ist teuer, aber + # eindeutig. Wir machen es in einem Insert mit GROUP BY trick. + to_create = max(0, 100 - existing) + if to_create > 0: + with db() as conn: + # Bulk-Insert ist am schnellsten + conn.executemany( + """INSERT INTO users + (email, pw_hash, name, referral_code, email_verified, + is_founder, founder_number) + VALUES (?, 'x', ?, ?, 1, 1, ?)""", + [ + ( + f"founder{i}-{secrets.token_hex(3)}@x.test", + f"founder{i}-{secrets.token_hex(3)}", + secrets.token_hex(4), + existing + i + 1, + ) + for i in range(to_create) + ] + ) + + # Pruefen: 100 Founder vorhanden + with db() as conn: + count = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + assert count >= 100, f"Setup falsch: nur {count} Founder" + + # Jetzt: ein neuer Pending-User darf KEINE Nummer mehr bekommen + new_uid = self._make_pending_user("over") + self._atomic_founder_update(new_uid) + + with db() as conn: + row = conn.execute( + "SELECT is_founder, founder_number, is_founder_pending FROM users WHERE id=?", + (new_uid,) + ).fetchone() + + assert row["is_founder"] in (0, None), ( + "User wurde Founder, obwohl bereits 100 vergeben sind." + ) + assert row["founder_number"] is None, ( + f"founder_number wurde vergeben trotz vollem Slot: {row['founder_number']}" + ) + # Pending bleibt erhalten — User kann spaeter bei Ausstieg eines + # bestehenden Founders nachruecken. + assert row["is_founder_pending"] == 1 diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..68d2b14 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,315 @@ +"""Security-Tests: require_owner, JWT-Blacklist, Login-Lockout. + +Diese Tests verifizieren die in Sprint60 eingefuehrten Security-Helper +und stellen sicher, dass z. B. eine versehentliche Aenderung an +require_owner / blacklist_jti hier sofort einen roten Test ergibt. +""" + +from __future__ import annotations + +import secrets +import time + +import pytest + + +# ------------------------------------------------------------------ +# Helper: zweiten frischen User registrieren (wie das `user`-Fixture) +# ------------------------------------------------------------------ +def _make_other_user(client) -> dict: + """Registriert einen zweiten verifizierten User mit JWT-Token.""" + email = f"other-{secrets.token_hex(4)}@example.com" + pw = "OtherPass123!" + name = f"other{secrets.token_hex(3)}" + r = client.post("/api/auth/register", json={ + "email": email, "password": pw, "name": name + }) + assert r.status_code == 200, r.text + + from database import db + with db() as conn: + conn.execute("UPDATE users SET email_verified=1 WHERE email=?", (email,)) + + r2 = client.post("/api/auth/login", json={"email": email, "password": pw}) + assert r2.status_code == 200, r2.text + token = r2.json()["token"] + return { + "email": email, + "password": pw, + "name": name, + "token": token, + "headers": {"Authorization": f"Bearer {token}"}, + } + + +# ================================================================== +# 1) Owner-Check via require_owner — Places +# ================================================================== +class TestRequireOwnerPlaces: + """places.py nutzt `require_owner` fuer PATCH/DELETE — wir testen + den vollen Lebenszyklus mit zwei verschiedenen Usern.""" + + def _create_place(self, client, user) -> dict: + r = client.post( + "/api/places", + headers=user["headers"], + json={ + "name": "Test-Cafe", + "typ": "restaurant", + "lat": 49.5, + "lon": 11.0, + "hund_rein": True, + }, + ) + assert r.status_code == 201, r.text + return r.json() + + def test_get_place_is_public(self, client, user): + """places sind public — fremder User darf lesen.""" + place = self._create_place(client, user) + other = _make_other_user(client) + + r = client.get(f"/api/places/{place['id']}", headers=other["headers"]) + assert r.status_code == 200 + assert r.json()["id"] == place["id"] + + def test_patch_place_other_user_is_forbidden(self, client, user): + """PATCH mit fremdem User -> 403 (require_owner greift).""" + place = self._create_place(client, user) + other = _make_other_user(client) + + r = client.patch( + f"/api/places/{place['id']}", + headers=other["headers"], + json={"name": "Hijacked"}, + ) + assert r.status_code == 403, r.text + + # Owner kann immer noch patchen + r2 = client.patch( + f"/api/places/{place['id']}", + headers=user["headers"], + json={"name": "Cafe Updated"}, + ) + assert r2.status_code == 200 + assert r2.json()["name"] == "Cafe Updated" + + def test_delete_place_other_user_is_forbidden(self, client, user): + """DELETE mit fremdem User -> 403.""" + place = self._create_place(client, user) + other = _make_other_user(client) + + r = client.delete(f"/api/places/{place['id']}", headers=other["headers"]) + assert r.status_code == 403, r.text + + # Owner kann loeschen + r2 = client.delete(f"/api/places/{place['id']}", headers=user["headers"]) + assert r2.status_code == 204 + + def test_patch_nonexistent_place_is_404(self, client, user): + """require_owner wirft 404 wenn row None ist.""" + r = client.patch( + "/api/places/9999999", + headers=user["headers"], + json={"name": "Ghost"}, + ) + assert r.status_code == 404 + + +# ================================================================== +# 2) JWT-Blacklist — Logout invalidiert Token serverseitig +# ================================================================== +class TestJwtBlacklist: + def test_logout_blacklists_bearer_token(self, client): + """Nach Logout muss das gleiche Token bei /auth/me 401 ergeben.""" + # Frischer User in diesem Test — nicht das `user`-Fixture verwenden, + # weil andere Tests es weiter brauchen koennten. + info = _make_other_user(client) + + # 1) Token funktioniert + r = client.get("/api/auth/me", headers=info["headers"]) + assert r.status_code == 200 + + # 2) Logout + r2 = client.post("/api/auth/logout", headers=info["headers"]) + assert r2.status_code == 200 + assert r2.json()["ok"] is True + + # 3) Token nun blacklisted -> 401 + r3 = client.get("/api/auth/me", headers=info["headers"]) + assert r3.status_code == 401, ( + f"Token wurde nach Logout NICHT blacklisted (status={r3.status_code})" + ) + + def test_blacklist_entry_persisted_in_db(self, client): + """Pruefen, dass das jti tatsaechlich in jwt_blacklist landet.""" + info = _make_other_user(client) + + # jti aus Token extrahieren + import jwt as _jwt + payload = _jwt.decode( + info["token"], options={"verify_signature": False} + ) + jti = payload.get("jti") + assert jti, "Token enthaelt kein jti" + + # Logout + client.post("/api/auth/logout", headers=info["headers"]) + + from database import db + with db() as conn: + row = conn.execute( + "SELECT jti, expires_at FROM jwt_blacklist WHERE jti=?", (jti,) + ).fetchone() + assert row is not None, "jwt_blacklist-Eintrag fehlt nach Logout" + assert row["jti"] == jti + + +# ================================================================== +# 3) Login-Lockout — 5 Fehlversuche -> 429 mit Retry-After +# ================================================================== +class TestLoginLockout: + """In conftest.py wird der Lockout fuer die Test-Session global + deaktiviert (sonst wuerden Auth-Tests sich gegenseitig blocken). + Hier aktivieren wir ihn fuer einzelne Tests gezielt zurueck.""" + + def _enable_lockout(self, monkeypatch): + """Aktiviert die Lockout-Logik fuer einen einzelnen Test wieder. + + In conftest.py werden routes.auth._db_is_account_locked und + _db_record_login_failure global gestubbt (damit Register-Spam + durch andere Tests nicht zur Session-weiten Sperre fuehrt). + Hier definieren wir die echten Implementierungen lokal neu + (Kopie aus routes/auth.py) und setzen sie per monkeypatch + zurueck — das wird nach dem Test automatisch revertiert. + """ + import routes.auth as _ra + from database import db + from datetime import datetime, timedelta, timezone + + # Counter leeren — sonst beeinflussen vorherige Tests die Sperre. + with db() as conn: + conn.execute("DELETE FROM login_attempts") + + _LOCKOUT_WINDOW_MIN = 15 + _LOCKOUT_ATTEMPTS_MAX = 5 + + def _is_locked(email: str): + with db() as conn: + row = conn.execute( + "SELECT locked_until FROM login_attempts WHERE email=? COLLATE NOCASE", + (email,) + ).fetchone() + if not row or not row["locked_until"]: + return None + try: + locked_until = datetime.fromisoformat(row["locked_until"]) + except Exception: + return None + now = datetime.now(timezone.utc) + if locked_until.tzinfo is None: + locked_until = locked_until.replace(tzinfo=timezone.utc) + if locked_until <= now: + return None + return int((locked_until - now).total_seconds()) + + def _record(email: str): + now = datetime.now(timezone.utc) + window_start = now - timedelta(minutes=_LOCKOUT_WINDOW_MIN) + with db() as conn: + row = conn.execute( + "SELECT attempts, last_attempt FROM login_attempts WHERE email=? COLLATE NOCASE", + (email,) + ).fetchone() + if row: + try: + last = datetime.fromisoformat(row["last_attempt"]) + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + except Exception: + last = now + attempts = (row["attempts"] + 1) if last >= window_start else 1 + else: + attempts = 1 + + locked_until = None + if attempts >= _LOCKOUT_ATTEMPTS_MAX: + locked_until = (now + timedelta(minutes=_LOCKOUT_WINDOW_MIN)).isoformat() + + conn.execute( + """INSERT INTO login_attempts (email, attempts, last_attempt, locked_until) + VALUES (?,?,?,?) + ON CONFLICT(email) DO UPDATE SET + attempts=excluded.attempts, + last_attempt=excluded.last_attempt, + locked_until=excluded.locked_until""", + (email.lower(), attempts, now.isoformat(), locked_until) + ) + + monkeypatch.setattr(_ra, "_db_is_account_locked", _is_locked) + monkeypatch.setattr(_ra, "_db_record_login_failure", _record) + + def test_lockout_after_five_failed_logins(self, client, monkeypatch): + """5x falsches PW -> 6. Versuch ergibt 429 mit Retry-After.""" + # Eigenen User anlegen, damit andere Tests das Lockout-Limit nicht + # zufaellig schon erreicht haben. + info = _make_other_user(client) + self._enable_lockout(monkeypatch) + + # 5 Fehlversuche + for i in range(5): + r = client.post("/api/auth/login", json={ + "email": info["email"], "password": "WRONG-PW!" + }) + assert r.status_code == 401, ( + f"Versuch {i+1}: erwartete 401, bekam {r.status_code}" + ) + + # 6. Versuch: jetzt gelockt + r6 = client.post("/api/auth/login", json={ + "email": info["email"], "password": "WRONG-PW!" + }) + assert r6.status_code == 429, ( + f"Erwartete 429 nach 5 Fehlversuchen, bekam {r6.status_code}" + ) + assert "Retry-After" in r6.headers, "Retry-After-Header fehlt bei Lockout" + + def test_lockout_writes_to_login_attempts_table(self, client, monkeypatch): + """login_attempts-Eintrag muss locked_until enthalten.""" + info = _make_other_user(client) + self._enable_lockout(monkeypatch) + + for _ in range(5): + client.post("/api/auth/login", json={ + "email": info["email"], "password": "WRONG-PW!" + }) + + from database import db + with db() as conn: + row = conn.execute( + "SELECT attempts, locked_until FROM login_attempts WHERE email=? COLLATE NOCASE", + (info["email"],) + ).fetchone() + assert row is not None, "Kein login_attempts-Eintrag angelegt" + assert row["attempts"] >= 5 + assert row["locked_until"] is not None, "locked_until nicht gesetzt" + + +# ================================================================== +# 4) Rate-Limit auf /auth/login (Brute-Force-Schutz auf IP-Ebene) +# ================================================================== +@pytest.mark.xfail( + reason="rl_check ist in conftest fuer alle Tests gestubbt — Rate-Limit " + "laesst sich pro Test nicht selektiv reaktivieren ohne andere " + "parallele Tests zu beeintraechtigen." +) +def test_login_rate_limit_blocks_burst(client): + """20+ schnelle Logins -> 429 vom Rate-Limiter.""" + info = _make_other_user(client) + statuses = [] + for _ in range(25): + r = client.post("/api/auth/login", json={ + "email": info["email"], "password": "WRONG-PW!" + }) + statuses.append(r.status_code) + assert 429 in statuses, f"Kein Rate-Limit-Treffer in {statuses}" diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..b52782e --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,104 @@ +"""Pydantic-Validation-Tests: max_length verhindert massive Payloads. + +Sprint60 hat in forum.py und diary.py max_length-Felder eingefuehrt +(titel<=200, text<=10000). Wir testen, dass ueberlange Eingaben +SOFORT mit 422 abgelehnt werden — bevor sie in die DB gelangen. +""" + +from __future__ import annotations + + +# ================================================================== +# Forum — ThreadCreate +# ================================================================== +class TestForumValidation: + def test_forum_thread_with_overlong_title_is_422(self, client, user): + """30000-Zeichen-Titel -> 422 (max_length=200).""" + r = client.post( + "/api/forum/threads", + headers=user["headers"], + json={ + "kategorie": "allgemein", + "titel": "T" * 30_000, + "text": "Inhalt", + }, + ) + assert r.status_code == 422, ( + f"Erwartete 422 (max_length), bekam {r.status_code}: {r.text[:200]}" + ) + + def test_forum_thread_with_overlong_text_is_422(self, client, user): + """50000-Zeichen-Text -> 422 (max_length=10000).""" + r = client.post( + "/api/forum/threads", + headers=user["headers"], + json={ + "kategorie": "allgemein", + "titel": "Ok-Titel", + "text": "X" * 50_000, + }, + ) + assert r.status_code == 422, ( + f"Erwartete 422, bekam {r.status_code}: {r.text[:200]}" + ) + + def test_forum_thread_at_max_length_passes_validation(self, client, user): + """200-Zeichen-Titel + 10000-Zeichen-Text muss durchgehen.""" + r = client.post( + "/api/forum/threads", + headers=user["headers"], + json={ + "kategorie": "allgemein", + "titel": "T" * 200, + "text": "X" * 10_000, + }, + ) + # Darf nicht 422 sein — moegliche Codes 200/201/400 sind ok + assert r.status_code != 422, ( + f"Grenzwerte sollten validieren, bekam 422: {r.text[:200]}" + ) + + +# ================================================================== +# Diary — DiaryCreate +# ================================================================== +class TestDiaryValidation: + def test_diary_with_overlong_text_is_422(self, client, user, dog): + """50000-Zeichen-Text -> 422 (max_length=10000).""" + r = client.post( + f"/api/dogs/{dog['id']}/diary", + headers=user["headers"], + json={ + "titel": "Mein Eintrag", + "text": "X" * 50_000, + }, + ) + assert r.status_code == 422, ( + f"Erwartete 422, bekam {r.status_code}: {r.text[:200]}" + ) + + def test_diary_with_overlong_title_is_422(self, client, user, dog): + """5000-Zeichen-Titel -> 422 (max_length=200).""" + r = client.post( + f"/api/dogs/{dog['id']}/diary", + headers=user["headers"], + json={ + "titel": "T" * 5_000, + "text": "kurzer Text", + }, + ) + assert r.status_code == 422, ( + f"Erwartete 422, bekam {r.status_code}: {r.text[:200]}" + ) + + def test_diary_with_normal_payload_succeeds(self, client, user, dog): + """Sanity-Check: normaler Eintrag geht durch.""" + r = client.post( + f"/api/dogs/{dog['id']}/diary", + headers=user["headers"], + json={ + "titel": "Normal", + "text": "Normaler Text-Inhalt.", + }, + ) + assert r.status_code in (200, 201), r.text