From e50a69d69f338d0ec66baa659a7d82e328e62a67 Mon Sep 17 00:00:00 2001 From: Akihiro Motoki Date: Thu, 12 Apr 2018 06:33:40 +0900 Subject: [PATCH] Workflow: Make steps pluggable via horizon plugin config This commit enhances django workflow implementation to allow horizon plugins to add workflow steps to a workflow in other repository like the main horizon repo. New setting "EXTRA_STEPS" is introduced to the horizon plugin 'enabled' file. To this aim, the workflow class looks up HORIZON_CONFIG['extra_steps'] with its class full name and loads them as extra steps if any. HORIZON_CONFIG['extra_steps'] are populated via horizon plugin settings. This commit completes the blueprint. blueprint horizon-plugin-tab-for-info-and-quotas Change-Id: I347d113f47587932e4f583d3152e781ad1a4849f --- doc/source/configuration/pluggable_panels.rst | 30 +++++++++++ horizon/test/unit/workflows/test_workflows.py | 52 ++++++++++++++++--- horizon/workflows/base.py | 37 +++++++++++-- openstack_dashboard/utils/settings.py | 13 +++-- ...gable-workflow-steps-c919cdd8b0cbea55.yaml | 21 ++++++++ 5 files changed, 136 insertions(+), 17 deletions(-) create mode 100644 releasenotes/notes/pluggable-workflow-steps-c919cdd8b0cbea55.yaml diff --git a/doc/source/configuration/pluggable_panels.rst b/doc/source/configuration/pluggable_panels.rst index 3cc8a49534..980347d359 100644 --- a/doc/source/configuration/pluggable_panels.rst +++ b/doc/source/configuration/pluggable_panels.rst @@ -130,6 +130,36 @@ listed there will be appended to the auto-discovered files. If set to ``True``, this settings file will not be added to the settings. +``EXTRA_STEPS`` +--------------- + +.. versionadded:: 14.0.0(Rocky) + +Extra workflow steps can be added to a workflow in horizon or other +horizon plugins by using this setting. Extra steps will be shown after +default steps defined in a corresponding workflow. + +This is a dict setting. A key of the dict specifies a workflow which extra +step(s) are added. The key must match a full class name of the target workflow. + +A value of the dict is a list of full name of an extra step classes (where a +module name and a class name must be delimiteed by a period). Steps specified +via ``EXTRA_STEPS`` will be displayed in the order of being registered. + +Example: + +.. code-block:: python + + EXTRA_STEPS = { + 'openstack_dashboard.dashboards.identity.projects.workflows.UpdateQuota': + ( + ('openstack_dashboard.dashboards.identity.projects.workflows.' + 'UpdateVolumeQuota'), + ('openstack_dashboard.dashboards.identity.projects.workflows.' + 'UpdateNetworkQuota'), + ), + } + ``EXTRA_TABS`` -------------- diff --git a/horizon/test/unit/workflows/test_workflows.py b/horizon/test/unit/workflows/test_workflows.py index 6ac215e22b..8dd0c85bde 100644 --- a/horizon/test/unit/workflows/test_workflows.py +++ b/horizon/test/unit/workflows/test_workflows.py @@ -73,6 +73,14 @@ class TestActionThree(workflows.Action): slug = "test_action_three" +class TestActionFour(workflows.Action): + field_four = forms.CharField(widget=forms.widgets.Textarea) + + class Meta(object): + name = "Test Action Four" + slug = "test_action_four" + + class AdminAction(workflows.Action): admin_id = forms.CharField(label="Admin") @@ -114,7 +122,7 @@ class TestStepTwo(workflows.Step): "other_callback_func")} -class TestExtraStep(workflows.Step): +class TestStepThree(workflows.Step): action_class = TestActionThree depends_on = ("project_id",) contributes = ("extra_data",) @@ -123,6 +131,11 @@ class TestExtraStep(workflows.Step): before = TestStepTwo +class TestStepFour(workflows.Step): + action_class = TestActionFour + contributes = ("field_four",) + + class AdminStep(workflows.Step): action_class = AdminAction contributes = ("admin_id",) @@ -147,6 +160,11 @@ class TestWorkflow(workflows.Workflow): default_steps = (TestStepOne, TestStepTwo) +class TestWorkflowWithConfig(workflows.Workflow): + slug = "test_workflow" + default_steps = (TestStepOne,) + + class TestWorkflowView(workflows.WorkflowView): workflow_class = TestWorkflow template_name = "workflow.html" @@ -176,17 +194,35 @@ class WorkflowsTests(test.TestCase): self._reset_workflow() def _reset_workflow(self): - TestWorkflow._cls_registry = set([]) + TestWorkflow._cls_registry = [] def test_workflow_construction(self): - TestWorkflow.register(TestExtraStep) + TestWorkflow.register(TestStepThree) flow = TestWorkflow(self.request) self.assertQuerysetEqual(flow.steps, ['', - '', + '', '']) self.assertEqual(set(['project_id']), flow.depends_on) + @test.update_settings(HORIZON_CONFIG={'extra_steps': { + 'horizon.test.unit.workflows.test_workflows.TestWorkflowWithConfig': ( + 'horizon.test.unit.workflows.test_workflows.TestStepTwo', + 'horizon.test.unit.workflows.test_workflows.TestStepThree', + 'horizon.test.unit.workflows.test_workflows.TestStepFour', + ), + }}) + def test_workflow_construction_with_config(self): + flow = TestWorkflowWithConfig(self.request) + # NOTE: TestStepThree must be placed between TestStepOne and + # TestStepTwo in honor of before/after of TestStepThree. + self.assertQuerysetEqual(flow.steps, + ['', + '', + '', + '', + ]) + def test_step_construction(self): step_one = TestStepOne(TestWorkflow(self.request)) # Action slug is moved from Meta by metaclass, and @@ -236,7 +272,7 @@ class WorkflowsTests(test.TestCase): InvalidStep(TestWorkflow(self.request)) def test_connection_handlers_called(self): - TestWorkflow.register(TestExtraStep) + TestWorkflow.register(TestStepThree) flow = TestWorkflow(self.request) # This should set the value without any errors, but trigger nothing @@ -292,15 +328,15 @@ class WorkflowsTests(test.TestCase): ['', '']) - TestWorkflow.register(TestExtraStep) + TestWorkflow.register(TestStepThree) flow = TestWorkflow(req) self.assertQuerysetEqual(flow.steps, ['', - '', + '', '']) def test_workflow_render(self): - TestWorkflow.register(TestExtraStep) + TestWorkflow.register(TestStepThree) req = self.factory.get("/foo") flow = TestWorkflow(req) output = http.HttpResponse(flow.render()) diff --git a/horizon/workflows/base.py b/horizon/workflows/base.py index c06d6f6b14..a27d73dc9e 100644 --- a/horizon/workflows/base.py +++ b/horizon/workflows/base.py @@ -12,11 +12,13 @@ # License for the specific language governing permissions and limitations # under the License. +import collections import copy from importlib import import_module import inspect import logging +from django.conf import settings from django import forms from django.forms.forms import NON_FIELD_ERRORS from django import template @@ -25,6 +27,7 @@ from django.template.defaultfilters import safe from django.template.defaultfilters import slugify from django import urls from django.utils.encoding import force_text +from django.utils import module_loading from django.utils.translation import ugettext_lazy as _ from openstack_auth import policy import six @@ -481,7 +484,7 @@ class Step(object): class WorkflowMetaclass(type): def __new__(mcs, name, bases, attrs): super(WorkflowMetaclass, mcs).__new__(mcs, name, bases, attrs) - attrs["_cls_registry"] = set([]) + attrs["_cls_registry"] = [] return type.__new__(mcs, name, bases, attrs) @@ -652,12 +655,15 @@ class Workflow(html.HTMLElement): self.entry_point = entry_point self.object = None + self._register_steps_from_config() + # Put together our steps in order. Note that we pre-register # non-default steps so that we can identify them and subsequently # insert them in order correctly. - self._registry = dict([(step_class, step_class(self)) for step_class - in self.__class__._cls_registry - if step_class not in self.default_steps]) + self._registry = collections.OrderedDict( + [(step_class, step_class(self)) for step_class + in self.__class__._cls_registry + if step_class not in self.default_steps]) self._gather_steps() # Determine all the context data we need to end up with. @@ -698,6 +704,27 @@ class Workflow(html.HTMLElement): if step.slug == slug: return step + def _register_steps_from_config(self): + my_name = '.'.join([self.__class__.__module__, + self.__class__.__name__]) + horizon_config = settings.HORIZON_CONFIG.get('extra_steps', {}) + extra_steps = horizon_config.get(my_name, []) + for step in extra_steps: + self._register_step_from_config(step, my_name) + + def _register_step_from_config(self, step_config, my_name): + if not isinstance(step_config, str): + LOG.error('Extra step definition must be a string ' + '(workflow "%s"', my_name) + return + try: + class_ = module_loading.import_string(step_config) + except ImportError: + LOG.error('Step class "%s" is not found (workflow "%s")', + step_config, my_name) + return + self.register(class_) + def _gather_steps(self): ordered_step_classes = self._order_steps() for default_step in self.default_steps: @@ -775,7 +802,7 @@ class Workflow(html.HTMLElement): if step_class in cls._cls_registry: return False else: - cls._cls_registry.add(step_class) + cls._cls_registry.append(step_class) return True @classmethod diff --git a/openstack_dashboard/utils/settings.py b/openstack_dashboard/utils/settings.py index 4100bc54c0..1ce62d08d5 100644 --- a/openstack_dashboard/utils/settings.py +++ b/openstack_dashboard/utils/settings.py @@ -113,7 +113,8 @@ def update_dashboards(modules, horizon_config, installed_apps): xstatic_modules = [] panel_customization = [] header_sections = [] - extra_tabs = {} + extra_tabs = collections.defaultdict(tuple) + extra_steps = collections.defaultdict(tuple) update_horizon_config = {} for key, config in import_dashboard_config(modules): if config.get('DISABLED', False): @@ -157,9 +158,12 @@ def update_dashboards(modules, horizon_config, installed_apps): elif config.get('PANEL') or config.get('PANEL_GROUP'): config.pop("__builtins__", None) panel_customization.append(config) - _extra_tabs = config.get('EXTRA_TABS', {}).items() - for tab_key, tab_defs in _extra_tabs: - extra_tabs[tab_key] = extra_tabs.get(tab_key, tuple()) + tab_defs + _extra_tabs = config.get('EXTRA_TABS', {}) + for tab_key, tab_defs in _extra_tabs.items(): + extra_tabs[tab_key] += tuple(tab_defs) + _extra_steps = config.get('EXTRA_STEPS', {}) + for step_key, step_defs in _extra_steps.items(): + extra_steps[step_key] += tuple(step_defs) # Preserve the dashboard order specified in settings dashboards = ([d for d in config_dashboards if d not in disabled_dashboards] + @@ -177,6 +181,7 @@ def update_dashboards(modules, horizon_config, installed_apps): horizon_config.setdefault('scss_files', []).extend(scss_files) horizon_config.setdefault('xstatic_modules', []).extend(xstatic_modules) horizon_config['extra_tabs'] = extra_tabs + horizon_config['extra_steps'] = extra_steps # apps contains reference to applications declared in the enabled folder # basically a list of applications that are internal and external plugins diff --git a/releasenotes/notes/pluggable-workflow-steps-c919cdd8b0cbea55.yaml b/releasenotes/notes/pluggable-workflow-steps-c919cdd8b0cbea55.yaml new file mode 100644 index 0000000000..df88ae7acb --- /dev/null +++ b/releasenotes/notes/pluggable-workflow-steps-c919cdd8b0cbea55.yaml @@ -0,0 +1,21 @@ +--- +features: + - | + Quota information panel and forms are now tabbified per back-end service. + + - Admin -> Defaults -> Default Quotas table + - Admin -> Defaults -> Update Defaults form + - Identity -> Projects -> Modify Quotas form + + - | + [:blueprint:`horizon-plugin-tab-for-info-and-quotas`] + (for horizon plugin developers) Django workflow step is now pluggable and + horizon plugins can add extra step(s) to an existing workflow provided by + horizon or other horizon plugins. Extra steps can be added via the horizon + plugin “enabled” file. For more detail, see ``EXTRA_TABS`` description in + `Pluggable Panels and Groups `__ + of the horizon documentation. +upgrade: + - The "Quotas" tab in the "Create Project" form was split out into + a new separate form "Modify Quotas". Quotas for a new project need to + be configured from "Modify Quotas" action after creating a new project.