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:
parent
6272df1288
commit
ab87ddb2da
2 changed files with 202 additions and 27 deletions
|
|
@ -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}`),
|
||||||
|
|
|
||||||
211
views_api.py
211
views_api.py
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue