Merge "Workflow: Make steps pluggable via horizon plugin config"
This commit is contained in:
commit
7c5cabacec
@ -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.
|
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``
|
``EXTRA_TABS``
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
|
@ -73,6 +73,14 @@ class TestActionThree(workflows.Action):
|
|||||||
slug = "test_action_three"
|
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):
|
class AdminAction(workflows.Action):
|
||||||
admin_id = forms.CharField(label="Admin")
|
admin_id = forms.CharField(label="Admin")
|
||||||
|
|
||||||
@ -114,7 +122,7 @@ class TestStepTwo(workflows.Step):
|
|||||||
"other_callback_func")}
|
"other_callback_func")}
|
||||||
|
|
||||||
|
|
||||||
class TestExtraStep(workflows.Step):
|
class TestStepThree(workflows.Step):
|
||||||
action_class = TestActionThree
|
action_class = TestActionThree
|
||||||
depends_on = ("project_id",)
|
depends_on = ("project_id",)
|
||||||
contributes = ("extra_data",)
|
contributes = ("extra_data",)
|
||||||
@ -123,6 +131,11 @@ class TestExtraStep(workflows.Step):
|
|||||||
before = TestStepTwo
|
before = TestStepTwo
|
||||||
|
|
||||||
|
|
||||||
|
class TestStepFour(workflows.Step):
|
||||||
|
action_class = TestActionFour
|
||||||
|
contributes = ("field_four",)
|
||||||
|
|
||||||
|
|
||||||
class AdminStep(workflows.Step):
|
class AdminStep(workflows.Step):
|
||||||
action_class = AdminAction
|
action_class = AdminAction
|
||||||
contributes = ("admin_id",)
|
contributes = ("admin_id",)
|
||||||
@ -147,6 +160,11 @@ class TestWorkflow(workflows.Workflow):
|
|||||||
default_steps = (TestStepOne, TestStepTwo)
|
default_steps = (TestStepOne, TestStepTwo)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkflowWithConfig(workflows.Workflow):
|
||||||
|
slug = "test_workflow"
|
||||||
|
default_steps = (TestStepOne,)
|
||||||
|
|
||||||
|
|
||||||
class TestWorkflowView(workflows.WorkflowView):
|
class TestWorkflowView(workflows.WorkflowView):
|
||||||
workflow_class = TestWorkflow
|
workflow_class = TestWorkflow
|
||||||
template_name = "workflow.html"
|
template_name = "workflow.html"
|
||||||
@ -176,17 +194,35 @@ class WorkflowsTests(test.TestCase):
|
|||||||
self._reset_workflow()
|
self._reset_workflow()
|
||||||
|
|
||||||
def _reset_workflow(self):
|
def _reset_workflow(self):
|
||||||
TestWorkflow._cls_registry = set([])
|
TestWorkflow._cls_registry = []
|
||||||
|
|
||||||
def test_workflow_construction(self):
|
def test_workflow_construction(self):
|
||||||
TestWorkflow.register(TestExtraStep)
|
TestWorkflow.register(TestStepThree)
|
||||||
flow = TestWorkflow(self.request)
|
flow = TestWorkflow(self.request)
|
||||||
self.assertQuerysetEqual(flow.steps,
|
self.assertQuerysetEqual(flow.steps,
|
||||||
['<TestStepOne: test_action_one>',
|
['<TestStepOne: test_action_one>',
|
||||||
'<TestExtraStep: test_action_three>',
|
'<TestStepThree: test_action_three>',
|
||||||
'<TestStepTwo: test_action_two>'])
|
'<TestStepTwo: test_action_two>'])
|
||||||
self.assertEqual(set(['project_id']), flow.depends_on)
|
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):
|
def test_step_construction(self):
|
||||||
step_one = TestStepOne(TestWorkflow(self.request))
|
step_one = TestStepOne(TestWorkflow(self.request))
|
||||||
# Action slug is moved from Meta by metaclass, and
|
# Action slug is moved from Meta by metaclass, and
|
||||||
@ -236,7 +272,7 @@ class WorkflowsTests(test.TestCase):
|
|||||||
InvalidStep(TestWorkflow(self.request))
|
InvalidStep(TestWorkflow(self.request))
|
||||||
|
|
||||||
def test_connection_handlers_called(self):
|
def test_connection_handlers_called(self):
|
||||||
TestWorkflow.register(TestExtraStep)
|
TestWorkflow.register(TestStepThree)
|
||||||
flow = TestWorkflow(self.request)
|
flow = TestWorkflow(self.request)
|
||||||
|
|
||||||
# This should set the value without any errors, but trigger nothing
|
# This should set the value without any errors, but trigger nothing
|
||||||
@ -292,15 +328,15 @@ class WorkflowsTests(test.TestCase):
|
|||||||
['<TestStepOne: test_action_one>',
|
['<TestStepOne: test_action_one>',
|
||||||
'<TestStepTwo: test_action_two>'])
|
'<TestStepTwo: test_action_two>'])
|
||||||
|
|
||||||
TestWorkflow.register(TestExtraStep)
|
TestWorkflow.register(TestStepThree)
|
||||||
flow = TestWorkflow(req)
|
flow = TestWorkflow(req)
|
||||||
self.assertQuerysetEqual(flow.steps,
|
self.assertQuerysetEqual(flow.steps,
|
||||||
['<TestStepOne: test_action_one>',
|
['<TestStepOne: test_action_one>',
|
||||||
'<TestExtraStep: test_action_three>',
|
'<TestStepThree: test_action_three>',
|
||||||
'<TestStepTwo: test_action_two>'])
|
'<TestStepTwo: test_action_two>'])
|
||||||
|
|
||||||
def test_workflow_render(self):
|
def test_workflow_render(self):
|
||||||
TestWorkflow.register(TestExtraStep)
|
TestWorkflow.register(TestStepThree)
|
||||||
req = self.factory.get("/foo")
|
req = self.factory.get("/foo")
|
||||||
flow = TestWorkflow(req)
|
flow = TestWorkflow(req)
|
||||||
output = http.HttpResponse(flow.render())
|
output = http.HttpResponse(flow.render())
|
||||||
|
@ -12,11 +12,13 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
import collections
|
||||||
import copy
|
import copy
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.forms.forms import NON_FIELD_ERRORS
|
from django.forms.forms import NON_FIELD_ERRORS
|
||||||
from django import template
|
from django import template
|
||||||
@ -25,6 +27,7 @@ from django.template.defaultfilters import safe
|
|||||||
from django.template.defaultfilters import slugify
|
from django.template.defaultfilters import slugify
|
||||||
from django import urls
|
from django import urls
|
||||||
from django.utils.encoding import force_text
|
from django.utils.encoding import force_text
|
||||||
|
from django.utils import module_loading
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from openstack_auth import policy
|
from openstack_auth import policy
|
||||||
import six
|
import six
|
||||||
@ -481,7 +484,7 @@ class Step(object):
|
|||||||
class WorkflowMetaclass(type):
|
class WorkflowMetaclass(type):
|
||||||
def __new__(mcs, name, bases, attrs):
|
def __new__(mcs, name, bases, attrs):
|
||||||
super(WorkflowMetaclass, mcs).__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)
|
return type.__new__(mcs, name, bases, attrs)
|
||||||
|
|
||||||
|
|
||||||
@ -652,10 +655,13 @@ class Workflow(html.HTMLElement):
|
|||||||
self.entry_point = entry_point
|
self.entry_point = entry_point
|
||||||
self.object = None
|
self.object = None
|
||||||
|
|
||||||
|
self._register_steps_from_config()
|
||||||
|
|
||||||
# Put together our steps in order. Note that we pre-register
|
# Put together our steps in order. Note that we pre-register
|
||||||
# non-default steps so that we can identify them and subsequently
|
# non-default steps so that we can identify them and subsequently
|
||||||
# insert them in order correctly.
|
# insert them in order correctly.
|
||||||
self._registry = dict([(step_class, step_class(self)) for step_class
|
self._registry = collections.OrderedDict(
|
||||||
|
[(step_class, step_class(self)) for step_class
|
||||||
in self.__class__._cls_registry
|
in self.__class__._cls_registry
|
||||||
if step_class not in self.default_steps])
|
if step_class not in self.default_steps])
|
||||||
self._gather_steps()
|
self._gather_steps()
|
||||||
@ -698,6 +704,27 @@ class Workflow(html.HTMLElement):
|
|||||||
if step.slug == slug:
|
if step.slug == slug:
|
||||||
return step
|
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):
|
def _gather_steps(self):
|
||||||
ordered_step_classes = self._order_steps()
|
ordered_step_classes = self._order_steps()
|
||||||
for default_step in self.default_steps:
|
for default_step in self.default_steps:
|
||||||
@ -775,7 +802,7 @@ class Workflow(html.HTMLElement):
|
|||||||
if step_class in cls._cls_registry:
|
if step_class in cls._cls_registry:
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
cls._cls_registry.add(step_class)
|
cls._cls_registry.append(step_class)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -113,7 +113,8 @@ def update_dashboards(modules, horizon_config, installed_apps):
|
|||||||
xstatic_modules = []
|
xstatic_modules = []
|
||||||
panel_customization = []
|
panel_customization = []
|
||||||
header_sections = []
|
header_sections = []
|
||||||
extra_tabs = {}
|
extra_tabs = collections.defaultdict(tuple)
|
||||||
|
extra_steps = collections.defaultdict(tuple)
|
||||||
update_horizon_config = {}
|
update_horizon_config = {}
|
||||||
for key, config in import_dashboard_config(modules):
|
for key, config in import_dashboard_config(modules):
|
||||||
if config.get('DISABLED', False):
|
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'):
|
elif config.get('PANEL') or config.get('PANEL_GROUP'):
|
||||||
config.pop("__builtins__", None)
|
config.pop("__builtins__", None)
|
||||||
panel_customization.append(config)
|
panel_customization.append(config)
|
||||||
_extra_tabs = config.get('EXTRA_TABS', {}).items()
|
_extra_tabs = config.get('EXTRA_TABS', {})
|
||||||
for tab_key, tab_defs in _extra_tabs:
|
for tab_key, tab_defs in _extra_tabs.items():
|
||||||
extra_tabs[tab_key] = extra_tabs.get(tab_key, tuple()) + tab_defs
|
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
|
# Preserve the dashboard order specified in settings
|
||||||
dashboards = ([d for d in config_dashboards
|
dashboards = ([d for d in config_dashboards
|
||||||
if d not in disabled_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('scss_files', []).extend(scss_files)
|
||||||
horizon_config.setdefault('xstatic_modules', []).extend(xstatic_modules)
|
horizon_config.setdefault('xstatic_modules', []).extend(xstatic_modules)
|
||||||
horizon_config['extra_tabs'] = extra_tabs
|
horizon_config['extra_tabs'] = extra_tabs
|
||||||
|
horizon_config['extra_steps'] = extra_steps
|
||||||
|
|
||||||
# apps contains reference to applications declared in the enabled folder
|
# apps contains reference to applications declared in the enabled folder
|
||||||
# basically a list of applications that are internal and external plugins
|
# basically a list of applications that are internal and external plugins
|
||||||
|
@ -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.
|
Loading…
Reference in New Issue
Block a user