diff --git a/crud.py b/crud.py index ebef9dc..ffe7e97 100644 --- a/crud.py +++ b/crud.py @@ -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) # --------------------------------------------------------------------- # diff --git a/migrations.py b/migrations.py index d59a2c1..d927a45 100644 --- a/migrations.py +++ b/migrations.py @@ -331,3 +331,128 @@ async def m001_initial(db): 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;") diff --git a/models.py b/models.py index b842a5a..49dccf9 100644 --- a/models.py +++ b/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): @@ -213,8 +268,10 @@ class MenuItemExtra(BaseModel): class CreateMenuItem(BaseModel): restaurant_id: str - category_id: Optional[str] = None - subcategory_id: Optional[str] = None + # 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 @@ -236,8 +293,10 @@ class CreateMenuItem(BaseModel): class MenuItem(BaseModel): id: str restaurant_id: str - category_id: Optional[str] = None - subcategory_id: Optional[str] = None + # 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 @@ -269,6 +328,10 @@ class MenuItem(BaseModel): return v or MenuItemExtra() +# Resolve the forward references on MenuNode (declared above MenuItem). +MenuNode.update_forward_refs(MenuItem=MenuItem) + + # --------------------------------------------------------------------- # # Modifier groups + modifiers # # --------------------------------------------------------------------- # diff --git a/static/js/menu.js b/static/js/menu.js index ed645c9..9c747e4 100644 --- a/static/js/menu.js +++ b/static/js/menu.js @@ -33,7 +33,7 @@ window.app = Vue.createApp({ }, filteredItems() { 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() { return this.categories.map((c) => ({label: c.name, value: c.id})) @@ -48,8 +48,7 @@ window.app = Vue.createApp({ _blankItem() { return { restaurant_id: '', - category_id: null, - subcategory_id: null, + node_id: null, name: '', description: '', price: 0, @@ -123,8 +122,8 @@ window.app = Vue.createApp({ const item = existing ? {...existing} : {...this._blankItem(), restaurant_id: this.restaurant.id} - if (!item.category_id && this.selectedCategoryId) { - item.category_id = this.selectedCategoryId + if (!item.node_id && this.selectedCategoryId) { + item.node_id = this.selectedCategoryId } this.itemDialog.data = item this.itemDialog.imagesText = (item.images || []).join(', ') diff --git a/templates/restaurant/menu.html b/templates/restaurant/menu.html index 9a8b067..eb7a476 100644 --- a/templates/restaurant/menu.html +++ b/templates/restaurant/menu.html @@ -182,7 +182,7 @@ dict: w.dict() for w in await get_availability_windows(item.id) ] enriched_items.append(item_dict) - if item.category_id and item.category_id in cat_map: - cat_map[item.category_id]["items"].append(item_dict) + # Backed by menu_nodes now: an item's node_id may be a depth-0 + # 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 { "restaurant": restaurant.dict(),