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:
parent
7751d303bb
commit
1ff66a7083
57 changed files with 1253 additions and 612 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1117
|
1118
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)):
|
||||||
|
|
|
||||||
|
|
@ -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)):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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).")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
233
tests/test_race.py
Normal 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
315
tests/test_security.py
Normal 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
104
tests/test_validation.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue