From ac712468029ea2981e912da36afd74a9a5de67bf Mon Sep 17 00:00:00 2001 From: Gabriel Hurley Date: Mon, 26 Mar 2012 18:08:48 -0700 Subject: [PATCH] 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 --- docs/source/ref/horizon.rst | 3 + docs/source/topics/customizing.rst | 22 +++ horizon/__init__.py | 2 +- horizon/base.py | 165 ++++++++++++++++---- horizon/dashboards/nova/dashboard.py | 21 ++- horizon/dashboards/syspanel/dashboard.py | 11 +- horizon/templates/horizon/_subnav_list.html | 2 +- horizon/templatetags/horizon.py | 26 ++- horizon/tests/base_tests.py | 10 +- 9 files changed, 206 insertions(+), 56 deletions(-) diff --git a/docs/source/ref/horizon.rst b/docs/source/ref/horizon.rst index 9206e950e..cc4b2d709 100644 --- a/docs/source/ref/horizon.rst +++ b/docs/source/ref/horizon.rst @@ -40,3 +40,6 @@ Panel .. autoclass:: Panel :members: + +.. autoclass:: PanelGroup + :members: diff --git a/docs/source/topics/customizing.rst b/docs/source/topics/customizing.rst index 7d377ddb0..040319a29 100644 --- a/docs/source/topics/customizing.rst +++ b/docs/source/topics/customizing.rst @@ -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 ============ diff --git a/horizon/__init__.py b/horizon/__init__.py index bd6380a36..71479fb96 100644 --- a/horizon/__init__.py +++ b/horizon/__init__.py @@ -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 diff --git a/horizon/base.py b/horizon/base.py index 8beef7319..68588e5e3 100644 --- a/horizon/base.py +++ b/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 "" % 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))) diff --git a/horizon/dashboards/nova/dashboard.py b/horizon/dashboards/nova/dashboard.py index 02f504ff9..c87bbceb6 100644 --- a/horizon/dashboards/nova/dashboard.py +++ b/horizon/dashboards/nova/dashboard.py @@ -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 diff --git a/horizon/dashboards/syspanel/dashboard.py b/horizon/dashboards/syspanel/dashboard.py index ba2706bce..a0373e9ea 100644 --- a/horizon/dashboards/syspanel/dashboard.py +++ b/horizon/dashboards/syspanel/dashboard.py @@ -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',) diff --git a/horizon/templates/horizon/_subnav_list.html b/horizon/templates/horizon/_subnav_list.html index b14eecc90..687493a3d 100644 --- a/horizon/templates/horizon/_subnav_list.html +++ b/horizon/templates/horizon/_subnav_list.html @@ -3,7 +3,7 @@ {% for heading, panels in components.iteritems %} {% with panels|can_haz_list:user as filtered_panels %} {% if filtered_panels %} -

{{ heading }}

+ {% if heading %}

{{ heading }}

{% endif %}