Prerequisite for the customer webapp module (aiolabs/webapp, branch feat/restaurant-bundle): the webapp's /r/:slug route needs to resolve a slug to a Restaurant payload without an admin key. crud.get_restaurant_by_slug already exists (used by the server- rendered CMS routes in views.py); just expose it as a public REST endpoint. Mirrors api_get_restaurant by id and is declared before the bare-id route so the static prefix wins FastAPI's path match. Verified live against seeded 'Big Jay's Bustaurant': GET /restaurant/api/v1/restaurants/by-slug/big-jays-bustaurant -> 200 with the Restaurant payload.
858 lines
28 KiB
Python
858 lines
28 KiB
Python
"""
|
|
REST API for the restaurant extension.
|
|
|
|
Two audiences:
|
|
* **CMS (restaurant owner)** — write-side endpoints, gated by
|
|
require_admin_key. Restaurants, categories, menu items, modifier
|
|
groups + modifiers, availability windows, settings.
|
|
* **Customer (webapp)** — read-side endpoints (public menu) and
|
|
order placement (no auth, customer pubkey optional).
|
|
|
|
All write endpoints fan out to nostr_publisher when nostr is enabled
|
|
in settings, so menu updates propagate to subscribed clients in
|
|
real time.
|
|
"""
|
|
|
|
from http import HTTPStatus
|
|
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
|
|
from lnbits.core.crud.users import get_account
|
|
from lnbits.core.crud.wallets import get_wallet
|
|
from lnbits.core.models import Account, WalletTypeInfo
|
|
from lnbits.decorators import (
|
|
check_admin,
|
|
check_user_exists,
|
|
require_admin_key,
|
|
require_invoice_key,
|
|
)
|
|
|
|
from .crud import (
|
|
create_availability_window,
|
|
create_menu_item,
|
|
create_menu_node,
|
|
create_modifier,
|
|
create_modifier_group,
|
|
create_restaurant,
|
|
delete_availability_window,
|
|
delete_menu_item,
|
|
delete_menu_node,
|
|
delete_modifier,
|
|
delete_modifier_group,
|
|
delete_restaurant,
|
|
get_availability_windows,
|
|
get_menu_item,
|
|
get_menu_items,
|
|
get_menu_node,
|
|
get_menu_nodes,
|
|
get_menu_tree,
|
|
get_modifier_groups,
|
|
get_modifiers,
|
|
get_order,
|
|
get_order_items,
|
|
get_orders,
|
|
get_print_job,
|
|
get_print_jobs,
|
|
get_restaurant,
|
|
get_restaurant_by_slug,
|
|
get_restaurants,
|
|
get_settings,
|
|
move_menu_node,
|
|
update_menu_item,
|
|
update_menu_node,
|
|
update_print_job,
|
|
update_restaurant,
|
|
update_settings,
|
|
)
|
|
from .models import (
|
|
AvailabilityWindow,
|
|
CreateAvailabilityWindow,
|
|
CreateMenuItem,
|
|
CreateMenuNode,
|
|
CreateModifier,
|
|
CreateModifierGroup,
|
|
CreateOrder,
|
|
CreateRestaurant,
|
|
MenuItem,
|
|
MenuNodeRow,
|
|
Modifier,
|
|
ModifierGroup,
|
|
Order,
|
|
OrderInvoice,
|
|
OrderWithItems,
|
|
Restaurant,
|
|
RestaurantSettings,
|
|
)
|
|
from .nostr_publisher import (
|
|
build_delete_event,
|
|
build_menu_item_event,
|
|
build_restaurant_metadata_event,
|
|
publish_event,
|
|
)
|
|
from .services import (
|
|
place_order,
|
|
quote_balance_required,
|
|
transition_order,
|
|
)
|
|
|
|
restaurant_api_router = APIRouter()
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Helpers #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
async def _resolve_signing_keypair(
|
|
restaurant: Restaurant,
|
|
) -> Optional[tuple[str, str]]:
|
|
"""
|
|
Resolve the (pubkey, prvkey) pair for signing Nostr events on behalf
|
|
of a restaurant.
|
|
|
|
Order of precedence:
|
|
1. restaurant.nostr_pubkey is set → use a per-restaurant key.
|
|
(Storage of the corresponding prvkey is intentionally out of
|
|
scope here; for now this branch is a no-op until we ship a
|
|
secret-management approach. Returns None.)
|
|
2. Otherwise → fall back to the LNbits Account keypair of the
|
|
wallet owner.
|
|
"""
|
|
if restaurant.nostr_pubkey:
|
|
# TODO: per-restaurant secret key vault.
|
|
return None
|
|
wallet_obj = await get_wallet(restaurant.wallet)
|
|
if not wallet_obj:
|
|
return None
|
|
account = await get_account(wallet_obj.user)
|
|
if not account or not account.pubkey or not account.prvkey:
|
|
return None
|
|
return account.pubkey, account.prvkey
|
|
|
|
|
|
async def _publish_restaurant(restaurant: Restaurant) -> None:
|
|
settings = await get_settings()
|
|
if not settings.nostr_publish_enabled:
|
|
return
|
|
keypair = await _resolve_signing_keypair(restaurant)
|
|
if not keypair:
|
|
return
|
|
pubkey, prvkey = keypair
|
|
|
|
from . import nostr_client
|
|
|
|
event = build_restaurant_metadata_event(restaurant, pubkey)
|
|
published = await publish_event(nostr_client, event, prvkey)
|
|
if published:
|
|
restaurant.nostr_event_id = published.id
|
|
restaurant.nostr_event_created_at = published.created_at
|
|
if not restaurant.nostr_pubkey:
|
|
# Echo back the resolved pubkey so the row carries it for
|
|
# discovery (e.g. webapp follows this pubkey).
|
|
restaurant.nostr_pubkey = pubkey
|
|
await update_restaurant(restaurant)
|
|
|
|
|
|
async def _ancestor_names_for_node(node_id: Optional[str]) -> tuple[str, ...]:
|
|
"""
|
|
Walk the materialized `path` of a node, returning the chain of
|
|
node names root-first (including the leaf node itself).
|
|
Returns () if node_id is None or path can't be resolved.
|
|
"""
|
|
if not node_id:
|
|
return ()
|
|
leaf = await get_menu_node(node_id)
|
|
if not leaf:
|
|
return ()
|
|
ancestor_ids = leaf.path.split("/")
|
|
if not ancestor_ids:
|
|
return ()
|
|
# One round-trip per node — at most MAX_MENU_DEPTH+1 calls (≤4).
|
|
names: list[str] = []
|
|
for nid in ancestor_ids:
|
|
n = await get_menu_node(nid)
|
|
if n:
|
|
names.append(n.name)
|
|
return tuple(names)
|
|
|
|
|
|
async def _publish_menu_item(item: MenuItem) -> None:
|
|
settings = await get_settings()
|
|
if not settings.nostr_publish_enabled:
|
|
return
|
|
restaurant = await get_restaurant(item.restaurant_id)
|
|
if not restaurant:
|
|
return
|
|
keypair = await _resolve_signing_keypair(restaurant)
|
|
if not keypair:
|
|
return
|
|
pubkey, prvkey = keypair
|
|
|
|
from . import nostr_client
|
|
|
|
ancestors = await _ancestor_names_for_node(item.node_id)
|
|
event = build_menu_item_event(
|
|
item, restaurant, pubkey, ancestor_names=ancestors
|
|
)
|
|
published = await publish_event(nostr_client, event, prvkey)
|
|
if published:
|
|
item.nostr_event_id = published.id
|
|
item.nostr_event_created_at = published.created_at
|
|
await update_menu_item(item)
|
|
|
|
|
|
async def _publish_menu_item_delete(item: MenuItem) -> None:
|
|
settings = await get_settings()
|
|
if not settings.nostr_publish_enabled or not item.nostr_event_id:
|
|
return
|
|
restaurant = await get_restaurant(item.restaurant_id)
|
|
if not restaurant:
|
|
return
|
|
keypair = await _resolve_signing_keypair(restaurant)
|
|
if not keypair:
|
|
return
|
|
pubkey, prvkey = keypair
|
|
|
|
from . import nostr_client
|
|
|
|
event = build_delete_event(30402, item.id, pubkey, "Menu item removed")
|
|
await publish_event(nostr_client, event, prvkey)
|
|
|
|
|
|
def _require_owner(restaurant: Restaurant, wallet: WalletTypeInfo) -> None:
|
|
if restaurant.wallet != wallet.wallet.id:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN,
|
|
detail="Not your restaurant.",
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Restaurants #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
@restaurant_api_router.get("/api/v1/restaurants")
|
|
async def api_list_restaurants(
|
|
all_wallets: bool = Query(False),
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
) -> list[Restaurant]:
|
|
wallet_ids = [wallet.wallet.id]
|
|
if all_wallets:
|
|
user = await get_user(wallet.wallet.user)
|
|
wallet_ids = user.wallet_ids if user else []
|
|
return await get_restaurants(wallet_ids)
|
|
|
|
|
|
@restaurant_api_router.get("/api/v1/restaurants/by-slug/{slug}")
|
|
async def api_get_restaurant_by_slug(slug: str) -> Restaurant:
|
|
"""Public — used by the customer webapp to resolve a URL slug
|
|
(e.g. /r/big-jays-bustaurant) to a restaurant. Mirrors
|
|
api_get_restaurant; declared *before* the bare-id route so the
|
|
static prefix wins the path match in FastAPI's router."""
|
|
restaurant = await get_restaurant_by_slug(slug)
|
|
if not restaurant:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found."
|
|
)
|
|
return restaurant
|
|
|
|
|
|
@restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}")
|
|
async def api_get_restaurant(restaurant_id: str) -> Restaurant:
|
|
"""Public — used by the webapp to fetch profile metadata."""
|
|
restaurant = await get_restaurant(restaurant_id)
|
|
if not restaurant:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found."
|
|
)
|
|
return restaurant
|
|
|
|
|
|
@restaurant_api_router.post("/api/v1/restaurants")
|
|
async def api_create_restaurant(
|
|
data: CreateRestaurant,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> Restaurant:
|
|
if not data.wallet:
|
|
data.wallet = wallet.wallet.id
|
|
restaurant = await create_restaurant(wallet=data.wallet, data=data)
|
|
await _publish_restaurant(restaurant)
|
|
return restaurant
|
|
|
|
|
|
@restaurant_api_router.put("/api/v1/restaurants/{restaurant_id}")
|
|
async def api_update_restaurant(
|
|
restaurant_id: str,
|
|
data: CreateRestaurant,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> Restaurant:
|
|
restaurant = await get_restaurant(restaurant_id)
|
|
if not restaurant:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found."
|
|
)
|
|
_require_owner(restaurant, wallet)
|
|
for k, v in data.dict().items():
|
|
if k == "wallet":
|
|
continue # never reassign wallet via update
|
|
setattr(restaurant, k, v)
|
|
restaurant = await update_restaurant(restaurant)
|
|
await _publish_restaurant(restaurant)
|
|
return restaurant
|
|
|
|
|
|
@restaurant_api_router.delete("/api/v1/restaurants/{restaurant_id}")
|
|
async def api_delete_restaurant(
|
|
restaurant_id: str,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
):
|
|
restaurant = await get_restaurant(restaurant_id)
|
|
if not restaurant:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found."
|
|
)
|
|
_require_owner(restaurant, wallet)
|
|
await delete_restaurant(restaurant_id)
|
|
return "", HTTPStatus.NO_CONTENT
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Categories + subcategories #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# 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.
|
|
|
|
A name change triggers re-publishing every item in the subtree
|
|
so their NIP-99 listings carry the new ancestor `t` tag. ≤50
|
|
items per restaurant in practice — eager re-publish is cheap.
|
|
"""
|
|
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)
|
|
name_changed = node.name != data.name
|
|
node.name = data.name
|
|
node.description = data.description
|
|
node.sort_order = data.sort_order
|
|
node.image_url = data.image_url
|
|
updated = await update_menu_node(node)
|
|
|
|
if name_changed:
|
|
await _republish_subtree_items(node_id)
|
|
|
|
return updated
|
|
|
|
|
|
async def _republish_subtree_items(node_id: str) -> None:
|
|
"""Re-publish every menu item under the given node's subtree,
|
|
so its kind-30402 events carry the updated ancestor `t` tag set."""
|
|
from .crud import db
|
|
|
|
rows = await db.fetchall(
|
|
"""
|
|
SELECT mi.* FROM restaurant.menu_items mi
|
|
JOIN restaurant.menu_nodes mn ON mn.id = mi.node_id
|
|
WHERE mn.path = (SELECT path FROM restaurant.menu_nodes WHERE id = :nid)
|
|
OR mn.path LIKE (SELECT path FROM restaurant.menu_nodes WHERE id = :nid) || '/%'
|
|
""",
|
|
{"nid": node_id},
|
|
model=MenuItem,
|
|
)
|
|
for it in rows:
|
|
try:
|
|
await _publish_menu_item(it)
|
|
except Exception as ex:
|
|
logger.warning(
|
|
f"[RESTAURANT] re-publish failed for item {it.id[:12]}..: {ex}"
|
|
)
|
|
|
|
|
|
@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) #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Menu items #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
@restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}/menu")
|
|
async def api_get_menu(restaurant_id: str) -> dict:
|
|
"""
|
|
Public composite endpoint: returns the menu in two shapes in one
|
|
round trip.
|
|
|
|
* ``tree`` — the full hydrated tree (root nodes with nested
|
|
children + items, depth, path). Each item is the
|
|
bare MenuItem (no modifier hydration).
|
|
* ``items`` — flat enriched list (modifier groups, modifier
|
|
options, availability windows attached); useful
|
|
for search / filter and for hydrating the items
|
|
referenced from ``tree``.
|
|
|
|
The webapp loads this once and then trusts Nostr events for
|
|
incremental updates.
|
|
"""
|
|
restaurant = await get_restaurant(restaurant_id)
|
|
if not restaurant:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found."
|
|
)
|
|
|
|
# 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)
|
|
enriched_items: list[dict] = []
|
|
for item in items:
|
|
item_dict = item.dict()
|
|
item_dict["modifier_groups"] = []
|
|
for grp in await get_modifier_groups(item.id):
|
|
grp_dict = grp.dict()
|
|
grp_dict["modifiers"] = [m.dict() for m in await get_modifiers(grp.id)]
|
|
item_dict["modifier_groups"].append(grp_dict)
|
|
item_dict["availability_windows"] = [
|
|
w.dict() for w in await get_availability_windows(item.id)
|
|
]
|
|
enriched_items.append(item_dict)
|
|
|
|
return {
|
|
"restaurant": restaurant.dict(),
|
|
"tree": [t.dict() for t in tree],
|
|
"items": enriched_items,
|
|
}
|
|
|
|
|
|
@restaurant_api_router.get("/api/v1/menu_items/{item_id}")
|
|
async def api_get_menu_item(item_id: str) -> MenuItem:
|
|
item = await get_menu_item(item_id)
|
|
if not item:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Menu item not found."
|
|
)
|
|
return item
|
|
|
|
|
|
@restaurant_api_router.post("/api/v1/menu_items")
|
|
async def api_create_menu_item(
|
|
data: CreateMenuItem,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> MenuItem:
|
|
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)
|
|
item = await create_menu_item(data)
|
|
await _publish_menu_item(item)
|
|
return item
|
|
|
|
|
|
@restaurant_api_router.put("/api/v1/menu_items/{item_id}")
|
|
async def api_update_menu_item(
|
|
item_id: str,
|
|
data: CreateMenuItem,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> MenuItem:
|
|
item = await get_menu_item(item_id)
|
|
if not item:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Menu item not found."
|
|
)
|
|
restaurant = await get_restaurant(item.restaurant_id)
|
|
if restaurant:
|
|
_require_owner(restaurant, wallet)
|
|
for k, v in data.dict().items():
|
|
if k == "restaurant_id":
|
|
continue # immutable
|
|
setattr(item, k, v)
|
|
item = await update_menu_item(item)
|
|
await _publish_menu_item(item) # re-publish (kind 30402 is replaceable)
|
|
return item
|
|
|
|
|
|
@restaurant_api_router.delete("/api/v1/menu_items/{item_id}")
|
|
async def api_delete_menu_item(
|
|
item_id: str,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
):
|
|
item = await get_menu_item(item_id)
|
|
if not item:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Menu item not found."
|
|
)
|
|
restaurant = await get_restaurant(item.restaurant_id)
|
|
if restaurant:
|
|
_require_owner(restaurant, wallet)
|
|
await _publish_menu_item_delete(item)
|
|
await delete_menu_item(item_id)
|
|
return "", HTTPStatus.NO_CONTENT
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Modifier groups + modifiers #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
@restaurant_api_router.get("/api/v1/menu_items/{item_id}/modifier_groups")
|
|
async def api_list_modifier_groups(item_id: str) -> list[ModifierGroup]:
|
|
return await get_modifier_groups(item_id)
|
|
|
|
|
|
@restaurant_api_router.post("/api/v1/modifier_groups")
|
|
async def api_create_modifier_group(
|
|
data: CreateModifierGroup,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> ModifierGroup:
|
|
return await create_modifier_group(data)
|
|
|
|
|
|
@restaurant_api_router.delete("/api/v1/modifier_groups/{group_id}")
|
|
async def api_delete_modifier_group(
|
|
group_id: str,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
):
|
|
await delete_modifier_group(group_id)
|
|
return "", HTTPStatus.NO_CONTENT
|
|
|
|
|
|
@restaurant_api_router.get("/api/v1/modifier_groups/{group_id}/modifiers")
|
|
async def api_list_modifiers(group_id: str) -> list[Modifier]:
|
|
return await get_modifiers(group_id)
|
|
|
|
|
|
@restaurant_api_router.post("/api/v1/modifiers")
|
|
async def api_create_modifier(
|
|
data: CreateModifier,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> Modifier:
|
|
return await create_modifier(data)
|
|
|
|
|
|
@restaurant_api_router.delete("/api/v1/modifiers/{modifier_id}")
|
|
async def api_delete_modifier(
|
|
modifier_id: str,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
):
|
|
await delete_modifier(modifier_id)
|
|
return "", HTTPStatus.NO_CONTENT
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Availability windows #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
@restaurant_api_router.get(
|
|
"/api/v1/menu_items/{item_id}/availability_windows"
|
|
)
|
|
async def api_list_availability_windows(item_id: str) -> list[AvailabilityWindow]:
|
|
return await get_availability_windows(item_id)
|
|
|
|
|
|
@restaurant_api_router.post("/api/v1/availability_windows")
|
|
async def api_create_availability_window(
|
|
data: CreateAvailabilityWindow,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> AvailabilityWindow:
|
|
return await create_availability_window(data)
|
|
|
|
|
|
@restaurant_api_router.delete("/api/v1/availability_windows/{window_id}")
|
|
async def api_delete_availability_window(
|
|
window_id: str,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
):
|
|
await delete_availability_window(window_id)
|
|
return "", HTTPStatus.NO_CONTENT
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Orders (customer-facing + KDS) #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
@restaurant_api_router.post("/api/v1/orders/quote")
|
|
async def api_quote(items: list[dict]) -> dict:
|
|
"""
|
|
Customer pre-flight: returns the total msat needed to pay this set
|
|
of line items. The webapp calls /quote *before* posting one order
|
|
per restaurant, so a customer with insufficient funds gets a single
|
|
clear error rather than partially paid orders.
|
|
"""
|
|
from .models import CreateOrderItem, SelectedModifier
|
|
|
|
parsed = [
|
|
CreateOrderItem(
|
|
menu_item_id=i["menu_item_id"],
|
|
quantity=int(i.get("quantity", 1)),
|
|
selected_modifiers=[
|
|
SelectedModifier(**m) for m in i.get("selected_modifiers", [])
|
|
],
|
|
note=i.get("note"),
|
|
)
|
|
for i in items
|
|
]
|
|
return {"required_msat": await quote_balance_required(parsed)}
|
|
|
|
|
|
@restaurant_api_router.post("/api/v1/orders")
|
|
async def api_create_order(data: CreateOrder) -> dict:
|
|
"""
|
|
Customer-facing — creates an order on a single restaurant and
|
|
returns the bolt11 to pay. The webapp posts N of these in parallel
|
|
(one per restaurant in the cart), having already pre-flighted with
|
|
/quote.
|
|
"""
|
|
try:
|
|
order, invoice = await place_order(data)
|
|
except ValueError as ve:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST, detail=str(ve)
|
|
) from ve
|
|
except Exception as ex:
|
|
logger.exception("[RESTAURANT] place_order failed")
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
|
) from ex
|
|
|
|
return {"order": order.dict(), "invoice": invoice.dict() if invoice else None}
|
|
|
|
|
|
@restaurant_api_router.get("/api/v1/orders/{order_id}")
|
|
async def api_get_order(order_id: str) -> OrderWithItems:
|
|
order = await get_order(order_id)
|
|
if not order:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Order not found."
|
|
)
|
|
items = await get_order_items(order_id)
|
|
return OrderWithItems(order=order, items=items)
|
|
|
|
|
|
@restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}/orders")
|
|
async def api_list_orders(
|
|
restaurant_id: str,
|
|
statuses: Optional[list[str]] = Query(default=None),
|
|
limit: int = Query(default=200, le=1000),
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
) -> list[Order]:
|
|
"""KDS / order-monitor data source. Owner-only."""
|
|
restaurant = await get_restaurant(restaurant_id)
|
|
if not restaurant:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found."
|
|
)
|
|
if restaurant.wallet != wallet.wallet.id:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN, detail="Not your restaurant."
|
|
)
|
|
return await get_orders(restaurant_id, statuses=statuses, limit=limit)
|
|
|
|
|
|
@restaurant_api_router.put("/api/v1/orders/{order_id}/status/{new_status}")
|
|
async def api_transition_order(
|
|
order_id: str,
|
|
new_status: str,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> Order:
|
|
order = await get_order(order_id)
|
|
if not order:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Order not found."
|
|
)
|
|
if order.wallet != wallet.wallet.id:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN, detail="Not your order."
|
|
)
|
|
try:
|
|
updated = await transition_order(order_id, new_status)
|
|
except ValueError as ve:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST, detail=str(ve)
|
|
) from ve
|
|
assert updated # not None — we just checked the order exists
|
|
return updated
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Print jobs #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
@restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}/print_jobs")
|
|
async def api_list_print_jobs(
|
|
restaurant_id: str,
|
|
status: Optional[str] = None,
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
):
|
|
restaurant = await get_restaurant(restaurant_id)
|
|
if not restaurant:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found."
|
|
)
|
|
if restaurant.wallet != wallet.wallet.id:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN, detail="Not your restaurant."
|
|
)
|
|
return await get_print_jobs(restaurant_id, status=status)
|
|
|
|
|
|
@restaurant_api_router.put("/api/v1/print_jobs/{job_id}/ack")
|
|
async def api_ack_print_job(
|
|
job_id: str,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
):
|
|
"""Called by printer-pi after a successful print to mark the job done."""
|
|
from datetime import datetime, timezone
|
|
|
|
job = await get_print_job(job_id)
|
|
if not job:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Print job not found."
|
|
)
|
|
restaurant = await get_restaurant(job.restaurant_id)
|
|
if not restaurant or restaurant.wallet != wallet.wallet.id:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN, detail="Not your print job."
|
|
)
|
|
job.status = "acknowledged"
|
|
job.acknowledged_at = datetime.now(timezone.utc)
|
|
await update_print_job(job)
|
|
return job
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Settings #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
@restaurant_api_router.get("/api/v1/settings")
|
|
async def api_get_settings(
|
|
admin: Account = Depends(check_admin),
|
|
) -> RestaurantSettings:
|
|
return await get_settings()
|
|
|
|
|
|
@restaurant_api_router.put("/api/v1/settings")
|
|
async def api_update_settings(
|
|
data: RestaurantSettings,
|
|
admin: Account = Depends(check_admin),
|
|
) -> RestaurantSettings:
|
|
return await update_settings(data)
|