Files
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

241 lines
7.5 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, update
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 | None:
"""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.
Prüfen und Setzen stehen in EINEM UPDATE mit Status-Bedingung: zwei
gleichzeitige Anfragen könnten sonst beide die Prüfung in der Route
passieren, und die zweite überschriebe Name und Token der ersten -
ohne dass die es je erfährt. So gewinnt genau eine; die andere
bekommt None und der Aufrufer meldet "schon weg"."""
token = secrets.token_urlsafe(32)
betroffen = db.execute(
update(Item)
.where(Item.id == item.id, Item.status == Status.available.value)
.values(
status=Status.reserved.value,
reserved_by=name,
reservation_token=token,
updated_at=jetzt(),
)
).rowcount
db.commit()
db.refresh(item)
return token if betroffen else None
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
def hauptbild_setzen(db: Session, bild: ItemBild) -> ItemBild:
"""Macht dieses Bild zum Vorschaubild und nimmt das Kennzeichen den
übrigen Bildern desselben Eintrags weg."""
for anderes in db.scalars(
select(ItemBild).where(ItemBild.item_id == bild.item_id)
):
anderes.is_primary = anderes.id == bild.id
db.commit()
db.refresh(bild)
return bild
def bild_loeschen(db: Session, bild: ItemBild) -> str:
"""Löscht die Zeile und meldet den Dateinamen zurück, damit der
Aufrufer die Datei entfernen kann."""
dateiname = bild.image_url
war_hauptbild = bild.is_primary
item_id = bild.item_id
db.delete(bild)
db.commit()
# War es das Vorschaubild, rückt das nächste nach - sonst stünde der
# Eintrag in der Galerie ohne Bild da, obwohl noch welche vorhanden sind.
if war_hauptbild:
naechstes = db.scalars(
select(ItemBild).where(ItemBild.item_id == item_id).order_by(ItemBild.id)
).first()
if naechstes is not None:
naechstes.is_primary = True
db.commit()
return dateiname