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:
parent
c2ea0297f9
commit
6272df1288
6 changed files with 516 additions and 63 deletions
363
crud.py
363
crud.py
|
|
@ -19,16 +19,20 @@ from lnbits.db import Database
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
|
MAX_MENU_DEPTH,
|
||||||
AvailabilityWindow,
|
AvailabilityWindow,
|
||||||
Category,
|
Category,
|
||||||
CreateAvailabilityWindow,
|
CreateAvailabilityWindow,
|
||||||
CreateCategory,
|
CreateCategory,
|
||||||
CreateMenuItem,
|
CreateMenuItem,
|
||||||
|
CreateMenuNode,
|
||||||
CreateModifier,
|
CreateModifier,
|
||||||
CreateModifierGroup,
|
CreateModifierGroup,
|
||||||
CreateRestaurant,
|
CreateRestaurant,
|
||||||
CreateSubcategory,
|
CreateSubcategory,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
MenuNode,
|
||||||
|
MenuNodeRow,
|
||||||
Modifier,
|
Modifier,
|
||||||
ModifierGroup,
|
ModifierGroup,
|
||||||
Order,
|
Order,
|
||||||
|
|
@ -156,16 +160,7 @@ async def delete_restaurant(restaurant_id: str) -> None:
|
||||||
{"rid": restaurant_id},
|
{"rid": restaurant_id},
|
||||||
)
|
)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"DELETE FROM restaurant.menu_nodes WHERE restaurant_id = :rid",
|
||||||
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",
|
|
||||||
{"rid": restaurant_id},
|
{"rid": restaurant_id},
|
||||||
)
|
)
|
||||||
await db.execute(
|
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:
|
async def create_menu_node(data: CreateMenuNode) -> MenuNode:
|
||||||
cat = Category(
|
"""
|
||||||
id=urlsafe_short_hash(),
|
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),
|
time=datetime.now(timezone.utc),
|
||||||
**data.dict(),
|
|
||||||
)
|
)
|
||||||
await db.insert("restaurant.categories", cat)
|
await db.insert("restaurant.menu_nodes", row)
|
||||||
return cat
|
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:
|
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
|
return category
|
||||||
|
|
||||||
|
|
||||||
async def get_category(category_id: str) -> Optional[Category]:
|
async def get_category(category_id: str) -> Optional[Category]:
|
||||||
return await db.fetchone(
|
row = await get_menu_node(category_id)
|
||||||
"SELECT * FROM restaurant.categories WHERE id = :id",
|
if not row or row.depth != 0:
|
||||||
{"id": category_id},
|
return None
|
||||||
Category,
|
return _node_row_to_category(row)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_categories(restaurant_id: str) -> list[Category]:
|
async def get_categories(restaurant_id: str) -> list[Category]:
|
||||||
return await db.fetchall(
|
rows = await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM restaurant.categories
|
SELECT * FROM restaurant.menu_nodes
|
||||||
WHERE restaurant_id = :rid
|
WHERE restaurant_id = :rid AND depth = 0
|
||||||
ORDER BY sort_order, time
|
ORDER BY sort_order, time
|
||||||
""",
|
""",
|
||||||
{"rid": restaurant_id},
|
{"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:
|
async def delete_category(category_id: str) -> None:
|
||||||
await db.execute(
|
await delete_menu_node(category_id, cascade=True)
|
||||||
"DELETE FROM restaurant.subcategories WHERE category_id = :cid",
|
|
||||||
{"cid": category_id},
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
"DELETE FROM restaurant.categories WHERE id = :id",
|
|
||||||
{"id": category_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_subcategory(data: CreateSubcategory) -> Subcategory:
|
async def create_subcategory(data: CreateSubcategory) -> Subcategory:
|
||||||
sub = Subcategory(
|
parent = await get_menu_node(data.category_id)
|
||||||
id=urlsafe_short_hash(),
|
if not parent:
|
||||||
time=datetime.now(timezone.utc),
|
raise ValueError("Category not found")
|
||||||
**data.dict(),
|
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 _node_row_to_subcategory(node)
|
||||||
return sub
|
|
||||||
|
|
||||||
|
|
||||||
async def update_subcategory(subcategory: Subcategory) -> Subcategory:
|
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
|
return subcategory
|
||||||
|
|
||||||
|
|
||||||
async def get_subcategories(category_id: str) -> list[Subcategory]:
|
async def get_subcategories(category_id: str) -> list[Subcategory]:
|
||||||
return await db.fetchall(
|
rows = await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM restaurant.subcategories
|
SELECT * FROM restaurant.menu_nodes
|
||||||
WHERE category_id = :cid
|
WHERE parent_id = :pid
|
||||||
ORDER BY sort_order, time
|
ORDER BY sort_order, time
|
||||||
""",
|
""",
|
||||||
{"cid": category_id},
|
{"pid": category_id},
|
||||||
model=Subcategory,
|
model=MenuNodeRow,
|
||||||
)
|
)
|
||||||
|
return [_node_row_to_subcategory(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
async def delete_subcategory(subcategory_id: str) -> None:
|
async def delete_subcategory(subcategory_id: str) -> None:
|
||||||
await db.execute(
|
await delete_menu_node(subcategory_id, cascade=True)
|
||||||
"DELETE FROM restaurant.subcategories WHERE id = :id",
|
|
||||||
{"id": subcategory_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
|
|
|
||||||
125
migrations.py
125
migrations.py
|
|
@ -331,3 +331,128 @@ async def m001_initial(db):
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m002_menu_tree(db):
|
||||||
|
"""
|
||||||
|
Replace the fixed `categories` + `subcategories` two-level model
|
||||||
|
with a single self-referential `menu_nodes` table (adjacency list
|
||||||
|
+ denormalized materialized path).
|
||||||
|
|
||||||
|
Why adjacency + path (not closure table, not Postgres ltree):
|
||||||
|
|
||||||
|
* Scale: 5–50 nodes per restaurant, depth ≤ 4. Closure table
|
||||||
|
is overhead at this size.
|
||||||
|
* Backend portability: works identically on SQLite + Postgres
|
||||||
|
with no extensions. ltree is Postgres-only.
|
||||||
|
* `path` ('rootid' or 'rootid/childid' / ...) gives O(1)
|
||||||
|
subtree queries (`WHERE path LIKE :p || '%'`), trivial
|
||||||
|
cycle detection on move, and a single-statement subtree
|
||||||
|
rewrite (substring + concat).
|
||||||
|
* `depth` is denormalized so we can reject "would exceed 4"
|
||||||
|
without walking the tree.
|
||||||
|
|
||||||
|
Items can attach to ANY node (not just leaves). On node delete,
|
||||||
|
the default cascade detaches items (sets node_id NULL) rather
|
||||||
|
than hard-deleting them; items are revenue-bearing and carry
|
||||||
|
nostr_event_ids, so orphaning them so the operator can re-home
|
||||||
|
via the CMS is friendlier than wiping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
# New menu_nodes table #
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE restaurant.menu_nodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
restaurant_id TEXT NOT NULL,
|
||||||
|
parent_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
image_url TEXT,
|
||||||
|
depth INTEGER NOT NULL DEFAULT 0,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"CREATE INDEX restaurant.idx_menu_nodes_restaurant "
|
||||||
|
"ON menu_nodes(restaurant_id);"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"CREATE INDEX restaurant.idx_menu_nodes_parent "
|
||||||
|
"ON menu_nodes(parent_id);"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"CREATE INDEX restaurant.idx_menu_nodes_path "
|
||||||
|
"ON menu_nodes(path);"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
# Backfill: top-level (depth 0) from categories #
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO restaurant.menu_nodes
|
||||||
|
(id, restaurant_id, parent_id, name, description, sort_order,
|
||||||
|
image_url, depth, path, time)
|
||||||
|
SELECT id, restaurant_id, NULL, name, description, sort_order,
|
||||||
|
image_url, 0, id, time
|
||||||
|
FROM restaurant.categories;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
# Backfill: depth-1 from subcategories #
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO restaurant.menu_nodes
|
||||||
|
(id, restaurant_id, parent_id, name, description, sort_order,
|
||||||
|
image_url, depth, path, time)
|
||||||
|
SELECT s.id, c.restaurant_id, s.category_id, s.name, NULL,
|
||||||
|
s.sort_order, NULL, 1, c.id || '/' || s.id, s.time
|
||||||
|
FROM restaurant.subcategories s
|
||||||
|
JOIN restaurant.categories c ON c.id = s.category_id;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
# Add menu_items.node_id and backfill #
|
||||||
|
# subcategory wins if both set #
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE restaurant.menu_items ADD COLUMN node_id TEXT;"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE restaurant.menu_items
|
||||||
|
SET node_id = COALESCE(subcategory_id, category_id);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"CREATE INDEX restaurant.idx_menu_items_node "
|
||||||
|
"ON menu_items(node_id);"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
# Drop old columns + tables #
|
||||||
|
# #
|
||||||
|
# `ALTER TABLE ... DROP COLUMN` requires SQLite ≥ 3.35 (2021-03). #
|
||||||
|
# LNbits's pinned dependencies are on a modern SQLite, but if a #
|
||||||
|
# downstream user is on something older the column drops will #
|
||||||
|
# fail loudly and they'll need to upgrade SQLite — preferable to #
|
||||||
|
# the table-rebuild dance which has more failure modes. #
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE restaurant.menu_items DROP COLUMN subcategory_id;"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE restaurant.menu_items DROP COLUMN category_id;"
|
||||||
|
)
|
||||||
|
await db.execute("DROP INDEX restaurant.idx_menu_items_category;")
|
||||||
|
await db.execute("DROP TABLE restaurant.subcategories;")
|
||||||
|
await db.execute("DROP TABLE restaurant.categories;")
|
||||||
|
|
|
||||||
73
models.py
73
models.py
|
|
@ -162,8 +162,63 @@ class Restaurant(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
# Categories / subcategories #
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# Transitional shims (kept until commit 3) #
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# These let the old /categories and /subcategories endpoints keep
|
||||||
|
# working over the new menu_nodes table for one commit's lifetime.
|
||||||
|
# Drop in commit 3.
|
||||||
|
|
||||||
|
|
||||||
class CreateCategory(BaseModel):
|
class CreateCategory(BaseModel):
|
||||||
|
|
@ -213,8 +268,10 @@ class MenuItemExtra(BaseModel):
|
||||||
|
|
||||||
class CreateMenuItem(BaseModel):
|
class CreateMenuItem(BaseModel):
|
||||||
restaurant_id: str
|
restaurant_id: str
|
||||||
category_id: Optional[str] = None
|
# Required at create time so newly-created items always land
|
||||||
subcategory_id: Optional[str] = None
|
# 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
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
price: float = 0
|
price: float = 0
|
||||||
|
|
@ -236,8 +293,10 @@ class CreateMenuItem(BaseModel):
|
||||||
class MenuItem(BaseModel):
|
class MenuItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
restaurant_id: str
|
restaurant_id: str
|
||||||
category_id: Optional[str] = None
|
# Optional in the persisted shape: lets a node be deleted with
|
||||||
subcategory_id: Optional[str] = None
|
# 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
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
price: float = 0
|
price: float = 0
|
||||||
|
|
@ -269,6 +328,10 @@ class MenuItem(BaseModel):
|
||||||
return v or MenuItemExtra()
|
return v or MenuItemExtra()
|
||||||
|
|
||||||
|
|
||||||
|
# Resolve the forward references on MenuNode (declared above MenuItem).
|
||||||
|
MenuNode.update_forward_refs(MenuItem=MenuItem)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
# Modifier groups + modifiers #
|
# Modifier groups + modifiers #
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
filteredItems() {
|
filteredItems() {
|
||||||
if (!this.selectedCategoryId) return this.items
|
if (!this.selectedCategoryId) return this.items
|
||||||
return this.items.filter((i) => i.category_id === this.selectedCategoryId)
|
return this.items.filter((i) => i.node_id === this.selectedCategoryId)
|
||||||
},
|
},
|
||||||
categoryOptions() {
|
categoryOptions() {
|
||||||
return this.categories.map((c) => ({label: c.name, value: c.id}))
|
return this.categories.map((c) => ({label: c.name, value: c.id}))
|
||||||
|
|
@ -48,8 +48,7 @@ window.app = Vue.createApp({
|
||||||
_blankItem() {
|
_blankItem() {
|
||||||
return {
|
return {
|
||||||
restaurant_id: '',
|
restaurant_id: '',
|
||||||
category_id: null,
|
node_id: null,
|
||||||
subcategory_id: null,
|
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
price: 0,
|
price: 0,
|
||||||
|
|
@ -123,8 +122,8 @@ window.app = Vue.createApp({
|
||||||
const item = existing
|
const item = existing
|
||||||
? {...existing}
|
? {...existing}
|
||||||
: {...this._blankItem(), restaurant_id: this.restaurant.id}
|
: {...this._blankItem(), restaurant_id: this.restaurant.id}
|
||||||
if (!item.category_id && this.selectedCategoryId) {
|
if (!item.node_id && this.selectedCategoryId) {
|
||||||
item.category_id = this.selectedCategoryId
|
item.node_id = this.selectedCategoryId
|
||||||
}
|
}
|
||||||
this.itemDialog.data = item
|
this.itemDialog.data = item
|
||||||
this.itemDialog.imagesText = (item.images || []).join(', ')
|
this.itemDialog.imagesText = (item.images || []).join(', ')
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="itemDialog.data.category_id"
|
v-model="itemDialog.data.node_id"
|
||||||
:options="categoryOptions"
|
:options="categoryOptions"
|
||||||
emit-value
|
emit-value
|
||||||
map-options
|
map-options
|
||||||
|
|
|
||||||
|
|
@ -398,8 +398,13 @@ async def api_get_menu(restaurant_id: str) -> dict:
|
||||||
w.dict() for w in await get_availability_windows(item.id)
|
w.dict() for w in await get_availability_windows(item.id)
|
||||||
]
|
]
|
||||||
enriched_items.append(item_dict)
|
enriched_items.append(item_dict)
|
||||||
if item.category_id and item.category_id in cat_map:
|
# Backed by menu_nodes now: an item's node_id may be a depth-0
|
||||||
cat_map[item.category_id]["items"].append(item_dict)
|
# node (legacy "category") or deeper. For this transitional
|
||||||
|
# endpoint we surface items only when they sit at depth-0 so
|
||||||
|
# the existing CMS keeps rendering. Commit 2 replaces this
|
||||||
|
# whole block with a real tree.
|
||||||
|
if item.node_id and item.node_id in cat_map:
|
||||||
|
cat_map[item.node_id]["items"].append(item_dict)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"restaurant": restaurant.dict(),
|
"restaurant": restaurant.dict(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue