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
229 lines
6.9 KiB
Python
229 lines
6.9 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
|
|
|
|
|
|
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
|