"""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