from __future__ import annotations import enum import secrets from datetime import date, datetime, timezone from sqlalchemy import ( Boolean, Column, Date, DateTime, Enum, Float, ForeignKey, Index, Integer, LargeBinary, String, Table, Text, UniqueConstraint, text, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from .database import Base def _now() -> datetime: return datetime.now(timezone.utc) class Role(str, enum.Enum): admin = "admin" user = "user" class BaseUnit(str, enum.Enum): piece = "piece" gram = "gram" milliliter = "milliliter" class MovementType(str, enum.Enum): in_ = "in" out = "out" adjust = "adjust" class DatePrecision(str, enum.Enum): """Wie genau ein MHD angegeben wurde. Bewusst als kurzer String gespeichert und nicht als DB-Enum: So lässt sich die Spalte auf bestehenden Tabellen per ADD COLUMN nachziehen, ohne vorher einen neuen Postgres-Typ anlegen zu müssen. """ day = "day" # 07.09.2026 month = "month" # 09/2026 class UnitKind(str, enum.Enum): """Art einer Einheit. Bestimmt die kanonische Basiseinheit für die Speicherung.""" count = "count" # Basis: Stück weight = "weight" # Basis: Gramm volume = "volume" # Basis: Milliliter class CategoryTracking(str, enum.Enum): """Wie Artikel einer Kategorie verwaltet werden. food = Lebensmittel: Chargen mit MHD und FEFO (bestehende Logik, unverändert). object = Gegenstand: nur Menge pro Lagerort, kein MHD, keine Chargen. Bewusst als kurzer String gespeichert (wie DatePrecision), damit sich die Spalte per ADD COLUMN nachziehen lässt, ohne einen Postgres-Enumtyp anzulegen. """ food = "food" object = "object" class FieldType(str, enum.Enum): """Art eines selbst definierten Feldes an einer Kategorie.""" text = "text" # einzeilig textarea = "textarea" # mehrzeilig number = "number" # Zahl, optional mit Einheit date = "date" # Datum (z.B. Kaufdatum) select = "select" # Auswahlliste (options = JSON-Liste) boolean = "boolean" # Ja/Nein class RemovalReason(str, enum.Enum): """Grund einer Gegenstands-Entnahme aus dem Bestand.""" lost = "lost" # verloren broken = "broken" # kaputt given_away = "given_away" # verschenkt sold = "sold" # verkauft used_up = "used_up" # aufgebraucht other = "other" # sonstiges class Unit(Base): """Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis. factor = wie viele Basiseinheiten 1 dieser Einheit entsprechen (z.B. Kilogramm: kind=weight, factor=1000; Liter: kind=volume, factor=1000). """ __tablename__ = "units" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) kind: Mapped[UnitKind] = mapped_column(Enum(UnitKind), nullable=False) factor: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(Integer, primary_key=True) username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) # Leer bei Konten, die es nur ueber Single Sign-on gibt. Ein solches Konto ist # per Passwort schlicht nicht anmeldbar (siehe routers/auth.py). password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) # Nur fuer die Erstverknuepfung mit einem SSO-Konto. Bewusst nicht Pflicht: # bestehende Installationen haben keine Adressen hinterlegt. email: Mapped[str | None] = mapped_column(String(255), nullable=True) role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.user) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) identities: Mapped[list[UserIdentity]] = relationship( back_populates="user", cascade="all, delete-orphan" ) __table_args__ = ( # Die Adresse entscheidet, welches Konto ein SSO-Login beim ersten Mal # uebernimmt – zwei Konten mit derselben Adresse waeren also mehrdeutig. # Gross-/Kleinschreibung darf dabei keinen Unterschied machen, und leere # Adressen (der Normalfall bei Passwort-Konten) bleiben aussen vor. Index( "uq_users_email", text("lower(email)"), unique=True, postgresql_where=text("email IS NOT NULL"), sqlite_where=text("email IS NOT NULL"), ), ) class OidcLoginState(Base): """Merkzettel zwischen „zum Anmeldedienst geschickt" und „zurueckgekommen". Bewusst eine Tabelle und kein Sitzungs-Cookie. Dieses Backend kennt sonst gar keine Cookies, und Starlettes CORS-Schicht wechselt bei ``allow_origins=["*"]`` **zusammen mit** ``allow_credentials=True`` auf die gespiegelte Herkunft, sobald ein Request ueberhaupt ein Cookie mitbringt (starlette/middleware/cors.py). Ohne Cookie entsteht diese Wechselwirkung erst gar nicht – und mit der ``db``-Fixture laesst sich der Ablauf pruefen, was bei einer Middleware ohne TestClient nicht ginge. """ __tablename__ = "oidc_login_states" state: Mapped[str] = mapped_column(String(64), primary_key=True) provider: Mapped[str] = mapped_column(String(64), nullable=False) nonce: Mapped[str] = mapped_column(String(64), nullable=False) code_verifier: Mapped[str] = mapped_column(String(128), nullable=False) # Gesetzt, wenn der Vorgang eine VERKNUEPFUNG ist statt einer Anmeldung: Der # Rueckweg haengt die Identitaet dann an genau dieses (bereits angemeldete) # Konto, statt jemanden anzumelden. Die Berechtigung dafuer stammt aus der # laufenden Vorrania-Sitzung, nicht aus der E-Mail-Adresse. link_user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), nullable=True ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) class OidcPendingLink(Base): """Eine gepruefte Anmeldedienst-Kennung, die noch keinem Konto gehoert. Entsteht, wenn sich jemand erfolgreich beim Anmeldedienst ausgewiesen hat, aber weder eine bekannte Kennung noch eine passende bestaetigte E-Mail vorliegt. Statt still ein zweites Konto anzulegen, fragt Vorrania nach: neues Konto oder bestehendes verknuepfen. Kurzlebig und einmalig verwendbar. Gespeichert werden nur die geprueften Angaben aus dem ID-Token – nie das Token selbst. """ __tablename__ = "oidc_pending_links" # Wie bei ApiToken liegt nur der SHA-256-Hash hier; den Klartext kennt allein # der Browser, der ihn im Adressfragment mitbekommen hat. code_hash: Mapped[str] = mapped_column(String(64), primary_key=True) provider: Mapped[str] = mapped_column(String(64), nullable=False) subject: Mapped[str] = mapped_column(String(255), nullable=False) email: Mapped[str | None] = mapped_column(String(255)) email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) username_vorschlag: Mapped[str] = mapped_column(String(64), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) class UserIdentity(Base): """Verknuepfung eines Vorrania-Kontos mit einem Konto beim Anmeldeanbieter. Eigene Tabelle statt zweier Spalten am Benutzer, damit ein Konto spaeter Identitaeten bei MEHREREN Anbietern haben kann (Authentik heute, etwas anderes morgen) – genau die Erweiterbarkeit, um die es beim OIDC-Umbau geht. """ __tablename__ = "user_identities" __table_args__ = (UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) # Das Kuerzel aus OIDC_PROVIDERS, z.B. "authentik". provider: Mapped[str] = mapped_column(String(64), nullable=False) # Die sub-Angabe des Anbieters – stabil, auch wenn sich die E-Mail aendert. subject: Mapped[str] = mapped_column(String(255), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) user: Mapped[User] = relationship(back_populates="identities") # Ober-/Untergruppen: eine Gruppe darf zu MEHREREN Obergruppen gehoeren, nicht # nur zu einer. „Grillwurst" haengt unter „Wurst" UND unter „Grillgut" – mit # einem Baum (ein Elternteil je Gruppe) waere genau das nicht abbildbar. Der # Graph darf deshalb Rauten haben, aber keine Ringe (Pruefung in routers/groups). group_parents = Table( "group_parents", Base.metadata, Column( "child_id", ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True ), # Eigener Index: der zusammengesetzte Primaerschluessel deckt nur die Suche # nach child_id ab, gefragt wird aber genauso oft „wer haengt unter X?". Column( "parent_id", ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True, index=True, ), ) class Group(Base): __tablename__ = "groups" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # Einheit des Gruppen-Mindestbestands (z.B. Kilogramm). NULL = zählt in Basiseinheiten. min_stock_unit_id: Mapped[int | None] = mapped_column( ForeignKey("units.id", ondelete="SET NULL"), nullable=True ) # Gruppen-Gebinde als Richtwert: wie viele Basiseinheiten „1 Packung/Glas" # der Gruppe zählt. Nötig, weil die Produkte einer Gruppe unterschiedlich # große Packungen haben können (Pesto 99 g vs. 160 g) – die Gruppe legt einen # gemeinsamen Richtwert fest. NULL = keiner definiert. package_size: Mapped[float | None] = mapped_column(Float, nullable=True) package_label: Mapped[str | None] = mapped_column(String(32), nullable=True) # true = ``min_stock`` (und die je-Ort-Werte) sind in diesem Gebinde erfasst, # sonst in ``min_stock_unit`` (bzw. Basiseinheit). min_stock_in_packages: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False ) products: Mapped[list[Product]] = relationship(back_populates="group") min_stock_unit: Mapped[Unit | None] = relationship() location_min_stocks: Mapped[list["GroupLocationMinStock"]] = relationship( cascade="all, delete-orphan" ) # Obergruppen dieser Gruppe (mehrere moeglich) bzw. die Gruppen, die # umgekehrt unter dieser haengen. Bestand und Mindestbestand einer Gruppe # zaehlen immer den ganzen Untergraphen mit – siehe services/groups.py. # ``lazy="selectin"``: /groups laedt alle Gruppen auf einmal – so faellt je # Ebene EINE Nachladeabfrage an statt einer je Gruppe. parents: Mapped[list["Group"]] = relationship( "Group", secondary=group_parents, primaryjoin=lambda: Group.id == group_parents.c.child_id, secondaryjoin=lambda: Group.id == group_parents.c.parent_id, back_populates="children", lazy="selectin", ) children: Mapped[list["Group"]] = relationship( "Group", secondary=group_parents, primaryjoin=lambda: Group.id == group_parents.c.parent_id, secondaryjoin=lambda: Group.id == group_parents.c.child_id, back_populates="parents", lazy="selectin", ) # Lagerorte tragen einen zufälligen 10-Zeichen-Code als ID statt einer # fortlaufenden Zahl. So kollidieren Sicherung/Import zwischen zwei Instanzen # praktisch nie, und der Code IST zugleich der Inhalt des QR /l/. # Alphabet ohne 0/O/1/I/L – wie bei den Einzelstück-UIDs gut ablesbar. _LOCATION_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" def generate_location_code() -> str: """Zufälliger 10-Zeichen-Code für einen Lagerort (Kollision vernachlässigbar).""" return "".join(secrets.choice(_LOCATION_CODE_ALPHABET) for _ in range(10)) class Location(Base): __tablename__ = "locations" id: Mapped[str] = mapped_column( String(10), primary_key=True, default=generate_location_code ) name: Mapped[str] = mapped_column(String(120), nullable=False) # Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat. parent_id: Mapped[str | None] = mapped_column( String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True ) class Category(Base): """Einordnung eines Artikels **allein für den Überblick**. Nicht zu verwechseln mit :class:`Group`: Eine Gruppe fasst Bestände mehrerer Marken zusammen (5 kg Mehl, egal von wem) und trägt Mindestbestand und EAN-Codes. Eine Kategorie tut nichts dergleichen – sie hilft nur, in einer langen Artikelliste "zeig mir alle Süßwaren" sagen zu können. Beliebig tief verschachtelbar (Süßwaren → Schokolade), wie :class:`Location`. """ __tablename__ = "categories" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(120), nullable=False) parent_id: Mapped[int | None] = mapped_column( ForeignKey("categories.id", ondelete="SET NULL"), nullable=True ) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) # Verwaltungsart der Artikel dieser Kategorie: "food" (Chargen+MHD) oder # "object" (Menge je Lagerort). Neue Kategorien sind Gegenstände; bestehende # (reine Lebensmittel-Installationen) werden bei der Migration auf "food" # gesetzt. Als kurzer String wie date_precision, damit per ADD COLUMN nachziehbar. tracking: Mapped[str] = mapped_column( String(16), nullable=False, default=CategoryTracking.object.value, server_default=CategoryTracking.food.value, ) # Wie lange eine angebrochene Packung dieser Kategorie noch haelt. # NULL = erbt von der Oberkategorie, zuletzt vom globalen Wert # (services/vererbung.py::oeffnungsfrist). open_days: Mapped[int | None] = mapped_column(Integer, nullable=True) class Product(Base): __tablename__ = "products" __table_args__ = (UniqueConstraint("barcode", name="uq_products_barcode"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) barcode: Mapped[str | None] = mapped_column(String(64), nullable=True) name: Mapped[str] = mapped_column(String(255), nullable=False) brand: Mapped[str | None] = mapped_column(String(255), nullable=True) image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) # Kanonische Basiseinheit (piece/gram/milliliter): bestimmt Speicherung + Art (kind). base_unit: Mapped[BaseUnit] = mapped_column( Enum(BaseUnit), nullable=False, default=BaseUnit.piece ) # Bevorzugte Anzeige-/Eingabeeinheit (z.B. Kilogramm). NULL = Basiseinheit selbst. display_unit_id: Mapped[int | None] = mapped_column( ForeignKey("units.id", ondelete="SET NULL"), nullable=True ) # Number of base units contained in one package (e.g. 500 g per package). package_size: Mapped[float | None] = mapped_column(Float, nullable=True) # Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", … package_label: Mapped[str | None] = mapped_column(String(32), nullable=True) # Zweiteinheit: Bruecke zwischen den Einheiten-ARTEN fuer DIESEN Artikel. # „3 Stueck ≙ 250 g" – gespeichert als das eingegebene PAAR, damit beim # naechsten Oeffnen genau das wieder dasteht und nicht „83,333 g je Stueck". # ``secondary_count`` zaehlt in der Basiseinheit des Artikels, # ``secondary_amount`` in ``secondary_base``. NULL = keine Bruecke, dann # bleibt es bei der strikten Trennung der Arten (services/conversion.py). # # String statt Enum wie bei ``Category.tracking``: ein natives Postgres-Enum # in der handgeschriebenen Migration nachzuziehen waere unnoetig heikel. secondary_base: Mapped[str | None] = mapped_column(String(16), nullable=True) secondary_count: Mapped[float | None] = mapped_column(Float, nullable=True) secondary_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # Frist nach dem Anbrechen, nur fuer diesen Artikel. NULL = erbt von der # Kategorie (und die von ihren Oberkategorien, zuletzt vom globalen Wert). # Noetig fuer Ausnahmen: H-Milch haelt geoeffnet so lange wie Frischmilch, # steckt aber in derselben Kategorie. open_days: Mapped[int | None] = mapped_column(Integer, nullable=True) # Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die # Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt). date_precision: Mapped[str] = mapped_column( String(8), nullable=False, default=DatePrecision.day.value, server_default=DatePrecision.day.value, ) # Gruppe: zaehlt Bestaende mehrerer Marken zusammen. group_id: Mapped[int | None] = mapped_column( ForeignKey("groups.id", ondelete="SET NULL"), nullable=True ) # Kategorie: reine Ordnungshilfe fuer die Artikelliste, voellig unabhaengig # von der Gruppe. Ein Artikel kann beides, eines oder keines haben. category_id: Mapped[int | None] = mapped_column( ForeignKey("categories.id", ondelete="SET NULL"), nullable=True ) min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units # In welcher Einheit der Mindestbestand erfasst wurde (nur für die Anzeige). min_stock_unit_id: Mapped[int | None] = mapped_column( ForeignKey("units.id", ondelete="SET NULL"), nullable=True ) min_stock_in_packages: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False ) source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF # Nur für Gegenstände: Bezugsquelle ("gekauft bei") und Link zum Onlineshop. shop_id: Mapped[int | None] = mapped_column( ForeignKey("shops.id", ondelete="SET NULL"), nullable=True ) product_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) # Gegenstände: als Einzelstücke (Items mit eigener UID/QR) statt als Menge je # Lagerort verwalten. So bekommt jedes physische Stück ein eigenes Kaufdatum, # eine eigene Garantie und Bezugsquelle (z.B. dieselbe Powerbank 2024 + 2025). individual: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) # Gegenstände: als Verbrauchsgegenstand wie ein Lebensmittel führen (Chargen, # Einheit, Packungsgröße, MHD optional, Mindestbestand), aber ohne eindeutigen # Code – z.B. Sonnencreme in ml. Schließt sich mit ``individual`` aus. Für die # Kernlogik gilt: foodLike = kein Gegenstand ODER bulk. bulk: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) # Zeitpunkt der letzten Änderung an den Stammdaten des Artikels (nicht am # Bestand). Wird bei jedem ORM-UPDATE der Zeile automatisch nachgezogen. updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_now, onupdate=_now ) group: Mapped[Group | None] = relationship(back_populates="products") category: Mapped[Category | None] = relationship() shop: Mapped[Shop | None] = relationship() display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id]) min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id]) lots: Mapped[list[Lot]] = relationship( back_populates="product", cascade="all, delete-orphan" ) field_values: Mapped[list[ProductFieldValue]] = relationship( back_populates="product", cascade="all, delete-orphan" ) location_min_stocks: Mapped[list["ProductLocationMinStock"]] = relationship( cascade="all, delete-orphan" ) class ApiToken(Base): """Langlebiges Token für externe Zugriffe (z.B. Home Assistant). Gespeichert wird nur der SHA-256-Hash; der Klartext wird einmalig beim Anlegen angezeigt. """ __tablename__ = "api_tokens" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(120), nullable=False) token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), nullable=True ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) class Barcode(Base): """Zusätzliche EAN-Codes für ein Produkt ODER eine Gruppe. Beispiel Gruppe "Mehl": alle Mehl-Marken einscannen; ein Scan ordnet das Produkt dann automatisch dieser Gruppe zu. """ __tablename__ = "barcodes" id: Mapped[int] = mapped_column(Integer, primary_key=True) code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) # Freitext zur Einordnung, z.B. "Mehl bei Aldi" note: Mapped[str | None] = mapped_column(String(120), nullable=True) product_id: Mapped[int | None] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), nullable=True ) group_id: Mapped[int | None] = mapped_column( ForeignKey("groups.id", ondelete="CASCADE"), nullable=True ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) class Lot(Base): __tablename__ = "lots" id: Mapped[int] = mapped_column(Integer, primary_key=True) product_id: Mapped[int] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), nullable=False ) quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units # Immer ein echtes Datum, damit FEFO und Ablauf-Abfragen unverändert bleiben. # Bei Monatsangaben steht hier der Monatsletzte (siehe services/dates.py). best_before: Mapped[date | None] = mapped_column(Date, nullable=True) # Wie genau die Angabe ursprünglich war – entscheidet nur über die Anzeige. best_before_precision: Mapped[str] = mapped_column( String(8), nullable=False, default=DatePrecision.day.value, server_default=DatePrecision.day.value, ) location_id: Mapped[str | None] = mapped_column( String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True ) # Angebrochen: NULL = ungeoeffnet. Bewusst KEIN eigenes Statusfeld – das # Datum traegt den Zustand mit und wird fuer die Frist sowieso gebraucht; # ein zweites Feld koennte ihm widersprechen. opened_on: Mapped[date | None] = mapped_column(Date, nullable=True) # „Zu verbrauchen bis" = opened_on + Frist (services/vererbung.py). Beim # Oeffnen gerechnet und GESPEICHERT: so bleibt das Datum an der offenen # Packung stehen, wenn die Frist spaeter geaendert wird, und die # Ablaufliste muss nicht je Zeile die Kategorie-Vererbung aufloesen. consume_by: Mapped[date | None] = mapped_column(Date, nullable=True) # Menge beim Oeffnen, damit „noch 400 ml von 1 l" anzeigbar ist. opened_quantity: Mapped[float | None] = mapped_column(Float, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) product: Mapped[Product] = relationship(back_populates="lots") @property def ist_angebrochen(self) -> bool: return self.opened_on is not None @property def faellig_am(self) -> date | None: """Das frueheste der beiden Daten – danach wird gewarnt. MHD und Verbrauchsdatum bleiben getrennt sichtbar; fuer Ablaufliste, Zustand und Erinnerungen zaehlt aber immer das naehere von beiden. """ termine = [d for d in (self.best_before, self.consume_by) if d is not None] return min(termine) if termine else None class Movement(Base): __tablename__ = "movements" id: Mapped[int] = mapped_column(Integer, primary_key=True) product_id: Mapped[int] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), nullable=False ) lot_id: Mapped[int | None] = mapped_column( ForeignKey("lots.id", ondelete="SET NULL"), nullable=True ) user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) type: Mapped[MovementType] = mapped_column(Enum(MovementType), nullable=False) quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered note: Mapped[str | None] = mapped_column(String(255), nullable=True) # Bei Gegenstands-Buchungen der betroffene Lagerort (Lebensmittel führen den # Ort an der Charge/Lot). Nullable, damit bestehende Bewegungen gültig bleiben. location_id: Mapped[str | None] = mapped_column( String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True ) # Grund einer Entnahme (lost/broken/…), nur bei type=out aus dem Entfernen-Dialog. # Als kurzer String, damit per ADD COLUMN nachziehbar (kein Postgres-Enumtyp). reason: Mapped[str | None] = mapped_column(String(16), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) class PackageType(Base): """Bezeichnung eines Gebindes in Einzahl und Mehrzahl. Der Artikel speichert weiterhin nur die **Einzahl** als Text (``Product.package_label``) – diese Tabelle liefert dazu die Mehrzahl. So bleiben vorhandene Artikel, Sicherungen und CSV-Dateien gültig; eine unbekannte Bezeichnung fällt schlicht auf die Einzahl zurück. """ __tablename__ = "package_types" id: Mapped[int] = mapped_column(Integer, primary_key=True) singular: Mapped[str] = mapped_column(String(32), nullable=False, unique=True) plural: Mapped[str] = mapped_column(String(32), nullable=False) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) class Measure(Base): """Ein Hausmaß wie „Esslöffel" – nur der NAME, ohne Menge. Die Menge gehört nicht an den Namen, sondern an die Kategorie bzw. den Artikel: 1 EL Mehl sind 10 g, 1 EL Zucker 12 g, 1 EL Öl 15 ml. Ein globaler Faktor wie bei :class:`Unit` wäre also schlicht falsch. Auch die ART (Gramm/Milliliter/Stück) steckt deshalb in der Menge, nicht hier. Der Name ist trotzdem global, damit „EL" überall dasselbe Maß bezeichnet – sonst wären „EL" und „Esslöffel" in zwei Kategorien zwei verschiedene Dinge und ein Rezept fände keines davon wieder. """ __tablename__ = "measures" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(40), nullable=False, unique=True) # Kurzform fuer Rezepte und schmale Spalten: „EL", „TL". short: Mapped[str | None] = mapped_column(String(10), nullable=True) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) class CategoryMeasure(Base): """Was ein Hausmaß in DIESER Kategorie wiegt bzw. fasst. Gilt über den Kategorie-Baum auch für die Unterkategorien; der nächste Vorfahr mit Eintrag gewinnt (services/vererbung.py). Eigene Tabelle statt nullbarer Spalten in einer gemeinsamen, wie es :class:`ProductLocationMinStock` und :class:`GroupLocationMinStock` schon vormachen. """ __tablename__ = "category_measures" __table_args__ = ( UniqueConstraint("measure_id", "category_id", name="uq_cat_measure"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) measure_id: Mapped[int] = mapped_column( ForeignKey("measures.id", ondelete="CASCADE"), nullable=False, index=True ) category_id: Mapped[int] = mapped_column( ForeignKey("categories.id", ondelete="CASCADE"), nullable=False, index=True ) # Menge in ``base`` – also 10 fuer „1 EL = 10 g". amount: Mapped[float] = mapped_column(Float, nullable=False) # Art der Menge: BaseUnit-Wert (piece/gram/milliliter). Als String wie # ``Product.secondary_base``, damit kein Postgres-Enumtyp nachzuziehen ist. base: Mapped[str] = mapped_column(String(16), nullable=False) class ProductMeasure(Base): """Hausmaß-Menge für EINEN Artikel – schlägt die der Kategorie. Für Ausnahmen innerhalb einer Kategorie: Puderzucker ist deutlich leichter als Kristallzucker, steckt aber in derselben Kategorie. """ __tablename__ = "product_measures" __table_args__ = ( UniqueConstraint("measure_id", "product_id", name="uq_prod_measure"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) measure_id: Mapped[int] = mapped_column( ForeignKey("measures.id", ondelete="CASCADE"), nullable=False, index=True ) product_id: Mapped[int] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True ) amount: Mapped[float] = mapped_column(Float, nullable=False) base: Mapped[str] = mapped_column(String(16), nullable=False) class ProductImage(Base): """Lokale Kopie des Artikelbilds. Eigene Tabelle statt einer Spalte an ``products``: Sonst zöge jede Artikelliste die Bilddaten mit, obwohl sie dort niemand braucht. ``source_url`` merkt sich, woher das Bild kam – daran ist erkennbar, ob eine geänderte ``Product.image_url`` ein neues Bild bedeutet. ``thumb`` ist dasselbe Bild auf Briefmarkengröße. Listen zeigen 34x34 Pixel je Zeile – ohne diese Spalte lud eine Liste mit 40 Einträgen 40 Fotos in voller Auflösung, also Dutzende Megabyte für ein paar Briefmarken. Nullbar, weil ältere Zeilen noch keine haben; sie wird beim ersten Abruf nachgezogen. """ __tablename__ = "product_images" product_id: Mapped[int] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), primary_key=True ) content_type: Mapped[str] = mapped_column(String(64), nullable=False) data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) thumb: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) source_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_now, onupdate=_now ) class BrandingAsset(Base): """Eigenes Logo bzw. Favicon der Installation. Bewusst in der Datenbank und nicht im Dateisystem: So landet das Bild automatisch im Backup und das Deployment braucht kein zusätzliches Volume. Es geht um wenige Kilobyte, die Größe ist beim Upload begrenzt. """ __tablename__ = "branding_assets" kind: Mapped[str] = mapped_column(String(16), primary_key=True) # "logo" | "favicon" content_type: Mapped[str] = mapped_column(String(64), nullable=False) data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_now, onupdate=_now ) class DashboardLayout(Base): """Ein Dashboard: benannte Anordnung von Karten. ``user_id = NULL`` ist die Vorgabe des Administrators: Sie dient neuen Benutzern als Ausgangspunkt und lässt sich – falls in den Einstellungen so gesetzt – auch verbindlich für alle machen. Jeder Benutzer darf **mehrere** Dashboards haben (deshalb steht auf ``user_id`` kein unique mehr); ``position`` bestimmt ihre Reihenfolge in der Seitenleiste. """ __tablename__ = "dashboard_layouts" id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), nullable=True ) name: Mapped[str] = mapped_column(String(80), nullable=False, default="Übersicht") position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # JSON-Text. "i" ist die Kennung *dieser Karte*, "type" ihre Art - erst # dadurch laesst sich dieselbe Art mehrfach auf ein Dashboard legen: # [{"i": "k3", "type": "expiring", "x": 0, "y": 0, "w": 4, "h": 3, "props": {}}] layout: Mapped[str] = mapped_column(Text, nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_now, onupdate=_now ) class EtikettenVorlage(Base): """Eine gespeicherte Zusammenstellung fuer den Etikettenexport. Benannt, damit man sie wieder aufrufen kann („Kabelexport"), und mit frei gewaehlten Spalten - erst dadurch kommen eigene Felder wie eine Kabellaenge oder die Kapazitaet einer Powerbank ueberhaupt aufs Etikett. Vorbild ist :class:`DashboardLayout`: eigene Tabelle statt ``Setting`` (dessen ``value`` fasst 255 Zeichen und haelt genau einen Wert je Schluessel), Nutzlast als JSON-Text. Anders als dort ohne ``user_id`` - eine Vorlage gehoert allen, nicht einem Konto. """ __tablename__ = "etiketten_vorlagen" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) # Worauf sich die Vorlage bezieht: items | products | locations. # Als kurzer String und nicht als DB-Enum, wie DatePrecision und # CategoryTracking - so laesst sich eine weitere Art spaeter ohne neuen # Postgres-Typ nachziehen. art: Mapped[str] = mapped_column(String(16), nullable=False, default="items") position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Spaltenwahl, Reihenfolge, Filter und Trennzeichen als JSON-Text. konfiguration: Mapped[str] = mapped_column(Text, nullable=False, default="{}") # Hinterlegte P-touch-Vorlage (.lbx). Hoechstens eine je Eintrag, deshalb # Spalten derselben Zeile statt einer zweiten Tabelle. datei_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # deferred: Sonst zoege jede Liste der Vorlagen die Binaerdaten mit - genau # das vermeidet ItemDocument.data aus demselben Grund. datei: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, deferred=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_now, onupdate=_now ) class Setting(Base): __tablename__ = "settings" key: Mapped[str] = mapped_column(String(64), primary_key=True) value: Mapped[str] = mapped_column(String(255), nullable=False) class IntegrationSecret(Base): """Zugangsgeheimnis für einen externen Dienst (heute: das Mealie-Dauertoken). Bewusst **nicht** in :class:`Setting`: ``GET /settings`` liefert jedem angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort wäre beim nächsten Geheimnis vergessen. Diese Tabelle hat deshalb überhaupt keinen Leseweg nach außen — setzen, löschen, und eine Auskunft „ist gesetzt". Was keinen Ausgang hat, kann nicht durchrutschen. Anders als bei :class:`ApiToken` muss der Klartext erhalten bleiben: Wir legen ihn einem fremden Dienst vor, ein Hash nützte dort nichts. Er liegt damit unverschlüsselt in der Datenbank — eine Verschlüsselung wäre Theater, solange der Schlüssel daneben läge. Der Schutz ist: kein Leseweg, nur Admin beim Setzen, nie in einer Antwort. """ __tablename__ = "integration_secrets" service: Mapped[str] = mapped_column(String(32), primary_key=True) secret: Mapped[str] = mapped_column(Text, nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_now, onupdate=_now ) class ShoppingEntry(Base): """Ein Eintrag, den jemand bewusst auf die Einkaufsliste gesetzt hat. Die Einkaufsliste war bis hierher vollstaendig **gerechnet**: Artikel unter ihrem Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt die Staerke der Liste und wird nicht ersetzt. Was fehlte, war ein Ort fuer "Milch gehoert drauf, weil ich das sage" - und damit auch fuer "diese Zutaten fehlen mir fuer das Rezept". Genau EINES von ``product_id``, ``group_id`` und ``free_text`` ist gesetzt. Geprueft wird das beim Anlegen (services/einkaufsliste.py), nicht in der Datenbank: Als CHECK-Bedingung waere es ueber drei nullbare Spalten unleserlich, und die Fehlermeldung waere keine, die man jemandem zeigen kann. Der Freitext ist unverzichtbar - fuer eine Rezeptzutat, die hier keinem Artikel entspricht ("Ras el Hanout"), ist er die einzige Form, in der sie auf den Zettel kommt. ``quantity_base`` steht in Basiseinheiten, wie alle Mengen im Projekt. Bei einem Freitext bleibt sie leer: "etwas Hefe" hat keine Zahl. ``recipe_slug``/``recipe_name`` halten die Herkunft fest. Sie sind mehr als Zierde: Derselbe Artikel kann fuer zwei Rezepte fehlen, und dann braucht man ihn zweimal. Je Rezept und Ziel gibt es deshalb hoechstens einen offenen Eintrag - ein zweiter Druck auf "fehlende Zutaten hinzufuegen" rechnet die Menge neu, statt die Liste zu verdoppeln. """ __tablename__ = "shopping_entries" id: Mapped[int] = mapped_column(Integer, primary_key=True) product_id: Mapped[int | None] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True ) group_id: Mapped[int | None] = mapped_column( ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True ) free_text: Mapped[str | None] = mapped_column(String(120), nullable=True) quantity_base: Mapped[float | None] = mapped_column(Float, nullable=True) # BaseUnit-Wert (piece/gram/milliliter) als String - wie ``Product # .secondary_base``, damit kein Postgres-Enumtyp nachzuziehen ist. base: Mapped[str | None] = mapped_column(String(16), nullable=True) # "manual" | "recipe" source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") recipe_slug: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) recipe_name: Mapped[str | None] = mapped_column(String(200), nullable=True) done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) done_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_by: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_now, nullable=False ) product: Mapped[Product | None] = relationship() group: Mapped[Group | None] = relationship() class ShoppingCheck(Base): """Ein abgehakter Posten der GERECHNETEN Einkaufsliste. Die gerechneten Vorschlaege haben keine Zeile in der Datenbank - sie entstehen bei jedem Aufruf neu aus den Mindestbestaenden. Abhaken liess sich deshalb nur im Browser merken und war nach dem Neuladen weg, mitten im Supermarkt. Gemerkt wird hier nur der Schluessel des Postens, nicht der Posten selbst: ``p12`` (Artikel), ``g3`` (Gruppe), ``l KUECHE p12`` (Artikel an einem Lagerort) - dieselben Schluessel, die die Oberflaeche ohnehin bildet. Damit bleibt die Liste gerechnet, und das Abhaken haengt sich nur daran. Sinkt der Bestand spaeter wieder, taucht der Posten mit seinem alten Haken auf; das Aufraeumen erledigt "Erledigte entfernen". """ __tablename__ = "shopping_checks" key: Mapped[str] = mapped_column(String(80), primary_key=True) checked_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_now, nullable=False ) user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) class MealieFoodMap(Base): """Gemerkte Zuordnung: Mealie-Lebensmittel X ist bei uns Artikel/Gruppe Y. Der unscharfe Abgleich in ``services/rezepte.py`` raet, dieser Tisch weiss. Jede Korrektur von Hand landet hier und gilt ab dann fuer jedes Rezept, in dem dasselbe Lebensmittel vorkommt — eine Zutat wird also hoechstens einmal im Leben zugeordnet. Schluessel ist der **normalisierte** Name (``matching.normalisieren``), damit "Schlagsahne", "schlagsahne" und "SCHLAGSAHNE " dieselbe Zeile treffen. Daneben steht der Name, wie Mealie ihn schreibt — nur zur Anzeige in der Liste der gemerkten Zuordnungen. Genau eines von ``product_id`` und ``group_id`` ist gesetzt; geprueft wird das beim Speichern (``services/zuordnung.py``), nicht in der Datenbank — dasselbe Muster wie bei den Bedarfszeilen. Verschwindet das Ziel spaeter, wird die Zeile beim Laden stillschweigend uebersprungen: Auf SQLite greift kein Fremdschluessel, und eine Zuordnung auf einen geloeschten Artikel darf den Rezeptabgleich nicht sprengen. """ __tablename__ = "mealie_food_map" food_key: Mapped[str] = mapped_column(String(200), primary_key=True) food_name: Mapped[str] = mapped_column(String(200), nullable=False) product_id: Mapped[int | None] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True ) group_id: Mapped[int | None] = mapped_column( ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_now, onupdate=_now ) class Shop(Base): """Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei"). Vom Admin verwaltbare Liste (wie Lagerorte). Am Artikel optional – "unbekannt" oder "mehrere Herkünfte" bleibt einfach leer. """ __tablename__ = "shops" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) website: Mapped[str | None] = mapped_column(String(1024), nullable=True) class FieldDefinition(Base): """Selbst definiertes Feld an einer Kategorie (z.B. „Kapazität“ in mAh). Gilt für Artikel dieser Kategorie und – über die Vererbung im Kategorie-Baum – auch für deren Unterkategorien. Der konkrete Wert je Artikel steht in :class:`ProductFieldValue`. """ __tablename__ = "field_definitions" id: Mapped[int] = mapped_column(Integer, primary_key=True) category_id: Mapped[int] = mapped_column( ForeignKey("categories.id", ondelete="CASCADE"), nullable=False ) label: Mapped[str] = mapped_column(String(120), nullable=False) # Maschinenlesbarer Schlüssel (aus dem Label abgeleitet) – für Export/Anzeige. key: Mapped[str] = mapped_column(String(120), nullable=False) field_type: Mapped[str] = mapped_column( String(16), nullable=False, default=FieldType.text.value ) # Einheit für Zahlenfelder (z.B. "mAh", "g", "W"). unit: Mapped[str | None] = mapped_column(String(32), nullable=True) # JSON-Liste der Auswahlmöglichkeiten für field_type == "select". options: Mapped[str | None] = mapped_column(Text, nullable=True) required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) class ProductFieldValue(Base): """Wert eines selbst definierten Feldes für einen konkreten Artikel.""" __tablename__ = "product_field_values" __table_args__ = ( UniqueConstraint( "product_id", "field_definition_id", name="uq_pfv_product_field" ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) product_id: Mapped[int] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), nullable=False ) field_definition_id: Mapped[int] = mapped_column( ForeignKey("field_definitions.id", ondelete="CASCADE"), nullable=False ) # Immer als Text gespeichert; typgerecht interpretiert wird beim Lesen/Schreiben. value: Mapped[str | None] = mapped_column(Text, nullable=True) product: Mapped[Product] = relationship(back_populates="field_values") field_definition: Mapped[FieldDefinition] = relationship() class Item(Base): """Ein physisches Einzelstück eines Gegenstands (Instanz) mit eigener UID/QR. Nur für Gegenstands-Produkte mit Einzelstück-Verwaltung (``Product.individual``). Je Stück ein eigener Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz – so lässt sich dasselbe Modell mehrfach getrennt führen (Powerbank 2024 + 2025). Der QR-Code trägt die UID; ein Scan öffnet genau dieses Stück. """ __tablename__ = "items" id: Mapped[int] = mapped_column(Integer, primary_key=True) # Kurzer, gut lesbarer Code für den QR (ohne verwechselbare Zeichen). uid: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False) product_id: Mapped[int] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), nullable=False ) location_id: Mapped[str | None] = mapped_column( String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True ) shop_id: Mapped[int | None] = mapped_column( ForeignKey("shops.id", ondelete="SET NULL"), nullable=True ) acquired_on: Mapped[date | None] = mapped_column(Date, nullable=True) # gekauft am warranty_until: Mapped[date | None] = mapped_column(Date, nullable=True) # Garantie bis note: Mapped[str | None] = mapped_column(String(255), nullable=True) # Notiz/Zustand # Kaufpreis in kleinster Einheit (Rappen/Cent), damit keine Float-Rundung # entsteht; Währung getrennt (z.B. CHF, EUR). price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) currency: Mapped[str | None] = mapped_column(String(3), nullable=True) # Zubehoer: Dieses Stueck gehoert zu einem anderen (Netzteil -> Laptop). # Genau ein Geraet je Teil, beliebig viele Teile je Geraet - dafuer genuegt # eine Spalte, es braucht keine Verknuepfungstabelle wie bei den Gruppen. # SET NULL statt CASCADE: Verschwindet das Geraet, wird das Netzteil wieder # eigenstaendig, statt ungefragt mitgeloescht zu werden. parent_item_id: Mapped[int | None] = mapped_column( ForeignKey("items.id", ondelete="SET NULL"), nullable=True, index=True ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) product: Mapped[Product] = relationship() # Beide Richtungen sind gefragt: am Geraet "was gehoert dazu", am Teil # "wozu gehoert das". selectin, weil sonst jede Einzelstueckliste je Zeile # eine Extraabfrage ausloest (wie bei Group.parents). gehoert_zu: Mapped["Item | None"] = relationship( "Item", remote_side="Item.id", back_populates="zubehoer", lazy="joined", # joined statt selectin: Es ist hoechstens EIN Geraet je Teil, ein # many-to-one-Join kann die Zeilen also nicht vervielfachen - und ohne # das holte jede Einzelstueckliste je Zeile eine Extraabfrage. join_depth=1, ) zubehoer: Mapped[list["Item"]] = relationship( "Item", back_populates="gehoert_zu", lazy="selectin", order_by="Item.uid" ) location: Mapped[Location | None] = relationship() shop: Mapped[Shop | None] = relationship() documents: Mapped[list["ItemDocument"]] = relationship( cascade="all, delete-orphan", order_by="ItemDocument.id" ) # Verleih-Historie, neueste zuerst. Der offene Verleih (returned_on NULL) # ist hoechstens einer, siehe ItemLoan. loans: Mapped[list["ItemLoan"]] = relationship( back_populates="item", cascade="all, delete-orphan", order_by="ItemLoan.lent_on.desc(), ItemLoan.id.desc()", ) class ItemDocument(Base): """Beleg zu einem Einzelstück: Rechnung oder Garantieschein (PDF oder Bild). Eigene Tabelle wie :class:`ProductImage`, damit die Binärdaten nicht bei jeder Item-Abfrage mitgezogen werden. Mehrere Belege je Stück sind erlaubt. """ __tablename__ = "item_documents" id: Mapped[int] = mapped_column(Integer, primary_key=True) item_id: Mapped[int] = mapped_column( ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True ) filename: Mapped[str] = mapped_column(String(255), nullable=False) content_type: Mapped[str] = mapped_column(String(64), nullable=False) # Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit. data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True) uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) class ItemLoan(Base): """Ein Verleih-Vorgang eines Einzelstücks: an wen, warum, bis wann. Bewusst eine eigene Tabelle statt eines Lagerort-Werts „verliehen": Der Lagerort bleibt stehen und sagt weiter, wohin das Stück nach der Rückgabe zurückgehört. Zurückgegebene Verleihe bleiben als Zeile erhalten – damit ist die Tabelle gleichzeitig die Verleih-Historie je Stück. ``returned_on`` NULL heißt „noch draußen"; ein Stück kann nur einen solchen offenen Verleih haben (Teilindex unten). Verliehene Stücke zählen nirgends als Bestand, siehe ``services/loans.py``. """ __tablename__ = "item_loans" __table_args__ = ( # Zweimal gleichzeitig verliehen kann ein einzelnes Stück nicht sein. # Als Teilindex, weil nur die OFFENEN Verleihe eindeutig sein dürfen: # zurückgegebene bleiben ja beliebig oft je Stück stehen. Index( "uq_item_loan_offen", "item_id", unique=True, postgresql_where=text("returned_on IS NULL"), sqlite_where=text("returned_on IS NULL"), ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) item_id: Mapped[int] = mapped_column( ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True ) # Freitext: Entleiher sind keine Vorrania-Benutzer (Nachbar, Kollege …). borrower: Mapped[str] = mapped_column(String(120), nullable=False) reason: Mapped[str | None] = mapped_column(String(255), nullable=True) lent_on: Mapped[date] = mapped_column(Date, nullable=False, default=date.today) # Erwartete Rückgabe – bewusst optional: „kurz mal mitgenommen" soll nicht # an einem Datumszwang scheitern. due_on: Mapped[date | None] = mapped_column(Date, nullable=True) returned_on: Mapped[date | None] = mapped_column(Date, nullable=True) # Wer herausgegeben hat. SET NULL, damit ein gelöschtes Konto den Vorgang # nicht mitnimmt. user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) item: Mapped[Item] = relationship(back_populates="loans") user: Mapped[User | None] = relationship() class ProductLocationMinStock(Base): """Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten). Der einzige Ort, an dem Artikel-Mindestbestaende stehen. ``location_id`` NULL heisst „Ueberall" – egal wo, Hauptsache die Menge ist im Haus; das war frueher das separate Feld ``Product.min_stock``. Damit ist „Ueberall" die Wurzel ueber allen Lagerorten und wird von derselben Verrechnung erfasst wie verschachtelte Orte (siehe routers/views.py). Mengen stehen in BASISEINHEITEN (g/ml/Stueck), nicht in Artikeleinheiten: sonst bedeutet ein gespeicherter Wert etwas anderes, sobald sich die Packungsgroesse aendert. In welcher Einheit er angezeigt und eingetippt wird, sagen ``Product.min_stock_unit_id`` und ``min_stock_in_packages``. """ __tablename__ = "product_location_min_stock" id: Mapped[int] = mapped_column(Integer, primary_key=True) product_id: Mapped[int] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True ) location_id: Mapped[str | None] = mapped_column( String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True ) min_stock: Mapped[float] = mapped_column(Float, nullable=False) location: Mapped[Location | None] = relationship() __table_args__ = ( UniqueConstraint("product_id", "location_id", name="uq_prod_loc_min"), # Postgres zaehlt NULLs in UNIQUE als verschieden – der Constraint oben # verhindert also KEINE zwei „Ueberall"-Zeilen. Dafuer ein Teilindex. Index( "uq_prod_ueberall_min", "product_id", unique=True, postgresql_where=text("location_id IS NULL"), sqlite_where=text("location_id IS NULL"), ), ) class GroupLocationMinStock(Base): """Mindestbestand einer Gruppe an EINEM Lagerort (in Basiseinheiten). ``location_id`` NULL = „Ueberall", genau wie bei :class:`ProductLocationMinStock`. Auch hier Basiseinheiten – nur so lassen sich Ober- und Untergruppe gegeneinander verrechnen, wenn die eine in Kilogramm und die andere in Glaesern erfasst ist. """ __tablename__ = "group_location_min_stock" id: Mapped[int] = mapped_column(Integer, primary_key=True) group_id: Mapped[int] = mapped_column( ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True ) location_id: Mapped[str | None] = mapped_column( String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True ) min_stock: Mapped[float] = mapped_column(Float, nullable=False) location: Mapped[Location | None] = relationship() __table_args__ = ( UniqueConstraint("group_id", "location_id", name="uq_group_loc_min"), Index( "uq_group_ueberall_min", "group_id", unique=True, postgresql_where=text("location_id IS NULL"), sqlite_where=text("location_id IS NULL"), ), )