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 time
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,
PartialProduct,
PartialStall,
PartialZone,
Product,
Stall,
Zone,
)
######################################## MERCHANT ########################################
@ -51,15 +61,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 (?, ?, ?, ?, ?, ?)
""",
(
@ -104,4 +106,166 @@ 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,))
######################################## 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.
"""
# 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 '{}'
);
"""
)
@ -37,15 +39,15 @@ 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,
image TEXT,
images TEXT DEFAULT '[]',
price REAL NOT NULL,
quantity INTEGER NOT NULL,
rating REAL DEFAULT 0
quantity INTEGER NOT NULL
);
"""
)

139
models.py
View file

@ -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))
@ -43,3 +49,134 @@ class Zone(PartialZone):
zone = cls(**dict(row))
zone.countries = json.loads(row["regions"])
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
color="primary"
icon="public"
label="Shipping Zones"
@click="openZoneDialog()"
>
<q-list>
@ -48,8 +47,8 @@
v-model="zoneDialog.data.countries"
></q-select>
<q-select
v-if="!zoneDialog.data.id"
style="width: 100px"
:disabled="!!zoneDialog.data.id"
:readonly="!!zoneDialog.data.id"
filled
dense
v-model="zoneDialog.data.currency"
@ -60,7 +59,7 @@
<q-input
filled
dense
:label="'Amount (' + zoneDialog.data.currency + ') *'"
:label="'Cost of Shipping (' + zoneDialog.data.currency + ') *'"
fill-mask="0"
reverse-fill-mask
: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) {
const template = await loadTemplateAsync(path)
const pica = window.pica()
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
tab: 'products',
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 () => {
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
@ -14,16 +15,49 @@ 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
}
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(
'POST',
'/nostrmarket/api/v1/merchant',
@ -33,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 () {

View file

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

View file

@ -38,7 +38,7 @@
<div class="row">
<div class="col-12">
<q-btn
disabled
@click="showImportKeysDialog"
label="Import Key"
color="primary"
class="float-left"
@ -61,20 +61,22 @@
<q-card>
<q-card-section>
<div class="row">
<div class="col-6"></div>
<div class="col-4">
<div class="col-8"></div>
<div class="col-2">
<shipping-zones
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
class="float-right"
></shipping-zones>
</div>
<div class="col-2">
<q-btn
@click="showKeys = !showKeys"
:label="showKeys ? 'Hide Keys' : 'Show Keys'"
icon="vpn_key"
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>
</div>
</div>
@ -86,6 +88,15 @@
></key-pair>
</q-card-section>
</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>
@ -102,14 +113,43 @@
</q-card-section>
</q-card>
</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>
{% endblock%}{% block scripts %} {{ window_vars(user) }}
<!-- todo: serve locally -->
<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='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/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>
{% endblock %}

View file

@ -1,3 +1,4 @@
import json
from http import HTTPStatus
from typing import List, Optional
@ -16,14 +17,33 @@ from lnbits.utils.exchange_rates import currencies
from . import nostrmarket_ext
from .crud import (
create_merchant,
create_product,
create_stall,
create_zone,
delete_product,
delete_stall,
delete_zone,
get_merchant_for_user,
get_products,
get_stall,
get_stalls,
get_zone,
get_zones,
update_product,
update_stall,
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 ########################################
@ -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")
async def api_list_currencies_available():
return list(currencies.keys())