Files
Vorrania/backend/app/models.py
Scarriffle f62289f975 Mehrere Dashboards und dieselbe Karte mehrfach
Zwei Grenzen sassen tief im Datenmodell. Erstens war "i" im gespeicherten
Layout zugleich die Kartenart; da react-grid-layout eindeutige Kennungen
verlangt, ging jede Art genau einmal - zwei Artikel gleichzeitig beobachten war
damit unmoeglich. Zweitens war dashboard_layouts.user_id unique, also genau ein
Dashboard je Benutzer.

Jetzt ist "i" die Kennung dieser einen Karte und "type" ihre Art, dazu kommt
"props" fuer das, was nur diese Karte angeht (etwa welcher Artikel). Aeltere
Anordnungen haben kein "type"; fuer sie gilt die alte Kennung als Art, sodass
gespeicherte Startseiten unveraendert weiterlaufen.

Die Tabelle bekommt Name und Reihenfolge, das unique faellt. Sein Name haengt
davon ab, wie die Tabelle entstanden ist, deshalb wird er in pg_constraint
nachgeschlagen statt geraten.

Eine Feinheit beim Anlegen: Wer noch auf der Admin-Vorgabe sitzt und sein
erstes eigenes Dashboard anlegt, wuerde die Vorgabe schlagartig verlieren -
sobald eigene Zeilen da sind, zaehlen nur noch die. Deshalb wird sie vorher
uebernommen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:11:49 +02:00

372 lines
14 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
from datetime import date, datetime, timezone
from sqlalchemy import (
Boolean,
Date,
DateTime,
Enum,
Float,
ForeignKey,
Integer,
LargeBinary,
String,
Text,
UniqueConstraint,
)
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 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)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.user)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
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
)
products: Mapped[list[Product]] = relationship(back_populates="group")
min_stock_unit: Mapped[Unit | None] = relationship()
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
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[int | None] = mapped_column(
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)
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)
# 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
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
group: Mapped[Group | None] = relationship(back_populates="products")
category: Mapped[Category | 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"
)
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[int | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
product: Mapped[Product] = relationship(back_populates="lots")
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)
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 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.
"""
__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)
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 Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(String(255), nullable=False)