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
195 lines
5.8 KiB
Python
195 lines
5.8 KiB
Python
"""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
|