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 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

@ -58,8 +58,10 @@ async def m001_initial(db):
CREATE TABLE nostrmarket.zones ( CREATE TABLE nostrmarket.zones (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id 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

@ -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 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')
const nostr = window.NostrTools const nostr = window.NostrTools
@ -12,6 +13,7 @@ const merchant = async () => {
data: function () { data: function () {
return { return {
merchant: {}, merchant: {},
shippingZones: [],
showKeys: false showKeys: false
} }
}, },
@ -40,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

@ -61,12 +61,18 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="row"> <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 <q-btn
@click="showKeys = !showKeys" @click="showKeys = !showKeys"
:label="showKeys ? 'Hide Keys' : 'Show Keys'" :label="showKeys ? 'Hide Keys' : 'Show Keys'"
color="primary" color="primary"
class="float-right"
> >
<q-tooltip> Show Public or Private keys </q-tooltip> <q-tooltip> Show Public or Private keys </q-tooltip>
</q-btn> </q-btn>
@ -103,6 +109,7 @@
<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/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='js/index.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script>
{% endblock %} {% endblock %}

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())