From cbbb3c743bdf2b83277a3635f4f30b27d56b5cf6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 23:21:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20add=20seed=5Flightning=5Fcafe.?= =?UTF-8?q?py=20=E2=80=94=20sats-priced=20demo=20restaurant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --force Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/seed_lightning_cafe.py | 826 +++++++++++++++++++++++++++++++++ 1 file changed, 826 insertions(+) create mode 100644 scripts/seed_lightning_cafe.py diff --git a/scripts/seed_lightning_cafe.py b/scripts/seed_lightning_cafe.py new file mode 100644 index 0000000..05b2fe8 --- /dev/null +++ b/scripts/seed_lightning_cafe.py @@ -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 \\ + [--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()