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