Files
einkaufsapp/backend/app/schemas_shopping.py
2026-08-08 20:31:58 +02:00

408 lines
11 KiB
Python

from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
Role = Literal["viewer", "editor", "owner"]
ItemStatus = Literal["open", "bought", "deferred"]
# --------------------------------------------------------------------------
# Listen
# --------------------------------------------------------------------------
class ListCreateIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
class ListUpdateIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
class ListOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
owner_id: str
rev: int
role: Role
may_share_public: bool
member_count: int
created_at: datetime
class MemberOut(BaseModel):
user_id: str
email: str
display_name: str | None
role: Role
may_share_public: bool
joined_at: datetime
class MemberRoleIn(BaseModel):
role: Role
# Zusatzrecht "darf öffentliche Links erzeugen". Nicht gesetzt =
# unverändert lassen.
may_share_public: bool | None = None
class TransferOwnershipIn(BaseModel):
"""Der bisherige Eigentümer wird dabei zum Bearbeiter."""
user_id: str = Field(min_length=36, max_length=36)
# --------------------------------------------------------------------------
# Einladungen
# --------------------------------------------------------------------------
class InviteCreateIn(BaseModel):
email: EmailStr
role: Literal["viewer", "editor"] = "editor"
class InviteOut(BaseModel):
id: str
email: str
role: Role
status: Literal["pending", "accepted", "revoked", "expired"]
invited_by_name: str | None
created_at: datetime
last_sent_at: datetime
send_count: int
expires_at: datetime
class InvitePreviewOut(BaseModel):
"""Was der Eingeladene vor dem Annehmen sieht."""
list_name: str
invited_by_name: str | None
role: Role
email: str
# Passt die Einladung zum angemeldeten Konto?
matches_current_user: bool
# --------------------------------------------------------------------------
# Märkte und Warengruppen
# --------------------------------------------------------------------------
class MarketIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
sort_order: int = Field(default=0, ge=-9999, le=9999)
class MarketOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
sort_order: int
row_rev: int
class CategoryIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
sort_order: int = Field(default=0, ge=-9999, le=9999)
class CategoryOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
sort_order: int
row_rev: int
# --------------------------------------------------------------------------
# Artikel
# --------------------------------------------------------------------------
class AttributeIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
value: str = Field(max_length=300)
class AttributeOut(BaseModel):
name: str
value: str
class ArticleIn(BaseModel):
name: str = Field(min_length=1, max_length=200)
barcode: str | None = Field(default=None, max_length=64)
note: str | None = Field(default=None, max_length=500)
default_market_id: str | None = Field(default=None, max_length=36)
default_category_id: str | None = Field(default=None, max_length=36)
attributes: list[AttributeIn] = Field(default_factory=list, max_length=40)
# Märkte, in denen es den Artikel gibt.
available_market_ids: list[str] = Field(default_factory=list, max_length=60)
class ArticleUpdateIn(BaseModel):
"""Alle Felder optional - nur Gesetztes wird geändert.
`attributes` und `available_market_ids` ersetzen jeweils die
komplette bisherige Menge, sie werden nicht zusammengeführt."""
name: str | None = Field(default=None, min_length=1, max_length=200)
barcode: str | None = Field(default=None, max_length=64)
note: str | None = Field(default=None, max_length=500)
default_market_id: str | None = Field(default=None, max_length=36)
default_category_id: str | None = Field(default=None, max_length=36)
attributes: list[AttributeIn] | None = Field(default=None, max_length=40)
available_market_ids: list[str] | None = Field(default=None, max_length=60)
class ArticleOut(BaseModel):
id: str
name: str
barcode: str | None
note: str | None
default_market_id: str | None
default_category_id: str | None
attributes: list[AttributeOut]
available_market_ids: list[str]
row_rev: int
# --------------------------------------------------------------------------
# Listeneinträge
# --------------------------------------------------------------------------
class ItemCreateIn(BaseModel):
"""Entweder `article_id` für einen bekannten Artikel, oder
`article_name` - dann wird der Artikel bei Bedarf angelegt."""
article_id: str | None = Field(default=None, max_length=36)
article_name: str | None = Field(default=None, min_length=1, max_length=200)
market_id: str | None = Field(default=None, max_length=36)
category_id: str | None = Field(default=None, max_length=36)
# Stückzahl - der Preis gilt für EIN Gebinde.
count: int = Field(default=1, ge=1, le=999)
# Gebinde, z. B. 500 ml
pack_size: Decimal | None = Field(default=None, ge=0, le=999999)
pack_unit: str | None = Field(default=None, max_length=32)
variant: str | None = Field(default=None, max_length=200)
note: str | None = Field(default=None, max_length=500)
class ItemUpdateIn(BaseModel):
market_id: str | None = Field(default=None, max_length=36)
category_id: str | None = Field(default=None, max_length=36)
count: int | None = Field(default=None, ge=1, le=999)
pack_size: Decimal | None = Field(default=None, ge=0, le=999999)
pack_unit: str | None = Field(default=None, max_length=32)
variant: str | None = Field(default=None, max_length=200)
note: str | None = Field(default=None, max_length=500)
status: ItemStatus | None = None
# Preis in Cent. Die Preishistorie kommt in Phase 7.
price_cents: int | None = Field(default=None, ge=0, le=100_000_000)
# Explizit auf null setzen statt "unverändert lassen":
clear_market: bool = False
clear_category: bool = False
class ItemOut(BaseModel):
id: str
article_id: str
article_name: str
created_by: str | None
created_by_name: str | None
market_id: str | None
category_id: str | None
count: int
pack_size: Decimal | None
pack_unit: str | None
variant: str | None
note: str | None
status: ItemStatus
# Preis für EIN Gebinde
price_cents: int | None
# Preis mal Stückzahl - vom Server gerechnet, damit App, öffentliche
# Ansicht und Ausdruck nicht auseinanderlaufen.
total_cents: int | None
row_rev: int
updated_at: datetime
class ClearBoughtOut(BaseModel):
removed: int
rev: int
# --------------------------------------------------------------------------
# Gruppierte Ansicht (Markt -> Warengruppe -> Artikel)
# --------------------------------------------------------------------------
class ViewCategory(BaseModel):
category_id: str | None
category_name: str
items: list[ItemOut]
class ViewMarket(BaseModel):
market_id: str | None
market_name: str
categories: list[ViewCategory]
open_count: int
total_cents: int
class ListView(BaseModel):
list_id: str
list_name: str
rev: int
markets: list[ViewMarket]
grand_total_cents: int
# --------------------------------------------------------------------------
# Öffentliche Ansichtslinks
# --------------------------------------------------------------------------
class PublicShareIn(BaseModel):
label: str | None = Field(default=None, max_length=120)
# Pflicht: ein unbefristeter Link wäre ein dauerhaft offenes Fenster.
expires_at: datetime
allow_check: bool = True
class PublicShareOut(BaseModel):
id: str
label: str | None
allow_check: bool
status: Literal["active", "expired", "revoked"]
created_by_name: str | None
created_at: datetime
expires_at: datetime
last_access_at: datetime | None
access_count: int
class PublicShareCreatedOut(BaseModel):
"""Antwort beim Erzeugen - die einzige Gelegenheit, den Link zu sehen.
In der Datenbank steht nur der Hash; später lässt sich die Adresse
nicht mehr anzeigen, nur ein neuer Link erzeugen.
"""
share: PublicShareOut
url: str
class PublicViewOut(BaseModel):
"""Was ein Empfänger ohne Konto sieht. Bewusst ohne Angaben dazu, wer
was eingetragen hat, und ohne Mitgliederliste."""
list_name: str
rev: int
allow_check: bool
expires_at: datetime
markets: list["ViewMarket"]
grand_total_cents: int
class PublicToggleIn(BaseModel):
status: Literal["open", "bought"]
class ListSnapshot(BaseModel):
"""Alles, was die Detailansicht braucht, in einer Antwort.
Vorher waren das vier Aufrufe. Bei jeder Synchronisation - also nach
jedem Abhaken - summieren sich vier Rundreisen zu einer spürbaren
Verzögerung, besonders im Mobilfunknetz.
"""
list: "ListOut"
view: "ListView"
markets: list["MarketOut"]
categories: list["CategoryOut"]
class ProductLookupOut(BaseModel):
"""Ergebnis der Strichcode-Abfrage.
`source` unterscheidet, woher die Angaben stammen - der eigene
Artikelstamm ist verlässlicher als eine Fremdquelle, und der Nutzer
soll das sehen können.
"""
barcode: str
found: bool
source: Literal["catalog", "openfoodfacts", "none"]
# Bei einem Treffer im eigenen Bestand: die Artikel-ID
article_id: str | None = None
name: str | None = None
brand: str | None = None
package: str | None = None
# "6 x 33 cl" -> count 6, Gebinde 33 cl
count: int | None = None
pack_size: Decimal | None = None
pack_unit: str | None = None
# --------------------------------------------------------------------------
# Preise
# --------------------------------------------------------------------------
class MarketPrice(BaseModel):
market_id: str
market_name: str
latest_cents: int
latest_at: datetime
latest_pack_size: Decimal | None = None
latest_pack_unit: str | None = None
# Preis je Mengeneinheit in Zehntelcent - bei kleinen Mengen gingen
# sonst zu viele Stellen verloren.
unit_price_deci: int | None = None
min_cents: int
max_cents: int
observations: int
class PriceEntry(BaseModel):
market_id: str
market_name: str
price_cents: int
pack_size: Decimal | None
pack_unit: str | None
recorded_at: datetime
class ArticlePrices(BaseModel):
article_id: str
article_name: str
markets: list[MarketPrice]
history: list[PriceEntry]
class PriceOverviewRow(BaseModel):
article_id: str
article_name: str
# market_id -> juengster Preis in Cent
prices: dict[str, int]
best_market_ids: list[str]
best_cents: int
# Abstand zwischen teuerstem und guenstigstem Markt
spread_cents: int
class PriceOverview(BaseModel):
markets: list[MarketPrice]
market_names: dict[str, str]
rows: list[PriceOverviewRow]
class PriceHint(BaseModel):
article_id: str
best_market_ids: list[str]
best_cents: int
spread_cents: int
prices: dict[str, int]