Merge pull request #9 from lnbits/receive_orders

Receive orders
This commit is contained in:
Vlad Stan 2023-03-07 14:36:01 +02:00 committed by GitHub
commit b4452b2141
15 changed files with 1055 additions and 65 deletions

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
from asyncio import Queue, Task
from typing import List from typing import List
from fastapi import APIRouter from fastapi import APIRouter
@ -25,15 +26,29 @@ def nostrmarket_renderer():
return template_renderer(["lnbits/extensions/nostrmarket/templates"]) return template_renderer(["lnbits/extensions/nostrmarket/templates"])
scheduled_tasks: List[asyncio.Task] = [] recieve_event_queue: Queue = Queue()
send_req_queue: Queue = Queue()
scheduled_tasks: List[Task] = []
from .tasks import subscribe_nostrclient_ws, wait_for_paid_invoices
from .tasks import (
subscribe_to_nostr_client,
wait_for_nostr_events,
wait_for_paid_invoices,
)
from .views import * # noqa from .views import * # noqa
from .views_api import * # noqa from .views_api import * # noqa
def nostrmarket_start(): def nostrmarket_start():
async def _subscribe_to_nostr_client():
await subscribe_to_nostr_client(recieve_event_queue, send_req_queue)
async def _wait_for_nostr_events():
await wait_for_nostr_events(recieve_event_queue, send_req_queue)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
task1 = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) task1 = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
task2 = loop.create_task(catch_everything_and_restart(subscribe_nostrclient_ws)) task2 = loop.create_task(catch_everything_and_restart(_subscribe_to_nostr_client))
scheduled_tasks.append([task1, task2]) task3 = loop.create_task(catch_everything_and_restart(_wait_for_nostr_events))
scheduled_tasks.append([task1, task2, task3])

142
crud.py
View file

@ -1,5 +1,4 @@
import json import json
import time
from typing import List, Optional from typing import List, Optional
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
@ -7,6 +6,7 @@ from lnbits.helpers import urlsafe_short_hash
from . import db from . import db
from .models import ( from .models import (
Merchant, Merchant,
Order,
PartialMerchant, PartialMerchant,
PartialProduct, PartialProduct,
PartialStall, PartialStall,
@ -45,6 +45,23 @@ async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
return Merchant.from_row(row) if row else None return Merchant.from_row(row) if row else None
async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
row = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE public_key = ? """,
(public_key,),
)
return Merchant.from_row(row) if row else None
async def get_public_keys_for_merchants() -> List[str]:
rows = await db.fetchall(
"""SELECT public_key FROM nostrmarket.merchants""",
)
return [row[0] for row in rows]
async def get_merchant_for_user(user_id: str) -> Optional[Merchant]: async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
row = await db.fetchone( row = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE user_id = ? """, """SELECT * FROM nostrmarket.merchants WHERE user_id = ? """,
@ -189,7 +206,7 @@ async def delete_stall(user_id: str, stall_id: str) -> None:
) )
######################################## STALL ######################################## ######################################## PRODUCTS ########################################
async def create_product(user_id: str, data: PartialProduct) -> Product: async def create_product(user_id: str, data: PartialProduct) -> Product:
@ -197,7 +214,7 @@ async def create_product(user_id: str, data: PartialProduct) -> Product:
await db.execute( await db.execute(
f""" f"""
INSERT INTO nostrmarket.products (user_id, id, stall_id, name, images, price, quantity, category_list, meta) INSERT INTO nostrmarket.products (user_id, id, stall_id, name, image, price, quantity, category_list, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
@ -261,6 +278,28 @@ async def get_products(user_id: str, stall_id: str) -> List[Product]:
return [Product.from_row(row) for row in rows] return [Product.from_row(row) for row in rows]
async def get_products_by_ids(user_id: str, product_ids: List[str]) -> List[Product]:
q = ",".join(["?"] * len(product_ids))
rows = await db.fetchall(
f"SELECT id, stall_id, name, price, quantity, category_list, meta FROM nostrmarket.products WHERE user_id = ? AND id IN ({q})",
(user_id, *product_ids),
)
return [Product.from_row(row) for row in rows]
async def get_wallet_for_product(product_id: str) -> Optional[str]:
row = await db.fetchone(
"""
SELECT s.wallet FROM nostrmarket.products p
INNER JOIN nostrmarket.stalls s
ON p.stall_id = s.id
WHERE p.id=?
""",
(product_id,),
)
return row[0] if row else None
async def delete_product(user_id: str, product_id: str) -> None: async def delete_product(user_id: str, product_id: str) -> None:
await db.execute( await db.execute(
"DELETE FROM nostrmarket.products WHERE user_id =? AND id = ?", "DELETE FROM nostrmarket.products WHERE user_id =? AND id = ?",
@ -269,3 +308,100 @@ async def delete_product(user_id: str, product_id: str) -> None:
product_id, product_id,
), ),
) )
######################################## ORDERS ########################################
async def create_order(user_id: str, o: Order) -> Order:
await db.execute(
f"""
INSERT INTO nostrmarket.orders (user_id, id, event_id, pubkey, address, contact_data, extra_data, order_items, stall_id, invoice_id, total)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
o.id,
o.event_id,
o.pubkey,
o.address,
json.dumps(o.contact.dict() if o.contact else {}),
json.dumps(o.extra.dict()),
json.dumps([i.dict() for i in o.items]),
o.stall_id,
o.invoice_id,
o.total,
),
)
order = await get_order(user_id, o.id)
assert order, "Newly created order couldn't be retrieved"
return order
async def get_order(user_id: str, order_id: str) -> Optional[Order]:
row = await db.fetchone(
"SELECT * FROM nostrmarket.orders WHERE user_id =? AND id = ?",
(
user_id,
order_id,
),
)
return Order.from_row(row) if row else None
async def get_order_by_event_id(user_id: str, event_id: str) -> Optional[Order]:
row = await db.fetchone(
"SELECT * FROM nostrmarket.orders WHERE user_id =? AND event_id =?",
(
user_id,
event_id,
),
)
return Order.from_row(row) if row else None
async def get_orders(user_id: str) -> List[Order]:
rows = await db.fetchall(
"SELECT * FROM nostrmarket.orders WHERE user_id = ? ORDER BY time DESC",
(user_id,),
)
return [Order.from_row(row) for row in rows]
async def get_orders_for_stall(user_id: str, stall_id: str) -> List[Order]:
rows = await db.fetchall(
"SELECT * FROM nostrmarket.orders WHERE user_id = ? AND stall_id = ? ORDER BY time DESC",
(
user_id,
stall_id,
),
)
return [Order.from_row(row) for row in rows]
async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]:
await db.execute(
f"UPDATE nostrmarket.orders SET paid = ? WHERE id = ?",
(paid, order_id),
)
row = await db.fetchone(
"SELECT * FROM nostrmarket.orders WHERE id = ?",
(order_id,),
)
return Order.from_row(row) if row else None
async def update_order_shipped_status(
user_id: str, order_id: str, shipped: bool
) -> Optional[Order]:
await db.execute(
f"UPDATE nostrmarket.orders SET shipped = ? WHERE user_id = ? AND id = ?",
(shipped, user_id, order_id),
)
row = await db.fetchone(
"SELECT * FROM nostrmarket.orders WHERE id = ?",
(order_id,),
)
return Order.from_row(row) if row else None

View file

@ -1,7 +1,7 @@
import base64 import base64
import json import json
import secrets import secrets
from typing import Optional from typing import Any, Optional, Tuple
import secp256k1 import secp256k1
from cffi import FFI from cffi import FFI
@ -31,7 +31,7 @@ def decrypt_message(encoded_message: str, encryption_key) -> str:
return unpadded_data.decode() return unpadded_data.decode()
def encrypt_message(message: str, encryption_key, iv: Optional[bytes]) -> str: def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) -> str:
padder = padding.PKCS7(128).padder() padder = padding.PKCS7(128).padder()
padded_data = padder.update(message.encode()) + padder.finalize() padded_data = padder.update(message.encode()) + padder.finalize()
@ -73,9 +73,11 @@ def copy_x(output, x32, y32, data):
return 1 return 1
def is_json(string: str): def order_from_json(s: str) -> Tuple[Optional[Any], Optional[str]]:
try: try:
json.loads(string) order = json.loads(s)
except ValueError as e: return (
return False (order, None) if (type(order) is dict) and "items" in order else (None, s)
return True )
except ValueError:
return None, s

View file

@ -71,39 +71,28 @@ async def m001_initial(db):
""" """
Initial orders table. Initial orders table.
""" """
empty_object = "{}"
await db.execute( await db.execute(
f""" f"""
CREATE TABLE nostrmarket.orders ( CREATE TABLE nostrmarket.orders (
user_id TEXT NOT NULL,
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, event_id TEXT,
username TEXT, pubkey TEXT NOT NULL,
pubkey TEXT, contact_data TEXT NOT NULL DEFAULT '{empty_object}',
shipping_zone TEXT NOT NULL, extra_data TEXT NOT NULL DEFAULT '{empty_object}',
order_items TEXT NOT NULL,
address TEXT, address TEXT,
email TEXT,
total REAL NOT NULL, total REAL NOT NULL,
stall_id TEXT NOT NULL,
invoice_id TEXT NOT NULL, invoice_id TEXT NOT NULL,
paid BOOLEAN NOT NULL, paid BOOLEAN NOT NULL DEFAULT false,
shipped BOOLEAN NOT NULL, shipped BOOLEAN NOT NULL DEFAULT false,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
); );
""" """
) )
"""
Initial order details table.
"""
await db.execute(
f"""
CREATE TABLE nostrmarket.order_details (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
product_id TEXT NOT NULL,
quantity INTEGER NOT NULL
);
"""
)
""" """
Initial market table. Initial market table.
""" """
@ -117,19 +106,6 @@ async def m001_initial(db):
""" """
) )
"""
Initial market stalls table.
"""
await db.execute(
f"""
CREATE TABLE nostrmarket.market_stalls (
id TEXT PRIMARY KEY,
market_id TEXT NOT NULL,
stall_id TEXT NOT NULL
);
"""
)
""" """
Initial chat messages table. Initial chat messages table.
""" """

148
models.py
View file

@ -6,7 +6,14 @@ from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from .helpers import sign_message_hash from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
from .helpers import (
decrypt_message,
encrypt_message,
get_shared_secret,
sign_message_hash,
)
from .nostr.event import NostrEvent from .nostr.event import NostrEvent
######################################## NOSTR ######################################## ######################################## NOSTR ########################################
@ -39,6 +46,28 @@ class Merchant(PartialMerchant):
def sign_hash(self, hash: bytes) -> str: def sign_hash(self, hash: bytes) -> str:
return sign_message_hash(self.private_key, hash) return sign_message_hash(self.private_key, hash)
def decrypt_message(self, encrypted_message: str, public_key: str) -> str:
encryption_key = get_shared_secret(self.private_key, public_key)
return decrypt_message(encrypted_message, encryption_key)
def encrypt_message(self, clear_text_message: str, public_key: str) -> str:
encryption_key = get_shared_secret(self.private_key, public_key)
return encrypt_message(clear_text_message, encryption_key)
def build_dm_event(self, message: str, to_pubkey: str) -> NostrEvent:
content = self.encrypt_message(message, to_pubkey)
event = NostrEvent(
pubkey=self.public_key,
created_at=round(time.time()),
kind=4,
tags=[["p", to_pubkey]],
content=content,
)
event.id = event.event_id
event.sig = self.sign_hash(bytes.fromhex(event.id))
return event
@classmethod @classmethod
def from_row(cls, row: Row) -> "Merchant": def from_row(cls, row: Row) -> "Merchant":
merchant = cls(**dict(row)) merchant = cls(**dict(row))
@ -213,3 +242,120 @@ class Product(PartialProduct, Nostrable):
product.config = ProductConfig(**json.loads(row["meta"])) product.config = ProductConfig(**json.loads(row["meta"]))
product.categories = json.loads(row["category_list"]) product.categories = json.loads(row["category_list"])
return product return product
class ProductOverview(BaseModel):
id: str
name: str
price: float
######################################## ORDERS ########################################
class OrderItem(BaseModel):
product_id: str
quantity: int
class OrderContact(BaseModel):
nostr: Optional[str]
phone: Optional[str]
email: Optional[str]
class OrderExtra(BaseModel):
products: List[ProductOverview]
currency: str
btc_price: str
@classmethod
async def from_products(cls, products: List[Product]):
currency = products[0].config.currency
exchange_rate = (
(await btc_price(currency)) if currency and currency != "sat" else 1
)
return OrderExtra(products=products, currency=currency, btc_price=exchange_rate)
class PartialOrder(BaseModel):
id: str
event_id: Optional[str]
pubkey: str
items: List[OrderItem]
contact: Optional[OrderContact]
address: Optional[str]
def validate_order(self):
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
def validate_order_items(self, product_list: List[Product]):
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
assert (
len(product_list) != 0
), f"No products found for order. Order: '{self.id}'"
product_ids = [p.id for p in product_list]
for item in self.items:
if item.product_id not in product_ids:
raise ValueError(
f"Order ({self.id}) item product does not exist: {item.product_id}"
)
stall_id = product_list[0].stall_id
for p in product_list:
if p.stall_id != stall_id:
raise ValueError(
f"Order ({self.id}) has products from different stalls"
)
async def total_sats(self, products: List[Product]) -> float:
product_prices = {}
for p in products:
product_prices[p.id] = p
amount: float = 0 # todo
for item in self.items:
price = product_prices[item.product_id].price
currency = product_prices[item.product_id].config.currency or "sat"
if currency != "sat":
price = await fiat_amount_as_satoshis(price, currency)
amount += item.quantity * price
return amount
class Order(PartialOrder):
stall_id: str
invoice_id: str
total: float
paid: bool = False
shipped: bool = False
time: Optional[int]
extra: OrderExtra
@classmethod
def from_row(cls, row: Row) -> "Order":
contact = OrderContact(**json.loads(row["contact_data"]))
extra = OrderExtra(**json.loads(row["extra_data"]))
items = [OrderItem(**z) for z in json.loads(row["order_items"])]
order = cls(**dict(row), contact=contact, items=items, extra=extra)
return order
class OrderStatusUpdate(BaseModel):
id: str
message: Optional[str]
paid: Optional[bool]
shipped: Optional[bool]
class PaymentOption(BaseModel):
type: str
link: str
class PaymentRequest(BaseModel):
id: str
message: Optional[str]
payment_options: List[PaymentOption]

View file

@ -1,5 +1,9 @@
from threading import Thread
from typing import Callable
import httpx import httpx
from loguru import logger from loguru import logger
from websocket import WebSocketApp
from lnbits.app import settings from lnbits.app import settings
from lnbits.helpers import url_for from lnbits.helpers import url_for
@ -10,7 +14,7 @@ from .event import NostrEvent
async def publish_nostr_event(e: NostrEvent): async def publish_nostr_event(e: NostrEvent):
url = url_for("/nostrclient/api/v1/publish", external=True) url = url_for("/nostrclient/api/v1/publish", external=True)
data = dict(e) data = dict(e)
# print("### published", dict(data)) print("### published", dict(data))
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
await client.post( await client.post(
@ -19,3 +23,44 @@ async def publish_nostr_event(e: NostrEvent):
) )
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
async def connect_to_nostrclient_ws(
on_open: Callable, on_message: Callable
) -> WebSocketApp:
def on_error(_, error):
logger.warning(error)
logger.debug(f"Subscribing to websockets for nostrclient extension")
ws = WebSocketApp(
f"ws://localhost:{settings.port}/nostrclient/api/v1/filters",
on_message=on_message,
on_open=on_open,
on_error=on_error,
)
wst = Thread(target=ws.run_forever)
wst.daemon = True
wst.start()
return ws
# async def handle_event(event, pubkeys):
# tags = [t[1] for t in event["tags"] if t[0] == "p"]
# to_merchant = None
# if tags and len(tags) > 0:
# to_merchant = tags[0]
# if event["pubkey"] in pubkeys or to_merchant in pubkeys:
# logger.debug(f"Event sent to {to_merchant}")
# pubkey = to_merchant if to_merchant in pubkeys else event["pubkey"]
# # Send event to market extension
# await send_event_to_market(event=event, pubkey=pubkey)
# async def send_event_to_market(event: dict, pubkey: str):
# # Sends event to market extension, for decrypt and handling
# market_url = url_for(f"/market/api/v1/nip04/{pubkey}", external=True)
# async with httpx.AsyncClient() as client:
# await client.post(url=market_url, json=event)

View file

@ -0,0 +1,205 @@
<div>
<q-table
flat
dense
:data="orders"
row-key="id"
:columns="ordersTable.columns"
:pagination.sync="ordersTable.pagination"
:filter="filter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td>
<q-td key="id" :props="props"> {{toShortId(props.row.id)}} </q-td>
<q-td key="total" :props="props"> {{props.row.total}} </q-td>
<!-- todo: currency per order -->
<q-td key="paid" :props="props">
<q-checkbox
v-model="props.row.paid"
:label="props.row.paid ? 'Yes' : 'No'"
disable
readonly
size="sm"
></q-checkbox>
</q-td>
<q-td key="shipped" :props="props">
<q-checkbox
v-model="props.row.shipped"
@input="showShipOrderDialog(props.row)"
:label="props.row.shipped ? 'Yes' : 'No'"
size="sm"
></q-checkbox>
</q-td>
<q-td key="pubkey" :props="props">
{{toShortId(props.row.pubkey)}}
</q-td>
<q-td key="time" :props="props"> {{formatDate(props.row.time)}} </q-td>
</q-tr>
<q-tr v-if="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Products:</div>
<div class="col-8">
<div class="row items-center no-wrap q-mb-md">
<div class="col-1">Quantity</div>
<div class="col-1"></div>
<div class="col-10">Name</div>
</div>
</div>
<div class="col-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg"></div>
<div class="col-8">
<div
v-for="item in props.row.items"
class="row items-center no-wrap q-mb-md"
>
<div class="col-1">{{item.quantity}}</div>
<div class="col-1">x</div>
<div class="col-10">
{{productOverview(props.row, item.product_id)}}
</div>
</div>
</div>
<div class="col-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md q-mt-md">
<div class="col-3 q-pr-lg">Order ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.id"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Address:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.address"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Customer Public Key:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.pubkey"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div
v-if="props.row.contact.phone"
class="row items-center no-wrap q-mb-md"
>
<div class="col-3 q-pr-lg">Phone:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.contact.phone"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div
v-if="props.row.contact.email"
class="row items-center no-wrap q-mb-md"
>
<div class="col-3 q-pr-lg">Email:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.contact.email"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Invoice ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.invoice_id"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
<q-dialog v-model="showShipDialog" position="top">
<q-card v-if="selectedOrder" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="updateOrderShipped" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="shippingMessage"
label="Shipping Message"
type="textarea"
rows="4"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:label="selectedOrder.shipped? 'Unship Order' : 'Ship Order'"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>

View file

@ -0,0 +1,137 @@
async function orderList(path) {
const template = await loadTemplateAsync(path)
Vue.component('order-list', {
name: 'order-list',
props: ['stall-id', 'adminkey', 'inkey'],
template,
data: function () {
return {
orders: [],
selectedOrder: null,
shippingMessage: '',
showShipDialog: false,
filter: '',
ordersTable: {
columns: [
{
name: '',
align: 'left',
label: '',
field: ''
},
{
name: 'id',
align: 'left',
label: 'ID',
field: 'id'
},
{
name: 'total',
align: 'left',
label: 'Total',
field: 'total'
},
{
name: 'paid',
align: 'left',
label: 'Paid',
field: 'paid'
},
{
name: 'shipped',
align: 'left',
label: 'Shipped',
field: 'shipped'
},
{
name: 'pubkey',
align: 'left',
label: 'Customer',
field: 'pubkey'
},
{
name: 'time',
align: 'left',
label: 'Date',
field: 'time'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
methods: {
toShortId: function (value) {
return value.substring(0, 5) + '...' + value.substring(value.length - 5)
},
formatDate: function (value) {
return Quasar.utils.date.formatDate(
new Date(value * 1000),
'YYYY-MM-DD HH:mm'
)
},
productOverview: function (order, productId) {
product = order.extra.products.find(p => p.id === productId)
if (product) {
return `${product.name} (${product.price} ${order.extra.currency})`
}
return ''
},
getOrders: async function () {
try {
const ordersPath = this.stallId
? `/stall/order/${this.stallId}`
: '/order'
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1' + ordersPath,
this.inkey
)
this.orders = data.map(s => ({...s, expanded: false}))
console.log('### this.orders', this.orders)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateOrderShipped: async function () {
console.log('### order', this.selectedOrder)
this.selectedOrder.shipped = !this.selectedOrder.shipped
try {
await LNbits.api.request(
'PATCH',
`/nostrmarket/api/v1/order/${this.selectedOrder.id}`,
this.adminkey,
{
id: this.selectedOrder.id,
message: this.shippingMessage,
shipped: this.selectedOrder.shipped
}
)
this.$q.notify({
type: 'positive',
message: 'Order updated!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
this.showShipDialog = false
},
showShipOrderDialog: function (order) {
this.selectedOrder = order
this.shippingMessage = order.shipped
? `The order has been shipped! Order ID: '${order.id}' `
: `The order has NOT yet been shipped! Order ID: '${order.id}'`
// do not change the status yet
this.selectedOrder.shipped = !order.shipped
this.showShipDialog = true
}
},
created: async function () {
await this.getOrders()
}
})
}

View file

@ -185,7 +185,13 @@
</div> </div>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="orders"> <q-tab-panel name="orders">
<div v-if="stall"></div> <div v-if="stall">
<order-list
:adminkey="adminkey"
:inkey="inkey"
:stall-id="stallId"
></order-list>
</div>
</q-tab-panel> </q-tab-panel>
</q-tab-panels> </q-tab-panels>
<q-dialog v-model="productDialog.showDialog" position="top"> <q-dialog v-model="productDialog.showDialog" position="top">

View file

@ -50,7 +50,7 @@
{{props.row.name}}</a {{props.row.name}}</a
> >
</q-td> </q-td>
<q-td key="currency" :props="props"> {{props.row.currency}} </q-td>
<q-td key="description" :props="props"> <q-td key="description" :props="props">
{{props.row.config.description}} {{props.row.config.description}}
</q-td> </q-td>

View file

@ -35,6 +35,12 @@ async function stallList(path) {
label: 'Name', label: 'Name',
field: 'id' field: 'id'
}, },
{
name: 'currency',
align: 'left',
label: 'Currency',
field: 'currency'
},
{ {
name: 'description', name: 'description',
align: 'left', align: 'left',

View file

@ -5,6 +5,7 @@ const merchant = async () => {
await shippingZones('static/components/shipping-zones/shipping-zones.html') await shippingZones('static/components/shipping-zones/shipping-zones.html')
await stallDetails('static/components/stall-details/stall-details.html') await stallDetails('static/components/stall-details/stall-details.html')
await stallList('static/components/stall-list/stall-list.html') await stallList('static/components/stall-list/stall-list.html')
await orderList('static/components/order-list/order-list.html')
const nostr = window.NostrTools const nostr = window.NostrTools

156
tasks.py
View file

@ -1,18 +1,31 @@
import asyncio import asyncio
import json import json
import threading from asyncio import Queue
import httpx import httpx
import websocket import websocket
from loguru import logger from loguru import logger
from websocket import WebSocketApp
from lnbits.core import get_wallet
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import url_for from lnbits.helpers import Optional, url_for
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import (
get_merchant_by_pubkey,
get_public_keys_for_merchants,
get_wallet_for_product,
update_order_paid_status,
)
from .helpers import order_from_json
from .models import OrderStatusUpdate, PartialOrder
from .nostr.event import NostrEvent
from .nostr.nostr_client import connect_to_nostrclient_ws, publish_nostr_event
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue)
while True: while True:
@ -24,8 +37,141 @@ async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "nostrmarket": if payment.extra.get("tag") != "nostrmarket":
return return
print("### on_invoice_paid") order_id = payment.extra.get("order_id")
merchant_pubkey = payment.extra.get("merchant_pubkey")
if not order_id or not merchant_pubkey:
return None
await handle_order_paid(order_id, merchant_pubkey)
async def subscribe_nostrclient_ws(): async def handle_order_paid(order_id: str, merchant_pubkey: str):
try:
order = await update_order_paid_status(order_id, True)
assert order, f"Paid order cannot be found. Order id: {order_id}"
order_status = OrderStatusUpdate(
id=order_id, message="Payment received.", paid=True, shipped=order.shipped
)
merchant = await get_merchant_by_pubkey(merchant_pubkey)
assert merchant, f"Merchant cannot be found for order {order_id}"
dm_content = json.dumps(
order_status.dict(), separators=(",", ":"), ensure_ascii=False
)
dm_event = merchant.build_dm_event(dm_content, order.pubkey)
await publish_nostr_event(dm_event)
except Exception as ex:
logger.warning(ex)
async def subscribe_to_nostr_client(recieve_event_queue: Queue, send_req_queue: Queue):
print("### subscribe_nostrclient_ws") print("### subscribe_nostrclient_ws")
def on_open(_):
logger.info("Connected to 'nostrclient' websocket")
def on_message(_, message):
print("### on_message", message)
recieve_event_queue.put_nowait(message)
# wait for 'nostrclient' extension to initialize
await asyncio.sleep(5)
ws: WebSocketApp = None
while True:
try:
req = None
if not ws:
ws = await connect_to_nostrclient_ws(on_open, on_message)
# be sure the connection is open
await asyncio.sleep(3)
req = await send_req_queue.get()
ws.send(json.dumps(req))
except Exception as ex:
logger.warning(ex)
if req:
await send_req_queue.put(req)
ws = None # todo close
await asyncio.sleep(5)
async def wait_for_nostr_events(recieve_event_queue: Queue, send_req_queue: Queue):
public_keys = await get_public_keys_for_merchants()
for p in public_keys:
await send_req_queue.put(
["REQ", f"direct-messages:{p}", {"kind": 4, "#p": [p]}]
)
while True:
message = await recieve_event_queue.get()
await handle_message(message)
async def handle_message(msg: str):
try:
type, subscription_id, event = json.loads(msg)
_, public_key = subscription_id.split(":")
if type.upper() == "EVENT":
event = NostrEvent(**event)
if event.kind == 4:
await handle_nip04_message(public_key, event)
except Exception as ex:
logger.warning(ex)
async def handle_nip04_message(public_key: str, event: NostrEvent):
merchant = await get_merchant_by_pubkey(public_key)
assert merchant, f"Merchant not found for public key '{public_key}'"
clear_text_msg = merchant.decrypt_message(event.content, event.pubkey)
dm_content = await handle_dirrect_message(event.pubkey, event.id, clear_text_msg)
if dm_content:
dm_event = merchant.build_dm_event(dm_content, event.pubkey)
await publish_nostr_event(dm_event)
async def handle_dirrect_message(
from_pubkey: str, event_id: str, msg: str
) -> Optional[str]:
order, text_msg = order_from_json(msg)
try:
if order:
order["pubkey"] = from_pubkey
order["event_id"] = event_id
return await handle_new_order(PartialOrder(**order))
else:
print("### text_msg", text_msg)
return None
except Exception as ex:
logger.warning(ex)
return None
async def handle_new_order(order: PartialOrder):
### todo: check that event_id not parsed already
order.validate_order()
first_product_id = order.items[0].product_id
wallet_id = await get_wallet_for_product(first_product_id)
assert wallet_id, f"Cannot find wallet id for product id: {first_product_id}"
wallet = await get_wallet(wallet_id)
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
market_url = url_for(f"/nostrmarket/api/v1/order", external=True)
async with httpx.AsyncClient() as client:
resp = await client.post(
url=market_url,
headers={
"X-Api-Key": wallet.adminkey,
},
json=order.dict(),
)
resp.raise_for_status()
data = resp.json()
if data:
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
return None

View file

@ -150,6 +150,7 @@
<script src="{{ url_for('nostrmarket_static', path='components/shipping-zones/shipping-zones.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='components/shipping-zones/shipping-zones.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/stall-details/stall-details.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='components/stall-details/stall-details.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/stall-list/stall-list.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='components/stall-list/stall-list.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/order-list/order-list.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -6,18 +6,20 @@ from fastapi import Depends
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from loguru import logger from loguru import logger
from lnbits.core import create_invoice
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
check_admin,
get_key_type, get_key_type,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.extensions.nostrmarket.nostr.event import NostrEvent
from lnbits.utils.exchange_rates import currencies from lnbits.utils.exchange_rates import currencies
from . import nostrmarket_ext from . import nostrmarket_ext, scheduled_tasks
from .crud import ( from .crud import (
create_merchant, create_merchant,
create_order,
create_product, create_product,
create_stall, create_stall,
create_zone, create_zone,
@ -25,12 +27,19 @@ from .crud import (
delete_stall, delete_stall,
delete_zone, delete_zone,
get_merchant_for_user, get_merchant_for_user,
get_order,
get_order_by_event_id,
get_orders,
get_orders_for_stall,
get_product, get_product,
get_products, get_products,
get_products_by_ids,
get_stall, get_stall,
get_stalls, get_stalls,
get_wallet_for_product,
get_zone, get_zone,
get_zones, get_zones,
update_order_shipped_status,
update_product, update_product,
update_stall, update_stall,
update_zone, update_zone,
@ -38,14 +47,21 @@ from .crud import (
from .models import ( from .models import (
Merchant, Merchant,
Nostrable, Nostrable,
Order,
OrderExtra,
OrderStatusUpdate,
PartialMerchant, PartialMerchant,
PartialOrder,
PartialProduct, PartialProduct,
PartialStall, PartialStall,
PartialZone, PartialZone,
PaymentOption,
PaymentRequest,
Product, Product,
Stall, Stall,
Zone, Zone,
) )
from .nostr.event import NostrEvent
from .nostr.nostr_client import publish_nostr_event from .nostr.nostr_client import publish_nostr_event
######################################## MERCHANT ######################################## ######################################## MERCHANT ########################################
@ -80,7 +96,7 @@ async def api_get_merchant(
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant", detail="Cannot get merchant",
) )
@ -95,13 +111,13 @@ async def api_get_zones(wallet: WalletTypeInfo = Depends(get_key_type)) -> List[
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant", detail="Cannot get zone",
) )
@nostrmarket_ext.post("/api/v1/zone") @nostrmarket_ext.post("/api/v1/zone")
async def api_create_zone( async def api_create_zone(
data: PartialZone, wallet: WalletTypeInfo = Depends(get_key_type) data: PartialZone, wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
try: try:
zone = await create_zone(wallet.wallet.user, data) zone = await create_zone(wallet.wallet.user, data)
@ -110,7 +126,7 @@ async def api_create_zone(
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant", detail="Cannot create zone",
) )
@ -136,7 +152,7 @@ async def api_update_zone(
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant", detail="Cannot update zone",
) )
@ -157,7 +173,7 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant", detail="Cannot delete zone",
) )
@ -274,6 +290,22 @@ async def api_get_stall_products(
) )
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
async def api_get_stall_orders(
stall_id: str,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
orders = await get_orders_for_stall(wallet.wallet.user, stall_id)
return orders
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get stall products",
)
@nostrmarket_ext.delete("/api/v1/stall/{stall_id}") @nostrmarket_ext.delete("/api/v1/stall/{stall_id}")
async def api_delete_stall( async def api_delete_stall(
stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
@ -418,6 +450,132 @@ async def api_delete_product(
) )
######################################## ORDERS ########################################
@nostrmarket_ext.post("/api/v1/order")
async def api_create_order(
data: PartialOrder, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Optional[PaymentRequest]:
try:
# print("### new order: ", json.dumps(data.dict()))
if await get_order(wallet.wallet.user, data.id):
return None
if data.event_id and await get_order_by_event_id(
wallet.wallet.user, data.event_id
):
return None
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Cannot find merchant!"
products = await get_products_by_ids(
wallet.wallet.user, [p.product_id for p in data.items]
)
data.validate_order_items(products)
total_amount = await data.total_sats(products)
wallet_id = await get_wallet_for_product(data.items[0].product_id)
assert wallet_id, "Missing wallet for order `{data.id}`"
payment_hash, invoice = await create_invoice(
wallet_id=wallet_id,
amount=round(total_amount),
memo=f"Order '{data.id}' for pubkey '{data.pubkey}'",
extra={
"tag": "nostrmarket",
"order_id": data.id,
"merchant_pubkey": merchant.public_key,
},
)
order = Order(
**data.dict(),
stall_id=products[0].stall_id,
invoice_id=payment_hash,
total=total_amount,
extra=await OrderExtra.from_products(products),
)
await create_order(wallet.wallet.user, order)
return PaymentRequest(
id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)]
)
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create order",
)
nostrmarket_ext.get("/api/v1/order/{order_id}")
async def api_get_order(order_id: str, wallet: WalletTypeInfo = Depends(get_key_type)):
try:
order = await get_order(wallet.wallet.user, order_id)
if not order:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Order does not exist.",
)
return order
except HTTPException as ex:
raise ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get order",
)
@nostrmarket_ext.get("/api/v1/order")
async def api_get_orders(wallet: WalletTypeInfo = Depends(get_key_type)):
try:
orders = await get_orders(wallet.wallet.user)
return orders
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get orders",
)
@nostrmarket_ext.patch("/api/v1/order/{order_id}")
async def api_update_order_status(
data: OrderStatusUpdate,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Order:
try:
assert data.shipped != None, "Shipped value is required for order"
order = await update_order_shipped_status(
wallet.wallet.user, data.id, data.shipped
)
assert order, "Cannot find updated order"
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, f"Merchant cannot be found for order {data.id}"
data.paid = order.paid
dm_content = json.dumps(data.dict(), separators=(",", ":"), ensure_ascii=False)
dm_event = merchant.build_dm_event(dm_content, order.pubkey)
await publish_nostr_event(dm_event)
return order
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update order",
)
######################################## OTHER ######################################## ######################################## OTHER ########################################
@ -426,6 +584,16 @@ async def api_list_currencies_available():
return list(currencies.keys()) return list(currencies.keys())
@nostrmarket_ext.delete("/api/v1", status_code=HTTPStatus.OK)
async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)):
for t in scheduled_tasks:
try:
t.cancel()
except Exception as ex:
logger.warning(ex)
return {"success": True}
######################################## HELPERS ######################################## ######################################## HELPERS ########################################