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:
parent
52f1ad1bb1
commit
5f4b416f5f
1 changed files with 621 additions and 0 deletions
621
crud.py
Normal file
621
crud.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue