feat(db,models,crud): m002 menu tree + node CRUD with shim

Replace the fixed two-level (categories + subcategories) menu shape
with an arbitrary-depth tree, capped at 4 levels. The legacy
Atitlan.io project this carries forward already used a self-FK tree;
real menus need the depth (Drinks -> Hot Beverages -> Coffee-based
-> Espressos). As a side benefit, the current CMS has no UI for
subcategories at all, so this refactor incidentally fixes that gap.

Pattern: adjacency list (parent_id self-FK) + denormalized
materialized path (TEXT, '/'-separated) + denormalized depth.
Rejected closure table (overkill at n=5..50) and Postgres ltree
(not portable to SQLite). Subtree queries become
'WHERE path LIKE :p || '%''; subtree moves are a single
SUBSTR + concat UPDATE; max-depth and cycle checks are O(1).

migrations.py
  m002_menu_tree:
    - CREATE TABLE menu_nodes (id, restaurant_id, parent_id,
      name, description, sort_order, image_url, depth, path, time)
      with indexes on (restaurant_id), (parent_id), (path).
    - Backfill depth-0 from categories; depth-1 from subcategories
      with path = parent.id || '/' || own.id.
    - ALTER menu_items ADD COLUMN node_id; backfill via
      COALESCE(subcategory_id, category_id). Index on node_id.
    - DROP subcategory_id, category_id; DROP TABLE subcategories,
      categories.

models.py
  - New MAX_MENU_DEPTH = 3 (zero-indexed; 4 levels total).
  - New MenuNodeRow (DB I/O shape) + MenuNode (extends with
    children + items for hydrated tree responses; never persisted).
  - New CreateMenuNode.
  - MenuItem.node_id is Optional (orphans allowed when a parent
    is deleted with cascade=False); CreateMenuItem.node_id is
    required (newly created items must land somewhere).
  - Category / Subcategory / Create* kept temporarily as
    transitional shim shapes for the old endpoints; dropped in
    commit 3.

crud.py
  - New: create/update/get/get_all/move/delete_menu_node and
    get_menu_tree. move_menu_node uses single-statement subtree
    rewrite (path = new_prefix || SUBSTR(path, old_len + 1)).
    Cycle check: new_parent's path must not contain node_id.
    Depth check: max descendant depth + delta_depth <= MAX_MENU_DEPTH.
    delete_menu_node default cascade=False (block on children/items);
    cascade=True detaches items (sets node_id NULL) rather than
    hard-deletes, since items carry nostr_event_ids and are
    revenue-bearing.
  - get_menu_tree fetches nodes + items in two queries and assembles
    the tree in O(n+m) Python — no recursive CTEs, identical on
    SQLite + Postgres.
  - Old create_category / get_categories / create_subcategory etc.
    rewritten as thin shims that translate to/from menu_nodes.
    Old endpoints keep working.
  - delete_restaurant cascade now deletes from menu_nodes
    (single statement) instead of categories + subcategories.

views_api.py
  - GET /restaurants/{id}/menu temporarily sources from menu_nodes
    via the shims; surfaces items only at depth-0 nodes for now
    (commit 2 replaces the whole block with a real tree response).

static/js/menu.js + templates/restaurant/menu.html
  - Rename category_id -> node_id in the item dialog payload so
    POST /menu_items satisfies the new CreateMenuItem schema.
    The CMS still renders against the depth-0 'categories'
    projection; full q-tree rewrite lands in commit 4.
This commit is contained in:
Padreug 2026-05-02 09:03:40 +02:00
commit 6272df1288
6 changed files with 516 additions and 63 deletions

363
crud.py
View file

@ -19,16 +19,20 @@ from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from .models import (
MAX_MENU_DEPTH,
AvailabilityWindow,
Category,
CreateAvailabilityWindow,
CreateCategory,
CreateMenuItem,
CreateMenuNode,
CreateModifier,
CreateModifierGroup,
CreateRestaurant,
CreateSubcategory,
MenuItem,
MenuNode,
MenuNodeRow,
Modifier,
ModifierGroup,
Order,
@ -156,16 +160,7 @@ async def delete_restaurant(restaurant_id: str) -> None:
{"rid": restaurant_id},
)
await db.execute(
"""
DELETE FROM restaurant.subcategories
WHERE category_id IN (
SELECT id FROM restaurant.categories WHERE restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"DELETE FROM restaurant.categories WHERE restaurant_id = :rid",
"DELETE FROM restaurant.menu_nodes WHERE restaurant_id = :rid",
{"rid": restaurant_id},
)
await db.execute(
@ -175,88 +170,354 @@ async def delete_restaurant(restaurant_id: str) -> None:
# --------------------------------------------------------------------- #
# Categories / subcategories #
# Menu nodes (tree) #
# --------------------------------------------------------------------- #
async def create_category(data: CreateCategory) -> Category:
cat = Category(
id=urlsafe_short_hash(),
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),
**data.dict(),
)
await db.insert("restaurant.categories", cat)
return cat
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},
)
# --------------------------------------------------------------------- #
# Categories / subcategories — transitional shims (drop in commit 3) #
# --------------------------------------------------------------------- #
# These keep the old /categories and /subcategories REST endpoints
# working over the new menu_nodes table for one commit's lifetime.
# Drop entirely in the next commit once the new endpoints are live.
def _node_row_to_category(row: MenuNodeRow) -> Category:
return Category(
id=row.id,
restaurant_id=row.restaurant_id,
name=row.name,
description=row.description,
sort_order=row.sort_order,
image_url=row.image_url,
time=row.time,
)
def _node_row_to_subcategory(row: MenuNodeRow) -> Subcategory:
# Subcategory carries the parent category id, not its own restaurant.
return Subcategory(
id=row.id,
category_id=row.parent_id or "",
name=row.name,
sort_order=row.sort_order,
time=row.time,
)
async def create_category(data: CreateCategory) -> Category:
node = await create_menu_node(
CreateMenuNode(
restaurant_id=data.restaurant_id,
parent_id=None,
name=data.name,
description=data.description,
sort_order=data.sort_order,
image_url=data.image_url,
)
)
return _node_row_to_category(node)
async def update_category(category: Category) -> Category:
await db.update("restaurant.categories", category)
row = await get_menu_node(category.id)
if not row:
raise ValueError("Category not found")
row.name = category.name
row.description = category.description
row.sort_order = category.sort_order
row.image_url = category.image_url
await update_menu_node(row)
return category
async def get_category(category_id: str) -> Optional[Category]:
return await db.fetchone(
"SELECT * FROM restaurant.categories WHERE id = :id",
{"id": category_id},
Category,
)
row = await get_menu_node(category_id)
if not row or row.depth != 0:
return None
return _node_row_to_category(row)
async def get_categories(restaurant_id: str) -> list[Category]:
return await db.fetchall(
rows = await db.fetchall(
"""
SELECT * FROM restaurant.categories
WHERE restaurant_id = :rid
SELECT * FROM restaurant.menu_nodes
WHERE restaurant_id = :rid AND depth = 0
ORDER BY sort_order, time
""",
{"rid": restaurant_id},
model=Category,
model=MenuNodeRow,
)
return [_node_row_to_category(r) for r in rows]
async def delete_category(category_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.subcategories WHERE category_id = :cid",
{"cid": category_id},
)
await db.execute(
"DELETE FROM restaurant.categories WHERE id = :id",
{"id": category_id},
)
await delete_menu_node(category_id, cascade=True)
async def create_subcategory(data: CreateSubcategory) -> Subcategory:
sub = Subcategory(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
parent = await get_menu_node(data.category_id)
if not parent:
raise ValueError("Category not found")
node = await create_menu_node(
CreateMenuNode(
restaurant_id=parent.restaurant_id,
parent_id=parent.id,
name=data.name,
sort_order=data.sort_order,
)
)
await db.insert("restaurant.subcategories", sub)
return sub
return _node_row_to_subcategory(node)
async def update_subcategory(subcategory: Subcategory) -> Subcategory:
await db.update("restaurant.subcategories", subcategory)
row = await get_menu_node(subcategory.id)
if not row:
raise ValueError("Subcategory not found")
row.name = subcategory.name
row.sort_order = subcategory.sort_order
await update_menu_node(row)
return subcategory
async def get_subcategories(category_id: str) -> list[Subcategory]:
return await db.fetchall(
rows = await db.fetchall(
"""
SELECT * FROM restaurant.subcategories
WHERE category_id = :cid
SELECT * FROM restaurant.menu_nodes
WHERE parent_id = :pid
ORDER BY sort_order, time
""",
{"cid": category_id},
model=Subcategory,
{"pid": category_id},
model=MenuNodeRow,
)
return [_node_row_to_subcategory(r) for r in rows]
async def delete_subcategory(subcategory_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.subcategories WHERE id = :id",
{"id": subcategory_id},
)
await delete_menu_node(subcategory_id, cascade=True)
# --------------------------------------------------------------------- #