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