feat(crud): async CRUD layer for all entities

- Restaurants: create / update / get / get_by_slug / get_by_wallets /
  get_all / delete (with ordered cascade through dependent rows)
- Categories + subcategories with cascade
- Menu items with adjust_stock helper for atomic decrement
- Modifier groups + modifiers with cascade
- Availability windows
- Orders + order items (id := payment_hash so the invoice listener
  can look up by payment_hash with zero metadata round-trip)
- Print jobs queue
- Settings (single-row config table)

JSON columns are passed through pydantic pre-validators on read so
nested models (OpenHours, lists, etc.) round-trip cleanly across
SQLite + Postgres.
This commit is contained in:
Padreug 2026-04-29 23:36:49 +02:00
commit 5f4b416f5f

621
crud.py Normal file
View file

@ -0,0 +1,621 @@
"""
Async CRUD layer for the restaurant extension.
All functions are coroutines that hit the Database singleton initialized
at module import time. Pydantic models are passed to db.insert/update so
nested objects (OpenHours, SocialLinks, lists, etc.) are JSON-serialized
consistently across SQLite + Postgres backends.
A note on JSON columns: db.insert() / db.update() handle serialization,
but db.fetchone(model=Model) / db.fetchall(model=Model) reverse it via
the model's pre-validators (defined in models.py).
"""
import json
from datetime import datetime, timezone
from typing import Optional
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from .models import (
AvailabilityWindow,
Category,
CreateAvailabilityWindow,
CreateCategory,
CreateMenuItem,
CreateModifier,
CreateModifierGroup,
CreateRestaurant,
CreateSubcategory,
MenuItem,
Modifier,
ModifierGroup,
Order,
OrderItemRow,
PrintJob,
Restaurant,
RestaurantSettings,
SelectedModifier,
Subcategory,
)
db = Database("ext_restaurant")
# --------------------------------------------------------------------- #
# Restaurants #
# --------------------------------------------------------------------- #
async def create_restaurant(wallet: str, data: CreateRestaurant) -> Restaurant:
restaurant = Restaurant(
id=urlsafe_short_hash(),
wallet=wallet,
time=datetime.now(timezone.utc),
**{k: v for k, v in data.dict().items() if k != "wallet"},
)
await db.insert("restaurant.restaurants", restaurant)
return restaurant
async def update_restaurant(restaurant: Restaurant) -> Restaurant:
await db.update("restaurant.restaurants", restaurant)
return restaurant
async def get_restaurant(restaurant_id: str) -> Optional[Restaurant]:
return await db.fetchone(
"SELECT * FROM restaurant.restaurants WHERE id = :id",
{"id": restaurant_id},
Restaurant,
)
async def get_restaurant_by_slug(slug: str) -> Optional[Restaurant]:
return await db.fetchone(
"SELECT * FROM restaurant.restaurants WHERE slug = :slug",
{"slug": slug},
Restaurant,
)
async def get_restaurants(wallet_ids: str | list[str]) -> list[Restaurant]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join([f"'{w}'" for w in wallet_ids])
return await db.fetchall(
f"SELECT * FROM restaurant.restaurants WHERE wallet IN ({q}) ORDER BY time DESC",
model=Restaurant,
)
async def get_all_restaurants() -> list[Restaurant]:
return await db.fetchall(
"SELECT * FROM restaurant.restaurants ORDER BY time DESC",
model=Restaurant,
)
async def delete_restaurant(restaurant_id: str) -> None:
# Cascade by app logic — relational FKs aren't enforced cross-backend,
# so we manually clean dependent rows in the right order.
await db.execute(
"""
DELETE FROM restaurant.print_jobs
WHERE order_id IN (
SELECT id FROM restaurant.orders WHERE restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"""
DELETE FROM restaurant.order_items
WHERE order_id IN (
SELECT id FROM restaurant.orders WHERE restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"DELETE FROM restaurant.orders WHERE restaurant_id = :rid",
{"rid": restaurant_id},
)
await db.execute(
"""
DELETE FROM restaurant.modifiers
WHERE group_id IN (
SELECT mg.id FROM restaurant.modifier_groups mg
JOIN restaurant.menu_items mi ON mg.menu_item_id = mi.id
WHERE mi.restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"""
DELETE FROM restaurant.modifier_groups
WHERE menu_item_id IN (
SELECT id FROM restaurant.menu_items WHERE restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"""
DELETE FROM restaurant.availability_windows
WHERE menu_item_id IN (
SELECT id FROM restaurant.menu_items WHERE restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"DELETE FROM restaurant.menu_items WHERE restaurant_id = :rid",
{"rid": restaurant_id},
)
await db.execute(
"""
DELETE FROM restaurant.subcategories
WHERE category_id IN (
SELECT id FROM restaurant.categories WHERE restaurant_id = :rid
)
""",
{"rid": restaurant_id},
)
await db.execute(
"DELETE FROM restaurant.categories WHERE restaurant_id = :rid",
{"rid": restaurant_id},
)
await db.execute(
"DELETE FROM restaurant.restaurants WHERE id = :id",
{"id": restaurant_id},
)
# --------------------------------------------------------------------- #
# Categories / subcategories #
# --------------------------------------------------------------------- #
async def create_category(data: CreateCategory) -> Category:
cat = Category(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
)
await db.insert("restaurant.categories", cat)
return cat
async def update_category(category: Category) -> Category:
await db.update("restaurant.categories", category)
return category
async def get_category(category_id: str) -> Optional[Category]:
return await db.fetchone(
"SELECT * FROM restaurant.categories WHERE id = :id",
{"id": category_id},
Category,
)
async def get_categories(restaurant_id: str) -> list[Category]:
return await db.fetchall(
"""
SELECT * FROM restaurant.categories
WHERE restaurant_id = :rid
ORDER BY sort_order, time
""",
{"rid": restaurant_id},
model=Category,
)
async def delete_category(category_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.subcategories WHERE category_id = :cid",
{"cid": category_id},
)
await db.execute(
"DELETE FROM restaurant.categories WHERE id = :id",
{"id": category_id},
)
async def create_subcategory(data: CreateSubcategory) -> Subcategory:
sub = Subcategory(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
)
await db.insert("restaurant.subcategories", sub)
return sub
async def update_subcategory(subcategory: Subcategory) -> Subcategory:
await db.update("restaurant.subcategories", subcategory)
return subcategory
async def get_subcategories(category_id: str) -> list[Subcategory]:
return await db.fetchall(
"""
SELECT * FROM restaurant.subcategories
WHERE category_id = :cid
ORDER BY sort_order, time
""",
{"cid": category_id},
model=Subcategory,
)
async def delete_subcategory(subcategory_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.subcategories WHERE id = :id",
{"id": subcategory_id},
)
# --------------------------------------------------------------------- #
# Menu items #
# --------------------------------------------------------------------- #
async def create_menu_item(data: CreateMenuItem) -> MenuItem:
item = MenuItem(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
)
await db.insert("restaurant.menu_items", item)
return item
async def update_menu_item(item: MenuItem) -> MenuItem:
await db.update("restaurant.menu_items", item)
return item
async def get_menu_item(item_id: str) -> Optional[MenuItem]:
return await db.fetchone(
"SELECT * FROM restaurant.menu_items WHERE id = :id",
{"id": item_id},
MenuItem,
)
async def get_menu_items(restaurant_id: str) -> list[MenuItem]:
return await db.fetchall(
"""
SELECT * FROM restaurant.menu_items
WHERE restaurant_id = :rid
ORDER BY sort_order, time
""",
{"rid": restaurant_id},
model=MenuItem,
)
async def get_menu_item_by_nostr_event(event_id: str) -> Optional[MenuItem]:
return await db.fetchone(
"SELECT * FROM restaurant.menu_items WHERE nostr_event_id = :nid",
{"nid": event_id},
MenuItem,
)
async def delete_menu_item(item_id: str) -> None:
await db.execute(
"""
DELETE FROM restaurant.modifiers
WHERE group_id IN (
SELECT id FROM restaurant.modifier_groups WHERE menu_item_id = :mid
)
""",
{"mid": item_id},
)
await db.execute(
"DELETE FROM restaurant.modifier_groups WHERE menu_item_id = :mid",
{"mid": item_id},
)
await db.execute(
"DELETE FROM restaurant.availability_windows WHERE menu_item_id = :mid",
{"mid": item_id},
)
await db.execute(
"DELETE FROM restaurant.menu_items WHERE id = :id",
{"id": item_id},
)
async def adjust_stock(item_id: str, delta: int) -> Optional[MenuItem]:
"""Decrement (negative delta) or increment stock atomically."""
item = await get_menu_item(item_id)
if not item or item.stock is None:
return item
item.stock = max(0, item.stock + delta)
return await update_menu_item(item)
# --------------------------------------------------------------------- #
# Modifier groups + modifiers #
# --------------------------------------------------------------------- #
async def create_modifier_group(data: CreateModifierGroup) -> ModifierGroup:
grp = ModifierGroup(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
)
await db.insert("restaurant.modifier_groups", grp)
return grp
async def update_modifier_group(grp: ModifierGroup) -> ModifierGroup:
await db.update("restaurant.modifier_groups", grp)
return grp
async def get_modifier_groups(menu_item_id: str) -> list[ModifierGroup]:
return await db.fetchall(
"""
SELECT * FROM restaurant.modifier_groups
WHERE menu_item_id = :mid
ORDER BY sort_order, time
""",
{"mid": menu_item_id},
model=ModifierGroup,
)
async def delete_modifier_group(group_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.modifiers WHERE group_id = :gid",
{"gid": group_id},
)
await db.execute(
"DELETE FROM restaurant.modifier_groups WHERE id = :id",
{"id": group_id},
)
async def create_modifier(data: CreateModifier) -> Modifier:
mod = Modifier(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
)
await db.insert("restaurant.modifiers", mod)
return mod
async def update_modifier(mod: Modifier) -> Modifier:
await db.update("restaurant.modifiers", mod)
return mod
async def get_modifiers(group_id: str) -> list[Modifier]:
return await db.fetchall(
"""
SELECT * FROM restaurant.modifiers
WHERE group_id = :gid
ORDER BY sort_order, time
""",
{"gid": group_id},
model=Modifier,
)
async def delete_modifier(modifier_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.modifiers WHERE id = :id",
{"id": modifier_id},
)
# --------------------------------------------------------------------- #
# Availability windows #
# --------------------------------------------------------------------- #
async def create_availability_window(
data: CreateAvailabilityWindow,
) -> AvailabilityWindow:
win = AvailabilityWindow(
id=urlsafe_short_hash(),
time=datetime.now(timezone.utc),
**data.dict(),
)
await db.insert("restaurant.availability_windows", win)
return win
async def get_availability_windows(menu_item_id: str) -> list[AvailabilityWindow]:
return await db.fetchall(
"""
SELECT * FROM restaurant.availability_windows
WHERE menu_item_id = :mid
ORDER BY weekday NULLS FIRST, start_time
""",
{"mid": menu_item_id},
model=AvailabilityWindow,
)
async def delete_availability_window(window_id: str) -> None:
await db.execute(
"DELETE FROM restaurant.availability_windows WHERE id = :id",
{"id": window_id},
)
# --------------------------------------------------------------------- #
# Orders + order items #
# --------------------------------------------------------------------- #
async def create_order(order: Order) -> Order:
"""Insert an Order row. Caller must construct the Order with id set
(typically id = payment_hash so we can look it up from the invoice
listener with no extra metadata)."""
await db.insert("restaurant.orders", order)
return order
async def update_order(order: Order) -> Order:
await db.update("restaurant.orders", order)
return order
async def get_order(order_id: str) -> Optional[Order]:
return await db.fetchone(
"SELECT * FROM restaurant.orders WHERE id = :id",
{"id": order_id},
Order,
)
async def get_order_by_payment_hash(payment_hash: str) -> Optional[Order]:
return await db.fetchone(
"SELECT * FROM restaurant.orders WHERE payment_hash = :ph",
{"ph": payment_hash},
Order,
)
async def get_orders(
restaurant_id: str,
statuses: Optional[list[str]] = None,
limit: int = 200,
) -> list[Order]:
if statuses:
placeholders = ",".join([f"'{s}'" for s in statuses])
return await db.fetchall(
f"""
SELECT * FROM restaurant.orders
WHERE restaurant_id = :rid AND status IN ({placeholders})
ORDER BY time DESC
LIMIT {int(limit)}
""",
{"rid": restaurant_id},
model=Order,
)
return await db.fetchall(
f"""
SELECT * FROM restaurant.orders
WHERE restaurant_id = :rid
ORDER BY time DESC
LIMIT {int(limit)}
""",
{"rid": restaurant_id},
model=Order,
)
async def create_order_item(item: OrderItemRow) -> OrderItemRow:
await db.insert("restaurant.order_items", item)
return item
async def get_order_items(order_id: str) -> list[OrderItemRow]:
rows = await db.fetchall(
"SELECT * FROM restaurant.order_items WHERE order_id = :oid ORDER BY time",
{"oid": order_id},
)
out: list[OrderItemRow] = []
for row in rows:
d = dict(row)
# selected_modifiers comes back as JSON string from db; parse here.
sm = d.get("selected_modifiers")
if isinstance(sm, str):
try:
d["selected_modifiers"] = [
SelectedModifier(**m) for m in (json.loads(sm) if sm else [])
]
except json.JSONDecodeError:
d["selected_modifiers"] = []
out.append(OrderItemRow(**d))
return out
# --------------------------------------------------------------------- #
# Print jobs #
# --------------------------------------------------------------------- #
async def create_print_job(restaurant_id: str, order_id: str) -> PrintJob:
job = PrintJob(
id=urlsafe_short_hash(),
restaurant_id=restaurant_id,
order_id=order_id,
time=datetime.now(timezone.utc),
)
await db.insert("restaurant.print_jobs", job)
return job
async def update_print_job(job: PrintJob) -> PrintJob:
await db.update("restaurant.print_jobs", job)
return job
async def get_print_jobs(
restaurant_id: str, status: Optional[str] = None
) -> list[PrintJob]:
if status:
return await db.fetchall(
"""
SELECT * FROM restaurant.print_jobs
WHERE restaurant_id = :rid AND status = :status
ORDER BY time DESC
""",
{"rid": restaurant_id, "status": status},
model=PrintJob,
)
return await db.fetchall(
"""
SELECT * FROM restaurant.print_jobs
WHERE restaurant_id = :rid
ORDER BY time DESC
""",
{"rid": restaurant_id},
model=PrintJob,
)
# --------------------------------------------------------------------- #
# Settings #
# --------------------------------------------------------------------- #
async def get_settings() -> RestaurantSettings:
row = await db.fetchone("SELECT * FROM restaurant.settings WHERE id = 1")
if row:
d = dict(row)
d.pop("id", None)
return RestaurantSettings(**d)
return RestaurantSettings()
async def update_settings(settings: RestaurantSettings) -> RestaurantSettings:
await db.execute(
"""
UPDATE restaurant.settings
SET nostr_publish_enabled = :npe,
nostr_orders_enabled = :noe,
invoice_expiry_seconds = :ies,
auto_accept_orders = :aao
WHERE id = 1
""",
{
"npe": settings.nostr_publish_enabled,
"noe": settings.nostr_orders_enabled,
"ies": settings.invoice_expiry_seconds,
"aao": settings.auto_accept_orders,
},
)
return settings