diff --git a/api/funkwhale_api/common/apps.py b/api/funkwhale_api/common/apps.py index cd671be29..d9ab45c2b 100644 --- a/api/funkwhale_api/common/apps.py +++ b/api/funkwhale_api/common/apps.py @@ -1,6 +1,59 @@ +""" +Ideal API: + +# myplugin/apps.py + +from funkwhale_api import plugins + +class Plugin(plugins.Plugin): + name = 'scrobbler' + config_options = [ + { + 'id': 'user_agent', + 'verbose_name': 'User agent string', + 'help_text': 'The user agent string used by this plugin for external HTTP request', + 'default': None, + }, + { + 'id': 'timeout', + 'type': 'int', + 'verbose_name': 'Timeout (in seconds)' + 'help_text': 'Max timeout for HTTP calls', + 'default': 10, + }, + ] + + def get_user_options(self): + from . import options + return [ + options.ListenBrainz, + options.LastFm, + ] + + +# myplugin/hooks.py + +from .apps import Plugin + + +@Plugin.register_action('history.listening_created') +def scrobble(plugin, user, listening, **kwargs): + user_options = plugin.get_options(user) + + if len(options) == 0: + return + + for option in user_options: + if option.id == 'listenbrainz': + broadcast_to_listenbrainz() + + + +""" from django.apps import AppConfig, apps from . import mutations +from . import plugins class CommonConfig(AppConfig): @@ -11,3 +64,12 @@ class CommonConfig(AppConfig): app_names = [app.name for app in apps.app_configs.values()] mutations.registry.autodiscover(app_names) + + plugins.init( + plugins.registry, + [ + app + for app in apps.app_configs.values() + if getattr(app, "_is_funkwhale_plugin", False) is True + ], + ) diff --git a/api/funkwhale_api/common/plugins.py b/api/funkwhale_api/common/plugins.py new file mode 100644 index 000000000..3a8d3c5c4 --- /dev/null +++ b/api/funkwhale_api/common/plugins.py @@ -0,0 +1,111 @@ +import logging +import persisting_theory +from django.apps import AppConfig + + +logger = logging.getLogger("funkwhale.plugins") + + +class PluginsRegistry(persisting_theory.Registry): + look_into = "hooks" + + def prepare_name(self, data, name=None): + return data.name + + def prepare_data(self, data): + data.plugins_registry = self + return data + + def dispatch_action(self, action_name, **kwargs): + logger.debug("Dispatching plugin action %s", action_name) + for plugin in self.values(): + try: + handler = plugin.hooked_actions[action_name] + except KeyError: + continue + + logger.debug("Hook found for plugin %s", plugin.name) + try: + handler(plugin=plugin, **kwargs) + except Exception: + logger.exception( + "Hook for action %s from plugin %s failed. The plugin may be misconfigured.", + action_name, + plugin.name, + ) + else: + logger.info( + "Hook for action %s from plugin %s successful", + action_name, + plugin.name, + ) + + +registry = PluginsRegistry() + + +class Plugin(AppConfig): + _is_funkwhale_plugin = True + is_initialized = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.config = {} + self.hooked_actions = {} + + def get_config(self, config): + """ + Called with config options extracted from env vars, if any specified + Returns a transformed dict + """ + return config + + def set_config(self, config): + """ + Simply persist the given config on the plugin + """ + self.config = config + + def initialize(self): + pass + + def register_action(self, action_name, func): + logger.debug( + "Registered hook for action %s via plugin %s", action_name, self.name + ) + self.hooked_actions[action_name] = func + + +def init(registry, plugins): + logger.debug("Initializing plugins...") + for plugin in plugins: + logger.info("Initializing plugin %s", plugin.name) + try: + config = plugin.get_config({}) + except Exception: + logger.exception( + "Error while getting configuration, plugin %s disabled", plugin.name + ) + continue + + try: + plugin.set_config(config) + except Exception: + logger.exception( + "Error while setting configuration, plugin %s disabled", plugin.name + ) + continue + + try: + plugin.initialize() + except Exception: + logger.exception( + "Error while initializing, plugin %s disabled", plugin.name + ) + continue + + plugin.is_initialized = True + + # initialization complete, now we can log the "hooks.py" file in each + # plugin directory + registry.autodiscover([p.name for p in plugins if p.is_initialized]) diff --git a/api/tests/common/test_plugins.py b/api/tests/common/test_plugins.py new file mode 100644 index 000000000..6f26b711c --- /dev/null +++ b/api/tests/common/test_plugins.py @@ -0,0 +1,106 @@ +from funkwhale_api.common import plugins + +# setup code to populate plugins registry +# plugin-to-user -> enable and configure +# plugin preferences + + +def test_plugin_register(plugins_registry): + class TestPlugin(plugins.Plugin): + name = "scrobbler" + verbose_name = "Audio Scrobbler" + + inst = TestPlugin(app_name="scrobbler", app_module="") + plugins_registry.register(inst) + + assert inst.plugins_registry == plugins_registry + assert inst.is_initialized is False + assert plugins_registry["scrobbler"] == inst + assert inst.config == {} + + +def test_plugin_get_config(plugins_registry): + class TestPlugin(plugins.Plugin): + name = "scrobbler" + verbose_name = "Audio Scrobbler" + + plugin = TestPlugin(app_name="", app_module="") + assert plugin.get_config({"hello": "world"}) == {"hello": "world"} + + +def test_plugin_set_config(plugins_registry): + class TestPlugin(plugins.Plugin): + name = "scrobbler" + verbose_name = "Audio Scrobbler" + + plugin = TestPlugin(app_name="", app_module="") + plugin.set_config({"hello": "world"}) + assert plugin.config == {"hello": "world"} + + +def test_plugin_initialize(plugins_registry): + class TestPlugin(plugins.Plugin): + name = "scrobbler" + verbose_name = "Audio Scrobbler" + + plugin = TestPlugin(app_name="", app_module="") + assert plugin.initialize() is None + + +def test_action(mocker, plugins_registry): + class TestPlugin(plugins.Plugin): + name = "scrobbler" + verbose_name = "Audio Scrobbler" + + inst = TestPlugin(app_name="scrobbler", app_module="") + plugins_registry.register(inst) + + stub = mocker.stub() + + # nothing hooked, so stub is not called + plugins_registry.dispatch_action("hello", user="test", arg1="value1", arg2="value2") + stub.assert_not_called() + + # now we hook the stub on the action + inst.register_action("hello", stub) + assert inst.hooked_actions == {"hello": stub} + plugins_registry.dispatch_action("hello", user="test", arg1="value1", arg2="value2") + + stub.assert_called_once_with(plugin=inst, user="test", arg1="value1", arg2="value2") + + +def test_plugins_init(plugins_registry, mocker): + class TestPlugin1(plugins.Plugin): + name = "scrobbler" + verbose_name = "Audio Scrobbler" + + class TestPlugin2(plugins.Plugin): + name = "webhooks" + verbose_name = "Webhooks" + + plugin1 = TestPlugin1(app_name="scrobbler", app_module="") + plugin2 = TestPlugin2(app_name="webhooks", app_module="") + + mocks = {} + for plugin in [plugin1, plugin2]: + d = { + "get_config": mocker.patch.object(plugin, "get_config"), + "set_config": mocker.patch.object(plugin, "set_config"), + "initialize": mocker.patch.object(plugin, "initialize"), + } + mocks[plugin.name] = d + + autodiscover = mocker.patch.object(plugins_registry, "autodiscover") + plugins.init(plugins_registry, [plugin1, plugin2]) + + autodiscover.assert_called_once_with([plugin1.name, plugin2.name]) + + for mock_conf in mocks.values(): + mock_conf["get_config"].assert_called_once_with({}) + mock_conf["set_config"].assert_called_once_with( + mock_conf["get_config"].return_value + ) + mock_conf["initialize"].assert_called_once_with() + + assert plugin1.is_initialized is True + assert plugin2.is_initialized is True diff --git a/api/tests/conftest.py b/api/tests/conftest.py index d5b87e724..014541d42 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -28,6 +28,7 @@ from rest_framework import fields as rest_fields from rest_framework.test import APIClient, APIRequestFactory from funkwhale_api.activity import record +from funkwhale_api.common import plugins from funkwhale_api.federation import actors from funkwhale_api.moderation import mrf @@ -437,3 +438,12 @@ def mrf_outbox_registry(mocker): registry = mrf.Registry() mocker.patch("funkwhale_api.moderation.mrf.outbox", registry) return registry + + +@pytest.fixture +def plugins_registry(mocker): + mocker.patch.dict(plugins.registry, {}) + mocker.patch.object( + plugins.Plugin, "_path_from_module", return_value="/tmp/dummypath" + ) + return plugins.registry