Compare commits

..
Author SHA1 Message Date
ArcandGitHub 5a6ff7ed02 Merge branch 'dev' into trans_exts 2026-05-22 14:15:17 +01:00
Arc 335c056a4b feat: translation support for extensions 2026-05-01 13:05:47 +01:00
10 changed files with 64 additions and 207 deletions
+4 -1
View File
@@ -306,7 +306,7 @@ async def update_payment_checking_id(
await (conn or db).execute( await (conn or db).execute(
f""" f"""
UPDATE apipayments UPDATE apipayments
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")} SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
WHERE checking_id = :old_id WHERE checking_id = :old_id
""", # noqa: S608 """, # noqa: S608
{ {
@@ -399,6 +399,7 @@ async def get_payment_count_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> list[PaymentCountStat]: ) -> list[PaymentCountStat]:
if not filters: if not filters:
filters = Filters() filters = Filters()
extra_stmts = [] extra_stmts = []
@@ -431,6 +432,7 @@ async def get_daily_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]: ) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters: if not filters:
filters = Filters() filters = Filters()
@@ -480,6 +482,7 @@ async def get_wallets_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> list[PaymentWalletStats]: ) -> list[PaymentWalletStats]:
if not filters: if not filters:
filters = Filters() filters = Filters()
-2
View File
@@ -25,7 +25,6 @@ from .payments import (
PaymentState, PaymentState,
PaymentWalletStats, PaymentWalletStats,
SettleInvoice, SettleInvoice,
UpdatePaymentExtra,
) )
from .tinyurl import TinyURL from .tinyurl import TinyURL
from .users import ( from .users import (
@@ -91,7 +90,6 @@ __all__ = [
"SimpleStatus", "SimpleStatus",
"TinyURL", "TinyURL",
"UpdateBalance", "UpdateBalance",
"UpdatePaymentExtra",
"UpdateSuperuserPassword", "UpdateSuperuserPassword",
"UpdateUser", "UpdateUser",
"UpdateUserPassword", "UpdateUserPassword",
-5
View File
@@ -35,11 +35,6 @@ class PaymentExtra(BaseModel):
lnurl_response: str | None = None lnurl_response: str | None = None
class UpdatePaymentExtra(BaseModel):
payment_hash: str
extra: dict = Field(default_factory=dict)
class PayInvoice(BaseModel): class PayInvoice(BaseModel):
payment_request: str payment_request: str
description: str | None = None description: str | None = None
+7 -3
View File
@@ -20,7 +20,7 @@ from lnbits.decorators import (
check_first_install, check_first_install,
check_user_exists, check_user_exists,
) )
from lnbits.helpers import check_callback_url, template_renderer from lnbits.helpers import check_callback_url, extension_id_from_path, template_renderer
from lnbits.settings import settings from lnbits.settings import settings
from ..crud import get_user from ..crud import get_user
@@ -198,7 +198,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
async def index( async def index(
request: Request, user: User = Depends(check_user_exists) request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse: ) -> HTMLResponse:
return template_renderer().TemplateResponse( return template_renderer(
extension_id=extension_id_from_path(request.url.path)
).TemplateResponse(
request, request,
"base.html", "base.html",
{ {
@@ -211,7 +213,9 @@ async def index(
@generic_router.get("/node/public") @generic_router.get("/node/public")
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)]) @generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
async def index_public(request: Request) -> HTMLResponse: async def index_public(request: Request) -> HTMLResponse:
return template_renderer().TemplateResponse(request, "base.html", {"public": True}) return template_renderer(
extension_id=extension_id_from_path(request.url.path)
).TemplateResponse(request, "base.html", {"public": True})
@generic_router.get("/uuidv4/{hex_value}") @generic_router.get("/uuidv4/{hex_value}")
-33
View File
@@ -34,7 +34,6 @@ from lnbits.core.models import (
PaymentWalletStats, PaymentWalletStats,
SettleInvoice, SettleInvoice,
SimpleStatus, SimpleStatus,
UpdatePaymentExtra,
) )
from lnbits.core.models.payments import UpdatePaymentLabels from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
@@ -298,38 +297,6 @@ async def api_update_payment_labels(
return SimpleStatus(success=True, message="Payment labels updated.") return SimpleStatus(success=True, message="Payment labels updated.")
@payment_router.patch(
"/extra",
name="Update payment extra",
description="Append new extra metadata to a payment.",
response_model=Payment,
)
async def api_update_payment_extra(
data: UpdatePaymentExtra,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> Payment:
payment = await get_standalone_payment(
data.payment_hash, wallet_id=key_type.wallet.id
)
if payment is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
if not payment.success:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Payment extra can only be updated after success."
)
duplicate_keys = sorted(set(payment.extra).intersection(data.extra))
if duplicate_keys:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Extra keys already exist: {', '.join(duplicate_keys)}.",
)
payment.extra.update(data.extra)
await update_payment(payment)
return payment
@payment_router.get("/fee-reserve") @payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice) invoice_obj = bolt11.decode(invoice)
+47 -1
View File
@@ -52,7 +52,45 @@ def static_url_for(static: str, path: str) -> str:
return f"/{static}/{path}?v={settings.server_startup_time}" return f"/{static}/{path}?v={settings.server_startup_time}"
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates: def extension_id_from_path(path: str) -> str | None:
parts = [part for part in path.split("/") if part]
if not parts:
return None
if len(parts) >= 3 and parts[0] == "upgrades":
return parts[2]
ext_id = parts[0]
ext_i18n_dir = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "static", "i18n"
)
if ext_i18n_dir.is_dir():
return ext_id
return None
def extension_i18n_urls(extension_id: str | None) -> list[str]:
if not extension_id:
return []
i18n_dir = Path(
settings.lnbits_extensions_path, "extensions", extension_id, "static", "i18n"
)
if not i18n_dir.is_dir():
return []
files = [file.name for file in i18n_dir.glob("*.js") if file.is_file()]
return [
static_url_for(f"{extension_id}/static", f"i18n/{filename}")
for filename in sorted(files, key=lambda name: (name != "en.js", name))
]
def template_renderer(
additional_folders: list | None = None,
extension_id: str | None = None,
) -> Jinja2Templates:
folders = [ folders = [
"lnbits/templates", "lnbits/templates",
settings.extension_builder_working_dir_path.as_posix(), settings.extension_builder_working_dir_path.as_posix(),
@@ -86,6 +124,14 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
t.env.globals["INCLUDED_CSS"] = vendor_files["css"] t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"] t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
if not extension_id and additional_folders:
for folder in additional_folders:
parts = Path(folder).parts
if parts and parts[-1] == "templates" and len(parts) >= 2:
extension_id = parts[-2]
break
t.env.globals["INCLUDED_EXTENSION_I18N"] = extension_i18n_urls(extension_id)
# backwards compatibility for extensions (tpos) # backwards compatibility for extensions (tpos)
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
+3 -1
View File
@@ -90,7 +90,9 @@
window.g.isPublicPage = false window.g.isPublicPage = false
{% endif %} {% endif %}
</script> </script>
{% endif %} {% endif %} {% for url in INCLUDED_EXTENSION_I18N %}
<script src="{{ url }}"></script>
{% endfor %}
<!-- app init --> <!-- app init -->
<script> <script>
window.app = Vue.createApp({ window.app = Vue.createApp({
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "lnbits" name = "lnbits"
version = "1.5.5-rc1" version = "1.5.4"
requires-python = ">=3.10,<3.13" requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system." description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
+1 -159
View File
@@ -6,7 +6,7 @@ import pytest
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import ValidationError from pydantic import ValidationError
from lnbits.core.crud.payments import create_payment, get_payment, get_payments from lnbits.core.crud.payments import create_payment, get_payments
from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
@@ -218,164 +218,6 @@ async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
cancel_mock.assert_awaited_once() cancel_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_payment_extra_update_appends_new_keys(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
checking_id = await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
tag="splitpayments",
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={
"payment_hash": payment_hash,
"extra": {"child": "daughter", "compliance_note": "reviewed"},
},
)
assert response.status_code == 200
extra = response.json()["extra"]
assert extra["tag"] == "splitpayments"
assert extra["child"] == "daughter"
assert extra["compliance_note"] == "reviewed"
payment = await get_payment(checking_id)
assert payment.extra == extra
@pytest.mark.anyio
async def test_payment_extra_update_creates_extra_when_missing(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
checking_id = await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "reviewed"}},
)
assert response.status_code == 200
assert response.json()["extra"] == {"note": "reviewed"}
payment = await get_payment(checking_id)
assert payment.extra == {"note": "reviewed"}
@pytest.mark.anyio
async def test_payment_extra_update_rejects_existing_keys(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
checking_id = await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
tag="original",
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"tag": "overwritten"}},
)
assert response.status_code == 400
assert response.json()["detail"] == "Extra keys already exist: tag."
payment = await get_payment(checking_id)
assert payment.extra == {"tag": "original"}
@pytest.mark.anyio
async def test_payment_extra_update_requires_admin_key(
client,
to_wallet,
inkey_headers_to,
):
payment_hash = uuid4().hex
await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=inkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "invoice key"}},
)
assert response.status_code == 403
assert response.json()["detail"] == "Invalid adminkey."
@pytest.mark.anyio
async def test_payment_extra_update_is_wallet_scoped(
client,
from_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
await _create_payment(
from_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "wrong wallet"}},
)
assert response.status_code == 404
assert response.json()["detail"] == "Payment does not exist."
@pytest.mark.anyio
async def test_payment_extra_update_requires_successful_payment(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
status=PaymentState.PENDING,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "too early"}},
)
assert response.status_code == 400
assert (
response.json()["detail"] == "Payment extra can only be updated after success."
)
async def _create_payment( async def _create_payment(
wallet_id: str, wallet_id: str,
*, *,
Generated
+1 -1
View File
@@ -1275,7 +1275,7 @@ wheels = [
[[package]] [[package]]
name = "lnbits" name = "lnbits"
version = "1.5.5rc1" version = "1.5.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },