"""Datenmodelle. Abweichungen von projekt.md sind in Plan.md begründet; die wichtigsten: * Feste Wertelisten statt freier VARCHAR - als Enum **und** als CHECK in der Datenbank. Ohne die CHECK-Bedingung könnte an SQLAlchemy vorbei (Import, Migration, sqlite3 von Hand) trotzdem "Gril" oder "reserviert" landen, und die Filter griffen dann still nicht mehr. * Status ``draft`` für den Stapel-Import: pro Foto entsteht ein Eintrag ohne Titel, Grösse und Kategorie. Darum sind diese drei Felder NULL-fähig - gefüllt sein müssen sie erst beim Wechsel auf ``available``. * ``reservation_token``: nur wer es hat, kann die eigene Reservierung wieder aufheben. Ein offener release-Endpunkt liesse jeden fremde Reservierungen löschen. """ from __future__ import annotations import enum from datetime import datetime, timezone from sqlalchemy import ( Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Basis def jetzt() -> datetime: """Zeitstempel in UTC. Bewusst in der Anwendung statt per DB-Trigger, damit es nicht von SQLite-Eigenheiten abhängt.""" return datetime.now(timezone.utc) class Geschlecht(str, enum.Enum): boy = "boy" girl = "girl" unisex = "unisex" class Saison(str, enum.Enum): spring_summer = "spring_summer" autumn_winter = "autumn_winter" all_year = "all_year" class Zustand(str, enum.Enum): new = "new" very_good = "very_good" good = "good" worn = "worn" class Status(str, enum.Enum): draft = "draft" available = "available" reserved = "reserved" given_away = "given_away" def _werte(aufzaehlung: type[enum.Enum]) -> str: """Werteliste für eine CHECK-Bedingung, z.B. 'boy','girl','unisex'.""" return ", ".join(f"'{e.value}'" for e in aufzaehlung) class Kategorie(Basis): __tablename__ = "categories" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True) slug: Mapped[str] = mapped_column(String(50), nullable=False, unique=True) items: Mapped[list["Item"]] = relationship(back_populates="kategorie") class Groesse(Basis): """Gepflegte Grössenliste. projekt.md sieht GET /api/v1/sizes vor, aber keine Tabelle dazu. Die Grössen aus den vorhandenen Einträgen zu ziehen (SELECT DISTINCT size) wäre bequem, sortiert aber falsch - '104' käme vor '80', weil als Text verglichen wird - und jeder Tippfehler würde zur Filteroption. Darum eine eigene Liste mit ausdrücklicher Sortierung. """ __tablename__ = "sizes" id: Mapped[int] = mapped_column(Integer, primary_key=True) label: Mapped[str] = mapped_column(String(20), nullable=False, unique=True) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) class Item(Basis): __tablename__ = "items" __table_args__ = ( CheckConstraint(f"gender IN ({_werte(Geschlecht)})", name="ck_items_gender"), CheckConstraint(f"season IN ({_werte(Saison)})", name="ck_items_season"), CheckConstraint(f"condition IN ({_werte(Zustand)})", name="ck_items_condition"), CheckConstraint(f"status IN ({_werte(Status)})", name="ck_items_status"), # Ein veröffentlichter Eintrag braucht Grösse und Kategorie. Entwürfe # dürfen beides offen lassen - genau dafür gibt es sie. CheckConstraint( "status = 'draft' OR (size IS NOT NULL AND category_id IS NOT NULL)", name="ck_items_vollstaendig_wenn_veroeffentlicht", ), # Nur ein reservierter Eintrag trägt einen Namen und ein Token. CheckConstraint( "status = 'reserved' OR (reserved_by IS NULL AND reservation_token IS NULL)", name="ck_items_reservierung_nur_wenn_reserviert", ), Index("ix_items_status_size", "status", "size"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) # Freiwillig: bleibt er leer, wird beim Anzeigen einer aus Kategorie und # Grösse gebildet (siehe anzeige_titel). Bewusst nicht beim Speichern - # sonst bliebe "Hose 98/104" stehen, wenn später die Grösse korrigiert # wird. title: Mapped[str | None] = mapped_column(String(100), nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) size: Mapped[str | None] = mapped_column(String(20), nullable=True) gender: Mapped[str] = mapped_column( String(10), nullable=False, default=Geschlecht.unisex.value ) season: Mapped[str] = mapped_column( String(15), nullable=False, default=Saison.all_year.value ) condition: Mapped[str] = mapped_column( String(20), nullable=False, default=Zustand.good.value ) status: Mapped[str] = mapped_column( String(20), nullable=False, default=Status.available.value, index=True ) category_id: Mapped[int | None] = mapped_column( ForeignKey("categories.id", ondelete="RESTRICT"), nullable=True, index=True ) reserved_by: Mapped[str | None] = mapped_column(String(100), nullable=True) reservation_token: Mapped[str | None] = mapped_column(String(64), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=jetzt ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=jetzt, onupdate=jetzt ) kategorie: Mapped[Kategorie | None] = relationship(back_populates="items") bilder: Mapped[list["ItemBild"]] = relationship( back_populates="item", cascade="all, delete-orphan", order_by="ItemBild.id", ) @property def anzeige_titel(self) -> str: """Titel für die Anzeige - eigener, sonst aus Kategorie und Grösse.""" if self.title: return self.title teile = [self.kategorie.name if self.kategorie else "Kleidungsstück"] if self.size: teile.append(self.size) return " ".join(teile) @property def hauptbild(self) -> "ItemBild | None": if not self.bilder: return None return next((b for b in self.bilder if b.is_primary), self.bilder[0]) class ItemBild(Basis): __tablename__ = "item_images" id: Mapped[int] = mapped_column(Integer, primary_key=True) item_id: Mapped[int] = mapped_column( ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True ) # Dateiname, nicht vom Upload übernommen, sondern selbst erzeugt # (siehe images.py in Phase 2). Damit ist Pfad-Manipulation ausgeschlossen. image_url: Mapped[str] = mapped_column(String(255), nullable=False) is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) item: Mapped[Item] = relationship(back_populates="bilder")