From 5751811243fbf7b5e6f69d9bedb66d78b1325e47 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Wed, 2 Sep 2026 07:58:31 +1000 Subject: [PATCH] plugin template update --- server/plugins/__template/config.json | 136 +++++++++++++++++++++++++ server/plugins/__template/rename_me.py | 38 ++++++- test/plugins/test___template.py | 42 +++++++- 3 files changed, 213 insertions(+), 3 deletions(-) diff --git a/server/plugins/__template/config.json b/server/plugins/__template/config.json index e7902ccd7..413bf869f 100755 --- a/server/plugins/__template/config.json +++ b/server/plugins/__template/config.json @@ -278,6 +278,142 @@ } ] }, + { + "function": "nested_form_example", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "button", + "elementOptions": [ + { "sourceSuffixes": [] }, + { "separator": "" }, + { "cssClasses": "col-xs-12" }, + { "onClick": "addViaPopupForm(this)" }, + { "getStringKey": "Gen_Add" } + ], + "transformers": [] + }, + { + "elementType": "select", + "elementHasInputValue": 1, + "elementOptions": [ + { "multiple": "true" }, + { "readonly": "true" }, + { "editable": "true" }, + { + "popupForm": [ + { + "function": "TMP_instance_name", + "type": { + "dataType": "string", + "elements": [ + { + "elementType": "input", + "elementOptions": [ + { "placeholder": "e.g. Site A" }, + { "cssClasses": "col-sm-10" } + ], + "transformers": [] + } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [{ "language_code": "en_us", "string": "Name" }], + "description": [ + { "language_code": "en_us", "string": "Friendly name for this instance. Shown in logs." } + ] + }, + { + "function": "TMP_instance_url", + "type": { + "dataType": "string", + "elements": [ + { + "elementType": "input", + "elementOptions": [ + { "placeholder": "https://host:port" }, + { "cssClasses": "col-sm-10" } + ], + "transformers": [] + } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [{ "language_code": "en_us", "string": "URL" }], + "description": [ + { "language_code": "en_us", "string": "Base URL for this instance." } + ] + }, + { + "function": "TMP_instance_enabled", + "type": { + "dataType": "boolean", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "checkbox" }], + "transformers": [] + } + ] + }, + "default_value": 1, + "options": [], + "localized": ["name", "description"], + "name": [{ "language_code": "en_us", "string": "Enabled" }], + "description": [ + { "language_code": "en_us", "string": "Uncheck to keep the instance configured but skip it during a run." } + ] + } + ] + } + ], + "transformers": ["name|base64"] + }, + { + "elementType": "button", + "elementOptions": [ + { "sourceSuffixes": [] }, + { "separator": "" }, + { "cssClasses": "col-xs-6" }, + { "onClick": "removeFromList(this)" }, + { "getStringKey": "Gen_Remove_Last" } + ], + "transformers": [] + }, + { + "elementType": "button", + "elementOptions": [ + { "sourceSuffixes": [] }, + { "separator": "" }, + { "cssClasses": "col-xs-6" }, + { "onClick": "removeAllOptions(this)" }, + { "getStringKey": "Gen_Remove_All" } + ], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Sample instances" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Example of the nested multi-instance settings pattern (a popup form per list entry) - use this instead of a fixed 'primary'/'secondary' pair when a plugin needs to support an arbitrary number of instances. See docs/PLUGINS_DEV.md#conventions-checklist and server/plugins/rest_import/config.json for the full-featured version this is based on." + } + ] + }, { "function": "CMD", "type": { diff --git a/server/plugins/__template/rename_me.py b/server/plugins/__template/rename_me.py index 2ac5cfd44..ad098873e 100755 --- a/server/plugins/__template/rename_me.py +++ b/server/plugins/__template/rename_me.py @@ -14,7 +14,7 @@ # artifacts, e.g. "/data/config") instead of hardcoding a path — see # docs/PLUGINS_DEV.md#persisting-plugin-data-state--config-files # from const import dbFolderPath, configPath -from plugin_helper import Plugin_Objects # noqa: E402, E261 [flake8 lint suppression] +from plugin_helper import Plugin_Objects, decode_settings_base64 # noqa: E402, E261 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402, E261 [flake8 lint suppression] from helper import get_setting_value # noqa: E402, E261 [flake8 lint suppression] @@ -48,6 +48,15 @@ def main(): mylog('verbose', [f'[{pluginName}] some_setting value {some_setting}']) + # Example: reading the nested "one or more instances" setting pattern + # (config.json's "nested_form_example") instead of a fixed hardcoded + # "primary"/"secondary" pair - see docs/PLUGINS_DEV.md#conventions-checklist. + for instance in get_configured_instances(): + mylog('verbose', [ + f"[{pluginName}] configured instance: {instance['name']} -> {instance['url']} " + f"(enabled={instance['enabled']})" + ]) + # retrieve data device_data = get_device_data(some_setting) @@ -86,6 +95,33 @@ def main(): return 0 +def get_configured_instances(): + """ + Example of processing the "nested_form_example" setting from config.json - + the multi-instance settings pattern (a popup form per list entry), used + when a plugin needs to support an arbitrary number of instances instead + of a fixed "primary"/"secondary" pair. See server/plugins/rest_import for + the full-featured version this is based on. + + Each raw entry in the setting's list is a base64-encoded JSON blob; + decode_settings_base64() turns it into a dict keyed by the popupForm's + "function" names (here: TMP_instance_name/_url/_enabled). + """ + raw_instances = get_setting_value('TMP_nested_form_example') or [] + + instances = [] + for raw in raw_instances: + cfg = decode_settings_base64(raw) + instances.append({ + 'name': cfg.get('TMP_instance_name', ''), + 'url': cfg.get('TMP_instance_url', ''), + 'enabled': bool(cfg.get('TMP_instance_enabled', True)), + }) + + # Skip instances the user unchecked rather than deleted. + return [instance for instance in instances if instance['enabled']] + + # retrieve data def get_device_data(some_setting): diff --git a/test/plugins/test___template.py b/test/plugins/test___template.py index 0997c13e2..cba7067d7 100644 --- a/test/plugins/test___template.py +++ b/test/plugins/test___template.py @@ -11,15 +11,37 @@ pytest "test/plugins/test___template.py" -v """ +import base64 +import json import os import sys import tempfile import types -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch _tmp_log = tempfile.mkdtemp() _tmp_db = tempfile.mkdtemp() + +def _decode_settings_base64(encoded_str, convert_types=True): + """Mirrors plugin_helper.decode_settings_base64 - reimplemented here + (rather than importing the real plugin_helper.py) since that module's + other top-level imports need the full container environment.""" + settings_list = json.loads(base64.b64decode(encoded_str).decode("utf-8")) + result = {} + for _, key, _type, value in settings_list: + result[key] = value.lower() == "true" if convert_types and _type.lower() == "boolean" else value + return result + + +def _encode_instance(name, url, enabled): + payload = [ + ["group", "TMP_instance_name", "string", name], + ["group", "TMP_instance_url", "string", url], + ["group", "TMP_instance_enabled", "boolean", str(enabled)], + ] + return base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + _stubbed_module_names = [] @@ -35,7 +57,7 @@ def _stub(name: str, **attrs): _stub("pytz", timezone=lambda tz: tz) _stub("conf") _stub("const", dataPath=_tmp_db, dbFolderPath=_tmp_db, configPath=_tmp_db, logPath=_tmp_log) -_stub("plugin_helper", Plugin_Objects=MagicMock) +_stub("plugin_helper", Plugin_Objects=MagicMock, decode_settings_base64=_decode_settings_base64) _stub("logger", mylog=lambda *a: None, Logger=MagicMock) _stub("helper", get_setting_value=lambda k: "") @@ -60,6 +82,22 @@ def test_returns_the_sample_devices(self): assert key in device +class TestGetConfiguredInstances: + def test_decodes_and_returns_enabled_instances(self): + raw = [ + _encode_instance("Site A", "https://a.example", True), + _encode_instance("Site B", "https://b.example", False), + ] + with patch("rename_me.get_setting_value", side_effect=lambda k: raw if k == "TMP_nested_form_example" else ""): + instances = rename_me.get_configured_instances() + + assert instances == [{"name": "Site A", "url": "https://a.example", "enabled": True}] + + def test_empty_setting_returns_no_instances(self): + with patch("rename_me.get_setting_value", return_value=""): + assert rename_me.get_configured_instances() == [] + + class TestMain: def test_writes_one_object_per_device_and_result_file_once(self): rename_me.plugin_objects = MagicMock()