restaurant/models.py
Padreug b7fa1aec4a refactor(http): drop categories/subcategories shim
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.
2026-05-09 07:11:06 +02:00

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