diff --git a/static/js/api.js b/static/js/api.js index 2ae4e15..52cf7f6 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -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}`), diff --git a/views_api.py b/views_api.py index 69d0bd6..de6af8f 100644 --- a/views_api.py +++ b/views_api.py @@ -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 }