From c15b765a7dab223c608b558703e177fbceaa821a Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 1 Mar 2023 09:22:39 +0200 Subject: [PATCH 01/26] feat: add empty card --- templates/nostrmarket/index.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index b4378d8..1235bf2 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -87,6 +87,14 @@ + + +
+
+
+
+
+
From e9b7494bb61ac397f3b0f6bb4dfe258c23ce3ad1 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 1 Mar 2023 11:35:04 +0200 Subject: [PATCH 02/26] feat: basic `stall flow` --- crud.py | 63 +++++++- migrations.py | 6 +- models.py | 27 ++++ static/components/stall-list/stall-list.html | 157 +++++++++++++++++++ static/components/stall-list/stall-list.js | 84 ++++++++++ static/js/index.js | 3 +- templates/nostrmarket/index.html | 20 +-- views_api.py | 86 +++++++++- 8 files changed, 432 insertions(+), 14 deletions(-) create mode 100644 static/components/stall-list/stall-list.html create mode 100644 static/components/stall-list/stall-list.js diff --git a/crud.py b/crud.py index 96c5bf0..e66f639 100644 --- a/crud.py +++ b/crud.py @@ -4,7 +4,7 @@ from typing import List, Optional from lnbits.helpers import urlsafe_short_hash from . import db -from .models import Merchant, PartialMerchant, PartialZone, Zone +from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone ######################################## MERCHANT ######################################## @@ -105,3 +105,64 @@ async def get_zones(user_id: str) -> List[Zone]: async def delete_zone(zone_id: str) -> None: await db.execute("DELETE FROM nostrmarket.zones WHERE id = ?", (zone_id,)) + + +######################################## STALL ######################################## + + +async def create_stall(user_id: str, data: PartialStall) -> Stall: + stall_id = urlsafe_short_hash() + await db.execute( + f""" + INSERT INTO nostrmarket.stalls (user_id, id, wallet, name, currency, zones, meta) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + stall_id, + data.wallet, + data.name, + data.currency, + json.dumps(data.shipping_zones), + json.dumps(dict(data.config)), + ), + ) + + stall = await get_stall(user_id, stall_id) + assert stall, "Newly created stall couldn't be retrieved" + return stall + + +async def get_stall(user_id: str, stall_id: str) -> Optional[Stall]: + row = await db.fetchone( + "SELECT * FROM nostrmarket.stalls WHERE user_id = ? AND id = ?", + ( + user_id, + stall_id, + ), + ) + return Stall.from_row(row) if row else None + + +async def get_stalls(user_id: str) -> List[Stall]: + rows = await db.fetchone( + "SELECT * FROM nostrmarket.stalls WHERE user_id = ?", + (user_id,), + ) + return [Stall.from_row(row) for row in rows] + + +async def update_stall(user_id: str, stall_id: str, **kwargs) -> Optional[Stall]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE market.stalls SET {q} WHERE user_id = ? AND id = ?", + (*kwargs.values(), user_id, stall_id), + ) + row = await db.fetchone( + "SELECT * FROM market.stalls WHERE user_id =? AND id = ?", + ( + user_id, + stall_id, + ), + ) + return Stall.from_row(row) if row else None diff --git a/migrations.py b/migrations.py index 04a4fe1..6d20f9b 100644 --- a/migrations.py +++ b/migrations.py @@ -18,15 +18,17 @@ async def m001_initial(db): """ Initial stalls table. """ + # user_id, id, wallet, name, currency, zones, meta await db.execute( """ CREATE TABLE nostrmarket.stalls ( + user_id TEXT NOT NULL, id TEXT PRIMARY KEY, wallet TEXT NOT NULL, name TEXT NOT NULL, currency TEXT, - shipping_zones TEXT NOT NULL, - rating REAL DEFAULT 0 + zones TEXT NOT NULL DEFAULT '[]', + meta TEXT NOT NULL DEFAULT '{}' ); """ ) diff --git a/models.py b/models.py index 40bb57f..8f98f24 100644 --- a/models.py +++ b/models.py @@ -43,3 +43,30 @@ class Zone(PartialZone): zone = cls(**dict(row)) zone.countries = json.loads(row["regions"]) return zone + + +######################################## STALLS ######################################## + + +class StallConfig(BaseModel): + image_url: Optional[str] + fiat_base_multiplier: int = 1 # todo: reminder wht is this for? + + +class PartialStall(BaseModel): + wallet: str + name: str + currency: str = "sat" + shipping_zones: List[str] = [] + config: StallConfig = StallConfig() + + +class Stall(PartialStall): + id: str + + @classmethod + def from_row(cls, row: Row) -> "Stall": + stall = cls(**dict(row)) + stall.config = StallConfig(**json.loads(row["meta"])) + stall.shipping_zones = json.loads(row["zones"]) + return stall diff --git a/static/components/stall-list/stall-list.html b/static/components/stall-list/stall-list.html new file mode 100644 index 0000000..6778614 --- /dev/null +++ b/static/components/stall-list/stall-list.html @@ -0,0 +1,157 @@ +
+
+
+ New Stall + + + +
+ +
+ +
+ + + + + + + + + +
+ Create Stall + Cancel +
+
+
+
+
+
diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js new file mode 100644 index 0000000..6ba13dd --- /dev/null +++ b/static/components/stall-list/stall-list.js @@ -0,0 +1,84 @@ +async function stallList(path) { + const template = await loadTemplateAsync(path) + Vue.component('stall-list', { + name: 'stall-list', + template, + + props: [`adminkey`, 'inkey', 'wallet-options'], + data: function () { + return { + filter: '', + stalls: [], + currencies: [], + stallDialog: { + show: false, + data: { + name: '', + wallet: null, + currency: 'sat', + shippingZones: [] + } + }, + zoneOptions: [] + } + }, + methods: { + sendStallFormData: async function () { + console.log('### sendStallFormData', this.stallDialog.data) + + await this.createStall({ + name: this.stallDialog.data.name, + wallet: this.stallDialog.data.wallet, + currency: this.stallDialog.data.currency, + shipping_zones: this.stallDialog.data.shippingZones.map(z => z.id), + config: {} + }) + }, + createStall: async function (stall) { + console.log('### createStall', stall) + try { + const {data} = await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/stall', + this.adminkey, + stall + ) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getCurrencies: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/currencies', + this.inkey + ) + + this.currencies = ['sat', ...data] + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getZones: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/zone', + this.inkey + ) + this.zoneOptions = data.map(z => ({ + id: z.id, + label: `${z.name} (${z.countries.join(', ')})` + })) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + } + }, + created: async function () { + await this.getCurrencies() + await this.getZones() + } + }) +} diff --git a/static/js/index.js b/static/js/index.js index 68ece51..343b5eb 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,9 +1,10 @@ const merchant = async () => { Vue.component(VueQrcode.name, VueQrcode) - await stallDetails('static/components/stall-details/stall-details.html') await keyPair('static/components/key-pair/key-pair.html') 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') const nostr = window.NostrTools diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index 1235bf2..8e61d74 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -86,15 +86,16 @@ > + + + + +
- - -
-
-
-
-
-
@@ -115,9 +116,10 @@ - + + {% endblock %} diff --git a/views_api.py b/views_api.py index f294de0..2488b7c 100644 --- a/views_api.py +++ b/views_api.py @@ -16,14 +16,18 @@ from lnbits.utils.exchange_rates import currencies from . import nostrmarket_ext from .crud import ( create_merchant, + create_stall, create_zone, delete_zone, get_merchant_for_user, + get_stall, + get_stalls, get_zone, get_zones, + update_stall, update_zone, ) -from .models import Merchant, PartialMerchant, PartialZone, Zone +from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone ######################################## MERCHANT ######################################## @@ -138,6 +142,86 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi ) +######################################## STALLS ######################################## + + +@nostrmarket_ext.post("/api/v1/stall") +async def api_create_stall( + data: PartialStall, + wallet: WalletTypeInfo = Depends(require_invoice_key), +): + try: + stall = await create_stall(wallet.wallet.user, data=data) + return stall.dict() + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create stall", + ) + + +@nostrmarket_ext.put("/api/v1/stall/{stall_id}") +async def api_update_stall( + data: Stall, + wallet: WalletTypeInfo = Depends(require_invoice_key), +): + try: + stall = await get_stall(wallet.wallet.user, data.id) + if not stall: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Stall does not exist.", + ) + stall = await update_stall(wallet.wallet.user, data.id, **data.dict()) + assert stall, "Cannot fetch updated stall" + return stall.dict() + except HTTPException as ex: + raise ex + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create stall", + ) + + +@nostrmarket_ext.get("/api/v1/stall/{stall_id}") +async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_type)): + try: + stall = await get_stall(wallet.wallet.user, stall_id) + if not stall: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Stall does not exist.", + ) + return stall + except HTTPException as ex: + raise ex + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create stall", + ) + + +@nostrmarket_ext.get("/api/v1/stall") +async def api_gey_stalls(wallet: WalletTypeInfo = Depends(get_key_type)): + try: + stalls = await get_stalls(wallet.wallet.user) + return stalls + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create stall", + ) + + +######################################## OTHER ######################################## + + @nostrmarket_ext.get("/api/v1/currencies") async def api_list_currencies_available(): return list(currencies.keys()) From aa10f639b5ecedd82daf717ade5df20b7b95f48d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 1 Mar 2023 11:46:21 +0200 Subject: [PATCH 03/26] feat: create stall --- static/components/stall-list/stall-list.html | 8 ++++++-- static/components/stall-list/stall-list.js | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/static/components/stall-list/stall-list.html b/static/components/stall-list/stall-list.html index 6778614..ba21bc4 100644 --- a/static/components/stall-list/stall-list.html +++ b/static/components/stall-list/stall-list.html @@ -2,7 +2,7 @@
Create Stall diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js index 6ba13dd..7ecf91f 100644 --- a/static/components/stall-list/stall-list.js +++ b/static/components/stall-list/stall-list.js @@ -35,7 +35,6 @@ async function stallList(path) { }) }, createStall: async function (stall) { - console.log('### createStall', stall) try { const {data} = await LNbits.api.request( 'POST', @@ -43,6 +42,11 @@ async function stallList(path) { this.adminkey, stall ) + this.stallDialog.show = false + this.$q.notify({ + type: 'positive', + message: 'Stall created!' + }) } catch (error) { LNbits.utils.notifyApiError(error) } @@ -74,6 +78,17 @@ async function stallList(path) { } catch (error) { LNbits.utils.notifyApiError(error) } + }, + openCreateStallDialog: async function () { + await this.getCurrencies() + await this.getZones() + this.stallDialog.data = { + name: '', + wallet: null, + currency: 'sat', + shippingZones: [] + } + this.stallDialog.show = true } }, created: async function () { From 7fad3fc38dcbb4ed166d4ffb281636c2ab031d41 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 1 Mar 2023 11:49:30 +0200 Subject: [PATCH 04/26] feat: add icon to keys --- templates/nostrmarket/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index 8e61d74..bb1517f 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -71,7 +71,8 @@
Show Public or Private keys From 7162f44450d04a4f33a547f74e4d24b47bd1d688 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 1 Mar 2023 14:09:39 +0200 Subject: [PATCH 05/26] feat: refine table columns --- migrations.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/migrations.py b/migrations.py index 6d20f9b..ae4ac68 100644 --- a/migrations.py +++ b/migrations.py @@ -44,10 +44,9 @@ async def m001_initial(db): name TEXT NOT NULL, categories TEXT, description TEXT, - image TEXT, + image_urls TEXT NOT NULL DEFAULT '[]', price REAL NOT NULL, - quantity INTEGER NOT NULL, - rating REAL DEFAULT 0 + quantity INTEGER NOT NULL ); """ ) From 7f7f5a08b094e61a5d90f46ac81e0276a11520cc Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 1 Mar 2023 14:23:51 +0200 Subject: [PATCH 06/26] fix: column name --- migrations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations.py b/migrations.py index ae4ac68..8af5726 100644 --- a/migrations.py +++ b/migrations.py @@ -44,7 +44,7 @@ async def m001_initial(db): name TEXT NOT NULL, categories TEXT, description TEXT, - image_urls TEXT NOT NULL DEFAULT '[]', + images TEXT NOT NULL DEFAULT '[]', price REAL NOT NULL, quantity INTEGER NOT NULL ); From aba3706a71043b50dc3f4f0d4729cfce7c0d57a4 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 1 Mar 2023 14:57:18 +0200 Subject: [PATCH 07/26] feat: minor UI improvements --- static/components/shipping-zones/shipping-zones.html | 1 - templates/nostrmarket/index.html | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/static/components/shipping-zones/shipping-zones.html b/static/components/shipping-zones/shipping-zones.html index 100afe9..e013302 100644 --- a/static/components/shipping-zones/shipping-zones.html +++ b/static/components/shipping-zones/shipping-zones.html @@ -4,7 +4,6 @@ unelevated color="primary" icon="public" - label="Shipping Zones" @click="openZoneDialog()" > diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index bb1517f..6ebd25c 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -61,19 +61,20 @@
-
-
+
+
Show Public or Private keys From 5972c44ad1f1bc6778e34db7b6004eec36e6d0aa Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 10:14:11 +0200 Subject: [PATCH 08/26] feat: publish event on `C_UD` for stalls --- crud.py | 47 ++++++++----- helpers.py | 81 +++++++++++++++++++++ models.py | 47 +++++++++++-- nostr/event.py | 57 +++++++++++++++ nostr/nostr_client.py | 21 ++++++ static/components/stall-list/stall-list.js | 10 ++- views_api.py | 82 ++++++++++++++++++---- 7 files changed, 305 insertions(+), 40 deletions(-) create mode 100644 helpers.py create mode 100644 nostr/event.py create mode 100644 nostr/nostr_client.py diff --git a/crud.py b/crud.py index e66f639..55cf4ae 100644 --- a/crud.py +++ b/crud.py @@ -1,4 +1,5 @@ import json +import time from typing import List, Optional from lnbits.helpers import urlsafe_short_hash @@ -51,15 +52,7 @@ async def create_zone(user_id: str, data: PartialZone) -> Zone: zone_id = urlsafe_short_hash() await db.execute( f""" - INSERT INTO nostrmarket.zones ( - id, - user_id, - name, - currency, - cost, - regions - - ) + INSERT INTO nostrmarket.zones (id, user_id, name, currency, cost, regions) VALUES (?, ?, ?, ?, ?, ?) """, ( @@ -112,6 +105,7 @@ async def delete_zone(zone_id: str) -> None: async def create_stall(user_id: str, data: PartialStall) -> Stall: stall_id = urlsafe_short_hash() + await db.execute( f""" INSERT INTO nostrmarket.stalls (user_id, id, wallet, name, currency, zones, meta) @@ -123,8 +117,10 @@ async def create_stall(user_id: str, data: PartialStall) -> Stall: data.wallet, data.name, data.currency, - json.dumps(data.shipping_zones), - json.dumps(dict(data.config)), + json.dumps( + [z.dict() for z in data.shipping_zones] + ), # todo: cost is float. should be int for sats + json.dumps(data.config.dict()), ), ) @@ -152,17 +148,32 @@ async def get_stalls(user_id: str) -> List[Stall]: return [Stall.from_row(row) for row in rows] -async def update_stall(user_id: str, stall_id: str, **kwargs) -> Optional[Stall]: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) +async def update_stall(user_id: str, stall: Stall) -> Optional[Stall]: await db.execute( - f"UPDATE market.stalls SET {q} WHERE user_id = ? AND id = ?", - (*kwargs.values(), user_id, stall_id), + f""" + UPDATE nostrmarket.stalls SET wallet = ?, name = ?, currency = ?, zones = ?, meta = ? + WHERE user_id = ? AND id = ? + """, + ( + stall.wallet, + stall.name, + stall.currency, + json.dumps( + [z.dict() for z in stall.shipping_zones] + ), # todo: cost is float. should be int for sats + json.dumps(stall.config.dict()), + user_id, + stall.id, + ), ) - row = await db.fetchone( - "SELECT * FROM market.stalls WHERE user_id =? AND id = ?", + return await get_stall(user_id, stall.id) + + +async def delete_stall(user_id: str, stall_id: str) -> None: + await db.execute( + "DELETE FROM nostrmarket.stalls WHERE user_id =? AND id = ?", ( user_id, stall_id, ), ) - return Stall.from_row(row) if row else None diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..8747c48 --- /dev/null +++ b/helpers.py @@ -0,0 +1,81 @@ +import base64 +import json +import secrets +from typing import Optional + +import secp256k1 +from cffi import FFI +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +def get_shared_secret(privkey: str, pubkey: str): + point = secp256k1.PublicKey(bytes.fromhex("02" + pubkey), True) + return point.ecdh(bytes.fromhex(privkey), hashfn=copy_x) + + +def decrypt_message(encoded_message: str, encryption_key) -> str: + encoded_data = encoded_message.split("?iv=") + encoded_content, encoded_iv = encoded_data[0], encoded_data[1] + + iv = base64.b64decode(encoded_iv) + cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv)) + encrypted_content = base64.b64decode(encoded_content) + + decryptor = cipher.decryptor() + decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize() + + unpadder = padding.PKCS7(128).unpadder() + unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize() + + return unpadded_data.decode() + + +def encrypt_message(message: str, encryption_key, iv: Optional[bytes]) -> str: + padder = padding.PKCS7(128).padder() + padded_data = padder.update(message.encode()) + padder.finalize() + + iv = iv if iv else secrets.token_bytes(16) + cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv)) + + encryptor = cipher.encryptor() + encrypted_message = encryptor.update(padded_data) + encryptor.finalize() + + return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" + + +def sign_message_hash(private_key: str, hash: bytes) -> str: + privkey = secp256k1.PrivateKey(bytes.fromhex(private_key)) + sig = privkey.schnorr_sign(hash, None, raw=True) + return sig.hex() + + +def test_decrypt_encrypt(encoded_message: str, encryption_key): + msg = decrypt_message(encoded_message, encryption_key) + + # ecrypt using the same initialisation vector + iv = base64.b64decode(encoded_message.split("?iv=")[1]) + ecrypted_msg = encrypt_message(msg, encryption_key, iv) + assert ( + encoded_message == ecrypted_msg + ), f"expected '{encoded_message}', but got '{ecrypted_msg}'" + print("### test_decrypt_encrypt", encoded_message == ecrypted_msg) + + +ffi = FFI() + + +@ffi.callback( + "int (unsigned char *, const unsigned char *, const unsigned char *, void *)" +) +def copy_x(output, x32, y32, data): + ffi.memmove(output, x32, 32) + return 1 + + +def is_json(string: str): + try: + json.loads(string) + except ValueError as e: + return False + return True diff --git a/models.py b/models.py index 8f98f24..bd44c44 100644 --- a/models.py +++ b/models.py @@ -1,10 +1,13 @@ import json +import time from sqlite3 import Row from typing import List, Optional -from fastapi import Query from pydantic import BaseModel +from .helpers import sign_message_hash +from .nostr.event import NostrEvent + ######################################## MERCHANT ######################################## class MerchantConfig(BaseModel): @@ -20,6 +23,9 @@ class PartialMerchant(BaseModel): class Merchant(PartialMerchant): id: str + def sign_hash(self, hash: bytes) -> str: + return sign_message_hash(self.private_key, hash) + @classmethod def from_row(cls, row: Row) -> "Merchant": merchant = cls(**dict(row)) @@ -49,24 +55,57 @@ class Zone(PartialZone): class StallConfig(BaseModel): + """Last published nostr event id for this Stall""" + + event_id: Optional[str] image_url: Optional[str] - fiat_base_multiplier: int = 1 # todo: reminder wht is this for? + description: Optional[str] class PartialStall(BaseModel): wallet: str name: str currency: str = "sat" - shipping_zones: List[str] = [] + shipping_zones: List[PartialZone] = [] config: StallConfig = StallConfig() class Stall(PartialStall): id: str + def to_nostr_event(self, pubkey: str) -> NostrEvent: + content = { + "name": self.name, + "description": self.config.description, + "currency": self.currency, + "shipping": [dict(z) for z in self.shipping_zones], + } + event = NostrEvent( + pubkey=pubkey, + created_at=round(time.time()), + kind=30005, + tags=[["d", self.id]], + content=json.dumps(content, separators=(",", ":"), ensure_ascii=False), + ) + event.id = event.event_id + + 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="Stall deleted", + ) + delete_event.id = delete_event.event_id + + return delete_event + @classmethod def from_row(cls, row: Row) -> "Stall": stall = cls(**dict(row)) stall.config = StallConfig(**json.loads(row["meta"])) - stall.shipping_zones = json.loads(row["zones"]) + stall.shipping_zones = [PartialZone(**z) for z in json.loads(row["zones"])] return stall diff --git a/nostr/event.py b/nostr/event.py new file mode 100644 index 0000000..c92a4fa --- /dev/null +++ b/nostr/event.py @@ -0,0 +1,57 @@ +import hashlib +import json +from typing import List, Optional + +from pydantic import BaseModel +from secp256k1 import PublicKey + + +class NostrEvent(BaseModel): + id: str = "" + pubkey: str + created_at: int + kind: int + tags: List[List[str]] = [] + content: str = "" + sig: Optional[str] + + def serialize(self) -> List: + return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content] + + def serialize_json(self) -> str: + e = self.serialize() + return json.dumps(e, separators=(",", ":"), ensure_ascii=False) + + @property + def event_id(self) -> str: + data = self.serialize_json() + id = hashlib.sha256(data.encode()).hexdigest() + return id + + def check_signature(self): + event_id = self.event_id + if self.id != event_id: + raise ValueError( + f"Invalid event id. Expected: '{event_id}' got '{self.id}'" + ) + try: + pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True) + except Exception: + raise ValueError( + f"Invalid public key: '{self.pubkey}' for event '{self.id}'" + ) + + valid_signature = pub_key.schnorr_verify( + bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True + ) + if not valid_signature: + raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'") + + def stringify(self) -> str: + return json.dumps(dict(self)) + + def tag_values(self, tag_name: str) -> List[str]: + return [t[1] for t in self.tags if t[0] == tag_name] + + def has_tag_value(self, tag_name: str, tag_value: str) -> bool: + return tag_value in self.tag_values(tag_name) diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py new file mode 100644 index 0000000..29a5b42 --- /dev/null +++ b/nostr/nostr_client.py @@ -0,0 +1,21 @@ +import httpx +from loguru import logger + +from lnbits.app import settings +from lnbits.helpers import url_for + +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)) + async with httpx.AsyncClient() as client: + try: + await client.post( + url, + json=data, + ) + except Exception as ex: + logger.warning(ex) diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js index 7ecf91f..6ba3ef5 100644 --- a/static/components/stall-list/stall-list.js +++ b/static/components/stall-list/stall-list.js @@ -30,7 +30,7 @@ async function stallList(path) { name: this.stallDialog.data.name, wallet: this.stallDialog.data.wallet, currency: this.stallDialog.data.currency, - shipping_zones: this.stallDialog.data.shippingZones.map(z => z.id), + shipping_zones: this.stallDialog.data.shippingZones, config: {} }) }, @@ -71,10 +71,14 @@ async function stallList(path) { '/nostrmarket/api/v1/zone', this.inkey ) + console.log('### zones', data) this.zoneOptions = data.map(z => ({ - id: z.id, - label: `${z.name} (${z.countries.join(', ')})` + ...z, + label: z.name + ? `${z.name} (${z.countries.join(', ')}})` + : z.countries.join(', ') })) + console.log('### this.zoneOptions', this.zoneOptions) } catch (error) { LNbits.utils.notifyApiError(error) } diff --git a/views_api.py b/views_api.py index 2488b7c..c03f7f4 100644 --- a/views_api.py +++ b/views_api.py @@ -1,3 +1,4 @@ +import json from http import HTTPStatus from typing import List, Optional @@ -18,6 +19,7 @@ from .crud import ( create_merchant, create_stall, create_zone, + delete_stall, delete_zone, get_merchant_for_user, get_stall, @@ -28,6 +30,7 @@ from .crud import ( update_zone, ) from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone +from .nostr.nostr_client import publish_nostr_event ######################################## MERCHANT ######################################## @@ -148,11 +151,23 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi @nostrmarket_ext.post("/api/v1/stall") async def api_create_stall( data: PartialStall, - wallet: WalletTypeInfo = Depends(require_invoice_key), -): + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Stall: try: + print("### stall", json.dumps(data.dict())) + merchant = await get_merchant_for_user(wallet.wallet.user) + assert merchant, "Cannot find merchat for stall" + stall = await create_stall(wallet.wallet.user, data=data) - return stall.dict() + + event = stall.to_nostr_event(merchant.public_key) + event.sig = merchant.sign_hash(bytes.fromhex(event.id)) + await publish_nostr_event(event) + + stall.config.event_id = event.id + await update_stall(wallet.wallet.user, stall) + + return stall except Exception as ex: logger.warning(ex) raise HTTPException( @@ -164,18 +179,23 @@ async def api_create_stall( @nostrmarket_ext.put("/api/v1/stall/{stall_id}") async def api_update_stall( data: Stall, - wallet: WalletTypeInfo = Depends(require_invoice_key), -): + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Stall: try: - stall = await get_stall(wallet.wallet.user, data.id) - if not stall: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Stall does not exist.", - ) - stall = await update_stall(wallet.wallet.user, data.id, **data.dict()) - assert stall, "Cannot fetch updated stall" - return stall.dict() + merchant = await get_merchant_for_user(wallet.wallet.user) + assert merchant, "Cannot find merchat for stall" + + event = data.to_nostr_event(merchant.public_key) + event.sig = merchant.sign_hash(bytes.fromhex(event.id)) + + data.config.event_id = event.id + # data.config.created_at = + stall = await update_stall(wallet.wallet.user, data) + assert stall, "Cannot update stall" + + await publish_nostr_event(event) + + return stall except HTTPException as ex: raise ex except Exception as ex: @@ -207,7 +227,7 @@ async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_ @nostrmarket_ext.get("/api/v1/stall") -async def api_gey_stalls(wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_get_stalls(wallet: WalletTypeInfo = Depends(get_key_type)): try: stalls = await get_stalls(wallet.wallet.user) return stalls @@ -219,6 +239,38 @@ async def api_gey_stalls(wallet: WalletTypeInfo = Depends(get_key_type)): ) +@nostrmarket_ext.delete("/api/v1/stall/{stall_id}") +async def api_delete_stall( + stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +): + try: + stall = await get_stall(wallet.wallet.user, stall_id) + if not stall: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Stall does not exist.", + ) + + merchant = await get_merchant_for_user(wallet.wallet.user) + assert merchant, "Cannot find merchat for stall" + + await delete_stall(wallet.wallet.user, stall_id) + + delete_event = stall.to_nostr_delete_event(merchant.public_key) + delete_event.sig = merchant.sign_hash(bytes.fromhex(delete_event.id)) + + await publish_nostr_event(delete_event) + + except HTTPException as ex: + raise ex + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot delte stall", + ) + + ######################################## OTHER ######################################## From 16a6104b25a93d811bab9af640bdae6fdcee6f04 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 10:28:10 +0200 Subject: [PATCH 09/26] fix: only allow zones with the same currency to be selected --- .../shipping-zones/shipping-zones.html | 4 ++-- static/components/stall-list/stall-list.html | 10 +++++++++- static/components/stall-list/stall-list.js | 17 +++++++++++++---- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/static/components/shipping-zones/shipping-zones.html b/static/components/shipping-zones/shipping-zones.html index e013302..6984c6c 100644 --- a/static/components/shipping-zones/shipping-zones.html +++ b/static/components/shipping-zones/shipping-zones.html @@ -47,8 +47,8 @@ v-model="zoneDialog.data.countries" > + z.currency === this.stallDialog.data.currency + ) + } + }, methods: { sendStallFormData: async function () { - console.log('### sendStallFormData', this.stallDialog.data) - await this.createStall({ name: this.stallDialog.data.name, wallet: this.stallDialog.data.wallet, currency: this.stallDialog.data.currency, shipping_zones: this.stallDialog.data.shippingZones, - config: {} + config: { + description: this.stallDialog.data.description + } }) }, createStall: async function (stall) { @@ -75,7 +83,7 @@ async function stallList(path) { this.zoneOptions = data.map(z => ({ ...z, label: z.name - ? `${z.name} (${z.countries.join(', ')}})` + ? `${z.name} (${z.countries.join(', ')})` : z.countries.join(', ') })) console.log('### this.zoneOptions', this.zoneOptions) @@ -88,6 +96,7 @@ async function stallList(path) { await this.getZones() this.stallDialog.data = { name: '', + description: '', wallet: null, currency: 'sat', shippingZones: [] From c0dce31231b49915c801419430a7b0470bedd3da Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 10:36:39 +0200 Subject: [PATCH 10/26] feat: validate currency for stall shipping zones --- models.py | 7 +++++++ views_api.py | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/models.py b/models.py index bd44c44..060b462 100644 --- a/models.py +++ b/models.py @@ -69,6 +69,13 @@ class PartialStall(BaseModel): shipping_zones: List[PartialZone] = [] config: StallConfig = StallConfig() + def validate_stall(self): + for z in self.shipping_zones: + if z.currency != self.currency: + raise ValueError( + f"Sipping zone '{z.name}' has different currency than stall." + ) + class Stall(PartialStall): id: str diff --git a/views_api.py b/views_api.py index c03f7f4..393c15b 100644 --- a/views_api.py +++ b/views_api.py @@ -154,6 +154,8 @@ async def api_create_stall( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Stall: try: + data.validate_stall() + print("### stall", json.dumps(data.dict())) merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Cannot find merchat for stall" @@ -182,6 +184,8 @@ async def api_update_stall( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Stall: try: + data.validate_stall() + merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Cannot find merchat for stall" From ac02337ad3a8ea0a613de211bc80c254b071347a Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 11:46:46 +0200 Subject: [PATCH 11/26] feat: show stall list table --- crud.py | 2 +- static/components/stall-list/stall-list.html | 122 +++++++++---------- static/components/stall-list/stall-list.js | 53 +++++++- views_api.py | 8 +- 4 files changed, 114 insertions(+), 71 deletions(-) diff --git a/crud.py b/crud.py index 55cf4ae..0f52c67 100644 --- a/crud.py +++ b/crud.py @@ -141,7 +141,7 @@ async def get_stall(user_id: str, stall_id: str) -> Optional[Stall]: async def get_stalls(user_id: str) -> List[Stall]: - rows = await db.fetchone( + rows = await db.fetchall( "SELECT * FROM nostrmarket.stalls WHERE user_id = ?", (user_id,), ) diff --git a/static/components/stall-list/stall-list.html b/static/components/stall-list/stall-list.html index 03e8ba2..a039c30 100644 --- a/static/components/stall-list/stall-list.html +++ b/static/components/stall-list/stall-list.html @@ -35,73 +35,65 @@
-->
- + + + {{props.row.config.description}} + + +
+ {{props.row.shipping_zones.filter(z => !!z.name).map(z => + z.name).join(', ')}} +
+
+ + + +
+
+
- - - - {% endraw %} - - --> +
+
+
+ + +
diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js index 352bcf1..1ca67fe 100644 --- a/static/components/stall-list/stall-list.js +++ b/static/components/stall-list/stall-list.js @@ -20,7 +20,44 @@ async function stallList(path) { shippingZones: [] } }, - zoneOptions: [] + zoneOptions: [], + stallsTable: { + columns: [ + { + name: '', + align: 'left', + label: '', + field: '' + }, + { + name: 'id', + align: 'left', + label: 'Name', + field: 'id' + }, + // { + // name: 'toggle', + // align: 'left', + // label: 'Active', + // field: '' + // }, + { + name: 'description', + align: 'left', + label: 'Description', + field: 'description' + }, + { + name: 'shippingZones', + align: 'left', + label: 'Shipping Zones', + field: 'shippingZones' + } + ], + pagination: { + rowsPerPage: 10 + } + } } }, computed: { @@ -72,6 +109,19 @@ async function stallList(path) { LNbits.utils.notifyApiError(error) } }, + getStalls: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/stall', + this.inkey + ) + console.log('### stalls', data) + this.stalls = data.map(s => ({...s, expanded: false})) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, getZones: async function () { try { const {data} = await LNbits.api.request( @@ -105,6 +155,7 @@ async function stallList(path) { } }, created: async function () { + await this.getStalls() await this.getCurrencies() await this.getZones() } diff --git a/views_api.py b/views_api.py index 393c15b..de83d66 100644 --- a/views_api.py +++ b/views_api.py @@ -193,7 +193,7 @@ async def api_update_stall( event.sig = merchant.sign_hash(bytes.fromhex(event.id)) data.config.event_id = event.id - # data.config.created_at = + # data.config.event_created_at = stall = await update_stall(wallet.wallet.user, data) assert stall, "Cannot update stall" @@ -206,7 +206,7 @@ async def api_update_stall( logger.warning(ex) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot create stall", + detail="Cannot update stall", ) @@ -226,7 +226,7 @@ async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_ logger.warning(ex) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot create stall", + detail="Cannot get stall", ) @@ -239,7 +239,7 @@ async def api_get_stalls(wallet: WalletTypeInfo = Depends(get_key_type)): logger.warning(ex) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot create stall", + detail="Cannot get stalls", ) From 6ff1997d36ba524eb766e5797b6ac39ea8a7b2f0 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 13:31:42 +0200 Subject: [PATCH 12/26] feat: add basic stall details --- .../stall-details/stall-details.html | 109 ++++++++++++++++++ .../components/stall-details/stall-details.js | 39 ++++++- static/components/stall-list/stall-list.html | 11 +- templates/nostrmarket/index.html | 3 +- 4 files changed, 158 insertions(+), 4 deletions(-) diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index e69de29..73a11d2 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -0,0 +1,109 @@ +
+ + + + + + + +
+
+
Name:
+
+ +
+
+
+
+
Description:
+
+ +
+
+
+
+
Wallet:
+
+ + +
+
+
+
+
Currency:
+
+ +
+
+
+
+
Shipping Zones:
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+ +
diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index 5b929e1..ab81ef1 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -1,15 +1,50 @@ async function stallDetails(path) { const template = await loadTemplateAsync(path) + Vue.component('stall-details', { name: 'stall-details', template, - //props: ['stall-id', 'adminkey', 'inkey', 'wallet-options'], + props: [ + 'stall-id', + 'adminkey', + 'inkey', + 'wallet-options', + 'zone-options', + 'currencies' + ], data: function () { return { tab: 'info', - relay: null + stall: null + // currencies: [], } + }, + computed: { + filteredZoneOptions: function () { + if (!this.stall) return [] + return this.zoneOptions.filter(z => z.currency === this.stall.currency) + } + }, + methods: { + getStall: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/stall/' + this.stallId, + this.inkey + ) + this.stall = data + console.log('### this.stall', this.stall) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + } + + }, + created: async function () { + await this.getStall() + console.log('### this.zoneOptions', this.zoneOptions) } }) } diff --git a/static/components/stall-list/stall-list.html b/static/components/stall-list/stall-list.html index a039c30..9ef981a 100644 --- a/static/components/stall-list/stall-list.html +++ b/static/components/stall-list/stall-list.html @@ -86,7 +86,16 @@
- + +
diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index 6ebd25c..41fa040 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -76,7 +76,7 @@ color="primary" class="float-right" > - Show Public or Private keys + Show Public and Private keys
@@ -115,6 +115,7 @@
{% endblock%}{% block scripts %} {{ window_vars(user) }} + From f95db754953023c0c43131f351459d9f585f13aa Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 13:41:10 +0200 Subject: [PATCH 13/26] fix: store id for shipping zone --- models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models.py b/models.py index 060b462..e52ba6e 100644 --- a/models.py +++ b/models.py @@ -66,7 +66,7 @@ class PartialStall(BaseModel): wallet: str name: str currency: str = "sat" - shipping_zones: List[PartialZone] = [] + shipping_zones: List[Zone] = [] config: StallConfig = StallConfig() def validate_stall(self): @@ -114,5 +114,5 @@ class Stall(PartialStall): def from_row(cls, row: Row) -> "Stall": stall = cls(**dict(row)) stall.config = StallConfig(**json.loads(row["meta"])) - stall.shipping_zones = [PartialZone(**z) for z in json.loads(row["zones"])] + stall.shipping_zones = [Zone(**z) for z in json.loads(row["zones"])] return stall From 6d8488d2c31f038001fbcdb30ab542a1639c0a05 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 13:58:49 +0200 Subject: [PATCH 14/26] fix: shipping zones label --- static/components/stall-details/stall-details.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index ab81ef1..0caf1a3 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -35,16 +35,20 @@ async function stallDetails(path) { this.inkey ) this.stall = data + this.stall.shipping_zones.forEach( + z => + (z.label = z.name + ? `${z.name} (${z.countries.join(', ')})` + : z.countries.join(', ')) + ) console.log('### this.stall', this.stall) } catch (error) { LNbits.utils.notifyApiError(error) } } - }, created: async function () { await this.getStall() - console.log('### this.zoneOptions', this.zoneOptions) } }) } From c1a0e721e6478873f3576fa48c00001a6147bff3 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 14:18:48 +0200 Subject: [PATCH 15/26] feat: update and delete stall --- .../stall-details/stall-details.html | 42 ++++++------ .../components/stall-details/stall-details.js | 67 ++++++++++++++++--- 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index 73a11d2..00b3b29 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -85,25 +85,25 @@
- +
+
+ Update Stall +
+
+ Delete Stall +
+
diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index 0caf1a3..86b09b3 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -27,6 +27,15 @@ async function stallDetails(path) { } }, methods: { + mapStall: function(stall) { + stall.shipping_zones.forEach( + z => + (z.label = z.name + ? `${z.name} (${z.countries.join(', ')})` + : z.countries.join(', ')) + ) + return stall + }, getStall: async function () { try { const {data} = await LNbits.api.request( @@ -34,18 +43,60 @@ async function stallDetails(path) { '/nostrmarket/api/v1/stall/' + this.stallId, this.inkey ) - this.stall = data - this.stall.shipping_zones.forEach( - z => - (z.label = z.name - ? `${z.name} (${z.countries.join(', ')})` - : z.countries.join(', ')) - ) + this.stall = this.mapStall(data) + console.log('### this.stall', this.stall) } catch (error) { LNbits.utils.notifyApiError(error) } - } + }, + updateRelay: async function () { + try { + const {data} = await LNbits.api.request( + 'PUT', + '/nostrmarket/api/v1/stall/' + this.stallId, + this.adminkey, + this.stall + ) + this.stall = this.mapStall(data) + this.$emit('stall-updated', this.stall) + this.$q.notify({ + type: 'positive', + message: 'Stall Updated', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }, + deleteRelay: function () { + LNbits.utils + .confirmDialog( + ` + Products and orders will be deleted also! + Are you sure you want to delete this relay? + ` + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/nostrmarket/api/v1/stall/' + this.stallId, + this.adminkey + ) + this.$emit('stall-deleted', this.stallId) + this.$q.notify({ + type: 'positive', + message: 'Stall Deleted', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }) + }, }, created: async function () { await this.getStall() From 5a526f86f1cfde11fbed8a7910b6e1a1d364b117 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 14:25:23 +0200 Subject: [PATCH 16/26] chore: label updates --- models.py | 2 +- static/components/stall-details/stall-details.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/models.py b/models.py index e52ba6e..7892c93 100644 --- a/models.py +++ b/models.py @@ -104,7 +104,7 @@ class Stall(PartialStall): created_at=round(time.time()), kind=5, tags=[["e", self.config.event_id]], - content="Stall deleted", + content=f"Stall '{self.name}' deleted", ) delete_event.id = delete_event.event_id diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index 86b09b3..7f71cf0 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -75,7 +75,7 @@ async function stallDetails(path) { .confirmDialog( ` Products and orders will be deleted also! - Are you sure you want to delete this relay? + Are you sure you want to delete this stall? ` ) .onOk(async () => { From f22e47e9ce4459fa2a5d3b0207c8a5af94579977 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 14:37:07 +0200 Subject: [PATCH 17/26] feat: handle stall delete and update --- .../components/stall-details/stall-details.html | 4 ++-- static/components/stall-details/stall-details.js | 10 +++++----- static/components/stall-list/stall-list.html | 4 ++-- static/components/stall-list/stall-list.js | 16 +++++++++++++--- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index 00b3b29..e2a3655 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -91,7 +91,7 @@ unelevated color="secondary" class="float-left" - @click="updateRelay()" + @click="updateStall()" >Update Stall
@@ -101,7 +101,7 @@ color="pink" icon="cancel" class="float-right" - @click="deleteRelay()" + @click="deleteStall()" >Delete Stall diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index 7f71cf0..98f9bfb 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -27,7 +27,7 @@ async function stallDetails(path) { } }, methods: { - mapStall: function(stall) { + mapStall: function (stall) { stall.shipping_zones.forEach( z => (z.label = z.name @@ -44,13 +44,13 @@ async function stallDetails(path) { this.inkey ) this.stall = this.mapStall(data) - + console.log('### this.stall', this.stall) } catch (error) { LNbits.utils.notifyApiError(error) } }, - updateRelay: async function () { + updateStall: async function () { try { const {data} = await LNbits.api.request( 'PUT', @@ -70,7 +70,7 @@ async function stallDetails(path) { LNbits.utils.notifyApiError(error) } }, - deleteRelay: function () { + deleteStall: function () { LNbits.utils .confirmDialog( ` @@ -96,7 +96,7 @@ async function stallDetails(path) { LNbits.utils.notifyApiError(error) } }) - }, + } }, created: async function () { await this.getStall() diff --git a/static/components/stall-list/stall-list.html b/static/components/stall-list/stall-list.html index 9ef981a..84e8fd4 100644 --- a/static/components/stall-list/stall-list.html +++ b/static/components/stall-list/stall-list.html @@ -93,9 +93,9 @@ :wallet-options="walletOptions" :zone-options="zoneOptions" :currencies="currencies" + @stall-deleted="handleStallDeleted" + @stall-updated="handleStallUpdated" > - diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js index 1ca67fe..7fb3f8c 100644 --- a/static/components/stall-list/stall-list.js +++ b/static/components/stall-list/stall-list.js @@ -88,6 +88,7 @@ async function stallList(path) { stall ) this.stallDialog.show = false + this.stalls.unshift(data) this.$q.notify({ type: 'positive', message: 'Stall created!' @@ -116,7 +117,6 @@ async function stallList(path) { '/nostrmarket/api/v1/stall', this.inkey ) - console.log('### stalls', data) this.stalls = data.map(s => ({...s, expanded: false})) } catch (error) { LNbits.utils.notifyApiError(error) @@ -129,18 +129,28 @@ async function stallList(path) { '/nostrmarket/api/v1/zone', this.inkey ) - console.log('### zones', data) this.zoneOptions = data.map(z => ({ ...z, label: z.name ? `${z.name} (${z.countries.join(', ')})` : z.countries.join(', ') })) - console.log('### this.zoneOptions', this.zoneOptions) } catch (error) { LNbits.utils.notifyApiError(error) } }, + handleStallDeleted: function (stallId) { + this.stalls = _.reject(this.stalls, function (obj) { + return obj.id === stallId + }) + }, + handleStallUpdated: function (stall) { + const index = this.stalls.findIndex(r => r.id === stall.id) + if (index !== -1) { + stall.expanded = true + this.stalls.splice(index, 1, stall) + } + }, openCreateStallDialog: async function () { await this.getCurrencies() await this.getZones() From 3b021eb5ea464ace1cf0e229421a7a8ce44438f8 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 14:39:25 +0200 Subject: [PATCH 18/26] feat: move update & delete inside info pannel --- .../stall-details/stall-details.html | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index e2a3655..9c8eac6 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -77,6 +77,27 @@
+
+
+ Update Stall +
+
+ Delete Stall +
+
@@ -85,25 +106,5 @@
-
-
- Update Stall -
-
- Delete Stall -
-
+ From 2acce94fe9ff076d0956a9570437780756dc526d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 15:52:40 +0200 Subject: [PATCH 19/26] feat: allow restore from nsec --- .../stall-details/stall-details.html | 1 - static/components/stall-list/stall-list.html | 21 ----------- static/js/index.js | 37 ++++++++++++++++--- templates/nostrmarket/index.html | 28 +++++++++++++- 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index 9c8eac6..9f1b198 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -106,5 +106,4 @@
- diff --git a/static/components/stall-list/stall-list.html b/static/components/stall-list/stall-list.html index 84e8fd4..86ae34e 100644 --- a/static/components/stall-list/stall-list.html +++ b/static/components/stall-list/stall-list.html @@ -21,19 +21,6 @@ - - {{props.row.config.description}} diff --git a/static/js/index.js b/static/js/index.js index 343b5eb..d8de942 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -15,15 +15,41 @@ const merchant = async () => { return { merchant: {}, shippingZones: [], - showKeys: false + showKeys: false, + importKeyDialog: { + show: false, + data: { + privateKey: null + } + } } }, methods: { generateKeys: async function () { - const privkey = nostr.generatePrivateKey() - const pubkey = nostr.getPublicKey(privkey) - - const payload = {private_key: privkey, public_key: pubkey, config: {}} + const privateKey = nostr.generatePrivateKey() + await this.createMerchant(privateKey) + }, + importKeys: async function () { + this.importKeyDialog.show = false + let privateKey = this.importKeyDialog.data.privateKey + if (!privateKey) { + return + } + if (privateKey.toLowerCase().startsWith('nsec')) { + privateKey = nostr.nip19.decode(privateKey) + } + await this.createMerchant(privateKey.data) + }, + showImportKeysDialog: async function () { + this.importKeyDialog.show = true + }, + createMerchant: async function (privateKey) { + const pubkey = nostr.getPublicKey(privateKey) + const payload = { + private_key: privateKey, + public_key: pubkey, + config: {} + } try { const {data} = await LNbits.api.request( 'POST', @@ -54,6 +80,7 @@ const merchant = async () => { } }, created: async function () { + console.log('### nostr', nostr) await this.getMerchant() } }) diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index 41fa040..ad9c94d 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -38,7 +38,7 @@
+
+ + + + +
+ Import + Cancel +
+
+
+
+
{% endblock%}{% block scripts %} {{ window_vars(user) }} From 5ad070684d540dd74dafffd02d71acb7d355b7c9 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 16:00:40 +0200 Subject: [PATCH 20/26] feat: import private key --- static/js/index.js | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index d8de942..5b26d4c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -35,22 +35,29 @@ const merchant = async () => { if (!privateKey) { return } - if (privateKey.toLowerCase().startsWith('nsec')) { - privateKey = nostr.nip19.decode(privateKey) + try { + if (privateKey.toLowerCase().startsWith('nsec')) { + privateKey = nostr.nip19.decode(privateKey).data + } + } catch (error) { + this.$q.notify({ + type: 'negative', + message: `${error}` + }) } - await this.createMerchant(privateKey.data) + await this.createMerchant(privateKey) }, showImportKeysDialog: async function () { this.importKeyDialog.show = true }, createMerchant: async function (privateKey) { - const pubkey = nostr.getPublicKey(privateKey) - const payload = { - private_key: privateKey, - public_key: pubkey, - config: {} - } try { + const pubkey = nostr.getPublicKey(privateKey) + const payload = { + private_key: privateKey, + public_key: pubkey, + config: {} + } const {data} = await LNbits.api.request( 'POST', '/nostrmarket/api/v1/merchant', @@ -60,10 +67,13 @@ const merchant = async () => { this.merchant = data this.$q.notify({ type: 'positive', - message: 'Keys generated!' + message: 'Merchant Created!' }) } catch (error) { - LNbits.utils.notifyApiError(error) + this.$q.notify({ + type: 'negative', + message: `${error}` + }) } }, getMerchant: async function () { @@ -80,7 +90,6 @@ const merchant = async () => { } }, created: async function () { - console.log('### nostr', nostr) await this.getMerchant() } }) From 5328ce170ca6a0cc33bf020fbeef3ed24028c406 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 17:42:28 +0200 Subject: [PATCH 21/26] fat: add create product logic --- crud.py | 53 ++++++- migrations.py | 5 +- models.py | 42 +++++ .../shipping-zones/shipping-zones.html | 2 +- .../stall-details/stall-details.html | 148 +++++++++++++++++- .../components/stall-details/stall-details.js | 110 ++++++++++++- static/components/stall-list/stall-list.js | 14 +- static/js/utils.js | 9 ++ templates/nostrmarket/index.html | 1 + views_api.py | 79 +++++++++- 10 files changed, 446 insertions(+), 17 deletions(-) diff --git a/crud.py b/crud.py index 0f52c67..ce190bf 100644 --- a/crud.py +++ b/crud.py @@ -5,7 +5,16 @@ from typing import List, Optional from lnbits.helpers import urlsafe_short_hash from . import db -from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone +from .models import ( + Merchant, + PartialMerchant, + PartialProduct, + PartialStall, + PartialZone, + Product, + Stall, + Zone, +) ######################################## MERCHANT ######################################## @@ -177,3 +186,45 @@ async def delete_stall(user_id: str, stall_id: str) -> None: stall_id, ), ) + + +######################################## STALL ######################################## + + +async def create_product(user_id: str, data: PartialProduct) -> Product: + product_id = urlsafe_short_hash() + + await db.execute( + f""" + INSERT INTO nostrmarket.products (user_id, id, stall_id, name, category_list, description, images, price, quantity) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + product_id, + data.stall_id, + data.name, + json.dumps(data.categories), + data.description, + data.image, + data.price, + data.quantity, + ), + ) + product = await get_product(user_id, product_id) + assert product, "Newly created product couldn't be retrieved" + + return product + + +async def get_product(user_id: str, product_id: str) -> Optional[Product]: + row = await db.fetchone( + "SELECT * FROM nostrmarket.products WHERE user_id =? AND id = ?", + ( + user_id, + product_id, + ), + ) + product = Product.from_row(row) if row else None + + return product diff --git a/migrations.py b/migrations.py index 8af5726..2e0a158 100644 --- a/migrations.py +++ b/migrations.py @@ -39,12 +39,13 @@ async def m001_initial(db): 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, - categories TEXT, + category_list TEXT DEFAULT '[]', description TEXT, - images TEXT NOT NULL DEFAULT '[]', + images TEXT DEFAULT '[]', price REAL NOT NULL, quantity INTEGER NOT NULL ); diff --git a/models.py b/models.py index 7892c93..b2bab4d 100644 --- a/models.py +++ b/models.py @@ -116,3 +116,45 @@ class Stall(PartialStall): stall.config = StallConfig(**json.loads(row["meta"])) stall.shipping_zones = [Zone(**z) for z in json.loads(row["zones"])] return stall + + +######################################## STALLS ######################################## + + +class PartialProduct(BaseModel): + stall_id: str + name: str + categories: List[str] = [] + description: Optional[str] + image: Optional[str] + price: float + quantity: int + + def validate_product(self): + if self.image: + image_is_url = self.image.startswith("https://") or self.image.startswith( + "http://" + ) + + if not image_is_url: + + def size(b64string): + return int((len(b64string) * 3) / 4 - b64string.count("=", -2)) + + image_size = size(self.image) / 1024 + if image_size > 100: + raise ValueError( + f""" + Image size is too big, {int(image_size)}Kb. + Max: 100kb, Compress the image at https://tinypng.com, or use an URL.""" + ) + + +class Product(PartialProduct): + id: str + + @classmethod + def from_row(cls, row: Row) -> "Product": + product = cls(**dict(row)) + product.categories = json.loads(row["category_list"]) + return product diff --git a/static/components/shipping-zones/shipping-zones.html b/static/components/shipping-zones/shipping-zones.html index 6984c6c..cedaca7 100644 --- a/static/components/shipping-zones/shipping-zones.html +++ b/static/components/shipping-zones/shipping-zones.html @@ -59,7 +59,7 @@ - -
+ +
+
+
+ New Product +
+
+
+
+
- +
+ + + + + + + + + + + + + + + + + + + + +
+ Update Product + + Create Product + + Cancel +
+
+
+
diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index 98f9bfb..dafd42a 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -1,6 +1,8 @@ async function stallDetails(path) { const template = await loadTemplateAsync(path) + const pica = window.pica() + Vue.component('stall-details', { name: 'stall-details', template, @@ -16,8 +18,21 @@ async function stallDetails(path) { data: function () { return { tab: 'info', - stall: null - // currencies: [], + stall: null, + products: [], + productDialog: { + showDialog: false, + url: true, + data: { + id: null, + name: '', + description: '', + categories: [], + image: null, + price: 0, + quantity: 0 + } + } } }, computed: { @@ -96,6 +111,97 @@ async function stallDetails(path) { LNbits.utils.notifyApiError(error) } }) + }, + imageAdded(file) { + const image = new Image() + image.src = URL.createObjectURL(file) + image.onload = async () => { + let fit = imgSizeFit(image) + let canvas = document.createElement('canvas') + canvas.setAttribute('width', fit.width) + canvas.setAttribute('height', fit.height) + output = await pica.resize(image, canvas) + this.productDialog.data.image = output.toDataURL('image/jpeg', 0.4) + this.productDialog = {...this.productDialog} + } + }, + imageCleared() { + this.productDialog.data.image = null + this.productDialog = {...this.productDialog} + }, + sendProductFormData: function () { + var data = { + stall_id: this.stall.id, + name: this.productDialog.data.name, + description: this.productDialog.data.description, + categories: this.productDialog.data.categories, + + image: this.productDialog.data.image, + price: this.productDialog.data.price, + quantity: this.productDialog.data.quantity + } + this.productDialog.showDialog = false + if (this.productDialog.data.id) { + this.updateProduct(data) + } else { + this.createProduct(data) + } + }, + updateProduct: function (data) { + var self = this + let wallet = _.findWhere(this.stalls, { + id: self.productDialog.data.stall + }).wallet + LNbits.api + .request( + 'PUT', + '/nostrmarket/api/v1/products/' + data.id, + _.findWhere(self.g.user.wallets, { + id: wallet + }).inkey, + data + ) + .then(async function (response) { + self.products = _.reject(self.products, function (obj) { + return obj.id == data.id + }) + let productData = mapProducts(response.data) + self.products.push(productData) + //SEND Nostr data + try { + await self.sendToRelays(productData, 'product', 'update') + } catch (e) { + console.error(e) + } + self.resetDialog('productDialog') + //self.productDialog.show = false + //self.productDialog.data = {} + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + createProduct: async function (payload) { + try { + const {data} = await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/product', + this.adminkey, + payload + ) + this.products.unshift(data) + this.$q.notify({ + type: 'positive', + message: 'Product Created', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }, + showNewProductDialog: async function () { + this.productDialog.showDialog = true } }, created: async function () { diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js index 7fb3f8c..5fd8ffd 100644 --- a/static/components/stall-list/stall-list.js +++ b/static/components/stall-list/stall-list.js @@ -35,12 +35,6 @@ async function stallList(path) { label: 'Name', field: 'id' }, - // { - // name: 'toggle', - // align: 'left', - // label: 'Active', - // field: '' - // }, { name: 'description', align: 'left', @@ -88,6 +82,7 @@ async function stallList(path) { stall ) this.stallDialog.show = false + data.expanded = false this.stalls.unshift(data) this.$q.notify({ type: 'positive', @@ -154,6 +149,13 @@ async function stallList(path) { openCreateStallDialog: async function () { await this.getCurrencies() await this.getZones() + if (!this.zoneOptions || !this.zoneOptions.length) { + this.$q.notify({ + type: 'warning', + message: 'Please create a Shipping Zone first!' + }) + return + } this.stallDialog.data = { name: '', description: '', diff --git a/static/js/utils.js b/static/js/utils.js index 11ebc81..83e886b 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -16,3 +16,12 @@ function loadTemplateAsync(path) { return result } + +function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) { + let ratio = Math.min( + 1, + maxWidth / img.naturalWidth, + maxHeight / img.naturalHeight + ) + return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio} +} diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index ad9c94d..fa07f17 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -143,6 +143,7 @@ {% endblock%}{% block scripts %} {{ window_vars(user) }} + diff --git a/views_api.py b/views_api.py index de83d66..472e17a 100644 --- a/views_api.py +++ b/views_api.py @@ -17,6 +17,7 @@ from lnbits.utils.exchange_rates import currencies from . import nostrmarket_ext from .crud import ( create_merchant, + create_product, create_stall, create_zone, delete_stall, @@ -29,7 +30,16 @@ from .crud import ( update_stall, update_zone, ) -from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone +from .models import ( + Merchant, + PartialMerchant, + PartialProduct, + PartialStall, + PartialZone, + Product, + Stall, + Zone, +) from .nostr.nostr_client import publish_nostr_event ######################################## MERCHANT ######################################## @@ -170,6 +180,11 @@ async def api_create_stall( await update_stall(wallet.wallet.user, stall) return stall + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) except Exception as ex: logger.warning(ex) raise HTTPException( @@ -202,6 +217,11 @@ async def api_update_stall( return stall except HTTPException as ex: raise ex + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) except Exception as ex: logger.warning(ex) raise HTTPException( @@ -271,10 +291,65 @@ async def api_delete_stall( logger.warning(ex) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot delte stall", + detail="Cannot delete stall", ) +######################################## PRODUCTS ######################################## + + +@nostrmarket_ext.post("/api/v1/product") +async def api_market_product_create( + data: PartialProduct, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> Product: + try: + data.validate_product() + product = await create_product(wallet.wallet.user, data=data) + + return product + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create product", + ) + + +# @nostrmarket_ext.get("/api/v1/product/{stall_id}") +# async def api_market_products( +# stall_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key), +# ): +# wallet_ids = [wallet.wallet.id] + + +# return [product.dict() for product in await get_products(stalls)] + + +# @market_ext.delete("/api/v1/products/{product_id}") +# async def api_market_products_delete( +# product_id, wallet: WalletTypeInfo = Depends(require_admin_key) +# ): +# product = await get_market_product(product_id) + +# if not product: +# return {"message": "Product does not exist."} + +# stall = await get_market_stall(product.stall) +# assert stall + +# if stall.wallet != wallet.wallet.id: +# return {"message": "Not your Market."} + +# await delete_market_product(product_id) +# raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + ######################################## OTHER ######################################## From e17cea65cbd4dee6fb3e56512127b3f915a4337b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 17:42:50 +0200 Subject: [PATCH 22/26] chore: code clean-up --- static/components/stall-details/stall-details.html | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index e0d9b3b..73615c9 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -137,19 +137,6 @@ v-model.trim="productDialog.data.description" label="Description" >
- - - Date: Thu, 2 Mar 2023 18:05:49 +0200 Subject: [PATCH 23/26] feat: add products table --- crud.py | 8 +++ .../stall-details/stall-details.html | 53 ++++++++++++++ .../components/stall-details/stall-details.js | 72 +++++++++++++++++++ views_api.py | 27 ++++--- 4 files changed, 150 insertions(+), 10 deletions(-) diff --git a/crud.py b/crud.py index ce190bf..d19a958 100644 --- a/crud.py +++ b/crud.py @@ -228,3 +228,11 @@ async def get_product(user_id: str, product_id: str) -> Optional[Product]: product = Product.from_row(row) if row else None return product + + +async def get_products(user_id: str, stall_id: str) -> List[Product]: + rows = await db.fetchall( + "SELECT * FROM nostrmarket.products WHERE user_id = ? AND stall_id = ?", + (user_id, stall_id), + ) + return [Product.from_row(row) for row in rows] diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index 73615c9..a4260b2 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -115,6 +115,59 @@
+ +
+
+ + + +
+
diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index dafd42a..9fead9e 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -32,6 +32,63 @@ async function stallDetails(path) { price: 0, quantity: 0 } + }, + productsFilter: '', + productsTable: { + columns: [ + { + name: 'delete', + align: 'left', + label: '', + field: '' + }, + { + name: 'edit', + align: 'left', + label: '', + field: '' + }, + + { + name: 'id', + align: 'left', + label: 'ID', + field: 'id' + }, + { + name: 'name', + align: 'left', + label: 'Name', + field: 'name' + }, + { + name: 'price', + align: 'left', + label: 'Price', + field: 'price' + }, + { + name: 'quantity', + align: 'left', + label: 'Quantity', + field: 'quantity' + }, + { + name: 'categories', + align: 'left', + label: 'Categories', + field: 'categories' + }, + { + name: 'description', + align: 'left', + label: 'Description', + field: 'description' + } + ], + pagination: { + rowsPerPage: 10 + } } } }, @@ -129,6 +186,20 @@ async function stallDetails(path) { this.productDialog.data.image = null this.productDialog = {...this.productDialog} }, + getProducts: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/product/' + this.stall.id, + this.inkey + ) + this.products = data + + console.log('### this.products', this.products) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, sendProductFormData: function () { var data = { stall_id: this.stall.id, @@ -206,6 +277,7 @@ async function stallDetails(path) { }, created: async function () { await this.getStall() + await this.getProducts() } }) } diff --git a/views_api.py b/views_api.py index 472e17a..76f4f11 100644 --- a/views_api.py +++ b/views_api.py @@ -23,6 +23,7 @@ from .crud import ( delete_stall, delete_zone, get_merchant_for_user, + get_products, get_stall, get_stalls, get_zone, @@ -299,9 +300,9 @@ async def api_delete_stall( @nostrmarket_ext.post("/api/v1/product") -async def api_market_product_create( +async def api_create_product( data: PartialProduct, - wallet: WalletTypeInfo = Depends(require_invoice_key), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Product: try: data.validate_product() @@ -321,14 +322,20 @@ async def api_market_product_create( ) -# @nostrmarket_ext.get("/api/v1/product/{stall_id}") -# async def api_market_products( -# stall_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key), -# ): -# wallet_ids = [wallet.wallet.id] - - -# return [product.dict() for product in await get_products(stalls)] +@nostrmarket_ext.get("/api/v1/product/{stall_id}") +async def api_get_product( + stall_id: str, + wallet: WalletTypeInfo = Depends(require_invoice_key), +): + try: + products = await get_products(wallet.wallet.user, stall_id) + return products + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot get product", + ) # @market_ext.delete("/api/v1/products/{product_id}") From 1e6aaf84368e9744df6e77765b3e19a6f52539c1 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 18:15:17 +0200 Subject: [PATCH 24/26] feat: delete product --- crud.py | 11 +++++++ .../stall-details/stall-details.html | 2 +- .../components/stall-details/stall-details.js | 26 +++++++++++++++- views_api.py | 31 +++++++++---------- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/crud.py b/crud.py index d19a958..a89455e 100644 --- a/crud.py +++ b/crud.py @@ -106,6 +106,7 @@ async def get_zones(user_id: str) -> List[Zone]: async def delete_zone(zone_id: str) -> None: + # todo: add user_id await db.execute("DELETE FROM nostrmarket.zones WHERE id = ?", (zone_id,)) @@ -236,3 +237,13 @@ async def get_products(user_id: str, stall_id: str) -> List[Product]: (user_id, stall_id), ) return [Product.from_row(row) for row in rows] + + +async def delete_product(user_id: str, product_id: str) -> None: + await db.execute( + "DELETE FROM nostrmarket.products WHERE user_id =? AND id = ?", + ( + user_id, + product_id, + ), + ) diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index a4260b2..b2c736a 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -134,7 +134,7 @@ size="sm" color="pink" dense - @click="props.row.expanded= !props.row.expanded" + @click="deleteProduct(props.row.id)" icon="delete" />
diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index 9fead9e..ac86980 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -17,7 +17,7 @@ async function stallDetails(path) { ], data: function () { return { - tab: 'info', + tab: 'products', stall: null, products: [], productDialog: { @@ -271,6 +271,30 @@ async function stallDetails(path) { LNbits.utils.notifyApiError(error) } }, + deleteProduct: async function (productId) { + LNbits.utils + .confirmDialog('Are you sure you want to delete this product?') + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/nostrmarket/api/v1/product/' + productId, + this.adminkey + ) + this.products = _.reject(this.products, function (obj) { + return obj.id === productId + }) + this.$q.notify({ + type: 'positive', + message: 'Product deleted', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }) + }, showNewProductDialog: async function () { this.productDialog.showDialog = true } diff --git a/views_api.py b/views_api.py index 76f4f11..9260c9a 100644 --- a/views_api.py +++ b/views_api.py @@ -20,6 +20,7 @@ from .crud import ( create_product, create_stall, create_zone, + delete_product, delete_stall, delete_zone, get_merchant_for_user, @@ -338,23 +339,19 @@ async def api_get_product( ) -# @market_ext.delete("/api/v1/products/{product_id}") -# async def api_market_products_delete( -# product_id, wallet: WalletTypeInfo = Depends(require_admin_key) -# ): -# product = await get_market_product(product_id) - -# if not product: -# return {"message": "Product does not exist."} - -# stall = await get_market_stall(product.stall) -# assert stall - -# if stall.wallet != wallet.wallet.id: -# return {"message": "Not your Market."} - -# await delete_market_product(product_id) -# raise HTTPException(status_code=HTTPStatus.NO_CONTENT) +@nostrmarket_ext.delete("/api/v1/product/{product_id}") +async def api_delete_product( + product_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + try: + await delete_product(wallet.wallet.user, product_id) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot delete product", + ) ######################################## OTHER ######################################## From 62e7d439c7489939f20bb8680a5f3004f168c282 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 18:38:06 +0200 Subject: [PATCH 25/26] feat: update product --- crud.py | 28 +++++++- .../stall-details/stall-details.html | 2 +- .../components/stall-details/stall-details.js | 64 ++++++++++--------- views_api.py | 25 ++++++++ 4 files changed, 84 insertions(+), 35 deletions(-) diff --git a/crud.py b/crud.py index a89455e..996bfc3 100644 --- a/crud.py +++ b/crud.py @@ -218,6 +218,30 @@ async def create_product(user_id: str, data: PartialProduct) -> Product: return product +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 = ? + WHERE user_id = ? AND id = ? + """, + ( + product.name, + json.dumps(product.categories), + product.description, + product.image, + product.price, + product.quantity, + user_id, + product.id, + ), + ) + updated_product = await get_product(user_id, product.id) + assert updated_product, "Updated product couldn't be retrieved" + + return updated_product + + async def get_product(user_id: str, product_id: str) -> Optional[Product]: row = await db.fetchone( "SELECT * FROM nostrmarket.products WHERE user_id =? AND id = ?", @@ -226,9 +250,7 @@ async def get_product(user_id: str, product_id: str) -> Optional[Product]: product_id, ), ) - product = Product.from_row(row) if row else None - - return product + return Product.from_row(row) if row else None async def get_products(user_id: str, stall_id: str) -> List[Product]: diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html index b2c736a..82610ca 100644 --- a/static/components/stall-details/stall-details.html +++ b/static/components/stall-details/stall-details.html @@ -143,7 +143,7 @@ size="sm" color="accent" dense - @click="props.row.expanded= !props.row.expanded" + @click="editProduct(props.row)" icon="edit" /> diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index ac86980..e62caaf 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -203,6 +203,7 @@ async function stallDetails(path) { sendProductFormData: function () { var data = { stall_id: this.stall.id, + id: this.productDialog.data.id, name: this.productDialog.data.name, description: this.productDialog.data.description, categories: this.productDialog.data.categories, @@ -218,39 +219,27 @@ async function stallDetails(path) { this.createProduct(data) } }, - updateProduct: function (data) { - var self = this - let wallet = _.findWhere(this.stalls, { - id: self.productDialog.data.stall - }).wallet - LNbits.api - .request( - 'PUT', - '/nostrmarket/api/v1/products/' + data.id, - _.findWhere(self.g.user.wallets, { - id: wallet - }).inkey, - data + updateProduct: async function (product) { + try { + const {data} = await LNbits.api.request( + 'PATCH', + '/nostrmarket/api/v1/product/' + product.id, + this.adminkey, + product ) - .then(async function (response) { - self.products = _.reject(self.products, function (obj) { - return obj.id == data.id - }) - let productData = mapProducts(response.data) - self.products.push(productData) - //SEND Nostr data - try { - await self.sendToRelays(productData, 'product', 'update') - } catch (e) { - console.error(e) - } - self.resetDialog('productDialog') - //self.productDialog.show = false - //self.productDialog.data = {} - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) + const index = this.products.findIndex(r => r.id === product.id) + if (index !== -1) { + this.products.splice(index, 1, data) + } + this.$q.notify({ + type: 'positive', + message: 'Product Updated', + timeout: 5000 }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } }, createProduct: async function (payload) { try { @@ -271,6 +260,10 @@ async function stallDetails(path) { LNbits.utils.notifyApiError(error) } }, + editProduct: async function (product) { + this.productDialog.data = {...product} + this.productDialog.showDialog = true + }, deleteProduct: async function (productId) { LNbits.utils .confirmDialog('Are you sure you want to delete this product?') @@ -296,6 +289,15 @@ async function stallDetails(path) { }) }, showNewProductDialog: async function () { + this.productDialog.data = { + id: null, + name: '', + description: '', + categories: [], + image: null, + price: 0, + quantity: 0 + } this.productDialog.showDialog = true } }, diff --git a/views_api.py b/views_api.py index 9260c9a..3de3f97 100644 --- a/views_api.py +++ b/views_api.py @@ -29,6 +29,7 @@ from .crud import ( get_stalls, get_zone, get_zones, + update_product, update_stall, update_zone, ) @@ -323,6 +324,30 @@ async def api_create_product( ) +@nostrmarket_ext.patch("/api/v1/product/{product_id}") +async def api_update_product( + product_id: str, + product: Product, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Product: + try: + product.validate_product() + product = await update_product(wallet.wallet.user, product) + + return product + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot update product", + ) + + @nostrmarket_ext.get("/api/v1/product/{stall_id}") async def api_get_product( stall_id: str, From 8ce68f38f6825ad6852acb6f8e8cad2f0311f00d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 18:45:27 +0200 Subject: [PATCH 26/26] feat: add `to_nostr_event` for product --- models.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/models.py b/models.py index b2bab4d..412bbfc 100644 --- a/models.py +++ b/models.py @@ -153,6 +153,28 @@ class PartialProduct(BaseModel): class Product(PartialProduct): id: str + def to_nostr_event(self, pubkey: str) -> NostrEvent: + content = { + "stall_id": self.stall_id, + "name": self.name, + "description": self.description, + "image": self.image, + "price": self.price, + "quantity": self.quantity, + } + categories = [["t", tag] for tag in self.categories] + + event = NostrEvent( + pubkey=pubkey, + created_at=round(time.time()), + kind=30005, + tags=[["d", self.id]] + categories, + content=json.dumps(content, separators=(",", ":"), ensure_ascii=False), + ) + event.id = event.event_id + + return event + @classmethod def from_row(cls, row: Row) -> "Product": product = cls(**dict(row))