diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5c5295..f3aff7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ### Added - set_handlers: `models_to_fetch` can now accept direct links to a files to download. #217 +- DeclarativeSettings API for Nextcloud 29. #222 ### Changed diff --git a/README.md b/README.md index 6f9ff0af..7ab95af5 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Python library that provides a robust and well-documented API that allows develo | User & Weather status | ✅ | ✅ | ✅ | ✅ | | Other APIs*** | ✅ | ✅ | ✅ | ✅ | | Talk Bot API* | N/A | ✅ | ✅ | ✅ | +| Settings UI API* | N/A | N/A | N/A | ✅ | | AI Providers API** | N/A | N/A | N/A | ✅ | *_available only for **NextcloudApp**_
diff --git a/docs/NextcloudUiApp.rst b/docs/NextcloudUiApp.rst index 56beee52..0cb90a87 100644 --- a/docs/NextcloudUiApp.rst +++ b/docs/NextcloudUiApp.rst @@ -19,6 +19,8 @@ Here we will simply describe in detail what happens in the example. ) nc.ui.resources.set_script("top_menu", "first_menu", "js/ui_example-main") nc.ui.top_menu.register("first_menu", "UI example", "img/icon.svg") + if nc.srv_version["major"] >= 29: + nc.ui.settings.register_form(SETTINGS_EXAMPLE) **set_initial_state** is analogue of PHP ``OCP\AppFramework\Services\IInitialState::provideInitialState`` @@ -26,6 +28,11 @@ Here we will simply describe in detail what happens in the example. There is also **set_style** (``Util::addStyle``) that can be used for CSS files and works the same way as **set_script**. +Starting with Nextcloud **29** AppAPI supports declaring Settings UI, with very simple and robust API. + +Settings values you declare will be saved to ``preferences_ex`` or ``appconfig_ex`` tables and can be retrieved using +:py:class:`nc_py_api._preferences_ex.PreferencesExAPI` or :py:class:`nc_py_api._preferences_ex.AppConfigExAPI` APIs. + Backend ------- diff --git a/docs/reference/ExApp.rst b/docs/reference/ExApp.rst index 5fd88641..fe508e1b 100644 --- a/docs/reference/ExApp.rst +++ b/docs/reference/ExApp.rst @@ -57,6 +57,18 @@ UI methods should be accessed with the help of :class:`~nc_py_api.nextcloud.Next .. autoclass:: nc_py_api.ex_app.ui.resources.UiStyle :members: +.. autoclass:: nc_py_api.ex_app.ui.settings.SettingsField + :members: + +.. autoclass:: nc_py_api.ex_app.ui.settings.SettingsForm + :members: + +.. autoclass:: nc_py_api.ex_app.ui.settings.SettingsFieldType + :members: + +.. autoclass:: nc_py_api.ex_app.ui.settings._DeclarativeSettingsAPI + :members: + .. autoclass:: nc_py_api.ex_app.providers.providers.ProvidersApi :members: diff --git a/examples/as_app/ui_example/lib/main.py b/examples/as_app/ui_example/lib/main.py index 13d7d61d..27951ae2 100644 --- a/examples/as_app/ui_example/lib/main.py +++ b/examples/as_app/ui_example/lib/main.py @@ -8,7 +8,14 @@ from pydantic import BaseModel from nc_py_api import NextcloudApp -from nc_py_api.ex_app import nc_app, run_app, set_handlers +from nc_py_api.ex_app import ( + SettingsField, + SettingsFieldType, + SettingsForm, + nc_app, + run_app, + set_handlers, +) @asynccontextmanager @@ -19,6 +26,130 @@ async def lifespan(_app: FastAPI): APP = FastAPI(lifespan=lifespan) +SETTINGS_EXAMPLE = SettingsForm( + id="settings_example", + section_type="admin", + section_id="ai_integration_team", + title="Example of declarative settings", + description="These fields are rendered dynamically from declarative schema", + fields=[ + SettingsField( + id="field1", + title="Multi-selection", + description="Select some option setting", + type=SettingsFieldType.MULTI_SELECT, + default=["foo", "bar"], + placeholder="Select some multiple options", + options=["foo", "bar", "baz"], + ), + SettingsField( + id="some_real_setting", + title="Choose init status check background job interval", + description="How often ExApp should check for initialization status", + type=SettingsFieldType.RADIO, + default="40m", + placeholder="Choose init status check background job interval", + options={ + "Each 40 minutes": "40m", + "Each 60 minutes": "60m", + "Each 120 minutes": "120m", + "Each day": f"{60 * 24}m", + }, + ), + SettingsField( + id="test_ex_app_field_1", + title="Default text field", + description="Set some simple text setting", + type=SettingsFieldType.TEXT, + default="foo", + placeholder="Enter text setting", + ), + SettingsField( + id="test_ex_app_field_1_1", + title="Email field", + description="Set email config", + type=SettingsFieldType.EMAIL, + default="", + placeholder="Enter email", + ), + SettingsField( + id="test_ex_app_field_1_2", + title="Tel field", + description="Set tel config", + type=SettingsFieldType.TEL, + default="", + placeholder="Enter your tel", + ), + SettingsField( + id="test_ex_app_field_1_3", + title="Url (website) field", + description="Set url config", + type=SettingsFieldType.URL, + default="", + placeholder="Enter url", + ), + SettingsField( + id="test_ex_app_field_1_4", + title="Number field", + description="Set number config", + type=SettingsFieldType.NUMBER, + default=0, + placeholder="Enter number value", + ), + SettingsField( + id="test_ex_app_field_2", + title="Password", + description="Set some secure value setting", + type=SettingsFieldType.PASSWORD, + default="", + placeholder="Set secure value", + ), + SettingsField( + id="test_ex_app_field_3", + title="Selection", + description="Select some option setting", + type=SettingsFieldType.SELECT, + default="foo", + placeholder="Select some option setting", + options=["foo", "bar", "baz"], + ), + SettingsField( + id="test_ex_app_field_3", + title="Selection", + description="Select some option setting", + type=SettingsFieldType.SELECT, + default="foo", + placeholder="Select some option setting", + options=["foo", "bar", "baz"], + ), + SettingsField( + id="test_ex_app_field_4", + title="Toggle something", + description="Select checkbox option setting", + type=SettingsFieldType.CHECKBOX, + default=False, + label="Verify something if enabled", + ), + SettingsField( + id="test_ex_app_field_5", + title="Multiple checkbox toggles, describing one setting", + description="Select checkbox option setting", + type=SettingsFieldType.MULTI_CHECKBOX, + default={"foo": True, "bar": True}, + options={"Foo": "foo", "Bar": "bar", "Baz": "baz", "Qux": "qux"}, + ), + SettingsField( + id="test_ex_app_field_6", + title="Radio toggles, describing one setting like single select", + description="Select radio option setting", + type=SettingsFieldType.RADIO, + label="Select single toggle", + default="foo", + options={"First radio": "foo", "Second radio": "bar", "Third radie": "baz"}, + ), + ], +) + def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: print(f"enabled={enabled}") @@ -28,6 +159,8 @@ def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: ) nc.ui.resources.set_script("top_menu", "first_menu", "js/ui_example-main") nc.ui.top_menu.register("first_menu", "UI example", "img/icon.svg") + if nc.srv_version["major"] >= 29: + nc.ui.settings.register_form(SETTINGS_EXAMPLE) return "" diff --git a/nc_py_api/_preferences_ex.py b/nc_py_api/_preferences_ex.py index 6f2d8440..82cff8fd 100644 --- a/nc_py_api/_preferences_ex.py +++ b/nc_py_api/_preferences_ex.py @@ -106,7 +106,7 @@ async def delete(self, keys: str | list[str], not_fail=True) -> None: class PreferencesExAPI(_BasicAppCfgPref): - """User specific preferences API.""" + """User specific preferences API, avalaible as **nc.preferences_ex**.""" _url_suffix = "ex-app/preference" @@ -134,7 +134,7 @@ async def set_value(self, key: str, value: str) -> None: class AppConfigExAPI(_BasicAppCfgPref): - """Non-user(App) specific preferences API.""" + """Non-user(App) specific preferences API, avalaible as **nc.appconfig_ex**.""" _url_suffix = "ex-app/config" diff --git a/nc_py_api/ex_app/__init__.py b/nc_py_api/ex_app/__init__.py index 6cfc3324..f8cfe0c8 100644 --- a/nc_py_api/ex_app/__init__.py +++ b/nc_py_api/ex_app/__init__.py @@ -11,4 +11,5 @@ ) from .misc import get_model_path, persistent_storage, verify_version from .ui.files_actions import UiActionFileInfo +from .ui.settings import SettingsField, SettingsFieldType, SettingsForm from .uvicorn_fastapi import run_app diff --git a/nc_py_api/ex_app/ui/settings.py b/nc_py_api/ex_app/ui/settings.py new file mode 100644 index 00000000..90f1bb38 --- /dev/null +++ b/nc_py_api/ex_app/ui/settings.py @@ -0,0 +1,178 @@ +"""Nextcloud API for declaring UI for settings.""" + +import dataclasses +import enum +import typing + +from ..._exceptions import NextcloudExceptionNotFound +from ..._misc import require_capabilities +from ..._session import AsyncNcSessionApp, NcSessionApp + + +class SettingsFieldType(enum.Enum): # StrEnum + """Declarative Settings Field Type.""" + + TEXT = "text" + """NcInputField type text""" + PASSWORD = "password" # noqa + """NcInputField type password""" + EMAIL = "email" + """NcInputField type email""" + TEL = "tel" + """NcInputField type tel""" + URL = "url" + """NcInputField type url""" + NUMBER = "number" + """NcInputField type number""" + CHECKBOX = "checkbox" + """NcCheckboxRadioSwitch type checkbox""" + MULTI_CHECKBOX = "multi-checkbox" + """Multiple NcCheckboxRadioSwitch type checkbox representing a one config value (saved as JSON object)""" + RADIO = "radio" + """NcCheckboxRadioSwitch type radio""" + SELECT = "select" + """NcSelect""" + MULTI_SELECT = "multi-select" + """Multiple NcSelect representing a one config value (saved as JSON array)""" + + +@dataclasses.dataclass +class SettingsField: + """Section field.""" + + id: str + title: str + type: SettingsFieldType + default: bool | int | float | str | list[bool | int | float | str] | dict[str, typing.Any] + options: dict | list = dataclasses.field(default_factory=dict) + description: str = "" + placeholder: str = "" + label: str = "" + notify = False # to be supported in future + + @classmethod + def from_dict(cls, data: dict) -> "SettingsField": + """Creates instance of class from dict, ignoring unknown keys.""" + filtered_data = { + k: SettingsFieldType(v) if k == "type" else v for k, v in data.items() if k in cls.__annotations__ + } + return cls(**filtered_data) + + def to_dict(self) -> dict: + """Returns data in format that is accepted by AppAPI.""" + return { + "id": self.id, + "title": self.title, + "type": self.type.value, + "default": self.default, + "description": self.description, + "options": ( + [{"name": key, "value": value} for key, value in self.options.items()] + if isinstance(self.options, dict) + else self.options + ), + "placeholder": self.placeholder, + "label": self.label, + "notify": self.notify, + } + + +@dataclasses.dataclass +class SettingsForm: + """Settings Form and Section.""" + + id: str + section_id: str + title: str + fields: list[SettingsField] = dataclasses.field(default_factory=list) + description: str = "" + priority: int = 50 + doc_url: str = "" + section_type: str = "personal" + + @classmethod + def from_dict(cls, data: dict) -> "SettingsForm": + """Creates instance of class from dict, ignoring unknown keys.""" + filtered_data = {k: v for k, v in data.items() if k in cls.__annotations__} + filtered_data["fields"] = [SettingsField.from_dict(i) for i in filtered_data.get("fields", [])] + return cls(**filtered_data) + + def to_dict(self) -> dict: + """Returns data in format that is accepted by AppAPI.""" + return { + "id": self.id, + "priority": self.priority, + "section_type": self.section_type, + "section_id": self.section_id, + "title": self.title, + "description": self.description, + "doc_url": self.doc_url, + "fields": [i.to_dict() for i in self.fields], + } + + +_EP_SUFFIX: str = "ui/settings" + + +class _DeclarativeSettingsAPI: + """Class providing API for creating UI for the ExApp settings.""" + + def __init__(self, session: NcSessionApp): + self._session = session + + def register_form(self, form_schema: SettingsForm | dict[str, typing.Any]) -> None: + """Registers or edit the Settings UI Form.""" + require_capabilities("app_api", self._session.capabilities) + param = {"formScheme": form_schema.to_dict() if isinstance(form_schema, SettingsForm) else form_schema} + self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=param) + + def unregister_form(self, form_id: str, not_fail=True) -> None: + """Removes Settings UI Form.""" + require_capabilities("app_api", self._session.capabilities) + try: + self._session.ocs("DELETE", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + def get_entry(self, form_id: str) -> SettingsForm | None: + """Get information of the Settings UI Form.""" + require_capabilities("app_api", self._session.capabilities) + try: + return SettingsForm.from_dict( + self._session.ocs("GET", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id}) + ) + except NextcloudExceptionNotFound: + return None + + +class _AsyncDeclarativeSettingsAPI: + """Class providing async API for creating UI for the ExApp settings.""" + + def __init__(self, session: AsyncNcSessionApp): + self._session = session + + async def register_form(self, form_schema: SettingsForm | dict[str, typing.Any]) -> None: + """Registers or edit the Settings UI Form.""" + require_capabilities("app_api", await self._session.capabilities) + param = {"formScheme": form_schema.to_dict() if isinstance(form_schema, SettingsForm) else form_schema} + await self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=param) + + async def unregister_form(self, form_id: str, not_fail=True) -> None: + """Removes Settings UI Form.""" + require_capabilities("app_api", await self._session.capabilities) + try: + await self._session.ocs("DELETE", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + async def get_entry(self, form_id: str) -> SettingsForm | None: + """Get information of the Settings UI Form.""" + require_capabilities("app_api", await self._session.capabilities) + try: + return SettingsForm.from_dict( + await self._session.ocs("GET", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id}) + ) + except NextcloudExceptionNotFound: + return None diff --git a/nc_py_api/ex_app/ui/ui.py b/nc_py_api/ex_app/ui/ui.py index ae0099b6..2121afd7 100644 --- a/nc_py_api/ex_app/ui/ui.py +++ b/nc_py_api/ex_app/ui/ui.py @@ -3,6 +3,7 @@ from ..._session import AsyncNcSessionApp, NcSessionApp from .files_actions import _AsyncUiFilesActionsAPI, _UiFilesActionsAPI from .resources import _AsyncUiResources, _UiResources +from .settings import _AsyncDeclarativeSettingsAPI, _DeclarativeSettingsAPI from .top_menu import _AsyncUiTopMenuAPI, _UiTopMenuAPI @@ -15,11 +16,14 @@ class UiApi: """Top App menu API.""" resources: _UiResources """Page(Template) resources API.""" + settings: _DeclarativeSettingsAPI + """API for ExApp settings UI""" def __init__(self, session: NcSessionApp): self.files_dropdown_menu = _UiFilesActionsAPI(session) self.top_menu = _UiTopMenuAPI(session) self.resources = _UiResources(session) + self.settings = _DeclarativeSettingsAPI(session) class AsyncUiApi: @@ -31,8 +35,11 @@ class AsyncUiApi: """Top App menu API.""" resources: _AsyncUiResources """Page(Template) resources API.""" + settings: _AsyncDeclarativeSettingsAPI + """API for ExApp settings UI""" def __init__(self, session: AsyncNcSessionApp): self.files_dropdown_menu = _AsyncUiFilesActionsAPI(session) self.top_menu = _AsyncUiTopMenuAPI(session) self.resources = _AsyncUiResources(session) + self.settings = _AsyncDeclarativeSettingsAPI(session) diff --git a/tests/actual_tests/ui_settings_test.py b/tests/actual_tests/ui_settings_test.py new file mode 100644 index 00000000..61037cc7 --- /dev/null +++ b/tests/actual_tests/ui_settings_test.py @@ -0,0 +1,69 @@ +import copy + +import pytest + +from nc_py_api import NextcloudExceptionNotFound, ex_app + +SETTINGS_EXAMPLE = ex_app.SettingsForm( + id="test_id", + section_type="admin", + section_id="test_section_id", + title="Some title", + description="Some description", + fields=[ + ex_app.SettingsField( + id="field1", + title="Multi-selection", + description="Select some option setting", + type=ex_app.SettingsFieldType.MULTI_SELECT, + default=["foo", "bar"], + placeholder="Select some multiple options", + options=["foo", "bar", "baz"], + ), + ], +) + + +@pytest.mark.require_nc(major=29) +def test_register_ui_settings(nc_app): + nc_app.ui.settings.register_form(SETTINGS_EXAMPLE) + result = nc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) + assert result == SETTINGS_EXAMPLE + nc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id) + assert nc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) is None + nc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id) + with pytest.raises(NextcloudExceptionNotFound): + nc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id, not_fail=False) + nc_app.ui.settings.register_form(SETTINGS_EXAMPLE) + result = nc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) + assert result.description == SETTINGS_EXAMPLE.description + new_settings = copy.copy(SETTINGS_EXAMPLE) + new_settings.description = "new desc" + nc_app.ui.settings.register_form(new_settings) + result = nc_app.ui.settings.get_entry(new_settings.id) + assert result.description == "new desc" + nc_app.ui.settings.unregister_form(new_settings.id) + assert nc_app.ui.settings.get_entry(new_settings.id) is None + + +@pytest.mark.require_nc(major=29) +@pytest.mark.asyncio(scope="session") +async def test_register_ui_settings_async(anc_app): + await anc_app.ui.settings.register_form(SETTINGS_EXAMPLE) + result = await anc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) + assert result == SETTINGS_EXAMPLE + await anc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id) + assert await anc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) is None + await anc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id) + with pytest.raises(NextcloudExceptionNotFound): + await anc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id, not_fail=False) + await anc_app.ui.settings.register_form(SETTINGS_EXAMPLE) + result = await anc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) + assert result.description == SETTINGS_EXAMPLE.description + new_settings = copy.copy(SETTINGS_EXAMPLE) + new_settings.description = "new desc" + await anc_app.ui.settings.register_form(new_settings) + result = await anc_app.ui.settings.get_entry(new_settings.id) + assert result.description == "new desc" + await anc_app.ui.settings.unregister_form(new_settings.id) + assert await anc_app.ui.settings.get_entry(new_settings.id) is None