Plugin-based panel configuration

This is an extension to the plugin-based dashboard configuration.
It adds support processing panel configuration configuration file
in the openstack_dashboard/enabled/ directory.

Panels can be added, removed to/from the panel group of a dashboard.
It also provide the ability to update the default panel of the
dashboard.

Change-Id: I2d7adfb8045c244ec063a6741e3b9fe21c188525
Implements: blueprint plugin-panel-config
This commit is contained in:
lin-hua-cheng 2014-02-26 21:23:12 -08:00 committed by Lin Hua Cheng
parent 5be0a3e950
commit f169ee58ab
18 changed files with 393 additions and 13 deletions

View File

@ -434,7 +434,7 @@ settings.
The default location for the dashboard configuration files is
``openstack_dashboard/enabled``, with another directory,
``openstack_dashboarrd/local/enabled`` for local overrides. Both sets of files
``openstack_dashboard/local/enabled`` for local overrides. Both sets of files
will be loaded, but the settings in ``openstack_dashboard/local/enabled`` will
overwrite the default ones. The settings are applied in alphabetical order of
the filenames. If the same dashboard has configuration files in ``enabled`` and
@ -493,3 +493,91 @@ create a file ``openstack_dashboard/local/enabled/_50_tuskar.py`` with::
'not_found': exceptions.NOT_FOUND,
'unauthorized': exceptions.UNAUTHORIZED,
}
Pluggable Settings for Panels
=================================
Panels customization can be made by providing a custom python module that
contains python code to add or remove panel to/from the dashboard. This
requires altering the settings file. For panels provided by third-party,
making this changes to add the panel is challenging. Panel configuration
files can now be dropped to a specified location and it will be read at startup
to alter the dashboard configuration.
The default location for the panel configuration files is
``openstack_dashboard/enabled``, with another directory,
``openstack_dashboard/local/enabled`` for local overrides. Both sets of files
will be loaded, but the settings in ``openstack_dashboard/local/enabled`` will
overwrite the default ones. The settings are applied in alphabetical order of
the filenames. If the same panel has configuration files in ``enabled`` and
``local/enabled``, the local name will be used. Note, that since names of
python modules can't start with a digit, the files are usually named with a
leading underscore and a number, so that you can control their order easily.
The files contain following keys:
``PANEL``
-------------
The name of the panel to be added to ``HORIZON_CONFIG``. Required.
``PANEL_DASHBOARD``
-------------
The name of the dashboard the ``PANEL`` associated with. Required.
``PANEL_GROUP``
-------------
The name of the panel group the ``PANEL`` is associated with.
``DEFAULT_PANEL``
-----------
If set, it will update the default panel of the ``PANEL_DASHBOARD``.
``ADD_PANEL``
----------------------
Python panel class of the ``PANEL`` to be added.
``REMOVE_PANEL``
------------
If set to ``True``, the PANEL will be removed from PANEL_DASHBOARD/PANEL_GROUP.
``DISABLED``
------------
If set to ``True``, this panel configuration will be skipped.
Examples
--------
To add a new panel to the Admin panel group in Admin dashboard, create a file
``openstack_dashboard/local/enabled/_60_admin_add_panel.py`` with the follwing
content::
PANEL = 'plugin_panel'
PANEL_DASHBOARD = 'admin'
PANEL_GROUP = 'admin'
ADD_PANEL = 'test_panels.plugin_panel.panel.PluginPanel'
To remove Info panel from Admin panel group in Admin dashboard locally, create
a file ``openstack_dashboard/local/enabled/_70_admin_remove_panel.py`` with
the following content::
PANEL = 'info'
PANEL_DASHBOARD = 'admin'
PANEL_GROUP = 'admin'
REMOVE_PANEL = True
To change the default panel of Admin dashboard to Defaults panel, create a file
``openstack_dashboard/local/enabled/_80_admin_default_panel.py`` with the
following content::
PANEL = 'defaults'
PANEL_DASHBOARD = 'admin'
PANEL_GROUP = 'admin'
DEFAULT_PANEL = 'defaults'

View File

@ -740,6 +740,9 @@ class Site(Registry, HorizonComponent):
for dash in self._registry.values():
dash._autodiscover()
# Load the plugin-based panel configuration
self._load_panel_customization()
# Allow for override modules
if self._conf.get("customization_module", None):
customization_module = self._conf["customization_module"]
@ -785,6 +788,61 @@ class Site(Registry, HorizonComponent):
if module_has_submodule(mod, mod_name):
raise
def _load_panel_customization(self):
"""Applies the plugin-based panel configurations.
This method parses the panel customization from the ``HORIZON_CONFIG``
and make changes to the dashboard accordingly.
It supports adding, removing and setting default panels on the
dashboard.
"""
panel_customization = self._conf.get("panel_customization", [])
for config in panel_customization:
dashboard = config.get('PANEL_DASHBOARD')
if not dashboard:
LOG.warning("Skipping %s because it doesn't have "
"PANEL_DASHBOARD defined.", config.__name__)
continue
try:
panel_slug = config.get('PANEL')
dashboard_cls = self.get_dashboard(dashboard)
panel_group = config.get('PANEL_GROUP')
default_panel = config.get('DEFAULT_PANEL')
# Set the default panel
if default_panel:
dashboard_cls.default_panel = default_panel
# Remove the panel
if config.get('REMOVE_PANEL', False):
for panel in dashboard_cls.get_panels():
if panel_slug == panel.slug:
dashboard_cls.unregister(panel.__class__)
elif config.get('ADD_PANEL', None):
# Add the panel to the dashboard
panel_path = config['ADD_PANEL']
mod_path, panel_cls = panel_path.rsplit(".", 1)
try:
mod = import_module(mod_path)
except ImportError:
LOG.warning("Could not load panel: %s", mod_path)
continue
panel = getattr(mod, panel_cls)
dashboard_cls.register(panel)
if panel_group:
dashboard_cls.get_panel_group(panel_group).\
panels.append(panel.slug)
else:
panels = list(dashboard_cls.panels)
panels.append(panel)
dashboard_cls.panels = tuple(panels)
except Exception as e:
LOG.warning('Could not process panel %(panel)s: %(exc)s',
{'panel': panel_slug, 'exc': e})
class HorizonSite(Site):
"""A singleton implementation of Site such that all dealings with horizon

View File

@ -0,0 +1,10 @@
# The name of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'plugin_panel'
# The name of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'admin'
# The name of the panel group the PANEL is associated with.
PANEL_GROUP = 'admin'
# Python panel class of the PANEL to be added.
ADD_PANEL = \
'openstack_dashboard.test.test_panels.plugin_panel.panel.PluginPanel'

View File

@ -0,0 +1,9 @@
# The name of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'info'
# The name of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'admin'
# The name of the panel group the PANEL is associated with.
PANEL_GROUP = 'admin'
# If set to True, the panel will be removed from PANEL_DASHBOARD/PANEL_GROUP.
REMOVE_PANEL = True

View File

@ -0,0 +1,9 @@
# The name of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'defaults'
# The name of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'admin'
# The name of the panel group the PANEL is associated with.
PANEL_GROUP = 'admin'
# If set, it will update the default panel of the PANEL_DASHBOARD.
DEFAULT_PANEL = 'defaults'

View File

@ -0,0 +1,22 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.utils.translation import ugettext_lazy as _
import horizon
class PluginPanel(horizon.Panel):
name = _("Plugin Panel")
slug = 'plugin_panel'

View File

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Plugin-based Panel" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Plugin-based Panel")%}
{% endblock page_header %}
{% block main %}
<div class="row-fluid">
<div class="span12">
Plugin-based Panel
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,22 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.conf.urls import patterns # noqa
from django.conf.urls import url # noqa
from openstack_dashboard.test.test_panels.plugin_panel import views
urlpatterns = patterns('',
url(r'^$', views.IndexView.as_view(), name='index'),
)

View File

@ -0,0 +1,19 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.views.generic import TemplateView # noqa
class IndexView(TemplateView):
template_name = 'admin/plugin_panel/index.html'

View File

@ -0,0 +1,10 @@
# The name of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'plugin_panel'
# The name of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'admin'
# The name of the panel group the PANEL is associated with.
PANEL_GROUP = 'admin'
# Python panel class of the PANEL to be added.
ADD_PANEL = \
'openstack_dashboard.test.test_panels.plugin_panel.panel.PluginPanel'

View File

@ -0,0 +1,9 @@
# The name of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'info'
# The name of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'admin'
# The name of the panel group the PANEL is associated with.
PANEL_GROUP = 'admin'
# If set to True, the panel will be removed from PANEL_DASHBOARD/PANEL_GROUP.
REMOVE_PANEL = True

View File

@ -0,0 +1,9 @@
# The name of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'defaults'
# The name of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'admin'
# The name of the panel group the PANEL is associated with.
PANEL_GROUP = 'admin'
# If set, it will update the default panel of the PANEL_DASHBOARD.
DEFAULT_PANEL = 'defaults'

View File

@ -0,0 +1,86 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import copy
from django.conf import settings
from django.core import urlresolvers
from django.test.utils import override_settings
from django.utils.importlib import import_module # noqa
import horizon
from horizon import base
from horizon import conf
from openstack_dashboard.dashboards.admin.info import panel as info_panel
from openstack_dashboard.test import helpers as test
from openstack_dashboard.test.test_panels.plugin_panel \
import panel as plugin_panel
import openstack_dashboard.test.test_plugins.panel_config
from openstack_dashboard.utils import settings as util_settings
HORIZON_CONFIG = copy.copy(settings.HORIZON_CONFIG)
INSTALLED_APPS = list(settings.INSTALLED_APPS)
util_settings.update_dashboards([
openstack_dashboard.test.test_plugins.panel_config,
], HORIZON_CONFIG, INSTALLED_APPS)
@override_settings(HORIZON_CONFIG=HORIZON_CONFIG,
INSTALLED_APPS=INSTALLED_APPS)
class PanelPluginTests(test.TestCase):
def setUp(self):
super(PanelPluginTests, self).setUp()
self.old_horizon_config = conf.HORIZON_CONFIG
conf.HORIZON_CONFIG = conf.LazySettings()
base.Horizon._urls()
# Trigger discovery, registration, and URLconf generation if it
# hasn't happened yet.
self.client.get(settings.LOGIN_URL)
def tearDown(self):
super(PanelPluginTests, self).tearDown()
conf.HORIZON_CONFIG = self.old_horizon_config
# Destroy our singleton and re-create it.
base.HorizonSite._instance = None
del base.Horizon
base.Horizon = base.HorizonSite()
self._reload_urls()
def _reload_urls(self):
"""Clears out the URL caches, reloads the root urls module, and
re-triggers the autodiscovery mechanism for Horizon. Allows URLs
to be re-calculated after registering new dashboards. Useful
only for testing and should never be used on a live site.
"""
urlresolvers.clear_url_caches()
reload(import_module(settings.ROOT_URLCONF))
base.Horizon._urls()
def test_add_panel(self):
dashboard = horizon.get_dashboard("admin")
self.assertIn(plugin_panel.PluginPanel,
[p.__class__ for p in dashboard.get_panels()])
def test_remove_panel(self):
dashboard = horizon.get_dashboard("admin")
self.assertNotIn(info_panel.Info,
[p.__class__ for p in dashboard.get_panels()])
def test_default_panel(self):
dashboard = horizon.get_dashboard("admin")
self.assertEqual(dashboard.default_panel, 'defaults')

View File

@ -41,19 +41,21 @@ def import_dashboard_config(modules):
config = collections.defaultdict(dict)
for module in modules:
for key, submodule in import_submodules(module).iteritems():
try:
if hasattr(submodule, 'DASHBOARD'):
dashboard = submodule.DASHBOARD
except AttributeError:
logging.warning("Skipping %s because it doesn't "
"have DASHBOARD defined." % submodule.__name__)
else:
config[dashboard].update(submodule.__dict__)
elif hasattr(submodule, 'PANEL'):
config[submodule.__name__] = submodule.__dict__
#_update_panels(config, submodule)
else:
logging.warning("Skipping %s because it doesn't have DASHBOARD"
" or PANEL defined.", submodule.__name__)
return sorted(config.iteritems(),
key=lambda c: c[1]['__name__'].rsplit('.', 1))
def update_dashboards(modules, horizon_config, installed_apps):
"""Imports dashboard configuration from modules and applies it.
"""Imports dashboard and panel configuration from modules and applies it.
The submodules from specified modules are imported, and the configuration
for the specific dashboards is merged, with the later modules overriding
@ -75,18 +77,30 @@ def update_dashboards(modules, horizon_config, installed_apps):
configurations will be applied in order ``qux``, ``baz`` (``baz`` is
second, because the most recent file which contributed to it, ``_30_baz``,
comes after ``_20_qux``).
Panel specific configurations are stored in horizon_config. Dashboards
from both plugin-based and openstack_dashboard must be registered before
the panel configuration can be applied. Making changes to the panel is
deferred until the horizon autodiscover is completed, configurations are
applied in alphabetical order of files where it was imported.
"""
dashboards = []
exceptions = {}
apps = []
for dashboard, config in import_dashboard_config(modules):
panel_customization = []
for key, config in import_dashboard_config(modules):
if config.get('DISABLED', False):
continue
dashboards.append(dashboard)
exceptions.update(config.get('ADD_EXCEPTIONS', {}))
apps.extend(config.get('ADD_INSTALLED_APPS', []))
if config.get('DEFAULT', False):
horizon_config['default_dashboard'] = dashboard
if config.get('DASHBOARD'):
dashboard = key
dashboards.append(dashboard)
exceptions.update(config.get('ADD_EXCEPTIONS', {}))
apps.extend(config.get('ADD_INSTALLED_APPS', []))
if config.get('DEFAULT', False):
horizon_config['default_dashboard'] = dashboard
elif config.get('PANEL'):
panel_customization.append(config)
horizon_config['panel_customization'] = panel_customization
horizon_config['dashboards'] = tuple(dashboards)
horizon_config['exceptions'].update(exceptions)
installed_apps.extend(apps)