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 lnbits.helpers import urlsafe_short_hash
from . import db from . import db
from .models import Merchant, PartialMerchant, PartialZone, Zone from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone
######################################## MERCHANT ######################################## ######################################## MERCHANT ########################################
@ -105,3 +105,64 @@ 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:
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(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. 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 '{}'
); );
""" """
) )

View file

@ -43,3 +43,30 @@ 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):
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 () => { 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

View file

@ -86,15 +86,16 @@
></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>
<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>
<div class="col-12 col-md-5 q-gutter-y-md"> <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="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='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

@ -16,14 +16,18 @@ 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_stall,
create_zone, create_zone,
delete_zone, delete_zone,
get_merchant_for_user, get_merchant_for_user,
get_stall,
get_stalls,
get_zone, get_zone,
get_zones, get_zones,
update_stall,
update_zone, update_zone,
) )
from .models import Merchant, PartialMerchant, PartialZone, Zone from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone
######################################## MERCHANT ######################################## ######################################## 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") @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())