feat(http): CMS pages + REST API for owners and customers
views.py (Jinja CMS pages, /restaurant/...):
- / restaurant list / dashboard
- /{slug} menu builder
- /{slug}/orders order monitor
- /{slug}/kds kitchen display
- /{slug}/settings restaurant + Nostr settings
views_api.py (REST under /restaurant/api/v1/):
Owner write-side (require_admin_key, ownership-checked):
- restaurants CRUD (publishes kind 0 metadata to Nostr on
create/update; signs with restaurant.nostr_pubkey override
or LNbits Account fallback)
- categories + subcategories CRUD
- menu_items CRUD (publishes/replaces kind 30402 NIP-99
listings on create/update; sends kind 5 NIP-09 deletion on
delete)
- modifier_groups + modifiers CRUD
- availability_windows CRUD
- orders status transitions (PUT /api/v1/orders/{id}/status/{new})
- print_jobs/{id}/ack
- settings (admin-only)
Customer-facing (no auth, customer pubkey optional):
- GET /api/v1/restaurants/{id} profile
- GET /api/v1/restaurants/{id}/menu full menu tree
(categories +
subcategories +
items + modifiers +
availability) in
one round trip
- POST /api/v1/orders/quote pre-flight balance
check; webapp calls
this *before* opening
any per-restaurant
invoice
- POST /api/v1/orders place an order on
one restaurant,
returns bolt11
KDS / order monitor (require_invoice_key, ownership-checked):
- GET /api/v1/restaurants/{id}/orders
- GET /api/v1/restaurants/{id}/print_jobs
crud.py: added get_print_job(job_id) helper used by the ack endpoint.
This commit is contained in:
parent
b155548036
commit
c37b17d474
3 changed files with 871 additions and 0 deletions
728
views_api.py
Normal file
728
views_api.py
Normal file
|
|
@ -0,0 +1,728 @@
|
|||
"""
|
||||
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 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_category,
|
||||
create_menu_item,
|
||||
create_modifier,
|
||||
create_modifier_group,
|
||||
create_restaurant,
|
||||
create_subcategory,
|
||||
delete_availability_window,
|
||||
delete_category,
|
||||
delete_menu_item,
|
||||
delete_modifier,
|
||||
delete_modifier_group,
|
||||
delete_restaurant,
|
||||
delete_subcategory,
|
||||
get_availability_windows,
|
||||
get_categories,
|
||||
get_category,
|
||||
get_menu_item,
|
||||
get_menu_items,
|
||||
get_modifier_groups,
|
||||
get_modifiers,
|
||||
get_order,
|
||||
get_order_items,
|
||||
get_orders,
|
||||
get_print_job,
|
||||
get_print_jobs,
|
||||
get_restaurant,
|
||||
get_restaurants,
|
||||
get_settings,
|
||||
get_subcategories,
|
||||
update_menu_item,
|
||||
update_print_job,
|
||||
update_restaurant,
|
||||
update_settings,
|
||||
)
|
||||
from .models import (
|
||||
AvailabilityWindow,
|
||||
Category,
|
||||
CreateAvailabilityWindow,
|
||||
CreateCategory,
|
||||
CreateMenuItem,
|
||||
CreateModifier,
|
||||
CreateModifierGroup,
|
||||
CreateOrder,
|
||||
CreateRestaurant,
|
||||
CreateSubcategory,
|
||||
MenuItem,
|
||||
Modifier,
|
||||
ModifierGroup,
|
||||
Order,
|
||||
OrderInvoice,
|
||||
OrderWithItems,
|
||||
Restaurant,
|
||||
RestaurantSettings,
|
||||
Subcategory,
|
||||
)
|
||||
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 _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
|
||||
|
||||
event = build_menu_item_event(item, restaurant, pubkey)
|
||||
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/{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 #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@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 #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
The webapp uses this once at load time, 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."
|
||||
)
|
||||
|
||||
categories = await get_categories(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] = []
|
||||
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)
|
||||
if item.category_id and item.category_id in cat_map:
|
||||
cat_map[item.category_id]["items"].append(item_dict)
|
||||
|
||||
return {
|
||||
"restaurant": restaurant.dict(),
|
||||
"categories": list(cat_map.values()),
|
||||
"items": enriched_items, # flat list; useful for search
|
||||
}
|
||||
|
||||
|
||||
@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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue