"""Pydantic-Schemas für Anfragen und Antworten. Die Aufteilung in Anlegen / Ändern / Antwort ist Absicht: beim Ändern sind alle Felder freiwillig (PATCH), beim Anlegen gelten die Pflichtfelder, und in der Antwort dürfen Felder stehen, die niemand setzen darf - etwa der abgeleitete Titel oder das Reservierungs-Token. """ from __future__ import annotations from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, field_validator from app.models import Geschlecht, Saison, Status, Zustand class KategorieAus(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str slug: str class GroesseAus(BaseModel): model_config = ConfigDict(from_attributes=True) id: int label: str sort_order: int class BildAus(BaseModel): model_config = ConfigDict(from_attributes=True) id: int image_url: str is_primary: bool def _leer_zu_none(wert: str | None) -> str | None: """Leere Eingaben aus einem Formular als 'nicht gesetzt' behandeln. Ein leeres Titelfeld soll NULL ergeben, damit der abgeleitete Titel greift - nicht den leeren String, der später als "gesetzt" gälte. """ if wert is None: return None wert = wert.strip() return wert or None class ItemAnlegen(BaseModel): title: str | None = Field(default=None, max_length=100) description: str | None = None size: str | None = Field(default=None, max_length=20) gender: Geschlecht = Geschlecht.unisex season: Saison = Saison.all_year condition: Zustand = Zustand.good category_id: int | None = None status: Status = Status.available _titel_putzen = field_validator("title", "size", "description", mode="before")( lambda v: _leer_zu_none(v) if isinstance(v, str) or v is None else v ) class ItemAendern(BaseModel): """Alle Felder freiwillig - nicht gesetzte bleiben unverändert. Bewusst ohne reserved_by/reservation_token: eine Reservierung läuft ausschliesslich über die eigenen Endpunkte, sonst könnte man sie per PATCH an Rate-Limit und Token-Prüfung vorbei setzen. """ title: str | None = Field(default=None, max_length=100) description: str | None = None size: str | None = Field(default=None, max_length=20) gender: Geschlecht | None = None season: Saison | None = None condition: Zustand | None = None category_id: int | None = None status: Status | None = None _putzen = field_validator("title", "size", "description", mode="before")( lambda v: _leer_zu_none(v) if isinstance(v, str) or v is None else v ) class ItemAus(BaseModel): model_config = ConfigDict(from_attributes=True) id: int title: str | None display_title: str description: str | None size: str | None gender: Geschlecht season: Saison condition: Zustand status: Status category_id: int | None kategorie: KategorieAus | None = None reserved_by: str | None created_at: datetime updated_at: datetime bilder: list[BildAus] = [] class ItemListeAus(BaseModel): """Antwort der Übersicht - mit Seitenangaben. projekt.md sieht keine Pagination vor; ohne sie liefert der Endpunkt irgendwann den gesamten Bestand samt Bildpfaden in einer Antwort. """ items: list[ItemAus] total: int limit: int offset: int class ReservierenRein(BaseModel): reserved_by: str = Field(min_length=1, max_length=100) @field_validator("reserved_by") @classmethod def keine_links(cls, wert: str) -> str: wert = wert.strip() if not wert: raise ValueError("Bitte einen Namen angeben.") # Die Galerie ist frei zugänglich; ohne diese Bremse wird das Feld # früher oder später als Werbefläche benutzt. verdaechtig = ("http://", "https://", "www.", "<") if any(teil in wert.lower() for teil in verdaechtig): raise ValueError("Bitte nur einen Namen angeben, keine Links.") return wert class ReservierenAus(BaseModel): item: ItemAus # Nur unmittelbar nach dem Reservieren sichtbar: daraus wird der Link # zum Aufheben gebildet. Taucht in keiner anderen Antwort auf. reservation_token: str