FastAPI mit SQLAlchemy und SQLite, Migrationen über Alembic. Die
Endpunkte aus projekt.md für items, categories und sizes stehen samt
Reservierung; Bilder und Anmeldung folgen in Phase 2 und 3.
Umgesetzt wie in Plan.md festgelegt:
- Feste Wertelisten als Enum UND als CHECK in der Datenbank. Die
CHECK-Bedingung ist der eigentliche Schutz: an SQLAlchemy vorbei (Import,
sqlite3 von Hand) käme sonst "Gril" durch, und die Filter griffen still
nicht mehr.
- Status "draft" für den Stapel-Import. Entwürfe erscheinen weder in der
Galerie noch in GET /items, solange nicht ausdrücklich status=draft
angefragt wird, und lassen sich nicht reservieren.
- Titel ist freiwillig und wird sonst beim ANZEIGEN aus Kategorie und
Grösse gebildet ("Jacken 98/104") - nicht beim Speichern, damit er einer
späteren Korrektur der Grösse folgt.
- reservation_token: nur wer es hat, kann die eigene Reservierung aufheben.
Verglichen mit compare_digest, gelöscht beim Freigeben und beim
Erledigen, damit ein alter Link nicht später eine fremde Reservierung
aufhebt.
- Eigene sizes-Tabelle mit sort_order statt SELECT DISTINCT: sonst stünde
"104" vor "56" und jeder Tippfehler würde zur Filteroption.
- Pagination auf GET /items, in projekt.md nicht vorgesehen.
Zwei SQLite-Eigenheiten, die leicht untergehen: foreign_keys ist
standardmässig AUS (ohne PRAGMA greift ON DELETE CASCADE nicht), und
check_same_thread muss für FastAPI abgeschaltet werden. Beides in
database.py, dazu WAL fürs gleichzeitige Lesen.
24 Tests, alle grün. Zusätzlich von Hand geprüft: Migration anwenden,
Eintrag anlegen, reservieren, ohne Token freigeben (403), mit Token
freigeben (200), Swagger UI erreichbar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
146 lines
4.2 KiB
Python
146 lines
4.2 KiB
Python
"""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
|