TabGroup: Make tabs pluggable via horizon plugin config
This commit enhances django tab implementation to allow horizon plugins to add tabs to a tab group in other repository like the main horizon repo. New setting "EXTRA_TABS" is introduced to the horizon plugin 'enabled' file. To this aim, the tab group class looks up HORIZON_CONFIG['extra_tabs'] with its class full name and loads them as extra tabs if any. HORIZON_CONFIG['extra_tabs'] are populated via horizon plugin settings. This commit moves update_settings in openstack_dashboard.test.helpers to horizon as I would like to use it in a new horizon unit test. blueprint horizon-plugin-tab-for-info-and-quotas Closes-Bug: #1746754 Change-Id: Ice2469a90553754929826d14d20b4719bd1f62d3
This commit is contained in:
parent
ec14dd91bd
commit
a987c039cf
@ -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``
|
||||
-------------------------
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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, ['<TabOne: tab_one>',
|
||||
'<TabDisabled: tab_disabled>',
|
||||
'<TabDelayed: tab_delayed>'])
|
||||
|
||||
def test_tab_group_active_tab(self):
|
||||
tg = Group(self.request)
|
||||
|
||||
|
@ -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: "<class 'django.http.HttpRequest'>"
|
||||
|
||||
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})
|
||||
|
||||
|
@ -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
|
||||
|
10
releasenotes/notes/pluggable-tabs-7b7467e7c64d1e5b.yaml
Normal file
10
releasenotes/notes/pluggable-tabs-7b7467e7c64d1e5b.yaml
Normal file
@ -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 <https://docs.openstack.org/horizon/latest/configuration/pluggable_panels.html#extra-tabs>`__
|
||||
of the horizon documentation.
|
Loading…
Reference in New Issue
Block a user