diff --git a/doc/source/configuration/pluggable_panels.rst b/doc/source/configuration/pluggable_panels.rst index aa94cc6325..a03664f811 100644 --- a/doc/source/configuration/pluggable_panels.rst +++ b/doc/source/configuration/pluggable_panels.rst @@ -107,6 +107,51 @@ 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_TABS`` +-------------- + +.. versionadded:: 14.0.0(Rocky) + +Extra tabs can be added to a tab group implemented in horizon or other +horizon plugins by using this setting. Extra tabs will be shown after +default tabs defined in a corresponding tab group. + +This is a dict setting. A key of the dict specifies a tab group which extra +tab(s) are added. The key must match a full class name of the target tab group. + +A value of the dict is a list of full name of an extra tab classes (where a +module name and a class name must be delimiteed by a period). Tabs specified +via ``EXTRA_TABS`` will be displayed in the order of being registered. + +There might be cases where you would like to specify the order of the extra +tabs as multiple horizon plugins can register extra tabs. You can specify a +priority of each tab in ``EXTRA_TABS`` by using a tuple of priority and a tab +class as an element of a dict value instead of a full name of an extra tab. +Priority is an integer of a tab and a tab with a lower value will be displayed +first. If a priority of an extra tab is omitted, ``0`` is assumed as a priority. + +Example: + +.. code-block:: python + + EXTRA_TABS = { + 'openstack_dashboard.dashboards.project.networks.tabs.NetworkDetailsTabs': ( + 'openstack_dashboard.dashboards.project.networks.subnets.tabs.SubnetsTab', + 'openstack_dashboard.dashboards.project.networks.ports.tabs.PortsTab', + ), + } + +Example (with priority): + +.. code-block:: python + + EXTRA_TABS = { + 'openstack_dashboard.dashboards.project.networks.tabs.NetworkDetailsTabs': ( + (1, 'openstack_dashboard.dashboards.project.networks.subnets.tabs.SubnetsTab'), + (2, 'openstack_dashboard.dashboards.project.networks.ports.tabs.PortsTab'), + ), + } + ``UPDATE_HORIZON_CONFIG`` ------------------------- diff --git a/horizon/tabs/base.py b/horizon/tabs/base.py index d39fdee95c..a32e0d605a 100644 --- a/horizon/tabs/base.py +++ b/horizon/tabs/base.py @@ -13,16 +13,22 @@ # under the License. from collections import OrderedDict +import logging +import operator import sys import six +from django.conf import settings from django.template.loader import render_to_string from django.template import TemplateSyntaxError +from django.utils import module_loading from horizon import exceptions from horizon.utils import html +LOG = logging.getLogger(__name__) + SEPARATOR = "__" CSS_TAB_GROUP_CLASSES = ["nav", "nav-tabs", "ajax-tabs"] CSS_ACTIVE_TAB_CLASSES = ["active"] @@ -36,6 +42,11 @@ class TabGroup(html.HTMLElement): The URL slug and pseudo-unique identifier for this tab group. + .. attribute:: tabs + + A list of :class:`.Tab` classes. Tabs specified here are displayed + in the order of the list. + .. attribute:: template_name The name of the template which will be used to render this tab group. @@ -104,15 +115,55 @@ class TabGroup(html.HTMLElement): self.request = request self.kwargs = kwargs self._data = None - tab_instances = [] - for tab in self.tabs: - tab_instances.append((tab.slug, tab(self, request))) - self._tabs = OrderedDict(tab_instances) + self._tabs = self._load_tabs(request) if self.sticky: self.attrs['data-sticky-tabs'] = 'sticky' if not self._set_active_tab(): self.tabs_not_available() + def _load_tabs(self, request): + tabs = tuple(self.tabs) + tabs += self._load_tabs_from_config() + return OrderedDict([(tab.slug, tab(self, request)) + for tab in tabs]) + + def _load_tabs_from_config(self): + my_name = '.'.join([self.__class__.__module__, + self.__class__.__name__]) + horizon_config = settings.HORIZON_CONFIG.get('extra_tabs', {}) + tabs_config = [self._load_tab_config(tab_config, my_name) + for tab_config in horizon_config.get(my_name, [])] + tabs_config = [t for t in tabs_config if t] + tabs_config.sort(key=operator.itemgetter(0)) + LOG.debug('Loaded extra tabs for %s: %s', + my_name, tabs_config) + return tuple(x[1] for x in tabs_config) + + @staticmethod + def _load_tab_config(tab_config, my_name): + if isinstance(tab_config, str): + tab_config = (0, tab_config) + if not isinstance(tab_config, (tuple, list)): + LOG.error('Extra tab definition must be a string or ' + 'a tuple/list (tab group "%s")', my_name) + return + if len(tab_config) != 2: + LOG.error('Entry must be a format of (priority, tab class) ' + '(tab group "%s")', my_name) + return + priority, tab_class = tab_config + if not isinstance(priority, int): + LOG.error('Priority of tab entry must be an integer ' + '(tab group "%s")', my_name) + return + try: + class_ = module_loading.import_string(tab_class) + except ImportError: + LOG.error('Tab class "%s" is not found (tab group "%s")', + tab_class, my_name) + return + return priority, class_ + def __repr__(self): return "<%s: %s>" % (self.__class__.__name__, self.slug) diff --git a/horizon/test/helpers.py b/horizon/test/helpers.py index 06f5fcdb3d..9a0dadee54 100644 --- a/horizon/test/helpers.py +++ b/horizon/test/helpers.py @@ -16,12 +16,15 @@ # License for the specific language governing permissions and limitations # under the License. +import collections +import copy import logging import os import socket import time import unittest +from django.conf import settings from django.contrib.auth.middleware import AuthenticationMiddleware from django.contrib.auth.models import Permission from django.contrib.auth.models import User @@ -32,6 +35,7 @@ from django.core.handlers import wsgi from django import http from django import test as django_test from django.test.client import RequestFactory +from django.test import utils as django_test_utils from django.utils.encoding import force_text import six @@ -331,3 +335,28 @@ class JasmineTests(SeleniumTestCase): if self.__class__ == JasmineTests: return self.run_jasmine() + + +class update_settings(django_test_utils.override_settings): + """override_settings which allows override an item in dict. + + django original override_settings replaces a dict completely, + however OpenStack dashboard setting has many dictionary configuration + and there are test case where we want to override only one item in + a dictionary and keep other items in the dictionary. + This version of override_settings allows this if keep_dict is True. + + If keep_dict False is specified, the original behavior of + Django override_settings is used. + """ + + def __init__(self, keep_dict=True, **kwargs): + if keep_dict: + for key, new_value in kwargs.items(): + value = getattr(settings, key, None) + if (isinstance(new_value, collections.Mapping) and + isinstance(value, collections.Mapping)): + copied = copy.copy(value) + copied.update(new_value) + kwargs[key] = copied + super(update_settings, self).__init__(**kwargs) diff --git a/horizon/test/unit/tabs/test_tabs.py b/horizon/test/unit/tabs/test_tabs.py index 235674011d..3ad4aebdad 100644 --- a/horizon/test/unit/tabs/test_tabs.py +++ b/horizon/test/unit/tabs/test_tabs.py @@ -75,6 +75,12 @@ class Group(horizon_tabs.TabGroup): self._assert_tabs_not_available = True +class GroupWithConfig(horizon_tabs.TabGroup): + slug = "tab_group" + tabs = (TabOne, TabDisallowed) + sticky = True + + class TabWithTable(horizon_tabs.TableTab): table_classes = (MyTable,) name = "Tab With My Table" @@ -144,6 +150,24 @@ class TabTests(test.TestCase): # Test get_selected_tab is None w/o GET input self.assertIsNone(tg.get_selected_tab()) + @test.update_settings( + HORIZON_CONFIG={'extra_tabs': { + 'horizon.test.unit.tabs.test_tabs.GroupWithConfig': ( + (2, 'horizon.test.unit.tabs.test_tabs.TabDelayed'), + # No priority means priority 0 + 'horizon.test.unit.tabs.test_tabs.TabDisabled', + ), + }} + ) + def test_tab_group_with_config(self): + tg = GroupWithConfig(self.request) + tabs = tg.get_tabs() + # "tab_disallowed" should NOT be in this list. + # Other tabs must be ordered in the priorities in HORIZON_CONFIG. + self.assertQuerysetEqual(tabs, ['', + '', + '']) + def test_tab_group_active_tab(self): tg = Group(self.request) diff --git a/openstack_dashboard/test/helpers.py b/openstack_dashboard/test/helpers.py index 8d3fb12020..81fa972da8 100644 --- a/openstack_dashboard/test/helpers.py +++ b/openstack_dashboard/test/helpers.py @@ -16,8 +16,6 @@ # License for the specific language governing permissions and limitations # under the License. -import collections -import copy from functools import wraps from importlib import import_module import logging @@ -30,7 +28,6 @@ from django.contrib.messages.storage import default_storage from django.core.handlers import wsgi from django import http as http_request from django.test.client import RequestFactory -from django.test import utils as django_test_utils from django import urls from django.utils import http @@ -69,6 +66,8 @@ LOG = logging.getLogger(__name__) # Makes output of failing mox tests much easier to read. wsgi.WSGIRequest.__repr__ = lambda self: "" +update_settings = horizon_helpers.update_settings + def create_stubs(stubs_to_create=None): """decorator to simplify setting up multiple stubs at once via mox @@ -754,31 +753,6 @@ class PluginTestCase(TestCase): base.Horizon._urls() -class update_settings(django_test_utils.override_settings): - """override_settings which allows override an item in dict. - - django original override_settings replaces a dict completely, - however OpenStack dashboard setting has many dictionary configuration - and there are test case where we want to override only one item in - a dictionary and keep other items in the dictionary. - This version of override_settings allows this if keep_dict is True. - - If keep_dict False is specified, the original behavior of - Django override_settings is used. - """ - - def __init__(self, keep_dict=True, **kwargs): - if keep_dict: - for key, new_value in kwargs.items(): - value = getattr(settings, key, None) - if (isinstance(new_value, collections.Mapping) and - isinstance(value, collections.Mapping)): - copied = copy.copy(value) - copied.update(new_value) - kwargs[key] = copied - super(update_settings, self).__init__(**kwargs) - - def mock_obj_to_dict(r): return mock.Mock(**{'to_dict.return_value': r}) diff --git a/openstack_dashboard/utils/settings.py b/openstack_dashboard/utils/settings.py index 8b5925acd3..145596d802 100644 --- a/openstack_dashboard/utils/settings.py +++ b/openstack_dashboard/utils/settings.py @@ -112,6 +112,7 @@ def update_dashboards(modules, horizon_config, installed_apps): scss_files = [] panel_customization = [] header_sections = [] + extra_tabs = {} update_horizon_config = {} for key, config in import_dashboard_config(modules): if config.get('DISABLED', False): @@ -154,6 +155,9 @@ 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 # Preserve the dashboard order specified in settings dashboards = ([d for d in config_dashboards if d not in disabled_dashboards] + @@ -169,6 +173,7 @@ def update_dashboards(modules, horizon_config, installed_apps): horizon_config.setdefault('js_files', []).extend(js_files) horizon_config.setdefault('js_spec_files', []).extend(js_spec_files) horizon_config.setdefault('scss_files', []).extend(scss_files) + horizon_config['extra_tabs'] = extra_tabs # 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-tabs-7b7467e7c64d1e5b.yaml b/releasenotes/notes/pluggable-tabs-7b7467e7c64d1e5b.yaml new file mode 100644 index 0000000000..ca839c75ce --- /dev/null +++ b/releasenotes/notes/pluggable-tabs-7b7467e7c64d1e5b.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + [:bug:`1746754`] + (for horizon plugin developers) Django tab is now pluggable and horizon + plugins can add extra tab(s) to an existing tab provided by horizon or + other horizon plugins. Extra tabs 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.