Merge pull request #3 from lnbits/shipping_zones

Shipping zones
This commit is contained in:
Vlad Stan 2023-02-28 16:31:12 +02:00 committed by GitHub
commit 396ac09724
8 changed files with 493 additions and 12 deletions

69
crud.py
View file

@ -1,10 +1,12 @@
import json
from typing import Optional
from typing import List, Optional
from lnbits.helpers import urlsafe_short_hash
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:
@ -40,3 +42,66 @@ async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
)
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

@ -58,8 +58,10 @@ async def m001_initial(db):
CREATE TABLE nostrmarket.zones (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
currency TEXT NOT NULL,
cost REAL NOT NULL,
countries TEXT NOT NULL
regions TEXT NOT NULL DEFAULT '[]'
);
"""
)

View file

@ -1,10 +1,12 @@
import json
from sqlite3 import Row
from typing import Optional
from typing import List, Optional
from fastapi import Query
from pydantic import BaseModel
######################################## MERCHANT ########################################
class MerchantConfig(BaseModel):
name: Optional[str]
@ -23,3 +25,21 @@ class Merchant(PartialMerchant):
merchant = cls(**dict(row))
merchant.config = MerchantConfig(**json.loads(row["meta"]))
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

@ -0,0 +1,98 @@
<div>
<q-btn-dropdown
split
unelevated
color="primary"
icon="public"
label="Shipping Zones"
@click="openZoneDialog()"
>
<q-list>
<q-item clickable v-close-popup @click="openZoneDialog()">
<q-item-section>
<q-item-label>New Shipping Zone</q-item-label>
<q-item-label caption>Create a new shipping zone</q-item-label>
</q-item-section>
</q-item>
<q-item
v-for="zone of zones"
:key="zone.id"
clickable
v-close-popup
@click="openZoneDialog(zone)"
>
<q-item-section>
<q-item-label>{{zone.name}}</q-item-label>
<q-item-label caption>{{zone.countries.join(", ")}}</q-item-label>
</q-item-section>
</q-item>
</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>

View file

@ -0,0 +1,189 @@
async function shippingZones(path) {
const template = await loadTemplateAsync(path)
Vue.component('shipping-zones', {
name: 'shipping-zones',
props: ['adminkey', 'inkey'],
template,
data: function () {
return {
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: {
openZoneDialog: function (data) {
data = data || {
id: null,
name: '',
countries: [],
cost: 0,
currency: 'sat'
}
this.zoneDialog.data = data
this.zoneDialog.showDialog = true
},
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

@ -3,6 +3,7 @@ const merchant = async () => {
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')
const nostr = window.NostrTools
@ -12,6 +13,7 @@ const merchant = async () => {
data: function () {
return {
merchant: {},
shippingZones: [],
showKeys: false
}
},
@ -40,9 +42,9 @@ const merchant = async () => {
getMerchant: async function () {
try {
const {data} = await LNbits.api.request(
'get',
'GET',
'/nostrmarket/api/v1/merchant',
this.g.user.wallets[0].adminkey
this.g.user.wallets[0].inkey
)
this.merchant = data
} catch (error) {

View file

@ -61,12 +61,18 @@
<q-card>
<q-card-section>
<div class="row">
<div class="col-12">
<div class="col-6"></div>
<div class="col-4">
<shipping-zones
:inkey="g.user.wallets[0].inkey"
:adminkey="g.user.wallets[0].adminkey"
></shipping-zones>
</div>
<div class="col-2">
<q-btn
@click="showKeys = !showKeys"
:label="showKeys ? 'Hide Keys' : 'Show Keys'"
color="primary"
class="float-right"
>
<q-tooltip> Show Public or Private keys </q-tooltip>
</q-btn>
@ -103,6 +109,7 @@
<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='js/index.js') }}"></script>
{% endblock %}

View file

@ -1,15 +1,31 @@
from http import HTTPStatus
from typing import Optional
from typing import List, Optional
from fastapi import Depends
from fastapi.exceptions import HTTPException
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 .crud import create_merchant, get_merchant_for_user
from .models import Merchant, PartialMerchant
from .crud import (
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")
@ -43,3 +59,85 @@ async def api_get_merchant(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
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())