Die Einkaufsliste war ueber /shopping-list und /shopping-list/groups schon abrufbar, aber nur mit einem Login-JWT, das nach 7 Tagen ablaeuft - fuer HA unpraktisch. - Neue Tabelle api_tokens (Name, SHA-256-Hash, Ersteller, zuletzt genutzt). Der Klartext wird nur einmal beim Anlegen zurueckgegeben. - get_current_user akzeptiert jetzt JWT ODER API-Token im Authorization-Header; bei Nutzung wird last_used_at fortgeschrieben. - Endpunkte /api-tokens (Admin): auflisten, erzeugen, widerrufen. - Einstellungsseite: Tokens verwalten inkl. einmaliger Anzeige und einem aufklappbaren Konfigurationsbeispiel fuer Home Assistant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
8.7 KiB
Python
232 lines
8.7 KiB
Python
from __future__ import annotations
|
|
|
|
import enum
|
|
from datetime import date, datetime, timezone
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Date,
|
|
DateTime,
|
|
Enum,
|
|
Float,
|
|
ForeignKey,
|
|
Integer,
|
|
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 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 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)
|
|
|
|
group_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("groups.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")
|
|
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
|
|
best_before: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
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 Setting(Base):
|
|
__tablename__ = "settings"
|
|
|
|
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
value: Mapped[str] = mapped_column(String(255), nullable=False)
|