Das Sitzungs-Token benannte den Benutzer ueber seinen Namen (sub = username). Damit war der Name die Kennung der Sitzung - ein Umbenennen haette jede laufende Anmeldung dieses Benutzers ungueltig gemacht, und deshalb war es gar nicht erst vorgesehen. Beim Anmeldedienst geaenderte Namen liessen sich in Vorrania folglich nirgends nachziehen. Neue Token tragen die ID (sub = "<id>", zusaetzlich uid), und deps.py loest die Sitzung darueber auf. Alles andere haengt ohnehin schon an der ID: Bewegungen, Dashboards, API-Tokens. Der Name ist damit reine Anzeige. Benutzer lassen sich jetzt unter "Benutzer" umbenennen - auch sich selbst, ohne aus der Sitzung zu fliegen. Der Anmeldeweg selbst sucht weiterhin ueber den Namen; das ist das Feld im Formular und soll so bleiben. Bestandsschutz: Token ohne uid werden weiter ueber den Namen aufgeloest, damit beim Update niemand abgemeldet wird. Der Zweig darf nach Ablauf der Token-Lebensdauer weg. Das zusaetzliche uid unterscheidet alt von neu eindeutig - ein Benutzer koennte schlicht "42" heissen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1026 lines
32 KiB
Python
1026 lines
32 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||
|
||
from .models import (
|
||
BaseUnit,
|
||
CategoryTracking,
|
||
DatePrecision,
|
||
FieldType,
|
||
RemovalReason,
|
||
Role,
|
||
UnitKind,
|
||
)
|
||
|
||
|
||
# ---- Units ----
|
||
class UnitOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
kind: UnitKind
|
||
factor: float
|
||
is_builtin: bool
|
||
|
||
|
||
class UnitCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=64)
|
||
kind: UnitKind
|
||
factor: float = Field(gt=0)
|
||
|
||
|
||
class UnitUpdate(BaseModel):
|
||
"""Nur der Name. Art und Faktor bleiben fest – sie stecken bereits in
|
||
umgerechneten Bestaenden, eine Aenderung wuerde die still verfaelschen."""
|
||
|
||
name: str = Field(min_length=1, max_length=64)
|
||
|
||
|
||
# ---- API-Tokens (externe Zugriffe, z.B. Home Assistant) ----
|
||
class ApiTokenOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
created_at: datetime
|
||
last_used_at: datetime | None = None
|
||
|
||
|
||
class ApiTokenCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
|
||
|
||
class ApiTokenCreated(ApiTokenOut):
|
||
token: str # nur einmalig beim Anlegen
|
||
|
||
|
||
# ---- Barcodes ----
|
||
class BarcodeOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
code: str
|
||
note: str | None = None
|
||
# Nur bei Gruppen-Codes gefüllt: der Artikel, über den der Code hier steht.
|
||
# Die Marke unterscheidet innerhalb einer Gruppe besser als der Name – in
|
||
# der Gruppe "Mehl" heißen alle Artikel irgendwie "…mehl".
|
||
product_name: str | None = None
|
||
product_brand: str | None = None
|
||
|
||
|
||
class ProductBarcodeOut(BaseModel):
|
||
"""Code, der über einen Artikel dieser Kategorie angehört.
|
||
|
||
Rein informativ: Er steht am Artikel, nicht an der Kategorie. Beim Scannen
|
||
findet die Suche immer zuerst den Artikel (siehe routers/products.lookup),
|
||
weshalb ein gleichlautender Kategorie-Eintrag nie greifen könnte.
|
||
"""
|
||
code: str
|
||
product_id: int
|
||
product_name: str
|
||
product_brand: str | None = None
|
||
|
||
|
||
# ---- Auth / Users ----
|
||
class Token(BaseModel):
|
||
access_token: str
|
||
token_type: str = "bearer"
|
||
role: Role
|
||
username: str
|
||
|
||
|
||
class UserOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
username: str
|
||
email: str | None = None
|
||
role: Role
|
||
created_at: datetime
|
||
|
||
|
||
class UserCreate(BaseModel):
|
||
username: str = Field(min_length=1, max_length=64)
|
||
password: str = Field(min_length=4, max_length=255)
|
||
email: str | None = Field(default=None, max_length=255)
|
||
role: Role = Role.user
|
||
|
||
|
||
class UserUpdate(BaseModel):
|
||
# Umbenennen ist gefahrlos: Sitzungen, Bewegungen, Dashboards und API-Tokens
|
||
# haengen an der Benutzer-ID, nicht am Namen.
|
||
username: str | None = Field(default=None, min_length=1, max_length=64)
|
||
password: str | None = Field(default=None, min_length=4, max_length=255)
|
||
# Leerer String heisst „Adresse entfernen" – deshalb hier nicht min_length.
|
||
email: str | None = Field(default=None, max_length=255)
|
||
role: Role | None = None
|
||
|
||
|
||
# ---- Single Sign-on (OpenID Connect) ----
|
||
class OidcProviderOut(BaseModel):
|
||
"""Was ein Client braucht, um den Anmeldeknopf zu zeigen und den Flow zu starten.
|
||
|
||
Bewusst oeffentlich abrufbar: die Web-Oberflaeche kennt keine Build-Variablen,
|
||
und die iOS-App muss die Angaben je Serverprofil vom jeweiligen Server lernen.
|
||
Ein Client-Secret steht hier nie drin.
|
||
"""
|
||
|
||
slug: str
|
||
display_name: str
|
||
discovery_url: str
|
||
# Client-ID fuer die App (oeffentlicher Client, PKCE). Leer, wenn fuer diesen
|
||
# Anbieter kein nativer Client eingerichtet ist.
|
||
native_client_id: str | None = None
|
||
native_redirect_uri: str
|
||
# Scopes fuer den App-Weg (inkl. offline_access) – der Browser-Weg wird vom
|
||
# Server selbst gebaut und braucht diese Angabe nicht.
|
||
scopes: str
|
||
|
||
|
||
class OidcIdentityOut(BaseModel):
|
||
"""Eine bestehende Verknuepfung des angemeldeten Kontos."""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
provider: str
|
||
display_name: str
|
||
created_at: datetime
|
||
last_login_at: datetime | None = None
|
||
|
||
|
||
class OidcLinkStart(BaseModel):
|
||
"""Adresse, zu der die Oberflaeche fuer die Verknuepfung weiterleiten soll.
|
||
|
||
Der Start laeuft ueber POST und nicht als einfacher Link, weil die
|
||
Berechtigung aus dem Bearer-Token der laufenden Sitzung kommt – und das
|
||
laesst sich bei einer normalen Seitennavigation nicht mitschicken.
|
||
"""
|
||
|
||
authorization_url: str
|
||
|
||
|
||
class OidcPendingOut(BaseModel):
|
||
"""Eine gepruefte Kennung, die noch keinem Konto gehoert.
|
||
|
||
Wird der Entscheidungsseite gezeigt: „neues Konto" oder „bestehendes
|
||
verknuepfen". Der Einmalcode aus der Adresse ist der Nachweis, dass die
|
||
Anmeldung beim Anbieter tatsaechlich stattgefunden hat.
|
||
"""
|
||
|
||
provider: str
|
||
display_name: str
|
||
email: str | None = None
|
||
username_vorschlag: str
|
||
# Ob dieser Server das Anlegen neuer Konten ueberhaupt zulaesst.
|
||
anlegen_erlaubt: bool
|
||
|
||
|
||
class OidcPendingVerknuepfen(BaseModel):
|
||
"""Eigentumsnachweis fuer das bestehende Konto."""
|
||
|
||
username: str = Field(min_length=1, max_length=64)
|
||
password: str = Field(min_length=1, max_length=255)
|
||
|
||
|
||
class OidcNativeLogin(BaseModel):
|
||
"""ID-Token aus dem App-Flow, das gegen ein Vorrania-Token getauscht wird."""
|
||
|
||
id_token: str = Field(min_length=1)
|
||
# Die nonce, die die App in ihrer Anfrage verwendet hat. AppAuth prueft sie
|
||
# zwar selbst, aber nur der Server-Vergleich schliesst aus, dass ein anderswo
|
||
# abgegriffenes ID-Token desselben Clients hier eingereicht wird.
|
||
nonce: str | None = None
|
||
|
||
|
||
# ---- Groups ----
|
||
class LocationMinStockIn(BaseModel):
|
||
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Basiseinheiten).
|
||
|
||
``location_id = null`` heisst „Überall" – egal wo, Hauptsache die Menge ist
|
||
im Haus. Das ersetzt den frueheren separaten Gesamt-Mindestbestand.
|
||
"""
|
||
location_id: str | None = None
|
||
min_stock: float = Field(ge=0)
|
||
|
||
|
||
class LocationMinStockOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
location_id: str | None = None
|
||
location_name: str | None = None # bei location_id = null: „Überall"
|
||
min_stock: float
|
||
# Bestand AN DIESEM Ort (inkl. Unterorte), ebenfalls in Basiseinheiten –
|
||
# bei Artikeln wie bei Gruppen. Bei „Überall" der Gesamtbestand.
|
||
stock: float | None = None
|
||
|
||
|
||
class GroupOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
min_stock: float | None = None # in der aktuellen Erfassungseinheit (s.u.)
|
||
min_stock_unit_id: int | None = None
|
||
# Gruppen-Gebinde (Richtwert) + ob der Mindestbestand darin erfasst ist:
|
||
package_size: float | None = None
|
||
package_label: str | None = None
|
||
min_stock_in_packages: bool = False
|
||
# Obergruppen (n:m – eine Gruppe darf unter mehreren haengen) und die
|
||
# direkten Untergruppen. Beide als IDs; ``_group_to_out`` setzt sie, weil
|
||
# ``Group.parents``/``children`` Objekte sind.
|
||
parent_ids: list[int] = []
|
||
child_ids: list[int] = []
|
||
# angereichert:
|
||
# Artikel im gesamten Untergruppen-Graphen – passt zu ``stock``, das
|
||
# ebenfalls transitiv rechnet. ``direct_product_count`` sind die, die
|
||
# unmittelbar an dieser Gruppe haengen.
|
||
product_count: int = 0
|
||
direct_product_count: int = 0
|
||
stock: float = 0.0 # Bestand in der aktuellen Erfassungseinheit
|
||
min_stock_unit_name: str | None = None # Name der verwalteten Einheit (g/kg …)
|
||
min_stock_unit_factor: float | None = None # Basiseinheiten je verwalteter Einheit
|
||
kind: str | None = None # Art der Mindestbestand-Einheit
|
||
barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind
|
||
# EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar.
|
||
product_barcodes: list[ProductBarcodeOut] = []
|
||
# Mindestbestand je Lagerort (zusaetzlich zum Gesamt-Mindestbestand oben).
|
||
location_min_stocks: list[LocationMinStockOut] = []
|
||
|
||
|
||
class GroupCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
min_stock: float | None = Field(default=None, ge=0)
|
||
min_stock_unit_id: int | None = None
|
||
package_size: float | None = Field(default=None, ge=0)
|
||
package_label: str | None = Field(default=None, max_length=32)
|
||
min_stock_in_packages: bool = False
|
||
parent_ids: list[int] = []
|
||
|
||
|
||
class GroupUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
min_stock: float | None = Field(default=None, ge=0)
|
||
min_stock_unit_id: int | None = None
|
||
package_size: float | None = Field(default=None, ge=0)
|
||
package_label: str | None = Field(default=None, max_length=32)
|
||
min_stock_in_packages: bool | None = None
|
||
# Nicht mitgeschickt = unveraendert, [] = alle Obergruppen entfernen.
|
||
parent_ids: list[int] | None = None
|
||
|
||
|
||
# ---- Categories ----
|
||
class CategoryOut(BaseModel):
|
||
"""Ordnungshilfe plus Verwaltungsart (Lebensmittel/Gegenstand)."""
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
parent_id: int | None = None
|
||
is_builtin: bool = False
|
||
# "food" = Chargen+MHD, "object" = Menge je Lagerort.
|
||
tracking: CategoryTracking = CategoryTracking.food
|
||
product_count: int = 0
|
||
|
||
|
||
class CategoryCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
parent_id: int | None = None
|
||
# None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand.
|
||
tracking: CategoryTracking | None = None
|
||
|
||
|
||
class CategoryUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
parent_id: int | None = None
|
||
tracking: CategoryTracking | None = None
|
||
|
||
|
||
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
|
||
class ShopOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
website: str | None = None
|
||
product_count: int = 0
|
||
|
||
|
||
class ShopCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
website: str | None = Field(default=None, max_length=1024)
|
||
|
||
|
||
class ShopUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
website: str | None = Field(default=None, max_length=1024)
|
||
|
||
|
||
# ---- Selbst definierte Felder je Kategorie ----
|
||
class FieldDefinitionBase(BaseModel):
|
||
label: str = Field(min_length=1, max_length=120)
|
||
field_type: FieldType = FieldType.text
|
||
unit: str | None = Field(default=None, max_length=32)
|
||
options: list[str] | None = None # nur für field_type == "select"
|
||
required: bool = False
|
||
position: int = 0
|
||
|
||
|
||
class FieldDefinitionCreate(FieldDefinitionBase):
|
||
category_id: int
|
||
|
||
|
||
class FieldDefinitionUpdate(BaseModel):
|
||
label: str | None = Field(default=None, min_length=1, max_length=120)
|
||
field_type: FieldType | None = None
|
||
unit: str | None = Field(default=None, max_length=32)
|
||
options: list[str] | None = None
|
||
required: bool | None = None
|
||
position: int | None = None
|
||
|
||
|
||
class FieldDefinitionOut(BaseModel):
|
||
id: int
|
||
category_id: int
|
||
label: str
|
||
key: str
|
||
field_type: FieldType
|
||
unit: str | None = None
|
||
options: list[str] = []
|
||
required: bool = False
|
||
position: int = 0
|
||
is_builtin: bool = False
|
||
# Bei der vererbten Liste (GET /categories/{id}/fields): stammt das Feld von
|
||
# einer Oberkategorie? Dann in der Verwaltung dort bearbeiten.
|
||
inherited: bool = False
|
||
|
||
|
||
# ---- Locations ----
|
||
class LocationOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: str
|
||
name: str
|
||
parent_id: str | None = None
|
||
|
||
|
||
class LocationCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
parent_id: str | None = None
|
||
|
||
|
||
class LocationUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
parent_id: str | None = None
|
||
|
||
|
||
# ---- Gebinde (Packung, Glas, …) ----
|
||
class PackageTypeCreate(BaseModel):
|
||
singular: str = Field(min_length=1, max_length=32)
|
||
plural: str = Field(min_length=1, max_length=32)
|
||
|
||
|
||
class PackageTypeUpdate(BaseModel):
|
||
singular: str | None = Field(default=None, min_length=1, max_length=32)
|
||
plural: str | None = Field(default=None, min_length=1, max_length=32)
|
||
|
||
|
||
class PackageTypeOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
singular: str
|
||
plural: str
|
||
is_builtin: bool = False
|
||
|
||
|
||
# ---- Products ----
|
||
class ProductBase(BaseModel):
|
||
barcode: str | None = None
|
||
name: str = Field(min_length=1, max_length=255)
|
||
brand: str | None = None
|
||
image_url: str | None = None
|
||
base_unit: BaseUnit = BaseUnit.piece
|
||
# Optionale verwaltete Einheit; setzt base_unit anhand ihrer Art und die Anzeigeeinheit.
|
||
unit_id: int | None = None
|
||
package_size: float | None = Field(default=None, gt=0)
|
||
# Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …)
|
||
package_label: str | None = Field(default=None, max_length=32)
|
||
# Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g").
|
||
# ``secondary_count`` zaehlt in der Basiseinheit des Artikels,
|
||
# ``secondary_amount`` in ``secondary_base``. Alle drei oder keines.
|
||
secondary_base: BaseUnit | None = None
|
||
secondary_count: float | None = Field(default=None, gt=0)
|
||
secondary_amount: float | None = Field(default=None, gt=0)
|
||
# Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
|
||
date_precision: DatePrecision = DatePrecision.day
|
||
group_id: int | None = None
|
||
# Kategorie: nur fuer den Ueberblick, unabhaengig von der Gruppe.
|
||
category_id: int | None = None
|
||
min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten
|
||
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
|
||
min_stock_unit_id: int | None = None
|
||
min_stock_in_packages: bool = False
|
||
# Nur für Gegenstände: Bezugsquelle und Onlineshop-Link.
|
||
shop_id: int | None = None
|
||
product_url: str | None = Field(default=None, max_length=1024)
|
||
# Gegenstände als Einzelstücke (Items mit UID/QR) statt als Menge führen.
|
||
individual: bool = False
|
||
# Gegenstand als Verbrauchsgegenstand wie ein Lebensmittel führen (Chargen).
|
||
bulk: bool = False
|
||
# Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
|
||
field_values: dict[int, str | None] | None = None
|
||
|
||
|
||
class ProductCreate(ProductBase):
|
||
pass
|
||
|
||
|
||
class ProductUpdate(BaseModel):
|
||
barcode: str | None = None
|
||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||
brand: str | None = None
|
||
image_url: str | None = None
|
||
base_unit: BaseUnit | None = None
|
||
unit_id: int | None = None
|
||
package_size: float | None = Field(default=None, gt=0)
|
||
package_label: str | None = Field(default=None, max_length=32)
|
||
# Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g").
|
||
# ``secondary_count`` zaehlt in der Basiseinheit des Artikels,
|
||
# ``secondary_amount`` in ``secondary_base``. Alle drei oder keines.
|
||
secondary_base: BaseUnit | None = None
|
||
secondary_count: float | None = Field(default=None, gt=0)
|
||
secondary_amount: float | None = Field(default=None, gt=0)
|
||
date_precision: DatePrecision | None = None
|
||
group_id: int | None = None
|
||
category_id: int | None = None
|
||
min_stock: float | None = Field(default=None, ge=0)
|
||
min_stock_unit_id: int | None = None
|
||
min_stock_in_packages: bool | None = None
|
||
shop_id: int | None = None
|
||
product_url: str | None = Field(default=None, max_length=1024)
|
||
individual: bool | None = None
|
||
bulk: bool | None = None
|
||
field_values: dict[int, str | None] | None = None
|
||
|
||
|
||
class ProductOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
barcode: str | None
|
||
name: str
|
||
brand: str | None
|
||
image_url: str | None
|
||
base_unit: BaseUnit
|
||
display_unit_id: int | None = None
|
||
package_size: float | None
|
||
package_label: str | None = None
|
||
# Zweiteinheit-Brücke, so wie eingegeben („3 Stück ≙ 250 g").
|
||
secondary_base: BaseUnit | None = None
|
||
secondary_count: float | None = None
|
||
secondary_amount: float | None = None
|
||
# Abgeleitet: Zweit-Basiseinheiten je EINER Basiseinheit des Artikels –
|
||
# damit die Oberflächen nicht selbst dividieren müssen.
|
||
secondary_factor: float | None = None
|
||
date_precision: DatePrecision = DatePrecision.day
|
||
group_id: int | None
|
||
# Name der Gruppe – wie category_name, damit die Listen danach filtern
|
||
# koennen, ohne die Gruppen zusaetzlich zu laden.
|
||
group_name: str | None = None
|
||
category_id: int | None = None
|
||
category_name: str | None = None
|
||
min_stock: float | None
|
||
min_stock_unit_id: int | None = None
|
||
min_stock_in_packages: bool = False
|
||
source: str
|
||
shop_id: int | None = None
|
||
product_url: str | None = None
|
||
individual: bool = False
|
||
bulk: bool = False
|
||
created_at: datetime
|
||
updated_at: datetime | None = None
|
||
# angereichert:
|
||
stock: float = 0.0
|
||
expired_count: int = 0
|
||
# Epoch (Sekunden) der letzten Bildänderung, sonst None (= kein Bild). Der
|
||
# Client cacht Bilder danach und lädt nur bei geänderter Version neu.
|
||
image_version: int | None = None
|
||
kind: str = ""
|
||
unit_name: str = ""
|
||
unit_factor: float = 1.0
|
||
# Mindestbestand in der erfassten Einheit (für die Anzeige):
|
||
min_stock_display: float | None = None
|
||
min_stock_unit_label: str = ""
|
||
# Zusätzliche EAN-Codes (neben dem Haupt-Barcode):
|
||
barcodes: list[BarcodeOut] = []
|
||
# Verwaltungsart aus der Kategorie (food/object), Bezugsquelle und Feldwerte:
|
||
tracking: CategoryTracking = CategoryTracking.food
|
||
shop_name: str | None = None
|
||
field_values: dict[int, str | None] = {}
|
||
# Mindestbestand je Lagerort (in Artikeleinheiten), zusaetzlich zum globalen.
|
||
location_min_stocks: list[LocationMinStockOut] = []
|
||
|
||
@field_validator("field_values", mode="before")
|
||
@classmethod
|
||
def _field_values_from_orm(cls, v):
|
||
"""Beim Lesen aus der DB kommt eine Liste ProductFieldValue – zu Map machen."""
|
||
if isinstance(v, list):
|
||
return {pfv.field_definition_id: pfv.value for pfv in v}
|
||
return v
|
||
|
||
|
||
class LookupResult(BaseModel):
|
||
found: bool
|
||
existing_product: ProductOut | None = None
|
||
suggestion: dict | None = None
|
||
# Ist der Code einer Gruppe zugeordnet (z.B. "Mehl"), wird sie hier gemeldet.
|
||
group_id: int | None = None
|
||
group_name: str | None = None
|
||
# Aus der Open-Food-Facts-Kategorie abgeleiteter Vorschlag (nur Vorschlag).
|
||
category_id: int | None = None
|
||
category_name: str | None = None
|
||
|
||
|
||
class BarcodeCreate(BaseModel):
|
||
code: str = Field(min_length=4, max_length=64)
|
||
note: str | None = Field(default=None, max_length=120)
|
||
|
||
|
||
class BarcodeNoteUpdate(BaseModel):
|
||
"""Notiz zu einem Code nachtragen oder ändern."""
|
||
note: str | None = Field(default=None, max_length=120)
|
||
|
||
|
||
# ---- Stock movements ----
|
||
class CheckInRequest(BaseModel):
|
||
product_id: int | None = None
|
||
barcode: str | None = None
|
||
quantity: float = Field(gt=0)
|
||
unit: str
|
||
best_before: date | None = None
|
||
# "month" legt das MHD auf den Monatsletzten (siehe services/dates.py).
|
||
best_before_precision: DatePrecision = DatePrecision.day
|
||
location_id: str | None = None
|
||
note: str | None = None
|
||
|
||
|
||
class CheckOutRequest(BaseModel):
|
||
product_id: int | None = None
|
||
barcode: str | None = None
|
||
quantity: float = Field(gt=0)
|
||
unit: str
|
||
# Optional: gezielt aus dieser Charge abbuchen (sonst automatisch FEFO).
|
||
lot_id: int | None = None
|
||
note: str | None = None
|
||
|
||
|
||
class LotOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
product_id: int
|
||
quantity: float
|
||
best_before: date | None
|
||
best_before_precision: DatePrecision = DatePrecision.day
|
||
location_id: str | None
|
||
created_at: datetime
|
||
|
||
|
||
class LotUpdate(BaseModel):
|
||
"""Korrektur einer Charge (Vertipper beim Einlagern o.ä.)."""
|
||
quantity: float | None = Field(default=None, gt=0)
|
||
best_before: date | None = None
|
||
best_before_precision: DatePrecision | None = None
|
||
location_id: str | None = None
|
||
|
||
|
||
class LotRow(BaseModel):
|
||
"""Eine Charge mit Artikel-Infos – für die übergreifende Chargenliste."""
|
||
id: int
|
||
product_id: int
|
||
product_name: str
|
||
product_brand: str | None = None
|
||
tracking: CategoryTracking = CategoryTracking.food # food / object
|
||
quantity: float # in Basiseinheiten
|
||
base_unit: BaseUnit
|
||
package_size: float | None = None
|
||
package_label: str | None = None
|
||
unit_name: str = ""
|
||
unit_factor: float = 1.0
|
||
best_before: date | None = None
|
||
best_before_precision: DatePrecision = DatePrecision.day
|
||
location_id: str | None = None
|
||
created_at: datetime
|
||
|
||
|
||
class BulkLotLocation(BaseModel):
|
||
"""Lagerort für mehrere Chargen auf einmal setzen (oder leeren)."""
|
||
lot_ids: list[int]
|
||
location_id: str | None = None
|
||
|
||
|
||
class LotSplit(BaseModel):
|
||
"""Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und
|
||
als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt."""
|
||
quantity: float = Field(gt=0)
|
||
location_id: str | None = None
|
||
|
||
|
||
# ---- Kassenzettel-Abgleich ----
|
||
class MatchRequest(BaseModel):
|
||
"""Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile."""
|
||
lines: list[str]
|
||
threshold: int | None = None # 0–100; ohne Angabe gilt die Einstellung
|
||
|
||
|
||
class MatchCandidate(BaseModel):
|
||
product_id: int
|
||
name: str
|
||
brand: str | None = None
|
||
score: int # 0–100, Ähnlichkeit zur Kassenzeile
|
||
# Für die Einheit beim Einlagern (ganze Gebinde bzw. Basiseinheit) – so muss
|
||
# die App den Artikel nicht noch einmal einzeln laden.
|
||
package_size: float | None = None
|
||
base_unit: str
|
||
|
||
|
||
class MatchLine(BaseModel):
|
||
text: str
|
||
candidates: list[MatchCandidate] = []
|
||
|
||
|
||
class CheckInResponse(BaseModel):
|
||
lot: LotOut
|
||
product_stock: float
|
||
|
||
|
||
class CheckInLine(BaseModel):
|
||
"""Eine Charge innerhalb eines Sammel-Einlagerns (Menge + eigenes MHD)."""
|
||
quantity: float = Field(gt=0)
|
||
best_before: date | None = None
|
||
best_before_precision: DatePrecision = DatePrecision.day
|
||
location_id: str | None = None
|
||
|
||
|
||
class BatchCheckInRequest(BaseModel):
|
||
product_id: int | None = None
|
||
barcode: str | None = None
|
||
unit: str
|
||
lines: list[CheckInLine] = Field(min_length=1)
|
||
note: str | None = None
|
||
|
||
|
||
class BatchCheckInResponse(BaseModel):
|
||
lots: list[LotOut]
|
||
product_stock: float
|
||
|
||
|
||
class CheckOutResponse(BaseModel):
|
||
affected_lots: list[dict]
|
||
product_stock: float
|
||
|
||
|
||
# ---- Gegenstände: Umlagern und Entfernen mit Grund ----
|
||
class RelocateRequest(BaseModel):
|
||
"""Menge eines Gegenstands von einem Lagerort zu einem anderen umbuchen (ohne Grund)."""
|
||
product_id: int | None = None
|
||
barcode: str | None = None
|
||
quantity: float = Field(gt=0)
|
||
from_location_id: str | None = None
|
||
to_location_id: str | None = None
|
||
note: str | None = None
|
||
|
||
|
||
class RemoveRequest(BaseModel):
|
||
"""Menge eines Gegenstands aus dem Bestand entfernen – Grund ist Pflicht."""
|
||
product_id: int | None = None
|
||
barcode: str | None = None
|
||
quantity: float = Field(gt=0)
|
||
location_id: str | None = None
|
||
reason: RemovalReason
|
||
note: str | None = None
|
||
|
||
|
||
class StockActionResponse(BaseModel):
|
||
product_stock: float
|
||
|
||
|
||
class RemovalStat(BaseModel):
|
||
"""Summe der Entnahmen je Grund (für die kleine Statistik am Artikel)."""
|
||
reason: RemovalReason
|
||
quantity: float
|
||
count: int
|
||
|
||
|
||
class RemovalHistoryItem(BaseModel):
|
||
reason: RemovalReason
|
||
quantity: float
|
||
location_id: str | None = None
|
||
location_name: str | None = None
|
||
note: str | None = None
|
||
username: str | None = None
|
||
created_at: datetime
|
||
|
||
|
||
class RemovalSummary(BaseModel):
|
||
stats: list[RemovalStat] = []
|
||
history: list[RemovalHistoryItem] = []
|
||
|
||
|
||
# ---- Einzelstücke (Items mit UID/QR) ----
|
||
class ItemCreate(BaseModel):
|
||
"""Ein oder mehrere Einzelstücke mit gemeinsamen Startwerten anlegen."""
|
||
count: int = Field(default=1, ge=1, le=200)
|
||
location_id: str | None = None
|
||
shop_id: int | None = None
|
||
acquired_on: date | None = None
|
||
warranty_until: date | None = None
|
||
note: str | None = Field(default=None, max_length=255)
|
||
price_cents: int | None = Field(default=None, ge=0)
|
||
currency: str | None = Field(default=None, max_length=3)
|
||
|
||
|
||
class ItemUpdate(BaseModel):
|
||
location_id: str | None = None
|
||
shop_id: int | None = None
|
||
acquired_on: date | None = None
|
||
warranty_until: date | None = None
|
||
note: str | None = Field(default=None, max_length=255)
|
||
price_cents: int | None = Field(default=None, ge=0)
|
||
currency: str | None = Field(default=None, max_length=3)
|
||
|
||
|
||
class ItemRemove(BaseModel):
|
||
reason: RemovalReason
|
||
note: str | None = Field(default=None, max_length=255)
|
||
|
||
|
||
class ItemDocumentOut(BaseModel):
|
||
"""Beleg-Metadaten (ohne Binärdaten)."""
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
filename: str
|
||
content_type: str
|
||
uploaded_at: datetime
|
||
|
||
|
||
class DocumentSuggestions(BaseModel):
|
||
"""Aus einem Beleg-PDF geschätzte Werte (ohne den Beleg zu speichern)."""
|
||
suggested_warranty_until: date | None = None
|
||
suggested_price_cents: int | None = None
|
||
# Alle plausiblen Preise (bester zuerst) – zur Auswahl, falls es mehrere gibt.
|
||
suggested_price_candidates: list[int] = []
|
||
suggested_acquired_on: date | None = None
|
||
# Erkannter Shop: id = bereits hinterlegt; sonst name = Vorschlag zum Anlegen.
|
||
suggested_shop_id: int | None = None
|
||
suggested_shop_name: str | None = None
|
||
|
||
|
||
class ItemDocumentUploadOut(ItemDocumentOut, DocumentSuggestions):
|
||
"""Antwort nach dem Upload – Beleg-Metadaten plus die geschätzten Werte."""
|
||
|
||
|
||
class ItemOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
uid: str
|
||
product_id: int
|
||
location_id: str | None = None
|
||
location_name: str | None = None
|
||
shop_id: int | None = None
|
||
shop_name: str | None = None
|
||
acquired_on: date | None = None
|
||
warranty_until: date | None = None
|
||
note: str | None = None
|
||
price_cents: int | None = None
|
||
currency: str | None = None
|
||
created_at: datetime
|
||
# Für QR-Auflösung/Anzeige mitgeliefert:
|
||
product_name: str | None = None
|
||
product_brand: str | None = None
|
||
category_id: int | None = None
|
||
category_name: str | None = None
|
||
documents: list[ItemDocumentOut] = []
|
||
|
||
|
||
# ---- Views ----
|
||
class ShoppingNeed(BaseModel):
|
||
"""Fehlmenge als Gebinde-Leitangabe – bei zaehlbaren Packungen (Glas/Dose)
|
||
auf ganze aufgerundet, weil man nur ganze Gebinde kauft – mit der
|
||
Basiseinheit als kleinem Hinweis. So bekommen auch API-Konsumenten direkt
|
||
„1 Glas (500 g)", ohne selbst Packungsgroessen umzurechnen."""
|
||
text: str # Leitangabe: "1 Glas", "4 Dosen", "500 g"
|
||
hint: str = "" # Basiseinheit klein: "500 g" / "2 l" – leer wenn identisch
|
||
count: float # aufgerundete Gebindezahl (zum Weiterrechnen)
|
||
label: str # zur count passendes, pluralisiertes Label
|
||
singular: str # Einzahl-Label ("Glas", "Gramm", …)
|
||
is_package: bool = False # zaehlbares Gebinde (Glas/Dose) vs. Basis-/Anzeigeeinheit
|
||
base_amount: float | None = None
|
||
base_unit: BaseUnit | None = None
|
||
stock: float # Bestand in derselben Leitangabe
|
||
min_stock: float # Mindestbestand in derselben Leitangabe
|
||
|
||
|
||
class ShoppingItem(BaseModel):
|
||
product_id: int
|
||
name: str
|
||
base_unit: BaseUnit
|
||
package_size: float | None = None
|
||
need: ShoppingNeed | None = None
|
||
stock: float
|
||
min_stock: float
|
||
deficit: float
|
||
|
||
|
||
class ExpiringItem(BaseModel):
|
||
lot_id: int
|
||
product_id: int
|
||
product_name: str
|
||
quantity: float # in Basiseinheiten
|
||
base_unit: BaseUnit
|
||
best_before: date
|
||
best_before_precision: DatePrecision = DatePrecision.day
|
||
days_left: int
|
||
# Für die Anzeige in Artikeleinheiten:
|
||
package_size: float | None = None
|
||
package_label: str | None = None
|
||
unit_name: str = ""
|
||
unit_factor: float = 1.0
|
||
|
||
|
||
class GroupShoppingItem(BaseModel):
|
||
group_id: int
|
||
name: str
|
||
stock: float
|
||
min_stock: float
|
||
deficit: float
|
||
unit_name: str = ""
|
||
product_count: int
|
||
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
|
||
subgroup_count: int = 0
|
||
need: ShoppingNeed | None = None
|
||
|
||
|
||
class LocationNeedProduct(BaseModel):
|
||
"""Ein Produkt-Bedarf an einem Lagerort (Mengen in Artikeleinheiten)."""
|
||
product_id: int
|
||
name: str
|
||
unit_label: str = ""
|
||
stock: float
|
||
min_stock: float
|
||
deficit: float
|
||
need: ShoppingNeed | None = None
|
||
|
||
|
||
class LocationNeedGroup(BaseModel):
|
||
group_id: int
|
||
name: str
|
||
unit_name: str = ""
|
||
stock: float
|
||
min_stock: float
|
||
deficit: float
|
||
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
|
||
subgroup_count: int = 0
|
||
need: ShoppingNeed | None = None
|
||
|
||
|
||
class LocationNeeds(BaseModel):
|
||
"""Alle Bedarfe (Produkte + Gruppen) eines Lagerorts."""
|
||
location_id: str
|
||
location_name: str
|
||
products: list[LocationNeedProduct] = []
|
||
groups: list[LocationNeedGroup] = []
|
||
|
||
|
||
class ShoppingListAll(BaseModel):
|
||
"""Komplette Einkaufsliste in einem Aufruf – dieselben drei Quellen, die auch
|
||
die Oberfläche zusammenführt."""
|
||
products: list[ShoppingItem] = []
|
||
groups: list[GroupShoppingItem] = []
|
||
by_location: list[LocationNeeds] = []
|
||
|
||
|
||
class LocationContentEntry(BaseModel):
|
||
"""Ein Artikel mit seinem Bestand an einem Lagerort (in Artikeleinheiten)."""
|
||
product_id: int
|
||
name: str
|
||
brand: str | None = None
|
||
stock: float
|
||
unit_label: str = ""
|
||
individual: bool = False
|
||
|
||
|
||
class LocationContents(BaseModel):
|
||
"""Alle Artikel, die an einem Lagerort liegen – inklusive der Unterorte."""
|
||
location_id: str
|
||
location_name: str
|
||
products: list[LocationContentEntry] = []
|
||
|
||
|
||
class MovementOut(BaseModel):
|
||
id: int
|
||
product_id: int
|
||
product_name: str
|
||
type: str
|
||
quantity: float # in Basiseinheiten
|
||
base_unit: BaseUnit
|
||
unit_used: str
|
||
username: str | None
|
||
note: str | None
|
||
created_at: datetime
|
||
# Für die Anzeige in Artikeleinheiten:
|
||
package_size: float | None = None
|
||
package_label: str | None = None
|
||
unit_name: str = ""
|
||
unit_factor: float = 1.0
|
||
|
||
|
||
class SettingOut(BaseModel):
|
||
key: str
|
||
value: str
|
||
|
||
|
||
# ---- Startseite ----
|
||
class DashboardLayoutIn(BaseModel):
|
||
"""Anordnung der Karten. Die Felder entsprechen react-grid-layout."""
|
||
layout: list[dict]
|
||
|
||
|
||
class DashboardCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=80)
|
||
layout: list[dict] = []
|
||
|
||
|
||
class DashboardUpdate(BaseModel):
|
||
"""Umbenennen und/oder umbauen – nur mitgeschickte Felder zaehlen."""
|
||
name: str | None = Field(default=None, min_length=1, max_length=80)
|
||
layout: list[dict] | None = None
|
||
position: int | None = None
|
||
|
||
|
||
class DashboardOut(BaseModel):
|
||
id: int
|
||
name: str
|
||
position: int
|
||
layout: list[dict]
|
||
|
||
|
||
class DashboardListOut(BaseModel):
|
||
dashboards: list[DashboardOut]
|
||
# Woher die Anordnung stammt: eigene, Admin-Vorgabe oder eingebaut.
|
||
source: str
|
||
# Ist die Vorgabe verbindlich, darf der Benutzer nicht umbauen.
|
||
enforced: bool
|
||
has_default: bool
|
||
|
||
|
||
class DashboardLayoutOut(BaseModel):
|
||
layout: list[dict]
|
||
source: str
|
||
enforced: bool
|
||
has_default: bool
|
||
|
||
|
||
class DashboardStats(BaseModel):
|
||
products_in_stock: int # Artikel mit Bestand > 0
|
||
article_units: float # Summe der Artikeleinheiten
|
||
expiring_soon: int # Chargen innerhalb der Warnfrist (nicht abgelaufen)
|
||
expired: int # bereits abgelaufene Chargen
|
||
shopping_items: int # Produkte + Gruppen unter Mindestbestand
|
||
products_total: int
|
||
|
||
|
||
class ExpirySplit(BaseModel):
|
||
"""Artikeleinheiten je Ablaufzustand – Grundlage für Ring und Säulen."""
|
||
ok: float
|
||
soon: float
|
||
expired: float
|
||
no_date: float # Chargen ohne MHD zählen separat, nicht als "ok"
|
||
|
||
|
||
class CategoryShare(BaseModel):
|
||
category_id: int | None
|
||
name: str
|
||
article_units: float
|
||
ok: float
|
||
soon: float
|
||
expired: float
|
||
no_date: float
|
||
|
||
|
||
class TimelinePoint(BaseModel):
|
||
# Zeitpunkt statt Datum: Bei kurzen Zeiträumen wird stündlich abgetastet,
|
||
# damit sichtbar wird, *wann* am Tag ein- und ausgelagert wurde.
|
||
at: datetime
|
||
article_units: float
|
||
|
||
|
||
class ActivityPoint(BaseModel):
|
||
at: datetime
|
||
checked_in: int
|
||
checked_out: int
|
||
|
||
|
||
class FlowPoint(BaseModel):
|
||
"""Ein Abschnitt eines Wasserfalls: Anfangsbestand, Zu- und Abgang.
|
||
|
||
Alle Werte sind Artikeleinheiten und liegen damit auf derselben Skala wie
|
||
die Bestandslinie – nur so darf beides in ein Diagramm.
|
||
``opening + checked_in - checked_out`` ergibt den Endbestand.
|
||
"""
|
||
|
||
at: datetime
|
||
opening: float
|
||
checked_in: float
|
||
checked_out: float
|