Merge pull request #4 from lnbits/add_stalls

Add stalls
This commit is contained in:
Vlad Stan 2023-03-02 18:46:28 +02:00 committed by GitHub
commit f13d5ec7ff
15 changed files with 1739 additions and 39 deletions

184
crud.py
View file

@ -1,10 +1,20 @@
import json import json
import time
from typing import List, Optional from typing import List, Optional
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from . import db from . import db
from .models import Merchant, PartialMerchant, PartialZone, Zone from .models import (
Merchant,
PartialMerchant,
PartialProduct,
PartialStall,
PartialZone,
Product,
Stall,
Zone,
)
######################################## MERCHANT ######################################## ######################################## MERCHANT ########################################
@ -51,15 +61,7 @@ async def create_zone(user_id: str, data: PartialZone) -> Zone:
zone_id = urlsafe_short_hash() zone_id = urlsafe_short_hash()
await db.execute( await db.execute(
f""" f"""
INSERT INTO nostrmarket.zones ( INSERT INTO nostrmarket.zones (id, user_id, name, currency, cost, regions)
id,
user_id,
name,
currency,
cost,
regions
)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""", """,
( (
@ -104,4 +106,166 @@ async def get_zones(user_id: str) -> List[Zone]:
async def delete_zone(zone_id: str) -> None: async def delete_zone(zone_id: str) -> None:
# todo: add user_id
await db.execute("DELETE FROM nostrmarket.zones WHERE id = ?", (zone_id,)) 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(
[z.dict() for z in data.shipping_zones]
), # todo: cost is float. should be int for sats
json.dumps(data.config.dict()),
),
)
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.fetchall(
"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: Stall) -> Optional[Stall]:
await db.execute(
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,
),
)
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,
),
)
######################################## 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 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 = ?",
(
user_id,
product_id,
),
)
return Product.from_row(row) if row else None
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]
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,
),
)

81
helpers.py Normal file
View file

@ -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

View file

@ -18,15 +18,17 @@ async def m001_initial(db):
""" """
Initial stalls table. Initial stalls table.
""" """
# user_id, id, wallet, name, currency, zones, meta
await db.execute( await db.execute(
""" """
CREATE TABLE nostrmarket.stalls ( CREATE TABLE nostrmarket.stalls (
user_id TEXT NOT NULL,
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
currency TEXT, currency TEXT,
shipping_zones TEXT NOT NULL, zones TEXT NOT NULL DEFAULT '[]',
rating REAL DEFAULT 0 meta TEXT NOT NULL DEFAULT '{}'
); );
""" """
) )
@ -37,15 +39,15 @@ async def m001_initial(db):
await db.execute( await db.execute(
f""" f"""
CREATE TABLE nostrmarket.products ( CREATE TABLE nostrmarket.products (
user_id TEXT NOT NULL,
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
stall_id TEXT NOT NULL, stall_id TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
categories TEXT, category_list TEXT DEFAULT '[]',
description TEXT, description TEXT,
image TEXT, images TEXT DEFAULT '[]',
price REAL NOT NULL, price REAL NOT NULL,
quantity INTEGER NOT NULL, quantity INTEGER NOT NULL
rating REAL DEFAULT 0
); );
""" """
) )

139
models.py
View file

@ -1,10 +1,13 @@
import json import json
import time
from sqlite3 import Row from sqlite3 import Row
from typing import List, Optional from typing import List, Optional
from fastapi import Query
from pydantic import BaseModel from pydantic import BaseModel
from .helpers import sign_message_hash
from .nostr.event import NostrEvent
######################################## MERCHANT ######################################## ######################################## MERCHANT ########################################
class MerchantConfig(BaseModel): class MerchantConfig(BaseModel):
@ -20,6 +23,9 @@ class PartialMerchant(BaseModel):
class Merchant(PartialMerchant): class Merchant(PartialMerchant):
id: str id: str
def sign_hash(self, hash: bytes) -> str:
return sign_message_hash(self.private_key, hash)
@classmethod @classmethod
def from_row(cls, row: Row) -> "Merchant": def from_row(cls, row: Row) -> "Merchant":
merchant = cls(**dict(row)) merchant = cls(**dict(row))
@ -43,3 +49,134 @@ class Zone(PartialZone):
zone = cls(**dict(row)) zone = cls(**dict(row))
zone.countries = json.loads(row["regions"]) zone.countries = json.loads(row["regions"])
return zone return zone
######################################## STALLS ########################################
class StallConfig(BaseModel):
"""Last published nostr event id for this Stall"""
event_id: Optional[str]
image_url: Optional[str]
description: Optional[str]
class PartialStall(BaseModel):
wallet: str
name: str
currency: str = "sat"
shipping_zones: List[Zone] = []
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
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=f"Stall '{self.name}' 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 = [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
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))
product.categories = json.loads(row["category_list"])
return product

57
nostr/event.py Normal file
View file

@ -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)

21
nostr/nostr_client.py Normal file
View file

@ -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)

View file

@ -4,7 +4,6 @@
unelevated unelevated
color="primary" color="primary"
icon="public" icon="public"
label="Shipping Zones"
@click="openZoneDialog()" @click="openZoneDialog()"
> >
<q-list> <q-list>
@ -48,8 +47,8 @@
v-model="zoneDialog.data.countries" v-model="zoneDialog.data.countries"
></q-select> ></q-select>
<q-select <q-select
v-if="!zoneDialog.data.id" :disabled="!!zoneDialog.data.id"
style="width: 100px" :readonly="!!zoneDialog.data.id"
filled filled
dense dense
v-model="zoneDialog.data.currency" v-model="zoneDialog.data.currency"
@ -60,7 +59,7 @@
<q-input <q-input
filled filled
dense dense
:label="'Amount (' + zoneDialog.data.currency + ') *'" :label="'Cost of Shipping (' + zoneDialog.data.currency + ') *'"
fill-mask="0" fill-mask="0"
reverse-fill-mask reverse-fill-mask
:step="zoneDialog.data.currency != 'sat' ? '0.01' : '1'" :step="zoneDialog.data.currency != 'sat' ? '0.01' : '1'"

View file

@ -0,0 +1,291 @@
<div>
<q-tabs v-model="tab" no-caps class="bg-dark text-white shadow-2">
<q-tab name="info" label="Stall Info"></q-tab>
<q-tab name="products" label="Products"></q-tab>
<q-tab name="orders" label="Orders"></q-tab>
</q-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="info">
<div v-if="stall">
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Name:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
v-model.trim="stall.name"
type="text"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Description:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
v-model.trim="stall.config.description"
type="textarea"
rows="3"
label="Description"
></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Wallet:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
filled
dense
emit-value
v-model="stall.wallet"
:options="walletOptions"
label="Wallet *"
>
</q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Currency:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
filled
dense
v-model="stall.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Shipping Zones:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
:options="filteredZoneOptions"
filled
dense
multiple
v-model.trim="stall.shipping_zones"
label="Shipping Zones"
></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
</div>
<div class="row items-center q-mt-xl">
<div class="col-6 q-pr-lg">
<q-btn
unelevated
color="secondary"
class="float-left"
@click="updateStall()"
>Update Stall</q-btn
>
</div>
<div class="col-6">
<q-btn
unelevated
color="pink"
icon="cancel"
class="float-right"
@click="deleteStall()"
>Delete Stall</q-btn
>
</div>
</div>
</q-tab-panel>
<q-tab-panel name="products">
<div v-if="stall">
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">
<q-btn
unelevated
color="green"
icon="plus"
class="float-left"
@click="showNewProductDialog()"
>New Product</q-btn
>
</div>
<div class="col-6 col-sm-8 q-pr-lg"></div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-12">
<q-table
flat
dense
:data="products"
row-key="id"
:columns="productsTable.columns"
:pagination.sync="productsTable.pagination"
:filter="productsFilter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="pink"
dense
@click="deleteProduct(props.row.id)"
icon="delete"
/>
</q-td>
<q-td auto-width>
<q-btn
size="sm"
color="accent"
dense
@click="editProduct(props.row)"
icon="edit"
/>
</q-td>
<q-td key="id" :props="props"> {{props.row.id}} </q-td>
<q-td key="name" :props="props"> {{props.row.name}} </q-td>
<q-td key="price" :props="props"> {{props.row.price}} </q-td>
<q-td key="quantity" :props="props">
{{props.row.quantity}}
</q-td>
<q-td key="categories" :props="props">
<div>
{{props.row.categories.filter(c => c).join(', ')}}
</div>
</q-td>
<q-td key="description" :props="props">
{{props.row.description}}
</q-td>
</q-tr>
</template>
</q-table>
</div>
</div>
</div>
</q-tab-panel>
<q-tab-panel name="orders">
<div v-if="stall"></div>
</q-tab-panel>
</q-tab-panels>
<q-dialog v-model="productDialog.showDialog" position="top">
<q-card v-if="stall" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendProductFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="productDialog.data.name"
label="Name"
></q-input>
<q-input
filled
dense
v-model.trim="productDialog.data.description"
label="Description"
></q-input>
<q-select
filled
multiple
dense
emit-value
v-model.trim="productDialog.data.categories"
use-input
use-chips
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add-unique"
label="Categories (Hit Enter to add)"
placeholder="crafts,robots,etc"
></q-select>
<q-toggle
:label="`${productDialog.url ? 'Insert image URL' : 'Upload image file'}`"
v-model="productDialog.url"
></q-toggle>
<q-input
v-if="productDialog.url"
filled
dense
v-model.trim="productDialog.data.image"
type="url"
label="Image URL"
></q-input>
<q-file
v-else
class="q-pr-md"
filled
dense
capture="environment"
accept="image/jpeg, image/png"
:max-file-size="3*1024**2"
label="Small image (optional)"
clearable
@input="imageAdded"
@clear="imageCleared"
>
<template v-if="productDialog.data.image" v-slot:before>
<img style="height: 1em" :src="productDialog.data.image" />
</template>
<template v-if="productDialog.data.image" v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="imageCleared"
class="cursor-pointer"
/>
</template>
</q-file>
<q-input
filled
dense
v-model.number="productDialog.data.price"
type="number"
:label="'Price (' + stall.currency + ') *'"
:step="stall.currency != 'sat' ? '0.01' : '1'"
:mask="stall.currency != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
></q-input>
<q-input
filled
dense
v-model.number="productDialog.data.quantity"
type="number"
label="Quantity"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="productDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Product</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="!productDialog.data.price
|| !productDialog.data.name
|| !productDialog.data.quantity"
type="submit"
>Create Product</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>

View file

@ -1,15 +1,309 @@
async function stallDetails(path) { async function stallDetails(path) {
const template = await loadTemplateAsync(path) const template = await loadTemplateAsync(path)
const pica = window.pica()
Vue.component('stall-details', { Vue.component('stall-details', {
name: 'stall-details', name: 'stall-details',
template, template,
//props: ['stall-id', 'adminkey', 'inkey', 'wallet-options'], props: [
'stall-id',
'adminkey',
'inkey',
'wallet-options',
'zone-options',
'currencies'
],
data: function () { data: function () {
return { return {
tab: 'info', tab: 'products',
relay: null stall: null,
products: [],
productDialog: {
showDialog: false,
url: true,
data: {
id: null,
name: '',
description: '',
categories: [],
image: null,
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
}
}
}
},
computed: {
filteredZoneOptions: function () {
if (!this.stall) return []
return this.zoneOptions.filter(z => z.currency === this.stall.currency)
}
},
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(
'GET',
'/nostrmarket/api/v1/stall/' + this.stallId,
this.inkey
)
this.stall = this.mapStall(data)
console.log('### this.stall', this.stall)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateStall: 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)
}
},
deleteStall: function () {
LNbits.utils
.confirmDialog(
`
Products and orders will be deleted also!
Are you sure you want to delete this stall?
`
)
.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)
}
})
},
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}
},
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,
id: this.productDialog.data.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: async function (product) {
try {
const {data} = await LNbits.api.request(
'PATCH',
'/nostrmarket/api/v1/product/' + product.id,
this.adminkey,
product
)
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 {
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)
}
},
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?')
.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.data = {
id: null,
name: '',
description: '',
categories: [],
image: null,
price: 0,
quantity: 0
}
this.productDialog.showDialog = true
}
},
created: async function () {
await this.getStall()
await this.getProducts()
} }
}) })
} }

View file

@ -0,0 +1,149 @@
<div>
<div class="row items-center no-wrap q-mb-md">
<div class="col q-pr-lg">
<q-btn
@click="openCreateStallDialog"
unelevated
color="green"
class="float-left"
>New Stall</q-btn
>
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
class="float-right"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table
flat
dense
:data="stalls"
row-key="id"
:columns="stallsTable.columns"
:pagination.sync="stallsTable.pagination"
:filter="filter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td>
<q-td key="id" :props="props">
<a style="color: unset" :href="props.row.id" target="_blank">
{{props.row.name}}</a
>
</q-td>
<q-td key="description" :props="props">
{{props.row.config.description}}
</q-td>
<q-td key="shippingZones" :props="props">
<div>
{{props.row.shipping_zones.filter(z => !!z.name).map(z =>
z.name).join(', ')}}
</div>
</q-td>
</q-tr>
<q-tr v-if="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center q-mb-lg">
<div class="col-12">
<stall-details
:stall-id="props.row.id"
:adminkey="adminkey"
:inkey="inkey"
:wallet-options="walletOptions"
:zone-options="zoneOptions"
:currencies="currencies"
@stall-deleted="handleStallDeleted"
@stall-updated="handleStallUpdated"
></stall-details>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
<div>
<q-dialog v-model="stallDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendStallFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="stallDialog.data.name"
label="Name"
></q-input>
<q-input
filled
dense
v-model.trim="stallDialog.data.description"
type="textarea"
rows="3"
label="Description"
></q-input>
<q-select
filled
dense
emit-value
v-model="stallDialog.data.wallet"
:options="walletOptions"
label="Wallet *"
>
</q-select>
<q-select
filled
dense
v-model="stallDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
<q-select
:options="filteredZoneOptions"
filled
dense
multiple
v-model.trim="stallDialog.data.shippingZones"
label="Shipping Zones"
></q-select>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="!stallDialog.data.name
|| !stallDialog.data.currency
|| !stallDialog.data.wallet
|| !stallDialog.data.shippingZones
|| !stallDialog.data.shippingZones.length"
type="submit"
>Create Stall</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
</div>

View file

@ -0,0 +1,175 @@
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: '',
description: '',
wallet: null,
currency: 'sat',
shippingZones: []
}
},
zoneOptions: [],
stallsTable: {
columns: [
{
name: '',
align: 'left',
label: '',
field: ''
},
{
name: 'id',
align: 'left',
label: 'Name',
field: 'id'
},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{
name: 'shippingZones',
align: 'left',
label: 'Shipping Zones',
field: 'shippingZones'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
computed: {
filteredZoneOptions: function () {
return this.zoneOptions.filter(
z => z.currency === this.stallDialog.data.currency
)
}
},
methods: {
sendStallFormData: async function () {
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: {
description: this.stallDialog.data.description
}
})
},
createStall: async function (stall) {
try {
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/stall',
this.adminkey,
stall
)
this.stallDialog.show = false
data.expanded = false
this.stalls.unshift(data)
this.$q.notify({
type: 'positive',
message: 'Stall created!'
})
} 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)
}
},
getStalls: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/stall',
this.inkey
)
this.stalls = data.map(s => ({...s, expanded: false}))
} 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 => ({
...z,
label: z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', ')
}))
} 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()
if (!this.zoneOptions || !this.zoneOptions.length) {
this.$q.notify({
type: 'warning',
message: 'Please create a Shipping Zone first!'
})
return
}
this.stallDialog.data = {
name: '',
description: '',
wallet: null,
currency: 'sat',
shippingZones: []
}
this.stallDialog.show = true
}
},
created: async function () {
await this.getStalls()
await this.getCurrencies()
await this.getZones()
}
})
}

View file

@ -1,9 +1,10 @@
const merchant = async () => { const merchant = async () => {
Vue.component(VueQrcode.name, VueQrcode) Vue.component(VueQrcode.name, VueQrcode)
await stallDetails('static/components/stall-details/stall-details.html')
await keyPair('static/components/key-pair/key-pair.html') await keyPair('static/components/key-pair/key-pair.html')
await shippingZones('static/components/shipping-zones/shipping-zones.html') await shippingZones('static/components/shipping-zones/shipping-zones.html')
await stallDetails('static/components/stall-details/stall-details.html')
await stallList('static/components/stall-list/stall-list.html')
const nostr = window.NostrTools const nostr = window.NostrTools
@ -14,16 +15,49 @@ const merchant = async () => {
return { return {
merchant: {}, merchant: {},
shippingZones: [], shippingZones: [],
showKeys: false showKeys: false,
importKeyDialog: {
show: false,
data: {
privateKey: null
}
}
} }
}, },
methods: { methods: {
generateKeys: async function () { generateKeys: async function () {
const privkey = nostr.generatePrivateKey() const privateKey = nostr.generatePrivateKey()
const pubkey = nostr.getPublicKey(privkey) await this.createMerchant(privateKey)
},
const payload = {private_key: privkey, public_key: pubkey, config: {}} importKeys: async function () {
this.importKeyDialog.show = false
let privateKey = this.importKeyDialog.data.privateKey
if (!privateKey) {
return
}
try { 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)
},
showImportKeysDialog: async function () {
this.importKeyDialog.show = true
},
createMerchant: async function (privateKey) {
try {
const pubkey = nostr.getPublicKey(privateKey)
const payload = {
private_key: privateKey,
public_key: pubkey,
config: {}
}
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'POST', 'POST',
'/nostrmarket/api/v1/merchant', '/nostrmarket/api/v1/merchant',
@ -33,10 +67,13 @@ const merchant = async () => {
this.merchant = data this.merchant = data
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
message: 'Keys generated!' message: 'Merchant Created!'
}) })
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) this.$q.notify({
type: 'negative',
message: `${error}`
})
} }
}, },
getMerchant: async function () { getMerchant: async function () {

View file

@ -16,3 +16,12 @@ function loadTemplateAsync(path) {
return result 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}
}

View file

@ -38,7 +38,7 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<q-btn <q-btn
disabled @click="showImportKeysDialog"
label="Import Key" label="Import Key"
color="primary" color="primary"
class="float-left" class="float-left"
@ -61,20 +61,22 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="row"> <div class="row">
<div class="col-6"></div> <div class="col-8"></div>
<div class="col-4"> <div class="col-2">
<shipping-zones <shipping-zones
:inkey="g.user.wallets[0].inkey" :inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey" :adminkey="g.user.wallets[0].adminkey"
class="float-right"
></shipping-zones> ></shipping-zones>
</div> </div>
<div class="col-2"> <div class="col-2">
<q-btn <q-btn
@click="showKeys = !showKeys" @click="showKeys = !showKeys"
:label="showKeys ? 'Hide Keys' : 'Show Keys'" icon="vpn_key"
color="primary" color="primary"
class="float-right"
> >
<q-tooltip> Show Public or Private keys </q-tooltip> <q-tooltip> Show Public and Private keys </q-tooltip>
</q-btn> </q-btn>
</div> </div>
</div> </div>
@ -86,6 +88,15 @@
></key-pair> ></key-pair>
</q-card-section> </q-card-section>
</q-card> </q-card>
<q-card class="q-mt-lg">
<q-card-section>
<stall-list
:adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey"
:wallet-options="g.user.walletOptions"
></stall-list>
</q-card-section>
</q-card>
</div> </div>
</div> </div>
@ -102,14 +113,43 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<div>
<q-dialog v-model="importKeyDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="importKeys" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="importKeyDialog.data.privateKey"
label="Private Key (hex or nsec)"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="!importKeyDialog.data.privateKey"
type="submit"
>Import</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
</div> </div>
{% endblock%}{% block scripts %} {{ window_vars(user) }} {% endblock%}{% block scripts %} {{ window_vars(user) }}
<!-- todo: serve locally -->
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script> <script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
<script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/stall-details/stall-details.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/key-pair/key-pair.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='components/key-pair/key-pair.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/shipping-zones/shipping-zones.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='components/shipping-zones/shipping-zones.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/stall-details/stall-details.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='components/stall-list/stall-list.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -1,3 +1,4 @@
import json
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional from typing import List, Optional
@ -16,14 +17,33 @@ from lnbits.utils.exchange_rates import currencies
from . import nostrmarket_ext from . import nostrmarket_ext
from .crud import ( from .crud import (
create_merchant, create_merchant,
create_product,
create_stall,
create_zone, create_zone,
delete_product,
delete_stall,
delete_zone, delete_zone,
get_merchant_for_user, get_merchant_for_user,
get_products,
get_stall,
get_stalls,
get_zone, get_zone,
get_zones, get_zones,
update_product,
update_stall,
update_zone, update_zone,
) )
from .models import Merchant, PartialMerchant, PartialZone, Zone from .models import (
Merchant,
PartialMerchant,
PartialProduct,
PartialStall,
PartialZone,
Product,
Stall,
Zone,
)
from .nostr.nostr_client import publish_nostr_event
######################################## MERCHANT ######################################## ######################################## MERCHANT ########################################
@ -138,6 +158,230 @@ 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_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"
stall = await create_stall(wallet.wallet.user, data=data)
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 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 stall",
)
@nostrmarket_ext.put("/api/v1/stall/{stall_id}")
async def api_update_stall(
data: 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"
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.event_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 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 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 get stall",
)
@nostrmarket_ext.get("/api/v1/stall")
async def api_get_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 get stalls",
)
@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 delete stall",
)
######################################## PRODUCTS ########################################
@nostrmarket_ext.post("/api/v1/product")
async def api_create_product(
data: PartialProduct,
wallet: WalletTypeInfo = Depends(require_admin_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.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,
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",
)
@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 ########################################
@nostrmarket_ext.get("/api/v1/currencies") @nostrmarket_ext.get("/api/v1/currencies")
async def api_list_currencies_available(): async def api_list_currencies_available():
return list(currencies.keys()) return list(currencies.keys())