diff --git a/Dockerfile b/Dockerfile index ff22003..07e8bd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,11 +8,6 @@ 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 @@ -27,12 +22,6 @@ 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 c16bf3f..9402ea3 100644 --- a/Makefile +++ b/Makefile @@ -287,8 +287,7 @@ 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; \ - 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)" + echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html aktualisiert)" # ---------------------------------------------------------- # TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS) diff --git a/VERSION b/VERSION index a2998a8..39987d0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1120 \ No newline at end of file +1099 \ No newline at end of file diff --git a/backend/auth.py b/backend/auth.py index 1b5f126..9cb25c6 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -212,49 +212,6 @@ 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 deleted file mode 100644 index a05b6be..0000000 --- a/backend/config.py +++ /dev/null @@ -1,20 +0,0 @@ -"""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 deleted file mode 100644 index 2cbaf3f..0000000 --- a/backend/errors.py +++ /dev/null @@ -1,47 +0,0 @@ -"""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 aac642e..569a887 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' 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) + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; " + "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: blob: https:; " "connect-src 'self' https:; " "frame-ancestors 'none'; " @@ -1763,40 +1763,19 @@ async def force_update(): Ban Yaro — Update +p{color:#94a3b8;font-size:14px}
⏳ 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 deleted file mode 100644 index a111ec5..0000000 --- a/backend/math_utils.py +++ /dev/null @@ -1,37 +0,0 @@ -"""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 fac5a1c..713a055 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, Field +from pydantic import BaseModel 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 = Field(..., max_length=254) + email: str class UserPatch(BaseModel): - rolle: Optional[str] = Field(None, max_length=30) # user | moderator | admin + rolle: Optional[str] = None # user | moderator | admin is_moderator: Optional[int] = None is_banned: Optional[int] = None - ban_reason: Optional[str] = Field(None, max_length=1000) + ban_reason: Optional[str] = None is_social_media: Optional[int] = None - subscription_tier: Optional[str] = Field(None, max_length=50) + subscription_tier: Optional[str] = None class WikiEnrichBody(BaseModel): limit: int = 10 diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py index d353520..bde0986 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, Field +from pydantic import BaseModel 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,6 +31,18 @@ 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) @@ -222,7 +234,7 @@ async def adoption_nearby( for row in rows: d = dict(row) if d.get("tierheim_lat") and d.get("tierheim_lon"): - dist = haversine_km(lat, lon, d["tierheim_lat"], d["tierheim_lon"]) + dist = _haversine(lat, lon, d["tierheim_lat"], d["tierheim_lon"]) if dist <= radius: d["distanz_km"] = round(dist, 1) cached_animals.append(d) @@ -238,7 +250,7 @@ async def adoption_nearby( # ------ Statische Tierheime (immer) ------ shelters = [] for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS: - dist = haversine_km(lat, lon, slat, slon) + dist = _haversine(lat, lon, slat, slon) if dist <= radius: shelters.append({ "id": sid, @@ -292,7 +304,7 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)): # ================================================================== class InterestBody(BaseModel): - nachricht: Optional[str] = Field(None, max_length=5000) + nachricht: Optional[str] = None # ------------------------------------------------------------------ @@ -342,7 +354,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_km(lat, lon, d["lat"], d["lon"]) + dist = _haversine(lat, lon, d["lat"], d["lon"]) d["distanz_km"] = round(dist, 1) if dist > radius: continue @@ -422,7 +434,7 @@ async def community_create( # PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer) # ------------------------------------------------------------------ class _StatusBody(BaseModel): - status: str = Field(..., max_length=50) + status: str @router.patch("/community/{listing_id}") def community_update_status( diff --git a/backend/routes/alerts.py b/backend/routes/alerts.py index 7f1a0a0..0065d18 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,9 +12,21 @@ _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, lon_delta = bbox_deg_from_km(lat, 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) return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta) @@ -48,7 +60,7 @@ async def nearby_alerts(lat: float, lon: float, user=Depends(get_optional_user)) (lat, lon, user["id"]) ) - 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) + 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) return {"poison": has_poison, "lost": has_lost} diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 18b092b..4a9def8 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, Field +from pydantic import BaseModel, EmailStr 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 = Field(..., min_length=1, max_length=200) + password: str class RegisterRequest(BaseModel): email: EmailStr - 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) + password: str + name: str + ref_code: Optional[str] = None def _gen_referral_code() -> str: @@ -426,8 +426,8 @@ class ForgotPasswordRequest(BaseModel): email: EmailStr class ResetPasswordRequest(BaseModel): - token: str = Field(..., min_length=10, max_length=200) - password: str = Field(..., min_length=8, max_length=200) + token: str + password: str @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 = Field(..., max_length=50) - message: Optional[str] = Field(None, max_length=2000) + tier: str + message: Optional[str] = None @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 e53a1d4..fe4028b 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, Field +from pydantic import BaseModel 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 = Field(..., min_length=3, max_length=2000) + grund: str # ------------------------------------------------------------------ @@ -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] = Field(None, max_length=200) - rasse_text: Optional[str] = Field(None, max_length=200) - verein: Optional[str] = Field(None, max_length=200) + zwingername: Optional[str] = None + rasse_text: Optional[str] = None + verein: Optional[str] = None vdh_mitglied: Optional[int] = 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) + stadt: Optional[str] = None + website: Optional[str] = None + beschreibung: Optional[str] = None @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 802440f..18eb085 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, Field +from pydantic import BaseModel 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 = Field(..., max_length=30) + visibility: str class CaptionBody(BaseModel): - caption: Optional[str] = Field(None, max_length=500) + caption: Optional[str] = None # ------------------------------------------------------------------ diff --git a/backend/routes/chat.py b/backend/routes/chat.py index 7147315..0874303 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, Field +from pydantic import BaseModel 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 = Field(..., min_length=1, max_length=2000) + text: str @router.post("/conversations/{conv_id}/messages", status_code=201) @@ -151,6 +151,8 @@ 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 8d85a84..baf2586 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -1,8 +1,8 @@ """BAN YARO — Tagebuch Routes""" -import os, uuid, json, logging, asyncio +import os, uuid, json, math, logging, asyncio from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel, Field +from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user, require_admin @@ -11,7 +11,6 @@ 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__) @@ -20,27 +19,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") class DiaryCreate(BaseModel): - 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) + 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 tags: Optional[list] = None gps_lat: Optional[float] = None gps_lon: Optional[float] = None - location_name: Optional[str] = Field(None, max_length=300) + location_name: Optional[str] = None is_milestone: bool = False dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary - weather_json: Optional[str] = Field(None, max_length=5000) # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS) + weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS) class DiaryUpdate(BaseModel): - titel: Optional[str] = Field(None, max_length=200) - text: Optional[str] = Field(None, max_length=10000) + titel: Optional[str] = None + text: Optional[str] = None tags: Optional[list] = None gps_lat: Optional[float] = None gps_lon: Optional[float] = None - location_name: Optional[str] = Field(None, max_length=300) + location_name: Optional[str] = None is_milestone: Optional[bool] = None dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen @@ -410,7 +409,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") @@ -423,6 +422,16 @@ 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)): @@ -436,7 +445,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"], @@ -447,7 +456,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"], @@ -494,7 +503,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 1c37b99..9cc2820 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, Field +from pydantic import BaseModel 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 = 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) + name: str + rasse: Optional[str] = None + geburtstag: Optional[str] = None + geschlecht: Optional[str] = None gewicht_kg: Optional[float] = None widerrist_cm: Optional[float] = None - chip_nr: Optional[str] = Field(None, max_length=50) - bio: Optional[str] = Field(None, max_length=2000) - is_public: bool = False + chip_nr: Optional[str] = None + bio: Optional[str] = None + is_public: bool = False class DogUpdate(BaseModel): - 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) + name: Optional[str] = None + rasse: Optional[str] = None + rasse_id: Optional[int] = None + geburtstag: Optional[str] = None + geschlecht: Optional[str] = None gewicht_kg: Optional[float] = None widerrist_cm: Optional[float] = None - chip_nr: Optional[str] = Field(None, max_length=50) - bio: Optional[str] = Field(None, max_length=2000) - is_public: Optional[bool] = None + chip_nr: Optional[str] = None + bio: Optional[str] = None + is_public: Optional[bool] = None @router.get("") @@ -180,22 +180,14 @@ 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: - # 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"],) - ) + 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"]) + ) return dict(dog) @@ -1033,8 +1025,8 @@ async def public_dog_profile(dog_id: int): class FoundReport(BaseModel): - message: Optional[str] = Field(None, max_length=1000) - kontakt: Optional[str] = Field(None, max_length=300) + message: Optional[str] = None + kontakt: Optional[str] = None # Gefunden-Meldung (kein Login nötig) @@ -1319,7 +1311,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 = Field(..., max_length=32) # YYYY-MM-DD + verstorben_am: str # 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 d485c80..2aa4760 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, Field +from pydantic import BaseModel 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] = Field(None, max_length=50) # trocken|nass|barf|mix - marke: Optional[str] = Field(None, max_length=200) + futter_typ: Optional[str] = None # trocken|nass|barf|mix + marke: Optional[str] = None kcal_tag: Optional[int] = None portionen: Optional[int] = None - notizen: Optional[str] = Field(None, max_length=5000) + notizen: Optional[str] = None class KiBeratungRequest(BaseModel): - 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) + frage: str + dog_name: Optional[str] = None + rasse: Optional[str] = None + alter: Optional[str] = None 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 = 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) + datum: str + uhrzeit: str + futter_name: str + futter_typ: Optional[str] = "trockenfutter" menge_g: Optional[int] = None - notiz: Optional[str] = Field(None, max_length=2000) + notiz: Optional[str] = None class ReaktionCreate(BaseModel): - datum: str = Field(..., max_length=32) - uhrzeit: str = Field(..., max_length=20) - reaktion_typ: str = Field(..., max_length=100) + datum: str + uhrzeit: str + reaktion_typ: str intensitaet: Optional[int] = 3 - notiz: Optional[str] = Field(None, max_length=2000) + notiz: Optional[str] = None # ------------------------------------------------------------------ diff --git a/backend/routes/events.py b/backend/routes/events.py index f2ca8a6..c066959 100644 --- a/backend/routes/events.py +++ b/backend/routes/events.py @@ -1,45 +1,54 @@ """BAN YARO — Events (Hundeveranstaltungen)""" +import math from datetime import date from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel 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 = Field('going', max_length=20) # 'going' | 'maybe' + status: str = 'going' # 'going' | 'maybe' class EventCreate(BaseModel): - 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) + titel: str + datum: str # YYYY-MM-DD + uhrzeit: Optional[str] = None lat: Optional[float] = None lon: Optional[float] = 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) + ort_name: Optional[str] = None + typ: str = 'sonstiges' + beschreibung: Optional[str] = None + link: Optional[str] = None class EventUpdate(BaseModel): - titel: Optional[str] = Field(None, max_length=200) - datum: Optional[str] = Field(None, max_length=32) - uhrzeit: Optional[str] = Field(None, max_length=20) + titel: Optional[str] = None + datum: Optional[str] = None + uhrzeit: Optional[str] = None lat: Optional[float] = None lon: Optional[float] = 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) + ort_name: Optional[str] = None + typ: Optional[str] = None + beschreibung: Optional[str] = None + link: Optional[str] = None # ------------------------------------------------------------------ @@ -77,7 +86,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_m(lat, lon, r['lat'], r['lon']) <= radius] + if r['lat'] is None or _haversine(lat, lon, r['lat'], r['lon']) <= radius] return result diff --git a/backend/routes/expenses.py b/backend/routes/expenses.py index 9a34f27..9c93475 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, Field +from pydantic import BaseModel 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 = Field(..., max_length=50) + kategorie: str betrag: float - datum: str = Field(..., max_length=32) - notiz: Optional[str] = Field(None, max_length=1000) + datum: str + notiz: Optional[str] = None class ExpenseUpdate(BaseModel): dog_id: Optional[int] = None - kategorie: Optional[str] = Field(None, max_length=50) + kategorie: Optional[str] = None betrag: Optional[float] = None - datum: Optional[str] = Field(None, max_length=32) - notiz: Optional[str] = Field(None, max_length=1000) + datum: Optional[str] = None + notiz: Optional[str] = None class RecurringCreate(BaseModel): dog_id: Optional[int] = None - kategorie: str = Field(..., max_length=50) + kategorie: str betrag: float - 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) + haeufigkeit: str # monatlich | quartalsweise | jaehrlich + startdatum: str # ISO date + notiz: Optional[str] = None class RecurringUpdate(BaseModel): dog_id: Optional[int] = None - kategorie: Optional[str] = Field(None, max_length=50) + kategorie: Optional[str] = None betrag: Optional[float] = 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) + haeufigkeit: Optional[str] = None + startdatum: Optional[str] = None + notiz: Optional[str] = None aktiv: Optional[bool] = None diff --git a/backend/routes/forum.py b/backend/routes/forum.py index f8ee5e0..2834ab0 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, Field +from pydantic import BaseModel 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 = Field('allgemein', max_length=100) - titel: str = Field(..., min_length=3, max_length=200) - text: str = Field(..., min_length=1, max_length=10000) + kategorie: str = 'allgemein' + titel: str + text: str thread_lat: Optional[float] = None thread_lon: Optional[float] = None - thread_ort: Optional[str] = Field(None, max_length=300) - client_time: Optional[str] = Field(None, max_length=64) + thread_ort: Optional[str] = None + client_time: Optional[str] = None class PostCreate(BaseModel): - text: str = Field(..., min_length=1, max_length=10000) - client_time: Optional[str] = Field(None, max_length=64) + text: str + client_time: Optional[str] = None class ThreadPatch(BaseModel): is_pinned: Optional[int] = None is_locked: Optional[int] = None class ThreadUpdate(BaseModel): - titel: Optional[str] = Field(None, max_length=200) - text: Optional[str] = Field(None, max_length=10000) + titel: Optional[str] = None + text: Optional[str] = None thread_lat: Optional[float] = None thread_lon: Optional[float] = None - thread_ort: Optional[str] = Field(None, max_length=300) + thread_ort: Optional[str] = None class PostUpdate(BaseModel): - text: str = Field(..., min_length=1, max_length=10000) + text: str class LikeBody(BaseModel): - target_type: str = Field(..., max_length=20) # 'thread' | 'post' + target_type: str # 'thread' | 'post' target_id: int class ReportBody(BaseModel): - target_type: str = Field(..., max_length=20) + target_type: str target_id: int - grund: str = Field(..., min_length=3, max_length=1000) + grund: str class LocationBody(BaseModel): lat: Optional[float] = None diff --git a/backend/routes/gassi_zeiten.py b/backend/routes/gassi_zeiten.py index 455aa15..77ff52f 100644 --- a/backend/routes/gassi_zeiten.py +++ b/backend/routes/gassi_zeiten.py @@ -1,28 +1,37 @@ """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, Field +from pydantic import BaseModel 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 = Field(..., max_length=20) # "17:00" - ort_name: Optional[str] = Field(None, max_length=300) + dog_id: Optional[int] = None + wochentage: List[str] # ["mo", "mi", "fr"] + uhrzeit: str # "17:00" + ort_name: Optional[str] = None lat: Optional[float] = None lon: Optional[float] = None radius_m: int = 500 - notiz: Optional[str] = Field(None, max_length=2000) + notiz: Optional[str] = None class GassiZeitUpdate(BaseModel): @@ -74,7 +83,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_m(lat, lon, d["lat"], d["lon"]) + dist = _haversine(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 7b9ed35..34ec5ec 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, Field +from pydantic import BaseModel 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 = 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) + typ: str + bezeichnung: Optional[str] = None + datum: str + naechstes: Optional[str] = None + notiz: Optional[str] = None # Gewicht wert: Optional[float] = None - einheit: Optional[str] = Field("kg", max_length=20) + einheit: Optional[str] = "kg" # Impfung - charge_nr: Optional[str] = Field(None, max_length=100) - tierarzt_name: Optional[str] = Field(None, max_length=200) + charge_nr: Optional[str] = None + tierarzt_name: Optional[str] = None # Tierarztbesuch kosten: Optional[float] = None - diagnose: Optional[str] = Field(None, max_length=2000) + diagnose: Optional[str] = None # Medikament - dosierung: Optional[str] = Field(None, max_length=200) - haeufigkeit: Optional[str] = Field(None, max_length=200) + dosierung: Optional[str] = None + haeufigkeit: Optional[str] = None aktiv: Optional[int] = 1 - bis_datum: Optional[str] = Field(None, max_length=32) + bis_datum: Optional[str] = None # Allergie - schweregrad: Optional[str] = Field(None, max_length=50) # leicht | mittel | schwer - reaktion: Optional[str] = Field(None, max_length=1000) + schweregrad: Optional[str] = None # leicht | mittel | schwer + reaktion: Optional[str] = None 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] = Field(None, max_length=32) - wurftermin: Optional[str] = Field(None, max_length=32) + deckdatum: Optional[str] = None + wurftermin: Optional[str] = None class HealthUpdate(BaseModel): - 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) + bezeichnung: Optional[str] = None + datum: Optional[str] = None + naechstes: Optional[str] = None + notiz: Optional[str] = None wert: Optional[float] = 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) + einheit: Optional[str] = None + charge_nr: Optional[str] = None + tierarzt_name: Optional[str] = None kosten: Optional[float] = 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) + diagnose: Optional[str] = None + dosierung: Optional[str] = None + haeufigkeit: Optional[str] = None aktiv: Optional[int] = 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) + bis_datum: Optional[str] = None + schweregrad: Optional[str] = None + reaktion: Optional[str] = None erinnerung: Optional[int] = None intervall_tage: Optional[int] = None tierarzt_id: Optional[int] = None - deckdatum: Optional[str] = Field(None, max_length=32) - wurftermin: Optional[str] = Field(None, max_length=32) + deckdatum: Optional[str] = None + wurftermin: Optional[str] = None # ------------------------------------------------------------------ @@ -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 = Field(..., min_length=3, max_length=5000) + symptoms: str @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 = Field(..., min_length=1, max_length=200) - police_nr: Optional[str] = Field(None, max_length=100) + anbieter: str + police_nr: Optional[str] = None jahresbeitrag: Optional[float] = 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) + kontakt: Optional[str] = None + ablaufdatum: Optional[str] = None + notizen: Optional[str] = None class InsuranceUpdate(BaseModel): - anbieter: Optional[str] = Field(None, max_length=200) - police_nr: Optional[str] = Field(None, max_length=100) + anbieter: Optional[str] = None + police_nr: Optional[str] = None jahresbeitrag: Optional[float] = 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) + kontakt: Optional[str] = None + ablaufdatum: Optional[str] = None + notizen: Optional[str] = None @router.get("/{dog_id}/insurance") @@ -674,12 +674,12 @@ TRIGGER_LABELS = { class BehaviorCreate(BaseModel): - 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) + datum: str + uhrzeit: Optional[str] = None + kategorie: str + intensitaet: int = 3 + trigger: Optional[str] = None + notiz: Optional[str] = None @router.get("/{dog_id}/behavior") diff --git a/backend/routes/help.py b/backend/routes/help.py index 05803f0..6551e4d 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, Field +from pydantic import BaseModel 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 = 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 + kategorie: str + frage: str + antwort: str + sort_order: int = 0 + aktiv: int = 1 class ArticleUpdate(BaseModel): - kategorie: Optional[str] = Field(None, max_length=100) - frage: Optional[str] = Field(None, max_length=500) - antwort: Optional[str] = Field(None, max_length=10000) + kategorie: Optional[str] = None + frage: Optional[str] = None + antwort: Optional[str] = None sort_order: Optional[int] = None aktiv: Optional[int] = None diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 87d104d..1f27759 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, Field +from pydantic import BaseModel 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 = Field(..., max_length=500) - quantity: float = 1.0 + description: str + quantity: float = 1.0 unit_price: float class InvoiceCreate(BaseModel): user_id: Optional[int] = None - recipient_name: str = Field(..., max_length=200) - recipient_email: str = Field(..., max_length=254) - recipient_address: Optional[str] = Field(None, max_length=500) + recipient_name: str + recipient_email: str + recipient_address: Optional[str] = None items: List[InvoiceItem] discount_pct: Optional[float] = 0.0 - service_period: Optional[str] = Field(None, max_length=200) - notes: Optional[str] = Field(None, max_length=5000) + service_period: Optional[str] = None + notes: Optional[str] = None class PayBody(BaseModel): - paid_at: str = Field(..., max_length=32) + paid_at: str paid_amount: float - notes: Optional[str] = Field(None, max_length=2000) + notes: Optional[str] = None class CancelBody(BaseModel): - reason: str = Field(..., min_length=3, max_length=1000) + reason: str # ------------------------------------------------------------------ diff --git a/backend/routes/ki.py b/backend/routes/ki.py index 5169634..2b16cbe 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, Field +from pydantic import BaseModel 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 = Field(..., min_length=10, max_length=1000) - rasse: Optional[str] = Field(None, max_length=80) - alter: Optional[str] = Field(None, max_length=50) + problem: str + rasse: Optional[str] = None + alter: Optional[str] = None @router.post("/training") @@ -23,6 +23,8 @@ 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" @@ -67,10 +69,10 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon.""" # POST /ki/tierarzt — KI-Tierarztfragen # ------------------------------------------------------------------ class TierarztRequest(BaseModel): - symptom: str = Field(..., min_length=5, max_length=1000) + symptom: str dog_id: Optional[int] = None - dog_name: Optional[str] = Field(None, max_length=80) - rasse: Optional[str] = Field(None, max_length=80) + dog_name: Optional[str] = None + rasse: Optional[str] = None @router.post("/tierarzt") @@ -79,6 +81,8 @@ 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: @@ -169,10 +173,10 @@ def _log_rasse_request(user_id: int): class BirthdayRequest(BaseModel): dog_id: int - name: str = Field(..., max_length=80) - rasse: Optional[str] = Field(None, max_length=80) + name: str + rasse: Optional[str] = None alter: Optional[int] = None - mode: str = Field("tomorrow", max_length=20) # "tomorrow" | "today" + mode: str = "tomorrow" # "tomorrow" | "today" @router.post("/geburtstag") async def ki_geburtstag(req: BirthdayRequest, request: Request, @@ -364,12 +368,12 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array.""" # ------------------------------------------------------------------ class AbschiedRequest(BaseModel): dog_id: int - name: str = Field(..., max_length=80) - rasse: Optional[str] = Field(None, max_length=80) + name: str + rasse: Optional[str] = None km_total: Optional[float] = None diary_count: Optional[int] = None gemeinsam_tage: Optional[int] = None - last_entry_titel: Optional[str] = Field(None, max_length=200) + last_entry_titel: Optional[str] = None @router.post("/abschied") async def ki_abschied(req: AbschiedRequest, request: Request, diff --git a/backend/routes/knigge.py b/backend/routes/knigge.py index d824f56..779d023 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, Field +from pydantic import BaseModel 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 = Field(..., max_length=100) - answer: str = Field(..., max_length=100) + szenario_id: str + answer: str class KiRatRequest(BaseModel): - situation: str = Field(..., min_length=3, max_length=2000) + situation: str # ------------------------------------------------------------------ diff --git a/backend/routes/laeufi.py b/backend/routes/laeufi.py index 5ca7e22..22189bd 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, Field +from pydantic import BaseModel 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 = Field(..., max_length=32) - ende: Optional[str] = Field(None, max_length=32) - notiz: Optional[str] = Field(None, max_length=2000) + beginn: str + ende: Optional[str] = None + notiz: Optional[str] = None class LaeufiUpdate(BaseModel): - beginn: Optional[str] = Field(None, max_length=32) - ende: Optional[str] = Field(None, max_length=32) - notiz: Optional[str] = Field(None, max_length=2000) + beginn: Optional[str] = None + ende: Optional[str] = None + notiz: Optional[str] = None class ProgestCreate(BaseModel): - datum: str = Field(..., max_length=32) + datum: str wert: Optional[float] = 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) + einheit: str = "ng/ml" + labor: Optional[str] = None + notiz: Optional[str] = None class ProgestUpdate(BaseModel): - datum: Optional[str] = Field(None, max_length=32) + datum: Optional[str] = None wert: Optional[float] = 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) + einheit: Optional[str] = None + labor: Optional[str] = None + notiz: Optional[str] = None class DeckCreate(BaseModel): - deckdatum: str = Field(..., max_length=32) + deckdatum: str laeufi_id: Optional[int] = None ruede_id: Optional[int] = None - ruede_name: Optional[str] = Field(None, max_length=200) - deckart: str = Field("natuerlich", max_length=50) + ruede_name: Optional[str] = None + deckart: str = "natuerlich" traechtig: int = 0 - ultraschall_datum: Optional[str] = Field(None, max_length=32) - notiz: Optional[str] = Field(None, max_length=2000) + ultraschall_datum: Optional[str] = None + notiz: Optional[str] = None class DeckUpdate(BaseModel): - deckdatum: Optional[str] = Field(None, max_length=32) + deckdatum: Optional[str] = None ruede_id: Optional[int] = None - ruede_name: Optional[str] = Field(None, max_length=200) - deckart: Optional[str] = Field(None, max_length=50) + ruede_name: Optional[str] = None + deckart: Optional[str] = None traechtig: Optional[int] = None - ultraschall_datum: Optional[str] = Field(None, max_length=32) - notiz: Optional[str] = Field(None, max_length=2000) + ultraschall_datum: Optional[str] = None + notiz: Optional[str] = None # ------------------------------------------------------------------ diff --git a/backend/routes/litters.py b/backend/routes/litters.py index 3294641..09250d8 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, Field +from pydantic import BaseModel 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] = 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) + 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 vater_id: Optional[int] = None mutter_id: Optional[int] = None - geburt_datum: Optional[str] = Field(None, max_length=32) - erwartetes_datum: Optional[str] = Field(None, max_length=32) + geburt_datum: Optional[str] = None + erwartetes_datum: Optional[str] = None welpen_gesamt: Optional[int] = None welpen_verfuegbar: Optional[int] = 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: str = Field("geplant", max_length=30) + beschreibung: Optional[str] = None + gesundheitstests: Optional[str] = None + preis_spanne: Optional[str] = None + status: str = "geplant" sichtbar: int = 0 - sichtbar_bis: Optional[str] = Field(None, max_length=32) + sichtbar_bis: Optional[str] = None class LitterUpdate(BaseModel): - 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) + wurf_rang: Optional[str] = None + wurf_name: Optional[str] = None + vater_name: Optional[str] = None + mutter_name: Optional[str] = None vater_id: Optional[int] = None mutter_id: Optional[int] = None - geburt_datum: Optional[str] = Field(None, max_length=32) - erwartetes_datum: Optional[str] = Field(None, max_length=32) + geburt_datum: Optional[str] = None + erwartetes_datum: Optional[str] = None welpen_gesamt: Optional[int] = None welpen_verfuegbar: Optional[int] = 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) + beschreibung: Optional[str] = None + gesundheitstests: Optional[str] = None + preis_spanne: Optional[str] = None + status: Optional[str] = None sichtbar: Optional[int] = None - sichtbar_bis: Optional[str] = Field(None, max_length=32) + sichtbar_bis: Optional[str] = None class PuppyCreate(BaseModel): - 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) + name: Optional[str] = None + geschlecht: Optional[str] = None # maennlich|weiblich + farbe: Optional[str] = None + chip_nr: Optional[str] = None geburtsgewicht: Optional[float] = None # Gramm - status: str = Field("verfuegbar", max_length=30) # verfuegbar|reserviert|abgegeben + status: str = "verfuegbar" # verfuegbar|reserviert|abgegeben status_sichtbar: int = 1 - notiz: Optional[str] = Field(None, max_length=2000) + notiz: Optional[str] = None class PuppyUpdate(BaseModel): - 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) + name: Optional[str] = None + geschlecht: Optional[str] = None + farbe: Optional[str] = None + chip_nr: Optional[str] = None geburtsgewicht: Optional[float] = None - status: Optional[str] = Field(None, max_length=30) + status: Optional[str] = None status_sichtbar: Optional[int] = None - notiz: Optional[str] = Field(None, max_length=2000) + notiz: Optional[str] = None class WeightEntry(BaseModel): gewicht_g: float - gemessen_am: str = Field(..., max_length=32) # YYYY-MM-DD + gemessen_am: str # YYYY-MM-DD # ------------------------------------------------------------------ @@ -663,15 +663,15 @@ async def generate_contract( # Warteliste # ------------------------------------------------------------------ class WaitlistEntry(BaseModel): - 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) + name: str + email: Optional[str] = None + telefon: Optional[str] = None + nachricht: Optional[str] = None + wunsch_geschlecht: str = "egal" + wunsch_farbe: Optional[str] = None prioritaet: int = 0 - status: str = Field("anfrage", max_length=30) - notiz: Optional[str] = Field(None, max_length=2000) + status: str = "anfrage" + notiz: Optional[str] = None class WaitlistUpdate(BaseModel): diff --git a/backend/routes/lost.py b/backend/routes/lost.py index 065145f..3b02ed3 100644 --- a/backend/routes/lost.py +++ b/backend/routes/lost.py @@ -1,32 +1,44 @@ """BAN YARO — Verlorener Hund Routes""" -import os, uuid +import os, uuid, math from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel, Field +from pydantic import BaseModel 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 = Field(..., min_length=1, max_length=80) - rasse: Optional[str] = Field(None, max_length=80) - beschreibung: str = Field(..., min_length=3, max_length=5000) + name: str + rasse: Optional[str] = None + beschreibung: str lat: float lon: float dog_id: Optional[int] = None - client_time: Optional[str] = Field(None, max_length=64) + client_time: Optional[str] = None # ------------------------------------------------------------------ @@ -48,7 +60,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_m(lat, lon, entry["lat"], entry["lon"]) + dist = _haversine(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 79b3d95..da6c682 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, Field +from pydantic import BaseModel 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 = Field(..., max_length=100) - titel: str = Field(..., min_length=1, max_length=200) - originaltitel: Optional[str] = Field(None, max_length=200) + id: str + titel: str + originaltitel: Optional[str] = None jahr: Optional[int] = None - genre: Optional[str] = Field(None, max_length=100) - typ: str = Field("film", max_length=30) - hund_rasse: Optional[str] = Field(None, max_length=200) - stirbt_der_hund: bool = False - beschreibung: Optional[str] = Field(None, max_length=5000) - bild_emoji: str = Field("🐾", max_length=10) + genre: Optional[str] = None + typ: str = "film" + hund_rasse: Optional[str] = None + stirbt_der_hund: bool = False + beschreibung: Optional[str] = None + bild_emoji: str = "🐾" imdb_rating: Optional[float] = None - streaming: Optional[str] = Field(None, max_length=500) + streaming: Optional[str] = None class MovieUpdate(BaseModel): - titel: Optional[str] = Field(None, max_length=200) - originaltitel: Optional[str] = Field(None, max_length=200) + titel: Optional[str] = None + originaltitel: Optional[str] = None jahr: Optional[int] = 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) + genre: Optional[str] = None + typ: Optional[str] = None + hund_rasse: Optional[str] = None stirbt_der_hund: Optional[bool] = None - beschreibung: Optional[str] = Field(None, max_length=5000) - bild_emoji: Optional[str] = Field(None, max_length=10) + beschreibung: Optional[str] = None + bild_emoji: Optional[str] = None imdb_rating: Optional[float] = None - streaming: Optional[str] = Field(None, max_length=500) + streaming: Optional[str] = None # ------------------------------------------------------------------ diff --git a/backend/routes/notes.py b/backend/routes/notes.py index 5a70c2b..6901f62 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, Field +from pydantic import BaseModel 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 = 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) + text: str + meta_json: Optional[Any] = None + location_name: Optional[str] = None + parent_label: Optional[str] = None + client_time: Optional[str] = None class NoteUpdate(BaseModel): - text: Optional[str] = Field(None, max_length=5000) + text: Optional[str] = None meta_json: Optional[Any] = None - location_name: Optional[str] = Field(None, max_length=300) - parent_label: Optional[str] = Field(None, max_length=200) + location_name: Optional[str] = None + parent_label: Optional[str] = None # ------------------------------------------------------------------ diff --git a/backend/routes/osm.py b/backend/routes/osm.py index 5fc22b9..d130898 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, Field +from pydantic import BaseModel 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 HTTPException(503, "Kartendaten gerade nicht verfügbar — bitte später nochmal.") + raise Exception("Alle Overpass-Instanzen fehlgeschlagen") 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 = Field(..., max_length=200) + type: str lat: float lon: float - name: Optional[str] = Field(None, max_length=300) - notiz: Optional[str] = Field(None, max_length=2000) + name: Optional[str] = None + notiz: Optional[str] = None 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 = Field(..., max_length=100) - grund: str = Field(..., max_length=200) + type: str + grund: str 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(..., max_length=300) - field: str = Field('opening_hours', max_length=50) - new_value: str = Field(..., max_length=1000) + poi_name: str + field: str = 'opening_hours' + new_value: str @router.post('/pois/{osm_id}/edit', status_code=201) diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index 22139e9..d738998 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, Field +from pydantic import BaseModel 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 = 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) + key: str + label: str + subject: str + body: str + from_account: str = "partner" class TemplateUpdate(BaseModel): - 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) + label: str + subject: str + body: str + from_account: str = "partner" class SendRequest(BaseModel): to: List[str] - 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 + subject: str + body: str + from_account: str = "partner" + template_id: Optional[int] = None # ------------------------------------------------------------------ diff --git a/backend/routes/partner.py b/backend/routes/partner.py index 16caafb..359dc1c 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, Field +from pydantic import BaseModel from database import db from auth import require_admin, get_current_user @@ -10,8 +10,8 @@ router = APIRouter() class PartnerCodeCreate(BaseModel): - code: str = Field(..., min_length=1, max_length=50) - label: str = Field(..., min_length=1, max_length=200) + code: str + label: str grants_founder: int = 1 max_uses: Optional[int] = None @@ -93,34 +93,21 @@ 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"]: - # 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: + # Neue Gründer-Nummer zuweisen + total = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + if total >= FOUNDER_MAX: raise HTTPException(400, f"Alle {FOUNDER_MAX} Gründer-Plätze sind vergeben.") - # is_founder + founder_number sind atomar gesetzt — aus updates entfernen - updates.pop("is_founder", None) - updates.pop("founder_number", None) + updates["founder_number"] = total + 1 elif updates.get("is_founder") == 0: # Gründer-Status entfernen → founder_number ebenfalls leeren updates["founder_number"] = None - 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) - ) + 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 733b367..884e8d3 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, Field +from pydantic import BaseModel 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] = Field(None, max_length=50) - allergien: Optional[str] = Field(None, max_length=2000) - besonderheiten: Optional[str] = Field(None, max_length=2000) + blutgruppe: Optional[str] = None + allergien: Optional[str] = None + besonderheiten: Optional[str] = None class VaccinationCreate(BaseModel): - 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) + krankheit: str + datum: str + naechste: Optional[str] = None + tierarzt: Optional[str] = None + charge_nr: Optional[str] = None class MedicationCreate(BaseModel): - 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) + name: str + dosierung: Optional[str] = None + von: Optional[str] = None + bis: Optional[str] = None + notiz: Optional[str] = None # ------------------------------------------------------------------ diff --git a/backend/routes/places.py b/backend/routes/places.py index 12570c0..c8ca526 100644 --- a/backend/routes/places.py +++ b/backend/routes/places.py @@ -1,40 +1,50 @@ """BAN YARO — Hundefreundliche Orte""" +import math from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel from typing import Optional from database import db -from auth import get_current_user, require_owner -from math_utils import haversine_m +from auth import get_current_user 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 = Field(..., min_length=1, max_length=200) - typ: str = Field(..., max_length=50) + name: str + typ: str lat: float lon: float - adresse: Optional[str] = Field(None, max_length=300) - website: Optional[str] = Field(None, max_length=500) - telefon: Optional[str] = Field(None, max_length=30) + adresse: Optional[str] = None + website: Optional[str] = None + telefon: Optional[str] = None hund_rein: Optional[bool] = None leine_pflicht: Optional[bool] = None wasser_fuer_hunde: Optional[bool] = None class PlaceUpdate(BaseModel): - name: Optional[str] = Field(None, max_length=200) - typ: Optional[str] = Field(None, max_length=50) + name: Optional[str] = None + typ: Optional[str] = None lat: Optional[float]= None lon: Optional[float]= 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) + adresse: Optional[str] = None + website: Optional[str] = None + telefon: Optional[str] = None hund_rein: Optional[bool] = None leine_pflicht: Optional[bool] = None wasser_fuer_hunde: Optional[bool] = None @@ -69,7 +79,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_m(lat, lon, r['lat'], r['lon']) <= radius] + result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius] return result @@ -121,10 +131,11 @@ 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 = require_owner( - conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(), - user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt." - ) + 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.") updates = data.model_dump(exclude_none=True) if not updates: @@ -149,8 +160,9 @@ 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: - require_owner( - conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(), - user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt." - ) + 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.") conn.execute("DELETE FROM places WHERE id = ?", (place_id,)) diff --git a/backend/routes/playdate.py b/backend/routes/playdate.py index 2f7ebdd..01d57ae 100644 --- a/backend/routes/playdate.py +++ b/backend/routes/playdate.py @@ -1,17 +1,30 @@ """BAN YARO — Playdate-Matching""" +import math import logging from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel 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: @@ -40,18 +53,18 @@ class ListingUpsert(BaseModel): dog_id: int lat: float lon: float - ort_name: Optional[str] = Field(None, max_length=300) + ort_name: Optional[str] = None radius_km: int = 10 - beschreibung: Optional[str] = Field(None, max_length=2000) + beschreibung: Optional[str] = None class RequestCreate(BaseModel): to_dog_id: int - nachricht: Optional[str] = Field(None, max_length=2000) + nachricht: Optional[str] = None class RequestPatch(BaseModel): - status: str = Field(..., max_length=30) # accepted | declined + status: str # accepted | declined # ------------------------------------------------------------------ @@ -94,7 +107,7 @@ async def nearby(lat: float, lon: float, radius: int = 10, result = [] for r in rows: - dist = haversine_km(lat, lon, r["lat"], r["lon"]) + dist = _haversine(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 97d0ed2..2372e74 100644 --- a/backend/routes/poison.py +++ b/backend/routes/poison.py @@ -1,33 +1,45 @@ """BAN YARO — Giftköder-Alarm Routes""" -import os, uuid +import os, uuid, math from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File -from pydantic import BaseModel, Field +from pydantic import BaseModel 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] = Field(None, max_length=2000) - typ: str = Field("unbekannt", max_length=50) + beschreibung: Optional[str] = None + typ: str = "unbekannt" class PoisonResolve(BaseModel): - grund: str = Field("beseitigt", max_length=50) # beseitigt | fehlerhaft | anderes + grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes # ------------------------------------------------------------------ @@ -50,7 +62,7 @@ async def list_poison(lat: float, lon: float, radius: int = 5000): results = [] for r in rows: entry = dict(r) - dist = haversine_m(lat, lon, entry["lat"], entry["lon"]) + dist = _haversine(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 2fe0e1d..baa196c 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, Field +from pydantic import BaseModel 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] = 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) + 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 notes_ki_enabled: Optional[int] = None gassi_stunde_push: Optional[int] = 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) + preferred_theme: Optional[str] = None + billing_address: Optional[str] = None + geburtstag: Optional[str] = None def _load_user(user_id: int) -> dict: @@ -61,7 +61,12 @@ 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.") - # Längen-Begrenzungen sind jetzt via Field max_length im Schema abgedeckt. + 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.") 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 19dbb32..3ee73d9 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, Field +from pydantic import BaseModel 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 = Field(..., max_length=2000) + endpoint: str keys: dict # { p256dh, auth } expirationTime: Optional[int] = None diff --git a/backend/routes/ratings.py b/backend/routes/ratings.py index cf8a733..dba63f5 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, Field +from pydantic import BaseModel 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 = Field(..., max_length=50) + target_type: str target_id: int stars: int - kommentar: Optional[str] = Field(None, max_length=5000) + kommentar: Optional[str] = None # ------------------------------------------------------------------ diff --git a/backend/routes/recalls.py b/backend/routes/recalls.py index de53542..d0182a3 100644 --- a/backend/routes/recalls.py +++ b/backend/routes/recalls.py @@ -49,27 +49,12 @@ 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. - - 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. - """ + """Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück.""" 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 0e81704..e1060ef 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, os, uuid +import json, math, os, uuid import httpx import polyline as _polyline from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel, Field +from pydantic import BaseModel from typing import Optional, List from database import db from auth import get_current_user, get_current_user_optional @@ -13,7 +13,6 @@ 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() @@ -28,6 +27,16 @@ 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 # ------------------------------------------------------------------ @@ -37,29 +46,29 @@ class GPSPoint(BaseModel): alt: Optional[float] = None class RouteCreate(BaseModel): - name: str = Field(..., min_length=1, max_length=200) - beschreibung: Optional[str] = Field(None, max_length=5000) + name: str + beschreibung: Optional[str] = None gps_track: List[GPSPoint] distanz_km: Optional[float] = None dauer_min: Optional[int] = None - schwierigkeit: Optional[str] = Field("leicht", max_length=30) # leicht | mittel | anspruchsvoll - untergrund: Optional[str] = Field(None, max_length=50) # wald | asphalt | wiese | mix + schwierigkeit: Optional[str] = "leicht" # leicht | mittel | anspruchsvoll + untergrund: Optional[str] = None # wald | asphalt | wiese | mix schatten: Optional[bool] = None leine_empfohlen: Optional[bool] = None is_public: Optional[bool] = False - hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) # eingeschränkt | gut | sehr_gut | premium - client_time: Optional[str] = Field(None, max_length=64) + hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium + client_time: Optional[str] = None dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind class RouteUpdate(BaseModel): - 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) + name: Optional[str] = None + beschreibung: Optional[str] = None + schwierigkeit: Optional[str] = None + untergrund: Optional[str] = None schatten: Optional[bool] = None leine_empfohlen: Optional[bool] = None is_public: Optional[bool] = None - hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) + hunde_tauglichkeit: Optional[str] = None class RouteDogs(BaseModel): dog_ids: List[int] @@ -128,7 +137,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_m(lat, lon, r['start_lat'], r['start_lon']) <= radius + if r['start_lat'] and _haversine(lat, lon, r['start_lat'], r['start_lon']) <= radius ] user_id = user['id'] if user else None @@ -420,7 +429,10 @@ 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] - new_km += haversine_km(p1['lat'], p1['lon'], p2['lat'], p2['lon']) + 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 = round(new_km, 2) # Dauer proportional schätzen (Original-Pace) @@ -553,7 +565,7 @@ async def add_route_photo( # POST /api/routes/{id}/feedback — Feedback an Route-Ersteller # ------------------------------------------------------------------ class RouteFeedback(BaseModel): - text: str = Field(..., min_length=5, max_length=2000) + text: str @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 2d1d0fc..0696f5f 100644 --- a/backend/routes/services.py +++ b/backend/routes/services.py @@ -1,23 +1,33 @@ """BAN YARO — Service-Angebote (Sitting & Walks Matching)""" +import math from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel 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 = Field(..., max_length=30) - beschreibung: Optional[str] = Field(None, max_length=5000) + type: str + beschreibung: Optional[str] = None preis_pro_tag: Optional[float] = None lat: Optional[float] = None lon: Optional[float] = None @@ -50,7 +60,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_km(lat, lon, d['lat'], d['lon']) + dist = _haversine(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 762c8cd..eb2aa58 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, Field +from pydantic import BaseModel from database import db from auth import get_current_user @@ -14,7 +14,7 @@ share_router = APIRouter() class ShareInvite(BaseModel): - role: str = Field("editor", max_length=20) # viewer | editor + role: str = "editor" # viewer | editor # ------------------------------------------------------------------ diff --git a/backend/routes/sitting.py b/backend/routes/sitting.py index 760d942..acfa2e6 100644 --- a/backend/routes/sitting.py +++ b/backend/routes/sitting.py @@ -1,23 +1,32 @@ """BAN YARO — Hundesitting""" import json +import math from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel 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] = Field(None, max_length=5000) + beschreibung: Optional[str] = None preis_pro_tag: float = 0 max_hunde: int = 1 lat: Optional[float] = None @@ -26,7 +35,7 @@ class SitterCreate(BaseModel): services: List[str] = [] class SitterUpdate(BaseModel): - beschreibung: Optional[str] = Field(None, max_length=5000) + beschreibung: Optional[str] = None preis_pro_tag: Optional[float] = None max_hunde: Optional[int] = None lat: Optional[float] = None @@ -38,12 +47,12 @@ class SitterUpdate(BaseModel): class RequestCreate(BaseModel): sitter_id: int dog_ids: List[int] = [] - von: str = Field(..., max_length=32) # YYYY-MM-DD - bis: str = Field(..., max_length=32) - nachricht: Optional[str] = Field(None, max_length=2000) + von: str # YYYY-MM-DD + bis: str + nachricht: Optional[str] = None class RequestUpdate(BaseModel): - status: str = Field(..., max_length=30) # angenommen | abgelehnt | abgebrochen + status: str # angenommen | abgelehnt | abgebrochen # ------------------------------------------------------------------ @@ -71,7 +80,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_m(lat, lon, d['lat'], d['lon']) + dist = _haversine(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 7bbc6da..b49e681 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, Field +from pydantic import BaseModel 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 = Field(..., max_length=32) # 'YYYY-MM-DD' + valid_until: str # 'YYYY-MM-DD' @router.post("", status_code=201) diff --git a/backend/routes/social.py b/backend/routes/social.py index 10db2d9..1cf204d 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, Field +from pydantic import BaseModel 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 = Field("both", max_length=30) - format: str = Field("post", max_length=30) - topic: str = Field(..., min_length=2, max_length=500) + platform: str = "both" + format: str = "post" + topic: str breed_id: Optional[int] = None class EvaluateRequest(BaseModel): - platform: str = Field("instagram", max_length=30) - format: str = Field("post", max_length=30) - draft: str = Field(..., min_length=1, max_length=10000) + platform: str = "instagram" + format: str = "post" + draft: str class StatusUpdate(BaseModel): - 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) + status: Optional[str] = None + scheduled_at: Optional[str] = None + published_at: Optional[str] = None + notes: Optional[str] = None + post_url: Optional[str] = None def _used_topics(limit: int = 30) -> str: diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py index 65b1c5e..8448478 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, Field +from pydantic import BaseModel 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 = 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) + 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 ist_notfallpraxis: bool = False - opening_hours: Optional[str] = Field(None, max_length=500) + opening_hours: Optional[str] = None lat: Optional[float] = None lon: Optional[float] = None - osm_id: Optional[str] = Field(None, max_length=100) + osm_id: Optional[str] = None 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] = Field(None, max_length=5000) + text: Optional[str] = None class TierarztUpdate(BaseModel): - 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) + 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 ist_notfallpraxis: Optional[bool] = None aktiv: Optional[bool] = None - opening_hours: Optional[str] = Field(None, max_length=500) + opening_hours: Optional[str] = None lat: Optional[float] = None lon: Optional[float] = None - osm_id: Optional[str] = Field(None, max_length=100) + osm_id: Optional[str] = None def _fmt_opening_hours(raw: str | None) -> str | None: diff --git a/backend/routes/training.py b/backend/routes/training.py index 5903b6f..078ceef 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, Field +from pydantic import BaseModel 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] = 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) + beschreibung: Optional[str] = None + schritte: Optional[str] = None # JSON-String: '["Schritt 1", ...]' + tipp: Optional[str] = None @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 = Field(..., max_length=200) - status: Optional[str] = Field(None, max_length=50) - dog_id: Optional[int] = None + exercise_id: str + status: Optional[str] = None + 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 = Field(..., max_length=200) - checked: bool - dog_id: Optional[int] = None + item_key: str + 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 = 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) + exercise_id: str + exercise_name: str + datum: Optional[str] = None + wiederholungen: int = 1 + erfolgsquote: int = 50 + hund_stimmung: Optional[str] = "aufmerksam" zufriedenheit: Optional[int] = 3 - notiz: Optional[str] = Field(None, max_length=2000) - tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll + notiz: Optional[str] = None + 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 07dbada..3a0c48b 100644 --- a/backend/routes/walks.py +++ b/backend/routes/walks.py @@ -1,43 +1,55 @@ """BAN YARO — Gassi-Treffen""" -import os, uuid +import math, os, uuid import httpx from datetime import date from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from pydantic import BaseModel, Field +from pydantic import BaseModel 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 = Field(..., min_length=1, max_length=200) - datum: str = Field(..., max_length=32) # YYYY-MM-DD - uhrzeit: str = Field(..., max_length=20) # HH:MM + titel: str + datum: str # YYYY-MM-DD + uhrzeit: str # HH:MM lat: float lon: float - ort_name: Optional[str] = Field(None, max_length=300) + ort_name: Optional[str] = None max_teilnehmer: int = 10 - beschreibung: Optional[str] = Field(None, max_length=5000) + beschreibung: Optional[str] = None class WalkUpdate(BaseModel): - titel: Optional[str] = Field(None, max_length=200) - datum: Optional[str] = Field(None, max_length=32) - uhrzeit: Optional[str] = Field(None, max_length=20) + titel: Optional[str] = None + datum: Optional[str] = None + uhrzeit: Optional[str] = None lat: Optional[float] = None lon: Optional[float] = None - ort_name: Optional[str] = Field(None, max_length=300) + ort_name: Optional[str] = None max_teilnehmer: Optional[int] = None - beschreibung: Optional[str] = Field(None, max_length=5000) + beschreibung: Optional[str] = None class JoinRequest(BaseModel): dog_ids: List[int] = [] # leere Liste = ohne Hund (selten) @@ -46,7 +58,7 @@ class InviteRequest(BaseModel): friend_id: int class RsvpRequest(BaseModel): - status: str = Field(..., max_length=20) # 'yes' | 'maybe' | 'no' + status: str # 'yes' | 'maybe' | 'no' # ------------------------------------------------------------------ @@ -79,7 +91,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_m(lat, lon, r['lat'], r['lon']) <= radius] + result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius] return result @@ -119,7 +131,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"], @@ -130,7 +142,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"], @@ -158,7 +170,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 7af856c..a05bb1b 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, Field +from pydantic import BaseModel 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 = Field(..., max_length=100) - titel: str = Field(..., min_length=3, max_length=200) - text: str = Field(..., min_length=10, max_length=10000) + rasse: str + titel: str + text: str # ------------------------------------------------------------------ @@ -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 = Field(..., max_length=30) # "approve" | "reject" - reject_reason: str = Field("", max_length=2000) + action: str # "approve" | "reject" + reject_reason: str = "" @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 = Field(..., max_length=30) # "hat" oder "will" + typ: str # "hat" oder "will" class ZuchterCreate(BaseModel): - 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) + rasse_slug: str + name: str + zwingername: str = "" + ort: str = "" + plz: str = "" + bundesland: str = "" vdh_mitglied: int = 0 - website: str = Field("", max_length=500) - telefon: str = Field("", max_length=30) - beschreibung: str = Field("", max_length=10000) + website: str = "" + telefon: str = "" + beschreibung: str = "" # ------------------------------------------------------------------ diff --git a/backend/routes/zucht_hunde.py b/backend/routes/zucht_hunde.py index 45aed56..8ef8c72 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, Field +from pydantic import BaseModel 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 = 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) + 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 vater_id: Optional[int] = None mutter_id: Optional[int] = None - zuechter_name: Optional[str] = Field(None, max_length=200) - eigentuemer_name: Optional[str] = Field(None, max_length=200) + zuechter_name: Optional[str] = None + eigentuemer_name: Optional[str] = None is_public: int = 1 - notiz: Optional[str] = Field(None, max_length=5000) - foto_url: Optional[str] = Field(None, max_length=500) + notiz: Optional[str] = None + foto_url: Optional[str] = None class HundUpdate(BaseModel): - 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) + 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 vater_id: Optional[int] = None mutter_id: Optional[int] = None - zuechter_name: Optional[str] = Field(None, max_length=200) - eigentuemer_name: Optional[str] = Field(None, max_length=200) + zuechter_name: Optional[str] = None + eigentuemer_name: Optional[str] = None is_public: Optional[int] = None - notiz: Optional[str] = Field(None, max_length=5000) - foto_url: Optional[str] = Field(None, max_length=500) + notiz: Optional[str] = None + foto_url: Optional[str] = None class HealthTestCreate(BaseModel): - 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) + 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 is_public: int = 1 class HealthTestUpdate(BaseModel): - 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) + 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 is_public: Optional[int] = None class GeneticTestCreate(BaseModel): - 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) + 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 is_public: int = 1 class GeneticTestUpdate(BaseModel): - 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) + 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 is_public: Optional[int] = None class TitelCreate(BaseModel): - 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) + 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 is_public: int = 1 class TitelUpdate(BaseModel): - 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) + 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 is_public: Optional[int] = None diff --git a/backend/routes/zucht_ki.py b/backend/routes/zucht_ki.py index 3f1fd43..e25c49c 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, Field +from pydantic import BaseModel 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] = Field(None, max_length=50) + welfare_level: Optional[str] = None class HundBeschreibungBody(BaseModel): diff --git a/backend/scheduler.py b/backend/scheduler.py index 5b816f1..11dcf62 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -46,14 +46,6 @@ 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 @@ -1840,13 +1832,11 @@ 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, dogs.user_id, d.dog_id, + SELECT d.id, d.titel, d.datum, d.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 @@ -2241,16 +2231,3 @@ 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 309f5e4..4ec2cef 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -235,45 +235,6 @@ 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 ------------------------------------------------------------ */ @@ -8944,44 +8905,3 @@ 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 4af159f..d206049 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: #7F6B58; /* a11y: WCAG AA 4.74:1 auf --c-bg #FAF7F2 (vorher #B0A090 = 2.37:1) */ + --c-text-muted: #B0A090; --c-text-inverse: #FAF7F2; /* Funktionsfarben */ @@ -179,7 +179,7 @@ --c-text: #F0EAE0; --c-text-secondary: #C0B0A0; - --c-text-muted: #A08878; /* a11y: WCAG AA 5.46:1 auf --c-bg #1A1410 (vorher #806A58 = 3.58:1) */ + --c-text-muted: #806A58; --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 a54eedc..ac91631 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: 44px; - height: 44px; + width: 40px; + height: 40px; border-radius: var(--radius-md); color: var(--c-text-secondary); cursor: pointer; @@ -99,8 +99,8 @@ /* Hamburger-Button (nur Mobile) */ .header-menu-btn { - width: 44px; - height: 44px; + width: 40px; + height: 40px; display: flex; align-items: center; justify-content: center; diff --git a/backend/static/css/lists.css b/backend/static/css/lists.css deleted file mode 100644 index f8d2014..0000000 --- a/backend/static/css/lists.css +++ /dev/null @@ -1,328 +0,0 @@ -/* ============================================================ - 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 deleted file mode 100644 index f9f5ec2..0000000 --- a/backend/static/css/utilities.css +++ /dev/null @@ -1,65 +0,0 @@ -/* ============================================================ - 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 23df4c7..bf2434e 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,24 @@ Ban Yaro - + - - - - - + + + @@ -101,8 +111,7 @@