Phase 1: Datenmodell, CRUD-API und Stammdaten

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
This commit is contained in:
2026-08-29 22:00:58 +02:00
co-authored by Claude Opus 5
parent 3cf6903df5
commit 3d7b7c706a
22 changed files with 1443 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
"""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