From d0471744e01bf8cbc3ce4879947277fb410214e4 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 6 Mar 2023 15:03:02 +0200 Subject: [PATCH] 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 ########################################