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