Adds PanelGroup class and site customization hook.
* Adds a PanelGroup class and slightly reworks the way panel ordering is handled to fix bug 963550. * Adds the option to load a python module containing site customizations after the site is fully initialized, but before the URLConf is dynamically constructed. Fixes bug 965839. Change-Id: Idc5358f2db6751494bcdfc382ec3bb6af65199b9
This commit is contained in:
parent
a5d5b4f288
commit
ac71246802
@ -40,3 +40,6 @@ Panel
|
||||
|
||||
.. autoclass:: Panel
|
||||
:members:
|
||||
|
||||
.. autoclass:: PanelGroup
|
||||
:members:
|
||||
|
@ -28,6 +28,28 @@ To override the OpenStack Logo image, replace the image at the directory path
|
||||
|
||||
The dimensions should be ``width: 108px, height: 121px``.
|
||||
|
||||
Modifying Existing Dashboards and Panels
|
||||
========================================
|
||||
|
||||
If you wish to alter dashboards or panels which are not part of your codebase,
|
||||
you can specify a custom python module which will be loaded after the entire
|
||||
Horizon site has been initialized, but prior to the URLconf construction.
|
||||
This allows for common site-customization requirements such as:
|
||||
|
||||
* Registering or unregistering panels from an existing dashboard.
|
||||
* Changing the names of dashboards and panels.
|
||||
* Re-ordering panels within a dashboard or panel group.
|
||||
|
||||
To specify the python module containing your modifications, add the key
|
||||
``customization_module`` to your ``settings.HORIZON_CONFIG`` dictionary.
|
||||
The value should be a string containing the path to your module in dotted
|
||||
python path notation. Example::
|
||||
|
||||
HORIZON_CONFIG = {
|
||||
"customization_module": "my_project.overrides"
|
||||
}
|
||||
|
||||
|
||||
Button Icons
|
||||
============
|
||||
|
||||
|
@ -26,7 +26,7 @@ methods like :func:`~horizon.register` and :func:`~horizon.unregister`.
|
||||
# should that fail.
|
||||
Horizon = None
|
||||
try:
|
||||
from horizon.base import Horizon, Dashboard, Panel, Workflow
|
||||
from horizon.base import Horizon, Dashboard, Panel, PanelGroup
|
||||
except ImportError:
|
||||
import warnings
|
||||
|
||||
|
165
horizon/base.py
165
horizon/base.py
@ -22,6 +22,7 @@ Public APIs are made available through the :mod:`horizon` module and
|
||||
the classes contained therein.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import inspect
|
||||
import logging
|
||||
@ -30,6 +31,7 @@ from django.conf import settings
|
||||
from django.conf.urls.defaults import patterns, url, include
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.datastructures import SortedDict
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.importlib import import_module
|
||||
from django.utils.module_loading import module_has_submodule
|
||||
@ -254,6 +256,49 @@ class Panel(HorizonComponent):
|
||||
return urlpatterns, self.slug, self.slug
|
||||
|
||||
|
||||
class PanelGroup(object):
|
||||
""" A container for a set of :class:`~horizon.Panel` classes.
|
||||
|
||||
When iterated, it will yield each of the ``Panel`` instances it
|
||||
contains.
|
||||
|
||||
.. attribute:: slug
|
||||
|
||||
A unique string to identify this panel group. Required.
|
||||
|
||||
.. attribute:: name
|
||||
|
||||
A user-friendly name which will be used as the group heading in
|
||||
places such as the navigation. Default: ``None``.
|
||||
|
||||
.. attribute:: panels
|
||||
|
||||
A list of panel module names which should be contained within this
|
||||
grouping.
|
||||
"""
|
||||
def __init__(self, dashboard, slug=None, name=None, panels=None):
|
||||
self.dashboard = dashboard
|
||||
self.slug = slug or getattr(self, "slug", "default")
|
||||
self.name = name or getattr(self, "name", None)
|
||||
# Our panels must be mutable so it can be extended by others.
|
||||
self.panels = list(panels or getattr(self, "panels", []))
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s: %s>" % (self.__class__.__name__, self.slug)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def __iter__(self):
|
||||
panel_instances = []
|
||||
for name in self.panels:
|
||||
try:
|
||||
panel_instances.append(self.dashboard.get_panel(name))
|
||||
except NotRegistered, e:
|
||||
LOG.debug(e)
|
||||
return iter(panel_instances)
|
||||
|
||||
|
||||
class Dashboard(Registry, HorizonComponent):
|
||||
""" A base class for defining Horizon dashboards.
|
||||
|
||||
@ -275,13 +320,18 @@ class Dashboard(Registry, HorizonComponent):
|
||||
|
||||
.. attribute:: panels
|
||||
|
||||
The ``panels`` attribute can be either a list containing the name
|
||||
The ``panels`` attribute can be either a flat list containing the name
|
||||
of each panel **module** which should be loaded as part of this
|
||||
dashboard, or a dictionary of tuples which define groups of panels
|
||||
as in the following example::
|
||||
dashboard, or a list of :class:`~horizon.PanelGroup` classes which
|
||||
define groups of panels as in the following example::
|
||||
|
||||
class SystemPanels(horizon.PanelGroup):
|
||||
slug = "syspanel"
|
||||
name = _("System Panel")
|
||||
panels = ('overview', 'instances', ...)
|
||||
|
||||
class Syspanel(horizon.Dashboard):
|
||||
panels = {'System Panel': ('overview', 'instances', ...)}
|
||||
panels = (SystemPanels,)
|
||||
|
||||
Automatically generated navigation will use the order of the
|
||||
modules in this attribute.
|
||||
@ -354,6 +404,10 @@ class Dashboard(Registry, HorizonComponent):
|
||||
def __repr__(self):
|
||||
return "<Dashboard: %s>" % self.slug
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Dashboard, self).__init__(*args, **kwargs)
|
||||
self._panel_groups = None
|
||||
|
||||
def get_panel(self, panel):
|
||||
"""
|
||||
Returns the specified :class:`~horizon.Panel` instance registered
|
||||
@ -364,27 +418,36 @@ class Dashboard(Registry, HorizonComponent):
|
||||
def get_panels(self):
|
||||
"""
|
||||
Returns the :class:`~horizon.Panel` instances registered with this
|
||||
dashboard in order.
|
||||
dashboard in order, without any panel groupings.
|
||||
"""
|
||||
all_panels = []
|
||||
panel_groups = self.get_panel_groups()
|
||||
for panel_group in panel_groups.values():
|
||||
all_panels.extend(panel_group)
|
||||
return all_panels
|
||||
|
||||
def get_panel_group(self, slug):
|
||||
return self._panel_groups[slug]
|
||||
|
||||
def get_panel_groups(self):
|
||||
registered = copy.copy(self._registry)
|
||||
if isinstance(self.panels, dict):
|
||||
panels = {}
|
||||
for heading, items in self.panels.iteritems():
|
||||
panels.setdefault(heading, [])
|
||||
for item in items:
|
||||
panel = self._registered(item)
|
||||
panels[heading].append(panel)
|
||||
registered.pop(panel.__class__)
|
||||
if len(registered):
|
||||
panels.setdefault(_("Other"), []).extend(registered.values())
|
||||
else:
|
||||
panels = []
|
||||
for item in self.panels:
|
||||
panel = self._registered(item)
|
||||
panels.append(panel)
|
||||
panel_groups = []
|
||||
|
||||
# Gather our known panels
|
||||
for panel_group in self._panel_groups.values():
|
||||
for panel in panel_group:
|
||||
registered.pop(panel.__class__)
|
||||
panels.extend(registered.values())
|
||||
return panels
|
||||
panel_groups.append((panel_group.slug, panel_group))
|
||||
|
||||
# Deal with leftovers (such as add-on registrations)
|
||||
if len(registered):
|
||||
slugs = [panel.slug for panel in registered.values()]
|
||||
new_group = PanelGroup(self,
|
||||
slug="other",
|
||||
name=_("Other"),
|
||||
panels=slugs)
|
||||
panel_groups.append((new_group.slug, new_group))
|
||||
return SortedDict(panel_groups)
|
||||
|
||||
def get_absolute_url(self):
|
||||
""" Returns the default URL for this dashboard.
|
||||
@ -405,7 +468,6 @@ class Dashboard(Registry, HorizonComponent):
|
||||
def _decorated_urls(self):
|
||||
urlpatterns = self._get_default_urlpatterns()
|
||||
|
||||
self._autodiscover()
|
||||
default_panel = None
|
||||
|
||||
# Add in each panel's views except for the default view.
|
||||
@ -437,14 +499,36 @@ class Dashboard(Registry, HorizonComponent):
|
||||
|
||||
def _autodiscover(self):
|
||||
""" Discovers panels to register from the current dashboard module. """
|
||||
if getattr(self, "_autodiscover_complete", False):
|
||||
return
|
||||
|
||||
panels_to_discover = []
|
||||
panel_groups = []
|
||||
# If we have a flat iterable of panel names, wrap it again so
|
||||
# we have a consistent structure for the next step.
|
||||
if all([isinstance(i, basestring) for i in self.panels]):
|
||||
self.panels = [self.panels]
|
||||
|
||||
# Now iterate our panel sets.
|
||||
for panel_set in self.panels:
|
||||
# Instantiate PanelGroup classes.
|
||||
if not isinstance(panel_set, collections.Iterable) and \
|
||||
issubclass(panel_set, PanelGroup):
|
||||
panel_group = panel_set(self)
|
||||
# Check for nested tuples, and convert them to PanelGroups
|
||||
elif not isinstance(panel_set, PanelGroup):
|
||||
panel_group = PanelGroup(self, panels=panel_set)
|
||||
|
||||
# Put our results into their appropriate places
|
||||
panels_to_discover.extend(panel_group.panels)
|
||||
panel_groups.append((panel_group.slug, panel_group))
|
||||
|
||||
self._panel_groups = SortedDict(panel_groups)
|
||||
|
||||
# Do the actual discovery
|
||||
package = '.'.join(self.__module__.split('.')[:-1])
|
||||
mod = import_module(package)
|
||||
panels = []
|
||||
if isinstance(self.panels, dict):
|
||||
[panels.extend(values) for values in self.panels.values()]
|
||||
else:
|
||||
panels = self.panels
|
||||
for panel in panels:
|
||||
for panel in panels_to_discover:
|
||||
try:
|
||||
before_import_registry = copy.copy(self._registry)
|
||||
import_module('.%s.panel' % panel, package)
|
||||
@ -452,6 +536,7 @@ class Dashboard(Registry, HorizonComponent):
|
||||
self._registry = before_import_registry
|
||||
if module_has_submodule(mod, panel):
|
||||
raise
|
||||
self._autodiscover_complete = True
|
||||
|
||||
@classmethod
|
||||
def register(cls, panel):
|
||||
@ -646,7 +731,27 @@ class Site(Registry, HorizonComponent):
|
||||
""" Constructs the URLconf for Horizon from registered Dashboards. """
|
||||
urlpatterns = self._get_default_urlpatterns()
|
||||
self._autodiscover()
|
||||
# Add in each dashboard's views.
|
||||
|
||||
# Discover each dashboard's panels.
|
||||
for dash in self._registry.values():
|
||||
dash._autodiscover()
|
||||
|
||||
# Allow for override modules
|
||||
config = getattr(settings, "HORIZON_CONFIG", {})
|
||||
if config.get("customization_module", None):
|
||||
customization_module = config["customization_module"]
|
||||
bits = customization_module.split('.')
|
||||
mod_name = bits.pop()
|
||||
package = '.'.join(bits)
|
||||
try:
|
||||
before_import_registry = copy.copy(self._registry)
|
||||
import_module('%s.%s' % (package, mod_name))
|
||||
except:
|
||||
self._registry = before_import_registry
|
||||
if module_has_submodule(package, mod_name):
|
||||
raise
|
||||
|
||||
# Compile the dynamic urlconf.
|
||||
for dash in self._registry.values():
|
||||
urlpatterns += patterns('',
|
||||
url(r'^%s/' % dash.slug, include(dash._decorated_urls)))
|
||||
|
@ -19,14 +19,25 @@ from django.utils.translation import ugettext_lazy as _
|
||||
import horizon
|
||||
|
||||
|
||||
class BasePanels(horizon.PanelGroup):
|
||||
slug = "compute"
|
||||
name = _("Manage Compute")
|
||||
panels = ('overview',
|
||||
'instances_and_volumes',
|
||||
'images_and_snapshots',
|
||||
'access_and_security')
|
||||
|
||||
|
||||
class ObjectStorePanels(horizon.PanelGroup):
|
||||
slug = "object_store"
|
||||
name = _("Object Store")
|
||||
panels = ('containers',)
|
||||
|
||||
|
||||
class Nova(horizon.Dashboard):
|
||||
name = _("Project")
|
||||
slug = "nova"
|
||||
panels = {_("Manage Compute"): ('overview',
|
||||
'instances_and_volumes',
|
||||
'access_and_security',
|
||||
'images_and_snapshots'),
|
||||
_("Object Store"): ('containers',)}
|
||||
panels = (BasePanels, ObjectStorePanels)
|
||||
default_panel = 'overview'
|
||||
supports_tenants = True
|
||||
|
||||
|
@ -19,12 +19,17 @@ from django.utils.translation import ugettext_lazy as _
|
||||
import horizon
|
||||
|
||||
|
||||
class SystemPanels(horizon.PanelGroup):
|
||||
slug = "syspanel"
|
||||
name = _("System Panel")
|
||||
panels = ('overview', 'instances', 'services', 'flavors', 'images',
|
||||
'projects', 'users', 'quotas',)
|
||||
|
||||
|
||||
class Syspanel(horizon.Dashboard):
|
||||
name = _("Admin")
|
||||
slug = "syspanel"
|
||||
panels = {_("System Panel"): ('overview', 'instances', 'services',
|
||||
'flavors', 'images', 'projects', 'users',
|
||||
'quotas',)}
|
||||
panels = (SystemPanels,)
|
||||
default_panel = 'overview'
|
||||
roles = ('admin',)
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
{% for heading, panels in components.iteritems %}
|
||||
{% with panels|can_haz_list:user as filtered_panels %}
|
||||
{% if filtered_panels %}
|
||||
<h4>{{ heading }}</h4>
|
||||
{% if heading %}<h4>{{ heading }}</h4>{% endif %}
|
||||
<ul class="main_nav">
|
||||
{% for panel in filtered_panels %}
|
||||
<li>
|
||||
|
@ -16,9 +16,8 @@
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import copy
|
||||
|
||||
from django import template
|
||||
from django.utils.datastructures import SortedDict
|
||||
|
||||
from horizon.base import Horizon
|
||||
|
||||
@ -78,21 +77,20 @@ def horizon_dashboard_nav(context):
|
||||
if 'request' not in context:
|
||||
return {}
|
||||
dashboard = context['request'].horizon['dashboard']
|
||||
if isinstance(dashboard.panels, dict):
|
||||
panels = copy.copy(dashboard.get_panels())
|
||||
else:
|
||||
panels = {dashboard.name: dashboard.get_panels()}
|
||||
panel_groups = dashboard.get_panel_groups()
|
||||
non_empty_groups = []
|
||||
|
||||
for heading, items in panels.iteritems():
|
||||
temp_panels = []
|
||||
for panel in items:
|
||||
for group in panel_groups.values():
|
||||
allowed_panels = []
|
||||
for panel in group:
|
||||
if callable(panel.nav) and panel.nav(context):
|
||||
temp_panels.append(panel)
|
||||
allowed_panels.append(panel)
|
||||
elif not callable(panel.nav) and panel.nav:
|
||||
temp_panels.append(panel)
|
||||
panels[heading] = temp_panels
|
||||
non_empty_panels = dict([(k, v) for k, v in panels.items() if len(v) > 0])
|
||||
return {'components': non_empty_panels,
|
||||
allowed_panels.append(panel)
|
||||
if allowed_panels:
|
||||
non_empty_groups.append((group.name, allowed_panels))
|
||||
|
||||
return {'components': SortedDict(non_empty_groups),
|
||||
'user': context['request'].user,
|
||||
'current': context['request'].horizon['panel'].slug,
|
||||
'request': context['request']}
|
||||
|
@ -138,7 +138,7 @@ class HorizonTests(BaseHorizonTests):
|
||||
def test_dashboard(self):
|
||||
syspanel = horizon.get_dashboard("syspanel")
|
||||
self.assertEqual(syspanel._registered_with, base.Horizon)
|
||||
self.assertQuerysetEqual(syspanel.get_panels().values()[0],
|
||||
self.assertQuerysetEqual(syspanel.get_panels(),
|
||||
['<Panel: overview>',
|
||||
'<Panel: instances>',
|
||||
'<Panel: services>',
|
||||
@ -148,12 +148,18 @@ class HorizonTests(BaseHorizonTests):
|
||||
'<Panel: users>',
|
||||
'<Panel: quotas>'])
|
||||
self.assertEqual(syspanel.get_absolute_url(), "/syspanel/")
|
||||
|
||||
# Test registering a module with a dashboard that defines panels
|
||||
# as a dictionary.
|
||||
syspanel.register(MyPanel)
|
||||
self.assertQuerysetEqual(syspanel.get_panels()['Other'],
|
||||
self.assertQuerysetEqual(syspanel.get_panel_groups()['other'],
|
||||
['<Panel: myslug>'])
|
||||
|
||||
# Test that panels defined as a tuple still return a PanelGroup
|
||||
settings_dash = horizon.get_dashboard("settings")
|
||||
self.assertQuerysetEqual(settings_dash.get_panel_groups().values(),
|
||||
['<PanelGroup: default>'])
|
||||
|
||||
# Test registering a module with a dashboard that defines panels
|
||||
# as a tuple.
|
||||
settings_dash = horizon.get_dashboard("settings")
|
||||
|
Loading…
Reference in New Issue
Block a user