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]