[feat] Payment labels (#3537)

This commit is contained in:
Vlad Stan 2025-11-21 10:33:53 +02:00 committed by GitHub
parent 3ccefb70fa
commit 7c7a04da9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 930 additions and 39 deletions

View file

@ -292,6 +292,7 @@ async def create_payment(
fee=-abs(data.fee),
tag=extra.get("tag", None),
extra=extra,
labels=data.labels or [],
)
await (conn or db).insert("apipayments", payment)

View file

@ -778,3 +778,11 @@ async def m037_create_assets_table(db: Connection):
);
"""
)
async def m038_add_labels_for_payments(db: Connection):
await db.execute(
"""
ALTER TABLE apipayments ADD COLUMN labels TEXT
"""
)

View file

@ -59,6 +59,7 @@ class CreatePayment(BaseModel):
expiry: datetime | None = None
webhook: str | None = None
fee: int = 0
labels: list[str] | None = None
class Payment(BaseModel):
@ -81,6 +82,7 @@ class Payment(BaseModel):
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = []
extra: dict = {}
def __init__(self, **data):
@ -184,7 +186,15 @@ class Payment(BaseModel):
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
__search_fields__ = [
"memo",
"amount",
"wallet_id",
"tag",
"status",
"time",
"labels",
]
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
@ -198,6 +208,7 @@ class PaymentFilters(FilterModel):
preimage: str | None
payment_hash: str | None
wallet_id: str | None
labels: str | None
class PaymentDataPoint(BaseModel):
@ -272,6 +283,7 @@ class CreateInvoice(BaseModel):
bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
labels: list[str] = []
@validator("payment_hash")
def check_hex(cls, v):
@ -320,3 +332,7 @@ class CancelInvoice(BaseModel):
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class UpdatePaymentLabels(BaseModel):
labels: list[str] = []

View file

@ -12,6 +12,7 @@ from lnbits.db import FilterModel
from lnbits.helpers import (
is_valid_email_address,
is_valid_external_id,
is_valid_label,
is_valid_pubkey,
is_valid_username,
)
@ -36,6 +37,14 @@ class WalletInviteRequest(BaseModel):
to_wallet_name: str
class UserLabel(BaseModel):
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
description: str | None = Field(default=None, max_length=250)
color: str | None = Field(
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
) # e.g., "#RRGGBB"
class UserExtra(BaseModel):
email_verified: bool | None = False
first_name: str | None = None
@ -55,6 +64,8 @@ class UserExtra(BaseModel):
wallet_invite_requests: list[WalletInviteRequest] = []
labels: list[UserLabel] = []
def add_wallet_invite_request(
self,
request_id: str,
@ -78,6 +89,18 @@ class UserExtra(BaseModel):
return invite
return None
def validate_labels(self):
seen_labels = set()
for label in self.labels:
if not label.name:
raise ValueError("Label name cannot be empty.")
# apply the same rule for labels as for usernames
if not is_valid_label(label.name):
raise ValueError(f"Invalid label name: {label.name}")
if label.name in seen_labels:
raise ValueError(f"Duplicate label name: {label.name}")
seen_labels.add(label.name)
def remove_wallet_invite_request(
self,
request_id: str,
@ -202,6 +225,8 @@ class Account(BaseModel):
if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.")
self.extra.validate_labels()
class AccountOverview(Account):
transaction_count: int | None = 0

View file

@ -61,6 +61,7 @@ async def pay_invoice(
extra: dict | None = None,
description: str = "",
tag: str = "",
labels: list[str] | None = None,
conn: Connection | None = None,
) -> Payment:
if settings.lnbits_only_allow_incoming_payments:
@ -91,6 +92,7 @@ async def pay_invoice(
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
labels=labels,
)
payment = await _pay_invoice(wallet.source_wallet_id, create_payment_model, conn)
@ -207,6 +209,7 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
webhook=data.webhook,
internal=data.internal,
payment_hash=data.payment_hash,
labels=data.labels,
)
if data.lnurl_withdraw:
@ -246,6 +249,7 @@ async def create_invoice(
webhook: str | None = None,
internal: bool | None = False,
payment_hash: str | None = None,
labels: list[str] | None = None,
conn: Connection | None = None,
) -> Payment:
if not amount > 0:
@ -329,6 +333,7 @@ async def create_invoice(
extra=extra,
webhook=webhook,
fee=invoice_response.fee_msat or 0,
labels=labels,
)
payment = await create_payment(

View file

@ -25,6 +25,7 @@ from lnbits.core.models.users import (
UpdateAccessControlList,
)
from lnbits.core.services import create_user_account
from lnbits.core.services.users import update_user_account
from lnbits.decorators import access_token_payload, check_user_exists
from lnbits.helpers import (
create_access_token,
@ -422,15 +423,6 @@ async def update(
) -> User | None:
if data.user_id != user.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
if not account:
@ -441,7 +433,7 @@ async def update(
if data.extra:
account.extra = data.extra
await update_account(account)
await update_user_account(account)
return await get_user_from_account(account)

View file

@ -183,7 +183,7 @@ async def manifest(request: Request, usr: str):
"src": (
settings.lnbits_custom_logo
if settings.lnbits_custom_logo
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@main/docs/logos/lnbits.png"
else "images/logos/lnbits.png"
),
"sizes": "512x512",
"type": "image/png",

View file

@ -15,7 +15,9 @@ from lnbits import bolt11
from lnbits.core.crud.payments import (
get_payment_count_stats,
get_wallets_stats,
update_payment,
)
from lnbits.core.crud.users import get_account
from lnbits.core.models import (
CancelInvoice,
CreateInvoice,
@ -32,6 +34,7 @@ from lnbits.core.models import (
SettleInvoice,
SimpleStatus,
)
from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import User
from lnbits.db import Filters, Page
from lnbits.decorators import (
@ -247,6 +250,7 @@ async def api_payments_create(
wallet_id=wallet_id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
labels=invoice_data.labels,
)
return payment
@ -260,6 +264,27 @@ async def api_payments_create(
return await create_payment_request(wallet_id, invoice_data)
@payment_router.put("/{payment_hash}/labels")
async def api_update_payment_labels(
payment_hash: str,
data: UpdatePaymentLabels,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> SimpleStatus:
payment = await get_standalone_payment(payment_hash, wallet_id=key_type.wallet.id)
if payment is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
account = await get_account(key_type.wallet.user)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account does not exist.")
# only keep labels that belong to the user
user_label_names = [label.name for label in account.extra.labels]
payment.labels = [label for label in data.labels if label in user_label_names]
await update_payment(payment)
return SimpleStatus(success=True, message="Payment labels updated.")
@payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice)

View file

@ -431,6 +431,8 @@ class Operator(Enum):
INCLUDE = "in"
EXCLUDE = "ex"
LIKE = "like"
EVERY = "every"
ANY = "any"
@property
def as_sql(self):
@ -450,7 +452,7 @@ class Operator(Enum):
return ">="
elif self == Operator.LE:
return "<="
elif self == Operator.LIKE:
elif self in {Operator.LIKE, Operator.EVERY, Operator.ANY}:
return "LIKE"
else:
raise ValueError("Unknown SQL Operator")
@ -481,6 +483,8 @@ class Filter(BaseModel, Generic[TFilterModel]):
def parse_query(
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
):
if i > 1000 or len(raw_values) > 1000:
raise ValueError("Too many filter values")
# Key format:
# key[operator]
# e.g. name[eq]
@ -497,11 +501,14 @@ class Filter(BaseModel, Generic[TFilterModel]):
if field in model.__fields__:
compare_field = model.__fields__[field]
values: dict = {}
for raw_value in raw_values:
if op in {Operator.EVERY, Operator.ANY}:
raw_values = [v for rv in raw_values for v in rv.split(",")]
for index, raw_value in enumerate(raw_values):
validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors:
raise ValidationError(errors=[errors], model=model)
values[f"{field}__{i}"] = validated
values[f"{field}__{index}"] = validated
else:
raise ValueError("Unknown filter field")
@ -514,10 +521,16 @@ class Filter(BaseModel, Generic[TFilterModel]):
clean_key = key.split("__")[0]
if self.model and self.model.__fields__[clean_key].type_ == datetime:
placeholder = compat_timestamp_placeholder(key)
else:
placeholder = f":{key}"
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
return " OR ".join(stmt)
else:
stmt.append(f"{clean_key} {self.op.as_sql} :{key}")
if self.op == Operator.EVERY:
statement = " AND ".join(stmt)
else:
statement = " OR ".join(stmt)
return f"({statement})"
class Filters(BaseModel, Generic[TFilterModel]):
@ -593,6 +606,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
for key, value in page_filter.values.items():
if page_filter.op == Operator.LIKE:
values[key] = f"%{value}%"
elif page_filter.op in {Operator.EVERY, Operator.ANY}:
values[key] = f"""%"{value}"%"""
else:
values[key] = value
if self.search and self.model:

View file

@ -201,6 +201,11 @@ def is_valid_username(username: str) -> bool:
return re.fullmatch(username_regex, username) is not None
def is_valid_label(label: str) -> bool:
label_regex = r"([A-Za-z0-9 ._-]{1,100}$)"
return re.fullmatch(label_regex, label) is not None
def is_valid_external_id(external_id: str) -> bool:
if len(external_id) > 256:
return False

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -25,12 +25,14 @@ window.localisation.en = {
reconnect: 'Reconnect',
open_channel: 'Open Channel',
open: 'Open',
clear: 'Clear',
close_channel: 'Close Channel',
close: 'Close',
restart: 'Restart server',
image_library: 'Image Library',
save: 'Save',
save_tooltip: 'Save your changes',
must_save: 'You have unsaved changes',
credit_debit: 'Credit / Debit',
credit_hint: 'Press Enter to credit/debit wallet (negative values allowed)',
credit_label: '{denomination} to credit/debit',
@ -694,6 +696,7 @@ window.localisation.en = {
view_list: 'View wallets as list',
view_column: 'View wallets as rows',
filter_payments: 'Filter payments',
filter_labels: 'Filter labels',
filter_date: 'Filter by date',
websocket_example: 'Websocket example',
secret_key: 'Secret Key',
@ -709,5 +712,15 @@ window.localisation.en = {
paid: 'Paid',
funding_source_retries: 'Max Retries',
funding_source_retries_desc:
'Maximum number of retries for funding sources, before it falls back to VoidWallet.'
'Maximum number of retries for funding sources, before it falls back to VoidWallet.',
add_label: 'Add Label',
label: 'Label',
labels: 'Labels',
no_labels_defined: 'No labels defined yet',
manage_labels: 'Manage Labels',
update_label: 'Update Label',
delete_label: 'Delete Label',
add_remove_labels: 'Add or Remove Labels',
payment_labels_updated: 'Payment labels updated',
color: 'Color'
}

View file

@ -94,7 +94,8 @@ window.LNbits = {
webhook: data.webhook,
webhook_status: data.webhook_status,
fiat_amount: data.fiat_amount,
fiat_currency: data.fiat_currency
fiat_currency: data.fiat_currency,
labels: data.labels
}
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)

View file

@ -0,0 +1,36 @@
window.app.component('lnbits-label-selector', {
template: '#lnbits-label-selector',
props: ['labels'],
mixins: [window.windowMixin],
data() {
return {
labelFilter: '',
localLabels: []
}
},
methods: {
toggleLabel(label) {
const hasLabel = this.localLabels.includes(label.name)
if (hasLabel) {
const index = this.localLabels.indexOf(label.name)
if (index !== -1) {
this.localLabels.splice(index, 1)
}
} else {
this.localLabels.push(label.name)
}
},
saveLabels() {
this.$emit('update:labels', this.localLabels)
},
clearLabels() {
this.localLabels = []
this.saveLabels()
}
},
created() {
this.localLabels = [...this.labels]
}
})

View file

@ -121,7 +121,9 @@ window.app.component('lnbits-payment-list', {
show: false,
payment: null,
preimage: null
}
},
selectedPayment: null,
filterLabels: []
}
},
computed: {
@ -161,12 +163,26 @@ window.app.component('lnbits-payment-list', {
this.fetchPayments()
},
searchByLabels(labels) {
if (!labels || labels.length === 0) {
this.clearLabelSeach()
return
}
this.filterLabels = labels
this.paymentsTable.filter['labels[every]'] = labels
this.fetchPayments()
},
clearDateSeach() {
this.searchDate = {from: null, to: null}
delete this.paymentFilter['time[ge]']
delete this.paymentFilter['time[le]']
this.fetchPayments()
},
clearLabelSeach() {
this.filterLabels = []
delete this.paymentsTable.filter['labels[every]']
this.fetchPayments()
},
fetchPayments(props) {
const params = LNbits.utils.prepareFilterQuery(
this.paymentsTable,
@ -354,6 +370,47 @@ window.app.component('lnbits-payment-list', {
paymentFilter['amount[le]'] = 0
}
this.paymentFilter = paymentFilter
},
async savePaymentLabels(labels) {
if (!this.selectedPayment) {
Quasar.Notify.create({
type: 'warning',
message: 'No payment selected'
})
return
}
try {
await LNbits.api.request(
'PUT',
`/api/v1/payments/${this.selectedPayment.payment_hash}/labels`,
this.g.wallet.adminkey,
{
labels: labels
}
)
const payment = this.payments.find(
p => p.checking_id === this.selectedPayment.checking_id
)
if (payment) {
payment.labels = [...labels]
}
Quasar.Notify.create({
type: 'positive',
message: this.$t('payment_labels_updated')
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
isLightColor(color) {
try {
return Quasar.colors.luminosity(color) > 0.5
} catch (e) {
console.warning(e)
return false
}
}
},
watch: {

View file

@ -4,6 +4,7 @@ window.PageAccount = {
data() {
return {
user: null,
untouchedUser: null,
hasUsername: false,
showUserId: false,
themeOptions: [
@ -145,6 +146,47 @@ window.PageAccount = {
nostr: {
identifier: ''
}
},
labels: [],
labelsDialog: {
show: false,
data: {
name: '',
description: '',
color: '#000000'
}
},
labelsTable: {
loading: false,
columns: [
{
name: 'actions',
align: 'left'
},
{
name: 'name',
align: 'left',
label: this.$t('Name'),
field: 'name',
sortable: true
},
{
name: 'description',
align: 'left',
label: this.$t('description'),
field: 'description'
},
{
name: 'color',
align: 'left',
label: this.$t('color'),
field: 'color'
}
],
pagination: {
rowsPerPage: 6,
page: 1
}
}
}
},
@ -159,6 +201,11 @@ window.PageAccount = {
}
}
},
computed: {
isUserTouched() {
return JSON.stringify(this.user) !== JSON.stringify(this.untouchedUser)
}
},
methods: {
activeLanguage(lang) {
return window.i18n.global.locale === lang
@ -181,6 +228,7 @@ window.PageAccount = {
}
)
this.user = data
this.untouchedUser = JSON.parse(JSON.stringify(data))
this.hasUsername = !!data.username
Quasar.Notify.create({
type: 'positive',
@ -220,6 +268,7 @@ window.PageAccount = {
}
)
this.user = data
this.untouchedUser = JSON.parse(JSON.stringify(data))
this.hasUsername = !!data.username
this.credentialsData.show = false
Quasar.Notify.create({
@ -242,6 +291,7 @@ window.PageAccount = {
}
)
this.user = data
this.untouchedUser = JSON.parse(JSON.stringify(data))
this.hasUsername = !!data.username
this.credentialsData.show = false
this.$q.notify({
@ -558,6 +608,73 @@ window.PageAccount = {
copyAssetLinkToClipboard(asset) {
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/binary`
this.copyText(assetUrl)
},
addUserLabel() {
if (!this.labelsDialog.data.name) {
this.$q.notify({
type: 'warning',
message: 'Name is required.'
})
return
}
if (!this.labelsDialog.data.color) {
this.$q.notify({
type: 'warning',
message: 'Color is required.'
})
return
}
this.user.extra.labels = this.user.extra.labels || []
const duplicate = this.user.extra.labels.find(
label => label.name === this.labelsDialog.data.name
)
if (duplicate) {
this.$q.notify({
type: 'warning',
message: 'A label with this name already exists.'
})
return
}
this.user.extra.labels.unshift({...this.labelsDialog.data})
this.labelsDialog.show = false
return true
},
openAddLabelDialog() {
this.labelsDialog.data = {
name: '',
description: '',
color: '#000000'
}
this.labelsDialog.show = true
},
openEditLabelDialog(label) {
this.labelsDialog.data = {
name: label.name,
description: label.description,
color: label.color
}
this.labelsDialog.show = true
},
updateUserLabel() {
const label = this.labelsDialog.data
const existingLabels = JSON.parse(JSON.stringify(this.user.extra.labels))
this.user.extra.labels = this.user.extra.labels.filter(
l => l.name !== label.name
)
const labelUpdated = this.addUserLabel()
if (!labelUpdated) {
this.user.extra.labels = existingLabels
}
this.labelsDialog.show = false
},
deleteUserLabel(label) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this label?')
.onOk(() => {
this.user.extra.labels = this.user.extra.labels.filter(
l => l.name !== label.name
)
})
}
},
@ -565,6 +682,7 @@ window.PageAccount = {
try {
const {data} = await LNbits.api.getAuthenticatedUser()
this.user = data
this.untouchedUser = JSON.parse(JSON.stringify(data))
this.hasUsername = !!data.username
if (!this.user.extra) this.user.extra = {}
} catch (e) {

View file

@ -84,6 +84,7 @@
"js/components/lnbits-manage-wallet-list.js",
"js/components/lnbits-language-dropdown.js",
"js/components/lnbits-payment-list.js",
"js/components/lnbits-label-selector.js",
"js/components/extension-settings.js",
"js/components/data-fields.js",
"js/components.js",

View file

@ -21,6 +21,7 @@ include('components/lnbits-manage-wallet-list.vue') %} {%
include('components/lnbits-language-dropdown.vue') %} {%
include('components/lnbits-payment-list.vue') %} {%
include('components/lnbits-wallet-new.vue') %} {%
include('components/lnbits-label-selector.vue') %} {%
include('components/lnbits-wallet-api-docs.vue') %} {%
include('components/lnbits-wallet-share.vue') %} {%
include('components/lnbits-wallet-charts.vue') %}

View file

@ -0,0 +1,107 @@
<template id="lnbits-label-selector">
<div v-if="g.user.extra.labels?.length">
<q-item header>
<q-input
v-model="labelFilter"
:label="$t('filter_labels')"
class="full-width"
filled
dense
autofocus
>
</q-input>
</q-item>
<q-separator></q-separator>
<q-scroll-area style="height: 230px; max-width: 300px">
<div v-for="label in g.user.extra.labels" :key="label.name">
<q-item
v-if="
!labelFilter ||
label.name.toLowerCase().includes(labelFilter.toLowerCase())
"
clickable
v-ripple
>
<q-item-section avatar top>
<q-checkbox
:model-value="localLabels.includes(label.name)"
@click="toggleLabel(label)"
dense
>
</q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label lines="1"
><span v-text="label.name"></span
></q-item-label>
<q-item-label caption
><span v-text="label.description"></span
></q-item-label>
</q-item-section>
<q-item-section side>
<q-badge
class="q-pa-sm"
size="xs"
rounded
:style="{
backgroundColor: label.color,
color: 'white'
}"
>
<span v-text="label.color"></span>
</q-badge>
</q-item-section>
</q-item>
</div>
</q-scroll-area>
<q-item footer>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-none"
icon="highlight_off"
@click="clearLabels"
>
<q-tooltip>
<span v-text="$t('clear')"></span>
</q-tooltip>
</q-btn>
<q-btn
v-close-popup
flat
href="/account#labels"
color="grey"
icon="settings"
class="q-ml-auto float-right"
>
<q-tooltip>
<span v-text="$t('manage_labels')"></span>
</q-tooltip>
</q-btn>
<q-btn
v-close-popup
flat
color="primary"
class="q-ml-none"
:label="$t('ok')"
@click="saveLabels"
>
<q-tooltip>
<span v-text="$t('manage_labels')"></span>
</q-tooltip>
</q-btn>
</q-item>
</div>
<div v-else class="q-pa-md">
<p v-text="$t('no_labels_defined')"></p>
<q-btn
flat
href="/account#labels"
color="primary"
:label="$t('manage_labels')"
></q-btn>
</div>
</template>

View file

@ -22,7 +22,12 @@
</q-input>
</div>
<div class="gt-sm col-auto">
<q-btn icon="event" flat color="grey">
<q-btn icon="event" flat color="grey" class="q-pa-sm">
<q-badge
v-if="searchDate?.to || searchDate?.from"
color="primary"
floating
></q-badge>
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="searchDate" mask="YYYY-MM-DD" range />
<div class="row">
@ -48,19 +53,30 @@
</div>
</div>
</q-popup-proxy>
<q-badge
v-if="searchDate?.to || searchDate?.from"
class="q-mt-lg q-mr-md"
color="primary"
rounded
floating
style="border-radius: 6px"
></q-badge>
<q-tooltip>
<span v-text="$t('filter_date')"></span>
</q-tooltip>
</q-btn>
<q-btn color="grey" icon="filter_alt" flat>
<q-btn icon="local_offer" class="q-pa-sm" flat color="grey">
<q-badge
v-if="filterLabels.length"
color="primary"
size="xs"
floating
v-text="filterLabels.length"
></q-badge>
<q-tooltip>
<span v-text="$t('label_filter')"></span>
</q-tooltip>
<q-popup-edit class="text-white q-pa-none">
<lnbits-label-selector
:labels="filterLabels"
@update:labels="searchByLabels"
></lnbits-label-selector>
</q-popup-edit>
</q-btn>
<q-btn color="grey" icon="filter_alt" class="q-pa-sm" flat>
<q-menu>
<q-item dense>
<q-checkbox
@ -109,7 +125,7 @@
persistent
icon="archive"
split
class="q-mr-sm"
class="q-mr-sm q-pa-sm"
color="grey"
@click="exportCSV(false)"
>
@ -263,6 +279,42 @@
<span class="text-grey-5" v-text="props.row.dateFrom"></span>
<q-tooltip><span v-text="props.row.date"></span></q-tooltip>
</i>
<q-icon
@click="selectedPayment = props.row"
name="local_offer"
color="grey"
class="q-ml-sm cursor-pointer"
size="xs"
>
<q-tooltip>
<span v-text="$t('add_remove_labels')"></span>
</q-tooltip>
<q-popup-edit class="text-white q-pa-none">
<lnbits-label-selector
:labels="props.row.labels"
@update:labels="savePaymentLabels"
></lnbits-label-selector>
</q-popup-edit>
</q-icon>
<template v-for="label in g.user.extra.labels" :key="label.name">
<q-badge
v-if="props.row.labels.includes(label.name)"
@click="searchByLabels([label.name])"
:style="{
backgroundColor: label.color,
color: isLightColor(label.color) ? 'black' : 'white'
}"
class="q-ml-sm cursor-pointer"
size="xs"
dense
rounded
>
<span v-text="label.name"></span>
<q-tooltip>
<span v-text="label.description || label.name"></span>
</q-tooltip>
</q-badge>
</template>
</q-td>
<q-td
auto-width

View file

@ -6,8 +6,17 @@
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn @click="updateAccount" unelevated color="primary">
<q-badge
v-if="isUserTouched"
color="negative"
size="xs"
floating
></q-badge>
<span v-text="$t('update_account')"></span>
</q-btn>
<q-badge v-if="isUserTouched" class="q-ml-sm" color="primary">
<span v-text="$t('must_save')"></span>
</q-badge>
</div>
</div>
</div>
@ -20,6 +29,7 @@
<q-card>
<q-splitter>
<template v-slot:before>
<!-- todo: small screen as in settings -->
<q-tabs v-model="tab" vertical active-color="primary">
<q-tab
name="user"
@ -72,6 +82,16 @@
><span v-text="$t('assets')"></span
></q-tooltip>
</q-tab>
<q-tab
name="labels"
icon="local_offer"
:label="$q.screen.gt.sm ? $t('labels') : ''"
@update="val => (tab = val.name)"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('labels')"></span
></q-tooltip>
</q-tab>
</q-tabs>
</template>
<template v-slot:after>
@ -1027,6 +1047,92 @@
</q-table>
</q-card-section>
</q-tab-panel>
<q-tab-panel name="labels">
<q-card-section>
<div class="row">
<div class="col-md-2 col-sm-12">
<q-btn
@click="openAddLabelDialog()"
:label="$t('add_label')"
color="primary"
class="full-width"
></q-btn>
</div>
<div class="col-md-1 col-sm-12"></div>
<div class="col-md-9 col-sm-12">
<q-input
:label="$t('search')"
dense
class="full-width q-pb-xl"
v-model="labelsTable.search"
>
<template v-slot:before>
<q-icon name="search"> </q-icon>
</template>
<template v-slot:append>
<q-icon
v-if="labelsTable.search !== ''"
name="close"
@click="labelsTable.search = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-table
:rows="user.extra.labels"
:columns="labelsTable.columns"
v-model:pagination="labelsTable.pagination"
:loading="labelsTable.loading"
row-key="name"
:filter="labelsTable.search"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="actions" :props="props">
<q-btn
@click="openEditLabelDialog(props.row)"
dense
flat
icon="edit"
color="primary"
></q-btn>
<q-btn
@click="deleteUserLabel(props.row)"
dense
flat
icon="delete"
color="negative"
class="q-ml-md"
></q-btn>
</q-td>
<q-td key="name" :props="props">
<span v-text="props.row.name"></span>
</q-td>
<q-td key="description" :props="props">
<span v-text="props.row.description"></span>
</q-td>
<q-td key="color" :props="props">
<q-badge
class="q-pa-sm"
:style="{
backgroundColor: props.row.color,
color: 'white'
}"
>
<span v-text="props.row.color"></span>
</q-badge>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-tab-panel>
</q-tab-panels>
</q-scroll-area>
</template>
@ -1052,11 +1158,6 @@
</div>
</div>
<div class="row q-mt-lg">
<q-btn
@click="runPasswordGuardedFunction()"
:label="$t('ok')"
color="primary"
></q-btn>
<q-btn
v-close-popup
flat
@ -1064,6 +1165,11 @@
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
<q-btn
@click="runPasswordGuardedFunction()"
:label="$t('ok')"
color="primary"
></q-btn>
</div>
</q-card>
</q-dialog>
@ -1167,4 +1273,81 @@
</div>
</q-card>
</q-dialog>
<q-dialog v-model="labelsDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong v-text="$t('label')"></strong>
<div class="row q-mt-md q-col-gutter-md">
<div class="col-12">
<q-input
v-model="labelsDialog.data.name"
dense
filled
:label="$t('name')"
>
</q-input>
</div>
<div class="col-12">
<q-input
v-model="labelsDialog.data.description"
dense
filled
type="textarea"
rows="3"
:label="$t('description')"
>
</q-input>
</div>
<div class="col-12">
<q-input
v-model="labelsDialog.data.color"
filled
dense
class="my-input"
>
<template v-slot:append>
<q-icon name="colorize" class="cursor-pointer">
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
>
<q-color
v-model="labelsDialog.data.color"
default-view="palette"
/>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
<q-btn
v-if="
user.extra.labels.some(
label => label.name === labelsDialog.data.name
)
"
@click="updateUserLabel()"
:disable="!labelsDialog.data.name || !labelsDialog.data.color"
:label="$t('update_label')"
color="primary"
></q-btn>
<q-btn
v-else
@click="addUserLabel()"
:disable="!labelsDialog.data.name || !labelsDialog.data.color"
:label="$t('add_label')"
color="primary"
></q-btn>
</div>
</q-card>
</q-dialog>
</template>

View file

@ -136,6 +136,7 @@
"js/components/lnbits-manage-wallet-list.js",
"js/components/lnbits-language-dropdown.js",
"js/components/lnbits-payment-list.js",
"js/components/lnbits-label-selector.js",
"js/components/extension-settings.js",
"js/components/data-fields.js",
"js/components.js",

View file

@ -1,12 +1,16 @@
import hashlib
from json import JSONDecodeError
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
import shortuuid
from pytest_mock.plugin import MockerFixture
from lnbits import bolt11
from lnbits.core.models import CreateInvoice, Payment
from lnbits.core.models.users import Account, UserExtra, UserLabel
from lnbits.core.services.users import create_user_account
from lnbits.core.views.payment_api import api_payment
from lnbits.fiat.base import FiatInvoiceResponse
from lnbits.settings import Settings
@ -789,3 +793,176 @@ async def test_api_payments_pay_lnurl(client, adminkey_headers_from):
)
assert response.status_code == 400
assert "value_error.url.scheme" in response.json()["detail"]
################################ Labels ################################
@pytest.mark.anyio
async def test_api_search_payment_labels(client):
tiny_id = shortuuid.uuid()[:8]
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"u{tiny_id}",
extra=UserExtra(
labels=[
UserLabel(name="label A", color="#FF0000"),
UserLabel(name="label B", color="#00FF00"),
]
),
)
)
assert len(user.extra.labels) == 2
adminkey = user.wallets[0].adminkey
payments_headers = {
"X-Api-Key": adminkey,
"Content-type": "application/json",
}
payment_count = 10
await _create_some_payments(payment_count, client, payments_headers)
# search payments by label A
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 2
for payment in data["data"]:
assert "label A" in payment["labels"]
# search payments by label B
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label B"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 3
for payment in data["data"]:
assert "label B" in payment["labels"]
# search payments by label C
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label C"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 5
for payment in data["data"]:
assert "label C" in payment["labels"]
# search payments by label A and B
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A", "label B"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 6
for payment in data["data"]:
assert "label A" in payment["labels"]
assert "label B" in payment["labels"]
# search payments for random label D (no payments)
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label D"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == 0
# search payments with no label filter (all payments)
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": []},
headers=payments_headers,
)
assert response.is_success
all_payments = response.json()
assert all_payments["total"] == payment_count
no_label_a_payment = next(
(
payment
for payment in all_payments["data"]
if "label A" not in payment["labels"]
),
None,
)
assert no_label_a_payment is not None
payment_hash = no_label_a_payment["payment_hash"]
response = await client.put(
f"/api/v1/payments/{payment_hash}/labels",
headers=payments_headers,
json={"labels": ["label A"]},
)
# search payments by label A after update
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 2 + 1 # one more after update
for payment in data["data"]:
assert "label A" in payment["labels"]
# remove label A from all payments
for payment in all_payments["data"]:
payment_hash = payment["payment_hash"]
response = await client.put(
f"/api/v1/payments/{payment_hash}/labels",
headers=payments_headers,
json={"labels": []},
)
# search payments by label A (none should have it now)
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == 0
async def _create_some_payments(payment_count: int, client, payments_headers):
payment_count = 10
for index in range(1, payment_count + 1):
labels = []
if index % 2 == 0:
labels.append("label A")
if index % 3 == 0:
labels.append("label B")
if index % 5 == 0:
# User does not have this label, but will be added to the payment.
labels.append("label C")
response = await client.post(
"/api/v1/payments",
headers=payments_headers,
json={
"out": False,
"amount": 1000 + index,
"memo": f"payment {index}",
"labels": labels,
},
)
assert response.is_success
data = response.json()
assert data["labels"] == labels
return payment_count

View file

@ -24,7 +24,9 @@ from lnbits.core.models.users import (
EndpointAccess,
LoginUsr,
UpdateAccessControlList,
UpdateUser,
UserAcls,
UserLabel,
)
from lnbits.core.services.users import create_user_account
from lnbits.core.views.user_api import api_users_reset_password
@ -1999,3 +2001,48 @@ async def test_api_delete_user_api_token_missing_token_id(
json=delete_token_request.dict(),
)
assert response.status_code == 200, "Does noting if token not found."
################################ Labels ################################
@pytest.mark.anyio
async def test_api_update_user_labels(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
user = await create_user_account()
assert user.extra.labels == []
user.extra.labels = [
UserLabel(name="label 01", color="#FF0000"),
UserLabel(name="label 02", color="#00FF00"),
]
data = UpdateUser(user_id=user.id, username=f"u{tiny_id}", extra=user.extra)
assert data.extra
response = await http_client.put(
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
)
assert response.status_code == 200
user_data = response.json()
assert len(user_data["extra"]["labels"]) == 2
assert user_data["extra"]["labels"][0]["name"] == "label 01"
assert user_data["extra"]["labels"][0]["color"] == "#FF0000"
assert user_data["extra"]["labels"][1]["name"] == "label 02"
assert user_data["extra"]["labels"][1]["color"] == "#00FF00"
data.extra.labels = []
response = await http_client.put(
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
)
assert response.status_code == 200
user_data = response.json()
assert len(user_data["extra"]["labels"]) == 0
json_data = data.dict()
json_data["extra"] = {"labels": [{"name": "label + 01", "color": "#FF0000"}]}
response = await http_client.put(
"/api/v1/auth/update?usr=" + user.id, json=json_data
)
assert response.status_code == 400
data = response.json()
assert (
"""string does not match regex "([A-Za-z0-9 ._-]{1,100}$)""" in data["detail"]
)

View file

@ -16,6 +16,7 @@ from lnbits.core.crud import (
get_payment,
update_payment,
)
from lnbits.core.crud.users import get_user_from_account
from lnbits.core.models import Account, CreateInvoice, PaymentState, User
from lnbits.core.models.users import UpdateSuperuserPassword
from lnbits.core.services import create_user_account, update_wallet_balance
@ -114,6 +115,10 @@ async def user_alan():
@pytest.fixture(scope="session")
async def admin_user():
username = "admin"
account = await get_account_by_username(username)
if account:
return await get_user_from_account(account)
account = Account(
id=ADMIN_USER_ID,
email=f"{username}@lnbits.com",