refactor(http): drop categories/subcategories shim

Remove the transitional layer added in commits 1+2:

models.py
  - Drop Category, Subcategory, CreateCategory, CreateSubcategory.

crud.py
  - Drop create_category / update_category / get_category /
    get_categories / delete_category and the subcategory variants
    along with the _node_row_to_category / _node_row_to_subcategory
    helpers. Tree state is owned exclusively by menu_node CRUD now.

views_api.py
  - Remove old endpoints:
      GET    /api/v1/restaurants/{id}/categories
      POST   /api/v1/categories
      DELETE /api/v1/categories/{id}
      GET    /api/v1/categories/{id}/subcategories
      POST   /api/v1/subcategories
      DELETE /api/v1/subcategories/{id}
    Hits return 404 now.
  - GET /api/v1/restaurants/{id}/menu loses the synthetic
    'categories' projection. Response is {restaurant, tree, items}.

static/js/api.js
  - Drop listCategories / createCategory / deleteCategory and the
    subcategory wrappers.

The CMS menu builder is broken between this commit and commit 4.
The plan acknowledged this trade-off: keeping commits revertible
beats the cost of an unshipped UI page rendering a stale empty
sidebar for one commit's lifetime.
This commit is contained in:
Padreug 2026-05-02 09:08:01 +02:00
commit b7fa1aec4a
4 changed files with 8 additions and 300 deletions

127
crud.py
View file

@ -21,15 +21,12 @@ from lnbits.helpers import urlsafe_short_hash
from .models import ( from .models import (
MAX_MENU_DEPTH, MAX_MENU_DEPTH,
AvailabilityWindow, AvailabilityWindow,
Category,
CreateAvailabilityWindow, CreateAvailabilityWindow,
CreateCategory,
CreateMenuItem, CreateMenuItem,
CreateMenuNode, CreateMenuNode,
CreateModifier, CreateModifier,
CreateModifierGroup, CreateModifierGroup,
CreateRestaurant, CreateRestaurant,
CreateSubcategory,
MenuItem, MenuItem,
MenuNode, MenuNode,
MenuNodeRow, MenuNodeRow,
@ -41,7 +38,6 @@ from .models import (
Restaurant, Restaurant,
RestaurantSettings, RestaurantSettings,
SelectedModifier, SelectedModifier,
Subcategory,
) )
db = Database("ext_restaurant") db = Database("ext_restaurant")
@ -397,129 +393,6 @@ async def delete_menu_node(node_id: str, cascade: bool = False) -> None:
) )
# --------------------------------------------------------------------- #
# Categories / subcategories — transitional shims (drop in commit 3) #
# --------------------------------------------------------------------- #
# These keep the old /categories and /subcategories REST endpoints
# working over the new menu_nodes table for one commit's lifetime.
# Drop entirely in the next commit once the new endpoints are live.
def _node_row_to_category(row: MenuNodeRow) -> Category:
return Category(
id=row.id,
restaurant_id=row.restaurant_id,
name=row.name,
description=row.description,
sort_order=row.sort_order,
image_url=row.image_url,
time=row.time,
)
def _node_row_to_subcategory(row: MenuNodeRow) -> Subcategory:
# Subcategory carries the parent category id, not its own restaurant.
return Subcategory(
id=row.id,
category_id=row.parent_id or "",
name=row.name,
sort_order=row.sort_order,
time=row.time,
)
async def create_category(data: CreateCategory) -> Category:
node = await create_menu_node(
CreateMenuNode(
restaurant_id=data.restaurant_id,
parent_id=None,
name=data.name,
description=data.description,
sort_order=data.sort_order,
image_url=data.image_url,
)
)
return _node_row_to_category(node)
async def update_category(category: Category) -> Category:
row = await get_menu_node(category.id)
if not row:
raise ValueError("Category not found")
row.name = category.name
row.description = category.description
row.sort_order = category.sort_order
row.image_url = category.image_url
await update_menu_node(row)
return category
async def get_category(category_id: str) -> Optional[Category]:
row = await get_menu_node(category_id)
if not row or row.depth != 0:
return None
return _node_row_to_category(row)
async def get_categories(restaurant_id: str) -> list[Category]:
rows = await db.fetchall(
"""
SELECT * FROM restaurant.menu_nodes
WHERE restaurant_id = :rid AND depth = 0
ORDER BY sort_order, time
""",
{"rid": restaurant_id},
model=MenuNodeRow,
)
return [_node_row_to_category(r) for r in rows]
async def delete_category(category_id: str) -> None:
await delete_menu_node(category_id, cascade=True)
async def create_subcategory(data: CreateSubcategory) -> Subcategory:
parent = await get_menu_node(data.category_id)
if not parent:
raise ValueError("Category not found")
node = await create_menu_node(
CreateMenuNode(
restaurant_id=parent.restaurant_id,
parent_id=parent.id,
name=data.name,
sort_order=data.sort_order,
)
)
return _node_row_to_subcategory(node)
async def update_subcategory(subcategory: Subcategory) -> Subcategory:
row = await get_menu_node(subcategory.id)
if not row:
raise ValueError("Subcategory not found")
row.name = subcategory.name
row.sort_order = subcategory.sort_order
await update_menu_node(row)
return subcategory
async def get_subcategories(category_id: str) -> list[Subcategory]:
rows = await db.fetchall(
"""
SELECT * FROM restaurant.menu_nodes
WHERE parent_id = :pid
ORDER BY sort_order, time
""",
{"pid": category_id},
model=MenuNodeRow,
)
return [_node_row_to_subcategory(r) for r in rows]
async def delete_subcategory(subcategory_id: str) -> None:
await delete_menu_node(subcategory_id, cascade=True)
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
# Menu items # # Menu items #
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #

View file

@ -213,46 +213,6 @@ class MenuNode(MenuNodeRow):
items: list["MenuItem"] = 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):
restaurant_id: str
name: str
description: Optional[str] = None
sort_order: int = 0
image_url: Optional[str] = None
class Category(BaseModel):
id: str
restaurant_id: str
name: str
description: Optional[str] = None
sort_order: int = 0
image_url: Optional[str] = None
time: datetime
class CreateSubcategory(BaseModel):
category_id: str
name: str
sort_order: int = 0
class Subcategory(BaseModel):
id: str
category_id: str
name: str
sort_order: int = 0
time: datetime
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
# Menu items # # Menu items #
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #

View file

@ -23,17 +23,6 @@
deleteRestaurant: (key, id) => deleteRestaurant: (key, id) =>
call(key, 'DELETE', `/restaurants/${id}`), call(key, 'DELETE', `/restaurants/${id}`),
// 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 (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) // Menu nodes (the tree)
listMenuNodes: (restaurantId) => listMenuNodes: (restaurantId) =>
call(null, 'GET', `/restaurants/${restaurantId}/menu_nodes`), call(null, 'GET', `/restaurants/${restaurantId}/menu_nodes`),

View file

@ -34,24 +34,18 @@ from lnbits.decorators import (
from .crud import ( from .crud import (
create_availability_window, create_availability_window,
create_category,
create_menu_item, create_menu_item,
create_menu_node, create_menu_node,
create_modifier, create_modifier,
create_modifier_group, create_modifier_group,
create_restaurant, create_restaurant,
create_subcategory,
delete_availability_window, delete_availability_window,
delete_category,
delete_menu_item, delete_menu_item,
delete_menu_node, delete_menu_node,
delete_modifier, delete_modifier,
delete_modifier_group, delete_modifier_group,
delete_restaurant, delete_restaurant,
delete_subcategory,
get_availability_windows, get_availability_windows,
get_categories,
get_category,
get_menu_item, get_menu_item,
get_menu_items, get_menu_items,
get_menu_node, get_menu_node,
@ -67,7 +61,6 @@ from .crud import (
get_restaurant, get_restaurant,
get_restaurants, get_restaurants,
get_settings, get_settings,
get_subcategories,
move_menu_node, move_menu_node,
update_menu_item, update_menu_item,
update_menu_node, update_menu_node,
@ -77,18 +70,14 @@ from .crud import (
) )
from .models import ( from .models import (
AvailabilityWindow, AvailabilityWindow,
Category,
CreateAvailabilityWindow, CreateAvailabilityWindow,
CreateCategory,
CreateMenuItem, CreateMenuItem,
CreateMenuNode, CreateMenuNode,
CreateModifier, CreateModifier,
CreateModifierGroup, CreateModifierGroup,
CreateOrder, CreateOrder,
CreateRestaurant, CreateRestaurant,
CreateSubcategory,
MenuItem, MenuItem,
MenuNode,
MenuNodeRow, MenuNodeRow,
Modifier, Modifier,
ModifierGroup, ModifierGroup,
@ -97,7 +86,6 @@ from .models import (
OrderWithItems, OrderWithItems,
Restaurant, Restaurant,
RestaurantSettings, RestaurantSettings,
Subcategory,
) )
from .nostr_publisher import ( from .nostr_publisher import (
build_delete_event, build_delete_event,
@ -421,72 +409,6 @@ async def api_delete_menu_node(
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
@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)
@restaurant_api_router.post("/api/v1/categories")
async def api_create_category(
data: CreateCategory,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Category:
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)
return await create_category(data)
@restaurant_api_router.delete("/api/v1/categories/{category_id}")
async def api_delete_category(
category_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
cat = await get_category(category_id)
if not cat:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Category not found."
)
restaurant = await get_restaurant(cat.restaurant_id)
if restaurant:
_require_owner(restaurant, wallet)
await delete_category(category_id)
return "", HTTPStatus.NO_CONTENT
@restaurant_api_router.get("/api/v1/categories/{category_id}/subcategories")
async def api_list_subcategories(category_id: str) -> list[Subcategory]:
return await get_subcategories(category_id)
@restaurant_api_router.post("/api/v1/subcategories")
async def api_create_subcategory(
data: CreateSubcategory,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Subcategory:
cat = await get_category(data.category_id)
if not cat:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Category not found."
)
restaurant = await get_restaurant(cat.restaurant_id)
if restaurant:
_require_owner(restaurant, wallet)
return await create_subcategory(data)
@restaurant_api_router.delete("/api/v1/subcategories/{subcategory_id}")
async def api_delete_subcategory(
subcategory_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
await delete_subcategory(subcategory_id)
return "", HTTPStatus.NO_CONTENT
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
# Menu items # # Menu items #
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
@ -495,18 +417,16 @@ 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 menu in three shapes in one Public composite endpoint: returns the menu in two shapes in one
round trip. round trip.
* ``tree`` the full hydrated tree (root nodes with * ``tree`` the full hydrated tree (root nodes with nested
nested children + items, depth, path). children + items, depth, path). Each item is the
bare MenuItem (no modifier hydration).
* ``items`` flat enriched list (modifier groups, modifier * ``items`` flat enriched list (modifier groups, modifier
options, availability windows attached); options, availability windows attached); useful
useful for search / filter. for search / filter and for hydrating the items
* ``categories`` depth-0 nodes only, with their direct items. referenced from ``tree``.
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 The webapp loads this once and then trusts Nostr events for
incremental updates. incremental updates.
@ -535,44 +455,10 @@ async def api_get_menu(restaurant_id: str) -> dict:
] ]
enriched_items.append(item_dict) enriched_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 { return {
"restaurant": restaurant.dict(), "restaurant": restaurant.dict(),
"tree": [t.dict() for t in tree], "tree": [t.dict() for t in tree],
"items": enriched_items, "items": enriched_items,
"categories": legacy_categories, # transitional, drop in commit 3
} }