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:
Padreug 2026-04-29 23:35:18 +02:00
commit 52f1ad1bb1

491
models.py Normal file
View 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