Zwei Ebenen in einer Tabelle, nach dem Vorbild von DashboardLayout: user_id NULL ist die Vorgabe des Administrators, eine Zeile mit user_id die zusaetzliche Auswahl eines Kontos. Beide Listen gelten zusammen - wer etwas versteckt, versteckt es zusaetzlich; einblenden, was der Administrator ausgeblendet hat, kann niemand. Das erspart einen dritten Zustand je Eintrag. Als Schluessel dient der Routenpfad; der ist ohnehin eindeutig, eine zweite Benennung waere nur eine weitere Stelle zum Auseinanderlaufen. Drei Routen: lesen (Vorgabe, eigene Auswahl und die fertige Vereinigung in einem Zug), eigene Auswahl setzen, Vorgabe setzen (nur Admin). Die Eindeutigkeit laeuft ueber einen Index auf coalesce(user_id, -1). Ein schlichtes UNIQUE(user_id) haette zwei Vorgabezeilen zugelassen, weil NULL in SQL nie gleich NULL ist - das war im Entwurf noch falsch. Versteckt wird nur der Eintrag, nicht die Seite: Lesezeichen, Dashboard-Karten und Lagerort-QR sollen weiter funktionieren. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1347 lines
60 KiB
Python
1347 lines
60 KiB
Python
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 UsageStatus(str, enum.Enum):
|
||
"""Woran ein Einzelstueck gerade ist (Hemd getragen, Auto in der Wasche).
|
||
|
||
Bewusst KEIN Wert fuer "verfuegbar": Fehlt die offene Zeile in
|
||
:class:`ItemUsage`, ist das Stueck verfuegbar - wie bei ``lots.opened_on``
|
||
und beim Verleih. Ein eigener Wert daneben koennte ihr widersprechen.
|
||
|
||
Absichtlich allgemein und nicht auf Kleidung zugeschnitten: "in_use" traegt
|
||
auch eine Bohrmaschine auf der Baustelle.
|
||
|
||
Als kurzer String gespeichert (wie CategoryTracking, RemovalReason), damit
|
||
sich die Spalte per ADD COLUMN nachziehen laesst.
|
||
"""
|
||
in_use = "in_use" # wird gerade benutzt/getragen
|
||
cleaning = "cleaning" # in der Waesche/Reinigung
|
||
repair = "repair" # in Reparatur
|
||
|
||
|
||
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)
|
||
# Ob Einzelstuecke dieser Kategorie einen Verwendungsstatus fuehren
|
||
# (Kleidung, Werkzeug - nicht Konserven). Wie tracking eine Kopie beim
|
||
# Anlegen und keine Aufloesung beim Lesen: So kommt die Markierung ohne
|
||
# zusaetzliche Abfrage am Einzelstueck an (services/items.py::item_to_out).
|
||
# Ein Haken an einer Oberkategorie wandert beim Speichern in den Unterbaum,
|
||
# siehe routers/categories.py::update_category.
|
||
usage_tracking: Mapped[bool] = mapped_column(
|
||
Boolean, nullable=False, default=False, server_default=text("false")
|
||
)
|
||
|
||
|
||
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 NavigationSichtbarkeit(Base):
|
||
"""Welche Eintraege der Seitenleiste jemand ausgeblendet hat.
|
||
|
||
``user_id = NULL`` ist die Vorgabe des Administrators: Was dort steht,
|
||
bekommt niemand zu sehen. Eine Zeile mit ``user_id`` ist die zusaetzliche
|
||
Auswahl eines Kontos.
|
||
|
||
**Beide Listen gelten zusammen.** Wer etwas versteckt, versteckt es
|
||
zusaetzlich; einblenden, was der Administrator ausgeblendet hat, kann
|
||
niemand. Das erspart einen dritten Zustand je Eintrag ("erben / zeigen /
|
||
verstecken") und laesst sich in einem Satz erklaeren.
|
||
|
||
Versteckt wird nur der EINTRAG, nicht die Seite: Lesezeichen,
|
||
Dashboard-Karten und Lagerort-QR verweisen weiter auf Routen, die hier
|
||
abgewaehlt sein koennen.
|
||
|
||
Vorbild ist :class:`DashboardLayout`: eigene Tabelle statt :class:`Setting`
|
||
(dessen ``value`` fasst 255 Zeichen und haelt genau einen Wert je
|
||
Schluessel), Nutzlast als JSON-Text. Als Schluessel dient der Routenpfad -
|
||
der ist ohnehin schon eindeutig, eine zweite Benennung waere nur eine
|
||
weitere Stelle, die auseinanderlaufen kann.
|
||
"""
|
||
|
||
__tablename__ = "navigation_sichtbarkeit"
|
||
__table_args__ = (
|
||
# Eine Zeile je Konto UND genau eine Vorgabezeile - beides mit einem
|
||
# Index. Ueber coalesce, weil NULL in SQL nie gleich NULL ist: ein
|
||
# schlichtes UNIQUE(user_id) liesse beliebig viele Vorgabezeilen zu.
|
||
Index("uq_navigation_zeile", text("coalesce(user_id, -1)"), unique=True),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
user_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||
)
|
||
# JSON-Liste von Routenpfaden, z.B. ["/rezepte", "/shopping"].
|
||
versteckt: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||
|
||
# Loescht man ein Konto, geht seine Zeile mit (ON DELETE CASCADE oben).
|
||
# In den Tests greift das nicht: SQLite erzwingt Fremdschluessel nur mit
|
||
# PRAGMA foreign_keys=ON, und das setzt die Testumgebung nicht.
|
||
|
||
|
||
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()",
|
||
)
|
||
# Verwendungs-Historie, neueste zuerst. Der laufende Zustand (ended_at NULL)
|
||
# ist hoechstens einer, siehe ItemUsage. Keine offene Zeile = verfuegbar.
|
||
usages: Mapped[list["ItemUsage"]] = relationship(
|
||
back_populates="item", cascade="all, delete-orphan",
|
||
order_by="ItemUsage.started_at.desc(), ItemUsage.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 ItemUsage(Base):
|
||
"""Ein Zeitraum, in dem ein Einzelstueck in einem Verwendungszustand war.
|
||
|
||
Bewusst eine eigene Tabelle und keine Spalte am Einzelstueck: So bleibt
|
||
nachvollziehbar, seit wann ein Hemd in der Waesche liegt und wie oft es
|
||
getragen wurde. Abgeschlossene Zeitraeume bleiben stehen - damit ist die
|
||
Tabelle gleichzeitig die Historie je Stueck.
|
||
|
||
``ended_at`` NULL heisst "laeuft noch"; ein Stueck kann nur einen solchen
|
||
laufenden Zustand haben (Teilindex unten). Es gibt keine Zeile fuer
|
||
"verfuegbar" - keine offene Zeile IST verfuegbar.
|
||
|
||
Anders als der Verleih aendert der Zustand den Bestand NICHT: ein getragenes
|
||
Hemd ist weiterhin im Haushalt. Und wie beim Verleih wird keine Bewegung
|
||
(Movement) geschrieben, siehe routers/loans.py.
|
||
"""
|
||
|
||
__tablename__ = "item_usages"
|
||
__table_args__ = (
|
||
# Zwei Zustaende gleichzeitig kann ein Stueck nicht haben. Als Teilindex,
|
||
# weil nur die LAUFENDEN eindeutig sein duerfen: abgeschlossene bleiben
|
||
# beliebig oft je Stueck stehen.
|
||
Index(
|
||
"uq_item_usage_offen",
|
||
"item_id",
|
||
unique=True,
|
||
postgresql_where=text("ended_at IS NULL"),
|
||
sqlite_where=text("ended_at 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
|
||
)
|
||
# UsageStatus-Wert als kurzer String (kein Postgres-Enumtyp).
|
||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||
# Zeitstempel und nicht nur ein Datum: Ein Hemd kann an einem Tag getragen,
|
||
# gewaschen und wieder eingeraeumt werden.
|
||
started_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), nullable=False, default=_now
|
||
)
|
||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||
# Wer umgestellt hat. SET NULL, damit ein geloeschtes Konto den Vorgang
|
||
# nicht mitnimmt.
|
||
user_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
|
||
item: Mapped[Item] = relationship(back_populates="usages")
|
||
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"),
|
||
),
|
||
)
|