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
+194
View File
@@ -0,0 +1,194 @@
"""Datenbankzugriffe.
Bewusst getrennt von den Routen: so lässt sich die Logik ohne HTTP testen,
und die Routen bleiben auf Statuscodes und Berechtigungen beschränkt.
"""
from __future__ import annotations
import secrets
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.models import Groesse, Item, ItemBild, Kategorie, Status, jetzt
from app.schemas import ItemAendern, ItemAnlegen
# ------------------------------------------------------------ Stammdaten ---
def kategorien(db: Session) -> list[Kategorie]:
return list(db.scalars(select(Kategorie).order_by(Kategorie.name)))
def kategorie_holen(db: Session, kategorie_id: int) -> Kategorie | None:
return db.get(Kategorie, kategorie_id)
def groessen(db: Session) -> list[Groesse]:
return list(db.scalars(select(Groesse).order_by(Groesse.sort_order, Groesse.label)))
# ----------------------------------------------------------------- Items ---
def _mit_beziehungen(stmt):
"""Kategorie und Bilder gleich mitladen.
Ohne das feuert die Übersicht pro Eintrag zwei zusätzliche Abfragen
(N+1) - bei 200 Kleidungsstücken also über 400 statt drei.
"""
return stmt.options(selectinload(Item.kategorie), selectinload(Item.bilder))
def item_holen(db: Session, item_id: int) -> Item | None:
return db.scalars(
_mit_beziehungen(select(Item).where(Item.id == item_id))
).one_or_none()
def items_suchen(
db: Session,
*,
size: str | None = None,
category_id: int | None = None,
status: str | None = None,
gender: str | None = None,
season: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Item], int]:
bedingungen = []
if size:
bedingungen.append(Item.size == size)
if category_id is not None:
bedingungen.append(Item.category_id == category_id)
if gender:
bedingungen.append(Item.gender == gender)
if season:
bedingungen.append(Item.season == season)
if status:
bedingungen.append(Item.status == status)
else:
# Entwürfe stammen aus dem Stapel-Import und haben noch keine
# Angaben. Sie gehören nicht in die Galerie - wer sie sehen will,
# muss ausdrücklich status=draft anfragen.
bedingungen.append(Item.status != Status.draft.value)
grund = select(Item)
for b in bedingungen:
grund = grund.where(b)
anzahl = db.scalar(
select(func.count()).select_from(grund.subquery())
) or 0
stmt = _mit_beziehungen(
grund.order_by(Item.created_at.desc(), Item.id.desc()).limit(limit).offset(offset)
)
return list(db.scalars(stmt)), anzahl
def item_anlegen(db: Session, daten: ItemAnlegen) -> Item:
item = Item(**daten.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return item
def entwuerfe_anlegen(db: Session, anzahl: int) -> list[Item]:
"""Legt leere Entwürfe an - einen pro hochgeladenem Foto.
Wird vom Stapel-Import benutzt (Phase 2 hängt die Bilder daran). Erst
beim Nachtragen von Grösse und Kategorie wird daraus ein sichtbarer
Eintrag.
"""
neue = [Item(status=Status.draft.value) for _ in range(anzahl)]
db.add_all(neue)
db.commit()
for item in neue:
db.refresh(item)
return neue
def item_aendern(db: Session, item: Item, daten: ItemAendern) -> Item:
for feld, wert in daten.model_dump(exclude_unset=True).items():
setattr(item, feld, wert)
db.commit()
db.refresh(item)
return item
def item_loeschen(db: Session, item: Item) -> list[str]:
"""Löscht den Eintrag und meldet die Dateinamen seiner Bilder zurück.
Die Dateien selbst räumt der Aufrufer weg: ON DELETE CASCADE entfernt
nur die Zeilen in item_images, nicht die Bilder auf der Platte. Ohne
das würde das Upload-Verzeichnis mit Waisen volllaufen.
"""
dateien = [bild.image_url for bild in item.bilder]
db.delete(item)
db.commit()
return dateien
# --------------------------------------------------------- Reservierung ---
def reservieren(db: Session, item: Item, name: str) -> str:
"""Reserviert und gibt das Token zurück, mit dem sich das rückgängig
machen lässt. Das Token verlässt die Anwendung nur dieses eine Mal."""
token = secrets.token_urlsafe(32)
item.status = Status.reserved.value
item.reserved_by = name
item.reservation_token = token
item.updated_at = jetzt()
db.commit()
db.refresh(item)
return token
def freigeben(db: Session, item: Item) -> Item:
item.status = Status.available.value
item.reserved_by = None
# Token entfernen, sonst könnte ein alter Link später die Reservierung
# einer anderen Person aufheben.
item.reservation_token = None
item.updated_at = jetzt()
db.commit()
db.refresh(item)
return item
def als_abgegeben_markieren(db: Session, item: Item) -> Item:
item.status = Status.given_away.value
# Name und Token löschen: datensparsam, und die Galerie ist öffentlich.
item.reserved_by = None
item.reservation_token = None
item.updated_at = jetzt()
db.commit()
db.refresh(item)
return item
def token_gueltig(item: Item, token: str | None) -> bool:
"""Vergleich in konstanter Zeit - ein einfaches == verrät über die
Laufzeit, wie viele Zeichen am Anfang gestimmt haben."""
if not token or not item.reservation_token:
return False
return secrets.compare_digest(item.reservation_token, token)
# ---------------------------------------------------------------- Bilder ---
def bild_anhaengen(db: Session, item: Item, dateiname: str) -> ItemBild:
bild = ItemBild(
item_id=item.id,
image_url=dateiname,
# Das erste Bild eines Eintrags wird automatisch zum Vorschaubild.
is_primary=not item.bilder,
)
db.add(bild)
db.commit()
db.refresh(bild)
return bild