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
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):
|
||||
|
|
@ -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 #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue