diff --git a/Dockerfile b/Dockerfile index 07e8bd6..ff22003 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg \ && rm -rf /var/lib/apt/lists/* +# Non-root User für Container-Hardening +# (Synology DSM-Volumes haben ACLs — daher chown auf /data + /app) +RUN groupadd -r appuser -g 1000 && \ + useradd -r -u 1000 -g appuser -d /app -s /sbin/nologin appuser + # Python-Dependencies zuerst (Docker Layer Cache) COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt @@ -22,6 +27,12 @@ COPY VERSION /app/VERSION RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \ /data/media/breeds/gallery /data/media/breeds/submissions +# USER appuser auskommentiert: Synology DSM Volume-ACLs blockieren das +# (SQLite OperationalError: 'attempt to write a readonly database'). User- +# Anlage bleibt im Dockerfile damit nicht-DS-Deployments später wechseln +# können via `USER appuser` Zeile auskommentieren-entfernen. +# USER appuser + EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] diff --git a/Makefile b/Makefile index 9402ea3..c16bf3f 100644 --- a/Makefile +++ b/Makefile @@ -287,7 +287,8 @@ bump: sed -i.bak -E "s/const VER[[:space:]]*=[[:space:]]*'[0-9]+'/const VER = '$$NEW'/" backend/static/sw.js && rm -f backend/static/sw.js.bak; \ sed -i.bak -E "s/const APP_VER[[:space:]]*=[[:space:]]*'[0-9]+'/const APP_VER = '$$NEW'/" backend/static/js/app.js && rm -f backend/static/js/app.js.bak; \ sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/index.html && rm -f backend/static/index.html.bak; \ - echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html aktualisiert)" + sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/landing.html && rm -f backend/static/landing.html.bak; \ + echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html, landing.html aktualisiert)" # ---------------------------------------------------------- # TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS) diff --git a/VERSION b/VERSION index 39987d0..a2998a8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1099 \ No newline at end of file +1120 \ No newline at end of file diff --git a/backend/auth.py b/backend/auth.py index 9cb25c6..1b5f126 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -212,6 +212,49 @@ def require_admin(user=Depends(get_current_user)): return user +def require_moderator(user=Depends(get_current_user)): + """Dependency: Admin oder Moderator. Konsequente Nutzung statt + Inline-`if user['rolle'] not in (...):` in den Routen.""" + if user["rolle"] not in ("admin", "moderator") and not user.get("is_moderator"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Moderator-Zugriff erforderlich.") + return user + + +def require_breeder(user=Depends(get_current_user)): + """Dependency: Admin oder Züchter (breeder/breeder_test).""" + if user["rolle"] == "admin": + return user + if user.get("subscription_tier") in ("breeder", "breeder_test"): + return user + raise HTTPException(status.HTTP_403_FORBIDDEN, "Züchter-Zugriff erforderlich.") + + +# ------------------------------------------------------------------ +# Owner-Checks — zentral, statt 54x inline `if row['user_id'] != user['id']: 403` +# ------------------------------------------------------------------ +def require_owner(row, user: dict, owner_field: str = "user_id", + not_found_msg: str = "Nicht gefunden", + forbidden_msg: str = "Kein Zugriff"): + """Wirft 404 wenn row None/falsy ist, 403 wenn User nicht Besitzer. + Returns row für chainability: + dog = require_owner(conn.execute(...).fetchone(), user, 'user_id', 'Hund nicht gefunden') + """ + if not row: + raise HTTPException(status.HTTP_404_NOT_FOUND, not_found_msg) + if row[owner_field] != user["id"]: + raise HTTPException(status.HTTP_403_FORBIDDEN, forbidden_msg) + return row + + +def is_owner_or_admin(row, user: dict, owner_field: str = "user_id") -> bool: + """True wenn User Owner ist oder Admin/Moderator.""" + if not row: + return False + if user["rolle"] in ("admin", "moderator") or user.get("is_moderator"): + return True + return row[owner_field] == user["id"] + + def has_pro_access(user: dict) -> bool: """True wenn User Pro-Features nutzen darf.""" if not user: diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..a05b6be --- /dev/null +++ b/backend/config.py @@ -0,0 +1,20 @@ +"""Zentrale Konfiguration — vermeidet 19× duplizierte os.getenv-Aufrufe +für MEDIA_DIR und gibt einheitliche Timeout-Konstanten für externe APIs.""" +import os + + +# Speicher-Pfade +DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db") +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs") +SCANINPUT_DIR = os.getenv("SCANINPUT_DIR", "/data/scaninput") + +# HTTP-Timeouts für externe APIs (in Sekunden) +# Verwendung: httpx.AsyncClient(timeout=API_TIMEOUT_DEFAULT) +API_TIMEOUT_SHORT = 5 # Schnelle Lookups (Geocoding, Reverse, einzelne Werte) +API_TIMEOUT_DEFAULT = 10 # Standardfall (Wetter, Wikipedia) +API_TIMEOUT_LONG = 30 # Größere Antworten (Overpass-Tiles, KI-Calls) + +# Standard-Header für externe Requests (Höflichkeit + Fair-Use) +HTTP_USER_AGENT = "BanYaro/1.0 (https://banyaro.app)" +HTTP_HEADERS = {"User-Agent": HTTP_USER_AGENT} diff --git a/backend/errors.py b/backend/errors.py new file mode 100644 index 0000000..2cbaf3f --- /dev/null +++ b/backend/errors.py @@ -0,0 +1,47 @@ +"""Standardisierte HTTP-Exceptions — vermeidet inkonsistente Texte +in 200+ raise-Statements.""" +from fastapi import HTTPException + + +def not_found(msg: str = "Nicht gefunden") -> HTTPException: + """404. Beispiel: `raise not_found('Hund nicht gefunden')`.""" + return HTTPException(404, msg) + + +def forbidden(msg: str = "Kein Zugriff") -> HTTPException: + """403.""" + return HTTPException(403, msg) + + +def bad_request(msg: str = "Ungültige Eingabe") -> HTTPException: + """400.""" + return HTTPException(400, msg) + + +def unauthorized(msg: str = "Nicht angemeldet") -> HTTPException: + """401.""" + return HTTPException(401, msg) + + +def conflict(msg: str = "Konflikt") -> HTTPException: + """409.""" + return HTTPException(409, msg) + + +def too_many_requests(msg: str = "Zu viele Anfragen", retry_after: int | None = None) -> HTTPException: + """429. Optional mit Retry-After Header (in Sekunden).""" + headers = {"Retry-After": str(retry_after)} if retry_after else None + return HTTPException(429, msg, headers=headers) + + +def service_unavailable(msg: str = "Dienst gerade nicht verfügbar") -> HTTPException: + """503.""" + return HTTPException(503, msg) + + +def require_or_404(row, msg: str = "Nicht gefunden"): + """Convenience: wirft 404 wenn row None/falsy, sonst gibt row zurück. + Beispiel: `dog = require_or_404(conn.execute(...).fetchone(), 'Hund nicht gefunden')`""" + if not row: + raise not_found(msg) + return row diff --git a/backend/main.py b/backend/main.py index 569a887..aac642e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -110,8 +110,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; " - "style-src 'self' 'unsafe-inline'; " + "script-src 'self' https://umami.motocamp.de; " # ohne unsafe-inline/eval — alle Inline-Scripts extrahiert + "style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt) "img-src 'self' data: blob: https:; " "connect-src 'self' https:; " "frame-ancestors 'none'; " @@ -1763,19 +1763,40 @@ async def force_update(): Ban Yaro — Update +p{color:#94a3b8;font-size:14px} +button{margin-top:24px;background:#C4843A;color:#fff;border:none;padding:12px 24px; +border-radius:8px;font-size:16px;cursor:pointer}
⏳ Einen Moment…

Wir besorgen neue Leckerlis 🦴

+ """ return HTMLResponse(content=html, headers={"Cache-Control": "no-store"}) diff --git a/backend/math_utils.py b/backend/math_utils.py new file mode 100644 index 0000000..a111ec5 --- /dev/null +++ b/backend/math_utils.py @@ -0,0 +1,37 @@ +"""Mathematische Helper-Funktionen — zentral statt 13× dupliziert.""" +import math + + +# Erdradius in Kilometern +EARTH_RADIUS_KM = 6371.0 + + +def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Distanz zwischen zwei GPS-Koordinaten in km (Haversine-Formel). + + Funktioniert für beliebige Punkte auf der Erde. Genauigkeit reicht + für App-Zwecke (Umkreissuche etc.). + """ + lat1_rad = math.radians(lat1) + lat2_rad = math.radians(lat2) + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = (math.sin(dlat / 2) ** 2 + + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2) + return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a)) + + +def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Distanz in Metern (Convenience-Wrapper).""" + return haversine_km(lat1, lon1, lat2, lon2) * 1000.0 + + +def bbox_deg_from_km(lat: float, radius_km: float): + """Bounding-Box-Approximation in Grad für radius_km um (lat, lon). + + Returns (lat_delta, lon_delta) — beide in Grad. + Verwendung: WHERE lat BETWEEN ?-lat_delta AND ?+lat_delta etc. + """ + lat_delta = radius_km / 111.0 + lon_delta = radius_km / (111.0 * max(abs(math.cos(math.radians(lat))), 0.01)) + return lat_delta, lon_delta diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 713a055..fac5a1c 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -12,7 +12,7 @@ from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from database import db, DB_PATH from auth import get_current_user @@ -92,15 +92,15 @@ _VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "bree class QuarterlyReportBody(BaseModel): year: int quarter: int - email: str + email: str = Field(..., max_length=254) class UserPatch(BaseModel): - rolle: Optional[str] = None # user | moderator | admin + rolle: Optional[str] = Field(None, max_length=30) # user | moderator | admin is_moderator: Optional[int] = None is_banned: Optional[int] = None - ban_reason: Optional[str] = None + ban_reason: Optional[str] = Field(None, max_length=1000) is_social_media: Optional[int] = None - subscription_tier: Optional[str] = None + subscription_tier: Optional[str] = Field(None, max_length=50) class WikiEnrichBody(BaseModel): limit: int = 10 diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py index bde0986..d353520 100644 --- a/backend/routes/adoption.py +++ b/backend/routes/adoption.py @@ -10,18 +10,18 @@ Caching: adoption_cache Tabelle, 24h TTL. """ import os -import math import logging import asyncio import uuid import httpx from datetime import datetime, timedelta from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user from routes.push import send_push_to_user +from math_utils import haversine_km MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -31,18 +31,6 @@ router = APIRouter() PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "") PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "") -# ------------------------------------------------------------------ -# Haversine — Distanz in km -# ------------------------------------------------------------------ -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6371.0 - p1 = math.radians(lat1) - p2 = math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * R * math.asin(math.sqrt(a)) - # ------------------------------------------------------------------ # Statische Tierheim-Daten (große deutsche Tierheime) @@ -234,7 +222,7 @@ async def adoption_nearby( for row in rows: d = dict(row) if d.get("tierheim_lat") and d.get("tierheim_lon"): - dist = _haversine(lat, lon, d["tierheim_lat"], d["tierheim_lon"]) + dist = haversine_km(lat, lon, d["tierheim_lat"], d["tierheim_lon"]) if dist <= radius: d["distanz_km"] = round(dist, 1) cached_animals.append(d) @@ -250,7 +238,7 @@ async def adoption_nearby( # ------ Statische Tierheime (immer) ------ shelters = [] for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS: - dist = _haversine(lat, lon, slat, slon) + dist = haversine_km(lat, lon, slat, slon) if dist <= radius: shelters.append({ "id": sid, @@ -304,7 +292,7 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)): # ================================================================== class InterestBody(BaseModel): - nachricht: Optional[str] = None + nachricht: Optional[str] = Field(None, max_length=5000) # ------------------------------------------------------------------ @@ -354,7 +342,7 @@ def community_list( d = dict(row) d["user_interested"] = bool(d.pop("_user_interested", 0)) if lat is not None and lon is not None and d.get("lat") and d.get("lon"): - dist = _haversine(lat, lon, d["lat"], d["lon"]) + dist = haversine_km(lat, lon, d["lat"], d["lon"]) d["distanz_km"] = round(dist, 1) if dist > radius: continue @@ -434,7 +422,7 @@ async def community_create( # PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer) # ------------------------------------------------------------------ class _StatusBody(BaseModel): - status: str + status: str = Field(..., max_length=50) @router.patch("/community/{listing_id}") def community_update_status( diff --git a/backend/routes/alerts.py b/backend/routes/alerts.py index 0065d18..7f1a0a0 100644 --- a/backend/routes/alerts.py +++ b/backend/routes/alerts.py @@ -1,10 +1,10 @@ """BAN YARO — Nearby Alerts (Giftköder + Vermisste Hunde)""" -import math from datetime import datetime from fastapi import APIRouter, Depends from database import db from auth import get_current_user_optional as get_optional_user +from math_utils import haversine_m, bbox_deg_from_km router = APIRouter() @@ -12,21 +12,9 @@ _RADIUS_M = 20_000 # 20 km _RADIUS_KM = _RADIUS_M / 1000.0 -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6_371_000 - p1, p2 = math.radians(lat1), math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * R * math.asin(math.sqrt(a)) - - def _bbox(lat: float, lon: float, radius_km: float) -> tuple[float, float, float, float]: """Bounding-Box-Approximation für lat/lon innerhalb radius_km.""" - lat_delta = radius_km / 111.0 - # cos darf bei Polen nicht 0 werden → mit kleinem Minimum absichern - cos_lat = max(abs(math.cos(math.radians(lat))), 0.01) - lon_delta = radius_km / (111.0 * cos_lat) + lat_delta, lon_delta = bbox_deg_from_km(lat, radius_km) return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta) @@ -60,7 +48,7 @@ async def nearby_alerts(lat: float, lon: float, user=Depends(get_optional_user)) (lat, lon, user["id"]) ) - has_poison = any(_haversine(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in poisons) - has_lost = any(_haversine(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in lost) + has_poison = any(haversine_m(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in poisons) + has_lost = any(haversine_m(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in lost) return {"poison": has_poison, "lost": has_lost} diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 4a9def8..18b092b 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -10,7 +10,7 @@ from typing import Optional import jwt as _pyjwt from fastapi import APIRouter, HTTPException, Request, Response, Depends from fastapi.responses import RedirectResponse -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, Field from database import db from auth import ( hash_password, verify_password, create_token, @@ -146,13 +146,13 @@ def _send_verification_email(email: str, name: str, token: str): class LoginRequest(BaseModel): email: EmailStr - password: str + password: str = Field(..., min_length=1, max_length=200) class RegisterRequest(BaseModel): email: EmailStr - password: str - name: str - ref_code: Optional[str] = None + password: str = Field(..., min_length=8, max_length=200) + name: str = Field(..., min_length=2, max_length=40) + ref_code: Optional[str] = Field(None, max_length=50) def _gen_referral_code() -> str: @@ -426,8 +426,8 @@ class ForgotPasswordRequest(BaseModel): email: EmailStr class ResetPasswordRequest(BaseModel): - token: str - password: str + token: str = Field(..., min_length=10, max_length=200) + password: str = Field(..., min_length=8, max_length=200) @router.post("/forgot-password") async def forgot_password(data: ForgotPasswordRequest, request: Request): @@ -471,8 +471,8 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request): class UpgradeRequestBody(BaseModel): - tier: str - message: Optional[str] = None + tier: str = Field(..., max_length=50) + message: Optional[str] = Field(None, max_length=2000) @router.post("/upgrade-request") async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)): diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index fe4028b..e53a1d4 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -6,7 +6,7 @@ from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi.responses import FileResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db @@ -237,7 +237,7 @@ async def admin_download_document(user_id: int, doc_id: int, admin=Depends(requi class RejectBody(BaseModel): - grund: str + grund: str = Field(..., min_length=3, max_length=2000) # ------------------------------------------------------------------ @@ -483,13 +483,13 @@ async def admin_create_profile(admin=Depends(require_admin)): # PUT /api/breeder/profile — eigenes Profil bearbeiten # ------------------------------------------------------------------ class BreederProfileUpdate(BaseModel): - zwingername: Optional[str] = None - rasse_text: Optional[str] = None - verein: Optional[str] = None + zwingername: Optional[str] = Field(None, max_length=200) + rasse_text: Optional[str] = Field(None, max_length=200) + verein: Optional[str] = Field(None, max_length=200) vdh_mitglied: Optional[int] = None - stadt: Optional[str] = None - website: Optional[str] = None - beschreibung: Optional[str] = None + stadt: Optional[str] = Field(None, max_length=200) + website: Optional[str] = Field(None, max_length=500) + beschreibung: Optional[str] = Field(None, max_length=10000) @router.put("/breeder/profile") async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)): diff --git a/backend/routes/breeder_photos.py b/backend/routes/breeder_photos.py index 18eb085..802440f 100644 --- a/backend/routes/breeder_photos.py +++ b/backend/routes/breeder_photos.py @@ -1,7 +1,7 @@ """BAN YARO — Züchter-Fotos (Upload, Verwaltung, öffentliche Ansicht)""" from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi.responses import FileResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional import os, logging, asyncio from database import db @@ -30,10 +30,10 @@ def _require_breeder(user=Depends(get_current_user)): # Modelle # ------------------------------------------------------------------ class VisibilityBody(BaseModel): - visibility: str + visibility: str = Field(..., max_length=30) class CaptionBody(BaseModel): - caption: Optional[str] = None + caption: Optional[str] = Field(None, max_length=500) # ------------------------------------------------------------------ diff --git a/backend/routes/chat.py b/backend/routes/chat.py index 0874303..7147315 100644 --- a/backend/routes/chat.py +++ b/backend/routes/chat.py @@ -4,7 +4,7 @@ import os import uuid from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import get_current_user @@ -142,7 +142,7 @@ async def get_messages(conv_id: int, offset: int = 0, limit: int = 50, class SendMsgModel(BaseModel): - text: str + text: str = Field(..., min_length=1, max_length=2000) @router.post("/conversations/{conv_id}/messages", status_code=201) @@ -151,8 +151,6 @@ async def send_message(conv_id: int, data: SendMsgModel, user=Depends(get_curren text = data.text.strip() if not text: raise HTTPException(400, "Nachricht darf nicht leer sein.") - if len(text) > 2000: - raise HTTPException(400, "Nachricht zu lang (max. 2000 Zeichen).") with db() as conn: conv = conn.execute( diff --git a/backend/routes/diary.py b/backend/routes/diary.py index baf2586..8d85a84 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -1,8 +1,8 @@ """BAN YARO — Tagebuch Routes""" -import os, uuid, json, math, logging, asyncio +import os, uuid, json, logging, asyncio from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user, require_admin @@ -11,6 +11,7 @@ import httpx import weather as weather_mod from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size from timeutils import safe_client_time +from math_utils import haversine_km logger = logging.getLogger(__name__) @@ -19,27 +20,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") class DiaryCreate(BaseModel): - datum: Optional[str] = None # ISO date, default heute - client_time: Optional[str] = None # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS) - typ: str = "eintrag" - titel: Optional[str] = None - text: Optional[str] = None + datum: Optional[str] = Field(None, max_length=32) # ISO date, default heute + client_time: Optional[str] = Field(None, max_length=64) # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS) + typ: str = Field("eintrag", max_length=50) + titel: Optional[str] = Field(None, max_length=200) + text: Optional[str] = Field(None, max_length=10000) tags: Optional[list] = None gps_lat: Optional[float] = None gps_lon: Optional[float] = None - location_name: Optional[str] = None + location_name: Optional[str] = Field(None, max_length=300) is_milestone: bool = False dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary - weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS) + weather_json: Optional[str] = Field(None, max_length=5000) # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS) class DiaryUpdate(BaseModel): - titel: Optional[str] = None - text: Optional[str] = None + titel: Optional[str] = Field(None, max_length=200) + text: Optional[str] = Field(None, max_length=10000) tags: Optional[list] = None gps_lat: Optional[float] = None gps_lon: Optional[float] = None - location_name: Optional[str] = None + location_name: Optional[str] = Field(None, max_length=300) is_milestone: Optional[bool] = None dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen @@ -409,7 +410,7 @@ async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list elat = el.get("lat") or el.get("center", {}).get("lat") elon = el.get("lon") or el.get("center", {}).get("lon") if elat and elon: - km = _haversine_km(lat, lon, elat, elon) + km = haversine_km(lat, lon, elat, elon) typ = next((el["tags"].get(k) for k in ["tourism", "historic", "leisure", "amenity", "shop"] if el["tags"].get(k)), "place") @@ -422,16 +423,6 @@ async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list return results[:limit] -def _haversine_km(lat1, lon1, lat2, lon2) -> float: - R = 6371 - dlat = math.radians(lat2 - lat1) - dlon = math.radians(lon2 - lon1) - a = (math.sin(dlat / 2) ** 2 - + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) - * math.sin(dlon / 2) ** 2) - return R * 2 * math.asin(math.sqrt(a)) - - @router.get("/{dog_id}/diary/nearby") async def nearby_places(dog_id: int, lat: float, lon: float, user=Depends(get_current_user)): @@ -445,7 +436,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float, (user["id"],) ).fetchall() for p in places: - km = _haversine_km(lat, lon, p["lat"], p["lon"]) + km = haversine_km(lat, lon, p["lat"], p["lon"]) if km <= 5: results.append({"name": p["name"], "type": p["typ"] or "place", "lat": p["lat"], "lon": p["lon"], @@ -456,7 +447,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float, "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''" ).fetchall() for p in osm: - km = _haversine_km(lat, lon, p["lat"], p["lon"]) + km = haversine_km(lat, lon, p["lat"], p["lon"]) if km <= 2: results.append({"name": p["name"], "type": p["type"], "lat": p["lat"], "lon": p["lon"], @@ -503,7 +494,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float, elat = el.get("lat") or el.get("center", {}).get("lat") elon = el.get("lon") or el.get("center", {}).get("lon") if elat and elon: - km = _haversine_km(lat, lon, elat, elon) + km = haversine_km(lat, lon, elat, elon) typ = next((el["tags"].get(k) for k in ["tourism","historic","leisure","amenity","shop"] if el["tags"].get(k)), "place") diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 9cc2820..1c37b99 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -3,7 +3,7 @@ import os import uuid from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user, has_pro_access @@ -29,28 +29,28 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") class DogCreate(BaseModel): - name: str - rasse: Optional[str] = None - geburtstag: Optional[str] = None - geschlecht: Optional[str] = None + name: str = Field(..., min_length=1, max_length=80) + rasse: Optional[str] = Field(None, max_length=80) + geburtstag: Optional[str] = Field(None, max_length=32) + geschlecht: Optional[str] = Field(None, max_length=20) gewicht_kg: Optional[float] = None widerrist_cm: Optional[float] = None - chip_nr: Optional[str] = None - bio: Optional[str] = None - is_public: bool = False + chip_nr: Optional[str] = Field(None, max_length=50) + bio: Optional[str] = Field(None, max_length=2000) + is_public: bool = False class DogUpdate(BaseModel): - name: Optional[str] = None - rasse: Optional[str] = None - rasse_id: Optional[int] = None - geburtstag: Optional[str] = None - geschlecht: Optional[str] = None + name: Optional[str] = Field(None, max_length=80) + rasse: Optional[str] = Field(None, max_length=80) + rasse_id: Optional[int] = None + geburtstag: Optional[str] = Field(None, max_length=32) + geschlecht: Optional[str] = Field(None, max_length=20) gewicht_kg: Optional[float] = None widerrist_cm: Optional[float] = None - chip_nr: Optional[str] = None - bio: Optional[str] = None - is_public: Optional[bool] = None + chip_nr: Optional[str] = Field(None, max_length=50) + bio: Optional[str] = Field(None, max_length=2000) + is_public: Optional[bool] = None @router.get("") @@ -180,14 +180,22 @@ async def create_dog(data: DogCreate, user=Depends(get_current_user)): if dog_count == 1: # genau dieser erste Hund plausible, reason = _is_plausible_dog(data.name, data.rasse, data.geburtstag) if plausible: - total = conn.execute( - "SELECT COUNT(*) FROM users WHERE is_founder=1" - ).fetchone()[0] - if total < 100: - conn.execute( - "UPDATE users SET is_founder=1, founder_number=?, is_founder_pending=0 WHERE id=?", - (total + 1, user["id"]) - ) + # Atomare Gründer-Vergabe — Race-frei via Sub-Query im UPDATE. + # Wenn schon 100 Founder oder User schon is_founder=1 → kein Update (rowcount=0) + 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"],) + ) return dict(dog) @@ -1025,8 +1033,8 @@ async def public_dog_profile(dog_id: int): class FoundReport(BaseModel): - message: Optional[str] = None - kontakt: Optional[str] = None + message: Optional[str] = Field(None, max_length=1000) + kontakt: Optional[str] = Field(None, max_length=300) # Gefunden-Meldung (kein Login nötig) @@ -1311,7 +1319,7 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)): # POST /api/dogs/{id}/gedenken — Hund als verstorben markieren # ------------------------------------------------------------------ class GedenkenData(BaseModel): - verstorben_am: str # YYYY-MM-DD + verstorben_am: str = Field(..., max_length=32) # YYYY-MM-DD @router.post("/{dog_id}/gedenken") async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)): diff --git a/backend/routes/ernaehrung.py b/backend/routes/ernaehrung.py index 2aa4760..d485c80 100644 --- a/backend/routes/ernaehrung.py +++ b/backend/routes/ernaehrung.py @@ -2,7 +2,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -16,18 +16,18 @@ logger = logging.getLogger(__name__) # Schemas # ------------------------------------------------------------------ class FutterProfilUpdate(BaseModel): - futter_typ: Optional[str] = None # trocken|nass|barf|mix - marke: Optional[str] = None + futter_typ: Optional[str] = Field(None, max_length=50) # trocken|nass|barf|mix + marke: Optional[str] = Field(None, max_length=200) kcal_tag: Optional[int] = None portionen: Optional[int] = None - notizen: Optional[str] = None + notizen: Optional[str] = Field(None, max_length=5000) class KiBeratungRequest(BaseModel): - frage: str - dog_name: Optional[str] = None - rasse: Optional[str] = None - alter: Optional[str] = None + frage: str = Field(..., min_length=3, max_length=2000) + dog_name: Optional[str] = Field(None, max_length=80) + rasse: Optional[str] = Field(None, max_length=80) + alter: Optional[str] = Field(None, max_length=50) gewicht: Optional[float] = None aktiv: Optional[bool] = None @@ -183,20 +183,20 @@ _GASTRO_HINWEIS = "Magen-Darm-Symptome wie {label} treten meist innerhalb wenige class FutterEintragCreate(BaseModel): - datum: str - uhrzeit: str - futter_name: str - futter_typ: Optional[str] = "trockenfutter" + datum: str = Field(..., max_length=32) + uhrzeit: str = Field(..., max_length=20) + futter_name: str = Field(..., max_length=200) + futter_typ: Optional[str] = Field("trockenfutter", max_length=50) menge_g: Optional[int] = None - notiz: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=2000) class ReaktionCreate(BaseModel): - datum: str - uhrzeit: str - reaktion_typ: str + datum: str = Field(..., max_length=32) + uhrzeit: str = Field(..., max_length=20) + reaktion_typ: str = Field(..., max_length=100) intensitaet: Optional[int] = 3 - notiz: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=2000) # ------------------------------------------------------------------ diff --git a/backend/routes/events.py b/backend/routes/events.py index c066959..f2ca8a6 100644 --- a/backend/routes/events.py +++ b/backend/routes/events.py @@ -1,54 +1,45 @@ """BAN YARO — Events (Hundeveranstaltungen)""" -import math from datetime import date from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user +from math_utils import haversine_m router = APIRouter() TYPEN = {'ausstellung', 'training', 'treffen', 'markt', 'wettkampf', 'sonstiges'} -def _haversine(lat1, lon1, lat2, lon2): - R = 6_371_000 - p1, p2 = math.radians(lat1), math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * R * math.asin(math.sqrt(a)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class RsvpCreate(BaseModel): - status: str = 'going' # 'going' | 'maybe' + status: str = Field('going', max_length=20) # 'going' | 'maybe' class EventCreate(BaseModel): - titel: str - datum: str # YYYY-MM-DD - uhrzeit: Optional[str] = None + titel: str = Field(..., min_length=3, max_length=200) + datum: str = Field(..., max_length=32) # YYYY-MM-DD + uhrzeit: Optional[str] = Field(None, max_length=20) lat: Optional[float] = None lon: Optional[float] = None - ort_name: Optional[str] = None - typ: str = 'sonstiges' - beschreibung: Optional[str] = None - link: Optional[str] = None + ort_name: Optional[str] = Field(None, max_length=300) + typ: str = Field('sonstiges', max_length=50) + beschreibung: Optional[str] = Field(None, max_length=10000) + link: Optional[str] = Field(None, max_length=500) class EventUpdate(BaseModel): - titel: Optional[str] = None - datum: Optional[str] = None - uhrzeit: Optional[str] = None + titel: Optional[str] = Field(None, max_length=200) + datum: Optional[str] = Field(None, max_length=32) + uhrzeit: Optional[str] = Field(None, max_length=20) lat: Optional[float] = None lon: Optional[float] = None - ort_name: Optional[str] = None - typ: Optional[str] = None - beschreibung: Optional[str] = None - link: Optional[str] = None + ort_name: Optional[str] = Field(None, max_length=300) + typ: Optional[str] = Field(None, max_length=50) + beschreibung: Optional[str] = Field(None, max_length=10000) + link: Optional[str] = Field(None, max_length=500) # ------------------------------------------------------------------ @@ -86,7 +77,7 @@ async def list_events( result = [dict(r) for r in rows] if lat is not None and lon is not None: result = [r for r in result - if r['lat'] is None or _haversine(lat, lon, r['lat'], r['lon']) <= radius] + if r['lat'] is None or haversine_m(lat, lon, r['lat'], r['lon']) <= radius] return result diff --git a/backend/routes/expenses.py b/backend/routes/expenses.py index 9c93475..9a34f27 100644 --- a/backend/routes/expenses.py +++ b/backend/routes/expenses.py @@ -4,7 +4,7 @@ import logging from datetime import date, timedelta from dateutil.relativedelta import relativedelta from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -20,35 +20,35 @@ KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonst # ------------------------------------------------------------------ class ExpenseCreate(BaseModel): dog_id: Optional[int] = None - kategorie: str + kategorie: str = Field(..., max_length=50) betrag: float - datum: str - notiz: Optional[str] = None + datum: str = Field(..., max_length=32) + notiz: Optional[str] = Field(None, max_length=1000) class ExpenseUpdate(BaseModel): dog_id: Optional[int] = None - kategorie: Optional[str] = None + kategorie: Optional[str] = Field(None, max_length=50) betrag: Optional[float] = None - datum: Optional[str] = None - notiz: Optional[str] = None + datum: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=1000) class RecurringCreate(BaseModel): dog_id: Optional[int] = None - kategorie: str + kategorie: str = Field(..., max_length=50) betrag: float - haeufigkeit: str # monatlich | quartalsweise | jaehrlich - startdatum: str # ISO date - notiz: Optional[str] = None + haeufigkeit: str = Field(..., max_length=30) # monatlich | quartalsweise | jaehrlich + startdatum: str = Field(..., max_length=32) # ISO date + notiz: Optional[str] = Field(None, max_length=1000) class RecurringUpdate(BaseModel): dog_id: Optional[int] = None - kategorie: Optional[str] = None + kategorie: Optional[str] = Field(None, max_length=50) betrag: Optional[float] = None - haeufigkeit: Optional[str] = None - startdatum: Optional[str] = None - notiz: Optional[str] = None + haeufigkeit: Optional[str] = Field(None, max_length=30) + startdatum: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=1000) aktiv: Optional[bool] = None diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 2834ab0..f8ee5e0 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -2,7 +2,7 @@ import os, uuid, json, logging from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user, get_current_user_optional @@ -27,40 +27,40 @@ KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung', # Schemas # ------------------------------------------------------------------ class ThreadCreate(BaseModel): - kategorie: str = 'allgemein' - titel: str - text: str + kategorie: str = Field('allgemein', max_length=100) + titel: str = Field(..., min_length=3, max_length=200) + text: str = Field(..., min_length=1, max_length=10000) thread_lat: Optional[float] = None thread_lon: Optional[float] = None - thread_ort: Optional[str] = None - client_time: Optional[str] = None + thread_ort: Optional[str] = Field(None, max_length=300) + client_time: Optional[str] = Field(None, max_length=64) class PostCreate(BaseModel): - text: str - client_time: Optional[str] = None + text: str = Field(..., min_length=1, max_length=10000) + client_time: Optional[str] = Field(None, max_length=64) class ThreadPatch(BaseModel): is_pinned: Optional[int] = None is_locked: Optional[int] = None class ThreadUpdate(BaseModel): - titel: Optional[str] = None - text: Optional[str] = None + titel: Optional[str] = Field(None, max_length=200) + text: Optional[str] = Field(None, max_length=10000) thread_lat: Optional[float] = None thread_lon: Optional[float] = None - thread_ort: Optional[str] = None + thread_ort: Optional[str] = Field(None, max_length=300) class PostUpdate(BaseModel): - text: str + text: str = Field(..., min_length=1, max_length=10000) class LikeBody(BaseModel): - target_type: str # 'thread' | 'post' + target_type: str = Field(..., max_length=20) # 'thread' | 'post' target_id: int class ReportBody(BaseModel): - target_type: str + target_type: str = Field(..., max_length=20) target_id: int - grund: str + grund: str = Field(..., min_length=3, max_length=1000) class LocationBody(BaseModel): lat: Optional[float] = None diff --git a/backend/routes/gassi_zeiten.py b/backend/routes/gassi_zeiten.py index 77ff52f..455aa15 100644 --- a/backend/routes/gassi_zeiten.py +++ b/backend/routes/gassi_zeiten.py @@ -1,37 +1,28 @@ """BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)""" import json -import math import logging from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from database import db from auth import get_current_user +from math_utils import haversine_m logger = logging.getLogger(__name__) router = APIRouter() -def _haversine(lat1, lon1, lat2, lon2): - R = 6_371_000 - p1, p2 = math.radians(lat1), math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * R * math.asin(math.sqrt(a)) - - class GassiZeitCreate(BaseModel): - dog_id: Optional[int] = None - wochentage: List[str] # ["mo", "mi", "fr"] - uhrzeit: str # "17:00" - ort_name: Optional[str] = None + dog_id: Optional[int] = None + wochentage: List[str] # ["mo", "mi", "fr"] + uhrzeit: str = Field(..., max_length=20) # "17:00" + ort_name: Optional[str] = Field(None, max_length=300) lat: Optional[float] = None lon: Optional[float] = None radius_m: int = 500 - notiz: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=2000) class GassiZeitUpdate(BaseModel): @@ -83,7 +74,7 @@ async def list_gassi_zeiten( # Distanz-Filter if lat is not None and lon is not None and d.get("lat") and d.get("lon"): - dist = _haversine(lat, lon, d["lat"], d["lon"]) + dist = haversine_m(lat, lon, d["lat"], d["lon"]) if not nur_eigene and dist > radius: continue d["distance_m"] = int(dist) diff --git a/backend/routes/health.py b/backend/routes/health.py index 34ec5ec..7b9ed35 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -3,7 +3,7 @@ import os, uuid from datetime import date, datetime from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -22,59 +22,59 @@ TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie # Schemas # ------------------------------------------------------------------ class HealthCreate(BaseModel): - typ: str - bezeichnung: Optional[str] = None - datum: str - naechstes: Optional[str] = None - notiz: Optional[str] = None + typ: str = Field(..., max_length=50) + bezeichnung: Optional[str] = Field(None, max_length=200) + datum: str = Field(..., max_length=32) + naechstes: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=5000) # Gewicht wert: Optional[float] = None - einheit: Optional[str] = "kg" + einheit: Optional[str] = Field("kg", max_length=20) # Impfung - charge_nr: Optional[str] = None - tierarzt_name: Optional[str] = None + charge_nr: Optional[str] = Field(None, max_length=100) + tierarzt_name: Optional[str] = Field(None, max_length=200) # Tierarztbesuch kosten: Optional[float] = None - diagnose: Optional[str] = None + diagnose: Optional[str] = Field(None, max_length=2000) # Medikament - dosierung: Optional[str] = None - haeufigkeit: Optional[str] = None + dosierung: Optional[str] = Field(None, max_length=200) + haeufigkeit: Optional[str] = Field(None, max_length=200) aktiv: Optional[int] = 1 - bis_datum: Optional[str] = None + bis_datum: Optional[str] = Field(None, max_length=32) # Allergie - schweregrad: Optional[str] = None # leicht | mittel | schwer - reaktion: Optional[str] = None + schweregrad: Optional[str] = Field(None, max_length=50) # leicht | mittel | schwer + reaktion: Optional[str] = Field(None, max_length=1000) erinnerung: Optional[int] = 1 intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage # Tierarzt-Verknüpfung tierarzt_id: Optional[int] = None # Züchter - deckdatum: Optional[str] = None - wurftermin: Optional[str] = None + deckdatum: Optional[str] = Field(None, max_length=32) + wurftermin: Optional[str] = Field(None, max_length=32) class HealthUpdate(BaseModel): - bezeichnung: Optional[str] = None - datum: Optional[str] = None - naechstes: Optional[str] = None - notiz: Optional[str] = None + bezeichnung: Optional[str] = Field(None, max_length=200) + datum: Optional[str] = Field(None, max_length=32) + naechstes: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=5000) wert: Optional[float] = None - einheit: Optional[str] = None - charge_nr: Optional[str] = None - tierarzt_name: Optional[str] = None + einheit: Optional[str] = Field(None, max_length=20) + charge_nr: Optional[str] = Field(None, max_length=100) + tierarzt_name: Optional[str] = Field(None, max_length=200) kosten: Optional[float] = None - diagnose: Optional[str] = None - dosierung: Optional[str] = None - haeufigkeit: Optional[str] = None + diagnose: Optional[str] = Field(None, max_length=2000) + dosierung: Optional[str] = Field(None, max_length=200) + haeufigkeit: Optional[str] = Field(None, max_length=200) aktiv: Optional[int] = None - bis_datum: Optional[str] = None - schweregrad: Optional[str] = None - reaktion: Optional[str] = None + bis_datum: Optional[str] = Field(None, max_length=32) + schweregrad: Optional[str] = Field(None, max_length=50) + reaktion: Optional[str] = Field(None, max_length=1000) erinnerung: Optional[int] = None intervall_tage: Optional[int] = None tierarzt_id: Optional[int] = None - deckdatum: Optional[str] = None - wurftermin: Optional[str] = None + deckdatum: Optional[str] = Field(None, max_length=32) + wurftermin: Optional[str] = Field(None, max_length=32) # ------------------------------------------------------------------ @@ -390,7 +390,7 @@ async def list_gewicht(dog_id: int, user=Depends(get_current_user)): # POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung # ------------------------------------------------------------------ class SymptomCheckRequest(BaseModel): - symptoms: str + symptoms: str = Field(..., min_length=3, max_length=5000) @router.post("/{dog_id}/health/symptom-check") @@ -576,20 +576,20 @@ async def terminvorschlaege(dog_id: int, user=Depends(get_current_user)): # ================================================================== class InsuranceCreate(BaseModel): - anbieter: str - police_nr: Optional[str] = None + anbieter: str = Field(..., min_length=1, max_length=200) + police_nr: Optional[str] = Field(None, max_length=100) jahresbeitrag: Optional[float] = None - kontakt: Optional[str] = None - ablaufdatum: Optional[str] = None - notizen: Optional[str] = None + kontakt: Optional[str] = Field(None, max_length=500) + ablaufdatum: Optional[str] = Field(None, max_length=32) + notizen: Optional[str] = Field(None, max_length=5000) class InsuranceUpdate(BaseModel): - anbieter: Optional[str] = None - police_nr: Optional[str] = None + anbieter: Optional[str] = Field(None, max_length=200) + police_nr: Optional[str] = Field(None, max_length=100) jahresbeitrag: Optional[float] = None - kontakt: Optional[str] = None - ablaufdatum: Optional[str] = None - notizen: Optional[str] = None + kontakt: Optional[str] = Field(None, max_length=500) + ablaufdatum: Optional[str] = Field(None, max_length=32) + notizen: Optional[str] = Field(None, max_length=5000) @router.get("/{dog_id}/insurance") @@ -674,12 +674,12 @@ TRIGGER_LABELS = { class BehaviorCreate(BaseModel): - datum: str - uhrzeit: Optional[str] = None - kategorie: str - intensitaet: int = 3 - trigger: Optional[str] = None - notiz: Optional[str] = None + datum: str = Field(..., max_length=32) + uhrzeit: Optional[str] = Field(None, max_length=20) + kategorie: str = Field(..., max_length=50) + intensitaet: int = 3 + trigger: Optional[str] = Field(None, max_length=200) + notiz: Optional[str] = Field(None, max_length=5000) @router.get("/{dog_id}/behavior") diff --git a/backend/routes/help.py b/backend/routes/help.py index 6551e4d..05803f0 100644 --- a/backend/routes/help.py +++ b/backend/routes/help.py @@ -1,7 +1,7 @@ """BAN YARO — Hilfe / FAQ Routes""" from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user_optional, require_admin @@ -31,17 +31,17 @@ def _load_active_help_articles() -> list[dict]: # Schemas # ------------------------------------------------------------------ class ArticleCreate(BaseModel): - kategorie: str - frage: str - antwort: str - sort_order: int = 0 - aktiv: int = 1 + kategorie: str = Field(..., max_length=100) + frage: str = Field(..., min_length=3, max_length=500) + antwort: str = Field(..., min_length=3, max_length=10000) + sort_order: int = 0 + aktiv: int = 1 class ArticleUpdate(BaseModel): - kategorie: Optional[str] = None - frage: Optional[str] = None - antwort: Optional[str] = None + kategorie: Optional[str] = Field(None, max_length=100) + frage: Optional[str] = Field(None, max_length=500) + antwort: Optional[str] = Field(None, max_length=10000) sort_order: Optional[int] = None aktiv: Optional[int] = None diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 1f27759..87d104d 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import require_admin import mailer @@ -19,30 +19,30 @@ logger = logging.getLogger(__name__) # Schemas # ------------------------------------------------------------------ class InvoiceItem(BaseModel): - description: str - quantity: float = 1.0 + description: str = Field(..., max_length=500) + quantity: float = 1.0 unit_price: float class InvoiceCreate(BaseModel): user_id: Optional[int] = None - recipient_name: str - recipient_email: str - recipient_address: Optional[str] = None + recipient_name: str = Field(..., max_length=200) + recipient_email: str = Field(..., max_length=254) + recipient_address: Optional[str] = Field(None, max_length=500) items: List[InvoiceItem] discount_pct: Optional[float] = 0.0 - service_period: Optional[str] = None - notes: Optional[str] = None + service_period: Optional[str] = Field(None, max_length=200) + notes: Optional[str] = Field(None, max_length=5000) class PayBody(BaseModel): - paid_at: str + paid_at: str = Field(..., max_length=32) paid_amount: float - notes: Optional[str] = None + notes: Optional[str] = Field(None, max_length=2000) class CancelBody(BaseModel): - reason: str + reason: str = Field(..., min_length=3, max_length=1000) # ------------------------------------------------------------------ diff --git a/backend/routes/ki.py b/backend/routes/ki.py index 2b16cbe..5169634 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -1,6 +1,6 @@ """BAN YARO — KI Routes""" from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional import ki as ki_module from auth import get_current_user @@ -11,9 +11,9 @@ router = APIRouter() class TrainingRequest(BaseModel): - problem: str - rasse: Optional[str] = None - alter: Optional[str] = None + problem: str = Field(..., min_length=10, max_length=1000) + rasse: Optional[str] = Field(None, max_length=80) + alter: Optional[str] = Field(None, max_length=50) @router.post("/training") @@ -23,8 +23,6 @@ async def ki_training(req: TrainingRequest, request: Request, rl_check(request, max_requests=10, window_seconds=3600, key="ki_training") if not req.problem or len(req.problem.strip()) < 10: raise HTTPException(400, "Bitte beschreibe das Problem genauer.") - if len(req.problem) > 1000: - raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).") rasse = req.rasse or "unbekannt" alter = req.alter or "unbekannt" @@ -69,10 +67,10 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon.""" # POST /ki/tierarzt — KI-Tierarztfragen # ------------------------------------------------------------------ class TierarztRequest(BaseModel): - symptom: str + symptom: str = Field(..., min_length=5, max_length=1000) dog_id: Optional[int] = None - dog_name: Optional[str] = None - rasse: Optional[str] = None + dog_name: Optional[str] = Field(None, max_length=80) + rasse: Optional[str] = Field(None, max_length=80) @router.post("/tierarzt") @@ -81,8 +79,6 @@ async def ki_tierarzt(req: TierarztRequest, request: Request, """KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung.""" if not req.symptom or len(req.symptom.strip()) < 5: raise HTTPException(400, "Bitte beschreibe das Symptom genauer.") - if len(req.symptom) > 1000: - raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).") # Rate-Limit: max 5 Anfragen pro User pro Tag with db() as conn: @@ -173,10 +169,10 @@ def _log_rasse_request(user_id: int): class BirthdayRequest(BaseModel): dog_id: int - name: str - rasse: Optional[str] = None + name: str = Field(..., max_length=80) + rasse: Optional[str] = Field(None, max_length=80) alter: Optional[int] = None - mode: str = "tomorrow" # "tomorrow" | "today" + mode: str = Field("tomorrow", max_length=20) # "tomorrow" | "today" @router.post("/geburtstag") async def ki_geburtstag(req: BirthdayRequest, request: Request, @@ -368,12 +364,12 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array.""" # ------------------------------------------------------------------ class AbschiedRequest(BaseModel): dog_id: int - name: str - rasse: Optional[str] = None + name: str = Field(..., max_length=80) + rasse: Optional[str] = Field(None, max_length=80) km_total: Optional[float] = None diary_count: Optional[int] = None gemeinsam_tage: Optional[int] = None - last_entry_titel: Optional[str] = None + last_entry_titel: Optional[str] = Field(None, max_length=200) @router.post("/abschied") async def ki_abschied(req: AbschiedRequest, request: Request, diff --git a/backend/routes/knigge.py b/backend/routes/knigge.py index 779d023..d824f56 100644 --- a/backend/routes/knigge.py +++ b/backend/routes/knigge.py @@ -1,7 +1,7 @@ """BAN YARO — Hunde-Knigge Routes""" from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user, get_current_user_optional @@ -13,12 +13,12 @@ router = APIRouter() # Schemas # ------------------------------------------------------------------ class VoteRequest(BaseModel): - szenario_id: str - answer: str + szenario_id: str = Field(..., max_length=100) + answer: str = Field(..., max_length=100) class KiRatRequest(BaseModel): - situation: str + situation: str = Field(..., min_length=3, max_length=2000) # ------------------------------------------------------------------ diff --git a/backend/routes/laeufi.py b/backend/routes/laeufi.py index 22189bd..5ca7e22 100644 --- a/backend/routes/laeufi.py +++ b/backend/routes/laeufi.py @@ -1,7 +1,7 @@ """BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)""" from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from datetime import date, timedelta @@ -78,47 +78,47 @@ def _calc_meilensteine(deckdatum_str: str) -> list: # Schemas # ------------------------------------------------------------------ class LaeufiCreate(BaseModel): - beginn: str - ende: Optional[str] = None - notiz: Optional[str] = None + beginn: str = Field(..., max_length=32) + ende: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=2000) class LaeufiUpdate(BaseModel): - beginn: Optional[str] = None - ende: Optional[str] = None - notiz: Optional[str] = None + beginn: Optional[str] = Field(None, max_length=32) + ende: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=2000) class ProgestCreate(BaseModel): - datum: str + datum: str = Field(..., max_length=32) wert: Optional[float] = None - einheit: str = "ng/ml" - labor: Optional[str] = None - notiz: Optional[str] = None + einheit: str = Field("ng/ml", max_length=20) + labor: Optional[str] = Field(None, max_length=200) + notiz: Optional[str] = Field(None, max_length=2000) class ProgestUpdate(BaseModel): - datum: Optional[str] = None + datum: Optional[str] = Field(None, max_length=32) wert: Optional[float] = None - einheit: Optional[str] = None - labor: Optional[str] = None - notiz: Optional[str] = None + einheit: Optional[str] = Field(None, max_length=20) + labor: Optional[str] = Field(None, max_length=200) + notiz: Optional[str] = Field(None, max_length=2000) class DeckCreate(BaseModel): - deckdatum: str + deckdatum: str = Field(..., max_length=32) laeufi_id: Optional[int] = None ruede_id: Optional[int] = None - ruede_name: Optional[str] = None - deckart: str = "natuerlich" + ruede_name: Optional[str] = Field(None, max_length=200) + deckart: str = Field("natuerlich", max_length=50) traechtig: int = 0 - ultraschall_datum: Optional[str] = None - notiz: Optional[str] = None + ultraschall_datum: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=2000) class DeckUpdate(BaseModel): - deckdatum: Optional[str] = None + deckdatum: Optional[str] = Field(None, max_length=32) ruede_id: Optional[int] = None - ruede_name: Optional[str] = None - deckart: Optional[str] = None + ruede_name: Optional[str] = Field(None, max_length=200) + deckart: Optional[str] = Field(None, max_length=50) traechtig: Optional[int] = None - ultraschall_datum: Optional[str] = None - notiz: Optional[str] = None + ultraschall_datum: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=2000) # ------------------------------------------------------------------ diff --git a/backend/routes/litters.py b/backend/routes/litters.py index 09250d8..3294641 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -4,7 +4,7 @@ import logging from datetime import date from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import HTMLResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db @@ -27,68 +27,68 @@ def _require_breeder(user=Depends(get_current_user)): # Schemas # ------------------------------------------------------------------ class LitterCreate(BaseModel): - wurf_rang: Optional[str] = None # A, B, C … - wurf_name: Optional[str] = None # z.B. "Vatertags-Wurf" - vater_name: Optional[str] = None - mutter_name: Optional[str] = None + wurf_rang: Optional[str] = Field(None, max_length=10) # A, B, C … + wurf_name: Optional[str] = Field(None, max_length=200) # z.B. "Vatertags-Wurf" + vater_name: Optional[str] = Field(None, max_length=200) + mutter_name: Optional[str] = Field(None, max_length=200) vater_id: Optional[int] = None mutter_id: Optional[int] = None - geburt_datum: Optional[str] = None - erwartetes_datum: Optional[str] = None + geburt_datum: Optional[str] = Field(None, max_length=32) + erwartetes_datum: Optional[str] = Field(None, max_length=32) welpen_gesamt: Optional[int] = None welpen_verfuegbar: Optional[int] = None - beschreibung: Optional[str] = None - gesundheitstests: Optional[str] = None - preis_spanne: Optional[str] = None - status: str = "geplant" + beschreibung: Optional[str] = Field(None, max_length=10000) + gesundheitstests: Optional[str] = Field(None, max_length=5000) + preis_spanne: Optional[str] = Field(None, max_length=100) + status: str = Field("geplant", max_length=30) sichtbar: int = 0 - sichtbar_bis: Optional[str] = None + sichtbar_bis: Optional[str] = Field(None, max_length=32) class LitterUpdate(BaseModel): - wurf_rang: Optional[str] = None - wurf_name: Optional[str] = None - vater_name: Optional[str] = None - mutter_name: Optional[str] = None + wurf_rang: Optional[str] = Field(None, max_length=10) + wurf_name: Optional[str] = Field(None, max_length=200) + vater_name: Optional[str] = Field(None, max_length=200) + mutter_name: Optional[str] = Field(None, max_length=200) vater_id: Optional[int] = None mutter_id: Optional[int] = None - geburt_datum: Optional[str] = None - erwartetes_datum: Optional[str] = None + geburt_datum: Optional[str] = Field(None, max_length=32) + erwartetes_datum: Optional[str] = Field(None, max_length=32) welpen_gesamt: Optional[int] = None welpen_verfuegbar: Optional[int] = None - beschreibung: Optional[str] = None - gesundheitstests: Optional[str] = None - preis_spanne: Optional[str] = None - status: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=10000) + gesundheitstests: Optional[str] = Field(None, max_length=5000) + preis_spanne: Optional[str] = Field(None, max_length=100) + status: Optional[str] = Field(None, max_length=30) sichtbar: Optional[int] = None - sichtbar_bis: Optional[str] = None + sichtbar_bis: Optional[str] = Field(None, max_length=32) class PuppyCreate(BaseModel): - name: Optional[str] = None - geschlecht: Optional[str] = None # maennlich|weiblich - farbe: Optional[str] = None - chip_nr: Optional[str] = None + name: Optional[str] = Field(None, max_length=80) + geschlecht: Optional[str] = Field(None, max_length=20) # maennlich|weiblich + farbe: Optional[str] = Field(None, max_length=100) + chip_nr: Optional[str] = Field(None, max_length=50) geburtsgewicht: Optional[float] = None # Gramm - status: str = "verfuegbar" # verfuegbar|reserviert|abgegeben + status: str = Field("verfuegbar", max_length=30) # verfuegbar|reserviert|abgegeben status_sichtbar: int = 1 - notiz: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=2000) class PuppyUpdate(BaseModel): - name: Optional[str] = None - geschlecht: Optional[str] = None - farbe: Optional[str] = None - chip_nr: Optional[str] = None + name: Optional[str] = Field(None, max_length=80) + geschlecht: Optional[str] = Field(None, max_length=20) + farbe: Optional[str] = Field(None, max_length=100) + chip_nr: Optional[str] = Field(None, max_length=50) geburtsgewicht: Optional[float] = None - status: Optional[str] = None + status: Optional[str] = Field(None, max_length=30) status_sichtbar: Optional[int] = None - notiz: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=2000) class WeightEntry(BaseModel): gewicht_g: float - gemessen_am: str # YYYY-MM-DD + gemessen_am: str = Field(..., max_length=32) # YYYY-MM-DD # ------------------------------------------------------------------ @@ -663,15 +663,15 @@ async def generate_contract( # Warteliste # ------------------------------------------------------------------ class WaitlistEntry(BaseModel): - name: str - email: Optional[str] = None - telefon: Optional[str] = None - nachricht: Optional[str] = None - wunsch_geschlecht: str = "egal" - wunsch_farbe: Optional[str] = None + name: str = Field(..., min_length=1, max_length=200) + email: Optional[str] = Field(None, max_length=254) + telefon: Optional[str] = Field(None, max_length=30) + nachricht: Optional[str] = Field(None, max_length=5000) + wunsch_geschlecht: str = Field("egal", max_length=20) + wunsch_farbe: Optional[str] = Field(None, max_length=100) prioritaet: int = 0 - status: str = "anfrage" - notiz: Optional[str] = None + status: str = Field("anfrage", max_length=30) + notiz: Optional[str] = Field(None, max_length=2000) class WaitlistUpdate(BaseModel): diff --git a/backend/routes/lost.py b/backend/routes/lost.py index 3b02ed3..065145f 100644 --- a/backend/routes/lost.py +++ b/backend/routes/lost.py @@ -1,44 +1,32 @@ """BAN YARO — Verlorener Hund Routes""" -import os, uuid, math +import os, uuid from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user from timeutils import safe_client_time from routes.push import send_push_to_all from media_utils import convert_media +from math_utils import haversine_m router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") -# ------------------------------------------------------------------ -# Haversine-Distanz in Metern -# ------------------------------------------------------------------ -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6_371_000 - p1 = math.radians(lat1) - p2 = math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * R * math.asin(math.sqrt(a)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class LostDogCreate(BaseModel): - name: str - rasse: Optional[str] = None - beschreibung: str + name: str = Field(..., min_length=1, max_length=80) + rasse: Optional[str] = Field(None, max_length=80) + beschreibung: str = Field(..., min_length=3, max_length=5000) lat: float lon: float dog_id: Optional[int] = None - client_time: Optional[str] = None + client_time: Optional[str] = Field(None, max_length=64) # ------------------------------------------------------------------ @@ -60,7 +48,7 @@ async def list_lost(lat: Optional[float] = None, lon: Optional[float] = None, for r in rows: entry = dict(r) if lat is not None and lon is not None: - dist = _haversine(lat, lon, entry["lat"], entry["lon"]) + dist = haversine_m(lat, lon, entry["lat"], entry["lon"]) if dist > radius_km * 1000: continue entry["distanz_m"] = round(dist) diff --git a/backend/routes/movies.py b/backend/routes/movies.py index da6c682..79b3d95 100644 --- a/backend/routes/movies.py +++ b/backend/routes/movies.py @@ -1,7 +1,7 @@ """BAN YARO — Hunde-Filme Routes""" from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from datetime import datetime from database import db @@ -207,31 +207,31 @@ class HundDesMonatsVoteRequest(BaseModel): dog_id: int class MovieCreate(BaseModel): - id: str - titel: str - originaltitel: Optional[str] = None + id: str = Field(..., max_length=100) + titel: str = Field(..., min_length=1, max_length=200) + originaltitel: Optional[str] = Field(None, max_length=200) jahr: Optional[int] = None - genre: Optional[str] = None - typ: str = "film" - hund_rasse: Optional[str] = None - stirbt_der_hund: bool = False - beschreibung: Optional[str] = None - bild_emoji: str = "🐾" + genre: Optional[str] = Field(None, max_length=100) + typ: str = Field("film", max_length=30) + hund_rasse: Optional[str] = Field(None, max_length=200) + stirbt_der_hund: bool = False + beschreibung: Optional[str] = Field(None, max_length=5000) + bild_emoji: str = Field("🐾", max_length=10) imdb_rating: Optional[float] = None - streaming: Optional[str] = None + streaming: Optional[str] = Field(None, max_length=500) class MovieUpdate(BaseModel): - titel: Optional[str] = None - originaltitel: Optional[str] = None + titel: Optional[str] = Field(None, max_length=200) + originaltitel: Optional[str] = Field(None, max_length=200) jahr: Optional[int] = None - genre: Optional[str] = None - typ: Optional[str] = None - hund_rasse: Optional[str] = None + genre: Optional[str] = Field(None, max_length=100) + typ: Optional[str] = Field(None, max_length=30) + hund_rasse: Optional[str] = Field(None, max_length=200) stirbt_der_hund: Optional[bool] = None - beschreibung: Optional[str] = None - bild_emoji: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=5000) + bild_emoji: Optional[str] = Field(None, max_length=10) imdb_rating: Optional[float] = None - streaming: Optional[str] = None + streaming: Optional[str] = Field(None, max_length=500) # ------------------------------------------------------------------ diff --git a/backend/routes/notes.py b/backend/routes/notes.py index 6901f62..5a70c2b 100644 --- a/backend/routes/notes.py +++ b/backend/routes/notes.py @@ -4,7 +4,7 @@ import json import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, Any, List from database import db from auth import get_current_user @@ -18,18 +18,18 @@ logger = logging.getLogger(__name__) # Schemas # ------------------------------------------------------------------ class NoteCreate(BaseModel): - text: str - meta_json: Optional[Any] = None - location_name: Optional[str] = None - parent_label: Optional[str] = None - client_time: Optional[str] = None + text: str = Field(..., min_length=1, max_length=5000) + meta_json: Optional[Any] = None + location_name: Optional[str] = Field(None, max_length=300) + parent_label: Optional[str] = Field(None, max_length=200) + client_time: Optional[str] = Field(None, max_length=64) class NoteUpdate(BaseModel): - text: Optional[str] = None + text: Optional[str] = Field(None, max_length=5000) meta_json: Optional[Any] = None - location_name: Optional[str] = None - parent_label: Optional[str] = None + location_name: Optional[str] = Field(None, max_length=300) + parent_label: Optional[str] = Field(None, max_length=200) # ------------------------------------------------------------------ diff --git a/backend/routes/osm.py b/backend/routes/osm.py index d130898..5fc22b9 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -9,7 +9,7 @@ import httpx import logging from typing import Optional from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import get_current_user, get_current_user_optional as get_optional_user @@ -110,7 +110,7 @@ async def _fetch_overpass(query): except Exception as exc: logger.warning(f"Overpass Verbindungsfehler {url}: {exc}") break # nächste URL - raise Exception("Alle Overpass-Instanzen fehlgeschlagen") + raise HTTPException(503, "Kartendaten gerade nicht verfügbar — bitte später nochmal.") def _stale_tiles(poi_type, tiles): stale = [] @@ -273,11 +273,11 @@ async def get_pois( # POST /user-poi — Community-Marker setzen # ------------------------------------------------------------------ class UserPoiIn(BaseModel): - type: str + type: str = Field(..., max_length=200) lat: float lon: float - name: Optional[str] = None - notiz: Optional[str] = None + name: Optional[str] = Field(None, max_length=300) + notiz: Optional[str] = Field(None, max_length=2000) ALLOWED_TYPES = { 'waste_basket', 'drinking_water', 'dog_park', @@ -331,8 +331,8 @@ async def delete_user_poi(poi_id: int, user = Depends(get_current_user)): # POST /report — Marker als ungültig melden # ------------------------------------------------------------------ class ReportIn(BaseModel): - type: str - grund: str + type: str = Field(..., max_length=100) + grund: str = Field(..., max_length=200) osm_id: Optional[int] = None user_poi_id: Optional[int] = None @@ -388,9 +388,9 @@ async def analyze_region( # POST /pois/{osm_id}/edit — Nutzer schlägt Korrektur vor # ------------------------------------------------------------------ class PoiEditCreate(BaseModel): - poi_name: str - field: str = 'opening_hours' - new_value: str + poi_name: str = Field(..., max_length=300) + field: str = Field('opening_hours', max_length=50) + new_value: str = Field(..., max_length=1000) @router.post('/pois/{osm_id}/edit', status_code=201) diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index d738998..22139e9 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -13,7 +13,7 @@ from typing import List, Optional import logging from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from auth import require_admin from database import db @@ -135,26 +135,26 @@ def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: # ------------------------------------------------------------------ class TemplateIn(BaseModel): - key: str - label: str - subject: str - body: str - from_account: str = "partner" + key: str = Field(..., max_length=100) + label: str = Field(..., max_length=200) + subject: str = Field(..., max_length=500) + body: str = Field(..., max_length=50000) + from_account: str = Field("partner", max_length=50) class TemplateUpdate(BaseModel): - label: str - subject: str - body: str - from_account: str = "partner" + label: str = Field(..., max_length=200) + subject: str = Field(..., max_length=500) + body: str = Field(..., max_length=50000) + from_account: str = Field("partner", max_length=50) class SendRequest(BaseModel): to: List[str] - subject: str - body: str - from_account: str = "partner" - template_id: Optional[int] = None + subject: str = Field(..., max_length=500) + body: str = Field(..., max_length=50000) + from_account: str = Field("partner", max_length=50) + template_id: Optional[int] = None # ------------------------------------------------------------------ diff --git a/backend/routes/partner.py b/backend/routes/partner.py index 359dc1c..16caafb 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -2,7 +2,7 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Depends -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import require_admin, get_current_user @@ -10,8 +10,8 @@ router = APIRouter() class PartnerCodeCreate(BaseModel): - code: str - label: str + code: str = Field(..., min_length=1, max_length=50) + label: str = Field(..., min_length=1, max_length=200) grants_founder: int = 1 max_uses: Optional[int] = None @@ -93,21 +93,34 @@ def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_adm if not target: raise HTTPException(404, "User nicht gefunden.") if updates.get("is_founder") == 1 and not target["founder_number"]: - # Neue Gründer-Nummer zuweisen - total = conn.execute( - "SELECT COUNT(*) FROM users WHERE is_founder=1" - ).fetchone()[0] - if total >= FOUNDER_MAX: + # Atomare Gründer-Vergabe — kein TOCTOU mehr zwischen COUNT und UPDATE. + # Sub-Query wird gegen Snapshot vor dem UPDATE evaluiert (SQL-Spec). + cur = conn.execute( + """UPDATE users + SET is_founder = 1, + founder_number = ( + SELECT IFNULL(MAX(founder_number), 0) + 1 + FROM users WHERE is_founder = 1 + ) + WHERE id = ? + AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < ? + AND (is_founder IS NULL OR is_founder = 0)""", + (user_id, FOUNDER_MAX) + ) + if cur.rowcount == 0: raise HTTPException(400, f"Alle {FOUNDER_MAX} Gründer-Plätze sind vergeben.") - updates["founder_number"] = total + 1 + # is_founder + founder_number sind atomar gesetzt — aus updates entfernen + updates.pop("is_founder", None) + updates.pop("founder_number", None) elif updates.get("is_founder") == 0: # Gründer-Status entfernen → founder_number ebenfalls leeren updates["founder_number"] = None - set_clause = ", ".join(f"{k}=?" for k in updates) - conn.execute( - f"UPDATE users SET {set_clause} WHERE id=?", - (*updates.values(), user_id) - ) + if updates: # nach atomarer Founder-Vergabe ggf. leer + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute( + f"UPDATE users SET {set_clause} WHERE id=?", + (*updates.values(), user_id) + ) row = conn.execute( "SELECT id, name, email, is_founder, is_partner, founder_number FROM users WHERE id=?", (user_id,) diff --git a/backend/routes/passport.py b/backend/routes/passport.py index 884e8d3..733b367 100644 --- a/backend/routes/passport.py +++ b/backend/routes/passport.py @@ -5,7 +5,7 @@ import secrets from datetime import date, datetime, timedelta from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -17,25 +17,25 @@ router = APIRouter() # Schemas # ------------------------------------------------------------------ class PassportMeta(BaseModel): - blutgruppe: Optional[str] = None - allergien: Optional[str] = None - besonderheiten: Optional[str] = None + blutgruppe: Optional[str] = Field(None, max_length=50) + allergien: Optional[str] = Field(None, max_length=2000) + besonderheiten: Optional[str] = Field(None, max_length=2000) class VaccinationCreate(BaseModel): - krankheit: str - datum: str - naechste: Optional[str] = None - tierarzt: Optional[str] = None - charge_nr: Optional[str] = None + krankheit: str = Field(..., max_length=200) + datum: str = Field(..., max_length=32) + naechste: Optional[str] = Field(None, max_length=32) + tierarzt: Optional[str] = Field(None, max_length=200) + charge_nr: Optional[str] = Field(None, max_length=100) class MedicationCreate(BaseModel): - name: str - dosierung: Optional[str] = None - von: Optional[str] = None - bis: Optional[str] = None - notiz: Optional[str] = None + name: str = Field(..., max_length=200) + dosierung: Optional[str] = Field(None, max_length=200) + von: Optional[str] = Field(None, max_length=32) + bis: Optional[str] = Field(None, max_length=32) + notiz: Optional[str] = Field(None, max_length=2000) # ------------------------------------------------------------------ diff --git a/backend/routes/places.py b/backend/routes/places.py index c8ca526..12570c0 100644 --- a/backend/routes/places.py +++ b/backend/routes/places.py @@ -1,50 +1,40 @@ """BAN YARO — Hundefreundliche Orte""" -import math from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db -from auth import get_current_user +from auth import get_current_user, require_owner +from math_utils import haversine_m router = APIRouter() TYPEN = {'restaurant', 'shop', 'freilauf', 'kotbeutel', 'tierarzt', 'hundesalon', 'hundeschule'} -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6_371_000 - p1 = math.radians(lat1) - p2 = math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * R * math.asin(math.sqrt(a)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class PlaceCreate(BaseModel): - name: str - typ: str + name: str = Field(..., min_length=1, max_length=200) + typ: str = Field(..., max_length=50) lat: float lon: float - adresse: Optional[str] = None - website: Optional[str] = None - telefon: Optional[str] = None + adresse: Optional[str] = Field(None, max_length=300) + website: Optional[str] = Field(None, max_length=500) + telefon: Optional[str] = Field(None, max_length=30) hund_rein: Optional[bool] = None leine_pflicht: Optional[bool] = None wasser_fuer_hunde: Optional[bool] = None class PlaceUpdate(BaseModel): - name: Optional[str] = None - typ: Optional[str] = None + name: Optional[str] = Field(None, max_length=200) + typ: Optional[str] = Field(None, max_length=50) lat: Optional[float]= None lon: Optional[float]= None - adresse: Optional[str] = None - website: Optional[str] = None - telefon: Optional[str] = None + adresse: Optional[str] = Field(None, max_length=300) + website: Optional[str] = Field(None, max_length=500) + telefon: Optional[str] = Field(None, max_length=30) hund_rein: Optional[bool] = None leine_pflicht: Optional[bool] = None wasser_fuer_hunde: Optional[bool] = None @@ -79,7 +69,7 @@ async def list_places( result = [_row_to_dict(r) for r in rows] if lat is not None and lon is not None: - result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius] + result = [r for r in result if haversine_m(lat, lon, r['lat'], r['lon']) <= radius] return result @@ -131,11 +121,10 @@ async def get_place(place_id: int): @router.patch("/{place_id}") async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_current_user)): with db() as conn: - row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone() - if not row: - raise HTTPException(404, "Ort nicht gefunden.") - if row['user_id'] != user['id']: - raise HTTPException(403, "Nicht berechtigt.") + row = require_owner( + conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(), + user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt." + ) updates = data.model_dump(exclude_none=True) if not updates: @@ -160,9 +149,8 @@ async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_curren @router.delete("/{place_id}", status_code=204) async def delete_place(place_id: int, user=Depends(get_current_user)): with db() as conn: - row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone() - if not row: - raise HTTPException(404, "Ort nicht gefunden.") - if row['user_id'] != user['id']: - raise HTTPException(403, "Nicht berechtigt.") + require_owner( + conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(), + user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt." + ) conn.execute("DELETE FROM places WHERE id = ?", (place_id,)) diff --git a/backend/routes/playdate.py b/backend/routes/playdate.py index 01d57ae..2f7ebdd 100644 --- a/backend/routes/playdate.py +++ b/backend/routes/playdate.py @@ -1,30 +1,17 @@ """BAN YARO — Playdate-Matching""" -import math import logging from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user +from math_utils import haversine_km router = APIRouter() logger = logging.getLogger(__name__) -# ------------------------------------------------------------------ -# Haversine -# ------------------------------------------------------------------ -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6371.0 - dlat = math.radians(lat2 - lat1) - dlon = math.radians(lon2 - lon1) - a = (math.sin(dlat / 2) ** 2 - + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) - * math.sin(dlon / 2) ** 2) - return R * 2 * math.asin(math.sqrt(a)) - - def _calc_alter(geburtstag: Optional[str]) -> Optional[str]: """Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'.""" if not geburtstag: @@ -53,18 +40,18 @@ class ListingUpsert(BaseModel): dog_id: int lat: float lon: float - ort_name: Optional[str] = None + ort_name: Optional[str] = Field(None, max_length=300) radius_km: int = 10 - beschreibung: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=2000) class RequestCreate(BaseModel): to_dog_id: int - nachricht: Optional[str] = None + nachricht: Optional[str] = Field(None, max_length=2000) class RequestPatch(BaseModel): - status: str # accepted | declined + status: str = Field(..., max_length=30) # accepted | declined # ------------------------------------------------------------------ @@ -107,7 +94,7 @@ async def nearby(lat: float, lon: float, radius: int = 10, result = [] for r in rows: - dist = _haversine(lat, lon, r["lat"], r["lon"]) + dist = haversine_km(lat, lon, r["lat"], r["lon"]) if dist <= radius: result.append({ "listing_id": r["listing_id"], diff --git a/backend/routes/poison.py b/backend/routes/poison.py index 2372e74..97d0ed2 100644 --- a/backend/routes/poison.py +++ b/backend/routes/poison.py @@ -1,45 +1,33 @@ """BAN YARO — Giftköder-Alarm Routes""" -import os, uuid, math +import os, uuid from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user from routes.push import send_push_nearby from media_utils import convert_media from ratelimit import check as rl_check +from math_utils import haversine_m router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") -# ------------------------------------------------------------------ -# Haversine-Distanz in Metern -# ------------------------------------------------------------------ -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6_371_000 - p1 = math.radians(lat1) - p2 = math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * R * math.asin(math.sqrt(a)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class PoisonCreate(BaseModel): lat: float lon: float - beschreibung: Optional[str] = None - typ: str = "unbekannt" + beschreibung: Optional[str] = Field(None, max_length=2000) + typ: str = Field("unbekannt", max_length=50) class PoisonResolve(BaseModel): - grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes + grund: str = Field("beseitigt", max_length=50) # beseitigt | fehlerhaft | anderes # ------------------------------------------------------------------ @@ -62,7 +50,7 @@ async def list_poison(lat: float, lon: float, radius: int = 5000): results = [] for r in rows: entry = dict(r) - dist = _haversine(lat, lon, entry["lat"], entry["lon"]) + dist = haversine_m(lat, lon, entry["lat"], entry["lon"]) if dist <= radius: entry["distanz_m"] = round(dist) results.append(entry) diff --git a/backend/routes/profile.py b/backend/routes/profile.py index baa196c..2fe0e1d 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -7,7 +7,7 @@ import uuid from typing import Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from auth import get_current_user from database import db @@ -20,17 +20,17 @@ VALID_SICHTBARKEIT = {"public", "friends", "private"} class ProfileUpdate(BaseModel): - real_name: Optional[str] = None - bio: Optional[str] = None - wohnort: Optional[str] = None - erfahrung: Optional[str] = None - social_link: Optional[str] = None - profil_sichtbarkeit: Optional[str] = None + real_name: Optional[str] = Field(None, max_length=100) + bio: Optional[str] = Field(None, max_length=300) + wohnort: Optional[str] = Field(None, max_length=60) + erfahrung: Optional[str] = Field(None, max_length=30) + social_link: Optional[str] = Field(None, max_length=120) + profil_sichtbarkeit: Optional[str] = Field(None, max_length=30) notes_ki_enabled: Optional[int] = None gassi_stunde_push: Optional[int] = None - preferred_theme: Optional[str] = None - billing_address: Optional[str] = None - geburtstag: Optional[str] = None + preferred_theme: Optional[str] = Field(None, max_length=20) + billing_address: Optional[str] = Field(None, max_length=500) + geburtstag: Optional[str] = Field(None, max_length=10) def _load_user(user_id: int) -> dict: @@ -61,12 +61,7 @@ async def update_profile(data: ProfileUpdate, user=Depends(get_current_user)): raise HTTPException(400, f"profil_sichtbarkeit muss eines von {sorted(VALID_SICHTBARKEIT)} sein.") if "preferred_theme" in fields and fields["preferred_theme"] not in ("system", "light", "dark"): raise HTTPException(400, "preferred_theme muss 'system', 'light' oder 'dark' sein.") - if "bio" in fields and len(fields["bio"]) > 300: - raise HTTPException(400, "bio darf maximal 300 Zeichen lang sein.") - if "wohnort" in fields and len(fields["wohnort"]) > 60: - raise HTTPException(400, "wohnort darf maximal 60 Zeichen lang sein.") - if "social_link" in fields and len(fields["social_link"]) > 120: - raise HTTPException(400, "social_link darf maximal 120 Zeichen lang sein.") + # Längen-Begrenzungen sind jetzt via Field max_length im Schema abgedeckt. if "geburtstag" in fields and fields["geburtstag"]: if not re.fullmatch(r"\d{2}\.\d{2}", fields["geburtstag"]): raise HTTPException(400, "geburtstag muss im Format TT.MM sein (z.B. 16.05).") diff --git a/backend/routes/push.py b/backend/routes/push.py index 3ee73d9..19dbb32 100644 --- a/backend/routes/push.py +++ b/backend/routes/push.py @@ -4,7 +4,7 @@ import os import json import logging from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from pywebpush import webpush, WebPushException @@ -33,7 +33,7 @@ async def get_vapid_key(): # POST /api/push/subscribe — Subscription speichern # ------------------------------------------------------------------ class PushSubscription(BaseModel): - endpoint: str + endpoint: str = Field(..., max_length=2000) keys: dict # { p256dh, auth } expirationTime: Optional[int] = None diff --git a/backend/routes/ratings.py b/backend/routes/ratings.py index dba63f5..cf8a733 100644 --- a/backend/routes/ratings.py +++ b/backend/routes/ratings.py @@ -1,7 +1,7 @@ """BAN YARO — Bewertungssystem (Ratings)""" from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -23,10 +23,10 @@ TABLE_MAP = { # Schemas # ------------------------------------------------------------------ class RatingCreate(BaseModel): - target_type: str + target_type: str = Field(..., max_length=50) target_id: int stars: int - kommentar: Optional[str] = None + kommentar: Optional[str] = Field(None, max_length=5000) # ------------------------------------------------------------------ diff --git a/backend/routes/recalls.py b/backend/routes/recalls.py index d0182a3..de53542 100644 --- a/backend/routes/recalls.py +++ b/backend/routes/recalls.py @@ -49,12 +49,27 @@ async def list_recalls(q: str = ""): # Interne Hilfsfunktion: RASFF API abfragen # ------------------------------------------------------------------ async def fetch_rasff_recalls() -> list[dict]: - """Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück.""" + """Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück. + + Hinweis: Die EU hat die API mehrfach umgezogen — wenn der Endpoint + 404 oder andere persistent fehler liefert, geben wir [] zurück und + loggen nur als Warning (nicht Error), damit das Error-Digest nicht + täglich spammt. + """ try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(RASFF_URL, params=RASFF_PARAMS) resp.raise_for_status() data = resp.json() + except httpx.HTTPStatusError as e: + if e.response.status_code in (404, 410, 503): + # API umgezogen oder temporär unten — Warning, kein Error + logger.warning( + f"RASFF API liefert {e.response.status_code} (Endpoint vermutlich umgezogen) — überspringe." + ) + else: + logger.error(f"RASFF API-HTTP-Fehler: {e}") + return [] except Exception as e: logger.error(f"RASFF API-Fehler: {e}") return [] diff --git a/backend/routes/routen.py b/backend/routes/routen.py index e1060ef..0e81704 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -1,11 +1,11 @@ """BAN YARO — Gassi-Routen""" import datetime as _dt -import json, math, os, uuid +import json, os, uuid import httpx import polyline as _polyline from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from database import db from auth import get_current_user, get_current_user_optional @@ -13,6 +13,7 @@ from routes.achievements import update_streak, check_and_award from timeutils import safe_client_time from media_utils import convert_media from routes.push import send_push_to_user +from math_utils import haversine_km, haversine_m router = APIRouter() @@ -27,16 +28,6 @@ def _check_speed(distanz_km, dauer_min) -> bool: return (distanz_km / (dauer_min / 60)) <= _MAX_AVG_KMH -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6_371_000 - p1 = math.radians(lat1) - p2 = math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * R * math.asin(math.sqrt(a)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -46,29 +37,29 @@ class GPSPoint(BaseModel): alt: Optional[float] = None class RouteCreate(BaseModel): - name: str - beschreibung: Optional[str] = None + name: str = Field(..., min_length=1, max_length=200) + beschreibung: Optional[str] = Field(None, max_length=5000) gps_track: List[GPSPoint] distanz_km: Optional[float] = None dauer_min: Optional[int] = None - schwierigkeit: Optional[str] = "leicht" # leicht | mittel | anspruchsvoll - untergrund: Optional[str] = None # wald | asphalt | wiese | mix + schwierigkeit: Optional[str] = Field("leicht", max_length=30) # leicht | mittel | anspruchsvoll + untergrund: Optional[str] = Field(None, max_length=50) # wald | asphalt | wiese | mix schatten: Optional[bool] = None leine_empfohlen: Optional[bool] = None is_public: Optional[bool] = False - hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium - client_time: Optional[str] = None + hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) # eingeschränkt | gut | sehr_gut | premium + client_time: Optional[str] = Field(None, max_length=64) dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind class RouteUpdate(BaseModel): - name: Optional[str] = None - beschreibung: Optional[str] = None - schwierigkeit: Optional[str] = None - untergrund: Optional[str] = None + name: Optional[str] = Field(None, max_length=200) + beschreibung: Optional[str] = Field(None, max_length=5000) + schwierigkeit: Optional[str] = Field(None, max_length=30) + untergrund: Optional[str] = Field(None, max_length=50) schatten: Optional[bool] = None leine_empfohlen: Optional[bool] = None is_public: Optional[bool] = None - hunde_tauglichkeit: Optional[str] = None + hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) class RouteDogs(BaseModel): dog_ids: List[int] @@ -137,7 +128,7 @@ async def list_routes( if lat is not None and lon is not None: result = [ r for r in result - if r['start_lat'] and _haversine(lat, lon, r['start_lat'], r['start_lon']) <= radius + if r['start_lat'] and haversine_m(lat, lon, r['start_lat'], r['start_lon']) <= radius ] user_id = user['id'] if user else None @@ -429,10 +420,7 @@ async def trim_route(route_id: int, data: RouteTrim, user=Depends(get_current_us new_km = 0.0 for i in range(1, len(new_track)): p1, p2 = new_track[i-1], new_track[i] - dlat = math.radians(p2['lat'] - p1['lat']) - dlon = math.radians(p2['lon'] - p1['lon']) - a = math.sin(dlat/2)**2 + math.cos(math.radians(p1['lat'])) * math.cos(math.radians(p2['lat'])) * math.sin(dlon/2)**2 - new_km += 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) + new_km += haversine_km(p1['lat'], p1['lon'], p2['lat'], p2['lon']) new_km = round(new_km, 2) # Dauer proportional schätzen (Original-Pace) @@ -565,7 +553,7 @@ async def add_route_photo( # POST /api/routes/{id}/feedback — Feedback an Route-Ersteller # ------------------------------------------------------------------ class RouteFeedback(BaseModel): - text: str + text: str = Field(..., min_length=5, max_length=2000) @router.post("/{route_id}/feedback", status_code=201) async def route_feedback(route_id: int, data: RouteFeedback, user=Depends(get_current_user)): diff --git a/backend/routes/services.py b/backend/routes/services.py index 0696f5f..2d1d0fc 100644 --- a/backend/routes/services.py +++ b/backend/routes/services.py @@ -1,33 +1,23 @@ """BAN YARO — Service-Angebote (Sitting & Walks Matching)""" -import math from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user +from math_utils import haversine_km router = APIRouter() ALLOWED_TYPES = {'sitting', 'walks'} -def _haversine(lat1, lon1, lat2, lon2): - R = 6371.0 - dlat = math.radians(lat2 - lat1) - dlon = math.radians(lon2 - lon1) - a = (math.sin(dlat / 2) ** 2 - + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) - * math.sin(dlon / 2) ** 2) - return R * 2 * math.asin(math.sqrt(a)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class ServiceCreate(BaseModel): - type: str - beschreibung: Optional[str] = None + type: str = Field(..., max_length=30) + beschreibung: Optional[str] = Field(None, max_length=5000) preis_pro_tag: Optional[float] = None lat: Optional[float] = None lon: Optional[float] = None @@ -60,7 +50,7 @@ async def list_services( for r in rows: d = dict(r) if lat is not None and lon is not None and d['lat'] and d['lon']: - dist = _haversine(lat, lon, d['lat'], d['lon']) + dist = haversine_km(lat, lon, d['lat'], d['lon']) if dist > radius: continue d['distanz_km'] = round(dist, 1) diff --git a/backend/routes/sharing.py b/backend/routes/sharing.py index eb2aa58..762c8cd 100644 --- a/backend/routes/sharing.py +++ b/backend/routes/sharing.py @@ -2,7 +2,7 @@ import secrets from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import get_current_user @@ -14,7 +14,7 @@ share_router = APIRouter() class ShareInvite(BaseModel): - role: str = "editor" # viewer | editor + role: str = Field("editor", max_length=20) # viewer | editor # ------------------------------------------------------------------ diff --git a/backend/routes/sitting.py b/backend/routes/sitting.py index acfa2e6..760d942 100644 --- a/backend/routes/sitting.py +++ b/backend/routes/sitting.py @@ -1,32 +1,23 @@ """BAN YARO — Hundesitting""" import json -import math from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from database import db from auth import get_current_user +from math_utils import haversine_m router = APIRouter() SERVICES = {'tagesbetreuung', 'uebernachtung', 'gassi', 'hausbesuch'} -def _haversine(lat1, lon1, lat2, lon2): - R = 6_371_000 - p1, p2 = math.radians(lat1), math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * R * math.asin(math.sqrt(a)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class SitterCreate(BaseModel): - beschreibung: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=5000) preis_pro_tag: float = 0 max_hunde: int = 1 lat: Optional[float] = None @@ -35,7 +26,7 @@ class SitterCreate(BaseModel): services: List[str] = [] class SitterUpdate(BaseModel): - beschreibung: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=5000) preis_pro_tag: Optional[float] = None max_hunde: Optional[int] = None lat: Optional[float] = None @@ -47,12 +38,12 @@ class SitterUpdate(BaseModel): class RequestCreate(BaseModel): sitter_id: int dog_ids: List[int] = [] - von: str # YYYY-MM-DD - bis: str - nachricht: Optional[str] = None + von: str = Field(..., max_length=32) # YYYY-MM-DD + bis: str = Field(..., max_length=32) + nachricht: Optional[str] = Field(None, max_length=2000) class RequestUpdate(BaseModel): - status: str # angenommen | abgelehnt | abgebrochen + status: str = Field(..., max_length=30) # angenommen | abgelehnt | abgebrochen # ------------------------------------------------------------------ @@ -80,7 +71,7 @@ async def list_sitters( if service and service not in d['services']: continue if lat is not None and lon is not None and d['lat'] and d['lon']: - dist = _haversine(lat, lon, d['lat'], d['lon']) + dist = haversine_m(lat, lon, d['lat'], d['lon']) if dist > radius: continue d['distanz_m'] = round(dist) diff --git a/backend/routes/sitting_access.py b/backend/routes/sitting_access.py index b49e681..7bbc6da 100644 --- a/backend/routes/sitting_access.py +++ b/backend/routes/sitting_access.py @@ -1,7 +1,7 @@ """BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)""" from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import get_current_user @@ -11,7 +11,7 @@ router = APIRouter() class AccessCreate(BaseModel): dog_id: int sitter_id: int - valid_until: str # 'YYYY-MM-DD' + valid_until: str = Field(..., max_length=32) # 'YYYY-MM-DD' @router.post("", status_code=201) diff --git a/backend/routes/social.py b/backend/routes/social.py index 1cf204d..10db2d9 100644 --- a/backend/routes/social.py +++ b/backend/routes/social.py @@ -9,7 +9,7 @@ import random from typing import Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from auth import get_current_user, require_social_media from database import db @@ -849,24 +849,24 @@ Antworte NUR mit einem JSON-Objekt: class GenerateRequest(BaseModel): - platform: str = "both" - format: str = "post" - topic: str + platform: str = Field("both", max_length=30) + format: str = Field("post", max_length=30) + topic: str = Field(..., min_length=2, max_length=500) breed_id: Optional[int] = None class EvaluateRequest(BaseModel): - platform: str = "instagram" - format: str = "post" - draft: str + platform: str = Field("instagram", max_length=30) + format: str = Field("post", max_length=30) + draft: str = Field(..., min_length=1, max_length=10000) class StatusUpdate(BaseModel): - status: Optional[str] = None - scheduled_at: Optional[str] = None - published_at: Optional[str] = None - notes: Optional[str] = None - post_url: Optional[str] = None + status: Optional[str] = Field(None, max_length=50) + scheduled_at: Optional[str] = Field(None, max_length=64) + published_at: Optional[str] = Field(None, max_length=64) + notes: Optional[str] = Field(None, max_length=5000) + post_url: Optional[str] = Field(None, max_length=500) def _used_topics(limit: int = 30) -> str: diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py index 8448478..65b1c5e 100644 --- a/backend/routes/tieraerzte.py +++ b/backend/routes/tieraerzte.py @@ -2,7 +2,7 @@ import math from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db from auth import get_current_user @@ -11,20 +11,20 @@ router = APIRouter() class TierarztCreate(BaseModel): - name: str - strasse: Optional[str] = None - plz: Optional[str] = None - ort: Optional[str] = None - telefon: Optional[str] = None - notfall_telefon: Optional[str] = None - email: Optional[str] = None - website: Optional[str] = None - notizen: Optional[str] = None + name: str = Field(..., min_length=1, max_length=200) + strasse: Optional[str] = Field(None, max_length=300) + plz: Optional[str] = Field(None, max_length=20) + ort: Optional[str] = Field(None, max_length=200) + telefon: Optional[str] = Field(None, max_length=30) + notfall_telefon: Optional[str] = Field(None, max_length=30) + email: Optional[str] = Field(None, max_length=254) + website: Optional[str] = Field(None, max_length=500) + notizen: Optional[str] = Field(None, max_length=5000) ist_notfallpraxis: bool = False - opening_hours: Optional[str] = None + opening_hours: Optional[str] = Field(None, max_length=500) lat: Optional[float] = None lon: Optional[float] = None - osm_id: Optional[str] = None + osm_id: Optional[str] = Field(None, max_length=100) class BewertungCreate(BaseModel): @@ -32,25 +32,25 @@ class BewertungCreate(BaseModel): wartezeit: Optional[int] = None freundlichkeit: Optional[int] = None kompetenz: Optional[int] = None - text: Optional[str] = None + text: Optional[str] = Field(None, max_length=5000) class TierarztUpdate(BaseModel): - name: Optional[str] = None - strasse: Optional[str] = None - plz: Optional[str] = None - ort: Optional[str] = None - telefon: Optional[str] = None - notfall_telefon: Optional[str] = None - email: Optional[str] = None - website: Optional[str] = None - notizen: Optional[str] = None + name: Optional[str] = Field(None, max_length=200) + strasse: Optional[str] = Field(None, max_length=300) + plz: Optional[str] = Field(None, max_length=20) + ort: Optional[str] = Field(None, max_length=200) + telefon: Optional[str] = Field(None, max_length=30) + notfall_telefon: Optional[str] = Field(None, max_length=30) + email: Optional[str] = Field(None, max_length=254) + website: Optional[str] = Field(None, max_length=500) + notizen: Optional[str] = Field(None, max_length=5000) ist_notfallpraxis: Optional[bool] = None aktiv: Optional[bool] = None - opening_hours: Optional[str] = None + opening_hours: Optional[str] = Field(None, max_length=500) lat: Optional[float] = None lon: Optional[float] = None - osm_id: Optional[str] = None + osm_id: Optional[str] = Field(None, max_length=100) def _fmt_opening_hours(raw: str | None) -> str | None: diff --git a/backend/routes/training.py b/backend/routes/training.py index 078ceef..5903b6f 100644 --- a/backend/routes/training.py +++ b/backend/routes/training.py @@ -1,7 +1,7 @@ """BAN YARO — Übungs- & Trainingsfortschritt""" from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional import datetime import ki @@ -61,9 +61,9 @@ async def get_exercises(): # Admin: Übung bearbeiten (beschreibung / schritte / tipp) # ------------------------------------------------------------------ class ExerciseUpdate(BaseModel): - beschreibung: Optional[str] = None - schritte: Optional[str] = None # JSON-String: '["Schritt 1", ...]' - tipp: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=10000) + schritte: Optional[str] = Field(None, max_length=10000) # JSON-String: '["Schritt 1", ...]' + tipp: Optional[str] = Field(None, max_length=5000) @router.put("/exercises/{exercise_id}") async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(require_admin)): @@ -93,9 +93,9 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ # Übungs-Status # ------------------------------------------------------------------ class ProgressUpdate(BaseModel): - exercise_id: str - status: Optional[str] = None - dog_id: Optional[int] = None + exercise_id: str = Field(..., max_length=200) + status: Optional[str] = Field(None, max_length=50) + dog_id: Optional[int] = None @router.get("/progress") async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)): @@ -137,9 +137,9 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)): # Trainingsplan-Checkboxen # ------------------------------------------------------------------ class PlanProgress(BaseModel): - item_key: str - checked: bool - dog_id: Optional[int] = None + item_key: str = Field(..., max_length=200) + checked: bool + dog_id: Optional[int] = None @router.get("/plan-progress") async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)): @@ -327,15 +327,15 @@ def _check_badges(conn, user_id: int, dog_name: str) -> list: class SessionCreate(BaseModel): dog_id: int - exercise_id: str - exercise_name: str - datum: Optional[str] = None - wiederholungen: int = 1 - erfolgsquote: int = 50 - hund_stimmung: Optional[str] = "aufmerksam" + exercise_id: str = Field(..., max_length=200) + exercise_name: str = Field(..., max_length=200) + datum: Optional[str] = Field(None, max_length=32) + wiederholungen: int = 1 + erfolgsquote: int = 50 + hund_stimmung: Optional[str] = Field("aufmerksam", max_length=50) zufriedenheit: Optional[int] = 3 - notiz: Optional[str] = None - tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll + notiz: Optional[str] = Field(None, max_length=2000) + tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll @router.post("/sessions") diff --git a/backend/routes/walks.py b/backend/routes/walks.py index 3a0c48b..07dbada 100644 --- a/backend/routes/walks.py +++ b/backend/routes/walks.py @@ -1,55 +1,43 @@ """BAN YARO — Gassi-Treffen""" -import math, os, uuid +import os, uuid import httpx from datetime import date from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from database import db from auth import get_current_user from routes.push import send_push_to_user +from math_utils import haversine_km, haversine_m MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") router = APIRouter() -def _haversine(lat1, lon1, lat2, lon2): - R = 6_371_000 - p1, p2 = math.radians(lat1), math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * R * math.asin(math.sqrt(a)) - - -def _haversine_km(lat1, lon1, lat2, lon2): - return _haversine(lat1, lon1, lat2, lon2) / 1000 - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class WalkCreate(BaseModel): - titel: str - datum: str # YYYY-MM-DD - uhrzeit: str # HH:MM + titel: str = Field(..., min_length=1, max_length=200) + datum: str = Field(..., max_length=32) # YYYY-MM-DD + uhrzeit: str = Field(..., max_length=20) # HH:MM lat: float lon: float - ort_name: Optional[str] = None + ort_name: Optional[str] = Field(None, max_length=300) max_teilnehmer: int = 10 - beschreibung: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=5000) class WalkUpdate(BaseModel): - titel: Optional[str] = None - datum: Optional[str] = None - uhrzeit: Optional[str] = None + titel: Optional[str] = Field(None, max_length=200) + datum: Optional[str] = Field(None, max_length=32) + uhrzeit: Optional[str] = Field(None, max_length=20) lat: Optional[float] = None lon: Optional[float] = None - ort_name: Optional[str] = None + ort_name: Optional[str] = Field(None, max_length=300) max_teilnehmer: Optional[int] = None - beschreibung: Optional[str] = None + beschreibung: Optional[str] = Field(None, max_length=5000) class JoinRequest(BaseModel): dog_ids: List[int] = [] # leere Liste = ohne Hund (selten) @@ -58,7 +46,7 @@ class InviteRequest(BaseModel): friend_id: int class RsvpRequest(BaseModel): - status: str # 'yes' | 'maybe' | 'no' + status: str = Field(..., max_length=20) # 'yes' | 'maybe' | 'no' # ------------------------------------------------------------------ @@ -91,7 +79,7 @@ async def list_walks( # Umkreis-Filter if lat is not None and lon is not None: - result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius] + result = [r for r in result if haversine_m(lat, lon, r['lat'], r['lon']) <= radius] return result @@ -131,7 +119,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)): "SELECT name, typ, lat, lon FROM places WHERE lat IS NOT NULL", ).fetchall() for p in places: - km = _haversine_km(lat, lon, p["lat"], p["lon"]) + km = haversine_km(lat, lon, p["lat"], p["lon"]) if km <= 5: results.append({"name": p["name"], "type": p["typ"] or "place", "lat": p["lat"], "lon": p["lon"], @@ -142,7 +130,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)): "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''" ).fetchall() for p in osm: - km = _haversine_km(lat, lon, p["lat"], p["lon"]) + km = haversine_km(lat, lon, p["lat"], p["lon"]) if km <= 2: results.append({"name": p["name"], "type": p["type"], "lat": p["lat"], "lon": p["lon"], @@ -170,7 +158,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)): elon = el.get("lon") or el.get("center", {}).get("lon") if elat is None or elon is None: continue - km = _haversine_km(lat, lon, elat, elon) + km = haversine_km(lat, lon, elat, elon) if km <= 1: results.append({"name": name, "type": "osm", "lat": elat, "lon": elon, diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index a05bb1b..7af856c 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -6,7 +6,7 @@ import time import logging from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File from fastapi.responses import JSONResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from database import db from auth import get_current_user, get_current_user_optional from ratelimit import check as rl_check, block_ip @@ -36,9 +36,9 @@ async def honeypot(request: Request): # Schemas # ------------------------------------------------------------------ class BerichtCreate(BaseModel): - rasse: str - titel: str - text: str + rasse: str = Field(..., max_length=100) + titel: str = Field(..., min_length=3, max_length=200) + text: str = Field(..., min_length=10, max_length=10000) # ------------------------------------------------------------------ @@ -411,8 +411,8 @@ async def list_submissions(user=Depends(get_current_user)): # PATCH /api/wiki/foto-submissions/{id} — genehmigen oder ablehnen # ------------------------------------------------------------------ class ReviewModel(BaseModel): - action: str # "approve" | "reject" - reject_reason: str = "" + action: str = Field(..., max_length=30) # "approve" | "reject" + reject_reason: str = Field("", max_length=2000) @router.patch("/foto-submissions/{sub_id}") @@ -575,19 +575,19 @@ async def get_rasse_stats(slug: str, user=Depends(get_current_user_optional)): # Schemas für Interesse und Züchter # ------------------------------------------------------------------ class InteresseCreate(BaseModel): - typ: str # "hat" oder "will" + typ: str = Field(..., max_length=30) # "hat" oder "will" class ZuchterCreate(BaseModel): - rasse_slug: str - name: str - zwingername: str = "" - ort: str = "" - plz: str = "" - bundesland: str = "" + rasse_slug: str = Field(..., max_length=100) + name: str = Field(..., min_length=1, max_length=200) + zwingername: str = Field("", max_length=200) + ort: str = Field("", max_length=200) + plz: str = Field("", max_length=20) + bundesland: str = Field("", max_length=100) vdh_mitglied: int = 0 - website: str = "" - telefon: str = "" - beschreibung: str = "" + website: str = Field("", max_length=500) + telefon: str = Field("", max_length=30) + beschreibung: str = Field("", max_length=10000) # ------------------------------------------------------------------ diff --git a/backend/routes/zucht_hunde.py b/backend/routes/zucht_hunde.py index 8ef8c72..45aed56 100644 --- a/backend/routes/zucht_hunde.py +++ b/backend/routes/zucht_hunde.py @@ -2,7 +2,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional from database import db @@ -134,108 +134,108 @@ def _ik_rating(ik: float) -> str: # Pydantic-Schemas # ------------------------------------------------------------------ class HundCreate(BaseModel): - name: str - rufname: Optional[str] = None - geschlecht: str # maennlich|weiblich - geburtsdatum: Optional[str] = None - sterbedatum: Optional[str] = None - chip_nr: Optional[str] = None - taetowiernummer: Optional[str] = None - zuchtbuchnummer: Optional[str] = None - farbe: Optional[str] = None + name: str = Field(..., min_length=1, max_length=200) + rufname: Optional[str] = Field(None, max_length=80) + geschlecht: str = Field(..., max_length=20) # maennlich|weiblich + geburtsdatum: Optional[str] = Field(None, max_length=32) + sterbedatum: Optional[str] = Field(None, max_length=32) + chip_nr: Optional[str] = Field(None, max_length=50) + taetowiernummer: Optional[str] = Field(None, max_length=50) + zuchtbuchnummer: Optional[str] = Field(None, max_length=100) + farbe: Optional[str] = Field(None, max_length=100) vater_id: Optional[int] = None mutter_id: Optional[int] = None - zuechter_name: Optional[str] = None - eigentuemer_name: Optional[str] = None + zuechter_name: Optional[str] = Field(None, max_length=200) + eigentuemer_name: Optional[str] = Field(None, max_length=200) is_public: int = 1 - notiz: Optional[str] = None - foto_url: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=5000) + foto_url: Optional[str] = Field(None, max_length=500) class HundUpdate(BaseModel): - name: Optional[str] = None - rufname: Optional[str] = None - geschlecht: Optional[str] = None - geburtsdatum: Optional[str] = None - sterbedatum: Optional[str] = None - chip_nr: Optional[str] = None - taetowiernummer: Optional[str] = None - zuchtbuchnummer: Optional[str] = None - farbe: Optional[str] = None + name: Optional[str] = Field(None, max_length=200) + rufname: Optional[str] = Field(None, max_length=80) + geschlecht: Optional[str] = Field(None, max_length=20) + geburtsdatum: Optional[str] = Field(None, max_length=32) + sterbedatum: Optional[str] = Field(None, max_length=32) + chip_nr: Optional[str] = Field(None, max_length=50) + taetowiernummer: Optional[str] = Field(None, max_length=50) + zuchtbuchnummer: Optional[str] = Field(None, max_length=100) + farbe: Optional[str] = Field(None, max_length=100) vater_id: Optional[int] = None mutter_id: Optional[int] = None - zuechter_name: Optional[str] = None - eigentuemer_name: Optional[str] = None + zuechter_name: Optional[str] = Field(None, max_length=200) + eigentuemer_name: Optional[str] = Field(None, max_length=200) is_public: Optional[int] = None - notiz: Optional[str] = None - foto_url: Optional[str] = None + notiz: Optional[str] = Field(None, max_length=5000) + foto_url: Optional[str] = Field(None, max_length=500) class HealthTestCreate(BaseModel): - test_typ: str # HD|ED|OCD|augen|herz|patella|ZTP|custom - test_name: Optional[str] = None - ergebnis: Optional[str] = None - untersuch_am: Optional[str] = None - gueltig_bis: Optional[str] = None - untersucher: Optional[str] = None - labor: Optional[str] = None - zertifikat_nr: Optional[str] = None + test_typ: str = Field(..., max_length=50) # HD|ED|OCD|augen|herz|patella|ZTP|custom + test_name: Optional[str] = Field(None, max_length=200) + ergebnis: Optional[str] = Field(None, max_length=500) + untersuch_am: Optional[str] = Field(None, max_length=32) + gueltig_bis: Optional[str] = Field(None, max_length=32) + untersucher: Optional[str] = Field(None, max_length=200) + labor: Optional[str] = Field(None, max_length=200) + zertifikat_nr: Optional[str] = Field(None, max_length=100) is_public: int = 1 class HealthTestUpdate(BaseModel): - test_typ: Optional[str] = None - test_name: Optional[str] = None - ergebnis: Optional[str] = None - untersuch_am: Optional[str] = None - gueltig_bis: Optional[str] = None - untersucher: Optional[str] = None - labor: Optional[str] = None - zertifikat_nr: Optional[str] = None + test_typ: Optional[str] = Field(None, max_length=50) + test_name: Optional[str] = Field(None, max_length=200) + ergebnis: Optional[str] = Field(None, max_length=500) + untersuch_am: Optional[str] = Field(None, max_length=32) + gueltig_bis: Optional[str] = Field(None, max_length=32) + untersucher: Optional[str] = Field(None, max_length=200) + labor: Optional[str] = Field(None, max_length=200) + zertifikat_nr: Optional[str] = Field(None, max_length=100) is_public: Optional[int] = None class GeneticTestCreate(BaseModel): - marker_name: str # MDR1|PRA-prcd|DM|vWD|HUU etc. - marker_kategorie: Optional[str] = None # krankheit|farbe|eigenschaft - genotyp: Optional[str] = None # +/+|+/-|-/- - ergebnis_klasse: Optional[str] = None # clear|carrier|affected - getestet_am: Optional[str] = None - labor: Optional[str] = None - zertifikat_nr: Optional[str] = None + marker_name: str = Field(..., max_length=100) # MDR1|PRA-prcd|DM|vWD|HUU etc. + marker_kategorie: Optional[str] = Field(None, max_length=50) # krankheit|farbe|eigenschaft + genotyp: Optional[str] = Field(None, max_length=20) # +/+|+/-|-/- + ergebnis_klasse: Optional[str] = Field(None, max_length=50) # clear|carrier|affected + getestet_am: Optional[str] = Field(None, max_length=32) + labor: Optional[str] = Field(None, max_length=200) + zertifikat_nr: Optional[str] = Field(None, max_length=100) is_public: int = 1 class GeneticTestUpdate(BaseModel): - marker_name: Optional[str] = None - marker_kategorie: Optional[str] = None - genotyp: Optional[str] = None - ergebnis_klasse: Optional[str] = None - getestet_am: Optional[str] = None - labor: Optional[str] = None - zertifikat_nr: Optional[str] = None + marker_name: Optional[str] = Field(None, max_length=100) + marker_kategorie: Optional[str] = Field(None, max_length=50) + genotyp: Optional[str] = Field(None, max_length=20) + ergebnis_klasse: Optional[str] = Field(None, max_length=50) + getestet_am: Optional[str] = Field(None, max_length=32) + labor: Optional[str] = Field(None, max_length=200) + zertifikat_nr: Optional[str] = Field(None, max_length=100) is_public: Optional[int] = None class TitelCreate(BaseModel): - titel_typ: str # ausstellung|arbeit|sport|zucht|champion|custom - titel_name: str - verliehen_am: Optional[str] = None - ort: Optional[str] = None - richter: Optional[str] = None - ausstellung: Optional[str] = None - formwert: Optional[str] = None + titel_typ: str = Field(..., max_length=50) # ausstellung|arbeit|sport|zucht|champion|custom + titel_name: str = Field(..., min_length=1, max_length=200) + verliehen_am: Optional[str] = Field(None, max_length=32) + ort: Optional[str] = Field(None, max_length=200) + richter: Optional[str] = Field(None, max_length=200) + ausstellung: Optional[str] = Field(None, max_length=200) + formwert: Optional[str] = Field(None, max_length=100) is_public: int = 1 class TitelUpdate(BaseModel): - titel_typ: Optional[str] = None - titel_name: Optional[str] = None - verliehen_am: Optional[str] = None - ort: Optional[str] = None - richter: Optional[str] = None - ausstellung: Optional[str] = None - formwert: Optional[str] = None + titel_typ: Optional[str] = Field(None, max_length=50) + titel_name: Optional[str] = Field(None, max_length=200) + verliehen_am: Optional[str] = Field(None, max_length=32) + ort: Optional[str] = Field(None, max_length=200) + richter: Optional[str] = Field(None, max_length=200) + ausstellung: Optional[str] = Field(None, max_length=200) + formwert: Optional[str] = Field(None, max_length=100) is_public: Optional[int] = None diff --git a/backend/routes/zucht_ki.py b/backend/routes/zucht_ki.py index e25c49c..3f1fd43 100644 --- a/backend/routes/zucht_ki.py +++ b/backend/routes/zucht_ki.py @@ -3,7 +3,7 @@ import logging from datetime import date, timedelta from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, Literal from database import db @@ -41,7 +41,7 @@ class PaarungAnalyseBody(BaseModel): vater_id: int mutter_id: int ik_prozent: Optional[float] = None - welfare_level: Optional[str] = None + welfare_level: Optional[str] = Field(None, max_length=50) class HundBeschreibungBody(BaseModel): diff --git a/backend/scheduler.py b/backend/scheduler.py index 11dcf62..5b816f1 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -46,6 +46,14 @@ def start(): misfire_grace_time=3600, coalesce=True, ) + _scheduler.add_job( + _job_purge_jwt_blacklist, + CronTrigger(hour=3, minute=30), # täglich 03:30 Uhr, nach poison_archive + id="purge_jwt_blacklist", + replace_existing=True, + misfire_grace_time=3600, + coalesce=True, + ) _scheduler.add_job( _job_weather_alert, CronTrigger(hour=7, minute=30), # täglich 07:30 Uhr @@ -1832,11 +1840,13 @@ async def _job_anniversary_reminders(): logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}") with db() as conn: + # diary hat keinen user_id — User kommt über dogs.user_id entries = conn.execute(""" - SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id, + SELECT d.id, d.titel, d.datum, dogs.user_id, d.dog_id, (SELECT dm.url FROM diary_media dm WHERE dm.diary_id=d.id LIMIT 1) AS foto_url FROM diary d + JOIN dogs ON dogs.id = d.dog_id WHERE strftime('%m-%d', d.datum) = ? AND d.datum < date('now') AND d.titel IS NOT NULL @@ -2231,3 +2241,16 @@ async def _job_error_digest(): except Exception as e: logger.error(f"Error-Digest: Mail-Fehler: {e}") _log_job("error_digest", "error", str(e)) + + +def _job_purge_jwt_blacklist(): + """Räumt abgelaufene Einträge aus jwt_blacklist auf — sonst wächst die + Tabelle monoton mit jedem Logout. Läuft täglich 03:30.""" + try: + from auth import _purge_expired_jwt + deleted = _purge_expired_jwt() + logger.info(f"jwt_blacklist: {deleted} abgelaufene Einträge gelöscht.") + _log_job("purge_jwt_blacklist", "ok", f"{deleted} entries deleted") + except Exception as e: + logger.exception(f"jwt_blacklist purge fehlgeschlagen: {e}") + _log_job("purge_jwt_blacklist", "error", str(e)) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 4ec2cef..309f5e4 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -235,6 +235,45 @@ color: var(--c-primary); } +/* ----- .by-tabs Modifier-Varianten ----------------------------- */ + +/* Grid-Layout (Admin/Health/Übungen — Desktop oft 2-3 Spalten) */ +.by-tabs.grid { + display: grid; + grid-template-columns: repeat(var(--tab-cols, 4), minmax(0, 1fr)); + overflow: visible; + gap: var(--space-2); +} + +/* Flex-Wrap (Zuchthunde — Buttons brechen um statt zu scrollen) */ +.by-tabs.wrap { + flex-wrap: wrap; + overflow-x: visible; +} + +/* Separated — eigener Hintergrund + Border (Sitting) */ +.by-tabs.separated { + padding: var(--space-3) var(--space-4) var(--space-2); + border-bottom: 1px solid var(--c-border); + background: var(--c-surface); +} + +/* Sticky (Admin Desktop vertikal) — nur ab 1024px */ +@media (min-width: 1024px) { + .by-tabs.sticky { + position: sticky; + top: var(--space-3); + flex-direction: column; + width: 190px; + gap: var(--space-1); + } + .by-tabs.sticky .by-tab { + justify-content: flex-start; + text-align: left; + padding: var(--space-2) var(--space-3); + } +} + /* ------------------------------------------------------------ 4. BY-SECTION-LABEL + BY-TOOLBAR — weitere gemeinsame Elemente ------------------------------------------------------------ */ @@ -8905,3 +8944,44 @@ svg.empty-state-icon { .offline-status-row .osr-text { flex: 1; min-width: 0; } .offline-status-row .osr-title { font-weight: 600; } .offline-status-row .osr-detail { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: 2px; } + +/* ============================================================ + .map-list-toggle — vereinheitlichter Karten/Listen-Umschalter + Verwendet von walks.js, events.js, routes.js, etc. + +
+ + +
+ ============================================================ */ +.map-list-toggle { + display: flex; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--c-surface); +} +.map-list-toggle button { + flex: 1; + height: 44px; + border: none; + background: transparent; + color: var(--c-text-secondary); + cursor: pointer; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-1); + transition: background 0.15s, color 0.15s; + -webkit-tap-highlight-color: transparent; +} +.map-list-toggle button.active { + background: var(--c-primary); + color: #fff; +} +.map-list-toggle button:not(.active):hover { + background: var(--c-surface-2); + color: var(--c-text); +} diff --git a/backend/static/css/design-system.css b/backend/static/css/design-system.css index d206049..4af159f 100644 --- a/backend/static/css/design-system.css +++ b/backend/static/css/design-system.css @@ -34,7 +34,7 @@ /* Text — Warmbraun aus dem Halsband */ --c-text: #2A1F14; --c-text-secondary: #7A6A58; - --c-text-muted: #B0A090; + --c-text-muted: #7F6B58; /* a11y: WCAG AA 4.74:1 auf --c-bg #FAF7F2 (vorher #B0A090 = 2.37:1) */ --c-text-inverse: #FAF7F2; /* Funktionsfarben */ @@ -179,7 +179,7 @@ --c-text: #F0EAE0; --c-text-secondary: #C0B0A0; - --c-text-muted: #806A58; + --c-text-muted: #A08878; /* a11y: WCAG AA 5.46:1 auf --c-bg #1A1410 (vorher #806A58 = 3.58:1) */ --c-text-inverse: #2A1F14; --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30); diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css index ac91631..a54eedc 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -86,8 +86,8 @@ display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; + width: 44px; + height: 44px; border-radius: var(--radius-md); color: var(--c-text-secondary); cursor: pointer; @@ -99,8 +99,8 @@ /* Hamburger-Button (nur Mobile) */ .header-menu-btn { - width: 40px; - height: 40px; + width: 44px; + height: 44px; display: flex; align-items: center; justify-content: center; diff --git a/backend/static/css/lists.css b/backend/static/css/lists.css new file mode 100644 index 0000000..f8d2014 --- /dev/null +++ b/backend/static/css/lists.css @@ -0,0 +1,328 @@ +/* ============================================================ + BAN YARO — Listen-Komponenten + Wiederverwendbare Klassen für Seiten mit Listen+Detail-Pattern: + Notes, Expenses, Health, Diary, Behavior-Log, ... + + Verwendung: +
+
...
+
Mai 2026
+
+
🍖
+
+
Titel
+
Vorschau-Text
+
+ 10:30 · 📍 Berlin +
+
+
25,50 €
+
+
+ ============================================================ */ + +/* ------------------------------------------------------------ + Shell + Header + ------------------------------------------------------------ */ +.list-shell { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.list-filter-bar { + display: flex; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + flex-wrap: wrap; + align-items: center; +} + +.list-search-wrap { + flex: 1; + min-width: 200px; + position: relative; + display: flex; + align-items: center; +} +.list-search-wrap > input { width: 100%; } + +/* ------------------------------------------------------------ + Group-Header (Monat / Datums-Gruppe) + ------------------------------------------------------------ */ +.list-group-header { + font-size: var(--text-xs); + font-weight: 600; + color: var(--c-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + padding: var(--space-3) var(--space-4) var(--space-1); + margin-top: var(--space-2); +} + +/* ------------------------------------------------------------ + Item-Card (universelle Listen-Karte) + ------------------------------------------------------------ */ +.list-item-card { + display: flex; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--c-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--c-border-light); + align-items: flex-start; + transition: background 0.15s, transform 0.1s; +} + +.list-item-card--clickable { + cursor: pointer; +} +.list-item-card--clickable:hover { + background: var(--c-surface-2); +} +.list-item-card--clickable:active { + transform: scale(0.98); +} + +.list-item-card--milestone { + border-left: 3px solid #f5c518; +} + +.list-item-card--inactive { + opacity: 0.55; + filter: grayscale(0.8); +} + +/* ------------------------------------------------------------ + Linke Spalte: Date-Col oder Meta-Badge + ------------------------------------------------------------ */ + +/* Date-Column (Diary-Style: Wochentag + Tag) */ +.list-item-date-col { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 44px; + text-align: center; +} +.list-item-date-col-weekday { + font-size: var(--text-xs); + font-weight: 600; + color: var(--c-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.list-item-date-col-day { + font-size: 1.5rem; + font-weight: 700; + color: var(--c-text); + line-height: 1.1; +} + +/* Meta-Badge (Expenses/Health-Style: farbiges Icon im Kreis) */ +.list-item-meta-badge { + width: 44px; + height: 44px; + border-radius: 50%; + background: color-mix(in srgb, var(--meta-color, var(--c-primary)) 15%, transparent); + color: var(--meta-color, var(--c-primary)); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + flex-shrink: 0; +} + +/* ------------------------------------------------------------ + Body (Hauptinhalt mittig) + ------------------------------------------------------------ */ +.list-item-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.list-item-title { + font-weight: 600; + font-size: var(--text-base); + color: var(--c-text); + line-height: 1.3; +} + +.list-item-text { + font-size: var(--text-sm); + color: var(--c-text-secondary); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.list-item-meta-row { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-xs); + color: var(--c-text-muted); + flex-wrap: wrap; +} + +/* ------------------------------------------------------------ + Chips + Micro-Badges (in Item-Body) + ------------------------------------------------------------ */ +.list-item-chips { + display: flex; + gap: var(--space-1); + flex-wrap: wrap; +} + +.list-item-chip { + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 4px; + background: color-mix(in srgb, var(--chip-color, var(--c-primary)) 15%, transparent); + color: var(--chip-color, var(--c-primary)); +} + +.list-item-micro-badges { + display: flex; + gap: var(--space-1); + flex-wrap: wrap; + margin-top: 2px; +} +.list-item-micro-badge { + padding: 1px 6px; + background: var(--c-surface-2); + border-radius: var(--radius-sm); + font-size: 11px; + color: var(--c-text-secondary); +} + +/* ------------------------------------------------------------ + Rechte Spalte: Thumbnail, Amount, Actions + ------------------------------------------------------------ */ +.list-item-thumb { + width: 64px; + height: 64px; + border-radius: var(--radius-md); + overflow: hidden; + object-fit: cover; + flex-shrink: 0; + background: var(--c-surface-2); + position: relative; +} +.list-item-thumb-count { + position: absolute; + bottom: 4px; + right: 4px; + background: rgba(0,0,0,0.65); + color: #fff; + font-size: 10px; + font-weight: 700; + padding: 1px 5px; + border-radius: var(--radius-sm); +} + +.list-item-amount { + font-weight: 700; + font-size: var(--text-base); + white-space: nowrap; + flex-shrink: 0; + align-self: center; +} +.list-item-amount--positive { color: var(--c-success); } +.list-item-amount--negative { color: var(--c-danger); } +.list-item-amount--neutral { color: var(--c-text); } + +.list-item-actions { + display: flex; + gap: 2px; + flex-shrink: 0; + align-self: center; +} +.list-item-action-btn { + padding: 6px 8px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--c-text-muted); + cursor: pointer; + font-size: var(--text-sm); + transition: all 0.15s; +} +.list-item-action-btn:hover { + color: var(--c-text); + background: var(--c-surface-2); +} +.list-item-action-btn--danger:hover { + color: var(--c-danger); + background: color-mix(in srgb, var(--c-danger) 10%, transparent); +} + +/* ------------------------------------------------------------ + Reminder/Hinweis-Banner (Health-Style) + ------------------------------------------------------------ */ +.list-reminders-banner { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-2) var(--space-4); +} + +.list-reminder-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + background: var(--c-surface); + border-radius: var(--radius-md); + border-left: 3px solid var(--c-text-muted); + font-size: var(--text-sm); +} +.list-reminder-item--urgent { border-left-color: var(--c-danger); } +.list-reminder-item--warning { border-left-color: var(--c-warning, #f59e0b); } +.list-reminder-item--success { border-left-color: var(--c-success); } + +/* ------------------------------------------------------------ + FAB (Floating Action Button) + ------------------------------------------------------------ */ +.list-fab { + position: fixed; + bottom: calc(env(safe-area-inset-bottom, 16px) + 16px); + right: 20px; + width: 54px; + height: 54px; + border-radius: 50%; + background: var(--c-primary); + color: #fff; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 18px rgba(196,132,58,0.4); + font-size: 26px; + z-index: 80; + transition: transform 0.12s, box-shadow 0.12s; +} +.list-fab:active { + transform: scale(0.92); + box-shadow: 0 2px 10px rgba(196,132,58,0.3); +} + +/* ------------------------------------------------------------ + Load-More + Empty-List in Listen-Context + ------------------------------------------------------------ */ +.list-load-more { + text-align: center; + padding: var(--space-4); +} diff --git a/backend/static/css/utilities.css b/backend/static/css/utilities.css new file mode 100644 index 0000000..f9f5ec2 --- /dev/null +++ b/backend/static/css/utilities.css @@ -0,0 +1,65 @@ +/* ============================================================ + BAN YARO — Utility-Klassen für häufige Inline-Patterns + Ergänzt design-system.css (Single-Property-Utilities sind dort) + ============================================================ */ + +/* ------------------------------------------------------------ + Text + Farb-Kombinationen (häufigste Inline-Patterns) + ------------------------------------------------------------ */ +.text-xs-muted { font-size: var(--text-xs); color: var(--c-text-muted); } +.text-xs-secondary { font-size: var(--text-xs); color: var(--c-text-secondary); } +.text-sm-muted { font-size: var(--text-sm); color: var(--c-text-muted); } +.text-sm-secondary { font-size: var(--text-sm); color: var(--c-text-secondary); } + +/* Caption = Mini-Label/Hinweis unter einem Wert */ +.caption { + font-size: var(--text-xs); + color: var(--c-text-secondary); + margin-top: 2px; +} + +/* ------------------------------------------------------------ + Flex-Layouts (kombiniert) + ------------------------------------------------------------ */ +.flex-gap-2 { display: flex; gap: var(--space-2); } +.flex-gap-3 { display: flex; gap: var(--space-3); } +.flex-col-gap-2 { display: flex; flex-direction: column; gap: var(--space-2); } +.flex-col-gap-3 { display: flex; flex-direction: column; gap: var(--space-3); } +.flex-col-gap-4 { display: flex; flex-direction: column; gap: var(--space-4); } + +.flex-center { display: flex; align-items: center; } +.flex-center-gap-1 { display: flex; align-items: center; gap: var(--space-1); } +.flex-center-gap-2 { display: flex; align-items: center; gap: var(--space-2); } +.flex-center-gap-3 { display: flex; align-items: center; gap: var(--space-3); } + +.flex-between { display: flex; align-items: center; justify-content: space-between; } +.flex-between-gap-2 { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); } + +/* min-width:0 + flex:1 — verhindert Overflow in Flex-Children */ +.flex-1-min { flex: 1; min-width: 0; } + +/* ------------------------------------------------------------ + Spacing-Lücken in design-system.css füllen + ------------------------------------------------------------ */ +.mb-1 { margin-bottom: var(--space-1); } +.mb-3 { margin-bottom: var(--space-3); } +.mt-1 { margin-top: var(--space-1); } +.mt-3 { margin-top: var(--space-3); } + +/* ------------------------------------------------------------ + Icon-Größen (statt width:NNpx;height:NNpx inline) + ------------------------------------------------------------ */ +.icon-xs { width: 12px; height: 12px; } +.icon-sm { width: 14px; height: 14px; } +.icon-md { width: 18px; height: 18px; } +.icon-lg { width: 22px; height: 22px; } + +/* ------------------------------------------------------------ + Form-Helper + ------------------------------------------------------------ */ +.label-block { + display: block; + font-size: var(--text-sm); + font-weight: 600; + margin-bottom: var(--space-1); +} diff --git a/backend/static/index.html b/backend/static/index.html index bf2434e..23df4c7 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,24 +86,14 @@ Ban Yaro - + - - - + + + + + @@ -111,7 +101,8 @@