[feat] Payment labels (#3537)
This commit is contained in:
parent
3ccefb70fa
commit
7c7a04da9d
26 changed files with 930 additions and 39 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
27
lnbits/db.py
27
lnbits/db.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
36
lnbits/static/js/components/lnbits-label-selector.js
Normal file
36
lnbits/static/js/components/lnbits-label-selector.js
Normal 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]
|
||||
}
|
||||
})
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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') %}
|
||||
|
|
|
|||
107
lnbits/templates/components/lnbits-label-selector.vue
Normal file
107
lnbits/templates/components/lnbits-label-selector.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue