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 import APIRouter, Depends, HTTPException
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from database import db, DB_PATH from database import db, DB_PATH
from auth import get_current_user from auth import get_current_user
@ -92,15 +92,15 @@ _VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "bree
class QuarterlyReportBody(BaseModel): class QuarterlyReportBody(BaseModel):
year: int year: int
quarter: int quarter: int
email: str email: str = Field(..., max_length=254)
class UserPatch(BaseModel): 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_moderator: Optional[int] = None
is_banned: 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 is_social_media: Optional[int] = None
subscription_tier: Optional[str] = None subscription_tier: Optional[str] = Field(None, max_length=50)
class WikiEnrichBody(BaseModel): class WikiEnrichBody(BaseModel):
limit: int = 10 limit: int = 10

View file

@ -16,7 +16,7 @@ import uuid
import httpx import httpx
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException 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 typing import Optional
from database import db from database import db
from auth import get_current_user 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): 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) # PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class _StatusBody(BaseModel): class _StatusBody(BaseModel):
status: str status: str = Field(..., max_length=50)
@router.patch("/community/{listing_id}") @router.patch("/community/{listing_id}")
def community_update_status( def community_update_status(

View file

@ -10,7 +10,7 @@ from typing import Optional
import jwt as _pyjwt import jwt as _pyjwt
from fastapi import APIRouter, HTTPException, Request, Response, Depends from fastapi import APIRouter, HTTPException, Request, Response, Depends
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, Field
from database import db from database import db
from auth import ( from auth import (
hash_password, verify_password, create_token, hash_password, verify_password, create_token,
@ -146,13 +146,13 @@ def _send_verification_email(email: str, name: str, token: str):
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
password: str password: str = Field(..., min_length=1, max_length=200)
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
password: str password: str = Field(..., min_length=8, max_length=200)
name: str name: str = Field(..., min_length=2, max_length=40)
ref_code: Optional[str] = None ref_code: Optional[str] = Field(None, max_length=50)
def _gen_referral_code() -> str: def _gen_referral_code() -> str:
@ -426,8 +426,8 @@ class ForgotPasswordRequest(BaseModel):
email: EmailStr email: EmailStr
class ResetPasswordRequest(BaseModel): class ResetPasswordRequest(BaseModel):
token: str token: str = Field(..., min_length=10, max_length=200)
password: str password: str = Field(..., min_length=8, max_length=200)
@router.post("/forgot-password") @router.post("/forgot-password")
async def forgot_password(data: ForgotPasswordRequest, request: Request): async def forgot_password(data: ForgotPasswordRequest, request: Request):
@ -471,8 +471,8 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
class UpgradeRequestBody(BaseModel): class UpgradeRequestBody(BaseModel):
tier: str tier: str = Field(..., max_length=50)
message: Optional[str] = None message: Optional[str] = Field(None, max_length=2000)
@router.post("/upgrade-request") @router.post("/upgrade-request")
async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)): 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 import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db 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): 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 # PUT /api/breeder/profile — eigenes Profil bearbeiten
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class BreederProfileUpdate(BaseModel): class BreederProfileUpdate(BaseModel):
zwingername: Optional[str] = None zwingername: Optional[str] = Field(None, max_length=200)
rasse_text: Optional[str] = None rasse_text: Optional[str] = Field(None, max_length=200)
verein: Optional[str] = None verein: Optional[str] = Field(None, max_length=200)
vdh_mitglied: Optional[int] = None vdh_mitglied: Optional[int] = None
stadt: Optional[str] = None stadt: Optional[str] = Field(None, max_length=200)
website: Optional[str] = None website: Optional[str] = Field(None, max_length=500)
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=10000)
@router.put("/breeder/profile") @router.put("/breeder/profile")
async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)): 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)""" """BAN YARO — Züchter-Fotos (Upload, Verwaltung, öffentliche Ansicht)"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
import os, logging, asyncio import os, logging, asyncio
from database import db from database import db
@ -30,10 +30,10 @@ def _require_breeder(user=Depends(get_current_user)):
# Modelle # Modelle
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class VisibilityBody(BaseModel): class VisibilityBody(BaseModel):
visibility: str visibility: str = Field(..., max_length=30)
class CaptionBody(BaseModel): 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 import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import db from database import db
from auth import get_current_user 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): class SendMsgModel(BaseModel):
text: str text: str = Field(..., min_length=1, max_length=2000)
@router.post("/conversations/{conv_id}/messages", status_code=201) @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() text = data.text.strip()
if not text: if not text:
raise HTTPException(400, "Nachricht darf nicht leer sein.") 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: with db() as conn:
conv = conn.execute( conv = conn.execute(

View file

@ -2,7 +2,7 @@
import os, uuid, json, logging, asyncio import os, uuid, json, logging, asyncio
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user, require_admin from auth import get_current_user, require_admin
@ -20,27 +20,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel): class DiaryCreate(BaseModel):
datum: Optional[str] = None # ISO date, default heute datum: Optional[str] = Field(None, max_length=32) # ISO date, default heute
client_time: Optional[str] = None # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS) client_time: Optional[str] = Field(None, max_length=64) # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS)
typ: str = "eintrag" typ: str = Field("eintrag", max_length=50)
titel: Optional[str] = None titel: Optional[str] = Field(None, max_length=200)
text: Optional[str] = None text: Optional[str] = Field(None, max_length=10000)
tags: Optional[list] = None tags: Optional[list] = None
gps_lat: Optional[float] = None gps_lat: Optional[float] = None
gps_lon: 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 is_milestone: bool = False
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary 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): class DiaryUpdate(BaseModel):
titel: Optional[str] = None titel: Optional[str] = Field(None, max_length=200)
text: Optional[str] = None text: Optional[str] = Field(None, max_length=10000)
tags: Optional[list] = None tags: Optional[list] = None
gps_lat: Optional[float] = None gps_lat: Optional[float] = None
gps_lon: 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 is_milestone: Optional[bool] = None
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen

View file

@ -3,7 +3,7 @@
import os import os
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user, has_pro_access from auth import get_current_user, has_pro_access
@ -29,28 +29,28 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DogCreate(BaseModel): class DogCreate(BaseModel):
name: str name: str = Field(..., min_length=1, max_length=80)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
geburtstag: Optional[str] = None geburtstag: Optional[str] = Field(None, max_length=32)
geschlecht: Optional[str] = None geschlecht: Optional[str] = Field(None, max_length=20)
gewicht_kg: Optional[float] = None gewicht_kg: Optional[float] = None
widerrist_cm: Optional[float] = None widerrist_cm: Optional[float] = None
chip_nr: Optional[str] = None chip_nr: Optional[str] = Field(None, max_length=50)
bio: Optional[str] = None bio: Optional[str] = Field(None, max_length=2000)
is_public: bool = False is_public: bool = False
class DogUpdate(BaseModel): class DogUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
rasse_id: Optional[int] = None rasse_id: Optional[int] = None
geburtstag: Optional[str] = None geburtstag: Optional[str] = Field(None, max_length=32)
geschlecht: Optional[str] = None geschlecht: Optional[str] = Field(None, max_length=20)
gewicht_kg: Optional[float] = None gewicht_kg: Optional[float] = None
widerrist_cm: Optional[float] = None widerrist_cm: Optional[float] = None
chip_nr: Optional[str] = None chip_nr: Optional[str] = Field(None, max_length=50)
bio: Optional[str] = None bio: Optional[str] = Field(None, max_length=2000)
is_public: Optional[bool] = None is_public: Optional[bool] = None
@router.get("") @router.get("")
@ -1033,8 +1033,8 @@ async def public_dog_profile(dog_id: int):
class FoundReport(BaseModel): class FoundReport(BaseModel):
message: Optional[str] = None message: Optional[str] = Field(None, max_length=1000)
kontakt: Optional[str] = None kontakt: Optional[str] = Field(None, max_length=300)
# Gefunden-Meldung (kein Login nötig) # 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 # POST /api/dogs/{id}/gedenken — Hund als verstorben markieren
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class GedenkenData(BaseModel): class GedenkenData(BaseModel):
verstorben_am: str # YYYY-MM-DD verstorben_am: str = Field(..., max_length=32) # YYYY-MM-DD
@router.post("/{dog_id}/gedenken") @router.post("/{dog_id}/gedenken")
async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)): async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)):

View file

@ -2,7 +2,7 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -16,18 +16,18 @@ logger = logging.getLogger(__name__)
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class FutterProfilUpdate(BaseModel): class FutterProfilUpdate(BaseModel):
futter_typ: Optional[str] = None # trocken|nass|barf|mix futter_typ: Optional[str] = Field(None, max_length=50) # trocken|nass|barf|mix
marke: Optional[str] = None marke: Optional[str] = Field(None, max_length=200)
kcal_tag: Optional[int] = None kcal_tag: Optional[int] = None
portionen: Optional[int] = None portionen: Optional[int] = None
notizen: Optional[str] = None notizen: Optional[str] = Field(None, max_length=5000)
class KiBeratungRequest(BaseModel): class KiBeratungRequest(BaseModel):
frage: str frage: str = Field(..., min_length=3, max_length=2000)
dog_name: Optional[str] = None dog_name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
alter: Optional[str] = None alter: Optional[str] = Field(None, max_length=50)
gewicht: Optional[float] = None gewicht: Optional[float] = None
aktiv: Optional[bool] = None aktiv: Optional[bool] = None
@ -183,20 +183,20 @@ _GASTRO_HINWEIS = "Magen-Darm-Symptome wie {label} treten meist innerhalb wenige
class FutterEintragCreate(BaseModel): class FutterEintragCreate(BaseModel):
datum: str datum: str = Field(..., max_length=32)
uhrzeit: str uhrzeit: str = Field(..., max_length=20)
futter_name: str futter_name: str = Field(..., max_length=200)
futter_typ: Optional[str] = "trockenfutter" futter_typ: Optional[str] = Field("trockenfutter", max_length=50)
menge_g: Optional[int] = None menge_g: Optional[int] = None
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class ReaktionCreate(BaseModel): class ReaktionCreate(BaseModel):
datum: str datum: str = Field(..., max_length=32)
uhrzeit: str uhrzeit: str = Field(..., max_length=20)
reaktion_typ: str reaktion_typ: str = Field(..., max_length=100)
intensitaet: Optional[int] = 3 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 datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -17,29 +17,29 @@ TYPEN = {'ausstellung', 'training', 'treffen', 'markt', 'wettkampf', 'sonstiges'
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class RsvpCreate(BaseModel): class RsvpCreate(BaseModel):
status: str = 'going' # 'going' | 'maybe' status: str = Field('going', max_length=20) # 'going' | 'maybe'
class EventCreate(BaseModel): class EventCreate(BaseModel):
titel: str titel: str = Field(..., min_length=3, max_length=200)
datum: str # YYYY-MM-DD datum: str = Field(..., max_length=32) # YYYY-MM-DD
uhrzeit: Optional[str] = None uhrzeit: Optional[str] = Field(None, max_length=20)
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
ort_name: Optional[str] = None ort_name: Optional[str] = Field(None, max_length=300)
typ: str = 'sonstiges' typ: str = Field('sonstiges', max_length=50)
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=10000)
link: Optional[str] = None link: Optional[str] = Field(None, max_length=500)
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
titel: Optional[str] = None titel: Optional[str] = Field(None, max_length=200)
datum: Optional[str] = None datum: Optional[str] = Field(None, max_length=32)
uhrzeit: Optional[str] = None uhrzeit: Optional[str] = Field(None, max_length=20)
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
ort_name: Optional[str] = None ort_name: Optional[str] = Field(None, max_length=300)
typ: Optional[str] = None typ: Optional[str] = Field(None, max_length=50)
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=10000)
link: Optional[str] = None link: Optional[str] = Field(None, max_length=500)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -4,7 +4,7 @@ import logging
from datetime import date, timedelta from datetime import date, timedelta
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -20,35 +20,35 @@ KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonst
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ExpenseCreate(BaseModel): class ExpenseCreate(BaseModel):
dog_id: Optional[int] = None dog_id: Optional[int] = None
kategorie: str kategorie: str = Field(..., max_length=50)
betrag: float betrag: float
datum: str datum: str = Field(..., max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=1000)
class ExpenseUpdate(BaseModel): class ExpenseUpdate(BaseModel):
dog_id: Optional[int] = None dog_id: Optional[int] = None
kategorie: Optional[str] = None kategorie: Optional[str] = Field(None, max_length=50)
betrag: Optional[float] = None betrag: Optional[float] = None
datum: Optional[str] = None datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=1000)
class RecurringCreate(BaseModel): class RecurringCreate(BaseModel):
dog_id: Optional[int] = None dog_id: Optional[int] = None
kategorie: str kategorie: str = Field(..., max_length=50)
betrag: float betrag: float
haeufigkeit: str # monatlich | quartalsweise | jaehrlich haeufigkeit: str = Field(..., max_length=30) # monatlich | quartalsweise | jaehrlich
startdatum: str # ISO date startdatum: str = Field(..., max_length=32) # ISO date
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=1000)
class RecurringUpdate(BaseModel): class RecurringUpdate(BaseModel):
dog_id: Optional[int] = None dog_id: Optional[int] = None
kategorie: Optional[str] = None kategorie: Optional[str] = Field(None, max_length=50)
betrag: Optional[float] = None betrag: Optional[float] = None
haeufigkeit: Optional[str] = None haeufigkeit: Optional[str] = Field(None, max_length=30)
startdatum: Optional[str] = None startdatum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=1000)
aktiv: Optional[bool] = None aktiv: Optional[bool] = None

View file

@ -2,7 +2,7 @@
import os, uuid, json, logging import os, uuid, json, logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional
@ -27,40 +27,40 @@ KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung',
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ThreadCreate(BaseModel): class ThreadCreate(BaseModel):
kategorie: str = 'allgemein' kategorie: str = Field('allgemein', max_length=100)
titel: str titel: str = Field(..., min_length=3, max_length=200)
text: str text: str = Field(..., min_length=1, max_length=10000)
thread_lat: Optional[float] = None thread_lat: Optional[float] = None
thread_lon: Optional[float] = None thread_lon: Optional[float] = None
thread_ort: Optional[str] = None thread_ort: Optional[str] = Field(None, max_length=300)
client_time: Optional[str] = None client_time: Optional[str] = Field(None, max_length=64)
class PostCreate(BaseModel): class PostCreate(BaseModel):
text: str text: str = Field(..., min_length=1, max_length=10000)
client_time: Optional[str] = None client_time: Optional[str] = Field(None, max_length=64)
class ThreadPatch(BaseModel): class ThreadPatch(BaseModel):
is_pinned: Optional[int] = None is_pinned: Optional[int] = None
is_locked: Optional[int] = None is_locked: Optional[int] = None
class ThreadUpdate(BaseModel): class ThreadUpdate(BaseModel):
titel: Optional[str] = None titel: Optional[str] = Field(None, max_length=200)
text: Optional[str] = None text: Optional[str] = Field(None, max_length=10000)
thread_lat: Optional[float] = None thread_lat: Optional[float] = None
thread_lon: 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): class PostUpdate(BaseModel):
text: str text: str = Field(..., min_length=1, max_length=10000)
class LikeBody(BaseModel): class LikeBody(BaseModel):
target_type: str # 'thread' | 'post' target_type: str = Field(..., max_length=20) # 'thread' | 'post'
target_id: int target_id: int
class ReportBody(BaseModel): class ReportBody(BaseModel):
target_type: str target_type: str = Field(..., max_length=20)
target_id: int target_id: int
grund: str grund: str = Field(..., min_length=3, max_length=1000)
class LocationBody(BaseModel): class LocationBody(BaseModel):
lat: Optional[float] = None lat: Optional[float] = None

View file

@ -3,7 +3,7 @@
import json import json
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -15,14 +15,14 @@ router = APIRouter()
class GassiZeitCreate(BaseModel): class GassiZeitCreate(BaseModel):
dog_id: Optional[int] = None dog_id: Optional[int] = None
wochentage: List[str] # ["mo", "mi", "fr"] wochentage: List[str] # ["mo", "mi", "fr"]
uhrzeit: str # "17:00" uhrzeit: str = Field(..., max_length=20) # "17:00"
ort_name: Optional[str] = None ort_name: Optional[str] = Field(None, max_length=300)
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
radius_m: int = 500 radius_m: int = 500
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class GassiZeitUpdate(BaseModel): class GassiZeitUpdate(BaseModel):

View file

@ -3,7 +3,7 @@
import os, uuid import os, uuid
from datetime import date, datetime from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -22,59 +22,59 @@ TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class HealthCreate(BaseModel): class HealthCreate(BaseModel):
typ: str typ: str = Field(..., max_length=50)
bezeichnung: Optional[str] = None bezeichnung: Optional[str] = Field(None, max_length=200)
datum: str datum: str = Field(..., max_length=32)
naechstes: Optional[str] = None naechstes: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=5000)
# Gewicht # Gewicht
wert: Optional[float] = None wert: Optional[float] = None
einheit: Optional[str] = "kg" einheit: Optional[str] = Field("kg", max_length=20)
# Impfung # Impfung
charge_nr: Optional[str] = None charge_nr: Optional[str] = Field(None, max_length=100)
tierarzt_name: Optional[str] = None tierarzt_name: Optional[str] = Field(None, max_length=200)
# Tierarztbesuch # Tierarztbesuch
kosten: Optional[float] = None kosten: Optional[float] = None
diagnose: Optional[str] = None diagnose: Optional[str] = Field(None, max_length=2000)
# Medikament # Medikament
dosierung: Optional[str] = None dosierung: Optional[str] = Field(None, max_length=200)
haeufigkeit: Optional[str] = None haeufigkeit: Optional[str] = Field(None, max_length=200)
aktiv: Optional[int] = 1 aktiv: Optional[int] = 1
bis_datum: Optional[str] = None bis_datum: Optional[str] = Field(None, max_length=32)
# Allergie # Allergie
schweregrad: Optional[str] = None # leicht | mittel | schwer schweregrad: Optional[str] = Field(None, max_length=50) # leicht | mittel | schwer
reaktion: Optional[str] = None reaktion: Optional[str] = Field(None, max_length=1000)
erinnerung: Optional[int] = 1 erinnerung: Optional[int] = 1
intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage
# Tierarzt-Verknüpfung # Tierarzt-Verknüpfung
tierarzt_id: Optional[int] = None tierarzt_id: Optional[int] = None
# Züchter # Züchter
deckdatum: Optional[str] = None deckdatum: Optional[str] = Field(None, max_length=32)
wurftermin: Optional[str] = None wurftermin: Optional[str] = Field(None, max_length=32)
class HealthUpdate(BaseModel): class HealthUpdate(BaseModel):
bezeichnung: Optional[str] = None bezeichnung: Optional[str] = Field(None, max_length=200)
datum: Optional[str] = None datum: Optional[str] = Field(None, max_length=32)
naechstes: Optional[str] = None naechstes: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=5000)
wert: Optional[float] = None wert: Optional[float] = None
einheit: Optional[str] = None einheit: Optional[str] = Field(None, max_length=20)
charge_nr: Optional[str] = None charge_nr: Optional[str] = Field(None, max_length=100)
tierarzt_name: Optional[str] = None tierarzt_name: Optional[str] = Field(None, max_length=200)
kosten: Optional[float] = None kosten: Optional[float] = None
diagnose: Optional[str] = None diagnose: Optional[str] = Field(None, max_length=2000)
dosierung: Optional[str] = None dosierung: Optional[str] = Field(None, max_length=200)
haeufigkeit: Optional[str] = None haeufigkeit: Optional[str] = Field(None, max_length=200)
aktiv: Optional[int] = None aktiv: Optional[int] = None
bis_datum: Optional[str] = None bis_datum: Optional[str] = Field(None, max_length=32)
schweregrad: Optional[str] = None schweregrad: Optional[str] = Field(None, max_length=50)
reaktion: Optional[str] = None reaktion: Optional[str] = Field(None, max_length=1000)
erinnerung: Optional[int] = None erinnerung: Optional[int] = None
intervall_tage: Optional[int] = None intervall_tage: Optional[int] = None
tierarzt_id: Optional[int] = None tierarzt_id: Optional[int] = None
deckdatum: Optional[str] = None deckdatum: Optional[str] = Field(None, max_length=32)
wurftermin: Optional[str] = None 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 # POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class SymptomCheckRequest(BaseModel): class SymptomCheckRequest(BaseModel):
symptoms: str symptoms: str = Field(..., min_length=3, max_length=5000)
@router.post("/{dog_id}/health/symptom-check") @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): class InsuranceCreate(BaseModel):
anbieter: str anbieter: str = Field(..., min_length=1, max_length=200)
police_nr: Optional[str] = None police_nr: Optional[str] = Field(None, max_length=100)
jahresbeitrag: Optional[float] = None jahresbeitrag: Optional[float] = None
kontakt: Optional[str] = None kontakt: Optional[str] = Field(None, max_length=500)
ablaufdatum: Optional[str] = None ablaufdatum: Optional[str] = Field(None, max_length=32)
notizen: Optional[str] = None notizen: Optional[str] = Field(None, max_length=5000)
class InsuranceUpdate(BaseModel): class InsuranceUpdate(BaseModel):
anbieter: Optional[str] = None anbieter: Optional[str] = Field(None, max_length=200)
police_nr: Optional[str] = None police_nr: Optional[str] = Field(None, max_length=100)
jahresbeitrag: Optional[float] = None jahresbeitrag: Optional[float] = None
kontakt: Optional[str] = None kontakt: Optional[str] = Field(None, max_length=500)
ablaufdatum: Optional[str] = None ablaufdatum: Optional[str] = Field(None, max_length=32)
notizen: Optional[str] = None notizen: Optional[str] = Field(None, max_length=5000)
@router.get("/{dog_id}/insurance") @router.get("/{dog_id}/insurance")
@ -674,12 +674,12 @@ TRIGGER_LABELS = {
class BehaviorCreate(BaseModel): class BehaviorCreate(BaseModel):
datum: str datum: str = Field(..., max_length=32)
uhrzeit: Optional[str] = None uhrzeit: Optional[str] = Field(None, max_length=20)
kategorie: str kategorie: str = Field(..., max_length=50)
intensitaet: int = 3 intensitaet: int = 3
trigger: Optional[str] = None trigger: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=5000)
@router.get("/{dog_id}/behavior") @router.get("/{dog_id}/behavior")

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hilfe / FAQ Routes""" """BAN YARO — Hilfe / FAQ Routes"""
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user_optional, require_admin from auth import get_current_user_optional, require_admin
@ -31,17 +31,17 @@ def _load_active_help_articles() -> list[dict]:
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ArticleCreate(BaseModel): class ArticleCreate(BaseModel):
kategorie: str kategorie: str = Field(..., max_length=100)
frage: str frage: str = Field(..., min_length=3, max_length=500)
antwort: str antwort: str = Field(..., min_length=3, max_length=10000)
sort_order: int = 0 sort_order: int = 0
aktiv: int = 1 aktiv: int = 1
class ArticleUpdate(BaseModel): class ArticleUpdate(BaseModel):
kategorie: Optional[str] = None kategorie: Optional[str] = Field(None, max_length=100)
frage: Optional[str] = None frage: Optional[str] = Field(None, max_length=500)
antwort: Optional[str] = None antwort: Optional[str] = Field(None, max_length=10000)
sort_order: Optional[int] = None sort_order: Optional[int] = None
aktiv: Optional[int] = None aktiv: Optional[int] = None

View file

@ -6,7 +6,7 @@ from datetime import datetime
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import db from database import db
from auth import require_admin from auth import require_admin
import mailer import mailer
@ -19,30 +19,30 @@ logger = logging.getLogger(__name__)
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class InvoiceItem(BaseModel): class InvoiceItem(BaseModel):
description: str description: str = Field(..., max_length=500)
quantity: float = 1.0 quantity: float = 1.0
unit_price: float unit_price: float
class InvoiceCreate(BaseModel): class InvoiceCreate(BaseModel):
user_id: Optional[int] = None user_id: Optional[int] = None
recipient_name: str recipient_name: str = Field(..., max_length=200)
recipient_email: str recipient_email: str = Field(..., max_length=254)
recipient_address: Optional[str] = None recipient_address: Optional[str] = Field(None, max_length=500)
items: List[InvoiceItem] items: List[InvoiceItem]
discount_pct: Optional[float] = 0.0 discount_pct: Optional[float] = 0.0
service_period: Optional[str] = None service_period: Optional[str] = Field(None, max_length=200)
notes: Optional[str] = None notes: Optional[str] = Field(None, max_length=5000)
class PayBody(BaseModel): class PayBody(BaseModel):
paid_at: str paid_at: str = Field(..., max_length=32)
paid_amount: float paid_amount: float
notes: Optional[str] = None notes: Optional[str] = Field(None, max_length=2000)
class CancelBody(BaseModel): class CancelBody(BaseModel):
reason: str reason: str = Field(..., min_length=3, max_length=1000)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -1,6 +1,6 @@
"""BAN YARO — KI Routes""" """BAN YARO — KI Routes"""
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
import ki as ki_module import ki as ki_module
from auth import get_current_user from auth import get_current_user
@ -11,9 +11,9 @@ router = APIRouter()
class TrainingRequest(BaseModel): class TrainingRequest(BaseModel):
problem: str problem: str = Field(..., min_length=10, max_length=1000)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
alter: Optional[str] = None alter: Optional[str] = Field(None, max_length=50)
@router.post("/training") @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") rl_check(request, max_requests=10, window_seconds=3600, key="ki_training")
if not req.problem or len(req.problem.strip()) < 10: if not req.problem or len(req.problem.strip()) < 10:
raise HTTPException(400, "Bitte beschreibe das Problem genauer.") 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" rasse = req.rasse or "unbekannt"
alter = req.alter or "unbekannt" alter = req.alter or "unbekannt"
@ -69,10 +67,10 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
# POST /ki/tierarzt — KI-Tierarztfragen # POST /ki/tierarzt — KI-Tierarztfragen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class TierarztRequest(BaseModel): class TierarztRequest(BaseModel):
symptom: str symptom: str = Field(..., min_length=5, max_length=1000)
dog_id: Optional[int] = None dog_id: Optional[int] = None
dog_name: Optional[str] = None dog_name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
@router.post("/tierarzt") @router.post("/tierarzt")
@ -81,8 +79,6 @@ async def ki_tierarzt(req: TierarztRequest, request: Request,
"""KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung.""" """KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung."""
if not req.symptom or len(req.symptom.strip()) < 5: if not req.symptom or len(req.symptom.strip()) < 5:
raise HTTPException(400, "Bitte beschreibe das Symptom genauer.") 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 # Rate-Limit: max 5 Anfragen pro User pro Tag
with db() as conn: with db() as conn:
@ -173,10 +169,10 @@ def _log_rasse_request(user_id: int):
class BirthdayRequest(BaseModel): class BirthdayRequest(BaseModel):
dog_id: int dog_id: int
name: str name: str = Field(..., max_length=80)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
alter: Optional[int] = None alter: Optional[int] = None
mode: str = "tomorrow" # "tomorrow" | "today" mode: str = Field("tomorrow", max_length=20) # "tomorrow" | "today"
@router.post("/geburtstag") @router.post("/geburtstag")
async def ki_geburtstag(req: BirthdayRequest, request: Request, 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): class AbschiedRequest(BaseModel):
dog_id: int dog_id: int
name: str name: str = Field(..., max_length=80)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
km_total: Optional[float] = None km_total: Optional[float] = None
diary_count: Optional[int] = None diary_count: Optional[int] = None
gemeinsam_tage: 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") @router.post("/abschied")
async def ki_abschied(req: AbschiedRequest, request: Request, async def ki_abschied(req: AbschiedRequest, request: Request,

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hunde-Knigge Routes""" """BAN YARO — Hunde-Knigge Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional
@ -13,12 +13,12 @@ router = APIRouter()
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class VoteRequest(BaseModel): class VoteRequest(BaseModel):
szenario_id: str szenario_id: str = Field(..., max_length=100)
answer: str answer: str = Field(..., max_length=100)
class KiRatRequest(BaseModel): 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)""" """BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from datetime import date, timedelta from datetime import date, timedelta
@ -78,47 +78,47 @@ def _calc_meilensteine(deckdatum_str: str) -> list:
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class LaeufiCreate(BaseModel): class LaeufiCreate(BaseModel):
beginn: str beginn: str = Field(..., max_length=32)
ende: Optional[str] = None ende: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class LaeufiUpdate(BaseModel): class LaeufiUpdate(BaseModel):
beginn: Optional[str] = None beginn: Optional[str] = Field(None, max_length=32)
ende: Optional[str] = None ende: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class ProgestCreate(BaseModel): class ProgestCreate(BaseModel):
datum: str datum: str = Field(..., max_length=32)
wert: Optional[float] = None wert: Optional[float] = None
einheit: str = "ng/ml" einheit: str = Field("ng/ml", max_length=20)
labor: Optional[str] = None labor: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class ProgestUpdate(BaseModel): class ProgestUpdate(BaseModel):
datum: Optional[str] = None datum: Optional[str] = Field(None, max_length=32)
wert: Optional[float] = None wert: Optional[float] = None
einheit: Optional[str] = None einheit: Optional[str] = Field(None, max_length=20)
labor: Optional[str] = None labor: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class DeckCreate(BaseModel): class DeckCreate(BaseModel):
deckdatum: str deckdatum: str = Field(..., max_length=32)
laeufi_id: Optional[int] = None laeufi_id: Optional[int] = None
ruede_id: Optional[int] = None ruede_id: Optional[int] = None
ruede_name: Optional[str] = None ruede_name: Optional[str] = Field(None, max_length=200)
deckart: str = "natuerlich" deckart: str = Field("natuerlich", max_length=50)
traechtig: int = 0 traechtig: int = 0
ultraschall_datum: Optional[str] = None ultraschall_datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class DeckUpdate(BaseModel): class DeckUpdate(BaseModel):
deckdatum: Optional[str] = None deckdatum: Optional[str] = Field(None, max_length=32)
ruede_id: Optional[int] = None ruede_id: Optional[int] = None
ruede_name: Optional[str] = None ruede_name: Optional[str] = Field(None, max_length=200)
deckart: Optional[str] = None deckart: Optional[str] = Field(None, max_length=50)
traechtig: Optional[int] = None traechtig: Optional[int] = None
ultraschall_datum: Optional[str] = None ultraschall_datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -4,7 +4,7 @@ import logging
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
@ -27,68 +27,68 @@ def _require_breeder(user=Depends(get_current_user)):
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class LitterCreate(BaseModel): class LitterCreate(BaseModel):
wurf_rang: Optional[str] = None # A, B, C … wurf_rang: Optional[str] = Field(None, max_length=10) # A, B, C …
wurf_name: Optional[str] = None # z.B. "Vatertags-Wurf" wurf_name: Optional[str] = Field(None, max_length=200) # z.B. "Vatertags-Wurf"
vater_name: Optional[str] = None vater_name: Optional[str] = Field(None, max_length=200)
mutter_name: Optional[str] = None mutter_name: Optional[str] = Field(None, max_length=200)
vater_id: Optional[int] = None vater_id: Optional[int] = None
mutter_id: Optional[int] = None mutter_id: Optional[int] = None
geburt_datum: Optional[str] = None geburt_datum: Optional[str] = Field(None, max_length=32)
erwartetes_datum: Optional[str] = None erwartetes_datum: Optional[str] = Field(None, max_length=32)
welpen_gesamt: Optional[int] = None welpen_gesamt: Optional[int] = None
welpen_verfuegbar: Optional[int] = None welpen_verfuegbar: Optional[int] = None
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=10000)
gesundheitstests: Optional[str] = None gesundheitstests: Optional[str] = Field(None, max_length=5000)
preis_spanne: Optional[str] = None preis_spanne: Optional[str] = Field(None, max_length=100)
status: str = "geplant" status: str = Field("geplant", max_length=30)
sichtbar: int = 0 sichtbar: int = 0
sichtbar_bis: Optional[str] = None sichtbar_bis: Optional[str] = Field(None, max_length=32)
class LitterUpdate(BaseModel): class LitterUpdate(BaseModel):
wurf_rang: Optional[str] = None wurf_rang: Optional[str] = Field(None, max_length=10)
wurf_name: Optional[str] = None wurf_name: Optional[str] = Field(None, max_length=200)
vater_name: Optional[str] = None vater_name: Optional[str] = Field(None, max_length=200)
mutter_name: Optional[str] = None mutter_name: Optional[str] = Field(None, max_length=200)
vater_id: Optional[int] = None vater_id: Optional[int] = None
mutter_id: Optional[int] = None mutter_id: Optional[int] = None
geburt_datum: Optional[str] = None geburt_datum: Optional[str] = Field(None, max_length=32)
erwartetes_datum: Optional[str] = None erwartetes_datum: Optional[str] = Field(None, max_length=32)
welpen_gesamt: Optional[int] = None welpen_gesamt: Optional[int] = None
welpen_verfuegbar: Optional[int] = None welpen_verfuegbar: Optional[int] = None
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=10000)
gesundheitstests: Optional[str] = None gesundheitstests: Optional[str] = Field(None, max_length=5000)
preis_spanne: Optional[str] = None preis_spanne: Optional[str] = Field(None, max_length=100)
status: Optional[str] = None status: Optional[str] = Field(None, max_length=30)
sichtbar: Optional[int] = None sichtbar: Optional[int] = None
sichtbar_bis: Optional[str] = None sichtbar_bis: Optional[str] = Field(None, max_length=32)
class PuppyCreate(BaseModel): class PuppyCreate(BaseModel):
name: Optional[str] = None name: Optional[str] = Field(None, max_length=80)
geschlecht: Optional[str] = None # maennlich|weiblich geschlecht: Optional[str] = Field(None, max_length=20) # maennlich|weiblich
farbe: Optional[str] = None farbe: Optional[str] = Field(None, max_length=100)
chip_nr: Optional[str] = None chip_nr: Optional[str] = Field(None, max_length=50)
geburtsgewicht: Optional[float] = None # Gramm 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 status_sichtbar: int = 1
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class PuppyUpdate(BaseModel): class PuppyUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = Field(None, max_length=80)
geschlecht: Optional[str] = None geschlecht: Optional[str] = Field(None, max_length=20)
farbe: Optional[str] = None farbe: Optional[str] = Field(None, max_length=100)
chip_nr: Optional[str] = None chip_nr: Optional[str] = Field(None, max_length=50)
geburtsgewicht: Optional[float] = None geburtsgewicht: Optional[float] = None
status: Optional[str] = None status: Optional[str] = Field(None, max_length=30)
status_sichtbar: Optional[int] = None status_sichtbar: Optional[int] = None
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class WeightEntry(BaseModel): class WeightEntry(BaseModel):
gewicht_g: float 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 # Warteliste
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class WaitlistEntry(BaseModel): class WaitlistEntry(BaseModel):
name: str name: str = Field(..., min_length=1, max_length=200)
email: Optional[str] = None email: Optional[str] = Field(None, max_length=254)
telefon: Optional[str] = None telefon: Optional[str] = Field(None, max_length=30)
nachricht: Optional[str] = None nachricht: Optional[str] = Field(None, max_length=5000)
wunsch_geschlecht: str = "egal" wunsch_geschlecht: str = Field("egal", max_length=20)
wunsch_farbe: Optional[str] = None wunsch_farbe: Optional[str] = Field(None, max_length=100)
prioritaet: int = 0 prioritaet: int = 0
status: str = "anfrage" status: str = Field("anfrage", max_length=30)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class WaitlistUpdate(BaseModel): class WaitlistUpdate(BaseModel):

View file

@ -3,7 +3,7 @@
import os, uuid import os, uuid
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -20,13 +20,13 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class LostDogCreate(BaseModel): class LostDogCreate(BaseModel):
name: str name: str = Field(..., min_length=1, max_length=80)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
beschreibung: str beschreibung: str = Field(..., min_length=3, max_length=5000)
lat: float lat: float
lon: float lon: float
dog_id: Optional[int] = None 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""" """BAN YARO — Hunde-Filme Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from database import db from database import db
@ -207,31 +207,31 @@ class HundDesMonatsVoteRequest(BaseModel):
dog_id: int dog_id: int
class MovieCreate(BaseModel): class MovieCreate(BaseModel):
id: str id: str = Field(..., max_length=100)
titel: str titel: str = Field(..., min_length=1, max_length=200)
originaltitel: Optional[str] = None originaltitel: Optional[str] = Field(None, max_length=200)
jahr: Optional[int] = None jahr: Optional[int] = None
genre: Optional[str] = None genre: Optional[str] = Field(None, max_length=100)
typ: str = "film" typ: str = Field("film", max_length=30)
hund_rasse: Optional[str] = None hund_rasse: Optional[str] = Field(None, max_length=200)
stirbt_der_hund: bool = False stirbt_der_hund: bool = False
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
bild_emoji: str = "🐾" bild_emoji: str = Field("🐾", max_length=10)
imdb_rating: Optional[float] = None imdb_rating: Optional[float] = None
streaming: Optional[str] = None streaming: Optional[str] = Field(None, max_length=500)
class MovieUpdate(BaseModel): class MovieUpdate(BaseModel):
titel: Optional[str] = None titel: Optional[str] = Field(None, max_length=200)
originaltitel: Optional[str] = None originaltitel: Optional[str] = Field(None, max_length=200)
jahr: Optional[int] = None jahr: Optional[int] = None
genre: Optional[str] = None genre: Optional[str] = Field(None, max_length=100)
typ: Optional[str] = None typ: Optional[str] = Field(None, max_length=30)
hund_rasse: Optional[str] = None hund_rasse: Optional[str] = Field(None, max_length=200)
stirbt_der_hund: Optional[bool] = None stirbt_der_hund: Optional[bool] = None
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
bild_emoji: Optional[str] = None bild_emoji: Optional[str] = Field(None, max_length=10)
imdb_rating: Optional[float] = None 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 import logging
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, Any, List from typing import Optional, Any, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -18,18 +18,18 @@ logger = logging.getLogger(__name__)
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class NoteCreate(BaseModel): class NoteCreate(BaseModel):
text: str text: str = Field(..., min_length=1, max_length=5000)
meta_json: Optional[Any] = None meta_json: Optional[Any] = None
location_name: Optional[str] = None location_name: Optional[str] = Field(None, max_length=300)
parent_label: Optional[str] = None parent_label: Optional[str] = Field(None, max_length=200)
client_time: Optional[str] = None client_time: Optional[str] = Field(None, max_length=64)
class NoteUpdate(BaseModel): class NoteUpdate(BaseModel):
text: Optional[str] = None text: Optional[str] = Field(None, max_length=5000)
meta_json: Optional[Any] = None meta_json: Optional[Any] = None
location_name: Optional[str] = None location_name: Optional[str] = Field(None, max_length=300)
parent_label: Optional[str] = None parent_label: Optional[str] = Field(None, max_length=200)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -9,7 +9,7 @@ import httpx
import logging import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import db from database import db
from auth import get_current_user, get_current_user_optional as get_optional_user 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 # POST /user-poi — Community-Marker setzen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class UserPoiIn(BaseModel): class UserPoiIn(BaseModel):
type: str type: str = Field(..., max_length=200)
lat: float lat: float
lon: float lon: float
name: Optional[str] = None name: Optional[str] = Field(None, max_length=300)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
ALLOWED_TYPES = { ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park', '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 # POST /report — Marker als ungültig melden
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ReportIn(BaseModel): class ReportIn(BaseModel):
type: str type: str = Field(..., max_length=100)
grund: str grund: str = Field(..., max_length=200)
osm_id: Optional[int] = None osm_id: Optional[int] = None
user_poi_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 # POST /pois/{osm_id}/edit — Nutzer schlägt Korrektur vor
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PoiEditCreate(BaseModel): class PoiEditCreate(BaseModel):
poi_name: str poi_name: str = Field(..., max_length=300)
field: str = 'opening_hours' field: str = Field('opening_hours', max_length=50)
new_value: str new_value: str = Field(..., max_length=1000)
@router.post('/pois/{osm_id}/edit', status_code=201) @router.post('/pois/{osm_id}/edit', status_code=201)

View file

@ -13,7 +13,7 @@ from typing import List, Optional
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from auth import require_admin from auth import require_admin
from database import db from database import db
@ -135,26 +135,26 @@ def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class TemplateIn(BaseModel): class TemplateIn(BaseModel):
key: str key: str = Field(..., max_length=100)
label: str label: str = Field(..., max_length=200)
subject: str subject: str = Field(..., max_length=500)
body: str body: str = Field(..., max_length=50000)
from_account: str = "partner" from_account: str = Field("partner", max_length=50)
class TemplateUpdate(BaseModel): class TemplateUpdate(BaseModel):
label: str label: str = Field(..., max_length=200)
subject: str subject: str = Field(..., max_length=500)
body: str body: str = Field(..., max_length=50000)
from_account: str = "partner" from_account: str = Field("partner", max_length=50)
class SendRequest(BaseModel): class SendRequest(BaseModel):
to: List[str] to: List[str]
subject: str subject: str = Field(..., max_length=500)
body: str body: str = Field(..., max_length=50000)
from_account: str = "partner" from_account: str = Field("partner", max_length=50)
template_id: Optional[int] = None template_id: Optional[int] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -2,7 +2,7 @@
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import db from database import db
from auth import require_admin, get_current_user from auth import require_admin, get_current_user
@ -10,8 +10,8 @@ router = APIRouter()
class PartnerCodeCreate(BaseModel): class PartnerCodeCreate(BaseModel):
code: str code: str = Field(..., min_length=1, max_length=50)
label: str label: str = Field(..., min_length=1, max_length=200)
grants_founder: int = 1 grants_founder: int = 1
max_uses: Optional[int] = None max_uses: Optional[int] = None

View file

@ -5,7 +5,7 @@ import secrets
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -17,25 +17,25 @@ router = APIRouter()
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PassportMeta(BaseModel): class PassportMeta(BaseModel):
blutgruppe: Optional[str] = None blutgruppe: Optional[str] = Field(None, max_length=50)
allergien: Optional[str] = None allergien: Optional[str] = Field(None, max_length=2000)
besonderheiten: Optional[str] = None besonderheiten: Optional[str] = Field(None, max_length=2000)
class VaccinationCreate(BaseModel): class VaccinationCreate(BaseModel):
krankheit: str krankheit: str = Field(..., max_length=200)
datum: str datum: str = Field(..., max_length=32)
naechste: Optional[str] = None naechste: Optional[str] = Field(None, max_length=32)
tierarzt: Optional[str] = None tierarzt: Optional[str] = Field(None, max_length=200)
charge_nr: Optional[str] = None charge_nr: Optional[str] = Field(None, max_length=100)
class MedicationCreate(BaseModel): class MedicationCreate(BaseModel):
name: str name: str = Field(..., max_length=200)
dosierung: Optional[str] = None dosierung: Optional[str] = Field(None, max_length=200)
von: Optional[str] = None von: Optional[str] = Field(None, max_length=32)
bis: Optional[str] = None bis: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hundefreundliche Orte""" """BAN YARO — Hundefreundliche Orte"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user, require_owner from auth import get_current_user, require_owner
@ -16,25 +16,25 @@ TYPEN = {'restaurant', 'shop', 'freilauf', 'kotbeutel', 'tierarzt', 'hundesalon'
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PlaceCreate(BaseModel): class PlaceCreate(BaseModel):
name: str name: str = Field(..., min_length=1, max_length=200)
typ: str typ: str = Field(..., max_length=50)
lat: float lat: float
lon: float lon: float
adresse: Optional[str] = None adresse: Optional[str] = Field(None, max_length=300)
website: Optional[str] = None website: Optional[str] = Field(None, max_length=500)
telefon: Optional[str] = None telefon: Optional[str] = Field(None, max_length=30)
hund_rein: Optional[bool] = None hund_rein: Optional[bool] = None
leine_pflicht: Optional[bool] = None leine_pflicht: Optional[bool] = None
wasser_fuer_hunde: Optional[bool] = None wasser_fuer_hunde: Optional[bool] = None
class PlaceUpdate(BaseModel): class PlaceUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = Field(None, max_length=200)
typ: Optional[str] = None typ: Optional[str] = Field(None, max_length=50)
lat: Optional[float]= None lat: Optional[float]= None
lon: Optional[float]= None lon: Optional[float]= None
adresse: Optional[str] = None adresse: Optional[str] = Field(None, max_length=300)
website: Optional[str] = None website: Optional[str] = Field(None, max_length=500)
telefon: Optional[str] = None telefon: Optional[str] = Field(None, max_length=30)
hund_rein: Optional[bool] = None hund_rein: Optional[bool] = None
leine_pflicht: Optional[bool] = None leine_pflicht: Optional[bool] = None
wasser_fuer_hunde: Optional[bool] = None wasser_fuer_hunde: Optional[bool] = None

View file

@ -2,7 +2,7 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -40,18 +40,18 @@ class ListingUpsert(BaseModel):
dog_id: int dog_id: int
lat: float lat: float
lon: float lon: float
ort_name: Optional[str] = None ort_name: Optional[str] = Field(None, max_length=300)
radius_km: int = 10 radius_km: int = 10
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=2000)
class RequestCreate(BaseModel): class RequestCreate(BaseModel):
to_dog_id: int to_dog_id: int
nachricht: Optional[str] = None nachricht: Optional[str] = Field(None, max_length=2000)
class RequestPatch(BaseModel): class RequestPatch(BaseModel):
status: str # accepted | declined status: str = Field(..., max_length=30) # accepted | declined
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -3,7 +3,7 @@
import os, uuid import os, uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -22,12 +22,12 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class PoisonCreate(BaseModel): class PoisonCreate(BaseModel):
lat: float lat: float
lon: float lon: float
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=2000)
typ: str = "unbekannt" typ: str = Field("unbekannt", max_length=50)
class PoisonResolve(BaseModel): 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 typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from auth import get_current_user from auth import get_current_user
from database import db from database import db
@ -20,17 +20,17 @@ VALID_SICHTBARKEIT = {"public", "friends", "private"}
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
real_name: Optional[str] = None real_name: Optional[str] = Field(None, max_length=100)
bio: Optional[str] = None bio: Optional[str] = Field(None, max_length=300)
wohnort: Optional[str] = None wohnort: Optional[str] = Field(None, max_length=60)
erfahrung: Optional[str] = None erfahrung: Optional[str] = Field(None, max_length=30)
social_link: Optional[str] = None social_link: Optional[str] = Field(None, max_length=120)
profil_sichtbarkeit: Optional[str] = None profil_sichtbarkeit: Optional[str] = Field(None, max_length=30)
notes_ki_enabled: Optional[int] = None notes_ki_enabled: Optional[int] = None
gassi_stunde_push: Optional[int] = None gassi_stunde_push: Optional[int] = None
preferred_theme: Optional[str] = None preferred_theme: Optional[str] = Field(None, max_length=20)
billing_address: Optional[str] = None billing_address: Optional[str] = Field(None, max_length=500)
geburtstag: Optional[str] = None geburtstag: Optional[str] = Field(None, max_length=10)
def _load_user(user_id: int) -> dict: 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.") 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"): 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.") raise HTTPException(400, "preferred_theme muss 'system', 'light' oder 'dark' sein.")
if "bio" in fields and len(fields["bio"]) > 300: # Längen-Begrenzungen sind jetzt via Field max_length im Schema abgedeckt.
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.")
if "geburtstag" in fields and fields["geburtstag"]: if "geburtstag" in fields and fields["geburtstag"]:
if not re.fullmatch(r"\d{2}\.\d{2}", 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).") 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 json
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from pywebpush import webpush, WebPushException from pywebpush import webpush, WebPushException
@ -33,7 +33,7 @@ async def get_vapid_key():
# POST /api/push/subscribe — Subscription speichern # POST /api/push/subscribe — Subscription speichern
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PushSubscription(BaseModel): class PushSubscription(BaseModel):
endpoint: str endpoint: str = Field(..., max_length=2000)
keys: dict # { p256dh, auth } keys: dict # { p256dh, auth }
expirationTime: Optional[int] = None expirationTime: Optional[int] = None

View file

@ -1,7 +1,7 @@
"""BAN YARO — Bewertungssystem (Ratings)""" """BAN YARO — Bewertungssystem (Ratings)"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -23,10 +23,10 @@ TABLE_MAP = {
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class RatingCreate(BaseModel): class RatingCreate(BaseModel):
target_type: str target_type: str = Field(..., max_length=50)
target_id: int target_id: int
stars: 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 httpx
import polyline as _polyline import polyline as _polyline
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional
@ -37,29 +37,29 @@ class GPSPoint(BaseModel):
alt: Optional[float] = None alt: Optional[float] = None
class RouteCreate(BaseModel): class RouteCreate(BaseModel):
name: str name: str = Field(..., min_length=1, max_length=200)
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
gps_track: List[GPSPoint] gps_track: List[GPSPoint]
distanz_km: Optional[float] = None distanz_km: Optional[float] = None
dauer_min: Optional[int] = None dauer_min: Optional[int] = None
schwierigkeit: Optional[str] = "leicht" # leicht | mittel | anspruchsvoll schwierigkeit: Optional[str] = Field("leicht", max_length=30) # leicht | mittel | anspruchsvoll
untergrund: Optional[str] = None # wald | asphalt | wiese | mix untergrund: Optional[str] = Field(None, max_length=50) # wald | asphalt | wiese | mix
schatten: Optional[bool] = None schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = False is_public: Optional[bool] = False
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) # eingeschränkt | gut | sehr_gut | premium
client_time: Optional[str] = None client_time: Optional[str] = Field(None, max_length=64)
dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind
class RouteUpdate(BaseModel): class RouteUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = Field(None, max_length=200)
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
schwierigkeit: Optional[str] = None schwierigkeit: Optional[str] = Field(None, max_length=30)
untergrund: Optional[str] = None untergrund: Optional[str] = Field(None, max_length=50)
schatten: Optional[bool] = None schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None leine_empfohlen: Optional[bool] = None
is_public: 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): class RouteDogs(BaseModel):
dog_ids: List[int] dog_ids: List[int]
@ -553,7 +553,7 @@ async def add_route_photo(
# POST /api/routes/{id}/feedback — Feedback an Route-Ersteller # POST /api/routes/{id}/feedback — Feedback an Route-Ersteller
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class RouteFeedback(BaseModel): class RouteFeedback(BaseModel):
text: str text: str = Field(..., min_length=5, max_length=2000)
@router.post("/{route_id}/feedback", status_code=201) @router.post("/{route_id}/feedback", status_code=201)
async def route_feedback(route_id: int, data: RouteFeedback, user=Depends(get_current_user)): 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)""" """BAN YARO — Service-Angebote (Sitting & Walks Matching)"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -16,8 +16,8 @@ ALLOWED_TYPES = {'sitting', 'walks'}
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ServiceCreate(BaseModel): class ServiceCreate(BaseModel):
type: str type: str = Field(..., max_length=30)
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
preis_pro_tag: Optional[float] = None preis_pro_tag: Optional[float] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None

View file

@ -2,7 +2,7 @@
import secrets import secrets
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -14,7 +14,7 @@ share_router = APIRouter()
class ShareInvite(BaseModel): 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 import json
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -17,7 +17,7 @@ SERVICES = {'tagesbetreuung', 'uebernachtung', 'gassi', 'hausbesuch'}
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class SitterCreate(BaseModel): class SitterCreate(BaseModel):
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
preis_pro_tag: float = 0 preis_pro_tag: float = 0
max_hunde: int = 1 max_hunde: int = 1
lat: Optional[float] = None lat: Optional[float] = None
@ -26,7 +26,7 @@ class SitterCreate(BaseModel):
services: List[str] = [] services: List[str] = []
class SitterUpdate(BaseModel): class SitterUpdate(BaseModel):
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
preis_pro_tag: Optional[float] = None preis_pro_tag: Optional[float] = None
max_hunde: Optional[int] = None max_hunde: Optional[int] = None
lat: Optional[float] = None lat: Optional[float] = None
@ -38,12 +38,12 @@ class SitterUpdate(BaseModel):
class RequestCreate(BaseModel): class RequestCreate(BaseModel):
sitter_id: int sitter_id: int
dog_ids: List[int] = [] dog_ids: List[int] = []
von: str # YYYY-MM-DD von: str = Field(..., max_length=32) # YYYY-MM-DD
bis: str bis: str = Field(..., max_length=32)
nachricht: Optional[str] = None nachricht: Optional[str] = Field(None, max_length=2000)
class RequestUpdate(BaseModel): 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)""" """BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -11,7 +11,7 @@ router = APIRouter()
class AccessCreate(BaseModel): class AccessCreate(BaseModel):
dog_id: int dog_id: int
sitter_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) @router.post("", status_code=201)

View file

@ -9,7 +9,7 @@ import random
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File 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 auth import get_current_user, require_social_media
from database import db from database import db
@ -849,24 +849,24 @@ Antworte NUR mit einem JSON-Objekt:
class GenerateRequest(BaseModel): class GenerateRequest(BaseModel):
platform: str = "both" platform: str = Field("both", max_length=30)
format: str = "post" format: str = Field("post", max_length=30)
topic: str topic: str = Field(..., min_length=2, max_length=500)
breed_id: Optional[int] = None breed_id: Optional[int] = None
class EvaluateRequest(BaseModel): class EvaluateRequest(BaseModel):
platform: str = "instagram" platform: str = Field("instagram", max_length=30)
format: str = "post" format: str = Field("post", max_length=30)
draft: str draft: str = Field(..., min_length=1, max_length=10000)
class StatusUpdate(BaseModel): class StatusUpdate(BaseModel):
status: Optional[str] = None status: Optional[str] = Field(None, max_length=50)
scheduled_at: Optional[str] = None scheduled_at: Optional[str] = Field(None, max_length=64)
published_at: Optional[str] = None published_at: Optional[str] = Field(None, max_length=64)
notes: Optional[str] = None notes: Optional[str] = Field(None, max_length=5000)
post_url: Optional[str] = None post_url: Optional[str] = Field(None, max_length=500)
def _used_topics(limit: int = 30) -> str: def _used_topics(limit: int = 30) -> str:

View file

@ -2,7 +2,7 @@
import math import math
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -11,20 +11,20 @@ router = APIRouter()
class TierarztCreate(BaseModel): class TierarztCreate(BaseModel):
name: str name: str = Field(..., min_length=1, max_length=200)
strasse: Optional[str] = None strasse: Optional[str] = Field(None, max_length=300)
plz: Optional[str] = None plz: Optional[str] = Field(None, max_length=20)
ort: Optional[str] = None ort: Optional[str] = Field(None, max_length=200)
telefon: Optional[str] = None telefon: Optional[str] = Field(None, max_length=30)
notfall_telefon: Optional[str] = None notfall_telefon: Optional[str] = Field(None, max_length=30)
email: Optional[str] = None email: Optional[str] = Field(None, max_length=254)
website: Optional[str] = None website: Optional[str] = Field(None, max_length=500)
notizen: Optional[str] = None notizen: Optional[str] = Field(None, max_length=5000)
ist_notfallpraxis: bool = False ist_notfallpraxis: bool = False
opening_hours: Optional[str] = None opening_hours: Optional[str] = Field(None, max_length=500)
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
osm_id: Optional[str] = None osm_id: Optional[str] = Field(None, max_length=100)
class BewertungCreate(BaseModel): class BewertungCreate(BaseModel):
@ -32,25 +32,25 @@ class BewertungCreate(BaseModel):
wartezeit: Optional[int] = None wartezeit: Optional[int] = None
freundlichkeit: Optional[int] = None freundlichkeit: Optional[int] = None
kompetenz: Optional[int] = None kompetenz: Optional[int] = None
text: Optional[str] = None text: Optional[str] = Field(None, max_length=5000)
class TierarztUpdate(BaseModel): class TierarztUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = Field(None, max_length=200)
strasse: Optional[str] = None strasse: Optional[str] = Field(None, max_length=300)
plz: Optional[str] = None plz: Optional[str] = Field(None, max_length=20)
ort: Optional[str] = None ort: Optional[str] = Field(None, max_length=200)
telefon: Optional[str] = None telefon: Optional[str] = Field(None, max_length=30)
notfall_telefon: Optional[str] = None notfall_telefon: Optional[str] = Field(None, max_length=30)
email: Optional[str] = None email: Optional[str] = Field(None, max_length=254)
website: Optional[str] = None website: Optional[str] = Field(None, max_length=500)
notizen: Optional[str] = None notizen: Optional[str] = Field(None, max_length=5000)
ist_notfallpraxis: Optional[bool] = None ist_notfallpraxis: Optional[bool] = None
aktiv: 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 lat: Optional[float] = None
lon: 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: def _fmt_opening_hours(raw: str | None) -> str | None:

View file

@ -1,7 +1,7 @@
"""BAN YARO — Übungs- & Trainingsfortschritt""" """BAN YARO — Übungs- & Trainingsfortschritt"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
import datetime import datetime
import ki import ki
@ -61,9 +61,9 @@ async def get_exercises():
# Admin: Übung bearbeiten (beschreibung / schritte / tipp) # Admin: Übung bearbeiten (beschreibung / schritte / tipp)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ExerciseUpdate(BaseModel): class ExerciseUpdate(BaseModel):
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=10000)
schritte: Optional[str] = None # JSON-String: '["Schritt 1", ...]' schritte: Optional[str] = Field(None, max_length=10000) # JSON-String: '["Schritt 1", ...]'
tipp: Optional[str] = None tipp: Optional[str] = Field(None, max_length=5000)
@router.put("/exercises/{exercise_id}") @router.put("/exercises/{exercise_id}")
async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(require_admin)): async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(require_admin)):
@ -93,9 +93,9 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ
# Übungs-Status # Übungs-Status
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ProgressUpdate(BaseModel): class ProgressUpdate(BaseModel):
exercise_id: str exercise_id: str = Field(..., max_length=200)
status: Optional[str] = None status: Optional[str] = Field(None, max_length=50)
dog_id: Optional[int] = None dog_id: Optional[int] = None
@router.get("/progress") @router.get("/progress")
async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)): async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
@ -137,9 +137,9 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
# Trainingsplan-Checkboxen # Trainingsplan-Checkboxen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PlanProgress(BaseModel): class PlanProgress(BaseModel):
item_key: str item_key: str = Field(..., max_length=200)
checked: bool checked: bool
dog_id: Optional[int] = None dog_id: Optional[int] = None
@router.get("/plan-progress") @router.get("/plan-progress")
async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)): async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
@ -327,15 +327,15 @@ def _check_badges(conn, user_id: int, dog_name: str) -> list:
class SessionCreate(BaseModel): class SessionCreate(BaseModel):
dog_id: int dog_id: int
exercise_id: str exercise_id: str = Field(..., max_length=200)
exercise_name: str exercise_name: str = Field(..., max_length=200)
datum: Optional[str] = None datum: Optional[str] = Field(None, max_length=32)
wiederholungen: int = 1 wiederholungen: int = 1
erfolgsquote: int = 50 erfolgsquote: int = 50
hund_stimmung: Optional[str] = "aufmerksam" hund_stimmung: Optional[str] = Field("aufmerksam", max_length=50)
zufriedenheit: Optional[int] = 3 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 tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll
@router.post("/sessions") @router.post("/sessions")

View file

@ -4,7 +4,7 @@ import os, uuid
import httpx import httpx
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -20,24 +20,24 @@ router = APIRouter()
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class WalkCreate(BaseModel): class WalkCreate(BaseModel):
titel: str titel: str = Field(..., min_length=1, max_length=200)
datum: str # YYYY-MM-DD datum: str = Field(..., max_length=32) # YYYY-MM-DD
uhrzeit: str # HH:MM uhrzeit: str = Field(..., max_length=20) # HH:MM
lat: float lat: float
lon: float lon: float
ort_name: Optional[str] = None ort_name: Optional[str] = Field(None, max_length=300)
max_teilnehmer: int = 10 max_teilnehmer: int = 10
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
class WalkUpdate(BaseModel): class WalkUpdate(BaseModel):
titel: Optional[str] = None titel: Optional[str] = Field(None, max_length=200)
datum: Optional[str] = None datum: Optional[str] = Field(None, max_length=32)
uhrzeit: Optional[str] = None uhrzeit: Optional[str] = Field(None, max_length=20)
lat: Optional[float] = None lat: Optional[float] = None
lon: 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 max_teilnehmer: Optional[int] = None
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
class JoinRequest(BaseModel): class JoinRequest(BaseModel):
dog_ids: List[int] = [] # leere Liste = ohne Hund (selten) dog_ids: List[int] = [] # leere Liste = ohne Hund (selten)
@ -46,7 +46,7 @@ class InviteRequest(BaseModel):
friend_id: int friend_id: int
class RsvpRequest(BaseModel): 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 import logging
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import db from database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional
from ratelimit import check as rl_check, block_ip from ratelimit import check as rl_check, block_ip
@ -36,9 +36,9 @@ async def honeypot(request: Request):
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class BerichtCreate(BaseModel): class BerichtCreate(BaseModel):
rasse: str rasse: str = Field(..., max_length=100)
titel: str titel: str = Field(..., min_length=3, max_length=200)
text: str 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 # PATCH /api/wiki/foto-submissions/{id} — genehmigen oder ablehnen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ReviewModel(BaseModel): class ReviewModel(BaseModel):
action: str # "approve" | "reject" action: str = Field(..., max_length=30) # "approve" | "reject"
reject_reason: str = "" reject_reason: str = Field("", max_length=2000)
@router.patch("/foto-submissions/{sub_id}") @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 # Schemas für Interesse und Züchter
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class InteresseCreate(BaseModel): class InteresseCreate(BaseModel):
typ: str # "hat" oder "will" typ: str = Field(..., max_length=30) # "hat" oder "will"
class ZuchterCreate(BaseModel): class ZuchterCreate(BaseModel):
rasse_slug: str rasse_slug: str = Field(..., max_length=100)
name: str name: str = Field(..., min_length=1, max_length=200)
zwingername: str = "" zwingername: str = Field("", max_length=200)
ort: str = "" ort: str = Field("", max_length=200)
plz: str = "" plz: str = Field("", max_length=20)
bundesland: str = "" bundesland: str = Field("", max_length=100)
vdh_mitglied: int = 0 vdh_mitglied: int = 0
website: str = "" website: str = Field("", max_length=500)
telefon: str = "" telefon: str = Field("", max_length=30)
beschreibung: str = "" beschreibung: str = Field("", max_length=10000)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -2,7 +2,7 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
@ -134,108 +134,108 @@ def _ik_rating(ik: float) -> str:
# Pydantic-Schemas # Pydantic-Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class HundCreate(BaseModel): class HundCreate(BaseModel):
name: str name: str = Field(..., min_length=1, max_length=200)
rufname: Optional[str] = None rufname: Optional[str] = Field(None, max_length=80)
geschlecht: str # maennlich|weiblich geschlecht: str = Field(..., max_length=20) # maennlich|weiblich
geburtsdatum: Optional[str] = None geburtsdatum: Optional[str] = Field(None, max_length=32)
sterbedatum: Optional[str] = None sterbedatum: Optional[str] = Field(None, max_length=32)
chip_nr: Optional[str] = None chip_nr: Optional[str] = Field(None, max_length=50)
taetowiernummer: Optional[str] = None taetowiernummer: Optional[str] = Field(None, max_length=50)
zuchtbuchnummer: Optional[str] = None zuchtbuchnummer: Optional[str] = Field(None, max_length=100)
farbe: Optional[str] = None farbe: Optional[str] = Field(None, max_length=100)
vater_id: Optional[int] = None vater_id: Optional[int] = None
mutter_id: Optional[int] = None mutter_id: Optional[int] = None
zuechter_name: Optional[str] = None zuechter_name: Optional[str] = Field(None, max_length=200)
eigentuemer_name: Optional[str] = None eigentuemer_name: Optional[str] = Field(None, max_length=200)
is_public: int = 1 is_public: int = 1
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=5000)
foto_url: Optional[str] = None foto_url: Optional[str] = Field(None, max_length=500)
class HundUpdate(BaseModel): class HundUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = Field(None, max_length=200)
rufname: Optional[str] = None rufname: Optional[str] = Field(None, max_length=80)
geschlecht: Optional[str] = None geschlecht: Optional[str] = Field(None, max_length=20)
geburtsdatum: Optional[str] = None geburtsdatum: Optional[str] = Field(None, max_length=32)
sterbedatum: Optional[str] = None sterbedatum: Optional[str] = Field(None, max_length=32)
chip_nr: Optional[str] = None chip_nr: Optional[str] = Field(None, max_length=50)
taetowiernummer: Optional[str] = None taetowiernummer: Optional[str] = Field(None, max_length=50)
zuchtbuchnummer: Optional[str] = None zuchtbuchnummer: Optional[str] = Field(None, max_length=100)
farbe: Optional[str] = None farbe: Optional[str] = Field(None, max_length=100)
vater_id: Optional[int] = None vater_id: Optional[int] = None
mutter_id: Optional[int] = None mutter_id: Optional[int] = None
zuechter_name: Optional[str] = None zuechter_name: Optional[str] = Field(None, max_length=200)
eigentuemer_name: Optional[str] = None eigentuemer_name: Optional[str] = Field(None, max_length=200)
is_public: Optional[int] = None is_public: Optional[int] = None
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=5000)
foto_url: Optional[str] = None foto_url: Optional[str] = Field(None, max_length=500)
class HealthTestCreate(BaseModel): class HealthTestCreate(BaseModel):
test_typ: str # HD|ED|OCD|augen|herz|patella|ZTP|custom test_typ: str = Field(..., max_length=50) # HD|ED|OCD|augen|herz|patella|ZTP|custom
test_name: Optional[str] = None test_name: Optional[str] = Field(None, max_length=200)
ergebnis: Optional[str] = None ergebnis: Optional[str] = Field(None, max_length=500)
untersuch_am: Optional[str] = None untersuch_am: Optional[str] = Field(None, max_length=32)
gueltig_bis: Optional[str] = None gueltig_bis: Optional[str] = Field(None, max_length=32)
untersucher: Optional[str] = None untersucher: Optional[str] = Field(None, max_length=200)
labor: Optional[str] = None labor: Optional[str] = Field(None, max_length=200)
zertifikat_nr: Optional[str] = None zertifikat_nr: Optional[str] = Field(None, max_length=100)
is_public: int = 1 is_public: int = 1
class HealthTestUpdate(BaseModel): class HealthTestUpdate(BaseModel):
test_typ: Optional[str] = None test_typ: Optional[str] = Field(None, max_length=50)
test_name: Optional[str] = None test_name: Optional[str] = Field(None, max_length=200)
ergebnis: Optional[str] = None ergebnis: Optional[str] = Field(None, max_length=500)
untersuch_am: Optional[str] = None untersuch_am: Optional[str] = Field(None, max_length=32)
gueltig_bis: Optional[str] = None gueltig_bis: Optional[str] = Field(None, max_length=32)
untersucher: Optional[str] = None untersucher: Optional[str] = Field(None, max_length=200)
labor: Optional[str] = None labor: Optional[str] = Field(None, max_length=200)
zertifikat_nr: Optional[str] = None zertifikat_nr: Optional[str] = Field(None, max_length=100)
is_public: Optional[int] = None is_public: Optional[int] = None
class GeneticTestCreate(BaseModel): class GeneticTestCreate(BaseModel):
marker_name: str # MDR1|PRA-prcd|DM|vWD|HUU etc. marker_name: str = Field(..., max_length=100) # MDR1|PRA-prcd|DM|vWD|HUU etc.
marker_kategorie: Optional[str] = None # krankheit|farbe|eigenschaft marker_kategorie: Optional[str] = Field(None, max_length=50) # krankheit|farbe|eigenschaft
genotyp: Optional[str] = None # +/+|+/-|-/- genotyp: Optional[str] = Field(None, max_length=20) # +/+|+/-|-/-
ergebnis_klasse: Optional[str] = None # clear|carrier|affected ergebnis_klasse: Optional[str] = Field(None, max_length=50) # clear|carrier|affected
getestet_am: Optional[str] = None getestet_am: Optional[str] = Field(None, max_length=32)
labor: Optional[str] = None labor: Optional[str] = Field(None, max_length=200)
zertifikat_nr: Optional[str] = None zertifikat_nr: Optional[str] = Field(None, max_length=100)
is_public: int = 1 is_public: int = 1
class GeneticTestUpdate(BaseModel): class GeneticTestUpdate(BaseModel):
marker_name: Optional[str] = None marker_name: Optional[str] = Field(None, max_length=100)
marker_kategorie: Optional[str] = None marker_kategorie: Optional[str] = Field(None, max_length=50)
genotyp: Optional[str] = None genotyp: Optional[str] = Field(None, max_length=20)
ergebnis_klasse: Optional[str] = None ergebnis_klasse: Optional[str] = Field(None, max_length=50)
getestet_am: Optional[str] = None getestet_am: Optional[str] = Field(None, max_length=32)
labor: Optional[str] = None labor: Optional[str] = Field(None, max_length=200)
zertifikat_nr: Optional[str] = None zertifikat_nr: Optional[str] = Field(None, max_length=100)
is_public: Optional[int] = None is_public: Optional[int] = None
class TitelCreate(BaseModel): class TitelCreate(BaseModel):
titel_typ: str # ausstellung|arbeit|sport|zucht|champion|custom titel_typ: str = Field(..., max_length=50) # ausstellung|arbeit|sport|zucht|champion|custom
titel_name: str titel_name: str = Field(..., min_length=1, max_length=200)
verliehen_am: Optional[str] = None verliehen_am: Optional[str] = Field(None, max_length=32)
ort: Optional[str] = None ort: Optional[str] = Field(None, max_length=200)
richter: Optional[str] = None richter: Optional[str] = Field(None, max_length=200)
ausstellung: Optional[str] = None ausstellung: Optional[str] = Field(None, max_length=200)
formwert: Optional[str] = None formwert: Optional[str] = Field(None, max_length=100)
is_public: int = 1 is_public: int = 1
class TitelUpdate(BaseModel): class TitelUpdate(BaseModel):
titel_typ: Optional[str] = None titel_typ: Optional[str] = Field(None, max_length=50)
titel_name: Optional[str] = None titel_name: Optional[str] = Field(None, max_length=200)
verliehen_am: Optional[str] = None verliehen_am: Optional[str] = Field(None, max_length=32)
ort: Optional[str] = None ort: Optional[str] = Field(None, max_length=200)
richter: Optional[str] = None richter: Optional[str] = Field(None, max_length=200)
ausstellung: Optional[str] = None ausstellung: Optional[str] = Field(None, max_length=200)
formwert: Optional[str] = None formwert: Optional[str] = Field(None, max_length=100)
is_public: Optional[int] = None is_public: Optional[int] = None

View file

@ -3,7 +3,7 @@
import logging import logging
from datetime import date, timedelta from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, Literal from typing import Optional, Literal
from database import db from database import db
@ -41,7 +41,7 @@ class PaarungAnalyseBody(BaseModel):
vater_id: int vater_id: int
mutter_id: int mutter_id: int
ik_prozent: Optional[float] = None ik_prozent: Optional[float] = None
welfare_level: Optional[str] = None welfare_level: Optional[str] = Field(None, max_length=50)
class HundBeschreibungBody(BaseModel): class HundBeschreibungBody(BaseModel):

View file

@ -34,7 +34,7 @@
/* Text — Warmbraun aus dem Halsband */ /* Text — Warmbraun aus dem Halsband */
--c-text: #2A1F14; --c-text: #2A1F14;
--c-text-secondary: #7A6A58; --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; --c-text-inverse: #FAF7F2;
/* Funktionsfarben */ /* Funktionsfarben */
@ -179,7 +179,7 @@
--c-text: #F0EAE0; --c-text: #F0EAE0;
--c-text-secondary: #C0B0A0; --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; --c-text-inverse: #2A1F14;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30); --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30);

View file

@ -86,8 +86,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; width: 44px;
height: 40px; height: 44px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--c-text-secondary); color: var(--c-text-secondary);
cursor: pointer; cursor: pointer;
@ -99,8 +99,8 @@
/* Hamburger-Button (nur Mobile) */ /* Hamburger-Button (nur Mobile) */
.header-menu-btn { .header-menu-btn {
width: 40px; width: 44px;
height: 40px; height: 44px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View file

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

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 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_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION; window.APP_VERSION = APP_VERSION;

View file

@ -2115,8 +2115,8 @@ window.Page_dog_profile = (() => {
</div> </div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative"> <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> <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-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:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;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>
<div id="dp-wrapped-dots" style="display:flex;gap:8px;justify-content:center;padding:16px 0 32px">${renderDots()}</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'); 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.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"> 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()); lb.addEventListener('click', () => lb.remove());
document.body.appendChild(lb); document.body.appendChild(lb);
} }

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark"> <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> <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="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"> <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 // ← 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_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten 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