restaurant/views_api.py
Padreug d29d4dbec9 feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#11)
Closes aiolabs/restaurant#11. Pre-cascade prerequisite for
aiolabs/lnbits#17 (signer abstraction phase 1), which lands an m002
startup job that NULLs the legacy `accounts.prvkey` column. After
this migration, the restaurant extension reads no plaintext nsec and
works with any NostrSigner backend (LocalSigner / RemoteBunkerSigner
/ ClientSideOnlySigner).

## What changed

### views_api.py — _resolve_signing_keypair → _resolve_signer

Was: `_resolve_signing_keypair(restaurant)` returned `(pubkey, prvkey)`
read directly from `account.pubkey` / `account.prvkey` after walking
wallet → account.

Now: `_resolve_signer(restaurant)` returns `NostrSigner | None`.
Precedence order preserved:

  1. `restaurant.nostr_pubkey` set → per-restaurant identity. Still
     a no-op TODO returning None until a per-restaurant signer /
     vault ships (separate concern, future work).
  2. fallback → `resolve_for_wallet(restaurant.wallet)` (the DRY
     helper from aiolabs/lnbits#23 — wallet → account → signer →
     can_sign-check in one call, returns None on any soft-fail).

Three call sites updated (`_publish_restaurant`, `_publish_menu_item`,
`_publish_menu_item_delete`): each now passes the resolved `signer`
to `publish_event` instead of the keypair tuple, and uses
`signer.pubkey` for tag construction. The discovery-echo line in
`_publish_restaurant` (`restaurant.nostr_pubkey = signer.pubkey`)
preserves prior behavior.

Dropped now-unused imports: `get_account`, `get_wallet`.

### nostr_publisher.py — publish_event

Was: `publish_event(client, event, private_key_hex)` called a local
`sign_nostr_event` helper that signed in place via
`coincurve.PrivateKey.sign_schnorr`.

Now: `publish_event(client, event, signer: NostrSigner)` builds the
unsigned dict (`kind`/`created_at`/`tags`/`content`), hands it to
`await signer.sign_event(...)`, and writes `id`/`pubkey`/`sig` back
onto the local `NostrEvent` model before publishing. The signer
backend (LocalSigner / RemoteBunkerSigner) is transparent.

Removed the `sign_nostr_event` helper entirely — the signer
abstraction handles all signing now.

Dropped the `coincurve` import; no direct crypto in this extension.

### docs/nostr-layer.md — signing prose

Updated the Signing section to reflect the signer-abstraction model:
`resolve_for_wallet` resolves a `NostrSigner`, the extension no
longer touches `account.prvkey` or calls `coincurve.sign_schnorr`
directly. The per-restaurant-identity TODO is preserved.

## Acceptance

- [x] `_resolve_signing_keypair` replaced with `_resolve_signer` returning NostrSigner
- [x] `sign_nostr_event` helper removed (signer handles it internally)
- [x] `publish_event` accepts a NostrSigner instead of private_key_hex
- [x] all three call sites updated to pass the signer
- [x] re-grep `restaurant/`: zero `account.prvkey` references
- [x] coincurve import dropped
- [x] docs/nostr-layer.md updated in the same commit

Manual smoke testing + tag + catalog entry follow the migration
landing; will run against the regtest stack with lnbits on
`issue-18-phase-2.3` (which validates both LocalSigner and
RemoteBunkerSigner signing paths end-to-end).

## Cross-references

- aiolabs/restaurant#11 — issue this commit closes
- aiolabs/lnbits#17 — the cascading signer-abstraction PR
- aiolabs/lnbits#23 — the resolve_for_wallet helper this uses
- aiolabs/lnbits#21 — umbrella audit (5 affected extensions)
- aiolabs/events#23 / aiolabs/tasks#3 — sister migrations (already on signer-abstraction branches)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:26:41 +02:00

849 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.models import Account, WalletTypeInfo
from lnbits.core.signers import NostrSigner, resolve_for_wallet
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_signer(
restaurant: Restaurant,
) -> Optional[NostrSigner]:
"""
Resolve a `NostrSigner` for signing events on behalf of a restaurant.
Order of precedence:
1. restaurant.nostr_pubkey is set → per-restaurant identity.
(A per-restaurant signer/vault is out of scope here; until
that lands this branch is a no-op. Returns None.)
2. Otherwise → fall back to the wallet owner's signer via
`resolve_for_wallet` (wallet → account → signer with a
can_sign-check; soft-fails to None on missing wallet, missing
account, unclassified row, or ClientSideOnlySigner accounts
where the server has no signing authority).
"""
if restaurant.nostr_pubkey:
# TODO: per-restaurant signer / secret vault.
return None
return await resolve_for_wallet(restaurant.wallet)
async def _publish_restaurant(restaurant: Restaurant) -> None:
settings = await get_settings()
if not settings.nostr_publish_enabled:
return
signer = await _resolve_signer(restaurant)
if signer is None:
return
from . import nostr_client
event = build_restaurant_metadata_event(restaurant, signer.pubkey)
published = await publish_event(nostr_client, event, signer)
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 = signer.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
signer = await _resolve_signer(restaurant)
if signer is None:
return
from . import nostr_client
ancestors = await _ancestor_names_for_node(item.node_id)
event = build_menu_item_event(
item, restaurant, signer.pubkey, ancestor_names=ancestors
)
published = await publish_event(nostr_client, event, signer)
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
signer = await _resolve_signer(restaurant)
if signer is None:
return
from . import nostr_client
event = build_delete_event(30402, item.id, signer.pubkey, "Menu item removed")
await publish_event(nostr_client, event, signer)
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)