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) => deleteRestaurant: (key, id) =>
call(key, 'DELETE', `/restaurants/${id}`), call(key, 'DELETE', `/restaurants/${id}`),
// Categories // Categories (transitional shim, drop in commit 3)
listCategories: (id) => call(null, 'GET', `/restaurants/${id}/categories`), listCategories: (id) => call(null, 'GET', `/restaurants/${id}/categories`),
createCategory: (key, data) => call(key, 'POST', '/categories', data), createCategory: (key, data) => call(key, 'POST', '/categories', data),
deleteCategory: (key, id) => call(key, 'DELETE', `/categories/${id}`), deleteCategory: (key, id) => call(key, 'DELETE', `/categories/${id}`),
// Subcategories // Subcategories (transitional shim, drop in commit 3)
listSubcategories: (catId) => listSubcategories: (catId) =>
call(null, 'GET', `/categories/${catId}/subcategories`), call(null, 'GET', `/categories/${catId}/subcategories`),
createSubcategory: (key, data) => call(key, 'POST', '/subcategories', data), createSubcategory: (key, data) => call(key, 'POST', '/subcategories', data),
deleteSubcategory: (key, id) => call(key, 'DELETE', `/subcategories/${id}`), 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 // Menu items
getMenu: (id) => call(null, 'GET', `/restaurants/${id}/menu`), getMenu: (id) => call(null, 'GET', `/restaurants/${id}/menu`),
getMenuItem: (id) => call(null, 'GET', `/menu_items/${id}`), 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 fastapi import APIRouter, Depends, Query
from loguru import logger from loguru import logger
from pydantic import BaseModel
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
@ -35,6 +36,7 @@ from .crud import (
create_availability_window, create_availability_window,
create_category, create_category,
create_menu_item, create_menu_item,
create_menu_node,
create_modifier, create_modifier,
create_modifier_group, create_modifier_group,
create_restaurant, create_restaurant,
@ -42,6 +44,7 @@ from .crud import (
delete_availability_window, delete_availability_window,
delete_category, delete_category,
delete_menu_item, delete_menu_item,
delete_menu_node,
delete_modifier, delete_modifier,
delete_modifier_group, delete_modifier_group,
delete_restaurant, delete_restaurant,
@ -51,6 +54,9 @@ from .crud import (
get_category, get_category,
get_menu_item, get_menu_item,
get_menu_items, get_menu_items,
get_menu_node,
get_menu_nodes,
get_menu_tree,
get_modifier_groups, get_modifier_groups,
get_modifiers, get_modifiers,
get_order, get_order,
@ -62,7 +68,9 @@ from .crud import (
get_restaurants, get_restaurants,
get_settings, get_settings,
get_subcategories, get_subcategories,
move_menu_node,
update_menu_item, update_menu_item,
update_menu_node,
update_print_job, update_print_job,
update_restaurant, update_restaurant,
update_settings, update_settings,
@ -73,12 +81,15 @@ from .models import (
CreateAvailabilityWindow, CreateAvailabilityWindow,
CreateCategory, CreateCategory,
CreateMenuItem, CreateMenuItem,
CreateMenuNode,
CreateModifier, CreateModifier,
CreateModifierGroup, CreateModifierGroup,
CreateOrder, CreateOrder,
CreateRestaurant, CreateRestaurant,
CreateSubcategory, CreateSubcategory,
MenuItem, MenuItem,
MenuNode,
MenuNodeRow,
Modifier, Modifier,
ModifierGroup, ModifierGroup,
Order, 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") @restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}/categories")
async def api_list_categories(restaurant_id: str) -> list[Category]: async def api_list_categories(restaurant_id: str) -> list[Category]:
return await get_categories(restaurant_id) 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") @restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}/menu")
async def api_get_menu(restaurant_id: str) -> dict: async def api_get_menu(restaurant_id: str) -> dict:
""" """
Public composite endpoint: returns the full menu tree (categories, Public composite endpoint: returns the menu in three shapes in one
subcategories, items, modifier groups, modifiers, availability) for round trip.
a restaurant 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. incremental updates.
""" """
restaurant = await get_restaurant(restaurant_id) 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." 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) 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] = [] enriched_items: list[dict] = []
for item in items: for item in items:
item_dict = item.dict() 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) w.dict() for w in await get_availability_windows(item.id)
] ]
enriched_items.append(item_dict) 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 # Synthetic transitional "categories" projection: depth-0 nodes
# endpoint we surface items only when they sit at depth-0 so # plus their immediate items, mapped to the legacy shape the CMS
# the existing CMS keeps rendering. Commit 2 replaces this # still consumes. Removed in commit 3.
# whole block with a real tree. items_by_node: dict[str, list[dict]] = {}
if item.node_id and item.node_id in cat_map: for it in enriched_items:
cat_map[item.node_id]["items"].append(item_dict) 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 { return {
"restaurant": restaurant.dict(), "restaurant": restaurant.dict(),
"categories": list(cat_map.values()), "tree": [t.dict() for t in tree],
"items": enriched_items, # flat list; useful for search "items": enriched_items,
"categories": legacy_categories, # transitional, drop in commit 3
} }