Remove the transitional layer added in commits 1+2:
models.py
- Drop Category, Subcategory, CreateCategory, CreateSubcategory.
crud.py
- Drop create_category / update_category / get_category /
get_categories / delete_category and the subcategory variants
along with the _node_row_to_category / _node_row_to_subcategory
helpers. Tree state is owned exclusively by menu_node CRUD now.
views_api.py
- Remove old endpoints:
GET /api/v1/restaurants/{id}/categories
POST /api/v1/categories
DELETE /api/v1/categories/{id}
GET /api/v1/categories/{id}/subcategories
POST /api/v1/subcategories
DELETE /api/v1/subcategories/{id}
Hits return 404 now.
- GET /api/v1/restaurants/{id}/menu loses the synthetic
'categories' projection. Response is {restaurant, tree, items}.
static/js/api.js
- Drop listCategories / createCategory / deleteCategory and the
subcategory wrappers.
The CMS menu builder is broken between this commit and commit 4.
The plan acknowledged this trade-off: keeping commits revertible
beats the cost of an unshipped UI page rendering a stale empty
sidebar for one commit's lifetime.
514 lines
16 KiB
Python
514 lines
16 KiB
Python
"""
|
|
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()
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Menu nodes (arbitrary-depth tree, max depth 4) #
|
|
# --------------------------------------------------------------------- #
|
|
#
|
|
# Adjacency list (parent_id self-FK) plus denormalized materialized
|
|
# `path` ('rootid' or 'rootid/childid' / ...) and `depth` (0..3).
|
|
#
|
|
# * MenuNodeRow is the persistence shape (no nested fields).
|
|
# * MenuNode extends it with `children` and `items` populated only
|
|
# by the tree builder (get_menu_tree). Never persist these — db
|
|
# writes go through MenuNodeRow.
|
|
#
|
|
# The legacy two-table category/subcategory shape is gone; we keep
|
|
# Category / Subcategory as transitional read-only projections for
|
|
# the shim commit, defined further down.
|
|
|
|
|
|
MAX_MENU_DEPTH = 3 # zero-indexed; 4 levels total
|
|
|
|
|
|
class CreateMenuNode(BaseModel):
|
|
restaurant_id: str
|
|
parent_id: Optional[str] = None
|
|
name: str
|
|
description: Optional[str] = None
|
|
sort_order: int = 0
|
|
image_url: Optional[str] = None
|
|
|
|
|
|
class MenuNodeRow(BaseModel):
|
|
"""Plain row mapping for db.insert / db.update."""
|
|
|
|
id: str
|
|
restaurant_id: str
|
|
parent_id: Optional[str] = None
|
|
name: str
|
|
description: Optional[str] = None
|
|
sort_order: int = 0
|
|
image_url: Optional[str] = None
|
|
depth: int = 0
|
|
path: str
|
|
time: datetime
|
|
|
|
|
|
class MenuNode(MenuNodeRow):
|
|
"""Hydrated tree node — adds `children` and `items` for the tree
|
|
response. Never persisted."""
|
|
|
|
children: list["MenuNode"] = Field(default_factory=list)
|
|
items: list["MenuItem"] = Field(default_factory=list)
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# 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
|
|
# Required at create time so newly-created items always land
|
|
# somewhere in the tree. Stored items can become orphaned later
|
|
# (cascade=False on parent delete) — see MenuItem.node_id below.
|
|
node_id: str
|
|
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
|
|
# Optional in the persisted shape: lets a node be deleted with
|
|
# cascade=False, leaving its items orphaned for the operator to
|
|
# re-home via the CMS instead of wiping revenue-bearing rows.
|
|
node_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()
|
|
|
|
|
|
# Resolve the forward references on MenuNode (declared above MenuItem).
|
|
MenuNode.update_forward_refs(MenuItem=MenuItem)
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# 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
|