fat: add create product logic

This commit is contained in:
Vlad Stan 2023-03-02 17:42:28 +02:00
parent 5ad070684d
commit 5328ce170c
10 changed files with 446 additions and 17 deletions

53
crud.py
View file

@ -5,7 +5,16 @@ 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, PartialStall, PartialZone, Stall, Zone from .models import (
Merchant,
PartialMerchant,
PartialProduct,
PartialStall,
PartialZone,
Product,
Stall,
Zone,
)
######################################## MERCHANT ######################################## ######################################## MERCHANT ########################################
@ -177,3 +186,45 @@ async def delete_stall(user_id: str, stall_id: str) -> None:
stall_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 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

View file

@ -39,12 +39,13 @@ 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,
images TEXT NOT NULL DEFAULT '[]', images TEXT DEFAULT '[]',
price REAL NOT NULL, price REAL NOT NULL,
quantity INTEGER NOT NULL quantity INTEGER NOT NULL
); );

View file

@ -116,3 +116,45 @@ class Stall(PartialStall):
stall.config = StallConfig(**json.loads(row["meta"])) stall.config = StallConfig(**json.loads(row["meta"]))
stall.shipping_zones = [Zone(**z) for z in json.loads(row["zones"])] stall.shipping_zones = [Zone(**z) for z in json.loads(row["zones"])]
return stall 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

View file

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

@ -99,11 +99,153 @@
</div> </div>
</div> </div>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="payment"> <q-tab-panel name="products">
<div v-if="stall"></div> <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>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="config"> <q-tab-panel name="orders">
<div v-if="stall"></div> <div v-if="stall"></div>
</q-tab-panel> </q-tab-panel>
</q-tab-panels> </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>
<!-- <div class="row"> -->
<!-- <div class="col-5">
<q-select
filled
dense
multiple
v-model.trim="productDialog.data.categories"
:options="categories"
label="Categories"
class="q-pr-sm"
></q-select>
</div> -->
<!-- <div class="col-7"> -->
<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> </div>

View file

@ -1,6 +1,8 @@
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,
@ -16,8 +18,21 @@ async function stallDetails(path) {
data: function () { data: function () {
return { return {
tab: 'info', tab: 'info',
stall: null stall: null,
// currencies: [], products: [],
productDialog: {
showDialog: false,
url: true,
data: {
id: null,
name: '',
description: '',
categories: [],
image: null,
price: 0,
quantity: 0
}
}
} }
}, },
computed: { computed: {
@ -96,6 +111,97 @@ async function stallDetails(path) {
LNbits.utils.notifyApiError(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}
},
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 () { created: async function () {

View file

@ -35,12 +35,6 @@ async function stallList(path) {
label: 'Name', label: 'Name',
field: 'id' field: 'id'
}, },
// {
// name: 'toggle',
// align: 'left',
// label: 'Active',
// field: ''
// },
{ {
name: 'description', name: 'description',
align: 'left', align: 'left',
@ -88,6 +82,7 @@ async function stallList(path) {
stall stall
) )
this.stallDialog.show = false this.stallDialog.show = false
data.expanded = false
this.stalls.unshift(data) this.stalls.unshift(data)
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
@ -154,6 +149,13 @@ async function stallList(path) {
openCreateStallDialog: async function () { openCreateStallDialog: async function () {
await this.getCurrencies() await this.getCurrencies()
await this.getZones() 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 = { this.stallDialog.data = {
name: '', name: '',
description: '', description: '',

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

@ -143,6 +143,7 @@
{% endblock%}{% block scripts %} {{ window_vars(user) }} {% endblock%}{% block scripts %} {{ window_vars(user) }}
<!-- todo: serve locally --> <!-- 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/key-pair/key-pair.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='components/key-pair/key-pair.js') }}"></script>

View file

@ -17,6 +17,7 @@ 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_stall,
create_zone, create_zone,
delete_stall, delete_stall,
@ -29,7 +30,16 @@ from .crud import (
update_stall, update_stall,
update_zone, 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 from .nostr.nostr_client import publish_nostr_event
######################################## MERCHANT ######################################## ######################################## MERCHANT ########################################
@ -170,6 +180,11 @@ async def api_create_stall(
await update_stall(wallet.wallet.user, stall) await update_stall(wallet.wallet.user, stall)
return stall return stall
except ValueError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
)
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise HTTPException(
@ -202,6 +217,11 @@ async def api_update_stall(
return stall return stall
except HTTPException as ex: except HTTPException as ex:
raise ex raise ex
except ValueError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
)
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise HTTPException(
@ -271,10 +291,65 @@ async def api_delete_stall(
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, 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 ######################################## ######################################## OTHER ########################################