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`
|
filtered by status. The KDS view escalates color by age (`>5min`
|
||||||
orange, `>15min` red) and offers one-tap state transitions.
|
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
|
Today the monitor + KDS poll every 5–8 s. SSE / Nostr push is on
|
||||||
the roadmap.
|
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) {
|
statusColor(status) {
|
||||||
return {paid: 'positive', accepted: 'blue', ready: 'amber'}[status] || 'grey'
|
return {paid: 'positive', accepted: 'blue', ready: 'amber'}[status] || 'grey'
|
||||||
},
|
},
|
||||||
cookingMode(order) {
|
|
||||||
return order && order.status === 'accepted'
|
|
||||||
},
|
|
||||||
cardClass(order) {
|
cardClass(order) {
|
||||||
// Visually escalate as orders age. >5min = highlight; >15min = alarm.
|
// Visually escalate as orders age. >5min = highlight; >15min = alarm.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,7 @@
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
<q-card-section
|
<q-card-section style="font-size: 1rem">
|
||||||
:style="{'font-size': cookingMode(order) ? '1.25rem' : '1rem'}"
|
|
||||||
>
|
|
||||||
<div v-for="line in order._items || []" :key="line.id">
|
<div v-for="line in order._items || []" :key="line.id">
|
||||||
<div>
|
<div>
|
||||||
<strong v-text="line.quantity + 'x'"></strong>
|
<strong v-text="line.quantity + 'x'"></strong>
|
||||||
|
|
@ -48,7 +46,6 @@
|
||||||
<div
|
<div
|
||||||
v-if="line.selected_modifiers && line.selected_modifiers.length"
|
v-if="line.selected_modifiers && line.selected_modifiers.length"
|
||||||
class="text-caption text-grey-7 q-pl-md"
|
class="text-caption text-grey-7 q-pl-md"
|
||||||
:style="cookingMode(order) ? 'font-size: 1.15rem; font-weight: 500; color: inherit' : ''"
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-for="(m, i) in line.selected_modifiers"
|
v-for="(m, i) in line.selected_modifiers"
|
||||||
|
|
@ -61,7 +58,6 @@
|
||||||
<div
|
<div
|
||||||
v-if="line.note"
|
v-if="line.note"
|
||||||
class="text-caption text-amber-9 q-pl-md"
|
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>
|
<q-icon name="info" size="xs"></q-icon>
|
||||||
<span v-text="line.note"></span>
|
<span v-text="line.note"></span>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue