Compare commits
No commits in common. "main" and "feat/restaurant-by-slug" have entirely different histories.
main
...
feat/resta
4 changed files with 1 additions and 854 deletions
20
docs/cms.md
20
docs/cms.md
|
|
@ -70,26 +70,6 @@ Both use the same data source (`GET /restaurants/{id}/orders`)
|
|||
filtered by status. The KDS view escalates color by age (`>5min`
|
||||
orange, `>15min` red) and offers one-tap state transitions.
|
||||
|
||||
When a card transitions to `accepted` (driven by `cookingMode(order)`
|
||||
in `kds.js`), three inline `:style` bindings kick in:
|
||||
|
||||
- the items `q-card-section` switches base font between `1rem` and
|
||||
`1.25rem`,
|
||||
- the modifier list (`.text-caption.text-grey-7`) bumps to `1.15rem`
|
||||
+ medium weight + `color: inherit` (drops the muted grey),
|
||||
- the per-line note (`.text-caption.text-amber-9`) bumps to `1.15rem`
|
||||
+ medium weight; color is left alone so it stays amber.
|
||||
|
||||
All cooking-mode styling is inline because an upstream `!important`
|
||||
rule (likely an lnbits theme override on Quasar's typography
|
||||
utilities) defeats class-based CSS rules — even with `!important`
|
||||
on our side. Inline `:style` wins without needing the arms race.
|
||||
Card chrome and the age-based `bg-{color}-1` from `cardClass()`
|
||||
are untouched. The amber
|
||||
per-line note keeps its color because only `.text-grey-7` is
|
||||
overridden. No background rules; card chrome and the age-based
|
||||
`bg-{color}-1` from `cardClass()` are untouched.
|
||||
|
||||
Today the monitor + KDS poll every 5–8 s. SSE / Nostr push is on
|
||||
the roadmap.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,826 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed "Lightning Cafe" — the canonical demo restaurant.
|
||||
|
||||
This seed is tracked in the repo on purpose: it's the data the demo
|
||||
deploy lands customers on, and it exercises every feature the
|
||||
restaurant extension and webapp bundle expose, so it doubles as a
|
||||
visual smoke-test fixture.
|
||||
|
||||
Features showcased
|
||||
------------------
|
||||
- Menu tree (root + nested subcategories up to depth 2)
|
||||
- Items with descriptions, dietary tags, allergens, ingredients,
|
||||
calorie counts
|
||||
- Featured items (is_featured=1) — pinned to top of category
|
||||
- Sold-out items (is_available=0) — "Sold out" badge
|
||||
- Low-stock items (stock <= low_stock_threshold) — "Low stock" hint
|
||||
- Multiple modifier groups per item: required/one (radio),
|
||||
required/many (limited multi-select), optional/many (free multi)
|
||||
- Modifier price deltas in sats
|
||||
|
||||
Prices are integer sats (no fractional). The extension's
|
||||
_SAT_ALIASES accept 'sat' as the canonical name; we use that.
|
||||
|
||||
Usage:
|
||||
python scripts/seed_lightning_cafe.py --wallet <wallet_id> \\
|
||||
[--db /path/to/ext_restaurant.sqlite3] \\
|
||||
[--slug lightning-cafe] \\
|
||||
[--force]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
CURRENCY = "sat"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Menu definition #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
MENU: list[dict] = [
|
||||
# ───────────────────────────── DRINKS ───────────────────────────── #
|
||||
{
|
||||
"name": "Drinks",
|
||||
"sort": 0,
|
||||
"children": [
|
||||
{
|
||||
"name": "Coffee",
|
||||
"sort": 0,
|
||||
"items": [
|
||||
{
|
||||
"name": "Espresso",
|
||||
"price": 200,
|
||||
"desc": "Double shot, locally roasted",
|
||||
"ingredients": ["espresso beans", "water"],
|
||||
"calories": 5,
|
||||
},
|
||||
{
|
||||
"name": "Americano",
|
||||
"price": 250,
|
||||
"desc": "Espresso lengthened with hot water",
|
||||
"ingredients": ["espresso beans", "water"],
|
||||
"calories": 10,
|
||||
},
|
||||
{
|
||||
"name": "Latte",
|
||||
"price": 500,
|
||||
"desc": "Espresso + steamed milk",
|
||||
"ingredients": ["espresso beans", "milk"],
|
||||
"allergens": ["dairy"],
|
||||
"calories": 120,
|
||||
"featured": True,
|
||||
"modifier_groups": [
|
||||
{
|
||||
"name": "Milk",
|
||||
"kind": "required",
|
||||
"selection": "one",
|
||||
"min": 1,
|
||||
"max": 1,
|
||||
"modifiers": [
|
||||
{"name": "Whole", "delta": 0, "default": True},
|
||||
{"name": "Oat", "delta": 50},
|
||||
{"name": "Almond", "delta": 50},
|
||||
{"name": "Soy", "delta": 50},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Flavor (optional)",
|
||||
"kind": "optional",
|
||||
"selection": "one",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"modifiers": [
|
||||
{"name": "Vanilla", "delta": 75},
|
||||
{"name": "Caramel", "delta": 75},
|
||||
{"name": "Hazelnut", "delta": 75},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Cappuccino",
|
||||
"price": 500,
|
||||
"desc": "Espresso + thick microfoam",
|
||||
"ingredients": ["espresso beans", "milk"],
|
||||
"allergens": ["dairy"],
|
||||
"calories": 80,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Cold",
|
||||
"sort": 1,
|
||||
"items": [
|
||||
{
|
||||
"name": "Iced Coffee",
|
||||
"price": 350,
|
||||
"desc": "Cold brew over ice",
|
||||
"dietary": ["vegan", "gluten_free"],
|
||||
"calories": 5,
|
||||
},
|
||||
{
|
||||
"name": "Mineral Water",
|
||||
"price": 150,
|
||||
"desc": "Sparkling, 500ml",
|
||||
"dietary": ["vegan", "gluten_free"],
|
||||
"calories": 0,
|
||||
},
|
||||
{
|
||||
"name": "Fresh Orange Juice",
|
||||
"price": 400,
|
||||
"desc": "Squeezed to order",
|
||||
"dietary": ["vegan", "gluten_free"],
|
||||
"ingredients": ["oranges"],
|
||||
"calories": 110,
|
||||
# Demo: low stock — should render the "low stock" hint.
|
||||
"stock": 3,
|
||||
"low_stock_threshold": 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Tea",
|
||||
"sort": 2,
|
||||
"items": [
|
||||
{
|
||||
"name": "Earl Grey",
|
||||
"price": 300,
|
||||
"desc": "Bergamot black tea",
|
||||
"ingredients": ["black tea", "bergamot oil"],
|
||||
"calories": 0,
|
||||
},
|
||||
{
|
||||
"name": "Matcha Latte",
|
||||
"price": 600,
|
||||
"desc": "Ceremonial-grade matcha + steamed milk",
|
||||
"allergens": ["dairy"],
|
||||
"calories": 110,
|
||||
"modifier_groups": [
|
||||
{
|
||||
"name": "Milk",
|
||||
"kind": "required",
|
||||
"selection": "one",
|
||||
"min": 1,
|
||||
"max": 1,
|
||||
"modifiers": [
|
||||
{"name": "Whole", "delta": 0, "default": True},
|
||||
{"name": "Oat", "delta": 50},
|
||||
{"name": "Almond", "delta": 50},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Chai Latte",
|
||||
"price": 600,
|
||||
"desc": "House-spiced chai with steamed milk",
|
||||
"allergens": ["dairy"],
|
||||
"ingredients": [
|
||||
"black tea",
|
||||
"cardamom",
|
||||
"ginger",
|
||||
"cinnamon",
|
||||
"cloves",
|
||||
"milk",
|
||||
],
|
||||
"calories": 140,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
# ─────────────────────────────── FOOD ─────────────────────────────── #
|
||||
{
|
||||
"name": "Food",
|
||||
"sort": 1,
|
||||
"children": [
|
||||
{
|
||||
"name": "Breakfast",
|
||||
"sort": 0,
|
||||
"items": [
|
||||
{
|
||||
"name": "Avocado Toast",
|
||||
"price": 1200,
|
||||
"desc": "Sourdough, smashed avocado, lime, chili flakes",
|
||||
"dietary": ["vegan"],
|
||||
"allergens": ["gluten"],
|
||||
"ingredients": [
|
||||
"sourdough",
|
||||
"avocado",
|
||||
"lime",
|
||||
"chili flakes",
|
||||
"olive oil",
|
||||
],
|
||||
"calories": 380,
|
||||
"featured": True,
|
||||
"modifier_groups": [
|
||||
{
|
||||
"name": "Add protein",
|
||||
"kind": "optional",
|
||||
"selection": "one",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"modifiers": [
|
||||
{"name": "Poached egg", "delta": 200},
|
||||
{"name": "Smoked salmon", "delta": 600},
|
||||
{"name": "Halloumi", "delta": 400},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Toppings",
|
||||
"kind": "optional",
|
||||
"selection": "many",
|
||||
"min": 0,
|
||||
"max": 4,
|
||||
"modifiers": [
|
||||
{"name": "Cherry tomatoes", "delta": 100},
|
||||
{"name": "Feta", "delta": 200},
|
||||
{"name": "Microgreens", "delta": 150},
|
||||
{"name": "Everything-bagel seasoning", "delta": 50},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Granola Bowl",
|
||||
"price": 900,
|
||||
"desc": "House granola, yogurt, seasonal fruit, honey",
|
||||
"dietary": ["vegetarian"],
|
||||
"allergens": ["dairy", "nuts", "gluten"],
|
||||
"calories": 420,
|
||||
},
|
||||
{
|
||||
"name": "Breakfast Burrito",
|
||||
"price": 1500,
|
||||
"desc": "Scrambled eggs, black beans, cheese, pico, salsa verde",
|
||||
"allergens": ["dairy", "eggs", "gluten"],
|
||||
"calories": 580,
|
||||
"modifier_groups": [
|
||||
{
|
||||
"name": "Heat level",
|
||||
"kind": "required",
|
||||
"selection": "one",
|
||||
"min": 1,
|
||||
"max": 1,
|
||||
"modifiers": [
|
||||
{"name": "Mild", "delta": 0, "default": True},
|
||||
{"name": "Medium", "delta": 0},
|
||||
{"name": "Spicy", "delta": 0},
|
||||
{"name": "Volcano 🌋", "delta": 0},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Add-ons",
|
||||
"kind": "optional",
|
||||
"selection": "many",
|
||||
"min": 0,
|
||||
"max": 3,
|
||||
"modifiers": [
|
||||
{"name": "Avocado", "delta": 150},
|
||||
{"name": "Bacon", "delta": 200},
|
||||
{"name": "Extra cheese", "delta": 100},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Lunch",
|
||||
"sort": 1,
|
||||
"items": [
|
||||
{
|
||||
"name": "Lightning Burger",
|
||||
"price": 2500,
|
||||
"desc": "Grass-fed beef, aged cheddar, brioche bun, "
|
||||
"house aioli, butter lettuce, tomato",
|
||||
"allergens": ["dairy", "eggs", "gluten"],
|
||||
"calories": 780,
|
||||
"featured": True,
|
||||
"modifier_groups": [
|
||||
{
|
||||
"name": "Doneness",
|
||||
"kind": "required",
|
||||
"selection": "one",
|
||||
"min": 1,
|
||||
"max": 1,
|
||||
"modifiers": [
|
||||
{"name": "Medium rare", "delta": 0, "default": True},
|
||||
{"name": "Medium", "delta": 0},
|
||||
{"name": "Medium well", "delta": 0},
|
||||
{"name": "Well done", "delta": 0},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Side",
|
||||
"kind": "required",
|
||||
"selection": "one",
|
||||
"min": 1,
|
||||
"max": 1,
|
||||
"modifiers": [
|
||||
{"name": "Fries", "delta": 0, "default": True},
|
||||
{"name": "Sweet potato fries", "delta": 200},
|
||||
{"name": "Side salad", "delta": 0},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Extras",
|
||||
"kind": "optional",
|
||||
"selection": "many",
|
||||
"min": 0,
|
||||
"max": 4,
|
||||
"modifiers": [
|
||||
{"name": "Bacon", "delta": 300},
|
||||
{"name": "Fried egg", "delta": 200},
|
||||
{"name": "Avocado", "delta": 250},
|
||||
{"name": "Caramelized onions", "delta": 100},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Caesar Salad",
|
||||
"price": 1400,
|
||||
"desc": "Romaine, parmesan, garlic croutons, classic dressing",
|
||||
"allergens": ["dairy", "eggs", "gluten", "fish"],
|
||||
"calories": 320,
|
||||
"modifier_groups": [
|
||||
{
|
||||
"name": "Add protein",
|
||||
"kind": "optional",
|
||||
"selection": "one",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"modifiers": [
|
||||
{"name": "Grilled chicken", "delta": 500},
|
||||
{"name": "Anchovies", "delta": 200},
|
||||
{"name": "Shrimp", "delta": 700},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Veggie Wrap",
|
||||
"price": 1100,
|
||||
"desc": "Hummus, roasted veg, greens, in a spinach tortilla",
|
||||
"dietary": ["vegan"],
|
||||
"allergens": ["gluten", "sesame"],
|
||||
"calories": 410,
|
||||
},
|
||||
{
|
||||
"name": "Daily Special",
|
||||
"price": 1800,
|
||||
"desc": "Ask the cashier — changes daily",
|
||||
# Demo: sold out — should render "Sold out" badge.
|
||||
"available": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Snacks",
|
||||
"sort": 2,
|
||||
"items": [
|
||||
{
|
||||
"name": "Almond Croissant",
|
||||
"price": 450,
|
||||
"desc": "Buttery, frangipane-filled, sliced almonds",
|
||||
"allergens": ["dairy", "gluten", "nuts", "eggs"],
|
||||
"calories": 460,
|
||||
},
|
||||
{
|
||||
"name": "Chocolate Chip Cookie",
|
||||
"price": 300,
|
||||
"desc": "Brown butter, dark chocolate, sea salt",
|
||||
"allergens": ["dairy", "gluten", "eggs"],
|
||||
"calories": 280,
|
||||
},
|
||||
{
|
||||
"name": "Energy Ball",
|
||||
"price": 250,
|
||||
"desc": "Dates, oats, cocoa, almonds, coconut",
|
||||
"dietary": ["vegan", "gluten_free"],
|
||||
"allergens": ["nuts"],
|
||||
"calories": 180,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# DB path detection #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
_DB_CANDIDATES = [
|
||||
os.environ.get("LNBITS_DATA_FOLDER", "") + "/ext_restaurant.sqlite3",
|
||||
"./data/ext_restaurant.sqlite3",
|
||||
"../data/ext_restaurant.sqlite3",
|
||||
str(Path.home() / ".lnbits/data/ext_restaurant.sqlite3"),
|
||||
"/var/lib/lnbits/data/ext_restaurant.sqlite3",
|
||||
"/var/lib/lnbits/ext_restaurant.sqlite3",
|
||||
]
|
||||
|
||||
|
||||
def find_db(explicit: Optional[str]) -> Path:
|
||||
if explicit:
|
||||
path = Path(explicit).expanduser().resolve()
|
||||
if not path.exists():
|
||||
sys.exit(f"error: db not found at {path}")
|
||||
return path
|
||||
for cand in _DB_CANDIDATES:
|
||||
if cand and Path(cand).exists():
|
||||
return Path(cand).resolve()
|
||||
sys.exit(
|
||||
"error: ext_restaurant.sqlite3 not found in any default location.\n"
|
||||
" Pass --db /path/to/ext_restaurant.sqlite3 explicitly.\n"
|
||||
f" Tried: {[c for c in _DB_CANDIDATES if c]}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Helpers #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def now_ts() -> int:
|
||||
# LNbits's Database on SQLite stores TIMESTAMP columns as Unix
|
||||
# integer seconds (see dict_to_model → datetime.fromtimestamp).
|
||||
return int(datetime.now(tz=timezone.utc).timestamp())
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def restaurant_extra() -> str:
|
||||
# `fields` is a dict at the Python model level, but LNbits's
|
||||
# dict_to_model expects dict-typed fields *inside* a JSON-encoded
|
||||
# submodel to be stored as a JSON string (it calls json.loads on
|
||||
# the value when re-hydrating).
|
||||
return json.dumps({"notes": None, "fields": json.dumps({})})
|
||||
|
||||
|
||||
def restaurant_socials() -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"website": None,
|
||||
"instagram": None,
|
||||
"facebook": None,
|
||||
"twitter": None,
|
||||
"nostr": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def restaurant_open_hours() -> str:
|
||||
# Open 7am-9pm every day. `schedule` is a dict inside the
|
||||
# OpenHours submodel — must be a JSON string per LNbits's
|
||||
# dict_to_model convention.
|
||||
schedule = {
|
||||
str(d): [{"start": "07:00", "end": "21:00"}] for d in range(7)
|
||||
}
|
||||
return json.dumps({"schedule": json.dumps(schedule)})
|
||||
|
||||
|
||||
def item_extra() -> str:
|
||||
return json.dumps({"notes": None, "fields": json.dumps({})})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Seeder #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def seed(db_path: Path, wallet: str, slug: str, force: bool) -> None:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"SELECT id, name FROM restaurants WHERE slug = ? AND wallet = ?",
|
||||
(slug, wallet),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
if not force:
|
||||
sys.exit(
|
||||
f"error: a restaurant with slug='{slug}' already exists for "
|
||||
f"wallet={wallet} (id={existing['id']}, name={existing['name']!r}).\n"
|
||||
f" Pass --force to drop and recreate, or pick a different "
|
||||
f"--slug."
|
||||
)
|
||||
_drop_restaurant(cur, existing["id"])
|
||||
print(f" dropped existing restaurant {existing['id']}")
|
||||
|
||||
restaurant_id = new_id()
|
||||
now = now_ts()
|
||||
|
||||
print(f"seeding 'Lightning Cafe' (id={restaurant_id})")
|
||||
print(f" db: {db_path}")
|
||||
print(f" wallet: {wallet}")
|
||||
print(f" slug: {slug}")
|
||||
print(f" currency: {CURRENCY}")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO restaurants (
|
||||
id, wallet, name, slug, description, currency, timezone,
|
||||
location, geohash, logo_url, banner_url, social_links,
|
||||
open_hours, is_open, accepts_cash, accepts_lightning,
|
||||
tip_presets, tax_rate, printer_endpoint, nostr_pubkey,
|
||||
nostr_relays, nostr_event_id, nostr_event_created_at,
|
||||
extra, time
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
restaurant_id,
|
||||
wallet,
|
||||
"Lightning Cafe",
|
||||
slug,
|
||||
"A demo cafe paying in sats — coffee, brunch, and the full "
|
||||
"feature tour of the LNbits restaurant extension.",
|
||||
CURRENCY,
|
||||
"UTC",
|
||||
"The Internet",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
restaurant_socials(),
|
||||
restaurant_open_hours(),
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
json.dumps([10, 15, 20]),
|
||||
0.0,
|
||||
None,
|
||||
None,
|
||||
json.dumps([]),
|
||||
None,
|
||||
None,
|
||||
restaurant_extra(),
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
for root_sort, root in enumerate(MENU):
|
||||
root_id = new_id()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO menu_nodes (
|
||||
id, restaurant_id, parent_id, name, description,
|
||||
sort_order, image_url, depth, path, time
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
root_id,
|
||||
restaurant_id,
|
||||
None,
|
||||
root["name"],
|
||||
None,
|
||||
root.get("sort", root_sort),
|
||||
None,
|
||||
0,
|
||||
root_id,
|
||||
now,
|
||||
),
|
||||
)
|
||||
_insert_items_in_node(
|
||||
cur, restaurant_id, root_id, root.get("items", []), now
|
||||
)
|
||||
for child_sort, child in enumerate(root.get("children", [])):
|
||||
child_id = new_id()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO menu_nodes (
|
||||
id, restaurant_id, parent_id, name, description,
|
||||
sort_order, image_url, depth, path, time
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
child_id,
|
||||
restaurant_id,
|
||||
root_id,
|
||||
child["name"],
|
||||
None,
|
||||
child.get("sort", child_sort),
|
||||
None,
|
||||
1,
|
||||
f"{root_id}/{child_id}",
|
||||
now,
|
||||
),
|
||||
)
|
||||
_insert_items_in_node(
|
||||
cur,
|
||||
restaurant_id,
|
||||
child_id,
|
||||
child.get("items", []),
|
||||
now,
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) AS n FROM menu_nodes WHERE restaurant_id = ?",
|
||||
(restaurant_id,),
|
||||
)
|
||||
nodes_n = cur.fetchone()["n"]
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) AS n FROM menu_items WHERE restaurant_id = ?",
|
||||
(restaurant_id,),
|
||||
)
|
||||
items_n = cur.fetchone()["n"]
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) AS n FROM modifier_groups mg "
|
||||
"JOIN menu_items mi ON mi.id = mg.menu_item_id "
|
||||
"WHERE mi.restaurant_id = ?",
|
||||
(restaurant_id,),
|
||||
)
|
||||
groups_n = cur.fetchone()["n"]
|
||||
|
||||
conn.close()
|
||||
|
||||
print(
|
||||
f" inserted {nodes_n} menu nodes, {items_n} items, "
|
||||
f"{groups_n} modifier groups"
|
||||
)
|
||||
print(f"\n open at http://localhost:5001/restaurant/{slug}")
|
||||
print(f" webapp: http://localhost:5187/r/{slug}")
|
||||
|
||||
|
||||
def _insert_items_in_node(
|
||||
cur: sqlite3.Cursor,
|
||||
restaurant_id: str,
|
||||
node_id: str,
|
||||
items: list[dict],
|
||||
now: int,
|
||||
) -> None:
|
||||
for sort_order, item in enumerate(items):
|
||||
item_id = new_id()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO menu_items (
|
||||
id, restaurant_id, node_id, name, description, price,
|
||||
currency, sku, images, dietary, allergens, ingredients,
|
||||
calories, sort_order, is_available, is_featured, stock,
|
||||
low_stock_threshold, nostr_event_id, nostr_event_created_at,
|
||||
extra, time
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
restaurant_id,
|
||||
node_id,
|
||||
item["name"],
|
||||
item.get("desc"),
|
||||
float(item["price"]),
|
||||
CURRENCY,
|
||||
None,
|
||||
json.dumps([]),
|
||||
json.dumps(item.get("dietary", [])),
|
||||
json.dumps(item.get("allergens", [])),
|
||||
json.dumps(item.get("ingredients", [])),
|
||||
item.get("calories"),
|
||||
sort_order,
|
||||
0 if item.get("available") is False else 1,
|
||||
1 if item.get("featured") else 0,
|
||||
item.get("stock"),
|
||||
item.get("low_stock_threshold"),
|
||||
None,
|
||||
None,
|
||||
item_extra(),
|
||||
now,
|
||||
),
|
||||
)
|
||||
for group_sort, group in enumerate(item.get("modifier_groups", [])):
|
||||
group_id = new_id()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO modifier_groups (
|
||||
id, menu_item_id, name, kind, selection,
|
||||
min_selections, max_selections, sort_order, time
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
group_id,
|
||||
item_id,
|
||||
group["name"],
|
||||
group.get("kind", "required"),
|
||||
group.get("selection", "one"),
|
||||
group.get("min", 0),
|
||||
group.get("max"),
|
||||
group_sort,
|
||||
now,
|
||||
),
|
||||
)
|
||||
for mod_sort, mod in enumerate(group.get("modifiers", [])):
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO modifiers (
|
||||
id, group_id, name, description, price_delta,
|
||||
is_default, sort_order, time
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
new_id(),
|
||||
group_id,
|
||||
mod["name"],
|
||||
mod.get("desc"),
|
||||
float(mod.get("delta", 0)),
|
||||
1 if mod.get("default") else 0,
|
||||
mod_sort,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _drop_restaurant(cur: sqlite3.Cursor, restaurant_id: str) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM modifiers WHERE group_id IN (
|
||||
SELECT mg.id FROM modifier_groups mg
|
||||
JOIN menu_items mi ON mi.id = mg.menu_item_id
|
||||
WHERE mi.restaurant_id = ?
|
||||
)
|
||||
""",
|
||||
(restaurant_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM modifier_groups WHERE menu_item_id IN (
|
||||
SELECT id FROM menu_items WHERE restaurant_id = ?
|
||||
)
|
||||
""",
|
||||
(restaurant_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM availability_windows WHERE menu_item_id IN ("
|
||||
"SELECT id FROM menu_items WHERE restaurant_id = ?)",
|
||||
(restaurant_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM menu_items WHERE restaurant_id = ?",
|
||||
(restaurant_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM menu_nodes WHERE restaurant_id = ?",
|
||||
(restaurant_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM restaurants WHERE id = ?",
|
||||
(restaurant_id,),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# main #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Seed Lightning Cafe (sats-priced demo restaurant) into "
|
||||
"ext_restaurant.sqlite3"
|
||||
)
|
||||
)
|
||||
p.add_argument(
|
||||
"--wallet",
|
||||
required=True,
|
||||
help="LNbits wallet id (operator's wallet)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--db",
|
||||
help="Path to ext_restaurant.sqlite3 (auto-detected if omitted)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--slug",
|
||||
default="lightning-cafe",
|
||||
help="URL slug (default: lightning-cafe)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Drop existing restaurant with this slug+wallet and recreate",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
db_path = find_db(args.db)
|
||||
seed(db_path, args.wallet, args.slug, args.force)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -23,9 +23,6 @@ window.app = Vue.createApp({
|
|||
statusColor(status) {
|
||||
return {paid: 'positive', accepted: 'blue', ready: 'amber'}[status] || 'grey'
|
||||
},
|
||||
cookingMode(order) {
|
||||
return order && order.status === 'accepted'
|
||||
},
|
||||
cardClass(order) {
|
||||
// Visually escalate as orders age. >5min = highlight; >15min = alarm.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@
|
|||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section
|
||||
:style="{'font-size': cookingMode(order) ? '1.25rem' : '1rem'}"
|
||||
>
|
||||
<q-card-section style="font-size: 1rem">
|
||||
<div v-for="line in order._items || []" :key="line.id">
|
||||
<div>
|
||||
<strong v-text="line.quantity + 'x'"></strong>
|
||||
|
|
@ -48,7 +46,6 @@
|
|||
<div
|
||||
v-if="line.selected_modifiers && line.selected_modifiers.length"
|
||||
class="text-caption text-grey-7 q-pl-md"
|
||||
:style="cookingMode(order) ? 'font-size: 1.15rem; font-weight: 500; color: inherit' : ''"
|
||||
>
|
||||
<span
|
||||
v-for="(m, i) in line.selected_modifiers"
|
||||
|
|
@ -61,7 +58,6 @@
|
|||
<div
|
||||
v-if="line.note"
|
||||
class="text-caption text-amber-9 q-pl-md"
|
||||
:style="cookingMode(order) ? 'font-size: 1.15rem; font-weight: 500' : ''"
|
||||
>
|
||||
<q-icon name="info" size="xs"></q-icon>
|
||||
<span v-text="line.note"></span>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue