Erste Produktivversion
This commit is contained in:
407
backend/app/schemas_shopping.py
Normal file
407
backend/app/schemas_shopping.py
Normal file
@@ -0,0 +1,407 @@
|
||||
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]
|
||||
Reference in New Issue
Block a user