440 lines
13 KiB
Python
440 lines
13 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
|
|
# Archivierung gilt je Person - dieselbe Liste kann bei einem
|
|
# Mitglied im Archiv liegen und beim anderen aktiv sein.
|
|
archived: bool = False
|
|
archived_at: datetime | None = None
|
|
# Nur beim Eigentümer gefüllt: Wann würde die Liste automatisch
|
|
# gelöscht? Die Frist läuft ausschließlich über sein Archivdatum.
|
|
delete_due_at: datetime | None = None
|
|
|
|
|
|
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):
|
|
# Artikel wechseln oder umbenennen. Welches von beidem geschieht,
|
|
# entscheidet der Server: Benutzt niemand sonst den bisherigen
|
|
# Artikel, wird er umbenannt - sonst haengt der Eintrag um.
|
|
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)
|
|
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]
|
|
|
|
|
|
class ArticleSuggestion(BaseModel):
|
|
"""Ein Vorschlag beim Eintippen eines Artikelnamens.
|
|
|
|
Enthaelt die zuletzt verwendeten Angaben, damit ein Antippen die
|
|
Eingabefelder fuellt - uebernommen wird der Eintrag dadurch noch
|
|
nicht, es bleibt Zeit fuer Aenderungen.
|
|
"""
|
|
|
|
article_id: str
|
|
name: str
|
|
count: int | None = None
|
|
pack_size: Decimal | None = None
|
|
pack_unit: str | None = None
|
|
variant: str | None = None
|
|
market_id: str | None = None
|
|
category_id: str | None = None
|
|
# Wie oft in der beruecksichtigten Zeitspanne verwendet
|
|
times_used: int
|
|
last_used_at: datetime
|