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:
@@ -0,0 +1,22 @@
|
||||
"""Stammdaten: Kategorien und Grössen."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud
|
||||
from app.database import get_db
|
||||
from app.schemas import GroesseAus, KategorieAus
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["Stammdaten"])
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[KategorieAus])
|
||||
def kategorien_auflisten(db: Session = Depends(get_db)):
|
||||
return crud.kategorien(db)
|
||||
|
||||
|
||||
@router.get("/sizes", response_model=list[GroesseAus])
|
||||
def groessen_auflisten(db: Session = Depends(get_db)):
|
||||
"""Gepflegte Liste statt SELECT DISTINCT über die Einträge - so stimmt
|
||||
die Reihenfolge (86 vor 104) und Tippfehler werden nicht zu Filtern."""
|
||||
return crud.groessen(db)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user