diff --git a/crud.py b/crud.py index 996bfc3..6ad0139 100644 --- a/crud.py +++ b/crud.py @@ -197,7 +197,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, category_list, description, images, price, quantity) + INSERT INTO nostrmarket.products (user_id, id, stall_id, name, images, price, quantity, category_list, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( @@ -205,11 +205,11 @@ async def create_product(user_id: str, data: PartialProduct) -> Product: product_id, data.stall_id, data.name, - json.dumps(data.categories), - data.description, data.image, data.price, data.quantity, + json.dumps(data.categories), + json.dumps(data.config.dict()), ), ) product = await get_product(user_id, product_id) @@ -222,16 +222,16 @@ async def update_product(user_id: str, product: Product) -> Product: await db.execute( f""" - UPDATE nostrmarket.products set name = ?, category_list = ?, description = ?, images = ?, price = ?, quantity = ? + UPDATE nostrmarket.products set name = ?, images = ?, price = ?, quantity = ?, category_list = ?, meta = ? WHERE user_id = ? AND id = ? """, ( product.name, - json.dumps(product.categories), - product.description, product.image, product.price, product.quantity, + json.dumps(product.categories), + json.dumps(product.config.dict()), user_id, product.id, ), diff --git a/migrations.py b/migrations.py index 2e0a158..696e584 100644 --- a/migrations.py +++ b/migrations.py @@ -37,17 +37,17 @@ async def m001_initial(db): Initial products table. """ await db.execute( - f""" + """ CREATE TABLE nostrmarket.products ( user_id TEXT NOT NULL, id TEXT PRIMARY KEY, stall_id TEXT NOT NULL, name TEXT NOT NULL, - category_list TEXT DEFAULT '[]', - description TEXT, images TEXT DEFAULT '[]', price REAL NOT NULL, - quantity INTEGER NOT NULL + quantity INTEGER NOT NULL, + category_list TEXT DEFAULT '[]', + meta TEXT NOT NULL DEFAULT '{}' ); """ ) diff --git a/models.py b/models.py index 412bbfc..194cf04 100644 --- a/models.py +++ b/models.py @@ -1,5 +1,6 @@ import json import time +from abc import abstractmethod from sqlite3 import Row from typing import List, Optional @@ -8,6 +9,18 @@ from pydantic import BaseModel from .helpers import sign_message_hash from .nostr.event import NostrEvent +######################################## NOSTR ######################################## + + +class Nostrable: + @abstractmethod + def to_nostr_event(self, pubkey: str) -> NostrEvent: + pass + + @abstractmethod + def to_nostr_delete_event(self, pubkey: str) -> NostrEvent: + pass + ######################################## MERCHANT ######################################## class MerchantConfig(BaseModel): @@ -77,7 +90,7 @@ class PartialStall(BaseModel): ) -class Stall(PartialStall): +class Stall(PartialStall, Nostrable): id: str def to_nostr_event(self, pubkey: str) -> NostrEvent: @@ -90,7 +103,7 @@ class Stall(PartialStall): event = NostrEvent( pubkey=pubkey, created_at=round(time.time()), - kind=30005, + kind=30017, tags=[["d", self.id]], content=json.dumps(content, separators=(",", ":"), ensure_ascii=False), ) @@ -121,14 +134,20 @@ class Stall(PartialStall): ######################################## STALLS ######################################## +class ProductConfig(BaseModel): + event_id: Optional[str] + description: Optional[str] + currency: Optional[str] + + class PartialProduct(BaseModel): stall_id: str name: str categories: List[str] = [] - description: Optional[str] image: Optional[str] price: float quantity: int + config: ProductConfig = ProductConfig() def validate_product(self): if self.image: @@ -150,15 +169,16 @@ class PartialProduct(BaseModel): ) -class Product(PartialProduct): +class Product(PartialProduct, Nostrable): id: str def to_nostr_event(self, pubkey: str) -> NostrEvent: content = { "stall_id": self.stall_id, "name": self.name, - "description": self.description, + "description": self.config.description, "image": self.image, + "currency": self.config.currency, "price": self.price, "quantity": self.quantity, } @@ -167,7 +187,7 @@ class Product(PartialProduct): event = NostrEvent( pubkey=pubkey, created_at=round(time.time()), - kind=30005, + kind=30018, tags=[["d", self.id]] + categories, content=json.dumps(content, separators=(",", ":"), ensure_ascii=False), ) @@ -175,8 +195,21 @@ class Product(PartialProduct): return event + def to_nostr_delete_event(self, pubkey: str) -> NostrEvent: + delete_event = NostrEvent( + pubkey=pubkey, + created_at=round(time.time()), + kind=5, + tags=[["e", self.config.event_id]], + content=f"Product '{self.name}' deleted", + ) + delete_event.id = delete_event.event_id + + return delete_event + @classmethod def from_row(cls, row: Row) -> "Product": product = cls(**dict(row)) + product.config = ProductConfig(**json.loads(row["meta"])) product.categories = json.loads(row["category_list"]) return product diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index 29a5b42..306c123 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -10,7 +10,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( diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index 82610ca..b26caf3 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -7,6 +7,20 @@
+
+
ID:
+
+ +
+
+
Name:
@@ -161,7 +175,7 @@
- {{props.row.description}} + {{props.row.config.description}} @@ -187,7 +201,7 @@ Product: try: data.validate_product() + + stall = await get_stall(wallet.wallet.user, data.stall_id) + assert stall, "Stall missing for product" + data.config.currency = stall.currency + product = await create_product(wallet.wallet.user, data=data) + event = await sign_and_send_to_nostr(wallet.wallet.user, product) + + product.config.event_id = event.id + await update_product(wallet.wallet.user, product) + return product except ValueError as ex: raise HTTPException( @@ -331,9 +346,22 @@ async def api_update_product( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Product: try: + if product_id != product.id: + raise ValueError("Bad product ID") + product.validate_product() + + stall = await get_stall(wallet.wallet.user, product.stall_id) + assert stall, "Stall missing for product" + product.config.currency = stall.currency + product = await update_product(wallet.wallet.user, product) + event = await sign_and_send_to_nostr(wallet.wallet.user, product) + + product.config.event_id = event.id + await update_product(wallet.wallet.user, product) + return product except ValueError as ex: raise HTTPException( @@ -348,13 +376,13 @@ async def api_update_product( ) -@nostrmarket_ext.get("/api/v1/product/{stall_id}") +@nostrmarket_ext.get("/api/v1/product/{product_id}") async def api_get_product( - stall_id: str, + product_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key), -): +) -> Optional[Product]: try: - products = await get_products(wallet.wallet.user, stall_id) + products = await get_product(wallet.wallet.user, product_id) return products except Exception as ex: logger.warning(ex) @@ -370,7 +398,18 @@ async def api_delete_product( wallet: WalletTypeInfo = Depends(require_admin_key), ): try: + product = await get_product(wallet.wallet.user, product_id) + if not product: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Product does not exist.", + ) + await delete_product(wallet.wallet.user, product_id) + await sign_and_send_to_nostr(wallet.wallet.user, product, True) + + except HTTPException as ex: + raise ex except Exception as ex: logger.warning(ex) raise HTTPException( @@ -385,3 +424,23 @@ async def api_delete_product( @nostrmarket_ext.get("/api/v1/currencies") async def api_list_currencies_available(): return list(currencies.keys()) + + +######################################## HELPERS ######################################## + + +async def sign_and_send_to_nostr( + user_id: str, n: Nostrable, delete=False +) -> NostrEvent: + merchant = await get_merchant_for_user(user_id) + assert merchant, "Cannot find merchant!" + + event = ( + n.to_nostr_delete_event(merchant.public_key) + if delete + else n.to_nostr_event(merchant.public_key) + ) + event.sig = merchant.sign_hash(bytes.fromhex(event.id)) + await publish_nostr_event(event) + + return event