From 2629f857a58a5a5e24de2e7709ad2288e27ea0d7 Mon Sep 17 00:00:00 2001 From: Michael Krotscheck Date: Thu, 16 Oct 2014 14:07:10 -0700 Subject: [PATCH] Plugins may now register their own user preferences To support our plugin system, I've added a stevedore hook that may be used to load plugin values and their defaults from third parties. This system initializes all plugins at startup, and decorates any response with the discovered results on request. Loading is done via stevedore, and I've started a new plugin package into which all of our other plugins should probably be moved. Change-Id: Idb7271dc37d01f36c512bbfd2be7f7b3c7b1a0c9 --- setup.cfg | 2 + storyboard/api/app.py | 4 + storyboard/api/v1/user_preferences.py | 12 ++- storyboard/db/api/users.py | 6 ++ storyboard/plugin/__init__.py | 0 storyboard/plugin/base.py | 67 +++++++++++++++ storyboard/plugin/user_preferences.py | 68 +++++++++++++++ storyboard/tests/plugin/__init__.py | 0 storyboard/tests/plugin/test_base.py | 58 +++++++++++++ .../tests/plugin/test_user_preferences.py | 84 +++++++++++++++++++ 10 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 storyboard/plugin/__init__.py create mode 100644 storyboard/plugin/base.py create mode 100644 storyboard/plugin/user_preferences.py create mode 100644 storyboard/tests/plugin/__init__.py create mode 100644 storyboard/tests/plugin/test_base.py create mode 100644 storyboard/tests/plugin/test_user_preferences.py diff --git a/setup.cfg b/setup.cfg index 19c3656e..ca57b16b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,8 @@ console_scripts = storyboard-migrate = storyboard.migrate.cli:main storyboard.worker.task = subscription = storyboard.worker.task.subscription:Subscription +storyboard.plugin.user_preferences = + [build_sphinx] source-dir = doc/source diff --git a/storyboard/api/app.py b/storyboard/api/app.py index 665a6095..ef49e0c8 100644 --- a/storyboard/api/app.py +++ b/storyboard/api/app.py @@ -30,6 +30,7 @@ from storyboard.api.v1.search import search_engine from storyboard.notifications.notification_hook import NotificationHook from storyboard.openstack.common.gettextutils import _ # noqa from storyboard.openstack.common import log +from storyboard.plugin.user_preferences import initialize_user_preferences CONF = cfg.CONF @@ -91,6 +92,9 @@ def setup_app(pecan_config=None): search_engine_cls = search_engine_impls.ENGINE_IMPLS[search_engine_name] search_engine.set_engine(search_engine_cls()) + # Load user preference plugins + initialize_user_preferences() + # Setup notifier if CONF.enable_notifications: hooks.append(NotificationHook()) diff --git a/storyboard/api/v1/user_preferences.py b/storyboard/api/v1/user_preferences.py index ad2b0a43..a88daad4 100644 --- a/storyboard/api/v1/user_preferences.py +++ b/storyboard/api/v1/user_preferences.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from oslo.config import cfg from pecan import abort from pecan import request from pecan import rest @@ -22,6 +23,11 @@ import wsmeext.pecan as wsme_pecan from storyboard.api.auth import authorization_checks as checks import storyboard.db.api.users as user_api +from storyboard.openstack.common import log + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) class UserPreferencesController(rest.RestController): @@ -40,7 +46,11 @@ class UserPreferencesController(rest.RestController): @wsme_pecan.wsexpose(types.DictType(unicode, unicode), int, body=types.DictType(unicode, unicode)) def post(self, user_id, body): - """Allow a user to update their preferences. + """Allow a user to update their preferences. Note that a user must + explicitly set a preference value to Null/None to have it deleted. + + :param user_id The ID of the user whose preferences we're updating. + :param body A dictionary of preference values. """ if request.current_user_id != user_id: abort(403) diff --git a/storyboard/db/api/users.py b/storyboard/db/api/users.py index d28598ed..551d414f 100644 --- a/storyboard/db/api/users.py +++ b/storyboard/db/api/users.py @@ -18,6 +18,7 @@ from oslo.db import exception as db_exc from storyboard.common import exception as exc from storyboard.db.api import base as api_base from storyboard.db import models +from storyboard.plugin.user_preferences import PREFERENCE_DEFAULTS def user_get(user_id, filter_non_public=False): @@ -74,6 +75,11 @@ def user_get_preferences(user_id): for pref in preferences: pref_dict[pref.key] = pref.cast_value + # Decorate with plugin defaults. + for key in PREFERENCE_DEFAULTS: + if key not in pref_dict: + pref_dict[key] = PREFERENCE_DEFAULTS[key] + return pref_dict diff --git a/storyboard/plugin/__init__.py b/storyboard/plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/storyboard/plugin/base.py b/storyboard/plugin/base.py new file mode 100644 index 00000000..c26f50d1 --- /dev/null +++ b/storyboard/plugin/base.py @@ -0,0 +1,67 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing permissions and +# limitations under the License. + +import abc +import six + +from oslo.config import cfg +from stevedore.enabled import EnabledExtensionManager + + +CONF = cfg.CONF + + +def is_enabled(ext): + """Check to see whether a plugin should be enabled. Assumes that the + plugin extends PluginBase. + + :param ext: The extension instance to check. + :return: True if it should be enabled. Otherwise false. + """ + return ext.obj.enabled() + + +@six.add_metaclass(abc.ABCMeta) +class PluginBase(object): + """Base class for all storyboard plugins. + + Every storyboard plugin will be provided an instance of the application + configuration, and will then be asked whether it should be enabled. Each + plugin should decide, given the configuration and the environment, + whether it has the necessary resources to operate properly. + """ + + def __init__(self, config): + self.config = config + + @abc.abstractmethod + def enabled(self): + """A method which indicates whether this plugin is properly + configured and should be enabled. If it's ready to go, return True. + Otherwise, return False. + """ + + +class StoryboardPluginLoader(EnabledExtensionManager): + """The storyboard plugin loader, a stevedore abstraction that formalizes + our plugin contract. + """ + + def __init__(self, namespace, on_load_failure_callback=None): + super(StoryboardPluginLoader, self) \ + .__init__(namespace=namespace, + check_func=is_enabled, + invoke_on_load=True, + invoke_args=(CONF,), + on_load_failure_callback=on_load_failure_callback) diff --git a/storyboard/plugin/user_preferences.py b/storyboard/plugin/user_preferences.py new file mode 100644 index 00000000..438c2a67 --- /dev/null +++ b/storyboard/plugin/user_preferences.py @@ -0,0 +1,68 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing permissions and +# limitations under the License. + +import abc + +import six + +from storyboard.openstack.common import log +from storyboard.plugin.base import PluginBase +from storyboard.plugin.base import StoryboardPluginLoader + + +LOG = log.getLogger(__name__) +PREFERENCE_DEFAULTS = dict() + + +def initialize_user_preferences(): + """Initialize any plugins that were installed via pip. This will parse + out all the default preference values into one dictionary for later + use in the API. + """ + manager = StoryboardPluginLoader( + namespace='storyboard.plugin.user_preferences') + + if manager.extensions: + manager.map(load_preferences, PREFERENCE_DEFAULTS) + + +def load_preferences(ext, defaults): + """Load all plugin default preferences into our cache. + + :param ext: The extension that's handling this event. + :param defaults: The current dict of default preferences. + """ + + plugin_defaults = ext.obj.get_default_preferences() + + for key in plugin_defaults: + if key in defaults: + # Let's not error out here. + LOG.error("Duplicate preference key %s found." % (key,)) + else: + defaults[key] = plugin_defaults[key] + + +@six.add_metaclass(abc.ABCMeta) +class UserPreferencesPluginBase(PluginBase): + """Base class for a plugin that provides a set of expected user + preferences and their default values. By extending this plugin, you can + add preferences for your own storyboard plugins and workers, and have + them be manageable via your web client (Your client may need to be + customized). + """ + + @abc.abstractmethod + def get_default_preferences(self): + """Return a dictionary of preferences and their default values.""" diff --git a/storyboard/tests/plugin/__init__.py b/storyboard/tests/plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/storyboard/tests/plugin/test_base.py b/storyboard/tests/plugin/test_base.py new file mode 100644 index 00000000..79aaa8bc --- /dev/null +++ b/storyboard/tests/plugin/test_base.py @@ -0,0 +1,58 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing permissions and +# limitations under the License. + +from stevedore.extension import Extension + +import storyboard.plugin.base as plugin_base +import storyboard.tests.base as base + + +class TestPluginBase(base.TestCase): + def setUp(self): + super(TestPluginBase, self).setUp() + + self.extensions = [] + self.extensions.append(Extension( + 'test_one', None, None, + TestBasePlugin(dict()) + )) + + def test_extensibility(self): + """Assert that we can actually instantiate a plugin.""" + + plugin = TestBasePlugin(dict()) + self.assertIsNotNone(plugin) + self.assertTrue(plugin.enabled()) + + def test_plugin_loader(self): + manager = plugin_base.StoryboardPluginLoader.make_test_instance( + self.extensions, + namespace='storyboard.plugin.testing' + ) + + results = manager.map(self._count_invocations) + + # One must exist. + self.assertEqual(1, len(manager.extensions)) + + # One should be invoked. + self.assertEqual(1, len(results)) + + def _count_invocations(self, ext): + return 1 + + +class TestBasePlugin(plugin_base.PluginBase): + def enabled(self): + return True diff --git a/storyboard/tests/plugin/test_user_preferences.py b/storyboard/tests/plugin/test_user_preferences.py new file mode 100644 index 00000000..7cc76dd3 --- /dev/null +++ b/storyboard/tests/plugin/test_user_preferences.py @@ -0,0 +1,84 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing permissions and +# limitations under the License. + +from stevedore.extension import Extension + +import storyboard.plugin.base as plugin_base +import storyboard.plugin.user_preferences as prefs_base +import storyboard.tests.base as base + + +class TestUserPreferencesPluginBase(base.TestCase): + def setUp(self): + super(TestUserPreferencesPluginBase, self).setUp() + + self.extensions = [] + self.extensions.append(Extension( + 'test_one', None, None, + TestPreferencesPlugin(dict()) + )) + self.extensions.append(Extension( + 'test_two', None, None, + TestOtherPreferencesPlugin(dict()) + )) + + def test_extensibility(self): + """Assert that we can actually instantiate a plugin.""" + + plugin = TestPreferencesPlugin(dict()) + self.assertIsNotNone(plugin) + self.assertTrue(plugin.enabled()) + + def test_plugin_loader(self): + """Perform a single plugin loading run, including two plugins and a + couple of overlapping preferences. + """ + manager = plugin_base.StoryboardPluginLoader.make_test_instance( + self.extensions, + namespace='storyboard.plugin.user_preferences') + + loaded_prefs = dict() + + self.assertEqual(2, len(manager.extensions)) + manager.map(prefs_base.load_preferences, loaded_prefs) + + self.assertTrue("foo" in loaded_prefs) + self.assertTrue("omg" in loaded_prefs) + self.assertTrue("lol" in loaded_prefs) + + self.assertEqual(loaded_prefs["foo"], "baz") + self.assertEqual(loaded_prefs["omg"], "wat") + self.assertEqual(loaded_prefs["lol"], "cat") + + +class TestPreferencesPlugin(prefs_base.UserPreferencesPluginBase): + def get_default_preferences(self): + return { + "foo": "baz", + "omg": "wat" + } + + def enabled(self): + return True + + +class TestOtherPreferencesPlugin(prefs_base.UserPreferencesPluginBase): + def get_default_preferences(self): + return { + "foo": "bar", + "lol": "cat" + } + + def enabled(self): + return True