Merge remote-tracking branch 'origin/dev' into sparkwallet
This commit is contained in:
@@ -12,7 +12,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.helpers import (
|
||||
download_url,
|
||||
@@ -777,6 +777,32 @@ class ExtensionDetailsRequest(BaseModel):
|
||||
version: str
|
||||
|
||||
|
||||
class ExtensionReviewsStatus(BaseModel):
|
||||
tag: str
|
||||
avg_rating: float
|
||||
review_count: int
|
||||
|
||||
|
||||
class CreateExtensionReview(BaseModel):
|
||||
tag: str
|
||||
name: str | None = Field(None)
|
||||
rating: int = Field(..., ge=0, le=1000)
|
||||
comment: str | None = Field(None)
|
||||
|
||||
|
||||
class ExtensionReviewPaymentRequest(BaseModel):
|
||||
payment_hash: str
|
||||
payment_request: str
|
||||
|
||||
|
||||
class ExtensionReview(BaseModel):
|
||||
id: str
|
||||
name: str | None = Field(default=None)
|
||||
tag: str | None = Field(default=None)
|
||||
rating: int = Field(default=0, ge=0, le=1000)
|
||||
comment: str | None = Field(default=None)
|
||||
|
||||
|
||||
async def github_api_get(url: str, error_msg: str | None) -> Any:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
if settings.lnbits_ext_github_token:
|
||||
|
||||
@@ -2,8 +2,10 @@ import sys
|
||||
import traceback
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.requests import Request
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_extensions
|
||||
@@ -14,10 +16,14 @@ from lnbits.core.models import (
|
||||
)
|
||||
from lnbits.core.models.extensions import (
|
||||
CreateExtension,
|
||||
CreateExtensionReview,
|
||||
Extension,
|
||||
ExtensionConfig,
|
||||
ExtensionMeta,
|
||||
ExtensionRelease,
|
||||
ExtensionReview,
|
||||
ExtensionReviewPaymentRequest,
|
||||
ExtensionReviewsStatus,
|
||||
InstallableExtension,
|
||||
PayToEnableInfo,
|
||||
ReleasePaymentInfo,
|
||||
@@ -34,6 +40,7 @@ from lnbits.core.services.extensions import (
|
||||
install_extension,
|
||||
uninstall_extension,
|
||||
)
|
||||
from lnbits.db import Page
|
||||
from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
check_account_id_exists,
|
||||
@@ -589,3 +596,49 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
for ext in installable_exts
|
||||
]
|
||||
return extension_data
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/reviews/tags",
|
||||
dependencies=[Depends(check_account_exists)],
|
||||
)
|
||||
async def get_extension_reviews_tags() -> list[ExtensionReviewsStatus]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(settings.lnbits_extensions_reviews_url + "/tags")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [ExtensionReviewsStatus(**item) for item in data]
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/reviews/{ext_id}",
|
||||
dependencies=[Depends(check_account_exists)],
|
||||
)
|
||||
async def get_extension_reviews(ext_id: str, request: Request) -> Page[ExtensionReview]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
query_string = str(request.query_params)
|
||||
resp = await client.get(
|
||||
settings.lnbits_extensions_reviews_url + f"/reviews/{ext_id}?{query_string}"
|
||||
)
|
||||
resp.raise_for_status()
|
||||
reviews = resp.json()
|
||||
return Page(
|
||||
data=[ExtensionReview(**item) for item in reviews["data"]],
|
||||
total=reviews["total"],
|
||||
)
|
||||
|
||||
|
||||
@extension_router.put(
|
||||
"/reviews",
|
||||
dependencies=[Depends(check_account_exists)],
|
||||
)
|
||||
async def create_extension_review(
|
||||
data: CreateExtensionReview,
|
||||
) -> ExtensionReviewPaymentRequest:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
settings.lnbits_extensions_reviews_url + "/reviews", json=data.dict()
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payment_request = resp.json()
|
||||
return ExtensionReviewPaymentRequest(**payment_request)
|
||||
|
||||
@@ -107,6 +107,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
||||
"LNBITS_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
|
||||
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
|
||||
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
|
||||
"LNBITS_EXTENSIONS_REVIEWS_URL": settings.lnbits_extensions_reviews_url,
|
||||
"LNBITS_DENOMINATION": settings.lnbits_denomination,
|
||||
"has_holdinvoice": settings.has_holdinvoice,
|
||||
"LNBITS_NOSTR_CONFIGURED": settings.is_nostr_notifications_configured(),
|
||||
|
||||
@@ -52,6 +52,13 @@ class ExtensionsSettings(LNbitsSettings):
|
||||
lnbits_user_default_extensions: list[str] = Field(default=[])
|
||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
|
||||
lnbits_extensions_reviews_url: str = Field(
|
||||
default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r",
|
||||
description="""
|
||||
URL for the paid reviews.
|
||||
Regular users can view this URL (not secret).
|
||||
""",
|
||||
)
|
||||
lnbits_extensions_manifests: list[str] = Field(
|
||||
default=[
|
||||
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
|
||||
|
||||
+5
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-10
File diff suppressed because one or more lines are too long
@@ -187,6 +187,25 @@ window.localisation.en = {
|
||||
'Only admin accounts can create extensions',
|
||||
admin_only: 'Admin Only',
|
||||
new_version: 'New Version',
|
||||
reviews_url: 'Reviews URL',
|
||||
reviews_url_label: 'Reviews server URL',
|
||||
reviews_url_hint:
|
||||
'Full PaidReviews URL including the settings id (e.g. https://example.com/paidreviews/SETTINGS_ID)',
|
||||
reviews_open: 'View reviews',
|
||||
reviews_leave: 'Leave a review',
|
||||
reviews_name: 'Your name',
|
||||
reviews_comment: 'Your review',
|
||||
reviews_rating: 'Rating',
|
||||
reviews_submit: 'Submit review',
|
||||
reviews_loading: 'Loading reviews...',
|
||||
reviews_refresh: 'Refresh reviews',
|
||||
reviews_error_load: 'Could not load reviews',
|
||||
reviews_url_not_configured: 'Reviews URL not configured',
|
||||
reviews_pay_invoice: 'Pay invoice',
|
||||
reviews_invoice_paid: 'Invoice paid',
|
||||
reviews_invoice_title: 'Pay this invoice to submit your review',
|
||||
reviews_count: 'Reviews',
|
||||
no_reviews: 'No reviews yet',
|
||||
extension_has_free_release: 'Has free releases',
|
||||
extension_has_paid_release: 'Has paid releases',
|
||||
extension_depends_on: 'Depends on:',
|
||||
|
||||
@@ -3,7 +3,34 @@ window.app.component(QrcodeVue)
|
||||
window.app.component('lnbits-extension-rating', {
|
||||
template: '#lnbits-extension-rating',
|
||||
name: 'lnbits-extension-rating',
|
||||
props: ['rating']
|
||||
props: {
|
||||
rating: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
displayRating() {
|
||||
return Math.round((this.rating || 0) * 2) / 2
|
||||
},
|
||||
hasData() {
|
||||
return this.count !== null && this.count !== undefined
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClick() {
|
||||
if (!this.clickable) return
|
||||
this.$emit('click')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
window.app.component('lnbits-fsat', {
|
||||
|
||||
@@ -26,7 +26,63 @@ window.PageExtensions = {
|
||||
selectedRelease: null,
|
||||
uninstallAndDropDb: false,
|
||||
maxStars: 5,
|
||||
paylinkWebsocket: null
|
||||
paylinkWebsocket: null,
|
||||
searchToggle: false,
|
||||
reviewsUrl: null,
|
||||
reviewsDialog: {
|
||||
show: false,
|
||||
extension: null,
|
||||
loading: false,
|
||||
submitting: false,
|
||||
form: {
|
||||
name: '',
|
||||
rating: 0,
|
||||
comment: ''
|
||||
},
|
||||
error: null
|
||||
},
|
||||
reviews: [],
|
||||
reviewsTable: {
|
||||
loading: false,
|
||||
columns: [
|
||||
{
|
||||
name: 'name',
|
||||
align: 'left',
|
||||
label: this.$t('Name'),
|
||||
field: 'name',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'comment',
|
||||
align: 'left',
|
||||
label: this.$t('Comment'),
|
||||
field: 'comment'
|
||||
},
|
||||
{
|
||||
name: 'created_at',
|
||||
align: 'left',
|
||||
label: this.$t('Date'),
|
||||
field: 'created_at'
|
||||
},
|
||||
{
|
||||
name: 'rating',
|
||||
align: 'right',
|
||||
label: 'Rating',
|
||||
field: 'rating'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 5,
|
||||
sortBy: 'created_at',
|
||||
descending: true,
|
||||
page: 1
|
||||
}
|
||||
},
|
||||
paymentDialog: {
|
||||
show: false,
|
||||
invoice: '',
|
||||
hash: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -71,7 +127,6 @@ window.PageExtensions = {
|
||||
// the install logic has been triggered one way or another
|
||||
this.unsubscribeFromPaylinkWs()
|
||||
|
||||
const extension = this.selectedExtension
|
||||
this.selectedExtension.inProgress = true
|
||||
this.showManageExtensionDialog = false
|
||||
release.payment_hash =
|
||||
@@ -285,8 +340,7 @@ window.PageExtensions = {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/${extension.id}/releases`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
`/api/v1/extension/${extension.id}/releases`
|
||||
)
|
||||
|
||||
this.selectedExtensionRepos = data.reduce((repos, release) => {
|
||||
@@ -324,6 +378,9 @@ window.PageExtensions = {
|
||||
if (!detailsLink) {
|
||||
return
|
||||
}
|
||||
// keep a reference to the extension so we can show rating stats
|
||||
this.selectedExtension =
|
||||
this.extensions.find(ext => ext.id === extId) || this.selectedExtension
|
||||
this.selectedExtensionDetails = null
|
||||
this.showExtensionDetailsDialog = true
|
||||
this.slide = 0
|
||||
@@ -332,8 +389,7 @@ window.PageExtensions = {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/${extId}/details?details_link=${detailsLink}`,
|
||||
this.g.user.wallets[0].inkey
|
||||
`/api/v1/extension/${extId}/details?details_link=${detailsLink}`
|
||||
)
|
||||
|
||||
this.selectedExtensionDetails = data
|
||||
@@ -454,7 +510,7 @@ window.PageExtensions = {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${extId}/invoice/install`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
null,
|
||||
{
|
||||
ext_id: extId,
|
||||
archive: release.archive,
|
||||
@@ -470,7 +526,7 @@ window.PageExtensions = {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${extId}/invoice/enable`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
null,
|
||||
{
|
||||
amount
|
||||
}
|
||||
@@ -561,8 +617,7 @@ window.PageExtensions = {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/release/${org}/${repo}/${release.version}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
`/api/v1/extension/release/${org}/${repo}/${release.version}`
|
||||
)
|
||||
release.loaded = true
|
||||
release.is_version_compatible = data.is_version_compatible
|
||||
@@ -587,18 +642,13 @@ window.PageExtensions = {
|
||||
continue
|
||||
}
|
||||
ext.inProgress = true
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/api/v1/extension`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
ext_id: ext.id,
|
||||
archive: ext.latestRelease.archive,
|
||||
source_repo: ext.latestRelease.source_repo,
|
||||
payment_hash: ext.latestRelease.payment_hash,
|
||||
version: ext.latestRelease.version
|
||||
}
|
||||
)
|
||||
await LNbits.api.request('POST', `/api/v1/extension`, null, {
|
||||
ext_id: ext.id,
|
||||
archive: ext.latestRelease.archive,
|
||||
source_repo: ext.latestRelease.source_repo,
|
||||
payment_hash: ext.latestRelease.payment_hash,
|
||||
version: ext.latestRelease.version
|
||||
})
|
||||
count++
|
||||
ext.isAvailable = true
|
||||
ext.isInstalled = true
|
||||
@@ -623,6 +673,163 @@ window.PageExtensions = {
|
||||
})
|
||||
this.showUpdateAllDialog = false
|
||||
},
|
||||
formatAvg(raw) {
|
||||
const val = Number(raw || 0)
|
||||
return Math.round((val / 2 / 100) * 2) / 2
|
||||
},
|
||||
|
||||
async loadReviewStats() {
|
||||
if (!this.reviewsUrl) {
|
||||
console.info('Extension reviews are not configured')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/reviews/tags`
|
||||
)
|
||||
const map = {}
|
||||
data.forEach(stat => {
|
||||
map[stat.tag] = stat
|
||||
})
|
||||
this.extensions.forEach(ext => {
|
||||
ext.reviewStats = map[ext.id] || null
|
||||
})
|
||||
this.filterExtensions(this.searchTerm, this.tab)
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
},
|
||||
async openReviews(extension) {
|
||||
const targetExtension =
|
||||
extension ||
|
||||
(this.selectedExtensionDetails
|
||||
? this.extensions.find(e => e.id === this.selectedExtensionDetails.id)
|
||||
: null)
|
||||
if (!targetExtension) {
|
||||
return
|
||||
}
|
||||
if (!this.reviewsUrl) {
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
message: this.$t('reviews_url_not_configured')
|
||||
})
|
||||
return
|
||||
}
|
||||
this.reviewsDialog.extension = targetExtension
|
||||
this.selectedExtension = extension
|
||||
this.reviewsDialog.show = true
|
||||
await this.getTagReviews()
|
||||
},
|
||||
async getTagReviews(props) {
|
||||
if (!this.reviewsUrl) {
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
message: this.$t('reviews_url_not_configured')
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.reviewsTable.loading = true
|
||||
const params = LNbits.utils.prepareFilterQuery(this.reviewsTable, props)
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/reviews/${this.selectedExtension.id}?${params}`
|
||||
)
|
||||
this.reviews = data.data
|
||||
this.reviewsTable.pagination.rowsNumber = data.total
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
} finally {
|
||||
this.reviewsTable.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
formatReviewDate(val) {
|
||||
if (!val) return ''
|
||||
const numeric = Number(val)
|
||||
if (!Number.isNaN(numeric)) {
|
||||
return this.utils.formatTimestamp(numeric)
|
||||
}
|
||||
return this.utils.formatDate(val)
|
||||
},
|
||||
async submitReview() {
|
||||
if (!this.reviewsDialog.extension || !this.reviewsUrl) {
|
||||
return
|
||||
}
|
||||
this.reviewsDialog.submitting = true
|
||||
try {
|
||||
const payload = {
|
||||
tag: this.reviewsDialog.extension.id,
|
||||
name: this.reviewsDialog.form.name,
|
||||
rating: this.reviewsDialog.form.rating * 100,
|
||||
comment: this.reviewsDialog.form.comment
|
||||
}
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/reviews`,
|
||||
null,
|
||||
payload
|
||||
)
|
||||
if (data.payment_request) {
|
||||
this.openInvoiceDialog(data.payment_request, data.payment_hash)
|
||||
} else {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Review submitted'
|
||||
})
|
||||
this.resetReviewForm()
|
||||
await this.getTagReviews()
|
||||
await this.loadReviewStats()
|
||||
}
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.reviewsDialog.submitting = false
|
||||
}
|
||||
},
|
||||
openInvoiceDialog(invoice, hash) {
|
||||
this.paymentDialog.invoice = invoice
|
||||
this.paymentDialog.hash = hash
|
||||
this.paymentDialog.show = true
|
||||
this.listenForPayment(hash)
|
||||
},
|
||||
resetReviewForm() {
|
||||
this.reviewsDialog.form = {
|
||||
name: '',
|
||||
rating: 0,
|
||||
comment: ''
|
||||
}
|
||||
this.paymentDialog = {
|
||||
show: false,
|
||||
invoice: '',
|
||||
hash: ''
|
||||
}
|
||||
},
|
||||
listenForPayment(hash) {
|
||||
try {
|
||||
const base = new URL(this.reviewsUrl)
|
||||
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
base.pathname = `/api/v1/ws/${hash}`
|
||||
const ws = new WebSocket(base)
|
||||
ws.addEventListener('message', async () => {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: this.$t('reviews_invoice_paid')
|
||||
})
|
||||
this.paymentDialog.show = false
|
||||
this.resetReviewForm()
|
||||
setTimeout(async () => {
|
||||
await this.getTagReviews()
|
||||
}, 1000)
|
||||
|
||||
await this.loadReviewStats()
|
||||
ws.close()
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
},
|
||||
async fetchAllExtensions() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
|
||||
@@ -637,6 +844,7 @@ window.PageExtensions = {
|
||||
async created() {
|
||||
this.extensions = await this.fetchAllExtensions()
|
||||
this.extbuilderEnabled = this.g.user.admin || this.LNBITS_EXT_BUILDER
|
||||
this.reviewsUrl = this.LNBITS_EXTENSIONS_REVIEWS_URL
|
||||
|
||||
if (this.g.user.extensions.length === 0) {
|
||||
this.tab = 'all'
|
||||
@@ -653,6 +861,8 @@ window.PageExtensions = {
|
||||
this.hasNewVersion(ext)
|
||||
)
|
||||
|
||||
await this.loadReviewStats()
|
||||
|
||||
this.filterExtensions(this.searchTerm, this.tab)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,11 +561,32 @@ include('components/lnbits-error.vue') %}
|
||||
</template>
|
||||
|
||||
<template id="lnbits-extension-rating">
|
||||
<div style="margin-bottom: 3px">
|
||||
<q-rating v-model="rating" size="1.5em" :max="5" color="primary"
|
||||
><q-tooltip>
|
||||
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
|
||||
></q-rating>
|
||||
<div class="row items-center q-gutter-xs" style="margin-bottom: 3px">
|
||||
<q-rating
|
||||
:model-value="displayRating"
|
||||
size="1.4em"
|
||||
:max="5"
|
||||
color="primary"
|
||||
icon="star_border"
|
||||
icon-selected="star"
|
||||
icon-half="star_half"
|
||||
readonly
|
||||
:class="clickable ? 'cursor-pointer' : ''"
|
||||
@click="handleClick"
|
||||
>
|
||||
<q-tooltip v-if="!hasData">
|
||||
<span v-text="$t('extension_rating_soon')"></span>
|
||||
</q-tooltip>
|
||||
<q-tooltip v-else-if="clickable">
|
||||
<span v-text="$t('reviews_open')"></span>
|
||||
</q-tooltip>
|
||||
</q-rating>
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-if="count !== null && count !== undefined"
|
||||
>
|
||||
<span v-text="`(${count})`"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -137,6 +137,19 @@
|
||||
:hint="$t('extension_builder_manifest_url_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>
|
||||
<span v-text="$t('reviews_url')"></span>
|
||||
</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_extensions_reviews_url"
|
||||
:label="$t('reviews_url_label')"
|
||||
:hint="$t('reviews_url_hint')"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<q-space></q-space>
|
||||
|
||||
<q-input
|
||||
v-if="$q.screen.gt.sm"
|
||||
:label="$t('search_extensions')"
|
||||
:dense="dense"
|
||||
class="float-right q-pr-xl"
|
||||
@@ -33,6 +34,14 @@
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-btn
|
||||
v-else
|
||||
flat
|
||||
icon="search"
|
||||
@click="searchToggle = !searchToggle"
|
||||
class="q-mr-md"
|
||||
>
|
||||
</q-btn>
|
||||
<q-badge
|
||||
v-if="g.user.admin && updatableExtensions?.length"
|
||||
@click="showUpdateAllDialog = true"
|
||||
@@ -68,6 +77,25 @@
|
||||
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
|
||||
></q-btn>
|
||||
</q-tabs>
|
||||
<div v-if="$q.screen.lt.sm && searchToggle" class="q-pa-sm">
|
||||
<q-input
|
||||
:label="$t('search_extensions')"
|
||||
dense
|
||||
class=""
|
||||
v-model="searchTerm"
|
||||
autofocus
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
v-if="searchTerm !== ''"
|
||||
name="close"
|
||||
@click="searchTerm = ''"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
@@ -135,7 +163,22 @@
|
||||
v-text="extension.name"
|
||||
></div>
|
||||
<div style="justify-content: space-between; display: flex">
|
||||
<lnbits-extension-rating :rating="0" />
|
||||
<lnbits-extension-rating
|
||||
:rating="
|
||||
formatAvg(
|
||||
extension.reviewStats
|
||||
? extension.reviewStats.avg_rating
|
||||
: 0
|
||||
)
|
||||
"
|
||||
:count="
|
||||
extension.reviewStats
|
||||
? extension.reviewStats.review_count
|
||||
: null
|
||||
"
|
||||
:clickable="!!reviewsUrl"
|
||||
@click="openReviews(extension)"
|
||||
/>
|
||||
<q-btn-group size="xs" style="margin: 5px 0">
|
||||
<q-btn
|
||||
v-if="extension.hasFreeRelease"
|
||||
@@ -970,8 +1013,20 @@
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-4 q-pl-sm">
|
||||
<lnbits-extension-rating
|
||||
:rating="0"
|
||||
size="2.5em"
|
||||
:rating="
|
||||
formatAvg(
|
||||
selectedExtension && selectedExtension.reviewStats
|
||||
? selectedExtension.reviewStats.avg_rating
|
||||
: 0
|
||||
)
|
||||
"
|
||||
:count="
|
||||
selectedExtension && selectedExtension.reviewStats
|
||||
? selectedExtension.reviewStats.review_count
|
||||
: null
|
||||
"
|
||||
:clickable="!!reviewsUrl"
|
||||
@click="openReviews(selectedExtension)"
|
||||
></lnbits-extension-rating>
|
||||
<div class="q-mt-md">
|
||||
<b>
|
||||
@@ -1029,6 +1084,182 @@
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="reviewsDialog.show" position="top">
|
||||
<q-card
|
||||
class="q-pa-md lnbits__dialog-card"
|
||||
style="width: 900px; max-width: 95vw"
|
||||
>
|
||||
<q-card-section class="q-gutter-y-sm">
|
||||
<div class="row items-center">
|
||||
<div class="col">
|
||||
<div
|
||||
class="text-h6"
|
||||
v-text="
|
||||
(reviewsDialog.extension && reviewsDialog.extension.name) ||
|
||||
(reviewsDialog.extension && reviewsDialog.extension.id)
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-text="reviewsDialog.extension && reviewsDialog.extension.id"
|
||||
></div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<lnbits-extension-rating
|
||||
:rating="
|
||||
formatAvg(
|
||||
reviewsDialog.extension && reviewsDialog.extension.reviewStats
|
||||
? reviewsDialog.extension.reviewStats.avg_rating
|
||||
: 0
|
||||
)
|
||||
"
|
||||
:count="
|
||||
reviewsDialog.extension && reviewsDialog.extension.reviewStats
|
||||
? reviewsDialog.extension.reviewStats.review_count
|
||||
: null
|
||||
"
|
||||
></lnbits-extension-rating>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section v-if="!reviewsUrl">
|
||||
<div
|
||||
class="text-negative"
|
||||
v-text="$t('reviews_url_not_configured')"
|
||||
></div>
|
||||
</q-card-section>
|
||||
<q-card-section v-else>
|
||||
<div v-if="reviewsDialog.loading" class="row justify-center q-pa-lg">
|
||||
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="reviewsDialog.error" class="text-negative q-mb-md">
|
||||
<span v-text="reviewsDialog.error"></span>
|
||||
</div>
|
||||
|
||||
<q-form @submit="submitReview" class="q-gutter-md">
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="reviewsDialog.form.name"
|
||||
:label="$t('reviews_name')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-rating
|
||||
v-model="reviewsDialog.form.rating"
|
||||
max="10"
|
||||
icon="star_border"
|
||||
icon-selected="star"
|
||||
icon-half="star_half"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
></q-rating>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
type="textarea"
|
||||
autogrow
|
||||
v-model="reviewsDialog.form.comment"
|
||||
:label="$t('reviews_comment')"
|
||||
></q-input>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<q-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
unelevated
|
||||
:loading="reviewsDialog.submitting"
|
||||
:disable="!reviewsDialog.form.rating"
|
||||
:label="$t('reviews_submit')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<div v-if="reviews.length === 0" class="text-grey q-mb-md">
|
||||
<span v-text="$t('no_reviews')"></span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-table
|
||||
:rows="reviews"
|
||||
:columns="reviewsTable.columns"
|
||||
v-model:pagination="reviewsTable.pagination"
|
||||
@request="getTagReviews"
|
||||
:loading="reviewsTable.loading"
|
||||
row-key="name"
|
||||
:filter="reviewsTable.search"
|
||||
>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td key="name" :props="props">
|
||||
<span v-text="props.row.name"></span>
|
||||
</q-td>
|
||||
<q-td key="comment" :props="props">
|
||||
<span v-text="props.row.comment"></span>
|
||||
</q-td>
|
||||
<q-td key="created_at" :props="props">
|
||||
<span
|
||||
v-text="formatReviewDate(props.row.created_at)"
|
||||
></span>
|
||||
</q-td>
|
||||
<q-td key="rating" :props="props">
|
||||
<q-rating
|
||||
class="float-right q-pt-xs"
|
||||
readonly
|
||||
icon="star_border"
|
||||
icon-selected="star"
|
||||
icon-half="star_half"
|
||||
:model-value="formatAvg(props.row.rating)"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
></q-rating>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator v-if="reviews.length"></q-separator>
|
||||
<q-card-section>
|
||||
<q-btn
|
||||
@click="reviewsDialog.show = false"
|
||||
color="grey"
|
||||
outline
|
||||
class="float-right"
|
||||
:label="$t('close')"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="paymentDialog.show" position="top">
|
||||
<q-card class="q-pa-md lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-xl">
|
||||
<lnbits-qrcode
|
||||
:value="paymentDialog.invoice"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
</q-card-section>
|
||||
<q-card-actions align="between">
|
||||
<q-btn v-close-popup flat color="grey" :label="$t('close')"></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="showUpdateAllDialog" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<div class="row">
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "lnbits"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1-rc2"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
||||
|
||||
+53
-16
@@ -8,19 +8,21 @@ import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
try:
|
||||
import psycopg2 # type: ignore
|
||||
except ImportError:
|
||||
print("Please install psycopg2")
|
||||
logger.warning("Please install psycopg2")
|
||||
sys.exit(1)
|
||||
|
||||
sqfolder = settings.lnbits_data_folder
|
||||
db_url = settings.lnbits_database_url
|
||||
|
||||
if db_url is None:
|
||||
print("missing LNBITS_DATABASE_URL")
|
||||
logger.warning("missing LNBITS_DATABASE_URL")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# parse postgres://lnbits:postgres@localhost:5432/lnbits
|
||||
@@ -66,7 +68,7 @@ def check_db_versions(sqdb):
|
||||
postgres.close()
|
||||
connection.close()
|
||||
|
||||
print("Database versions OK, converting")
|
||||
logger.info("Database versions OK, converting")
|
||||
|
||||
|
||||
def fix_id(seq, values):
|
||||
@@ -95,11 +97,11 @@ def insert_to_pg(query, data):
|
||||
cursor.execute(query, d)
|
||||
except Exception as exc:
|
||||
if args.ignore_errors:
|
||||
print(exc)
|
||||
print(f"Failed to insert {d}")
|
||||
logger.error(exc)
|
||||
logger.error(f"Failed to insert {d}")
|
||||
else:
|
||||
print("query:", query)
|
||||
print("data:", d)
|
||||
logger.error("query:", query)
|
||||
logger.error("data:", d)
|
||||
raise ValueError(f"Failed to insert {d}") from exc
|
||||
connection.commit()
|
||||
|
||||
@@ -110,17 +112,20 @@ def insert_to_pg(query, data):
|
||||
def migrate_core(file: str, exclude_tables: list[str] | None = None):
|
||||
if exclude_tables is None:
|
||||
exclude_tables = []
|
||||
print(f"Migrating core: {file}")
|
||||
logger.info(f"Migrating core: {file}")
|
||||
migrate_db(file, "public", exclude_tables)
|
||||
print("✅ Migrated core")
|
||||
logger.info("✅ Migrated core")
|
||||
|
||||
|
||||
def migrate_ext(file: str):
|
||||
filename = os.path.basename(file)
|
||||
schema = filename.replace("ext_", "").split(".")[0]
|
||||
print(f"Migrating ext: {schema} from file {file}")
|
||||
migrate_db(file, schema)
|
||||
print(f"✅ Migrated ext: {schema}")
|
||||
try:
|
||||
logger.info(f"Migrating ext: {schema} from file {file}")
|
||||
migrate_db(file, schema)
|
||||
logger.info(f"✅ Migrated ext: {schema}")
|
||||
except Exception as exc:
|
||||
logger.error(f"🛑 Failed to migrate extension {schema}: {exc}")
|
||||
|
||||
|
||||
def migrate_db(file: str, schema: str, exclude_tables: list[str] | None = None):
|
||||
@@ -139,7 +144,7 @@ def migrate_db(file: str, schema: str, exclude_tables: list[str] | None = None):
|
||||
|
||||
for table in tables:
|
||||
table_name = table[0]
|
||||
print(f"Migrating table {table_name}")
|
||||
logger.info(f"Migrating table {table_name}")
|
||||
# hard coded skip for dbversions (already produced during startup)
|
||||
if table_name == "dbversions":
|
||||
continue
|
||||
@@ -152,7 +157,7 @@ def migrate_db(file: str, schema: str, exclude_tables: list[str] | None = None):
|
||||
data = cursor.execute(f"SELECT * FROM {table_name};").fetchall()
|
||||
|
||||
if len(data) == 0:
|
||||
print(f"🛑 You sneaky dev! Table {table_name} is empty!")
|
||||
logger.warning(f"🛑 You sneaky dev! Table {table_name} is empty!")
|
||||
|
||||
insert_to_pg(q, data)
|
||||
cursor.close()
|
||||
@@ -161,12 +166,44 @@ def migrate_db(file: str, schema: str, exclude_tables: list[str] | None = None):
|
||||
def build_insert_query(schema, table_name, columns):
|
||||
to_columns = ", ".join([f'"{column[1].lower()}"' for column in columns])
|
||||
values = ", ".join([to_column_type(column[2]) for column in columns])
|
||||
on_conflict_update = build_on_conflict_query_statement(schema, table_name, columns)
|
||||
return f"""
|
||||
INSERT INTO {schema}.{table_name}({to_columns})
|
||||
VALUES ({values});
|
||||
VALUES ({values})
|
||||
{on_conflict_update}
|
||||
"""
|
||||
|
||||
|
||||
def build_on_conflict_query_statement(schema, table_name, columns):
|
||||
unique_cols = table_unique_columns(schema, table_name)
|
||||
if len(unique_cols) == 0:
|
||||
return ""
|
||||
return f"""
|
||||
ON CONFLICT ({", ".join([f'"{col}"' for col in unique_cols])})
|
||||
DO UPDATE SET
|
||||
{", ".join([
|
||||
f'"{column[1].lower()}"=EXCLUDED."{column[1].lower()}"'
|
||||
for column in columns
|
||||
])}
|
||||
"""
|
||||
|
||||
|
||||
def table_unique_columns(schema, table_name):
|
||||
cursor = get_postgres_cursor()
|
||||
query = f"""
|
||||
SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid
|
||||
AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = '{schema}.{table_name}'::regclass
|
||||
AND i.indisunique;
|
||||
"""
|
||||
cursor.execute(query)
|
||||
columns = [row[0] for row in cursor.fetchall()]
|
||||
cursor.close()
|
||||
return columns
|
||||
|
||||
|
||||
def to_column_type(column_type):
|
||||
if column_type == "TIMESTAMP":
|
||||
return "to_timestamp(%s)"
|
||||
@@ -218,7 +255,7 @@ parser.add_argument(
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("Selected path: ", args.sqlite_path)
|
||||
logger.info("Selected path: ", args.sqlite_path)
|
||||
|
||||
if os.path.isdir(args.sqlite_path):
|
||||
exclude_tables = ["dbversions"]
|
||||
|
||||
Reference in New Issue
Block a user