From cec7d2ee254aeaec155c8bf4e3ff9da3e2ee1aa3 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 3 Mar 2023 18:24:53 +0200 Subject: [PATCH 01/14] feat: listen for direct messages --- __init__.py | 10 +++--- crud.py | 17 +++++++++++ models.py | 6 +++- nostr/nostr_client.py | 47 +++++++++++++++++++++++++++- tasks.py | 71 ++++++++++++++++++++++++++++++++++++++++--- views_api.py | 2 +- 6 files changed, 142 insertions(+), 11 deletions(-) diff --git a/__init__.py b/__init__.py index 9e188f2..9f3a39e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,5 @@ import asyncio +from asyncio import Task from typing import List from fastapi import APIRouter @@ -25,9 +26,9 @@ def nostrmarket_renderer(): return template_renderer(["lnbits/extensions/nostrmarket/templates"]) -scheduled_tasks: List[asyncio.Task] = [] +scheduled_tasks: List[Task] = [] -from .tasks import subscribe_nostrclient_ws, wait_for_paid_invoices +from .tasks import subscribe_nostrclient, wait_for_nostr_events, wait_for_paid_invoices from .views import * # noqa from .views_api import * # noqa @@ -35,5 +36,6 @@ from .views_api import * # noqa def nostrmarket_start(): 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_nostrclient)) + task3 = loop.create_task(catch_everything_and_restart(wait_for_nostr_events)) + scheduled_tasks.append([task1, task2, task3]) diff --git a/crud.py b/crud.py index 990b9ad..edd9a09 100644 --- a/crud.py +++ b/crud.py @@ -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 = ? """, diff --git a/models.py b/models.py index 194cf04..58b093d 100644 --- a/models.py +++ b/models.py @@ -6,7 +6,7 @@ from typing import List, Optional from pydantic import BaseModel -from .helpers import sign_message_hash +from .helpers import decrypt_message, get_shared_secret, sign_message_hash from .nostr.event import NostrEvent ######################################## NOSTR ######################################## @@ -39,6 +39,10 @@ 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) + @classmethod def from_row(cls, row: Row) -> "Merchant": merchant = cls(**dict(row)) diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index 306c123..bb64c58 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -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) diff --git a/tasks.py b/tasks.py index 3254dcc..901504c 100644 --- a/tasks.py +++ b/tasks.py @@ -1,18 +1,25 @@ 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.models import Payment -from lnbits.helpers import url_for from lnbits.tasks import register_invoice_listener +from .crud import get_merchant, get_merchant_by_pubkey, get_public_keys_for_merchants +from .nostr.event import NostrEvent +from .nostr.nostr_client import connect_to_nostrclient_ws + +recieve_event_queue: Queue = Queue() +send_req_queue: Queue = Queue() + async def wait_for_paid_invoices(): - invoice_queue = asyncio.Queue() + invoice_queue = Queue() register_invoice_listener(invoice_queue) while True: @@ -27,5 +34,61 @@ async def on_invoice_paid(payment: Payment) -> None: print("### on_invoice_paid") -async def subscribe_nostrclient_ws(): +async def subscribe_nostrclient(): 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() + print("### req", req) + 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(): + 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: + merchant = await get_merchant_by_pubkey(public_key) + if not merchant: + return + clear_text_msg = merchant.decrypt_message(event.content, event.pubkey) + print("### clear_text_msg", clear_text_msg) + + except Exception as ex: + logger.warning(ex) diff --git a/views_api.py b/views_api.py index 4680977..ecb14f9 100644 --- a/views_api.py +++ b/views_api.py @@ -12,7 +12,6 @@ from lnbits.decorators import ( 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 @@ -46,6 +45,7 @@ from .models import ( Stall, Zone, ) +from .nostr.event import NostrEvent from .nostr.nostr_client import publish_nostr_event ######################################## MERCHANT ######################################## From d0471744e01bf8cbc3ce4879947277fb410214e4 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Mar 2023 15:03:02 +0200 Subject: [PATCH 02/14] feat: create order on DM --- __init__.py | 21 ++++++++++++--- crud.py | 63 +++++++++++++++++++++++++++++++++++++++++-- helpers.py | 12 ++++----- migrations.py | 41 +++++----------------------- models.py | 37 +++++++++++++++++++++++++ nostr/nostr_client.py | 30 ++++++++++----------- tasks.py | 61 ++++++++++++++++++++++++++++++++++------- views_api.py | 52 ++++++++++++++++++++++++++++++++++- 8 files changed, 246 insertions(+), 71 deletions(-) diff --git a/__init__.py b/__init__.py index 9f3a39e..b4f7af0 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,5 @@ import asyncio -from asyncio import Task +from asyncio import Queue, Task from typing import List from fastapi import APIRouter @@ -26,16 +26,29 @@ def nostrmarket_renderer(): return template_renderer(["lnbits/extensions/nostrmarket/templates"]) +recieve_event_queue: Queue = Queue() +send_req_queue: Queue = Queue() scheduled_tasks: List[Task] = [] -from .tasks import subscribe_nostrclient, wait_for_nostr_events, 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)) - task3 = loop.create_task(catch_everything_and_restart(wait_for_nostr_events)) + 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]) diff --git a/crud.py b/crud.py index edd9a09..d443dd0 100644 --- a/crud.py +++ b/crud.py @@ -7,7 +7,9 @@ from lnbits.helpers import urlsafe_short_hash from . import db from .models import ( Merchant, + Order, PartialMerchant, + PartialOrder, PartialProduct, PartialStall, PartialZone, @@ -206,7 +208,7 @@ async def delete_stall(user_id: str, stall_id: str) -> None: ) -######################################## STALL ######################################## +######################################## PRODUCTS ######################################## async def create_product(user_id: str, data: PartialProduct) -> Product: @@ -214,7 +216,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 (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( @@ -278,6 +280,29 @@ 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 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 = ?", @@ -286,3 +311,37 @@ 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, contact_data, order_items, invoice_id, total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + o.id, + o.event_id, + o.pubkey, + json.dumps(o.contact.dict()), + json.dumps([i.dict() for i in o.items]), + 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 diff --git a/helpers.py b/helpers.py index 8747c48..5141009 100644 --- a/helpers.py +++ b/helpers.py @@ -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 @@ -73,9 +73,9 @@ 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 "items" in order else (None, s) + except ValueError: + return None, s diff --git a/migrations.py b/migrations.py index 680c3cc..eead2a7 100644 --- a/migrations.py +++ b/migrations.py @@ -71,39 +71,25 @@ 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, + event_id TEXT, pubkey TEXT, - shipping_zone TEXT NOT NULL, - address TEXT, - email TEXT, + contact_data TEXT NOT NULL DEFAULT '{empty_object}', + order_items TEXT NOT NULL, total REAL 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 +103,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. """ diff --git a/models.py b/models.py index 58b093d..2747085 100644 --- a/models.py +++ b/models.py @@ -217,3 +217,40 @@ class Product(PartialProduct, Nostrable): product.config = ProductConfig(**json.loads(row["meta"])) product.categories = json.loads(row["category_list"]) return product + + +######################################## ORDERS ######################################## + + +class OrderItem(BaseModel): + product_id: str + quantity: int + + +class OrderContact(BaseModel): + nostr: Optional[str] + phone: Optional[str] + email: Optional[str] + + +class PartialOrder(BaseModel): + id: Optional[str] + event_id: Optional[str] + pubkey: str + items: List[OrderItem] + contact: Optional[OrderContact] + + +class Order(PartialOrder): + id: str + invoice_id: str + total: float + paid: bool = False + shipped: bool = False + + @classmethod + def from_row(cls, row: Row) -> "Order": + contact = OrderContact(**json.loads(row["contact_data"])) + items = [OrderItem(**z) for z in json.loads(row["order_items"])] + order = cls(**dict(row), contact=contact, items=items) + return order \ No newline at end of file diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index bb64c58..3e8a47e 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -46,21 +46,21 @@ async def connect_to_nostrclient_ws( 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] +# 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) +# 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) +# 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) diff --git a/tasks.py b/tasks.py index 901504c..4ee1e4d 100644 --- a/tasks.py +++ b/tasks.py @@ -7,16 +7,22 @@ import websocket from loguru import logger from websocket import WebSocketApp +from lnbits.core import get_wallet from lnbits.core.models import Payment +from lnbits.extensions.nostrmarket.models import PartialOrder +from lnbits.helpers import url_for from lnbits.tasks import register_invoice_listener -from .crud import get_merchant, get_merchant_by_pubkey, get_public_keys_for_merchants +from .crud import ( + get_merchant_by_pubkey, + get_product, + get_public_keys_for_merchants, + get_wallet_for_product, +) +from .helpers import order_from_json from .nostr.event import NostrEvent from .nostr.nostr_client import connect_to_nostrclient_ws -recieve_event_queue: Queue = Queue() -send_req_queue: Queue = Queue() - async def wait_for_paid_invoices(): invoice_queue = Queue() @@ -34,7 +40,7 @@ async def on_invoice_paid(payment: Payment) -> None: print("### on_invoice_paid") -async def subscribe_nostrclient(): +async def subscribe_to_nostr_client(recieve_event_queue: Queue, send_req_queue: Queue): print("### subscribe_nostrclient_ws") def on_open(_): @@ -65,7 +71,7 @@ async def subscribe_nostrclient(): await asyncio.sleep(5) -async def wait_for_nostr_events(): +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( @@ -85,10 +91,47 @@ async def handle_message(msg: str): event = NostrEvent(**event) if event.kind == 4: merchant = await get_merchant_by_pubkey(public_key) - if not merchant: - return + assert merchant, f"Merchant not found for public key '{public_key}'" + clear_text_msg = merchant.decrypt_message(event.content, event.pubkey) - print("### clear_text_msg", clear_text_msg) + await handle_nip04_message( + event.pubkey, event.id, clear_text_msg + ) except Exception as ex: logger.warning(ex) + + +async def handle_nip04_message(from_pubkey: str, event_id: str, msg: str): + order, text_msg = order_from_json(msg) + try: + if order: + print("### order", from_pubkey, event_id, msg) + ### check that event_id not parsed already + order["pubkey"] = from_pubkey + order["event_id"] = event_id + partial_order = PartialOrder(**order) + assert len(partial_order.items) != 0, "Order has no items. Order: " + msg + + first_product_id = partial_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: + await client.post( + url=market_url, + headers={ + "X-Api-Key": wallet.adminkey, + }, + json=order, + ) + else: + print("### text_msg", text_msg) + except Exception as ex: + logger.warning(ex) diff --git a/views_api.py b/views_api.py index ecb14f9..2102dc9 100644 --- a/views_api.py +++ b/views_api.py @@ -5,6 +5,7 @@ from typing import List, Optional from fastapi import Depends from fastapi.exceptions import HTTPException from loguru import logger +from lnbits.core import create_invoice from lnbits.decorators import ( WalletTypeInfo, @@ -17,6 +18,7 @@ from lnbits.utils.exchange_rates import currencies from . import nostrmarket_ext from .crud import ( create_merchant, + create_order, create_product, create_stall, create_zone, @@ -26,8 +28,10 @@ from .crud import ( get_merchant_for_user, get_product, get_products, + get_products_by_ids, get_stall, get_stalls, + get_wallet_for_product, get_zone, get_zones, update_product, @@ -37,7 +41,9 @@ from .crud import ( from .models import ( Merchant, Nostrable, + Order, PartialMerchant, + PartialOrder, PartialProduct, PartialStall, PartialZone, @@ -101,7 +107,7 @@ async def api_get_zones(wallet: WalletTypeInfo = Depends(get_key_type)) -> List[ @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) @@ -418,6 +424,50 @@ 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) +): + try: + data.id = data.id or data.event_id + + wallet_id = await get_wallet_for_product(data.items[0].product_id) + assert wallet_id, "Missing wallet for order `{data.id}`" + + product_ids = [p.product_id for p in data.items] + products = await get_products_by_ids(wallet.wallet.user, product_ids) + + product_prices = {} + for p in products: + product_prices[p.id] = p + + amount: float = 0 # todo + for item in data.items: + amount += item.quantity * product_prices[item.product_id].price + + payment_hash, payment_request = await create_invoice( + wallet_id=wallet_id, + amount=round(amount), + memo=f"Order '{data.id}' for pubkey '{data.pubkey}'", + extra={ + "tag": "nostrmarket", + "order_id": data.id, + } + ) + + order = Order(**data.dict(), invoice_id=payment_hash, total=100) + await create_order(wallet.wallet.user, order) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create order", + ) + + ######################################## OTHER ######################################## From bee52340a28dd3d8273de282cf77b647a7650718 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Mar 2023 16:31:34 +0200 Subject: [PATCH 03/14] feat: return payment-request for order --- crud.py | 19 ++++++++++++++++--- helpers.py | 4 +++- models.py | 33 ++++++++++++++++++++++++++++++--- tasks.py | 10 ++++++---- views_api.py | 43 +++++++++++++++++++++++++------------------ 5 files changed, 80 insertions(+), 29 deletions(-) diff --git a/crud.py b/crud.py index d443dd0..6e362b4 100644 --- a/crud.py +++ b/crud.py @@ -283,13 +283,12 @@ async def get_products(user_id: str, stall_id: str) -> List[Product]: 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 FROM nostrmarket.products WHERE user_id = ? AND id IN ({q})", + 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( """ @@ -312,8 +311,10 @@ async def delete_product(user_id: str, product_id: str) -> None: ), ) + ######################################## ORDERS ######################################## + async def create_order(user_id: str, o: Order) -> Order: await db.execute( f""" @@ -325,7 +326,7 @@ async def create_order(user_id: str, o: Order) -> Order: o.id, o.event_id, o.pubkey, - json.dumps(o.contact.dict()), + json.dumps(o.contact.dict() if o.contact else {}), json.dumps([i.dict() for i in o.items]), o.invoice_id, o.total, @@ -336,6 +337,7 @@ async def create_order(user_id: str, o: Order) -> Order: 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 = ?", @@ -345,3 +347,14 @@ async def get_order(user_id: str, order_id: str) -> Optional[Order]: ), ) 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 diff --git a/helpers.py b/helpers.py index 5141009..706d2c6 100644 --- a/helpers.py +++ b/helpers.py @@ -76,6 +76,8 @@ def copy_x(output, x32, y32, data): def order_from_json(s: str) -> Tuple[Optional[Any], Optional[str]]: try: order = json.loads(s) - return (order, None) if "items" in order else (None, s) + return ( + (order, None) if (type(order) is dict) and "items" in order else (None, s) + ) except ValueError: return None, s diff --git a/models.py b/models.py index 2747085..945c1ae 100644 --- a/models.py +++ b/models.py @@ -6,6 +6,8 @@ from typing import List, Optional from pydantic import BaseModel +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis + from .helpers import decrypt_message, get_shared_secret, sign_message_hash from .nostr.event import NostrEvent @@ -234,15 +236,29 @@ class OrderContact(BaseModel): class PartialOrder(BaseModel): - id: Optional[str] + id: str event_id: Optional[str] pubkey: str items: List[OrderItem] contact: Optional[OrderContact] + 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): - id: str invoice_id: str total: float paid: bool = False @@ -253,4 +269,15 @@ class Order(PartialOrder): contact = OrderContact(**json.loads(row["contact_data"])) items = [OrderItem(**z) for z in json.loads(row["order_items"])] order = cls(**dict(row), contact=contact, items=items) - return order \ No newline at end of file + return order + + +class PaymentOption(BaseModel): + type: str + link: str + + +class PaymentRequest(BaseModel): + id: str + message: Optional[str] + payment_options: List[PaymentOption] diff --git a/tasks.py b/tasks.py index 4ee1e4d..b8bc11f 100644 --- a/tasks.py +++ b/tasks.py @@ -94,9 +94,7 @@ async def handle_message(msg: str): assert merchant, f"Merchant not found for public key '{public_key}'" clear_text_msg = merchant.decrypt_message(event.content, event.pubkey) - await handle_nip04_message( - event.pubkey, event.id, clear_text_msg - ) + await handle_nip04_message(event.pubkey, event.id, clear_text_msg) except Exception as ex: logger.warning(ex) @@ -124,13 +122,17 @@ async def handle_nip04_message(from_pubkey: str, event_id: str, msg: str): market_url = url_for(f"/nostrmarket/api/v1/order", external=True) async with httpx.AsyncClient() as client: - await client.post( + resp = await client.post( url=market_url, headers={ "X-Api-Key": wallet.adminkey, }, json=order, ) + resp.raise_for_status() + data = resp.json() + + print("### payment request", data) else: print("### text_msg", text_msg) except Exception as ex: diff --git a/views_api.py b/views_api.py index 2102dc9..56d45fe 100644 --- a/views_api.py +++ b/views_api.py @@ -5,8 +5,8 @@ from typing import List, Optional from fastapi import Depends from fastapi.exceptions import HTTPException from loguru import logger -from lnbits.core import create_invoice +from lnbits.core import create_invoice from lnbits.decorators import ( WalletTypeInfo, get_key_type, @@ -26,6 +26,8 @@ from .crud import ( delete_stall, delete_zone, get_merchant_for_user, + get_order, + get_order_by_event_id, get_product, get_products, get_products_by_ids, @@ -47,6 +49,8 @@ from .models import ( PartialProduct, PartialStall, PartialZone, + PaymentOption, + PaymentRequest, Product, Stall, Zone, @@ -430,36 +434,39 @@ async def api_delete_product( @nostrmarket_ext.post("/api/v1/order") async def api_create_order( data: PartialOrder, wallet: WalletTypeInfo = Depends(require_admin_key) -): +) -> Optional[PaymentRequest]: try: - data.id = data.id or data.event_id + 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 + + products = await get_products_by_ids( + wallet.wallet.user, [p.product_id for p in data.items] + ) + 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}`" - - product_ids = [p.product_id for p in data.items] - products = await get_products_by_ids(wallet.wallet.user, product_ids) - product_prices = {} - for p in products: - product_prices[p.id] = p - - amount: float = 0 # todo - for item in data.items: - amount += item.quantity * product_prices[item.product_id].price - - payment_hash, payment_request = await create_invoice( + payment_hash, invoice = await create_invoice( wallet_id=wallet_id, - amount=round(amount), + amount=round(total_amount), memo=f"Order '{data.id}' for pubkey '{data.pubkey}'", extra={ "tag": "nostrmarket", "order_id": data.id, - } + }, ) - order = Order(**data.dict(), invoice_id=payment_hash, total=100) + order = Order(**data.dict(), invoice_id=payment_hash, total=total_amount) 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( From 35298a4f44eff66fe732f7b465a9007656dee522 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Mar 2023 17:33:55 +0200 Subject: [PATCH 04/14] feat: send invoice back for order --- helpers.py | 2 +- models.py | 40 +++++++++++++++++++++++++++++++++++++++- tasks.py | 29 ++++++++++++++++++++--------- views_api.py | 1 - 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/helpers.py b/helpers.py index 706d2c6..ee73cd9 100644 --- a/helpers.py +++ b/helpers.py @@ -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() diff --git a/models.py b/models.py index 945c1ae..cd93d38 100644 --- a/models.py +++ b/models.py @@ -8,7 +8,12 @@ from pydantic import BaseModel from lnbits.utils.exchange_rates import fiat_amount_as_satoshis -from .helpers import decrypt_message, get_shared_secret, sign_message_hash +from .helpers import ( + decrypt_message, + encrypt_message, + get_shared_secret, + sign_message_hash, +) from .nostr.event import NostrEvent ######################################## NOSTR ######################################## @@ -45,6 +50,24 @@ class Merchant(PartialMerchant): 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)) @@ -242,6 +265,9 @@ class PartialOrder(BaseModel): items: List[OrderItem] contact: Optional[OrderContact] + def validate_order(self): + assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'" + async def total_sats(self, products: List[Product]) -> float: product_prices = {} for p in products: @@ -281,3 +307,15 @@ class PaymentRequest(BaseModel): id: str message: Optional[str] payment_options: List[PaymentOption] + + def to_nostr_event(self, author_pubkey: str, to_pubkey: str) -> NostrEvent: + event = NostrEvent( + pubkey=author_pubkey, + created_at=round(time.time()), + kind=4, + tags=[["p", to_pubkey]], + content=json.dumps(self.dict(), separators=(",", ":"), ensure_ascii=False), + ) + event.id = event.event_id + + return event diff --git a/tasks.py b/tasks.py index b8bc11f..b5e9619 100644 --- a/tasks.py +++ b/tasks.py @@ -9,19 +9,18 @@ from websocket import WebSocketApp from lnbits.core import get_wallet from lnbits.core.models import Payment -from lnbits.extensions.nostrmarket.models import PartialOrder -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_product, get_public_keys_for_merchants, get_wallet_for_product, ) from .helpers import order_from_json +from .models import PartialOrder from .nostr.event import NostrEvent -from .nostr.nostr_client import connect_to_nostrclient_ws +from .nostr.nostr_client import connect_to_nostrclient_ws, publish_nostr_event async def wait_for_paid_invoices(): @@ -94,21 +93,28 @@ async def handle_message(msg: str): assert merchant, f"Merchant not found for public key '{public_key}'" clear_text_msg = merchant.decrypt_message(event.content, event.pubkey) - await handle_nip04_message(event.pubkey, event.id, clear_text_msg) + dm_resp = await handle_dirrect_message( + event.pubkey, event.id, clear_text_msg + ) + if dm_resp: + dm_event = merchant.build_dm_event(dm_resp, event.pubkey) + await publish_nostr_event(dm_event) except Exception as ex: logger.warning(ex) -async def handle_nip04_message(from_pubkey: str, event_id: str, msg: str): +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: - print("### order", from_pubkey, event_id, msg) ### check that event_id not parsed already order["pubkey"] = from_pubkey order["event_id"] = event_id partial_order = PartialOrder(**order) + partial_order.validate_order() assert len(partial_order.items) != 0, "Order has no items. Order: " + msg first_product_id = partial_order.items[0].product_id @@ -131,9 +137,14 @@ async def handle_nip04_message(from_pubkey: str, event_id: str, msg: str): ) resp.raise_for_status() data = resp.json() - - print("### payment request", data) + return ( + json.dumps(data, separators=(",", ":"), ensure_ascii=False) + if data + else None + ) else: print("### text_msg", text_msg) + return None except Exception as ex: logger.warning(ex) + return None diff --git a/views_api.py b/views_api.py index 56d45fe..bca744d 100644 --- a/views_api.py +++ b/views_api.py @@ -1,4 +1,3 @@ -import json from http import HTTPStatus from typing import List, Optional From 1b317b1b9b763bd406ef9fb6607d78755bdf3203 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Mar 2023 18:01:05 +0200 Subject: [PATCH 05/14] refactor: extract simpler methods --- models.py | 13 +-------- tasks.py | 85 +++++++++++++++++++++++++++++-------------------------- 2 files changed, 46 insertions(+), 52 deletions(-) diff --git a/models.py b/models.py index cd93d38..711a3f4 100644 --- a/models.py +++ b/models.py @@ -256,6 +256,7 @@ class OrderContact(BaseModel): nostr: Optional[str] phone: Optional[str] email: Optional[str] + address: Optional[str] class PartialOrder(BaseModel): @@ -307,15 +308,3 @@ class PaymentRequest(BaseModel): id: str message: Optional[str] payment_options: List[PaymentOption] - - def to_nostr_event(self, author_pubkey: str, to_pubkey: str) -> NostrEvent: - event = NostrEvent( - pubkey=author_pubkey, - created_at=round(time.time()), - kind=4, - tags=[["p", to_pubkey]], - content=json.dumps(self.dict(), separators=(",", ":"), ensure_ascii=False), - ) - event.id = event.event_id - - return event diff --git a/tasks.py b/tasks.py index b5e9619..9a78344 100644 --- a/tasks.py +++ b/tasks.py @@ -89,62 +89,67 @@ async def handle_message(msg: str): if type.upper() == "EVENT": event = NostrEvent(**event) if event.kind == 4: - 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_resp = await handle_dirrect_message( - event.pubkey, event.id, clear_text_msg - ) - if dm_resp: - dm_event = merchant.build_dm_event(dm_resp, event.pubkey) - await publish_nostr_event(dm_event) + 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_resp = await handle_dirrect_message(event.pubkey, event.id, clear_text_msg) + if dm_resp: + dm_event = merchant.build_dm_event(dm_resp, 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: - ### check that event_id not parsed already order["pubkey"] = from_pubkey order["event_id"] = event_id - partial_order = PartialOrder(**order) - partial_order.validate_order() - assert len(partial_order.items) != 0, "Order has no items. Order: " + msg - - first_product_id = partial_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, - ) - resp.raise_for_status() - data = resp.json() - return ( - json.dumps(data, separators=(",", ":"), ensure_ascii=False) - if data - else None - ) + 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): + ### check that event_id not parsed already + + order.validate_order() + assert ( + len(order.items) != 0 + ), f"Order has no items. Order: '{order.id}' ({order.event_id})" + + 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 From da41ed365114656b4b4b5be14ab9445a216a98c1 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Mar 2023 18:07:52 +0200 Subject: [PATCH 06/14] feat: add address for order --- crud.py | 5 +++-- migrations.py | 3 ++- models.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crud.py b/crud.py index 6e362b4..c74512f 100644 --- a/crud.py +++ b/crud.py @@ -318,14 +318,15 @@ async def delete_product(user_id: str, product_id: str) -> None: async def create_order(user_id: str, o: Order) -> Order: await db.execute( f""" - INSERT INTO nostrmarket.orders (user_id, id, event_id, pubkey, contact_data, order_items, invoice_id, total) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO nostrmarket.orders (user_id, id, event_id, pubkey, address, contact_data, order_items, 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([i.dict() for i in o.items]), o.invoice_id, diff --git a/migrations.py b/migrations.py index eead2a7..4ed088b 100644 --- a/migrations.py +++ b/migrations.py @@ -78,9 +78,10 @@ async def m001_initial(db): user_id TEXT NOT NULL, id TEXT PRIMARY KEY, event_id TEXT, - pubkey TEXT, + pubkey EXT NOT NULL, contact_data TEXT NOT NULL DEFAULT '{empty_object}', order_items TEXT NOT NULL, + address TEXT, total REAL NOT NULL, invoice_id TEXT NOT NULL, paid BOOLEAN NOT NULL DEFAULT false, diff --git a/models.py b/models.py index 711a3f4..0cfee8f 100644 --- a/models.py +++ b/models.py @@ -256,7 +256,6 @@ class OrderContact(BaseModel): nostr: Optional[str] phone: Optional[str] email: Optional[str] - address: Optional[str] class PartialOrder(BaseModel): @@ -265,6 +264,7 @@ class PartialOrder(BaseModel): 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}'" From b446629707130ffa00a3f5bbd49949ecac3da0a2 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Mar 2023 09:12:14 +0200 Subject: [PATCH 07/14] feat: add basic `order-list` --- static/components/order-list/order-list.html | 3 +++ static/components/order-list/order-list.js | 18 ++++++++++++++++++ .../stall-details/stall-details.html | 8 +++++++- static/js/index.js | 1 + templates/nostrmarket/index.html | 1 + 5 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 static/components/order-list/order-list.html create mode 100644 static/components/order-list/order-list.js diff --git a/static/components/order-list/order-list.html b/static/components/order-list/order-list.html new file mode 100644 index 0000000..147c21f --- /dev/null +++ b/static/components/order-list/order-list.html @@ -0,0 +1,3 @@ +
+ xx1 +
\ No newline at end of file diff --git a/static/components/order-list/order-list.js b/static/components/order-list/order-list.js new file mode 100644 index 0000000..15b1528 --- /dev/null +++ b/static/components/order-list/order-list.js @@ -0,0 +1,18 @@ +async function orderList(path) { + const template = await loadTemplateAsync(path) + Vue.component('order-list', { + name: 'order-list', + props: ['adminkey', 'inkey'], + template, + + data: function () { + return { + } + }, + methods: { + }, + created: async function () { + } + }) + } + \ No newline at end of file diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index b26caf3..9ffe49a 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -185,7 +185,13 @@ -
+
+ +
diff --git a/static/js/index.js b/static/js/index.js index 5b26d4c..3eb50ad 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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 diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index fa07f17..b866226 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -150,6 +150,7 @@ + {% endblock %} From 40c25ad085a63a406b0649bd0ffd70fdb8fbea3a Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Mar 2023 10:31:25 +0200 Subject: [PATCH 08/14] feat: show orders per stall --- crud.py | 24 +++- migrations.py | 3 +- models.py | 22 ++++ static/components/order-list/order-list.html | 45 +++++++- static/components/order-list/order-list.js | 107 +++++++++++++++--- .../stall-details/stall-details.html | 8 +- static/components/stall-list/stall-list.html | 2 +- static/components/stall-list/stall-list.js | 6 + tasks.py | 3 - views_api.py | 64 ++++++++++- 10 files changed, 255 insertions(+), 29 deletions(-) diff --git a/crud.py b/crud.py index c74512f..be080b7 100644 --- a/crud.py +++ b/crud.py @@ -318,8 +318,8 @@ async def delete_product(user_id: str, product_id: str) -> None: 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, order_items, invoice_id, total) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO nostrmarket.orders (user_id, id, event_id, pubkey, address, contact_data, order_items, stall_id, invoice_id, total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( user_id, @@ -329,6 +329,7 @@ async def create_order(user_id: str, o: Order) -> Order: o.address, json.dumps(o.contact.dict() if o.contact else {}), json.dumps([i.dict() for i in o.items]), + o.stall_id, o.invoice_id, o.total, ), @@ -359,3 +360,22 @@ async def get_order_by_event_id(user_id: str, event_id: str) -> Optional[Order]: ), ) 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 = ?", + (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 = ?", + ( + user_id, + stall_id, + ), + ) + return [Order.from_row(row) for row in rows] diff --git a/migrations.py b/migrations.py index 4ed088b..7c50264 100644 --- a/migrations.py +++ b/migrations.py @@ -78,11 +78,12 @@ async def m001_initial(db): user_id TEXT NOT NULL, id TEXT PRIMARY KEY, event_id TEXT, - pubkey EXT NOT NULL, + pubkey TEXT NOT NULL, contact_data TEXT NOT NULL DEFAULT '{empty_object}', order_items TEXT NOT NULL, address TEXT, total REAL NOT NULL, + stall_id TEXT NOT NULL, invoice_id TEXT NOT NULL, paid BOOLEAN NOT NULL DEFAULT false, shipped BOOLEAN NOT NULL DEFAULT false, diff --git a/models.py b/models.py index 0cfee8f..a8415e1 100644 --- a/models.py +++ b/models.py @@ -269,6 +269,26 @@ class PartialOrder(BaseModel): 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: @@ -286,10 +306,12 @@ class PartialOrder(BaseModel): class Order(PartialOrder): + stall_id: str invoice_id: str total: float paid: bool = False shipped: bool = False + time: int @classmethod def from_row(cls, row: Row) -> "Order": diff --git a/static/components/order-list/order-list.html b/static/components/order-list/order-list.html index 147c21f..99c556a 100644 --- a/static/components/order-list/order-list.html +++ b/static/components/order-list/order-list.html @@ -1,3 +1,44 @@
- xx1 -
\ No newline at end of file + + + + diff --git a/static/components/order-list/order-list.js b/static/components/order-list/order-list.js index 15b1528..9d5e1f7 100644 --- a/static/components/order-list/order-list.js +++ b/static/components/order-list/order-list.js @@ -1,18 +1,95 @@ async function orderList(path) { - const template = await loadTemplateAsync(path) - Vue.component('order-list', { - name: 'order-list', - props: ['adminkey', 'inkey'], - template, - - data: function () { - return { + const template = await loadTemplateAsync(path) + Vue.component('order-list', { + name: 'order-list', + props: ['stall-id', 'adminkey', 'inkey'], + template, + + data: function () { + return { + orders: [], + + 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: { - }, - created: async function () { } - }) - } - \ No newline at end of file + }, + 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' + ) + }, + 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) + } + } + }, + created: async function () { + await this.getOrders() + } + }) +} diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index 9ffe49a..4239f68 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -187,10 +187,10 @@
+ :adminkey="adminkey" + :inkey="inkey" + :stall-id="stallId" + >
diff --git a/static/components/stall-list/stall-list.html b/static/components/stall-list/stall-list.html index 86ae34e..bc6236e 100644 --- a/static/components/stall-list/stall-list.html +++ b/static/components/stall-list/stall-list.html @@ -50,7 +50,7 @@ {{props.row.name}} - + {{props.row.currency}} {{props.row.config.description}} diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js index 5fd8ffd..d41c062 100644 --- a/static/components/stall-list/stall-list.js +++ b/static/components/stall-list/stall-list.js @@ -35,6 +35,12 @@ async function stallList(path) { label: 'Name', field: 'id' }, + { + name: 'currency', + align: 'left', + label: 'Currency', + field: 'currency' + }, { name: 'description', align: 'left', diff --git a/tasks.py b/tasks.py index 9a78344..f4b9477 100644 --- a/tasks.py +++ b/tasks.py @@ -127,9 +127,6 @@ async def handle_new_order(order: PartialOrder): ### check that event_id not parsed already order.validate_order() - assert ( - len(order.items) != 0 - ), f"Order has no items. Order: '{order.id}' ({order.event_id})" first_product_id = order.items[0].product_id wallet_id = await get_wallet_for_product(first_product_id) diff --git a/views_api.py b/views_api.py index bca744d..e21193d 100644 --- a/views_api.py +++ b/views_api.py @@ -1,3 +1,4 @@ +import json from http import HTTPStatus from typing import List, Optional @@ -27,6 +28,8 @@ from .crud import ( 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, @@ -283,6 +286,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) @@ -435,6 +454,7 @@ 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( @@ -445,6 +465,8 @@ async def api_create_order( 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) @@ -460,7 +482,12 @@ async def api_create_order( }, ) - order = Order(**data.dict(), invoice_id=payment_hash, total=total_amount) + order = Order( + **data.dict(), + stall_id=products[0].stall_id, + invoice_id=payment_hash, + total=total_amount, + ) await create_order(wallet.wallet.user, order) return PaymentRequest( @@ -474,6 +501,41 @@ async def api_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", + ) + + ######################################## OTHER ######################################## From aa7e30e5f9434ec89b8ebb8ff2b2759cd3687533 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Mar 2023 10:40:00 +0200 Subject: [PATCH 09/14] feat: show `paid` and `shipped` statues --- crud.py | 4 ++-- static/components/order-list/order-list.html | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crud.py b/crud.py index be080b7..62e862f 100644 --- a/crud.py +++ b/crud.py @@ -364,7 +364,7 @@ async def get_order_by_event_id(user_id: str, event_id: str) -> Optional[Order]: async def get_orders(user_id: str) -> List[Order]: rows = await db.fetchall( - "SELECT * FROM nostrmarket.orders WHERE user_id = ?", + "SELECT * FROM nostrmarket.orders WHERE user_id = ? ORDER BY time DESC", (user_id,), ) return [Order.from_row(row) for row in rows] @@ -372,7 +372,7 @@ async def get_orders(user_id: str) -> List[Order]: 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 = ?", + "SELECT * FROM nostrmarket.orders WHERE user_id = ? AND stall_id = ? ORDER BY time DESC", ( user_id, stall_id, diff --git a/static/components/order-list/order-list.html b/static/components/order-list/order-list.html index 99c556a..2f086fa 100644 --- a/static/components/order-list/order-list.html +++ b/static/components/order-list/order-list.html @@ -23,9 +23,24 @@ {{toShortId(props.row.id)}} {{props.row.total}} + - {{props.row.paid}} - {{props.row.shipped}} + + + + + + {{toShortId(props.row.pubkey)}} From 68795d2db26154dc69a4fe89833513541e55c055 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Mar 2023 11:02:49 +0200 Subject: [PATCH 10/14] feat: show order details --- static/components/order-list/order-list.html | 112 ++++++++++++++++++- 1 file changed, 108 insertions(+), 4 deletions(-) diff --git a/static/components/order-list/order-list.html b/static/components/order-list/order-list.html index 2f086fa..f1ebbb3 100644 --- a/static/components/order-list/order-list.html +++ b/static/components/order-list/order-list.html @@ -34,8 +34,8 @@ size="sm" > - - + -
-
+
+
Order ID:
+
+ +
+
+
+
+
Customer Public Key:
+
+ +
+
+
+
+
Address:
+
+ +
+
+
+
+
Nostr Contact Pubkey:
+
+ +
+
+
+
+
Phone:
+
+ +
+
+
+
+
Email:
+
+ +
+
+
+
+
Invoice ID:
+
+ +
+
From 76af65c148b2e548d8cf1e44be2c80bb00f78f22 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Mar 2023 12:00:43 +0200 Subject: [PATCH 11/14] feat: show more details about the order --- crud.py | 5 +- migrations.py | 1 + models.py | 28 +++++++++-- static/components/order-list/order-list.html | 52 ++++++++++++-------- static/components/order-list/order-list.js | 7 +++ tasks.py | 4 +- views_api.py | 2 + 7 files changed, 72 insertions(+), 27 deletions(-) diff --git a/crud.py b/crud.py index 62e862f..4297f7a 100644 --- a/crud.py +++ b/crud.py @@ -318,8 +318,8 @@ async def delete_product(user_id: str, product_id: str) -> None: 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, order_items, stall_id, invoice_id, total) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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, @@ -328,6 +328,7 @@ async def create_order(user_id: str, o: Order) -> Order: 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, diff --git a/migrations.py b/migrations.py index 7c50264..f006d36 100644 --- a/migrations.py +++ b/migrations.py @@ -80,6 +80,7 @@ async def m001_initial(db): 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, total REAL NOT NULL, diff --git a/models.py b/models.py index a8415e1..3c1111f 100644 --- a/models.py +++ b/models.py @@ -6,7 +6,7 @@ from typing import List, Optional from pydantic import BaseModel -from lnbits.utils.exchange_rates import fiat_amount_as_satoshis +from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis from .helpers import ( decrypt_message, @@ -244,6 +244,12 @@ class Product(PartialProduct, Nostrable): return product +class ProductOverview(BaseModel): + id: str + name: str + price: float + + ######################################## ORDERS ######################################## @@ -258,6 +264,20 @@ class OrderContact(BaseModel): 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] @@ -311,13 +331,15 @@ class Order(PartialOrder): total: float paid: bool = False shipped: bool = False - time: int + 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) + order = cls(**dict(row), contact=contact, items=items, extra=extra) return order diff --git a/static/components/order-list/order-list.html b/static/components/order-list/order-list.html index f1ebbb3..a0968e7 100644 --- a/static/components/order-list/order-list.html +++ b/static/components/order-list/order-list.html @@ -63,20 +63,7 @@
-
-
Customer Public Key:
-
- -
-
-
+
Address:
@@ -91,23 +78,48 @@
-
-
Nostr Contact Pubkey:
+
+
Products:
+
+
+
Quantity
+
+
Name
+
+
+
+
+
+
+
+
+
{{item.quantity}}
+
x
+
+ {{productOverview(props.row, item.product_id)}} +
+
+
+
+
+
+
Customer Public Key:
+
p.id === productId) + if (product) { + return `${product.name} (${product.price} ${order.extra.currency})` + } + return '' + }, getOrders: async function () { try { const ordersPath = this.stallId diff --git a/tasks.py b/tasks.py index f4b9477..69df5a3 100644 --- a/tasks.py +++ b/tasks.py @@ -36,7 +36,7 @@ async def on_invoice_paid(payment: Payment) -> None: if payment.extra.get("tag") != "nostrmarket": return - print("### on_invoice_paid") + print("### on_invoice_paid", json.dumps(payment)) async def subscribe_to_nostr_client(recieve_event_queue: Queue, send_req_queue: Queue): @@ -124,7 +124,7 @@ async def handle_dirrect_message( async def handle_new_order(order: PartialOrder): - ### check that event_id not parsed already + ### todo: check that event_id not parsed already order.validate_order() diff --git a/views_api.py b/views_api.py index e21193d..48935aa 100644 --- a/views_api.py +++ b/views_api.py @@ -46,6 +46,7 @@ from .models import ( Merchant, Nostrable, Order, + OrderExtra, PartialMerchant, PartialOrder, PartialProduct, @@ -487,6 +488,7 @@ async def api_create_order( 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) From 8094bcaf8ac2d882ebc0e96ae97a37475834f497 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Mar 2023 13:10:01 +0200 Subject: [PATCH 12/14] feat: handle order paid --- crud.py | 27 +++++++++++++++++++++++++-- models.py | 7 +++++++ tasks.py | 38 ++++++++++++++++++++++++++++++++------ views_api.py | 34 +++++++++++++++++++++++++++++----- 4 files changed, 93 insertions(+), 13 deletions(-) diff --git a/crud.py b/crud.py index 4297f7a..b8ee099 100644 --- a/crud.py +++ b/crud.py @@ -1,5 +1,4 @@ import json -import time from typing import List, Optional from lnbits.helpers import urlsafe_short_hash @@ -9,7 +8,6 @@ from .models import ( Merchant, Order, PartialMerchant, - PartialOrder, PartialProduct, PartialStall, PartialZone, @@ -380,3 +378,28 @@ async def get_orders_for_stall(user_id: str, stall_id: str) -> List[Order]: ), ) 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(order_id: str, shipped: bool) -> Optional[Order]: + await db.execute( + f"UPDATE nostrmarket.orders SET shipped = ? WHERE id = ?", + (order_id, shipped), + ) + + row = await db.fetchone( + "SELECT * FROM nostrmarket.orders WHERE id = ?", + (order_id,), + ) + return Order.from_row(row) if row else None diff --git a/models.py b/models.py index 3c1111f..f228ed5 100644 --- a/models.py +++ b/models.py @@ -343,6 +343,13 @@ class Order(PartialOrder): return order +class OrderStatusUpdate(BaseModel): + id: str + message: Optional[str] + paid: Optional[bool] + shipped: Optional[bool] + + class PaymentOption(BaseModel): type: str link: str diff --git a/tasks.py b/tasks.py index 69df5a3..f5afec3 100644 --- a/tasks.py +++ b/tasks.py @@ -16,9 +16,10 @@ 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 PartialOrder +from .models import OrderStatusUpdate, PartialOrder from .nostr.event import NostrEvent from .nostr.nostr_client import connect_to_nostrclient_ws, publish_nostr_event @@ -36,7 +37,33 @@ async def on_invoice_paid(payment: Payment) -> None: if payment.extra.get("tag") != "nostrmarket": return - print("### on_invoice_paid", json.dumps(payment)) + 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 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 foud 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): @@ -60,7 +87,6 @@ async def subscribe_to_nostr_client(recieve_event_queue: Queue, send_req_queue: # be sure the connection is open await asyncio.sleep(3) req = await send_req_queue.get() - print("### req", req) ws.send(json.dumps(req)) except Exception as ex: logger.warning(ex) @@ -100,9 +126,9 @@ async def handle_nip04_message(public_key: str, event: NostrEvent): assert merchant, f"Merchant not found for public key '{public_key}'" clear_text_msg = merchant.decrypt_message(event.content, event.pubkey) - dm_resp = await handle_dirrect_message(event.pubkey, event.id, clear_text_msg) - if dm_resp: - dm_event = merchant.build_dm_event(dm_resp, 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) diff --git a/views_api.py b/views_api.py index 48935aa..32696e4 100644 --- a/views_api.py +++ b/views_api.py @@ -93,7 +93,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", ) @@ -108,7 +108,7 @@ 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", ) @@ -123,7 +123,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", ) @@ -149,7 +149,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", ) @@ -170,7 +170,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", ) @@ -463,6 +463,9 @@ async def api_create_order( ): 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] ) @@ -480,6 +483,7 @@ async def api_create_order( extra={ "tag": "nostrmarket", "order_id": data.id, + "merchant_pubkey": merchant.public_key, }, ) @@ -538,6 +542,26 @@ async def api_get_orders(wallet: WalletTypeInfo = Depends(get_key_type)): ) +# @nostrmarket_ext.patch("/api/v1/order/{order_id}") +# async def api_update_order( +# data: OrderStatusUpdate, +# wallet: WalletTypeInfo = Depends(require_admin_key), +# ) -> Zone: +# try: + +# zone = await update_order(wallet.wallet.user, data) +# assert zone, "Cannot find updated zone" +# return zone +# except HTTPException as ex: +# raise ex +# except Exception as ex: +# logger.warning(ex) +# raise HTTPException( +# status_code=HTTPStatus.INTERNAL_SERVER_ERROR, +# detail="Cannot update order", +# ) + + ######################################## OTHER ######################################## From 612d31eae07dc4998e300b700c47a43427e46cd1 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Mar 2023 14:30:58 +0200 Subject: [PATCH 13/14] feat: add ship/unship functionality --- crud.py | 8 +- static/components/order-list/order-list.html | 84 +++++++++++++------- static/components/order-list/order-list.js | 37 ++++++++- tasks.py | 3 +- views_api.py | 47 +++++++---- 5 files changed, 129 insertions(+), 50 deletions(-) diff --git a/crud.py b/crud.py index b8ee099..2e6013b 100644 --- a/crud.py +++ b/crud.py @@ -392,10 +392,12 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order] return Order.from_row(row) if row else None -async def update_order_shipped_status(order_id: str, shipped: bool) -> Optional[Order]: +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 id = ?", - (order_id, shipped), + f"UPDATE nostrmarket.orders SET shipped = ? WHERE user_id = ? AND id = ?", + (shipped, user_id, order_id), ) row = await db.fetchone( diff --git a/static/components/order-list/order-list.html b/static/components/order-list/order-list.html index a0968e7..e629b1f 100644 --- a/static/components/order-list/order-list.html +++ b/static/components/order-list/order-list.html @@ -37,6 +37,7 @@ @@ -49,6 +50,33 @@ +
+
Products:
+
+
+
Quantity
+
+
Name
+
+
+
+
+
+
+
+
+
{{item.quantity}}
+
x
+
+ {{productOverview(props.row, item.product_id)}} +
+
+
+
+
Order ID:
@@ -78,33 +106,7 @@
-
-
Products:
-
-
-
Quantity
-
-
Name
-
-
-
-
-
-
-
-
-
{{item.quantity}}
-
x
-
- {{productOverview(props.row, item.product_id)}} -
-
-
-
-
+
Customer Public Key:
@@ -172,4 +174,32 @@ + + + + + + +
+ + + Cancel +
+
+
+
diff --git a/static/components/order-list/order-list.js b/static/components/order-list/order-list.js index bb4f335..482cc8a 100644 --- a/static/components/order-list/order-list.js +++ b/static/components/order-list/order-list.js @@ -8,7 +8,9 @@ async function orderList(path) { data: function () { return { orders: [], - + selectedOrder: null, + shippingMessage: '', + showShipDialog: false, filter: '', ordersTable: { columns: [ @@ -93,6 +95,39 @@ async function orderList(path) { } 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 () { diff --git a/tasks.py b/tasks.py index f5afec3..c97c2b2 100644 --- a/tasks.py +++ b/tasks.py @@ -45,7 +45,6 @@ async def on_invoice_paid(payment: Payment) -> None: await handle_order_paid(order_id, merchant_pubkey) - async def handle_order_paid(order_id: str, merchant_pubkey: str): try: order = await update_order_paid_status(order_id, True) @@ -55,7 +54,7 @@ async def handle_order_paid(order_id: str, merchant_pubkey: str): ) merchant = await get_merchant_by_pubkey(merchant_pubkey) - assert merchant, f"Merchant cannot be foud for order {order_id}" + assert merchant, f"Merchant cannot be found for order {order_id}" dm_content = json.dumps( order_status.dict(), separators=(",", ":"), ensure_ascii=False ) diff --git a/views_api.py b/views_api.py index 32696e4..29f6cd3 100644 --- a/views_api.py +++ b/views_api.py @@ -38,6 +38,7 @@ from .crud import ( get_wallet_for_product, get_zone, get_zones, + update_order_shipped_status, update_product, update_stall, update_zone, @@ -47,6 +48,7 @@ from .models import ( Nostrable, Order, OrderExtra, + OrderStatusUpdate, PartialMerchant, PartialOrder, PartialProduct, @@ -542,24 +544,35 @@ async def api_get_orders(wallet: WalletTypeInfo = Depends(get_key_type)): ) -# @nostrmarket_ext.patch("/api/v1/order/{order_id}") -# async def api_update_order( -# data: OrderStatusUpdate, -# wallet: WalletTypeInfo = Depends(require_admin_key), -# ) -> Zone: -# try: +@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" -# zone = await update_order(wallet.wallet.user, data) -# assert zone, "Cannot find updated zone" -# return zone -# except HTTPException as ex: -# raise ex -# except Exception as ex: -# logger.warning(ex) -# raise HTTPException( -# status_code=HTTPStatus.INTERNAL_SERVER_ERROR, -# detail="Cannot update 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 ######################################## From 9558181914f6301fb899effcd2fcbe5cd462faba Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Mar 2023 14:34:05 +0200 Subject: [PATCH 14/14] feat: add `destroy` extension enpoint --- views_api.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/views_api.py b/views_api.py index 29f6cd3..6d561d8 100644 --- a/views_api.py +++ b/views_api.py @@ -9,13 +9,14 @@ 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.utils.exchange_rates import currencies -from . import nostrmarket_ext +from . import nostrmarket_ext, scheduled_tasks from .crud import ( create_merchant, create_order, @@ -583,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 ########################################