From c37b17d474e46c42d181473ecc3d8c95b324c836 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 29 Apr 2026 23:44:38 +0200 Subject: [PATCH] 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. --- crud.py | 8 + views.py | 135 ++++++++++ views_api.py | 728 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 871 insertions(+) create mode 100644 views.py create mode 100644 views_api.py diff --git a/crud.py b/crud.py index 21c2016..ebef9dc 100644 --- a/crud.py +++ b/crud.py @@ -563,6 +563,14 @@ async def update_print_job(job: PrintJob) -> PrintJob: return job +async def get_print_job(job_id: str) -> Optional[PrintJob]: + return await db.fetchone( + "SELECT * FROM restaurant.print_jobs WHERE id = :id", + {"id": job_id}, + PrintJob, + ) + + async def get_print_jobs( restaurant_id: str, status: Optional[str] = None ) -> list[PrintJob]: diff --git a/views.py b/views.py new file mode 100644 index 0000000..ec6451f --- /dev/null +++ b/views.py @@ -0,0 +1,135 @@ +""" +Server-rendered CMS routes for restaurant owners. + +Mounted at `/restaurant/...`. Customer-facing pages live in the AIO +webapp (~/dev/webapp); this extension only renders the CMS. + +Pages +----- + /restaurant/ dashboard (restaurant list) + /restaurant/{slug} menu builder + /restaurant/{slug}/orders order monitor + /restaurant/{slug}/kds kitchen display + /restaurant/{slug}/settings restaurant + Nostr settings + +All pages require a logged-in LNbits user (check_user_exists). +""" + +import json +from http import HTTPStatus + +from fastapi import APIRouter, Depends, Request +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists +from lnbits.helpers import template_renderer + +from .crud import get_restaurant_by_slug +from .models import Restaurant + +restaurant_generic_router = APIRouter() + + +def restaurant_renderer(): + return template_renderer(["restaurant/templates"]) + + +def _restaurant_jsonable(restaurant: Restaurant) -> dict: + """ + Convert a Restaurant pydantic model to a plain JSON-serializable + dict for Jinja's `tojson` filter. + + `restaurant.dict()` returns a dict with a `datetime` on `time`, + which Python's stdlib `JSONEncoder` (used by Jinja `tojson`) can't + serialize — it errors out as + TypeError: JSONEncoder.default() missing 1 required positional argument: 'o' + Pydantic v1's `.json()` knows how to serialize datetime as + ISO-8601, so we round-trip via JSON to get a clean dict. + """ + return json.loads(restaurant.json()) + + +@restaurant_generic_router.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return restaurant_renderer().TemplateResponse( + "restaurant/index.html", + {"request": request, "user": user.json()}, + ) + + +@restaurant_generic_router.get("/{slug}", response_class=HTMLResponse) +async def menu_builder( + request: Request, slug: str, user: User = Depends(check_user_exists) +): + restaurant = await get_restaurant_by_slug(slug) + if not restaurant: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found." + ) + return restaurant_renderer().TemplateResponse( + "restaurant/menu.html", + { + "request": request, + "user": user.json(), + "restaurant": _restaurant_jsonable(restaurant), + }, + ) + + +@restaurant_generic_router.get("/{slug}/orders", response_class=HTMLResponse) +async def orders( + request: Request, slug: str, user: User = Depends(check_user_exists) +): + restaurant = await get_restaurant_by_slug(slug) + if not restaurant: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found." + ) + return restaurant_renderer().TemplateResponse( + "restaurant/orders.html", + { + "request": request, + "user": user.json(), + "restaurant": _restaurant_jsonable(restaurant), + }, + ) + + +@restaurant_generic_router.get("/{slug}/kds", response_class=HTMLResponse) +async def kds( + request: Request, slug: str, user: User = Depends(check_user_exists) +): + restaurant = await get_restaurant_by_slug(slug) + if not restaurant: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found." + ) + return restaurant_renderer().TemplateResponse( + "restaurant/kds.html", + { + "request": request, + "user": user.json(), + "restaurant": _restaurant_jsonable(restaurant), + }, + ) + + +@restaurant_generic_router.get("/{slug}/settings", response_class=HTMLResponse) +async def settings_page( + request: Request, slug: str, user: User = Depends(check_user_exists) +): + restaurant = await get_restaurant_by_slug(slug) + if not restaurant: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found." + ) + return restaurant_renderer().TemplateResponse( + "restaurant/settings.html", + { + "request": request, + "user": user.json(), + "restaurant": _restaurant_jsonable(restaurant), + }, + ) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..69b0bd9 --- /dev/null +++ b/views_api.py @@ -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)