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
from asyncio import Queue, Task
from typing import List
from fastapi import APIRouter
@ -25,15 +26,29 @@ def nostrmarket_renderer():
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_api import * # noqa
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()
task1 = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
task2 = loop.create_task(catch_everything_and_restart(subscribe_nostrclient_ws))
scheduled_tasks.append([task1, task2])
task2 = loop.create_task(catch_everything_and_restart(_subscribe_to_nostr_client))
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 time
from typing import List, Optional
from lnbits.helpers import urlsafe_short_hash
@ -7,6 +6,7 @@ from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import (
Merchant,
Order,
PartialMerchant,
PartialProduct,
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
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]:
row = await db.fetchone(
"""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:
@ -197,7 +214,7 @@ async def create_product(user_id: str, data: PartialProduct) -> Product:
await db.execute(
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
@ -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]
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:
await db.execute(
"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,
),
)
######################################## 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 json
import secrets
from typing import Optional
from typing import Any, Optional, Tuple
import secp256k1
from cffi import FFI
@ -31,7 +31,7 @@ def decrypt_message(encoded_message: str, encryption_key) -> str:
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()
padded_data = padder.update(message.encode()) + padder.finalize()
@ -73,9 +73,11 @@ def copy_x(output, x32, y32, data):
return 1
def is_json(string: str):
def order_from_json(s: str) -> Tuple[Optional[Any], Optional[str]]:
try:
json.loads(string)
except ValueError as e:
return False
return True
order = json.loads(s)
return (
(order, None) if (type(order) is dict) and "items" in order else (None, s)
)
except ValueError:
return None, s

View file

@ -71,39 +71,28 @@ async def m001_initial(db):
"""
Initial orders table.
"""
empty_object = "{}"
await db.execute(
f"""
CREATE TABLE nostrmarket.orders (
user_id TEXT NOT NULL,
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
username TEXT,
pubkey TEXT,
shipping_zone TEXT NOT NULL,
event_id TEXT,
pubkey TEXT NOT NULL,
contact_data TEXT NOT NULL DEFAULT '{empty_object}',
extra_data TEXT NOT NULL DEFAULT '{empty_object}',
order_items TEXT NOT NULL,
address TEXT,
email TEXT,
total REAL NOT NULL,
stall_id TEXT NOT NULL,
invoice_id TEXT NOT NULL,
paid BOOLEAN NOT NULL,
shipped BOOLEAN NOT NULL,
paid BOOLEAN NOT NULL DEFAULT false,
shipped BOOLEAN NOT NULL DEFAULT false,
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.
"""
@ -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.
"""

148
models.py
View file

@ -6,7 +6,14 @@ from typing import List, Optional
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
######################################## NOSTR ########################################
@ -39,6 +46,28 @@ class Merchant(PartialMerchant):
def sign_hash(self, hash: bytes) -> str:
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
def from_row(cls, row: Row) -> "Merchant":
merchant = cls(**dict(row))
@ -213,3 +242,120 @@ class Product(PartialProduct, Nostrable):
product.config = ProductConfig(**json.loads(row["meta"]))
product.categories = json.loads(row["category_list"])
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
from loguru import logger
from websocket import WebSocketApp
from lnbits.app import settings
from lnbits.helpers import url_for
@ -10,7 +14,7 @@ from .event import NostrEvent
async def publish_nostr_event(e: NostrEvent):
url = url_for("/nostrclient/api/v1/publish", external=True)
data = dict(e)
# print("### published", dict(data))
print("### published", dict(data))
async with httpx.AsyncClient() as client:
try:
await client.post(
@ -19,3 +23,44 @@ async def publish_nostr_event(e: NostrEvent):
)
except Exception as 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>
</q-tab-panel>
<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-panels>
<q-dialog v-model="productDialog.showDialog" position="top">

View file

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

View file

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

View file

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

156
tasks.py
View file

@ -1,18 +1,31 @@
import asyncio
import json
import threading
from asyncio import Queue
import httpx
import websocket
from loguru import logger
from websocket import WebSocketApp
from lnbits.core import get_wallet
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 .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():
invoice_queue = asyncio.Queue()
invoice_queue = Queue()
register_invoice_listener(invoice_queue)
while True:
@ -24,8 +37,141 @@ async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "nostrmarket":
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")
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/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/order-list/order-list.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script>
{% endblock %}

View file

@ -6,18 +6,20 @@ from fastapi import Depends
from fastapi.exceptions import HTTPException
from loguru import logger
from lnbits.core import create_invoice
from lnbits.decorators import (
WalletTypeInfo,
check_admin,
get_key_type,
require_admin_key,
require_invoice_key,
)
from lnbits.extensions.nostrmarket.nostr.event import NostrEvent
from lnbits.utils.exchange_rates import currencies
from . import nostrmarket_ext
from . import nostrmarket_ext, scheduled_tasks
from .crud import (
create_merchant,
create_order,
create_product,
create_stall,
create_zone,
@ -25,12 +27,19 @@ from .crud import (
delete_stall,
delete_zone,
get_merchant_for_user,
get_order,
get_order_by_event_id,
get_orders,
get_orders_for_stall,
get_product,
get_products,
get_products_by_ids,
get_stall,
get_stalls,
get_wallet_for_product,
get_zone,
get_zones,
update_order_shipped_status,
update_product,
update_stall,
update_zone,
@ -38,14 +47,21 @@ from .crud import (
from .models import (
Merchant,
Nostrable,
Order,
OrderExtra,
OrderStatusUpdate,
PartialMerchant,
PartialOrder,
PartialProduct,
PartialStall,
PartialZone,
PaymentOption,
PaymentRequest,
Product,
Stall,
Zone,
)
from .nostr.event import NostrEvent
from .nostr.nostr_client import publish_nostr_event
######################################## MERCHANT ########################################
@ -80,7 +96,7 @@ async def api_get_merchant(
logger.warning(ex)
raise HTTPException(
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)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant",
detail="Cannot get zone",
)
@nostrmarket_ext.post("/api/v1/zone")
async def api_create_zone(
data: PartialZone, wallet: WalletTypeInfo = Depends(get_key_type)
data: PartialZone, wallet: WalletTypeInfo = Depends(require_admin_key)
):
try:
zone = await create_zone(wallet.wallet.user, data)
@ -110,7 +126,7 @@ async def api_create_zone(
logger.warning(ex)
raise HTTPException(
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)
raise HTTPException(
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)
raise HTTPException(
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}")
async def api_delete_stall(
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 ########################################
@ -426,6 +584,16 @@ async def api_list_currencies_available():
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 ########################################