feat(models): pydantic v1 models for all entities
- Restaurant + nested OpenHours / SocialLinks / RestaurantExtra - Category, Subcategory - MenuItem with structured dietary, allergens, ingredients lists - ModifierGroup (required/optional) + Modifier with price_delta - AvailabilityWindow (weekday + HH:MM range) - Order + OrderItemRow with SelectedModifier snapshot - OrderInvoice (returned to client after order creation) - PrintJob, RestaurantSettings JSON list/dict columns are parsed in pre-validators so callers always see structured types.
This commit is contained in:
parent
5255a99f1a
commit
52f1ad1bb1
1 changed files with 491 additions and 0 deletions
491
models.py
Normal file
491
models.py
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
"""
|
||||
Pydantic v1 models for the restaurant extension.
|
||||
|
||||
Naming convention:
|
||||
* `<Entity>` — the row as stored / returned (id + timestamps).
|
||||
* `Create<Entity>` — request body for POST.
|
||||
* `Update<Entity>` — request body for PUT/PATCH (all fields optional).
|
||||
|
||||
JSON-encoded list/dict columns are parsed in pre-validators so callers
|
||||
always see structured types.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Helpers #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _parse_json_list(v: Any) -> list:
|
||||
if v is None or v == "":
|
||||
return []
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return json.loads(v) or []
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return list(v)
|
||||
|
||||
|
||||
def _parse_json_dict(v: Any) -> dict:
|
||||
if v is None or v == "":
|
||||
return {}
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return json.loads(v) or {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return dict(v)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Restaurant #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class OpenHours(BaseModel):
|
||||
"""Weekly opening schedule. Weekday key 0=Mon .. 6=Sun.
|
||||
|
||||
Each day is a list of {start, end} ranges so a venue can be open
|
||||
e.g. 11:00-15:00 and 18:00-23:00 in the same day. Persisted as
|
||||
JSON in the DB.
|
||||
|
||||
Typed as plain `dict` (not `dict[str, list[dict[str, str]]]`) so
|
||||
LNbits's `db.dict_to_model` walks the field cleanly: its
|
||||
introspection calls `issubclass(field.type_, bool)` while
|
||||
iterating, and a parameterized generic alias trips it with
|
||||
"issubclass() arg 1 must be a class". The runtime shape is
|
||||
still the dict-of-lists-of-dicts described above.
|
||||
"""
|
||||
|
||||
schedule: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SocialLinks(BaseModel):
|
||||
website: Optional[str] = None
|
||||
instagram: Optional[str] = None
|
||||
facebook: Optional[str] = None
|
||||
twitter: Optional[str] = None
|
||||
nostr: Optional[str] = None
|
||||
|
||||
|
||||
class RestaurantExtra(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
# Typed as plain `dict` (not `dict[str, str]`) so LNbits's
|
||||
# `db.dict_to_model` round-trips it cleanly — see OpenHours for
|
||||
# the same workaround and rationale.
|
||||
fields: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CreateRestaurant(BaseModel):
|
||||
wallet: Optional[str] = None
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
currency: str = "sat"
|
||||
timezone: str = "UTC"
|
||||
location: Optional[str] = None
|
||||
geohash: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
banner_url: Optional[str] = None
|
||||
social_links: SocialLinks = Field(default_factory=SocialLinks)
|
||||
open_hours: OpenHours = Field(default_factory=OpenHours)
|
||||
is_open: bool = True
|
||||
accepts_cash: bool = True
|
||||
accepts_lightning: bool = True
|
||||
tip_presets: list[int] = Field(default_factory=list)
|
||||
tax_rate: float = 0
|
||||
printer_endpoint: Optional[str] = None
|
||||
nostr_pubkey: Optional[str] = None
|
||||
nostr_relays: list[str] = Field(default_factory=list)
|
||||
extra: RestaurantExtra = Field(default_factory=RestaurantExtra)
|
||||
|
||||
|
||||
class Restaurant(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
currency: str = "sat"
|
||||
timezone: str = "UTC"
|
||||
location: Optional[str] = None
|
||||
geohash: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
banner_url: Optional[str] = None
|
||||
social_links: SocialLinks = Field(default_factory=SocialLinks)
|
||||
open_hours: OpenHours = Field(default_factory=OpenHours)
|
||||
is_open: bool = True
|
||||
accepts_cash: bool = True
|
||||
accepts_lightning: bool = True
|
||||
tip_presets: list[int] = Field(default_factory=list)
|
||||
tax_rate: float = 0
|
||||
printer_endpoint: Optional[str] = None
|
||||
nostr_pubkey: Optional[str] = None
|
||||
nostr_relays: list[str] = Field(default_factory=list)
|
||||
nostr_event_id: Optional[str] = None
|
||||
nostr_event_created_at: Optional[int] = None
|
||||
extra: RestaurantExtra = Field(default_factory=RestaurantExtra)
|
||||
time: datetime
|
||||
|
||||
@validator("social_links", pre=True)
|
||||
def _parse_social(cls, v):
|
||||
if isinstance(v, str):
|
||||
return SocialLinks(**_parse_json_dict(v))
|
||||
return v or SocialLinks()
|
||||
|
||||
@validator("open_hours", pre=True)
|
||||
def _parse_hours(cls, v):
|
||||
if isinstance(v, str):
|
||||
return OpenHours(**_parse_json_dict(v))
|
||||
return v or OpenHours()
|
||||
|
||||
@validator("tip_presets", pre=True)
|
||||
def _parse_presets(cls, v):
|
||||
return _parse_json_list(v)
|
||||
|
||||
@validator("nostr_relays", pre=True)
|
||||
def _parse_relays(cls, v):
|
||||
return _parse_json_list(v)
|
||||
|
||||
@validator("extra", pre=True)
|
||||
def _parse_extra(cls, v):
|
||||
if isinstance(v, str):
|
||||
return RestaurantExtra(**_parse_json_dict(v))
|
||||
return v or RestaurantExtra()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Categories / subcategories #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class CreateCategory(BaseModel):
|
||||
restaurant_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
image_url: Optional[str] = None
|
||||
|
||||
|
||||
class Category(BaseModel):
|
||||
id: str
|
||||
restaurant_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
image_url: Optional[str] = None
|
||||
time: datetime
|
||||
|
||||
|
||||
class CreateSubcategory(BaseModel):
|
||||
category_id: str
|
||||
name: str
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class Subcategory(BaseModel):
|
||||
id: str
|
||||
category_id: str
|
||||
name: str
|
||||
sort_order: int = 0
|
||||
time: datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Menu items #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class MenuItemExtra(BaseModel):
|
||||
"""Free-form metadata that doesn't deserve a column yet."""
|
||||
|
||||
notes: Optional[str] = None
|
||||
# Plain `dict` — see OpenHours for the LNbits round-trip workaround.
|
||||
fields: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CreateMenuItem(BaseModel):
|
||||
restaurant_id: str
|
||||
category_id: Optional[str] = None
|
||||
subcategory_id: Optional[str] = None
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price: float = 0
|
||||
currency: str = "sat"
|
||||
sku: Optional[str] = None
|
||||
images: list[str] = Field(default_factory=list)
|
||||
dietary: list[str] = Field(default_factory=list)
|
||||
allergens: list[str] = Field(default_factory=list)
|
||||
ingredients: list[str] = Field(default_factory=list)
|
||||
calories: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
is_available: bool = True
|
||||
is_featured: bool = False
|
||||
stock: Optional[int] = None
|
||||
low_stock_threshold: Optional[int] = None
|
||||
extra: MenuItemExtra = Field(default_factory=MenuItemExtra)
|
||||
|
||||
|
||||
class MenuItem(BaseModel):
|
||||
id: str
|
||||
restaurant_id: str
|
||||
category_id: Optional[str] = None
|
||||
subcategory_id: Optional[str] = None
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price: float = 0
|
||||
currency: str = "sat"
|
||||
sku: Optional[str] = None
|
||||
images: list[str] = Field(default_factory=list)
|
||||
dietary: list[str] = Field(default_factory=list)
|
||||
allergens: list[str] = Field(default_factory=list)
|
||||
ingredients: list[str] = Field(default_factory=list)
|
||||
calories: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
is_available: bool = True
|
||||
is_featured: bool = False
|
||||
stock: Optional[int] = None
|
||||
low_stock_threshold: Optional[int] = None
|
||||
nostr_event_id: Optional[str] = None
|
||||
nostr_event_created_at: Optional[int] = None
|
||||
extra: MenuItemExtra = Field(default_factory=MenuItemExtra)
|
||||
time: datetime
|
||||
|
||||
@validator("images", "dietary", "allergens", "ingredients", pre=True)
|
||||
def _parse_lists(cls, v):
|
||||
return _parse_json_list(v)
|
||||
|
||||
@validator("extra", pre=True)
|
||||
def _parse_extra(cls, v):
|
||||
if isinstance(v, str):
|
||||
return MenuItemExtra(**_parse_json_dict(v))
|
||||
return v or MenuItemExtra()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Modifier groups + modifiers #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class CreateModifierGroup(BaseModel):
|
||||
menu_item_id: str
|
||||
name: str
|
||||
kind: str = "required" # 'required' | 'optional'
|
||||
selection: str = "one" # 'one' | 'many'
|
||||
min_selections: int = 0
|
||||
max_selections: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class ModifierGroup(BaseModel):
|
||||
id: str
|
||||
menu_item_id: str
|
||||
name: str
|
||||
kind: str = "required"
|
||||
selection: str = "one"
|
||||
min_selections: int = 0
|
||||
max_selections: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
time: datetime
|
||||
|
||||
|
||||
class CreateModifier(BaseModel):
|
||||
group_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price_delta: float = 0
|
||||
is_default: bool = False
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class Modifier(BaseModel):
|
||||
id: str
|
||||
group_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price_delta: float = 0
|
||||
is_default: bool = False
|
||||
sort_order: int = 0
|
||||
time: datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Availability windows #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class CreateAvailabilityWindow(BaseModel):
|
||||
menu_item_id: str
|
||||
weekday: Optional[int] = None # 0=Mon, 6=Sun, None = every day
|
||||
start_time: str # 'HH:MM'
|
||||
end_time: str # 'HH:MM'
|
||||
|
||||
@validator("weekday")
|
||||
def _check_weekday(cls, v):
|
||||
if v is not None and not 0 <= v <= 6:
|
||||
raise ValueError("weekday must be in 0..6 or null")
|
||||
return v
|
||||
|
||||
|
||||
class AvailabilityWindow(BaseModel):
|
||||
id: str
|
||||
menu_item_id: str
|
||||
weekday: Optional[int] = None
|
||||
start_time: str
|
||||
end_time: str
|
||||
time: datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Orders #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class SelectedModifier(BaseModel):
|
||||
"""Snapshot of a chosen modifier at order time."""
|
||||
|
||||
group_id: Optional[str] = None
|
||||
group_name: Optional[str] = None
|
||||
modifier_id: Optional[str] = None
|
||||
name: str
|
||||
price_delta: float = 0
|
||||
|
||||
|
||||
class CreateOrderItem(BaseModel):
|
||||
menu_item_id: str
|
||||
quantity: int = 1
|
||||
selected_modifiers: list[SelectedModifier] = Field(default_factory=list)
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class OrderItemRow(BaseModel):
|
||||
id: str
|
||||
order_id: str
|
||||
menu_item_id: Optional[str] = None
|
||||
name: str
|
||||
quantity: int = 1
|
||||
unit_price_msat: int = 0
|
||||
line_total_msat: int = 0
|
||||
selected_modifiers: list[SelectedModifier] = Field(default_factory=list)
|
||||
note: Optional[str] = None
|
||||
time: datetime
|
||||
|
||||
@validator("selected_modifiers", pre=True)
|
||||
def _parse_mods(cls, v):
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
raw = json.loads(v) if v else []
|
||||
return [SelectedModifier(**m) for m in raw]
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return v or []
|
||||
|
||||
|
||||
class OrderExtra(BaseModel):
|
||||
fiat: bool = False
|
||||
fiat_currency: Optional[str] = None
|
||||
fiat_rate: Optional[float] = None
|
||||
refund_address: Optional[str] = None
|
||||
# Plain `dict` — see OpenHours for the LNbits round-trip workaround.
|
||||
fields: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CreateOrder(BaseModel):
|
||||
restaurant_id: str
|
||||
customer_pubkey: Optional[str] = None
|
||||
customer_name: Optional[str] = None
|
||||
customer_contact: Optional[str] = None
|
||||
items: list[CreateOrderItem]
|
||||
tip_msat: int = 0
|
||||
note: Optional[str] = None
|
||||
parent_order_ref: Optional[str] = None
|
||||
channel: str = "rest" # 'rest' | 'nostr' | 'kiosk' | 'pos'
|
||||
payment_method: str = "lightning" # 'lightning' | 'cash' | 'internal'
|
||||
extra: OrderExtra = Field(default_factory=OrderExtra)
|
||||
|
||||
|
||||
class Order(BaseModel):
|
||||
id: str
|
||||
restaurant_id: str
|
||||
wallet: str
|
||||
customer_pubkey: Optional[str] = None
|
||||
customer_name: Optional[str] = None
|
||||
customer_contact: Optional[str] = None
|
||||
status: str = "pending"
|
||||
channel: str = "rest"
|
||||
payment_method: str = "lightning"
|
||||
payment_hash: Optional[str] = None
|
||||
bolt11: Optional[str] = None
|
||||
subtotal_msat: int = 0
|
||||
tip_msat: int = 0
|
||||
tax_msat: int = 0
|
||||
total_msat: int = 0
|
||||
currency_display: str = "sat"
|
||||
fiat_amount: Optional[float] = None
|
||||
fiat_rate: Optional[float] = None
|
||||
note: Optional[str] = None
|
||||
parent_order_ref: Optional[str] = None
|
||||
paid_at: Optional[datetime] = None
|
||||
accepted_at: Optional[datetime] = None
|
||||
ready_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
canceled_at: Optional[datetime] = None
|
||||
extra: OrderExtra = Field(default_factory=OrderExtra)
|
||||
time: datetime
|
||||
|
||||
@validator("extra", pre=True)
|
||||
def _parse_extra(cls, v):
|
||||
if isinstance(v, str):
|
||||
return OrderExtra(**_parse_json_dict(v))
|
||||
return v or OrderExtra()
|
||||
|
||||
|
||||
class OrderWithItems(BaseModel):
|
||||
order: Order
|
||||
items: list[OrderItemRow]
|
||||
|
||||
|
||||
class OrderInvoice(BaseModel):
|
||||
"""Returned after a customer creates an order — pay this to confirm."""
|
||||
|
||||
order_id: str
|
||||
payment_hash: str
|
||||
bolt11: str
|
||||
amount_msat: int
|
||||
expires_at: int
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Print jobs #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class PrintJob(BaseModel):
|
||||
id: str
|
||||
restaurant_id: str
|
||||
order_id: str
|
||||
status: str = "queued"
|
||||
attempts: int = 0
|
||||
last_error: Optional[str] = None
|
||||
sent_at: Optional[datetime] = None
|
||||
acknowledged_at: Optional[datetime] = None
|
||||
time: datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Settings #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class RestaurantSettings(BaseModel):
|
||||
nostr_publish_enabled: bool = True
|
||||
nostr_orders_enabled: bool = False
|
||||
invoice_expiry_seconds: int = 900
|
||||
auto_accept_orders: bool = False
|
||||
Loading…
Add table
Add a link
Reference in a new issue