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:
+195
@@ -0,0 +1,195 @@
|
||||
"""Datenmodelle.
|
||||
|
||||
Abweichungen von projekt.md sind in Plan.md begründet; die wichtigsten:
|
||||
|
||||
* Feste Wertelisten statt freier VARCHAR - als Enum **und** als CHECK in der
|
||||
Datenbank. Ohne die CHECK-Bedingung könnte an SQLAlchemy vorbei (Import,
|
||||
Migration, sqlite3 von Hand) trotzdem "Gril" oder "reserviert" landen, und
|
||||
die Filter griffen dann still nicht mehr.
|
||||
* Status ``draft`` für den Stapel-Import: pro Foto entsteht ein Eintrag ohne
|
||||
Titel, Grösse und Kategorie. Darum sind diese drei Felder NULL-fähig -
|
||||
gefüllt sein müssen sie erst beim Wechsel auf ``available``.
|
||||
* ``reservation_token``: nur wer es hat, kann die eigene Reservierung wieder
|
||||
aufheben. Ein offener release-Endpunkt liesse jeden fremde Reservierungen
|
||||
löschen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Basis
|
||||
|
||||
|
||||
def jetzt() -> datetime:
|
||||
"""Zeitstempel in UTC. Bewusst in der Anwendung statt per DB-Trigger,
|
||||
damit es nicht von SQLite-Eigenheiten abhängt."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Geschlecht(str, enum.Enum):
|
||||
boy = "boy"
|
||||
girl = "girl"
|
||||
unisex = "unisex"
|
||||
|
||||
|
||||
class Saison(str, enum.Enum):
|
||||
spring_summer = "spring_summer"
|
||||
autumn_winter = "autumn_winter"
|
||||
all_year = "all_year"
|
||||
|
||||
|
||||
class Zustand(str, enum.Enum):
|
||||
new = "new"
|
||||
very_good = "very_good"
|
||||
good = "good"
|
||||
worn = "worn"
|
||||
|
||||
|
||||
class Status(str, enum.Enum):
|
||||
draft = "draft"
|
||||
available = "available"
|
||||
reserved = "reserved"
|
||||
given_away = "given_away"
|
||||
|
||||
|
||||
def _werte(aufzaehlung: type[enum.Enum]) -> str:
|
||||
"""Werteliste für eine CHECK-Bedingung, z.B. 'boy','girl','unisex'."""
|
||||
return ", ".join(f"'{e.value}'" for e in aufzaehlung)
|
||||
|
||||
|
||||
class Kategorie(Basis):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
|
||||
slug: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
|
||||
|
||||
items: Mapped[list["Item"]] = relationship(back_populates="kategorie")
|
||||
|
||||
|
||||
class Groesse(Basis):
|
||||
"""Gepflegte Grössenliste.
|
||||
|
||||
projekt.md sieht GET /api/v1/sizes vor, aber keine Tabelle dazu. Die
|
||||
Grössen aus den vorhandenen Einträgen zu ziehen (SELECT DISTINCT size)
|
||||
wäre bequem, sortiert aber falsch - '104' käme vor '80', weil als Text
|
||||
verglichen wird - und jeder Tippfehler würde zur Filteroption. Darum
|
||||
eine eigene Liste mit ausdrücklicher Sortierung.
|
||||
"""
|
||||
|
||||
__tablename__ = "sizes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
label: Mapped[str] = mapped_column(String(20), nullable=False, unique=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class Item(Basis):
|
||||
__tablename__ = "items"
|
||||
__table_args__ = (
|
||||
CheckConstraint(f"gender IN ({_werte(Geschlecht)})", name="ck_items_gender"),
|
||||
CheckConstraint(f"season IN ({_werte(Saison)})", name="ck_items_season"),
|
||||
CheckConstraint(f"condition IN ({_werte(Zustand)})", name="ck_items_condition"),
|
||||
CheckConstraint(f"status IN ({_werte(Status)})", name="ck_items_status"),
|
||||
# Ein veröffentlichter Eintrag braucht Grösse und Kategorie. Entwürfe
|
||||
# dürfen beides offen lassen - genau dafür gibt es sie.
|
||||
CheckConstraint(
|
||||
"status = 'draft' OR (size IS NOT NULL AND category_id IS NOT NULL)",
|
||||
name="ck_items_vollstaendig_wenn_veroeffentlicht",
|
||||
),
|
||||
# Nur ein reservierter Eintrag trägt einen Namen und ein Token.
|
||||
CheckConstraint(
|
||||
"status = 'reserved' OR (reserved_by IS NULL AND reservation_token IS NULL)",
|
||||
name="ck_items_reservierung_nur_wenn_reserviert",
|
||||
),
|
||||
Index("ix_items_status_size", "status", "size"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# Freiwillig: bleibt er leer, wird beim Anzeigen einer aus Kategorie und
|
||||
# Grösse gebildet (siehe anzeige_titel). Bewusst nicht beim Speichern -
|
||||
# sonst bliebe "Hose 98/104" stehen, wenn später die Grösse korrigiert
|
||||
# wird.
|
||||
title: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
size: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
gender: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False, default=Geschlecht.unisex.value
|
||||
)
|
||||
season: Mapped[str] = mapped_column(
|
||||
String(15), nullable=False, default=Saison.all_year.value
|
||||
)
|
||||
condition: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default=Zustand.good.value
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default=Status.available.value, index=True
|
||||
)
|
||||
|
||||
category_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("categories.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
|
||||
reserved_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
reservation_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=jetzt
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=jetzt, onupdate=jetzt
|
||||
)
|
||||
|
||||
kategorie: Mapped[Kategorie | None] = relationship(back_populates="items")
|
||||
bilder: Mapped[list["ItemBild"]] = relationship(
|
||||
back_populates="item",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ItemBild.id",
|
||||
)
|
||||
|
||||
@property
|
||||
def anzeige_titel(self) -> str:
|
||||
"""Titel für die Anzeige - eigener, sonst aus Kategorie und Grösse."""
|
||||
if self.title:
|
||||
return self.title
|
||||
teile = [self.kategorie.name if self.kategorie else "Kleidungsstück"]
|
||||
if self.size:
|
||||
teile.append(self.size)
|
||||
return " ".join(teile)
|
||||
|
||||
@property
|
||||
def hauptbild(self) -> "ItemBild | None":
|
||||
if not self.bilder:
|
||||
return None
|
||||
return next((b for b in self.bilder if b.is_primary), self.bilder[0])
|
||||
|
||||
|
||||
class ItemBild(Basis):
|
||||
__tablename__ = "item_images"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# Dateiname, nicht vom Upload übernommen, sondern selbst erzeugt
|
||||
# (siehe images.py in Phase 2). Damit ist Pfad-Manipulation ausgeschlossen.
|
||||
image_url: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
item: Mapped[Item] = relationship(back_populates="bilder")
|
||||
Reference in New Issue
Block a user