Files
kleiderboerse/app/routers/items.py
T
StefanandClaude Fable 5 c0a18ac735 Wettlauf beim Reservieren, 500er bei unsinnigen Eingaben
Drei P3-Funde aus der Durchsicht vom 14.09.:

Reservieren: Statusprüfung und Schreiben waren getrennt - zwei
gleichzeitige Anfragen konnten beide passieren, die zweite überschrieb
Name und Token der ersten, ohne dass die es erfuhr. Jetzt entscheidet
ein UPDATE mit Status-Bedingung; der Verlierer bekommt None und die
Route meldet "schon weg" (Seite) bzw. 409 (API).

/?limit=abc lieferte jedem anonymen Besucher einen internen
Serverfehler, limit=-1 hiess in SQLite "alles". _limit_lesen() fällt
bei Unsinn auf die Seitengrösse zurück und deckelt bei 500.

Erfassen/Bearbeiten: int(category_id) und die Pydantic-Prüfung warfen
im Handler - 500 statt Fehlermeldung. Jetzt Meldung; ausserdem werden
die Angaben VOR den Bildern geprüft, damit bei abgelehnten Angaben
keine verwaisten Bilddateien liegen bleiben.

Vier neue Tests, jeder einmal gegen den alten Code gelaufen und dabei
rot geworden. 84 lokal grün (HEIC-Test braucht pillow-heif, Docker).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 17:22:54 +02:00

195 lines
7.3 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, Request, Response, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app import crud, images, security
from app.config import einstellungen
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,
dependencies=[security.BetreiberNoetig])
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,
dependencies=[security.BetreiberNoetig])
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,
dependencies=[security.BetreiberNoetig])
def item_loeschen(item_id: int, db: Session = Depends(get_db)):
item = _item_oder_404(db, item_id)
# ON DELETE CASCADE räumt nur die Zeilen in item_images weg. Die Dateien
# müssen hier weg, sonst läuft das Upload-Verzeichnis mit Waisen voll.
dateien = crud.item_loeschen(db, item)
for name in dateien:
images.loeschen(einstellungen().upload_pfad, name)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# --------------------------------------------------------- Reservierung ---
@router.post("/{item_id}/reserve", response_model=ReservierenAus)
# shared_limit statt limit: slowapi zählt sonst pro URL-Pfad, und weil jedes
# Kleidungsstück eine eigene URL hat, bekäme jedes seinen eigenen Zähler -
# ein Skript könnte also den gesamten Bestand reservieren, ohne je an ein
# Limit zu stossen. Der feste scope fasst alle Reservierungen zusammen.
@security.limiter.shared_limit(
lambda: einstellungen().limit_reservieren, scope="reservieren"
)
def item_reservieren(
request: Request,
item_id: int,
daten: ReservierenRein,
db: Session = Depends(get_db),
):
"""Reserviert ein Kleidungsstück - bewusst ohne Anmeldung.
Die Galerie ist frei zugänglich, es gibt also keine vorgelagerte Hürde.
Das Rate-Limit ist damit das Einzige, was zwischen einem Skript und dem
gesamten Bestand steht.
"""
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)
if token is None:
# Zwischen Prüfung und Schreiben war jemand schneller.
raise HTTPException(
status_code=409, detail="Dieses Kleidungsstück ist nicht mehr verfügbar."
)
return ReservierenAus(item=_als_antwort(item), reservation_token=token)
@router.post("/{item_id}/release", response_model=ItemAus)
def item_freigeben(
request: Request,
item_id: int,
token: str | None = Query(default=None),
db: Session = Depends(get_db),
):
"""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 security.betreiber_optional(request) 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,
dependencies=[security.BetreiberNoetig])
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))