feat: basic stall flow

This commit is contained in:
Vlad Stan 2023-03-01 11:35:04 +02:00
parent c15b765a7d
commit e9b7494bb6
8 changed files with 432 additions and 14 deletions

63
crud.py
View file

@ -4,7 +4,7 @@ from typing import List, Optional
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Merchant, PartialMerchant, PartialZone, Zone
from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone
######################################## MERCHANT ########################################
@ -105,3 +105,64 @@ async def get_zones(user_id: str) -> List[Zone]:
async def delete_zone(zone_id: str) -> None:
await db.execute("DELETE FROM nostrmarket.zones WHERE id = ?", (zone_id,))
######################################## STALL ########################################
async def create_stall(user_id: str, data: PartialStall) -> Stall:
stall_id = urlsafe_short_hash()
await db.execute(
f"""
INSERT INTO nostrmarket.stalls (user_id, id, wallet, name, currency, zones, meta)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
stall_id,
data.wallet,
data.name,
data.currency,
json.dumps(data.shipping_zones),
json.dumps(dict(data.config)),
),
)
stall = await get_stall(user_id, stall_id)
assert stall, "Newly created stall couldn't be retrieved"
return stall
async def get_stall(user_id: str, stall_id: str) -> Optional[Stall]:
row = await db.fetchone(
"SELECT * FROM nostrmarket.stalls WHERE user_id = ? AND id = ?",
(
user_id,
stall_id,
),
)
return Stall.from_row(row) if row else None
async def get_stalls(user_id: str) -> List[Stall]:
rows = await db.fetchone(
"SELECT * FROM nostrmarket.stalls WHERE user_id = ?",
(user_id,),
)
return [Stall.from_row(row) for row in rows]
async def update_stall(user_id: str, stall_id: str, **kwargs) -> Optional[Stall]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE market.stalls SET {q} WHERE user_id = ? AND id = ?",
(*kwargs.values(), user_id, stall_id),
)
row = await db.fetchone(
"SELECT * FROM market.stalls WHERE user_id =? AND id = ?",
(
user_id,
stall_id,
),
)
return Stall.from_row(row) if row else None

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 '{}'
);
"""
)

View file

@ -43,3 +43,30 @@ class Zone(PartialZone):
zone = cls(**dict(row))
zone.countries = json.loads(row["regions"])
return zone
######################################## STALLS ########################################
class StallConfig(BaseModel):
image_url: Optional[str]
fiat_base_multiplier: int = 1 # todo: reminder wht is this for?
class PartialStall(BaseModel):
wallet: str
name: str
currency: str = "sat"
shipping_zones: List[str] = []
config: StallConfig = StallConfig()
class Stall(PartialStall):
id: str
@classmethod
def from_row(cls, row: Row) -> "Stall":
stall = cls(**dict(row))
stall.config = StallConfig(**json.loads(row["meta"]))
stall.shipping_zones = json.loads(row["zones"])
return stall

View file

@ -0,0 +1,157 @@
<div>
<div class="row items-center no-wrap q-mb-md">
<div class="col q-pr-lg">
<q-btn
@click="stallDialog.show = true"
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 class="col-auto">
<q-btn outline color="grey" label="...">
<q-menu auto-close>
<q-list style="min-width: 100px">
<q-item clickable>
<q-item-section @click="exportrelayCSV"
>Export to CSV</q-item-section
>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div> -->
</div>
<!--
<q-table
flat
dense
:data="stalls"
row-key="id"
:columns="relaysTable.columns"
:pagination.sync="relaysTable.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.id}}</a
>
</q-td>
<q-td key="toggle" :props="props">
<q-toggle
size="sm"
color="secodary"
v-model="props.row.active"
@input="showToggleRelayDialog(props.row)"
></q-toggle>
</q-td>
<q-td auto-width> {{props.row.name}} </q-td>
<q-td key="description" :props="props">
{{props.row.description}}
</q-td>
<q-td key="pubkey" :props="props">
<div>{{props.row.pubkey}}</div>
</q-td>
<q-td key="contact" :props="props">
<div>{{props.row.contact}}</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">
<relay-details
:relay-id="props.row.id"
:adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey"
:wallet-options="g.user.walletOptions"
@relay-deleted="handleRelayDeleted"
@relay-updated="handleRelayUpdated"
></relay-details>
</div>
</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</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-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="zoneOptions"
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.wallet == null"
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,84 @@
async function stallList(path) {
const template = await loadTemplateAsync(path)
Vue.component('stall-list', {
name: 'stall-list',
template,
props: [`adminkey`, 'inkey', 'wallet-options'],
data: function () {
return {
filter: '',
stalls: [],
currencies: [],
stallDialog: {
show: false,
data: {
name: '',
wallet: null,
currency: 'sat',
shippingZones: []
}
},
zoneOptions: []
}
},
methods: {
sendStallFormData: async function () {
console.log('### sendStallFormData', this.stallDialog.data)
await this.createStall({
name: this.stallDialog.data.name,
wallet: this.stallDialog.data.wallet,
currency: this.stallDialog.data.currency,
shipping_zones: this.stallDialog.data.shippingZones.map(z => z.id),
config: {}
})
},
createStall: async function (stall) {
console.log('### createStall', stall)
try {
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/stall',
this.adminkey,
stall
)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getCurrencies: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/currencies',
this.inkey
)
this.currencies = ['sat', ...data]
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getZones: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/zone',
this.inkey
)
this.zoneOptions = data.map(z => ({
id: z.id,
label: `${z.name} (${z.countries.join(', ')})`
}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
}
},
created: async function () {
await this.getCurrencies()
await this.getZones()
}
})
}

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

View file

@ -86,15 +86,16 @@
></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>
<q-card>
<q-card-section>
<div class="row">
<div class="col-6"></div>
<div class="col-6"></div>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
@ -115,9 +116,10 @@
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.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

@ -16,14 +16,18 @@ from lnbits.utils.exchange_rates import currencies
from . import nostrmarket_ext
from .crud import (
create_merchant,
create_stall,
create_zone,
delete_zone,
get_merchant_for_user,
get_stall,
get_stalls,
get_zone,
get_zones,
update_stall,
update_zone,
)
from .models import Merchant, PartialMerchant, PartialZone, Zone
from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone
######################################## MERCHANT ########################################
@ -138,6 +142,86 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi
)
######################################## STALLS ########################################
@nostrmarket_ext.post("/api/v1/stall")
async def api_create_stall(
data: PartialStall,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
stall = await create_stall(wallet.wallet.user, data=data)
return stall.dict()
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create stall",
)
@nostrmarket_ext.put("/api/v1/stall/{stall_id}")
async def api_update_stall(
data: Stall,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
stall = await get_stall(wallet.wallet.user, data.id)
if not stall:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Stall does not exist.",
)
stall = await update_stall(wallet.wallet.user, data.id, **data.dict())
assert stall, "Cannot fetch updated stall"
return stall.dict()
except HTTPException as ex:
raise ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create stall",
)
@nostrmarket_ext.get("/api/v1/stall/{stall_id}")
async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_type)):
try:
stall = await get_stall(wallet.wallet.user, stall_id)
if not stall:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Stall does not exist.",
)
return stall
except HTTPException as ex:
raise ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create stall",
)
@nostrmarket_ext.get("/api/v1/stall")
async def api_gey_stalls(wallet: WalletTypeInfo = Depends(get_key_type)):
try:
stalls = await get_stalls(wallet.wallet.user)
return stalls
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create stall",
)
######################################## OTHER ########################################
@nostrmarket_ext.get("/api/v1/currencies")
async def api_list_currencies_available():
return list(currencies.keys())