commit
b4452b2141
15 changed files with 1055 additions and 65 deletions
23
__init__.py
23
__init__.py
|
|
@ -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
142
crud.py
|
|
@ -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
|
||||||
|
|
|
||||||
16
helpers.py
16
helpers.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
148
models.py
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
205
static/components/order-list/order-list.html
Normal file
205
static/components/order-list/order-list.html
Normal 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>
|
||||||
137
static/components/order-list/order-list.js
Normal file
137
static/components/order-list/order-list.js
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
156
tasks.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
184
views_api.py
184
views_api.py
|
|
@ -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 ########################################
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue