feat(http): /menu_nodes endpoints + tree-shaped /menu response

views_api.py:
  - New endpoints (admin-key-gated, ownership-checked):
    * GET    /api/v1/restaurants/{id}/menu_nodes  flat list of nodes
    * GET    /api/v1/menu_nodes/{id}              single node
    * POST   /api/v1/menu_nodes                   create
    * PUT    /api/v1/menu_nodes/{id}              edit name / desc /
                                                  sort_order /
                                                  image_url
    * PUT    /api/v1/menu_nodes/{id}/move         body
                                                  {new_parent_id}
    * DELETE /api/v1/menu_nodes/{id}?cascade=true|false
  - ValueError from CRUD (depth, cycle, has-children-without-cascade)
    surfaces as 400 (creates / moves) or 409 (delete blocked).
  - GET /api/v1/restaurants/{id}/menu now returns three views in
    one round trip:
      tree:       hydrated tree (root nodes -> children + items)
      items:      flat enriched list (modifiers + availability)
      categories: transitional projection of depth-0 nodes with
                  their immediate items, in the legacy shape — kept
                  for one commit's lifetime so the existing CMS
                  keeps rendering. Drops in commit 3.

static/js/api.js:
  - listMenuNodes / getMenuNode / createMenuNode / updateMenuNode /
    moveMenuNode / deleteMenuNode added.
  - Old category/subcategory methods marked transitional in
    comments (drop in commit 3).

No JS / template churn — the CMS still reads from menu.categories
which is now produced from menu_nodes via the synthetic projection.
Commit 4 replaces the CMS with q-tree.
This commit is contained in:
Padreug 2026-05-02 09:05:39 +02:00
commit ab87ddb2da
2 changed files with 202 additions and 27 deletions

View file

@ -23,17 +23,29 @@
deleteRestaurant: (key, id) =>
call(key, 'DELETE', `/restaurants/${id}`),
// Categories
// Categories (transitional shim, drop in commit 3)
listCategories: (id) => call(null, 'GET', `/restaurants/${id}/categories`),
createCategory: (key, data) => call(key, 'POST', '/categories', data),
deleteCategory: (key, id) => call(key, 'DELETE', `/categories/${id}`),
// Subcategories
// Subcategories (transitional shim, drop in commit 3)
listSubcategories: (catId) =>
call(null, 'GET', `/categories/${catId}/subcategories`),
createSubcategory: (key, data) => call(key, 'POST', '/subcategories', data),
deleteSubcategory: (key, id) => call(key, 'DELETE', `/subcategories/${id}`),
// Menu nodes (the tree)
listMenuNodes: (restaurantId) =>
call(null, 'GET', `/restaurants/${restaurantId}/menu_nodes`),
getMenuNode: (id) => call(null, 'GET', `/menu_nodes/${id}`),
createMenuNode: (key, data) => call(key, 'POST', '/menu_nodes', data),
updateMenuNode: (key, id, data) =>
call(key, 'PUT', `/menu_nodes/${id}`, data),
moveMenuNode: (key, id, newParentId) =>
call(key, 'PUT', `/menu_nodes/${id}/move`, {new_parent_id: newParentId}),
deleteMenuNode: (key, id, cascade = false) =>
call(key, 'DELETE', `/menu_nodes/${id}?cascade=${cascade ? 'true' : 'false'}`),
// Menu items
getMenu: (id) => call(null, 'GET', `/restaurants/${id}/menu`),
getMenuItem: (id) => call(null, 'GET', `/menu_items/${id}`),

View file

@ -18,6 +18,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, Query
from loguru import logger
from pydantic import BaseModel
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
@ -35,6 +36,7 @@ from .crud import (
create_availability_window,
create_category,
create_menu_item,
create_menu_node,
create_modifier,
create_modifier_group,
create_restaurant,
@ -42,6 +44,7 @@ from .crud import (
delete_availability_window,
delete_category,
delete_menu_item,
delete_menu_node,
delete_modifier,
delete_modifier_group,
delete_restaurant,
@ -51,6 +54,9 @@ from .crud import (
get_category,
get_menu_item,
get_menu_items,
get_menu_node,
get_menu_nodes,
get_menu_tree,
get_modifier_groups,
get_modifiers,
get_order,
@ -62,7 +68,9 @@ from .crud import (
get_restaurants,
get_settings,
get_subcategories,
move_menu_node,
update_menu_item,
update_menu_node,
update_print_job,
update_restaurant,
update_settings,
@ -73,12 +81,15 @@ from .models import (
CreateAvailabilityWindow,
CreateCategory,
CreateMenuItem,
CreateMenuNode,
CreateModifier,
CreateModifierGroup,
CreateOrder,
CreateRestaurant,
CreateSubcategory,
MenuItem,
MenuNode,
MenuNodeRow,
Modifier,
ModifierGroup,
Order,
@ -287,6 +298,129 @@ async def api_delete_restaurant(
# --------------------------------------------------------------------- #
# --------------------------------------------------------------------- #
# Menu nodes (the tree) #
# --------------------------------------------------------------------- #
class _MoveNodeRequest(BaseModel):
"""Body for PUT /menu_nodes/{id}/move."""
new_parent_id: Optional[str] = None
@restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}/menu_nodes")
async def api_list_menu_nodes(restaurant_id: str) -> list[MenuNodeRow]:
"""Flat list of all nodes for a restaurant — useful for parent
pickers and admin tooling. The hydrated tree is on
`/api/v1/restaurants/{id}/menu`."""
return await get_menu_nodes(restaurant_id)
@restaurant_api_router.get("/api/v1/menu_nodes/{node_id}")
async def api_get_menu_node(node_id: str) -> MenuNodeRow:
node = await get_menu_node(node_id)
if not node:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Node not found."
)
return node
@restaurant_api_router.post("/api/v1/menu_nodes")
async def api_create_menu_node(
data: CreateMenuNode,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> MenuNodeRow:
restaurant = await get_restaurant(data.restaurant_id)
if not restaurant:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found."
)
_require_owner(restaurant, wallet)
try:
node = await create_menu_node(data)
except ValueError as ve:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(ve)
) from ve
return MenuNodeRow(**node.dict(exclude={"children", "items"}))
@restaurant_api_router.put("/api/v1/menu_nodes/{node_id}")
async def api_update_menu_node(
node_id: str,
data: CreateMenuNode,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> MenuNodeRow:
"""Update editable fields (name, description, sort_order, image_url).
Tree position changes go through PUT /menu_nodes/{id}/move."""
node = await get_menu_node(node_id)
if not node:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Node not found."
)
restaurant = await get_restaurant(node.restaurant_id)
if restaurant:
_require_owner(restaurant, wallet)
node.name = data.name
node.description = data.description
node.sort_order = data.sort_order
node.image_url = data.image_url
return await update_menu_node(node)
@restaurant_api_router.put("/api/v1/menu_nodes/{node_id}/move")
async def api_move_menu_node(
node_id: str,
body: _MoveNodeRequest,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> MenuNodeRow:
node = await get_menu_node(node_id)
if not node:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Node not found."
)
restaurant = await get_restaurant(node.restaurant_id)
if restaurant:
_require_owner(restaurant, wallet)
try:
return await move_menu_node(node_id, body.new_parent_id)
except ValueError as ve:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(ve)
) from ve
@restaurant_api_router.delete("/api/v1/menu_nodes/{node_id}")
async def api_delete_menu_node(
node_id: str,
cascade: bool = Query(default=False),
wallet: WalletTypeInfo = Depends(require_admin_key),
):
node = await get_menu_node(node_id)
if not node:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Node not found."
)
restaurant = await get_restaurant(node.restaurant_id)
if restaurant:
_require_owner(restaurant, wallet)
try:
await delete_menu_node(node_id, cascade=cascade)
except ValueError as ve:
# 409 reads more naturally than 400 for "blocked by children/items".
raise HTTPException(
status_code=HTTPStatus.CONFLICT, detail=str(ve)
) from ve
return "", HTTPStatus.NO_CONTENT
# --------------------------------------------------------------------- #
# Categories / subcategories — transitional shim (drop in commit 3) #
# --------------------------------------------------------------------- #
@restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}/categories")
async def api_list_categories(restaurant_id: str) -> list[Category]:
return await get_categories(restaurant_id)
@ -361,11 +495,20 @@ async def api_delete_subcategory(
@restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}/menu")
async def api_get_menu(restaurant_id: str) -> dict:
"""
Public composite endpoint: returns the full menu tree (categories,
subcategories, items, modifier groups, modifiers, availability) for
a restaurant in one round trip.
Public composite endpoint: returns the menu in three shapes in one
round trip.
The webapp uses this once at load time, then trusts Nostr events for
* ``tree`` the full hydrated tree (root nodes with
nested children + items, depth, path).
* ``items`` flat enriched list (modifier groups, modifier
options, availability windows attached);
useful for search / filter.
* ``categories`` depth-0 nodes only, with their direct items.
A transitional projection so the existing
CMS keeps rendering until commit 4 swaps it
for q-tree. Drops in commit 3.
The webapp loads this once and then trusts Nostr events for
incremental updates.
"""
restaurant = await get_restaurant(restaurant_id)
@ -374,18 +517,11 @@ async def api_get_menu(restaurant_id: str) -> dict:
status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found."
)
categories = await get_categories(restaurant_id)
# Hydrated tree — nodes with children + items already attached.
tree = await get_menu_tree(restaurant_id)
# Flat enriched items list (used by search-style consumers).
items = await get_menu_items(restaurant_id)
cat_map: dict[str, dict] = {}
for cat in categories:
cat_dict = cat.dict()
cat_dict["subcategories"] = [
s.dict() for s in await get_subcategories(cat.id)
]
cat_dict["items"] = []
cat_map[cat.id] = cat_dict
enriched_items: list[dict] = []
for item in items:
item_dict = item.dict()
@ -398,18 +534,45 @@ async def api_get_menu(restaurant_id: str) -> dict:
w.dict() for w in await get_availability_windows(item.id)
]
enriched_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)
# Synthetic transitional "categories" projection: depth-0 nodes
# plus their immediate items, mapped to the legacy shape the CMS
# still consumes. Removed in commit 3.
items_by_node: dict[str, list[dict]] = {}
for it in enriched_items:
nid = it.get("node_id")
if nid:
items_by_node.setdefault(nid, []).append(it)
legacy_categories: list[dict] = []
for root in tree:
cat_dict = {
"id": root.id,
"restaurant_id": root.restaurant_id,
"name": root.name,
"description": root.description,
"sort_order": root.sort_order,
"image_url": root.image_url,
"time": root.time,
"subcategories": [
{
"id": child.id,
"category_id": root.id,
"name": child.name,
"sort_order": child.sort_order,
"time": child.time,
}
for child in root.children
],
"items": items_by_node.get(root.id, []),
}
legacy_categories.append(cat_dict)
return {
"restaurant": restaurant.dict(),
"categories": list(cat_map.values()),
"items": enriched_items, # flat list; useful for search
"tree": [t.dict() for t in tree],
"items": enriched_items,
"categories": legacy_categories, # transitional, drop in commit 3
}