Files
kleiderboerse/app/routers/items.py
T
StefanandClaude Opus 5 1d122b33f9 Phase 2: Bild-Upload, WebP-Konvertierung und Stapel-Import
Uploads werden geprüft, nach WebP gewandelt (max. 1200 px Breite) und über
eine eigene Route ausgeliefert. Dazu der Stapel-Import: viele Fotos auf
einmal hochladen, pro Foto entsteht ein Entwurf.

Der Upload ist die grösste Angriffsfläche der Anwendung - die Galerie ist
frei zugänglich, und hier landet fremdes Binärmaterial auf der Platte.
Entsprechend:

- Der Dateiname wird selbst erzeugt (UUID + .webp), nie aus dem Upload
  übernommen. Beim Ausliefern wird er zusätzlich gegen ein Muster geprüft:
  er kommt dort aus der URL und ist damit Nutzereingabe, auch wenn wir ihn
  ursprünglich vergeben haben.
- Der Inhalt entscheidet, nicht die Endung - Pillow muss das Bild öffnen
  können. Eine als .jpg getarnte PHP-Datei fällt durch.
- Pixelmasse ausdrücklich begrenzt, VOR dem Dekodieren. Pillow allein
  wirft erst bei der doppelten Zahl einen Fehler; dazwischen gibt es nur
  eine Warnung, die niemand sieht.
- Gespeichert wird nur das Ergebnis. Das entfernt eingebettete
  Fremdinhalte und die EXIF-Daten - bei Handyfotos oft samt
  GPS-Koordinaten der Wohnung. Die Drehung wird vorher auf die Bildpunkte
  angewendet, sonst lägen Hochkant-Fotos quer.
- Bilder liegen nicht unter static/, sondern werden mit festem
  Content-Type und nosniff ausgeliefert.

HEIC wird unterstützt (pillow-heif): iPhones nehmen so auf, und ohne das
fiele beim Stapel-Import jedes iPhone-Foto durch. Ein unlesbares Bild
lässt bewusst den ganzen Stapel scheitern - bei zwanzig Fotos fiele sonst
niemandem auf, dass eines fehlt.

Beim Löschen werden die Dateien mitentfernt (ON DELETE CASCADE räumt nur
die Zeilen weg), und wird das Vorschaubild gelöscht, rückt das nächste
nach.

42 Tests, alle grün. Zusätzlich von Hand gegen die laufende API geprüft:
3000x2200-Foto kommt als WebP 1200x880 an, Pfadmanipulation und getarnte
Dateien werden abgewiesen, Entwürfe bleiben aus der Galerie draussen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
2026-08-29 22:25:06 +02:00

168 lines
6.2 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, images
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)
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)
# 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)
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))