feat: manage shipping zones

This commit is contained in:
Vlad Stan 2023-02-28 16:30:09 +02:00
parent dcda99830e
commit 31c5a82cb9
8 changed files with 444 additions and 20 deletions

69
crud.py
View file

@ -1,10 +1,12 @@
import json import json
from typing import 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 from .models import Merchant, PartialMerchant, PartialZone, Zone
######################################## MERCHANT ########################################
async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant: async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
@ -40,3 +42,66 @@ async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
) )
return Merchant.from_row(row) if row else None return Merchant.from_row(row) if row else None
######################################## ZONES ########################################
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
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
zone_id,
user_id,
data.name,
data.currency,
data.cost,
json.dumps(data.countries),
),
)
zone = await get_zone(user_id, zone_id)
assert zone, "Newly created zone couldn't be retrieved"
return zone
async def update_zone(user_id: str, z: Zone) -> Optional[Zone]:
await db.execute(
f"UPDATE nostrmarket.zones SET name = ?, cost = ?, regions = ? WHERE id = ? AND user_id = ?",
(z.name, z.cost, json.dumps(z.countries), z.id, user_id),
)
return await get_zone(user_id, z.id)
async def get_zone(user_id: str, zone_id: str) -> Optional[Zone]:
row = await db.fetchone(
"SELECT * FROM nostrmarket.zones WHERE user_id = ? AND id = ?",
(
user_id,
zone_id,
),
)
return Zone.from_row(row) if row else None
async def get_zones(user_id: str) -> List[Zone]:
rows = await db.fetchall(
"SELECT * FROM nostrmarket.zones WHERE user_id = ?", (user_id,)
)
return [Zone.from_row(row) for row in rows]
async def delete_zone(zone_id: str) -> None:
await db.execute("DELETE FROM nostrmarket.zones WHERE id = ?", (zone_id,))

View file

@ -59,8 +59,9 @@ async def m001_initial(db):
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
currency TEXT NOT NULL,
cost REAL NOT NULL, cost REAL NOT NULL,
countries TEXT NOT NULL regions TEXT NOT NULL DEFAULT '[]'
); );
""" """
) )

View file

@ -1,10 +1,12 @@
import json import json
from sqlite3 import Row from sqlite3 import Row
from typing import Optional from typing import List, Optional
from fastapi import Query
from pydantic import BaseModel from pydantic import BaseModel
######################################## MERCHANT ########################################
class MerchantConfig(BaseModel): class MerchantConfig(BaseModel):
name: Optional[str] name: Optional[str]
@ -23,3 +25,21 @@ class Merchant(PartialMerchant):
merchant = cls(**dict(row)) merchant = cls(**dict(row))
merchant.config = MerchantConfig(**json.loads(row["meta"])) merchant.config = MerchantConfig(**json.loads(row["meta"]))
return merchant return merchant
######################################## ZONES ########################################
class PartialZone(BaseModel):
name: Optional[str]
currency: str
cost: float
countries: List[str] = []
class Zone(PartialZone):
id: str
@classmethod
def from_row(cls, row: Row) -> "Zone":
zone = cls(**dict(row))
zone.countries = json.loads(row["regions"])
return zone

View file

@ -5,10 +5,10 @@
color="primary" color="primary"
icon="public" icon="public"
label="Shipping Zones" label="Shipping Zones"
@click="createShippingZone" @click="openZoneDialog()"
> >
<q-list> <q-list>
<q-item clickable v-close-popup @click="createShippingZone"> <q-item clickable v-close-popup @click="openZoneDialog()">
<q-item-section> <q-item-section>
<q-item-label>New Shipping Zone</q-item-label> <q-item-label>New Shipping Zone</q-item-label>
<q-item-label caption>Create a new shipping zone</q-item-label> <q-item-label caption>Create a new shipping zone</q-item-label>
@ -19,13 +19,80 @@
:key="zone.id" :key="zone.id"
clickable clickable
v-close-popup v-close-popup
@click="editShippingZone" @click="openZoneDialog(zone)"
> >
<q-item-section> <q-item-section>
<q-item-label>XXX</q-item-label> <q-item-label>{{zone.name}}</q-item-label>
<q-item-label caption>xxxxxxxxxxxxx</q-item-label> <q-item-label caption>{{zone.countries.join(", ")}}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list></q-btn-dropdown </q-list></q-btn-dropdown
> >
<q-dialog v-model="zoneDialog.showDialog" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendZoneFormData" class="q-gutter-md">
<q-input
filled
dense
label="Zone Name"
type="text"
v-model.trim="zoneDialog.data.name"
></q-input>
<q-select
filled
dense
multiple
:options="shippingZoneOptions"
label="Countries"
v-model="zoneDialog.data.countries"
></q-select>
<q-select
v-if="zoneDialog.data.id"
style="width: 100px"
filled
dense
v-model="zoneDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
<q-input
filled
dense
:label="'Amount (' + zoneDialog.data.currency + ') *'"
fill-mask="0"
reverse-fill-mask
:step="zoneDialog.data.currency != 'sat' ? '0.01' : '1'"
type="number"
v-model.trim="zoneDialog.data.cost"
></q-input>
<div class="row q-mt-lg">
<div v-if="zoneDialog.data.id">
<q-btn unelevated color="primary" type="submit">Update</q-btn>
<q-btn
@click="deleteShippingZone()"
class="q-ml-md"
unelevated
color="pink"
>Delete</q-btn
>
</div>
<div v-else>
<q-btn
unelevated
color="primary"
:disable="!zoneDialog.data.countries || !zoneDialog.data.countries.length"
type="submit"
>Create Shipping Zone</q-btn
>
</div>
<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

@ -2,18 +2,188 @@ async function shippingZones(path) {
const template = await loadTemplateAsync(path) const template = await loadTemplateAsync(path)
Vue.component('shipping-zones', { Vue.component('shipping-zones', {
name: 'shipping-zones', name: 'shipping-zones',
props: ['adminkey', 'inkey'],
template, template,
data: function () { data: function () {
return { return {
zones: [] zones: [],
zoneDialog: {
showDialog: false,
data: {
id: null,
name: '',
countries: [],
cost: 0,
currency: 'sat'
}
},
currencies: [],
shippingZoneOptions: [
'Free (digital)',
'Flat rate',
'Worldwide',
'Europe',
'Australia',
'Austria',
'Belgium',
'Brazil',
'Canada',
'Denmark',
'Finland',
'France',
'Germany',
'Greece',
'Hong Kong',
'Hungary',
'Ireland',
'Indonesia',
'Israel',
'Italy',
'Japan',
'Kazakhstan',
'Korea',
'Luxembourg',
'Malaysia',
'Mexico',
'Netherlands',
'New Zealand',
'Norway',
'Poland',
'Portugal',
'Romania',
'Russia',
'Saudi Arabia',
'Singapore',
'Spain',
'Sweden',
'Switzerland',
'Thailand',
'Turkey',
'Ukraine',
'United Kingdom**',
'United States***',
'Vietnam',
'China'
]
} }
}, },
methods: { methods: {
createShippingZone: async function () { openZoneDialog: function (data) {
console.log('### createShippingZone', createShippingZone) data = data || {
id: null,
name: '',
countries: [],
cost: 0,
currency: 'sat'
}
this.zoneDialog.data = data
this.zoneDialog.showDialog = true
}, },
editShippingZone: async function () {} createZone: async function () {
try {
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/zone',
this.adminkey,
{}
)
this.zones = 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.zones = data
console.log('### data', data)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendZoneFormData: async function () {
console.log('### data', this.zoneDialog.data)
this.zoneDialog.showDialog = false
if (this.zoneDialog.data.id) {
await this.updateShippingZone(this.zoneDialog.data)
} else {
await this.createShippingZone(this.zoneDialog.data)
}
await this.getZones()
},
createShippingZone: async function (newZone) {
console.log('### newZone', newZone)
try {
await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/zone',
this.adminkey,
newZone
)
this.$q.notify({
type: 'positive',
message: 'Zone created!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateShippingZone: async function (updatedZone) {
try {
await LNbits.api.request(
'PATCH',
`/nostrmarket/api/v1/zone/${updatedZone.id}`,
this.adminkey,
updatedZone
)
this.$q.notify({
type: 'positive',
message: 'Zone updated!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deleteShippingZone: async function () {
try {
await LNbits.api.request(
'DELETE',
`/nostrmarket/api/v1/zone/${this.zoneDialog.data.id}`,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Zone deleted!'
})
await this.getZones()
this.zoneDialog.showDialog = false
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async getCurrencies() {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/currencies',
this.inkey
)
this.currencies = ['sat', ...data]
} catch (error) {
LNbits.utils.notifyApiError(error)
}
}
},
created: async function () {
await this.getZones()
await this.getCurrencies()
} }
}) })
} }

View file

@ -42,9 +42,9 @@ const merchant = async () => {
getMerchant: async function () { getMerchant: async function () {
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'get', 'GET',
'/nostrmarket/api/v1/merchant', '/nostrmarket/api/v1/merchant',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].inkey
) )
this.merchant = data this.merchant = data
} catch (error) { } catch (error) {

View file

@ -63,7 +63,10 @@
<div class="row"> <div class="row">
<div class="col-6"></div> <div class="col-6"></div>
<div class="col-4"> <div class="col-4">
<shipping-zones></shipping-zones> <shipping-zones
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
></shipping-zones>
</div> </div>
<div class="col-2"> <div class="col-2">
<q-btn <q-btn

View file

@ -1,15 +1,31 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Optional from typing import List, Optional
from fastapi import Depends from fastapi import Depends
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from loguru import logger from loguru import logger
from lnbits.decorators import WalletTypeInfo, require_admin_key, require_invoice_key from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
require_admin_key,
require_invoice_key,
)
from lnbits.utils.exchange_rates import currencies
from . import nostrmarket_ext from . import nostrmarket_ext
from .crud import create_merchant, get_merchant_for_user from .crud import (
from .models import Merchant, PartialMerchant create_merchant,
create_zone,
delete_zone,
get_merchant_for_user,
get_zone,
get_zones,
update_zone,
)
from .models import Merchant, PartialMerchant, PartialZone, Zone
######################################## MERCHANT ########################################
@nostrmarket_ext.post("/api/v1/merchant") @nostrmarket_ext.post("/api/v1/merchant")
@ -43,3 +59,85 @@ async def api_get_merchant(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant", detail="Cannot create merchant",
) )
######################################## ZONES ########################################
@nostrmarket_ext.get("/api/v1/zone")
async def api_get_zones(wallet: WalletTypeInfo = Depends(get_key_type)) -> List[Zone]:
try:
return await get_zones(wallet.wallet.user)
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant",
)
@nostrmarket_ext.post("/api/v1/zone")
async def api_create_zone(
data: PartialZone, wallet: WalletTypeInfo = Depends(get_key_type)
):
try:
zone = await create_zone(wallet.wallet.user, data)
return zone
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant",
)
@nostrmarket_ext.patch("/api/v1/zone/{zone_id}")
async def api_update_zone(
data: Zone,
zone_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Zone:
try:
zone = await get_zone(wallet.wallet.user, zone_id)
if not zone:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Zone does not exist.",
)
zone = await update_zone(wallet.wallet.user, data)
assert zone, "Cannot find updated zone"
return zone
except HTTPException as ex:
raise ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant",
)
@nostrmarket_ext.delete("/api/v1/zone/{zone_id}")
async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
try:
zone = await get_zone(wallet.wallet.user, zone_id)
if not zone:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Zone does not exist.",
)
await delete_zone(zone_id)
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant",
)
@nostrmarket_ext.get("/api/v1/currencies")
async def api_list_currencies_available():
return list(currencies.keys())