restaurant/crud.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

763 lines
23 KiB
Python

"""
Async CRUD layer for the restaurant extension.
All functions are coroutines that hit the Database singleton initialized
at module import time. Pydantic models are passed to db.insert/update so
nested objects (OpenHours, SocialLinks, lists, etc.) are JSON-serialized
consistently across SQLite + Postgres backends.
A note on JSON columns: db.insert() / db.update() handle serialization,
but db.fetchone(model=Model) / db.fetchall(model=Model) reverse it via
the model's pre-validators (defined in models.py).
"""
import json
from datetime import datetime, timezone
from typing import Optional
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from .models import (
MAX_MENU_DEPTH,
AvailabilityWindow,
CreateAvailabilityWindow,
CreateMenuItem,
CreateMenuNode,
CreateModifier,
CreateModifierGroup,
CreateRestaurant,
MenuItem,
MenuNode,
MenuNodeRow,
Modifier,
ModifierGroup,
Order,
OrderItemRow,
PrintJob,
Restaurant,
RestaurantSettings,
SelectedModifier,
)
db = Database("ext_restaurant")
# --------------------------------------------------------------------- #
# Restaurants #
# --------------------------------------------------------------------- #
async def create_restaurant(wallet: str, data: CreateRestaurant) -> Restaurant:
restaurant = Restaurant(
id=urlsafe_short_hash(),
wallet=wallet,
time=datetime.now(timezone.utc),
**{k: v for k, v in data.dict().items() if k != "wallet"},
)
await db.insert("restaurant.restaurants", restaurant)
return restaurant
async def update_restaurant(restaurant: Restaurant) -> Restaurant:
await db.update("restaurant.restaurants", restaurant)
return restaurant
async def get_restaurant(restaurant_id: str) -> Optional[Restaurant]:
return await db.fetchone(
"SELECT * FROM restaurant.restaurants WHERE id = :id",
{"id": restaurant_id},
Restaurant,
)
async def get_restaurant_by_slug(slug: str) -> Optional[Restaurant]:
return await db.fetchone(
"SELECT * FROM restaurant.restaurants WHERE slug = :slug",
{"slug": slug},
Restaurant,
)
async def get_restaurants(wallet_ids: str | list[str]) -> list[Restaurant]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join([f"'{w}'" for w in wallet_ids])
return await db.fetchall(
f"SELECT * FROM restaurant.restaurants WHERE wallet IN ({q}) ORDER BY time DESC",
model=Restaurant,
)
async def get_all_restaurants() -> list[Restaurant]:
return await db.fetchall(
"SELECT * FROM restaurant.restaurants ORDER BY time DESC",
model=Restaurant,
)
async def delete_restaurant(restaurant_id: str) -> None:
# Cascade by app logic — relational FKs aren't enforced cross-backend,
# so we manually clean dependent rows in the right order.
await db.execute(
"""
DELETE FROM restaurant.print_jobs
WHERE order_id IN (
SELECT id FROM restaurant.orders WHERE restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"""
DELETE FROM restaurant.order_items
WHERE order_id IN (
SELECT id FROM restaurant.orders WHERE restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"DELETE FROM restaurant.orders WHERE restaurant_id = :rid",
{"rid": restaurant_id},
)
await db.execute(
"""
DELETE FROM restaurant.modifiers
WHERE group_id IN (
SELECT mg.id FROM restaurant.modifier_groups mg
JOIN restaurant.menu_items mi ON mg.menu_item_id = mi.id
WHERE mi.restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"""
DELETE FROM restaurant.modifier_groups
WHERE menu_item_id IN (
SELECT id FROM restaurant.menu_items WHERE restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"""
DELETE FROM restaurant.availability_windows
WHERE menu_item_id IN (
SELECT id FROM restaurant.menu_items WHERE restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"DELETE FROM restaurant.menu_items WHERE restaurant_id = :rid",
{"rid": restaurant_id},
)
await db.execute(
"DELETE FROM restaurant.menu_nodes WHERE restaurant_id = :rid",
{"rid": restaurant_id},
)
await db.execute(
"DELETE FROM restaurant.restaurants WHERE id = :id",
{"id": restaurant_id},
)
# --------------------------------------------------------------------- #
# Menu nodes (tree) #
# --------------------------------------------------------------------- #
async def create_menu_node(data: CreateMenuNode) -> MenuNode:
"""
Insert a node. Depth + path are derived from parent.
Raises ValueError if parent doesn't exist, lives on a different
restaurant, or sits at MAX_MENU_DEPTH (would push the new node
past the cap).
"""
new_id = urlsafe_short_hash()
if data.parent_id:
parent = await get_menu_node(data.parent_id)
if not parent or parent.restaurant_id != data.restaurant_id:
raise ValueError("Parent not found or in another restaurant")
if parent.depth >= MAX_MENU_DEPTH:
raise ValueError(
f"Cannot create node: depth {MAX_MENU_DEPTH + 1} exceeds "
f"max depth ({MAX_MENU_DEPTH + 1})"
)
depth = parent.depth + 1
path = f"{parent.path}/{new_id}"
else:
depth, path = 0, new_id
row = MenuNodeRow(
id=new_id,
restaurant_id=data.restaurant_id,
parent_id=data.parent_id,
name=data.name,
description=data.description,
sort_order=data.sort_order,
image_url=data.image_url,
depth=depth,
path=path,
time=datetime.now(timezone.utc),
)
await db.insert("restaurant.menu_nodes", row)
return MenuNode(**row.dict())
async def update_menu_node(node: MenuNodeRow | MenuNode) -> MenuNodeRow:
"""Update name / description / sort_order / image_url. Tree
position changes go through move_menu_node."""
row = MenuNodeRow(**{k: v for k, v in node.dict().items()
if k in MenuNodeRow.__fields__})
await db.update("restaurant.menu_nodes", row)
return row
async def get_menu_node(node_id: str) -> Optional[MenuNodeRow]:
return await db.fetchone(
"SELECT * FROM restaurant.menu_nodes WHERE id = :id",
{"id": node_id},
MenuNodeRow,
)
async def get_menu_nodes(restaurant_id: str) -> list[MenuNodeRow]:
return await db.fetchall(
"""
SELECT * FROM restaurant.menu_nodes
WHERE restaurant_id = :rid
ORDER BY depth, sort_order, time
""",
{"rid": restaurant_id},
model=MenuNodeRow,
)
async def get_menu_tree(restaurant_id: str) -> list[MenuNode]:
"""
Build the full hydrated tree for a restaurant: every node + every
item, in one pair of queries, assembled in O(n+m) Python. For
n=5..50 nodes and m=10..200 items this is microseconds — far
simpler than recursive CTEs and identical on SQLite + Postgres.
"""
rows = await get_menu_nodes(restaurant_id)
items = await get_menu_items(restaurant_id)
by_id: dict[str, MenuNode] = {
r.id: MenuNode(**r.dict()) for r in rows
}
roots: list[MenuNode] = []
for r in rows:
node = by_id[r.id]
if r.parent_id and r.parent_id in by_id:
by_id[r.parent_id].children.append(node)
else:
roots.append(node)
for it in items:
if it.node_id and it.node_id in by_id:
by_id[it.node_id].items.append(it)
return roots
async def move_menu_node(
node_id: str, new_parent_id: Optional[str]
) -> MenuNodeRow:
"""
Move a node (and its entire subtree) under a new parent, or to
the root if new_parent_id is None.
Single-statement subtree path rewrite using SUBSTR + concat:
`path = new_prefix || SUBSTR(path, len(old_path) + 1)`. SQLite's
SUBSTR is 1-indexed (matches Postgres).
Raises ValueError on:
* missing node / parent
* cross-restaurant move
* cycle (new_parent_id is in the moved node's subtree)
* any descendant would exceed MAX_MENU_DEPTH after the move
"""
node = await get_menu_node(node_id)
if not node:
raise ValueError("Node not found")
if new_parent_id:
parent = await get_menu_node(new_parent_id)
if not parent or parent.restaurant_id != node.restaurant_id:
raise ValueError("Parent not found or in another restaurant")
# Cycle prevention: parent must not be inside node's subtree.
if node_id in parent.path.split("/"):
raise ValueError("Cannot move a node into its own subtree")
new_depth = parent.depth + 1
new_prefix = f"{parent.path}/{node_id}"
else:
new_depth = 0
new_prefix = node_id
# Reject if any descendant would exceed MAX_MENU_DEPTH.
max_d_row = await db.fetchone(
"""
SELECT MAX(depth) AS max_depth FROM restaurant.menu_nodes
WHERE path = :p OR path LIKE :p || '/%'
""",
{"p": node.path},
)
max_d = (max_d_row["max_depth"] if max_d_row else None) or node.depth
delta_depth = new_depth - node.depth
if max_d + delta_depth > MAX_MENU_DEPTH:
raise ValueError(
f"Move would exceed max depth ({MAX_MENU_DEPTH + 1})"
)
old_path = node.path
await db.execute(
"""
UPDATE restaurant.menu_nodes
SET path = :new_prefix || SUBSTR(path, :old_len + 1),
depth = depth + :delta
WHERE path = :old_path
OR path LIKE :old_path || '/%'
""",
{
"new_prefix": new_prefix,
"old_len": len(old_path),
"delta": delta_depth,
"old_path": old_path,
},
)
await db.execute(
"UPDATE restaurant.menu_nodes SET parent_id = :pid WHERE id = :id",
{"pid": new_parent_id, "id": node_id},
)
refreshed = await get_menu_node(node_id)
assert refreshed
return refreshed
async def delete_menu_node(node_id: str, cascade: bool = False) -> None:
"""
Delete a node. If it has children or items:
* cascade=False (default): raise ValueError. The CMS prompts
the operator to confirm before passing cascade=True.
* cascade=True: delete the entire subtree of nodes, but
DETACH items (set node_id NULL) rather than wipe them.
Items carry nostr_event_ids and are revenue-bearing —
orphaning them so the operator can re-home is friendlier
than deleting.
"""
node = await get_menu_node(node_id)
if not node:
return
has_children_row = await db.fetchone(
"SELECT 1 AS one FROM restaurant.menu_nodes WHERE parent_id = :id LIMIT 1",
{"id": node_id},
)
has_items_row = await db.fetchone(
"SELECT 1 AS one FROM restaurant.menu_items WHERE node_id = :id LIMIT 1",
{"id": node_id},
)
if (has_children_row or has_items_row) and not cascade:
raise ValueError(
"Node has children or items; pass cascade=true to delete"
)
if cascade:
# Detach items in the entire subtree.
await db.execute(
"""
UPDATE restaurant.menu_items
SET node_id = NULL
WHERE node_id IN (
SELECT id FROM restaurant.menu_nodes
WHERE path = :p OR path LIKE :p || '/%'
)
""",
{"p": node.path},
)
await db.execute(
"""
DELETE FROM restaurant.menu_nodes
WHERE path = :p OR path LIKE :p || '/%'
""",
{"p": node.path},
)
else:
await db.execute(
"DELETE FROM restaurant.menu_nodes WHERE id = :id",
{"id": node_id},
)
# --------------------------------------------------------------------- #
# Menu items #
# --------------------------------------------------------------------- #
async def create_menu_item(data: CreateMenuItem) -> MenuItem:
item = MenuItem(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
)
await db.insert("restaurant.menu_items", item)
return item
async def update_menu_item(item: MenuItem) -> MenuItem:
await db.update("restaurant.menu_items", item)
return item
async def get_menu_item(item_id: str) -> Optional[MenuItem]:
return await db.fetchone(
"SELECT * FROM restaurant.menu_items WHERE id = :id",
{"id": item_id},
MenuItem,
)
async def get_menu_items(restaurant_id: str) -> list[MenuItem]:
return await db.fetchall(
"""
SELECT * FROM restaurant.menu_items
WHERE restaurant_id = :rid
ORDER BY sort_order, time
""",
{"rid": restaurant_id},
model=MenuItem,
)
async def get_menu_item_by_nostr_event(event_id: str) -> Optional[MenuItem]:
return await db.fetchone(
"SELECT * FROM restaurant.menu_items WHERE nostr_event_id = :nid",
{"nid": event_id},
MenuItem,
)
async def delete_menu_item(item_id: str) -> None:
await db.execute(
"""
DELETE FROM restaurant.modifiers
WHERE group_id IN (
SELECT id FROM restaurant.modifier_groups WHERE menu_item_id = :mid
)
""",
{"mid": item_id},
)
await db.execute(
"DELETE FROM restaurant.modifier_groups WHERE menu_item_id = :mid",
{"mid": item_id},
)
await db.execute(
"DELETE FROM restaurant.availability_windows WHERE menu_item_id = :mid",
{"mid": item_id},
)
await db.execute(
"DELETE FROM restaurant.menu_items WHERE id = :id",
{"id": item_id},
)
async def adjust_stock(item_id: str, delta: int) -> Optional[MenuItem]:
"""Decrement (negative delta) or increment stock atomically."""
item = await get_menu_item(item_id)
if not item or item.stock is None:
return item
item.stock = max(0, item.stock + delta)
return await update_menu_item(item)
# --------------------------------------------------------------------- #
# Modifier groups + modifiers #
# --------------------------------------------------------------------- #
async def create_modifier_group(data: CreateModifierGroup) -> ModifierGroup:
grp = ModifierGroup(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
)
await db.insert("restaurant.modifier_groups", grp)
return grp
async def update_modifier_group(grp: ModifierGroup) -> ModifierGroup:
await db.update("restaurant.modifier_groups", grp)
return grp
async def get_modifier_groups(menu_item_id: str) -> list[ModifierGroup]:
return await db.fetchall(
"""
SELECT * FROM restaurant.modifier_groups
WHERE menu_item_id = :mid
ORDER BY sort_order, time
""",
{"mid": menu_item_id},
model=ModifierGroup,
)
async def delete_modifier_group(group_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.modifiers WHERE group_id = :gid",
{"gid": group_id},
)
await db.execute(
"DELETE FROM restaurant.modifier_groups WHERE id = :id",
{"id": group_id},
)
async def create_modifier(data: CreateModifier) -> Modifier:
mod = Modifier(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
)
await db.insert("restaurant.modifiers", mod)
return mod
async def update_modifier(mod: Modifier) -> Modifier:
await db.update("restaurant.modifiers", mod)
return mod
async def get_modifiers(group_id: str) -> list[Modifier]:
return await db.fetchall(
"""
SELECT * FROM restaurant.modifiers
WHERE group_id = :gid
ORDER BY sort_order, time
""",
{"gid": group_id},
model=Modifier,
)
async def delete_modifier(modifier_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.modifiers WHERE id = :id",
{"id": modifier_id},
)
# --------------------------------------------------------------------- #
# Availability windows #
# --------------------------------------------------------------------- #
async def create_availability_window(
data: CreateAvailabilityWindow,
) -> AvailabilityWindow:
win = AvailabilityWindow(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
)
await db.insert("restaurant.availability_windows", win)
return win
async def get_availability_windows(menu_item_id: str) -> list[AvailabilityWindow]:
return await db.fetchall(
"""
SELECT * FROM restaurant.availability_windows
WHERE menu_item_id = :mid
ORDER BY weekday NULLS FIRST, start_time
""",
{"mid": menu_item_id},
model=AvailabilityWindow,
)
async def delete_availability_window(window_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.availability_windows WHERE id = :id",
{"id": window_id},
)
# --------------------------------------------------------------------- #
# Orders + order items #
# --------------------------------------------------------------------- #
async def create_order(order: Order) -> Order:
"""Insert an Order row. Caller must construct the Order with id set
(typically id = payment_hash so we can look it up from the invoice
listener with no extra metadata)."""
await db.insert("restaurant.orders", order)
return order
async def update_order(order: Order) -> Order:
await db.update("restaurant.orders", order)
return order
async def get_order(order_id: str) -> Optional[Order]:
return await db.fetchone(
"SELECT * FROM restaurant.orders WHERE id = :id",
{"id": order_id},
Order,
)
async def get_order_by_payment_hash(payment_hash: str) -> Optional[Order]:
return await db.fetchone(
"SELECT * FROM restaurant.orders WHERE payment_hash = :ph",
{"ph": payment_hash},
Order,
)
async def get_orders(
restaurant_id: str,
statuses: Optional[list[str]] = None,
limit: int = 200,
) -> list[Order]:
if statuses:
placeholders = ",".join([f"'{s}'" for s in statuses])
return await db.fetchall(
f"""
SELECT * FROM restaurant.orders
WHERE restaurant_id = :rid AND status IN ({placeholders})
ORDER BY time DESC
LIMIT {int(limit)}
""",
{"rid": restaurant_id},
model=Order,
)
return await db.fetchall(
f"""
SELECT * FROM restaurant.orders
WHERE restaurant_id = :rid
ORDER BY time DESC
LIMIT {int(limit)}
""",
{"rid": restaurant_id},
model=Order,
)
async def create_order_item(item: OrderItemRow) -> OrderItemRow:
await db.insert("restaurant.order_items", item)
return item
async def get_order_items(order_id: str) -> list[OrderItemRow]:
rows = await db.fetchall(
"SELECT * FROM restaurant.order_items WHERE order_id = :oid ORDER BY time",
{"oid": order_id},
)
out: list[OrderItemRow] = []
for row in rows:
d = dict(row)
# selected_modifiers comes back as JSON string from db; parse here.
sm = d.get("selected_modifiers")
if isinstance(sm, str):
try:
d["selected_modifiers"] = [
SelectedModifier(**m) for m in (json.loads(sm) if sm else [])
]
except json.JSONDecodeError:
d["selected_modifiers"] = []
out.append(OrderItemRow(**d))
return out
# --------------------------------------------------------------------- #
# Print jobs #
# --------------------------------------------------------------------- #
async def create_print_job(restaurant_id: str, order_id: str) -> PrintJob:
job = PrintJob(
id=urlsafe_short_hash(),
restaurant_id=restaurant_id,
order_id=order_id,
time=datetime.now(timezone.utc),
)
await db.insert("restaurant.print_jobs", job)
return job
async def update_print_job(job: PrintJob) -> PrintJob:
await db.update("restaurant.print_jobs", job)
return job
async def get_print_job(job_id: str) -> Optional[PrintJob]:
return await db.fetchone(
"SELECT * FROM restaurant.print_jobs WHERE id = :id",
{"id": job_id},
PrintJob,
)
async def get_print_jobs(
restaurant_id: str, status: Optional[str] = None
) -> list[PrintJob]:
if status:
return await db.fetchall(
"""
SELECT * FROM restaurant.print_jobs
WHERE restaurant_id = :rid AND status = :status
ORDER BY time DESC
""",
{"rid": restaurant_id, "status": status},
model=PrintJob,
)
return await db.fetchall(
"""
SELECT * FROM restaurant.print_jobs
WHERE restaurant_id = :rid
ORDER BY time DESC
""",
{"rid": restaurant_id},
model=PrintJob,
)
# --------------------------------------------------------------------- #
# Settings #
# --------------------------------------------------------------------- #
async def get_settings() -> RestaurantSettings:
row = await db.fetchone("SELECT * FROM restaurant.settings WHERE id = 1")
if row:
d = dict(row)
d.pop("id", None)
return RestaurantSettings(**d)
return RestaurantSettings()
async def update_settings(settings: RestaurantSettings) -> RestaurantSettings:
await db.execute(
"""
UPDATE restaurant.settings
SET nostr_publish_enabled = :npe,
nostr_orders_enabled = :noe,
invoice_expiry_seconds = :ies,
auto_accept_orders = :aao
WHERE id = 1
""",
{
"npe": settings.nostr_publish_enabled,
"noe": settings.nostr_orders_enabled,
"ies": settings.invoice_expiry_seconds,
"aao": settings.auto_accept_orders,
},
)
return settings