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
This commit is contained in:
Akihiro Motoki 2018-04-12 06:33:40 +09:00
parent 07237c1fc6
commit e50a69d69f
5 changed files with 136 additions and 17 deletions

View File

@ -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``
--------------

View File

@ -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,
['<TestStepOne: test_action_one>',
'<TestExtraStep: test_action_three>',
'<TestStepThree: test_action_three>',
'<TestStepTwo: test_action_two>'])
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,
['<TestStepOne: test_action_one>',
'<TestStepThree: test_action_three>',
'<TestStepTwo: test_action_two>',
'<TestStepFour: test_action_four>',
])
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):
['<TestStepOne: test_action_one>',
'<TestStepTwo: test_action_two>'])
TestWorkflow.register(TestExtraStep)
TestWorkflow.register(TestStepThree)
flow = TestWorkflow(req)
self.assertQuerysetEqual(flow.steps,
['<TestStepOne: test_action_one>',
'<TestExtraStep: test_action_three>',
'<TestStepThree: test_action_three>',
'<TestStepTwo: test_action_two>'])
def test_workflow_render(self):
TestWorkflow.register(TestExtraStep)
TestWorkflow.register(TestStepThree)
req = self.factory.get("/foo")
flow = TestWorkflow(req)
output = http.HttpResponse(flow.render())

View File

@ -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

View File

@ -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

View File

@ -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 <https://docs.openstack.org/horizon/latest/configuration/pluggable_panels.html#extra-steps>`__
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.