Sicherheit + Tests + A11y, SW by-v1118

PYDANTIC max_length (38 Routen, ~400 Field-Constraints):
Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.).
Pragmatische Limits:
- Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000
- Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100
- Hund-Name/Rasse: 80 · Hund-Bio: 2000

Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py,
notes.py, auth.py, profile.py. Manuelle len()-Checks in profile,
chat, ki entfernt (jetzt durch Field abgedeckt).

PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail):
- test_security.py: require_owner (Places GET/PATCH/DELETE mit
  Fremduser → 403), JWT-Blacklist (Logout invalidiert Token),
  Login-Lockout (5 Fehlversuche → 429 + Retry-After Header)
- test_race.py: Invoice-Counter (20 parallele Threads, alle unique),
  Founder-Number (atomare Vergabe, voll bei 100)
- test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text
  50k → 422 (verifiziert Pydantic max_length-Sweep)

A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast):
- #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44
- dog-profile Wrapped-Slider Prev/Next 40→44
- forum-Lightbox Close 40→44
- --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS)
- --c-text-muted Dark:  #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS)
- Branding-Farben unangetastet
This commit is contained in:
rene 2026-05-27 13:40:30 +02:00
parent 7751d303bb
commit 1ff66a7083
57 changed files with 1253 additions and 612 deletions

View file

@ -1 +1 @@
1117
1118

View file

@ -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

View file

@ -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(

View file

@ -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)):

View file

@ -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)):

View file

@ -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)
# ------------------------------------------------------------------

View file

@ -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(

View file

@ -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

View file

@ -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,27 +29,27 @@ 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
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
name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
rasse_id: Optional[int] = None
geburtstag: Optional[str] = None
geschlecht: Optional[str] = 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
chip_nr: Optional[str] = Field(None, max_length=50)
bio: Optional[str] = Field(None, max_length=2000)
is_public: Optional[bool] = None
@ -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)):

View file

@ -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)
# ------------------------------------------------------------------

View file

@ -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)
# ------------------------------------------------------------------

View file

@ -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

View file

@ -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

View file

@ -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
@ -17,12 +17,12 @@ 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
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):

View file

@ -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
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] = None
notiz: Optional[str] = None
trigger: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = Field(None, max_length=5000)
@router.get("/{dog_id}/behavior")

View file

@ -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
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

View file

@ -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
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)
# ------------------------------------------------------------------

View file

@ -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,

View file

@ -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)
# ------------------------------------------------------------------

View file

@ -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)
# ------------------------------------------------------------------

View file

@ -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):

View file

@ -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)
# ------------------------------------------------------------------

View file

@ -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
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] = None
bild_emoji: str = "🐾"
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)
# ------------------------------------------------------------------

View file

@ -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
text: str = Field(..., min_length=1, max_length=5000)
meta_json: Optional[Any] = None
location_name: Optional[str] = None
parent_label: Optional[str] = None
client_time: Optional[str] = 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)
# ------------------------------------------------------------------

View file

@ -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)

View file

@ -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,25 +135,25 @@ 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"
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

View file

@ -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

View file

@ -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)
# ------------------------------------------------------------------

View file

@ -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

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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).")

View file

@ -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

View file

@ -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)
# ------------------------------------------------------------------

View file

@ -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)):

View file

@ -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

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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)

View file

@ -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:

View file

@ -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:

View file

@ -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,8 +93,8 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ
# Übungs-Status
# ------------------------------------------------------------------
class ProgressUpdate(BaseModel):
exercise_id: str
status: Optional[str] = None
exercise_id: str = Field(..., max_length=200)
status: Optional[str] = Field(None, max_length=50)
dog_id: Optional[int] = None
@router.get("/progress")
@ -137,7 +137,7 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
# Trainingsplan-Checkboxen
# ------------------------------------------------------------------
class PlanProgress(BaseModel):
item_key: str
item_key: str = Field(..., max_length=200)
checked: bool
dog_id: Optional[int] = None
@ -327,14 +327,14 @@ 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
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] = "aufmerksam"
hund_stimmung: Optional[str] = Field("aufmerksam", max_length=50)
zufriedenheit: Optional[int] = 3
notiz: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000)
tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll

View file

@ -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'
# ------------------------------------------------------------------

View file

@ -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)
# ------------------------------------------------------------------

View file

@ -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

View file

@ -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):

View file

@ -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);

View file

@ -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;

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1117"></script>
<script src="/js/boot-early.js?v=1118"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1117">
<link rel="stylesheet" href="/css/layout.css?v=1117">
<link rel="stylesheet" href="/css/components.css?v=1117">
<link rel="stylesheet" href="/css/utilities.css?v=1117">
<link rel="stylesheet" href="/css/lists.css?v=1117">
<link rel="stylesheet" href="/css/design-system.css?v=1118">
<link rel="stylesheet" href="/css/layout.css?v=1118">
<link rel="stylesheet" href="/css/components.css?v=1118">
<link rel="stylesheet" href="/css/utilities.css?v=1118">
<link rel="stylesheet" href="/css/lists.css?v=1118">
</head>
<body>
@ -318,7 +318,7 @@
</div>
<div id="header-actions"></div>
<button id="header-user-btn" aria-label="Profil"
style="width:36px;height:36px;border-radius:50%;border:2px solid var(--c-border);
style="width:44px;height:44px;border-radius:50%;border:2px solid var(--c-border);
background:var(--c-surface-2);cursor:pointer;flex-shrink:0;
display:flex;align-items:center;justify-content:center;overflow:hidden;
padding:0;position:relative">
@ -617,11 +617,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1117"></script>
<script src="/js/ui.js?v=1117"></script>
<script src="/js/app.js?v=1117"></script>
<script src="/js/worlds.js?v=1117"></script>
<script src="/js/offline-indicator.js?v=1117"></script>
<script src="/js/api.js?v=1118"></script>
<script src="/js/ui.js?v=1118"></script>
<script src="/js/app.js?v=1118"></script>
<script src="/js/worlds.js?v=1118"></script>
<script src="/js/offline-indicator.js?v=1118"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -631,7 +631,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1117"></script>
<script src="/js/boot.js?v=1118"></script>
</body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1117'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1118'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;

View file

@ -2115,8 +2115,8 @@ window.Page_dog_profile = (() => {
</div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">
<div id="dp-wrapped-card-container" style="width:100%;max-width:400px;color:#fff;">${cards[0]}</div>
<button id="dp-wrapped-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:none;align-items:center;justify-content:center"></button>
<button id="dp-wrapped-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>
<button id="dp-wrapped-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:44px;height:44px;font-size:1.3rem;color:#fff;cursor:pointer;display:none;align-items:center;justify-content:center"></button>
<button id="dp-wrapped-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:44px;height:44px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>
</div>
<div id="dp-wrapped-dots" style="display:flex;gap:8px;justify-content:center;padding:16px 0 32px">${renderDots()}</div>
`;

View file

@ -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 = `<img src="${UI.escape(src)}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom">
<button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:40px;height:40px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>`;
<button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:44px;height:44px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>`;
lb.addEventListener('click', () => lb.remove());
document.body.appendChild(lb);
}

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1117"></script>
<script src="/js/landing-init.js?v=1118"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -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

233
tests/test_race.py Normal file
View file

@ -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

315
tests/test_security.py Normal file
View file

@ -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}"

104
tests/test_validation.py Normal file
View file

@ -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