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
8
crud.py
8
crud.py
|
|
@ -563,6 +563,14 @@ async def update_print_job(job: PrintJob) -> PrintJob:
|
||||||
return job
|
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(
|
async def get_print_jobs(
|
||||||
restaurant_id: str, status: Optional[str] = None
|
restaurant_id: str, status: Optional[str] = None
|
||||||
) -> list[PrintJob]:
|
) -> list[PrintJob]:
|
||||||
|
|
|
||||||
135
views.py
Normal file
135
views.py
Normal file
|
|
@ -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),
|
||||||
|
},
|
||||||
|
)
|
||||||
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