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:
Akihiro Motoki 2018-02-02 02:33:23 +09:00
parent ec14dd91bd
commit a987c039cf
7 changed files with 170 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.