Files
kleiderboerse/app/routers/items.py
T
StefanandClaude Opus 5 3d7b7c706a 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
2026-08-29 22:00:58 +02:00

165 lines
6.0 KiB
Python

"""Kleidungsstücke: durchsuchen, verwalten, reservieren.
Der Schreibschutz (Anmeldung, Rate-Limit) kommt in Phase 3 dazu; die
Berechtigungslogik der Reservierung steckt aber schon hier, weil sie den
Datenfluss bestimmt.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app import crud
from app.database import get_db
from app.models import Geschlecht, Item, Saison, Status
from app.schemas import (
ItemAendern,
ItemAnlegen,
ItemAus,
ItemListeAus,
ReservierenAus,
ReservierenRein,
)
router = APIRouter(prefix="/api/v1/items", tags=["Kleidungsstücke"])
def _item_oder_404(db: Session, item_id: int) -> Item:
item = crud.item_holen(db, item_id)
if item is None:
raise HTTPException(status_code=404, detail="Kleidungsstück nicht gefunden.")
return item
def _als_antwort(item: Item) -> ItemAus:
"""Baut die Antwort und ergänzt den abgeleiteten Titel.
display_title entsteht hier und nicht beim Speichern: sonst bliebe ein
automatisch gesetzter Titel stehen, wenn später die Grösse korrigiert
wird.
"""
return ItemAus.model_validate({**item.__dict__, "display_title": item.anzeige_titel,
"kategorie": item.kategorie, "bilder": item.bilder})
@router.get("", response_model=ItemListeAus)
def items_auflisten(
db: Session = Depends(get_db),
size: str | None = None,
category_id: int | None = None,
item_status: Status | None = Query(default=None, alias="status"),
gender: Geschlecht | None = None,
season: Saison | None = None,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
):
treffer, gesamt = crud.items_suchen(
db,
size=size,
category_id=category_id,
status=item_status.value if item_status else None,
gender=gender.value if gender else None,
season=season.value if season else None,
limit=limit,
offset=offset,
)
return ItemListeAus(
items=[_als_antwort(i) for i in treffer],
total=gesamt,
limit=limit,
offset=offset,
)
@router.get("/{item_id}", response_model=ItemAus)
def item_anzeigen(item_id: int, db: Session = Depends(get_db)):
return _als_antwort(_item_oder_404(db, item_id))
@router.post("", response_model=ItemAus, status_code=status.HTTP_201_CREATED)
def item_anlegen(daten: ItemAnlegen, db: Session = Depends(get_db)):
if daten.category_id is not None and crud.kategorie_holen(db, daten.category_id) is None:
raise HTTPException(status_code=422, detail="Kategorie gibt es nicht.")
try:
item = crud.item_anlegen(db, daten)
except IntegrityError as e:
db.rollback()
# Greift z.B., wenn ein Eintrag ohne Grösse oder Kategorie
# veröffentlicht werden soll (CHECK in models.py).
raise HTTPException(
status_code=422,
detail="Ein sichtbarer Eintrag braucht Grösse und Kategorie.",
) from e
return _als_antwort(item)
@router.patch("/{item_id}", response_model=ItemAus)
def item_aendern(item_id: int, daten: ItemAendern, db: Session = Depends(get_db)):
item = _item_oder_404(db, item_id)
if daten.category_id is not None and crud.kategorie_holen(db, daten.category_id) is None:
raise HTTPException(status_code=422, detail="Kategorie gibt es nicht.")
try:
geaendert = crud.item_aendern(db, item, daten)
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=422,
detail="Ein sichtbarer Eintrag braucht Grösse und Kategorie.",
) from e
return _als_antwort(geaendert)
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def item_loeschen(item_id: int, db: Session = Depends(get_db)):
item = _item_oder_404(db, item_id)
# Die Dateien selbst löscht Phase 2 (images.py); hier fallen nur die
# Namen an, damit die Aufräumlogik an einer Stelle liegt.
crud.item_loeschen(db, item)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# --------------------------------------------------------- Reservierung ---
@router.post("/{item_id}/reserve", response_model=ReservierenAus)
def item_reservieren(item_id: int, daten: ReservierenRein, db: Session = Depends(get_db)):
item = _item_oder_404(db, item_id)
if item.status == Status.draft.value:
raise HTTPException(status_code=404, detail="Kleidungsstück nicht gefunden.")
if item.status != Status.available.value:
raise HTTPException(
status_code=409, detail="Dieses Kleidungsstück ist nicht mehr verfügbar."
)
token = crud.reservieren(db, item, daten.reserved_by)
return ReservierenAus(item=_als_antwort(item), reservation_token=token)
@router.post("/{item_id}/release", response_model=ItemAus)
def item_freigeben(
item_id: int,
token: str | None = Query(default=None),
db: Session = Depends(get_db),
ist_betreiber: bool = False, # wird in Phase 3 durch die Anmeldung ersetzt
):
"""Hebt eine Reservierung auf.
Erlaubt für den Reservierenden (mit Token) und den Betreiber. Ohne
Token wäre der Endpunkt offen - dann könnte jeder Besucher fremde
Reservierungen löschen, und weil reserved_by mitgeht, bliebe nicht
einmal nachvollziehbar, dass jemand reserviert hatte.
"""
item = _item_oder_404(db, item_id)
if item.status != Status.reserved.value:
raise HTTPException(status_code=409, detail="Dieses Kleidungsstück ist nicht reserviert.")
if not ist_betreiber and not crud.token_gueltig(item, token):
raise HTTPException(
status_code=403,
detail="Zum Aufheben braucht es den Link aus der Reservierung.",
)
return _als_antwort(crud.freigeben(db, item))
@router.post("/{item_id}/mark-given", response_model=ItemAus)
def item_abgegeben(item_id: int, db: Session = Depends(get_db)):
item = _item_oder_404(db, item_id)
return _als_antwort(crud.als_abgegeben_markieren(db, item))