feat(scripts): add seed_lightning_cafe.py — sats-priced demo restaurant
The canonical demo seed for the restaurant extension + webapp
bundle. Drops a "Lightning Cafe" restaurant at slug=lightning-cafe
with currency='sat', exercising every feature surface so it
doubles as a visual smoke-test fixture:
- Menu tree (depth 2: Drinks→{Coffee,Cold,Tea}, Food→{Breakfast,
Lunch,Snacks})
- 19 items with descriptions, dietary tags, allergens, ingredients,
calorie counts
- Featured items (is_featured=1) — Latte, Avocado Toast, Lightning
Burger
- Sold-out item (is_available=0) — Daily Special
- Low-stock item (stock=3, low_stock_threshold=5) — Fresh OJ
- Multiple modifier groups per item showing all three patterns:
required/one (radio, e.g. Milk choice)
required/many (limited multi, e.g. Burger extras max 4)
optional/many (free multi, e.g. Avocado Toast toppings)
- Modifier price deltas in sats (+50 for oat milk, +200 for egg, …)
The /var/lib/lnbits/data path is in the auto-detect list so the
script Just Works on the standard NixOS lnbits service layout.
Usage:
python3 scripts/seed_lightning_cafe.py --wallet <wallet_id> --force
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
42746d7321
commit
cbbb3c743b
1 changed files with 826 additions and 0 deletions
826
scripts/seed_lightning_cafe.py
Normal file
826
scripts/seed_lightning_cafe.py
Normal file
|
|
@ -0,0 +1,826 @@
|
||||||
|
#!/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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue