Files
Vorrania/backend/app/models.py
T
ScarriffleandClaude Opus 5 7d8a54a3fc Mealie anbinden: Passwort einmal, danach nur noch ein Dauertoken
Unter Einstellungen gibt es jetzt eine Karte "Mealie (Rezepte)": Adresse,
Benutzername, Passwort. Vorrania meldet sich damit EINMAL an
(POST /api/auth/token), laesst sich von Mealie ueber
POST /api/users/api-tokens ein Dauertoken ausstellen und verwirft das
Passwort sofort - es steht in keiner Zeile der Datenbank, in keiner
Antwort und in keinem Fehlertext. Wer sein Passwort gar nicht erst
eintippen will, haekelt "Ich gebe lieber selbst ein Token ein" an.

Das Token liegt NICHT in den Einstellungen: GET /settings liefert jedem
angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort
waere beim naechsten Geheimnis vergessen, deshalb eine eigene Tabelle ohne
jeden Leseweg - setzen, loeschen, und die Auskunft "ist gesetzt". Was
keinen Ausgang hat, kann nicht durchrutschen. Ein Test prueft genau das.

Der Dienst folgt services/oidc.py, nicht off.py: Bei Open Food Facts ist
"kein Treffer" der Normalfall und wird still zu None, hier ist "Mealie
antwortet nicht" ein Fehler, den der Nutzer sehen muss. Die Kennungen
trennen auch, was verschieden ist - ein falsches Passwort (400) ist etwas
anderes als ein weggetretener Server (502).

Mealie 1.x legt die Haushalts-Routen unter /api/groups/, 2.x unter
/api/households/. Erkannt wird das zur Laufzeit mit einer Anfrage und
dann eine Stunde gepuffert; ueber die Versionsnummer waere es unzuverlaessig,
die melden Installationen uneinheitlich.

Rezeptliste und -abgleich kommen als Naechstes; dies ist der Teil, mit dem
sich der Server ueberhaupt erst verbinden laesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 12:54:43 +02:00

1097 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<code>.
# 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"),
),
)