mirror of
https://github.com/lnbits/lnbits.git
synced 2025-07-17 08:22:32 +02:00
basic offlineshop functionality.
This commit is contained in:
@ -131,14 +131,15 @@ async def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wa
|
|||||||
# ---------------
|
# ---------------
|
||||||
|
|
||||||
|
|
||||||
async def get_standalone_payment(checking_id: str) -> Optional[Payment]:
|
async def get_standalone_payment(checking_id_or_hash: str) -> Optional[Payment]:
|
||||||
row = await db.fetchone(
|
row = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM apipayments
|
FROM apipayments
|
||||||
WHERE checking_id = ?
|
WHERE checking_id = ? OR payment_hash = ?
|
||||||
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(checking_id,),
|
(checking_id_or_hash, checking_id_or_hash),
|
||||||
)
|
)
|
||||||
|
|
||||||
return Payment.from_row(row) if row else None
|
return Payment.from_row(row) if row else None
|
||||||
@ -285,11 +286,7 @@ async def create_payment(
|
|||||||
|
|
||||||
async def update_payment_status(checking_id: str, pending: bool) -> None:
|
async def update_payment_status(checking_id: str, pending: bool) -> None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
|
"UPDATE apipayments SET pending = ? WHERE checking_id = ?", (int(pending), checking_id,),
|
||||||
(
|
|
||||||
int(pending),
|
|
||||||
checking_id,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
1
lnbits/extensions/offlineshop/README.md
Normal file
1
lnbits/extensions/offlineshop/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Offline Shop
|
12
lnbits/extensions/offlineshop/__init__.py
Normal file
12
lnbits/extensions/offlineshop/__init__.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from quart import Blueprint
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
|
||||||
|
db = Database("ext_offlineshop")
|
||||||
|
|
||||||
|
offlineshop_ext: Blueprint = Blueprint("offlineshop", __name__, static_folder="static", template_folder="templates")
|
||||||
|
|
||||||
|
|
||||||
|
from .views_api import * # noqa
|
||||||
|
from .views import * # noqa
|
||||||
|
from .lnurl import * # noqa
|
8
lnbits/extensions/offlineshop/config.json
Normal file
8
lnbits/extensions/offlineshop/config.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "Offline Shop",
|
||||||
|
"short_description": "Sell stuff with Lightning and lnurlpay on a shop without internet or any electronic device.",
|
||||||
|
"icon": "nature_people",
|
||||||
|
"contributors": [
|
||||||
|
"fiatjaf"
|
||||||
|
]
|
||||||
|
}
|
80
lnbits/extensions/offlineshop/crud.py
Normal file
80
lnbits/extensions/offlineshop/crud.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .wordlists import animals
|
||||||
|
from .models import Shop, Item
|
||||||
|
|
||||||
|
|
||||||
|
async def create_shop(*, wallet_id: str) -> int:
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO shops (wallet, wordlist)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
(wallet_id, "\n".join(animals)),
|
||||||
|
)
|
||||||
|
return result._result_proxy.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
async def get_shop(id: int) -> Optional[Shop]:
|
||||||
|
row = await db.fetchone("SELECT * FROM shops WHERE id = ?", (id,))
|
||||||
|
return Shop(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]:
|
||||||
|
row = await db.fetchone("SELECT * FROM shops WHERE wallet = ?", (wallet,))
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
# create on the fly
|
||||||
|
ls_id = await create_shop(wallet_id=wallet)
|
||||||
|
return await get_shop(ls_id)
|
||||||
|
|
||||||
|
return Shop(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def add_item(shop: int, name: str, description: str, image: Optional[str], price: int, unit: str,) -> int:
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO items (shop, name, description, image, price, unit)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(shop, name, description, image, price, unit),
|
||||||
|
)
|
||||||
|
return result._result_proxy.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
async def update_item(
|
||||||
|
shop: int, item_id: int, name: str, description: str, image: Optional[str], price: int, unit: str,
|
||||||
|
) -> int:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE items SET
|
||||||
|
name = ?,
|
||||||
|
description = ?,
|
||||||
|
image = ?,
|
||||||
|
price = ?,
|
||||||
|
unit = ?
|
||||||
|
WHERE shop = ? AND id = ?
|
||||||
|
""",
|
||||||
|
(name, description, image, price, unit, shop, item_id),
|
||||||
|
)
|
||||||
|
return item_id
|
||||||
|
|
||||||
|
|
||||||
|
async def get_item(id: int) -> Optional[Item]:
|
||||||
|
row = await db.fetchone("SELECT * FROM items WHERE id = ? LIMIT 1", (id,))
|
||||||
|
return Item(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_items(shop: int) -> List[Item]:
|
||||||
|
rows = await db.fetchall("SELECT * FROM items WHERE shop = ?", (shop,))
|
||||||
|
return [Item(**dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_item_from_shop(shop: int, item_id: int):
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM items WHERE shop = ? AND id = ?
|
||||||
|
""",
|
||||||
|
(shop, item_id),
|
||||||
|
)
|
48
lnbits/extensions/offlineshop/helpers.py
Normal file
48
lnbits/extensions/offlineshop/helpers.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import trio # type: ignore
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fiat_rate(currency: str):
|
||||||
|
assert currency == "USD", "Only USD is supported as fiat currency."
|
||||||
|
return await get_usd_rate()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_usd_rate():
|
||||||
|
"""
|
||||||
|
Returns an average satoshi price from multiple sources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
satoshi_prices = [None, None, None]
|
||||||
|
|
||||||
|
async def fetch_price(index, url, getter):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(url)
|
||||||
|
r.raise_for_status()
|
||||||
|
satoshi_price = int(100_000_000 / float(getter(r.json())))
|
||||||
|
satoshi_prices[index] = satoshi_price
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with trio.open_nursery() as nursery:
|
||||||
|
nursery.start_soon(
|
||||||
|
fetch_price,
|
||||||
|
0,
|
||||||
|
"https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD",
|
||||||
|
lambda d: d["result"]["XXBTCZUSD"]["c"][0],
|
||||||
|
)
|
||||||
|
nursery.start_soon(
|
||||||
|
fetch_price,
|
||||||
|
1,
|
||||||
|
"https://www.bitstamp.net/api/v2/ticker/btcusd",
|
||||||
|
lambda d: d["last"],
|
||||||
|
)
|
||||||
|
nursery.start_soon(
|
||||||
|
fetch_price,
|
||||||
|
2,
|
||||||
|
"https://api.coincap.io/v2/rates/bitcoin",
|
||||||
|
lambda d: d["data"]["rateUsd"],
|
||||||
|
)
|
||||||
|
|
||||||
|
satoshi_prices = [x for x in satoshi_prices if x]
|
||||||
|
return sum(satoshi_prices) / len(satoshi_prices)
|
63
lnbits/extensions/offlineshop/lnurl.py
Normal file
63
lnbits/extensions/offlineshop/lnurl.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import hashlib
|
||||||
|
from quart import jsonify, url_for, request
|
||||||
|
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||||
|
|
||||||
|
from lnbits.core.services import create_invoice
|
||||||
|
|
||||||
|
from . import offlineshop_ext
|
||||||
|
from .crud import get_shop, get_item
|
||||||
|
from .helpers import get_fiat_rate
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
|
||||||
|
async def lnurl_response(item_id):
|
||||||
|
item = await get_item(item_id)
|
||||||
|
if not item:
|
||||||
|
return jsonify({"status": "ERROR", "reason": "Item not found."})
|
||||||
|
|
||||||
|
rate = await get_fiat_rate(item.unit) if item.unit != "sat" else 1
|
||||||
|
price_msat = item.price * 1000 * rate
|
||||||
|
|
||||||
|
resp = LnurlPayResponse(
|
||||||
|
callback=url_for("shop.lnurl_callback", item_id=item.id, _external=True),
|
||||||
|
min_sendable=price_msat,
|
||||||
|
max_sendable=price_msat,
|
||||||
|
metadata=await item.lnurlpay_metadata(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(resp.dict())
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/lnurl/cb/<item_id>", methods=["GET"])
|
||||||
|
async def lnurl_callback(item_id):
|
||||||
|
item = await get_item(item_id)
|
||||||
|
if not item:
|
||||||
|
return jsonify({"status": "ERROR", "reason": "Couldn't find item."})
|
||||||
|
|
||||||
|
if item.unit == "sat":
|
||||||
|
min = item.price * 1000
|
||||||
|
max = item.price * 1000
|
||||||
|
else:
|
||||||
|
rate = await get_fiat_rate(item.unit)
|
||||||
|
# allow some fluctuation (the fiat price may have changed between the calls)
|
||||||
|
min = rate * 995 * item.price
|
||||||
|
max = rate * 1010 * item.price
|
||||||
|
|
||||||
|
amount_received = int(request.args.get("amount"))
|
||||||
|
if amount_received < min:
|
||||||
|
return jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is smaller than minimum {min}.").dict())
|
||||||
|
elif amount_received > max:
|
||||||
|
return jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is greater than maximum {max}.").dict())
|
||||||
|
|
||||||
|
shop = await get_shop(item.shop)
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=shop.wallet,
|
||||||
|
amount=int(amount_received / 1000),
|
||||||
|
memo=await item.name,
|
||||||
|
description_hash=hashlib.sha256((await item.lnurlpay_metadata()).encode("utf-8")).digest(),
|
||||||
|
extra={"tag": "offlineshop", "item": item.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = LnurlPayActionResponse(pr=payment_request, success_action=item.success_action(payment_hash, shop), routes=[])
|
||||||
|
|
||||||
|
return jsonify(resp.dict())
|
28
lnbits/extensions/offlineshop/migrations.py
Normal file
28
lnbits/extensions/offlineshop/migrations.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial offlineshop tables.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE shops (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
wordlist TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE items (
|
||||||
|
shop INTEGER NOT NULL REFERENCES shop (id),
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
image TEXT, -- image/png;base64,...
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
price INTEGER NOT NULL,
|
||||||
|
unit TEXT NOT NULL DEFAULT 'sat'
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
65
lnbits/extensions/offlineshop/models.py
Normal file
65
lnbits/extensions/offlineshop/models.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import json
|
||||||
|
from collections import OrderedDict
|
||||||
|
from quart import url_for
|
||||||
|
from typing import NamedTuple, Optional
|
||||||
|
from lnurl import encode as lnurl_encode # type: ignore
|
||||||
|
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||||
|
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class Shop(NamedTuple):
|
||||||
|
id: int
|
||||||
|
wallet: str
|
||||||
|
wordlist: str
|
||||||
|
|
||||||
|
def get_word(self, payment_hash):
|
||||||
|
# initialize confirmation words cache
|
||||||
|
self.fulfilled_payments = self.words or OrderedDict()
|
||||||
|
|
||||||
|
if payment_hash in self.fulfilled_payments:
|
||||||
|
return self.fulfilled_payments[payment_hash]
|
||||||
|
|
||||||
|
# get a new word
|
||||||
|
self.counter = (self.counter or -1) + 1
|
||||||
|
wordlist = self.wordlist.split("\n")
|
||||||
|
word = [self.counter % len(wordlist)]
|
||||||
|
|
||||||
|
# cleanup confirmation words cache
|
||||||
|
to_remove = self.fulfilled_payments - 23
|
||||||
|
if to_remove > 0:
|
||||||
|
for i in range(to_remove):
|
||||||
|
self.fulfilled_payments.popitem(False)
|
||||||
|
|
||||||
|
return word
|
||||||
|
|
||||||
|
|
||||||
|
class Item(NamedTuple):
|
||||||
|
shop: int
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
image: str
|
||||||
|
enabled: bool
|
||||||
|
price: int
|
||||||
|
unit: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lnurl(self) -> str:
|
||||||
|
return lnurl_encode(url_for("offlineshop.lnurl_response", item_id=self.id))
|
||||||
|
|
||||||
|
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||||
|
metadata = [["text/plain", self.description]]
|
||||||
|
|
||||||
|
if self.image:
|
||||||
|
metadata.append(self.image.split(","))
|
||||||
|
|
||||||
|
return LnurlPayMetadata(json.dumps(metadata))
|
||||||
|
|
||||||
|
def success_action(self, shop: Shop, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
|
||||||
|
if not self.wordlist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return UrlAction(
|
||||||
|
url=url_for("offlineshop.confirmation_code", p=payment_hash, _external=True),
|
||||||
|
description="Open to get the confirmation code for your purchase.",
|
||||||
|
)
|
157
lnbits/extensions/offlineshop/static/js/index.js
Normal file
157
lnbits/extensions/offlineshop/static/js/index.js
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||||
|
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
const pica = window.pica()
|
||||||
|
|
||||||
|
const defaultItemData = {
|
||||||
|
unit: 'sat'
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedWallet: null,
|
||||||
|
offlineshop: {
|
||||||
|
wordlist: [],
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
itemDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {...defaultItemData},
|
||||||
|
units: ['sat', 'USD']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async imageAdded(file) {
|
||||||
|
let image = new Image()
|
||||||
|
image.src = URL.createObjectURL(file)
|
||||||
|
let canvas = document.getElementById('uploading-image')
|
||||||
|
image.onload = async () => {
|
||||||
|
canvas.setAttribute('width', 300)
|
||||||
|
canvas.setAttribute('height', 300)
|
||||||
|
await pica.resize(image, canvas)
|
||||||
|
this.itemDialog.data.image = canvas.toDataURL()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
imageCleared() {
|
||||||
|
this.itemDialog.data.image = null
|
||||||
|
let canvas = document.getElementById('uploading-image')
|
||||||
|
canvas.setAttribute('height', 0)
|
||||||
|
canvas.setAttribute('width', 0)
|
||||||
|
let ctx = canvas.getContext('2d')
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
},
|
||||||
|
disabledAddItemButton() {
|
||||||
|
return (
|
||||||
|
!this.itemDialog.data.name ||
|
||||||
|
this.itemDialog.data.name.length === 0 ||
|
||||||
|
!this.itemDialog.data.price ||
|
||||||
|
!this.itemDialog.data.description ||
|
||||||
|
!this.itemDialog.data.unit ||
|
||||||
|
this.itemDialog.data.unit.length === 0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
changedWallet(wallet) {
|
||||||
|
this.selectedWallet = wallet
|
||||||
|
this.loadShop()
|
||||||
|
},
|
||||||
|
loadShop() {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/offlineshop/api/v1/offlineshop',
|
||||||
|
this.selectedWallet.inkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.offlineshop = response.data
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addItem() {
|
||||||
|
let {name, image, description, price, unit} = this.itemDialog.data
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'POST',
|
||||||
|
'/offlineshop/api/v1/offlineshop/items',
|
||||||
|
this.selectedWallet.inkey,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
price,
|
||||||
|
unit
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.$q.notify({
|
||||||
|
message: `Item '${this.itemDialog.data.name}' added.`,
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
|
this.loadShop()
|
||||||
|
this.itemsDialog.show = false
|
||||||
|
this.itemsDialog.data = {...defaultItemData}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
toggleItem(itemId) {
|
||||||
|
let item = this.offlineshop.items.find(item => item.id === itemId)
|
||||||
|
item.enabled = !item.enabled
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/offlineshop/api/v1/offlineshop/items/' + itemId,
|
||||||
|
this.selectedWallet.inkey,
|
||||||
|
item
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.$q.notify({
|
||||||
|
message: `Item ${item.enabled ? 'enabled' : 'disabled'}.`,
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
|
this.offlineshop.items = this.offlineshop.items
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteItem(itemId) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this item?')
|
||||||
|
.onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/offlineshop/api/v1/offlineshop/items/' + itemId,
|
||||||
|
this.selectedWallet.inkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.$q.notify({
|
||||||
|
message: `Item deleted.`,
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
|
this.offlineshop.items.splice(
|
||||||
|
this.offlineshop.items.findIndex(item => item.id === itemId),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.selectedWallet = this.g.user.wallets[0]
|
||||||
|
this.loadShop()
|
||||||
|
}
|
||||||
|
})
|
@ -0,0 +1,46 @@
|
|||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="How to use"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>
|
||||||
|
Sell stuff offline, accept Lightning payments. Powered by LNURL-pay.
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="API info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Get the shop data along with items"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">GET</span></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<offlineshop object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H
|
||||||
|
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
195
lnbits/extensions/offlineshop/templates/offlineshop/index.html
Normal file
195
lnbits/extensions/offlineshop/templates/offlineshop/index.html
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Items</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col q-ml-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
@click="itemDialog.show = true"
|
||||||
|
>Add new item</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% raw %}
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="offlineshop.items"
|
||||||
|
row-key="id"
|
||||||
|
no-data-label="No items for sale yet"
|
||||||
|
:pagination="{rowsPerPage: 0}"
|
||||||
|
:binary-state-sort="true"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width>Name</q-th>
|
||||||
|
<q-th auto-width>Description</q-th>
|
||||||
|
<q-th auto-width>Image</q-th>
|
||||||
|
<q-th auto-width>Price</q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
:icon="props.row.enabled ? 'done' : 'block'"
|
||||||
|
:color="props.row.enabled ? 'green' : ($q.dark.isActive ? 'grey-7' : 'grey-5')"
|
||||||
|
type="a"
|
||||||
|
@click="toggleItem(props.row.id)"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>{{ props.row.name }}</q-td>
|
||||||
|
<q-td auto-width> {{ props.row.description }} </q-td>
|
||||||
|
<q-td class="text-right" auto-width>
|
||||||
|
<q-img
|
||||||
|
v-if="props.row.image"
|
||||||
|
:src="props.row.image"
|
||||||
|
:ratio="1"
|
||||||
|
style="height: 1em"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
<q-td class="text-center" auto-width>
|
||||||
|
{{ props.row.price }} {{ props.row.unit }}
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="delete"
|
||||||
|
color="negative"
|
||||||
|
type="a"
|
||||||
|
@click="deleteItem(props.row.id)"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card class="q-pa-sm col-5">
|
||||||
|
<q-card-section class="q-pa-none text-center">
|
||||||
|
<q-form class="q-gutter-md">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
:options="g.user.wallets"
|
||||||
|
:value="selectedWallet"
|
||||||
|
label="Using wallet:"
|
||||||
|
option-label="name"
|
||||||
|
@input="changedWallet"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">LNbits OfflineShop extension</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "offlineshop/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="itemDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-card-section>
|
||||||
|
<q-form @submit="addItem" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="itemDialog.data.name"
|
||||||
|
type="text"
|
||||||
|
label="Item name"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="itemDialog.data.description"
|
||||||
|
type="text"
|
||||||
|
label="Brief description"
|
||||||
|
></q-input>
|
||||||
|
<q-file
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
capture="environment"
|
||||||
|
accept="image/jpeg, image/png"
|
||||||
|
:max-file-size="1048576"
|
||||||
|
label="Small image (optional)"
|
||||||
|
clearable
|
||||||
|
@input="imageAdded"
|
||||||
|
@clear="imageCleared"
|
||||||
|
>
|
||||||
|
<template v-if="itemDialog.data.image" v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
name="cancel"
|
||||||
|
@click.stop.prevent="imageCleared"
|
||||||
|
class="cursor-pointer"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-file>
|
||||||
|
<canvas id="uploading-image"></canvas>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="itemDialog.data.price"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:label="`Item price (${itemDialog.data.unit})`"
|
||||||
|
></q-input>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="itemDialog.data.unit"
|
||||||
|
type="text"
|
||||||
|
label="Unit"
|
||||||
|
:options="itemDialog.units"
|
||||||
|
></q-select>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<div class="col q-ml-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
:disable="disabledAddItemButton()"
|
||||||
|
type="submit"
|
||||||
|
>Add item</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col q-ml-lg">
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
|
||||||
|
<script src="/offlineshop/static/js/index.js"></script>
|
||||||
|
{% endblock %}
|
34
lnbits/extensions/offlineshop/views.py
Normal file
34
lnbits/extensions/offlineshop/views.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import time
|
||||||
|
from quart import g, render_template, request
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from lnbits.decorators import check_user_exists, validate_uuids
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.core.crud import get_standalone_payment
|
||||||
|
|
||||||
|
from . import offlineshop_ext
|
||||||
|
from .crud import get_item, get_shop
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/")
|
||||||
|
@validate_uuids(["usr"], required=True)
|
||||||
|
@check_user_exists()
|
||||||
|
async def index():
|
||||||
|
return await render_template("offlineshop/index.html", user=g.user)
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/confirmation")
|
||||||
|
async def confirmation_code():
|
||||||
|
payment_hash = request.args.get("p")
|
||||||
|
payment: Payment = await get_standalone_payment(payment_hash)
|
||||||
|
if not payment:
|
||||||
|
return f"Couldn't find the payment {payment_hash}.", HTTPStatus.NOT_FOUND
|
||||||
|
if payment.pending:
|
||||||
|
return f"Payment {payment_hash} wasn't received yet. Please try again in a minute.", HTTPStatus.PAYMENT_REQUIRED
|
||||||
|
|
||||||
|
if payment.time + 60 * 15 < time.time():
|
||||||
|
return "too much time has passed."
|
||||||
|
|
||||||
|
item = await get_item(payment.extra.get("item"))
|
||||||
|
shop = await get_shop(item.shop)
|
||||||
|
return shop.next_word(payment_hash)
|
72
lnbits/extensions/offlineshop/views_api.py
Normal file
72
lnbits/extensions/offlineshop/views_api.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from quart import g, jsonify
|
||||||
|
from http import HTTPStatus
|
||||||
|
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||||
|
|
||||||
|
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||||
|
|
||||||
|
from . import offlineshop_ext
|
||||||
|
from .crud import (
|
||||||
|
get_or_create_shop_by_wallet,
|
||||||
|
add_item,
|
||||||
|
update_item,
|
||||||
|
get_items,
|
||||||
|
delete_item_from_shop,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/api/v1/offlineshop", methods=["GET"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_shop_from_wallet():
|
||||||
|
shop = await get_or_create_shop_by_wallet(g.wallet.id)
|
||||||
|
items = await get_items(shop.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
jsonify({**shop._asdict(), **{"items": [item._asdict() for item in items],},}),
|
||||||
|
HTTPStatus.OK,
|
||||||
|
)
|
||||||
|
except LnurlInvalidUrl:
|
||||||
|
return (
|
||||||
|
jsonify({"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."}),
|
||||||
|
HTTPStatus.UPGRADE_REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/api/v1/offlineshop/items", methods=["POST"])
|
||||||
|
@offlineshop_ext.route("/api/v1/offlineshop/items/<item_id>", methods=["PUT"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
@api_validate_post_request(
|
||||||
|
schema={
|
||||||
|
"name": {"type": "string", "empty": False, "required": True},
|
||||||
|
"description": {"type": "string", "empty": False, "required": True},
|
||||||
|
"image": {"type": "string", "required": False},
|
||||||
|
"price": {"type": "number", "required": True},
|
||||||
|
"unit": {"type": "string", "allowed": ["sat", "USD"], "required": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def api_add_or_update_item(item_id=None):
|
||||||
|
shop = await get_or_create_shop_by_wallet(g.wallet.id)
|
||||||
|
if item_id == None:
|
||||||
|
await add_item(
|
||||||
|
shop.id, g.data["name"], g.data["description"], g.data.get("image"), g.data["price"], g.data["unit"],
|
||||||
|
)
|
||||||
|
return "", HTTPStatus.CREATED
|
||||||
|
else:
|
||||||
|
await update_item(
|
||||||
|
shop.id,
|
||||||
|
item_id,
|
||||||
|
g.data["name"],
|
||||||
|
g.data["description"],
|
||||||
|
g.data.get("image"),
|
||||||
|
g.data["price"],
|
||||||
|
g.data["unit"],
|
||||||
|
)
|
||||||
|
return "", HTTPStatus.OK
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/api/v1/offlineshop/items/<item_id>", methods=["DELETE"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_delete_item(item_id):
|
||||||
|
shop = await get_or_create_shop_by_wallet(g.wallet.id)
|
||||||
|
await delete_item_from_shop(shop.id, item_id)
|
||||||
|
return "", HTTPStatus.NO_CONTENT
|
28
lnbits/extensions/offlineshop/wordlists.py
Normal file
28
lnbits/extensions/offlineshop/wordlists.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
animals = [
|
||||||
|
"albatross",
|
||||||
|
"bison",
|
||||||
|
"chicken",
|
||||||
|
"duck",
|
||||||
|
"eagle",
|
||||||
|
"flamingo",
|
||||||
|
"gorila",
|
||||||
|
"hamster",
|
||||||
|
"iguana",
|
||||||
|
"jaguar",
|
||||||
|
"koala",
|
||||||
|
"llama",
|
||||||
|
"macaroni penguim",
|
||||||
|
"numbat",
|
||||||
|
"octopus",
|
||||||
|
"platypus",
|
||||||
|
"quetzal",
|
||||||
|
"rabbit",
|
||||||
|
"salmon",
|
||||||
|
"tuna",
|
||||||
|
"unicorn",
|
||||||
|
"vulture",
|
||||||
|
"wolf",
|
||||||
|
"xenops",
|
||||||
|
"yak",
|
||||||
|
"zebra",
|
||||||
|
]
|
Reference in New Issue
Block a user