[FEAT] improve update_admin_settings (#1903)

* [FEAT] improve update_admin_settings

while working on the push notification pr i found it very hard just to update
2 settings inside the db, so i improved upon update_admin_settings.
now you just need to provide a dict with key/values you want to update inside db.

also debugging the endpoints for update_settings i found despite the type of `EditableSettings`
fastapi did in fact pass a dict.

* t

* use `EditableSettings` as param in update_settings

* fix settings model validation

we previously overrode the pydantic validation with our own method

* make `LnbitsSettings` a `BaseModel` and only add `BaseSettings` later

this allows us to instantiate `EditableSettings` without the environment values being loaded in

* add test

* forbid extra fields in update api

* fixup

* add test

* test datadir

* move UpdateSettings

* fix compat

* fixup webpush

---------

Co-authored-by: jacksn <jkranawetter05@gmail.com>
This commit is contained in:
dni ⚡ 2023-09-12 11:59:32 +02:00 committed by GitHub
parent 1b2db34e08
commit bda054415a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 76 additions and 25 deletions

View file

@ -12,6 +12,7 @@ from lnbits.db import Connection, Database, Filters, Page
from lnbits.extension_manager import InstallableExtension from lnbits.extension_manager import InstallableExtension
from lnbits.settings import ( from lnbits.settings import (
AdminSettings, AdminSettings,
EditableSettings,
SuperSettings, SuperSettings,
WebPushSettings, WebPushSettings,
settings, settings,
@ -797,17 +798,14 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
return admin_settings return admin_settings
async def delete_admin_settings(): async def delete_admin_settings() -> None:
await db.execute("DELETE FROM settings") await db.execute("DELETE FROM settings")
async def update_admin_settings(data: dict): async def update_admin_settings(data: EditableSettings) -> None:
row = await db.fetchone("SELECT editable_settings FROM settings") row = await db.fetchone("SELECT editable_settings FROM settings")
if not row: editable_settings = json.loads(row["editable_settings"]) if row else {}
return None editable_settings.update(data.dict(exclude_unset=True))
editable_settings = json.loads(row["editable_settings"])
for key, value in data.items():
editable_settings[key] = value
await db.execute( await db.execute(
"UPDATE settings SET editable_settings = ?", (json.dumps(editable_settings),) "UPDATE settings SET editable_settings = ?", (json.dumps(editable_settings),)
) )

View file

@ -573,7 +573,7 @@ async def check_webpush_settings():
"lnbits_webpush_pubkey": pubkey, "lnbits_webpush_pubkey": pubkey,
} }
update_cached_settings(push_settings) update_cached_settings(push_settings)
await update_admin_settings(push_settings) await update_admin_settings(EditableSettings(**push_settings))
logger.info("Initialized webpush settings with generated VAPID key pair.") logger.info("Initialized webpush settings with generated VAPID key pair.")
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}") logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")

View file

@ -19,7 +19,7 @@ from lnbits.core.services import (
) )
from lnbits.decorators import check_admin, check_super_user from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart from lnbits.server import server_restart
from lnbits.settings import AdminSettings, settings from lnbits.settings import AdminSettings, UpdateSettings, settings
from .. import core_app, core_app_extra from .. import core_app, core_app_extra
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
@ -58,7 +58,7 @@ async def api_get_settings(
"/admin/api/v1/settings/", "/admin/api/v1/settings/",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
) )
async def api_update_settings(data: dict, user: User = Depends(check_admin)): async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
await update_admin_settings(data) await update_admin_settings(data)
admin_settings = await get_admin_settings(user.super_user) admin_settings = await get_admin_settings(user.super_user)
assert admin_settings, "Updated admin settings not found." assert admin_settings, "Updated admin settings not found."

View file

@ -9,7 +9,7 @@ from typing import Any, List, Optional
import httpx import httpx
from loguru import logger from loguru import logger
from pydantic import BaseSettings, Extra, Field, validator from pydantic import BaseModel, BaseSettings, Extra, Field, validator
def list_parse_fallback(v: str): def list_parse_fallback(v: str):
@ -23,20 +23,13 @@ def list_parse_fallback(v: str):
return [] return []
class LNbitsSettings(BaseSettings): class LNbitsSettings(BaseModel):
@classmethod @classmethod
def validate(cls, val): def validate_list(cls, val):
if isinstance(val, str): if isinstance(val, str):
val = val.split(",") if val else [] val = val.split(",") if val else []
return val return val
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
json_loads = list_parse_fallback
extra = Extra.ignore
class UsersSettings(LNbitsSettings): class UsersSettings(LNbitsSettings):
lnbits_admin_users: List[str] = Field(default=[]) lnbits_admin_users: List[str] = Field(default=[])
@ -253,7 +246,7 @@ class EditableSettings(
) )
@classmethod @classmethod
def validate_editable_settings(cls, val): def validate_editable_settings(cls, val):
return super().validate(val) return super().validate_list(val)
@classmethod @classmethod
def from_dict(cls, d: dict): def from_dict(cls, d: dict):
@ -269,6 +262,11 @@ class EditableSettings(
prop.pop("env_names", None) prop.pop("env_names", None)
class UpdateSettings(EditableSettings):
class Config:
extra = Extra.forbid
class EnvSettings(LNbitsSettings): class EnvSettings(LNbitsSettings):
debug: bool = Field(default=False) debug: bool = Field(default=False)
bundle_assets: bool = Field(default=True) bundle_assets: bool = Field(default=True)
@ -338,19 +336,25 @@ class ReadOnlySettings(
) )
@classmethod @classmethod
def validate_readonly_settings(cls, val): def validate_readonly_settings(cls, val):
return super().validate(val) return super().validate_list(val)
@classmethod @classmethod
def readonly_fields(cls): def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")] return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
class Settings(EditableSettings, ReadOnlySettings, TransientSettings): class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
@classmethod @classmethod
def from_row(cls, row: Row) -> "Settings": def from_row(cls, row: Row) -> "Settings":
data = dict(row) data = dict(row)
return cls(**data) return cls(**data)
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
json_loads = list_parse_fallback
class SuperSettings(EditableSettings): class SuperSettings(EditableSettings):
super_user: str super_user: str

View file

@ -10,7 +10,7 @@ from fastapi.testclient import TestClient
from httpx import AsyncClient from httpx import AsyncClient
from lnbits.app import create_app from lnbits.app import create_app
from lnbits.core.crud import create_account, create_wallet from lnbits.core.crud import create_account, create_wallet, get_user
from lnbits.core.models import CreateInvoice from lnbits.core.models import CreateInvoice
from lnbits.core.services import update_wallet_balance from lnbits.core.services import update_wallet_balance
from lnbits.core.views.api import api_payments_create_invoice from lnbits.core.views.api import api_payments_create_invoice
@ -18,7 +18,10 @@ from lnbits.db import Database
from lnbits.settings import settings from lnbits.settings import settings
from tests.helpers import get_hold_invoice, get_random_invoice_data, get_real_invoice from tests.helpers import get_hold_invoice, get_random_invoice_data, get_real_invoice
# dont install extensions for tests # override settings for tests
settings.lnbits_admin_extensions = []
settings.lnbits_data_folder = "./tests/data"
settings.lnbits_admin_ui = True
settings.lnbits_extensions_default_install = [] settings.lnbits_extensions_default_install = []
@ -86,6 +89,12 @@ async def to_user():
yield user yield user
@pytest_asyncio.fixture(scope="session")
async def superuser():
user = await get_user(settings.super_user)
yield user
@pytest_asyncio.fixture(scope="session") @pytest_asyncio.fixture(scope="session")
async def to_wallet(to_user): async def to_wallet(to_user):
user = to_user user = to_user

View file

@ -0,0 +1,40 @@
import pytest
from lnbits.settings import settings
@pytest.mark.asyncio
async def test_admin_get_settings_permission_denied(client, from_user):
response = await client.get(f"/admin/api/v1/settings/?usr={from_user.id}")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_admin_get_settings(client, superuser):
response = await client.get(f"/admin/api/v1/settings/?usr={superuser.id}")
assert response.status_code == 200
result = response.json()
assert "super_user" not in result
@pytest.mark.asyncio
async def test_admin_update_settings(client, superuser):
new_site_title = "UPDATED SITETITLE"
response = await client.put(
f"/admin/api/v1/settings/?usr={superuser.id}",
json={"lnbits_site_title": new_site_title},
)
assert response.status_code == 200
result = response.json()
assert "status" in result
assert result.get("status") == "Success"
assert settings.lnbits_site_title == new_site_title
@pytest.mark.asyncio
async def test_admin_update_noneditable_settings(client, superuser):
response = await client.put(
f"/admin/api/v1/settings/?usr={superuser.id}",
json={"super_user": "UPDATED"},
)
assert response.status_code == 400