Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Full support for dashboard and panel configuration via service catalog.
There are no longer any dependencies on settings for whether or not
particular components are made available in the site.

Implements blueprint toggle-features.

Also fixes bug 929983, making the Horizon object a proper
singleton and ensuring test isolation for the base horizon tests.

Fixes a case where a missing service catalog would cause
a 500 error. Fixes bug 930833,

Change-Id: If19762afe75859e63aa7bd5128a6795655df2c90
  • Loading branch information
gabrielhurley committed Feb 13, 2012
1 parent 797c497 commit aed4766
Show file tree
Hide file tree
Showing 16 changed files with 255 additions and 113 deletions.
5 changes: 4 additions & 1 deletion horizon/horizon/api/__init__.py
Expand Up @@ -36,5 +36,8 @@
from horizon.api.keystone import *
from horizon.api.nova import *
from horizon.api.swift import *
if settings.QUANTUM_ENABLED:
# Quantum is optional. Ignore it if it's not installed.
try:
from horizon.api.quantum import *
except ImportError:
pass
55 changes: 41 additions & 14 deletions horizon/horizon/base.py
Expand Up @@ -36,7 +36,8 @@
from django.utils.module_loading import module_has_submodule
from django.utils.translation import ugettext as _

from horizon.decorators import require_roles, _current_component
from horizon.decorators import (require_roles, require_services,
_current_component)


LOG = logging.getLogger(__name__)
Expand Down Expand Up @@ -108,7 +109,7 @@ def _register(self, cls):
raise ValueError('Only classes may be registered.')
elif not issubclass(cls, self._registerable_class):
raise ValueError('Only %s classes or subclasses may be registered.'
% self._registerable_class)
% self._registerable_class.__name__)

if cls not in self._registry:
cls._registered_with = self
Expand All @@ -135,9 +136,9 @@ def _unregister(self, cls):

def _registered(self, cls):
if inspect.isclass(cls) and issubclass(cls, self._registerable_class):
cls = self._registry.get(cls, None)
if cls:
return cls
found = self._registry.get(cls, None)
if found:
return found
else:
# Allow for fetching by slugs as well.
for registered in self._registry.values():
Expand All @@ -153,9 +154,10 @@ def _registered(self, cls):
"parent": parent,
"name": self.name})
else:
slug = getattr(cls, "slug", cls)
raise NotRegistered('%(type)s with slug "%(slug)s" is not '
'registered.'
% {"type": class_name, "slug": cls})
'registered.' % {"type": class_name,
"slug": slug})


class Panel(HorizonComponent):
Expand Down Expand Up @@ -183,6 +185,11 @@ class Panel(HorizonComponent):
is combined cumulatively with any roles required on the
``Dashboard`` class with which it is registered.
.. attribute:: services
A list of service names, all of which must be in the service catalog
in order for this panel to be available.
.. attribute:: urls
Path to a URLconf of views for this panel using dotted Python
Expand Down Expand Up @@ -235,7 +242,9 @@ def _decorated_urls(self):

# Apply access controls to all views in the patterns
roles = getattr(self, 'roles', [])
services = getattr(self, 'services', [])
_decorate_urlconf(urlpatterns, require_roles, roles)
_decorate_urlconf(urlpatterns, require_services, services)
_decorate_urlconf(urlpatterns, _current_component, panel=self)

# Return the three arguments to django.conf.urls.defaults.include
Expand Down Expand Up @@ -295,13 +304,18 @@ class Syspanel(horizon.Dashboard):
for this dashboard, that's the panel that is displayed.
Default: ``None``.
.. attribute: roles
.. attribute:: roles
A list of role names, all of which a user must possess in order
to access any panel registered with this dashboard. This attribute
is combined cumulatively with any roles required on individual
:class:`~horizon.Panel` classes.
.. attribute:: services
A list of service names, all of which must be in the service catalog
in order for this dashboard to be available.
.. attribute:: urls
Optional path to a URLconf of additional views for this dashboard
Expand Down Expand Up @@ -410,7 +424,9 @@ def _decorated_urls(self):
_decorate_urlconf(urlpatterns, login_required)
# Apply access controls to all views in the patterns
roles = getattr(self, 'roles', [])
services = getattr(self, 'services', [])
_decorate_urlconf(urlpatterns, require_roles, roles)
_decorate_urlconf(urlpatterns, require_services, services)
_decorate_urlconf(urlpatterns, _current_component, dashboard=self)

# Return the three arguments to django.conf.urls.defaults.include
Expand All @@ -437,13 +453,11 @@ def _autodiscover(self):
@classmethod
def register(cls, panel):
""" Registers a :class:`~horizon.Panel` with this dashboard. """
from horizon import Horizon
return Horizon.register_panel(cls, panel)

@classmethod
def unregister(cls, panel):
""" Unregisters a :class:`~horizon.Panel` from this dashboard. """
from horizon import Horizon
return Horizon.unregister_panel(cls, panel)


Expand All @@ -465,7 +479,8 @@ def __reversed__(self):


class Site(Registry, HorizonComponent):
""" The core OpenStack Dashboard class. """
""" The overarching class which encompasses all dashboards and panels. """

# Required for registry
_registerable_class = Dashboard

Expand Down Expand Up @@ -620,9 +635,7 @@ def url_patterns():
def _urls(self):
""" Constructs the URLconf for Horizon from registered Dashboards. """
urlpatterns = self._get_default_urlpatterns()

self._autodiscover()

# Add in each dashboard's views.
for dash in self._registry.values():
urlpatterns += patterns('',
Expand Down Expand Up @@ -653,5 +666,19 @@ def _autodiscover(self):
if module_has_submodule(mod, mod_name):
raise


class HorizonSite(Site):
"""
A singleton implementation of Site such that all dealings with horizon
get the same instance no matter what. There can be only one.
"""
_instance = None

def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super(Site, cls).__new__(cls, *args, **kwargs)
return cls._instance


# The one true Horizon
Horizon = Site()
Horizon = HorizonSite()
21 changes: 4 additions & 17 deletions horizon/horizon/context_processors.py
Expand Up @@ -34,17 +34,15 @@
def horizon(request):
""" The main Horizon context processor. Required for Horizon to function.
Adds three variables to the request context:
The following variables are added to the request context:
``authorized_tenants``
A list of tenant objects which the current user has access to.
``object_store_configured``
Boolean. Will be ``True`` if there is a service of type
``object-store`` in the user's ``ServiceCatalog``.
``regions``
``network_configured``
Boolean. Will be ``True`` if ``settings.QUANTUM_ENABLED`` is ``True``.
A dictionary containing information about region support, the current
region, and available regions.
Additionally, it sets the names ``True`` and ``False`` in the context
to their boolean equivalents for convenience.
Expand All @@ -63,17 +61,6 @@ def horizon(request):
if request.user.is_authenticated():
context['authorized_tenants'] = request.user.authorized_tenants

# Object Store/Swift context
catalog = getattr(request.user, 'service_catalog', [])
object_store = catalog and api.get_service_from_catalog(catalog,
'object-store')
context['object_store_configured'] = object_store

# Quantum context
# TODO(gabriel): Convert to service catalog check when Quantum starts
# supporting keystone integration.
context['network_configured'] = getattr(settings, 'QUANTUM_ENABLED', None)

# Region context/support
available_regions = getattr(settings, 'AVAILABLE_REGIONS', [])
regions = {'support': len(available_regions) > 1,
Expand Down
5 changes: 1 addition & 4 deletions horizon/horizon/dashboards/nova/containers/panel.py
Expand Up @@ -27,9 +27,6 @@
class Containers(horizon.Panel):
name = _("Containers")
slug = 'containers'

def nav(self, context):
return context['object_store_configured']

services = ('object-store',)

dashboard.Nova.register(Containers)
4 changes: 1 addition & 3 deletions horizon/horizon/dashboards/nova/networks/panel.py
Expand Up @@ -25,9 +25,7 @@
class Networks(horizon.Panel):
name = "Networks"
slug = 'networks'

def nav(self, context):
return context.get('network_configured', False)
services = ("network",)


dashboard.Nova.register(Networks)
42 changes: 41 additions & 1 deletion horizon/horizon/decorators.py
Expand Up @@ -25,7 +25,7 @@

from django.utils.decorators import available_attrs

from horizon.exceptions import NotAuthorized
from horizon.exceptions import NotAuthorized, NotFound


def _current_component(view_func, dashboard=None, panel=None):
Expand Down Expand Up @@ -79,6 +79,46 @@ def dec(request, *args, **kwargs):
return view_func


def require_services(view_func, required):
""" Enforces service-based access controls.
:param list required: A tuple of service type names, all of which the
must be present in the service catalog in order
access the decorated view.
Example usage::
from horizon.decorators import require_services
@require_services(['object-store'])
def my_swift_view(request):
...
Raises a :exc:`~horizon.exceptions.NotFound` exception if the
requirements are not met.
"""
# We only need to check each service once for a view, so we'll use a set
current_services = getattr(view_func, '_required_services', set([]))
view_func._required_services = current_services | set(required)

@functools.wraps(view_func, assigned=available_attrs(view_func))
def dec(request, *args, **kwargs):
if request.user.is_authenticated():
services = set([service['type'] for service in
request.user.service_catalog])
# set operator <= tests that all members of set 1 are in set 2
if view_func._required_services <= set(services):
return view_func(request, *args, **kwargs)
raise NotFound("The services for this view are not available.")

# If we don't have any services, just return the original view.
if required:
return dec
else:
return view_func


def enforce_admin_access(view_func):
""" Marks a view as requiring the ``"admin"`` role for access. """
return require_roles(view_func, ('admin',))
12 changes: 10 additions & 2 deletions horizon/horizon/middleware.py
Expand Up @@ -23,6 +23,7 @@

import logging

from django import http
from django import shortcuts
from django.contrib import messages
from django.utils.translation import ugettext as _
Expand Down Expand Up @@ -56,7 +57,7 @@ def process_request(self, request):
authd = api.tenant_list_for_token(request,
token,
endpoint_type='internalURL')
except Exception, e:
except:
authd = []
LOG.exception('Could not retrieve tenant list.')
if hasattr(request.user, 'message_set'):
Expand All @@ -65,11 +66,18 @@ def process_request(self, request):
request.user.authorized_tenants = authd

def process_exception(self, request, exception):
""" Catch NotAuthorized and Http302 and handle them gracefully. """
"""
Catches internal Horizon exception classes such as NotAuthorized,
NotFound and Http302 and handles them gracefully.
"""
if isinstance(exception, exceptions.NotAuthorized):
messages.error(request, unicode(exception))
return shortcuts.redirect('/auth/login')

# If an internal "NotFound" error gets this far, return a real 404.
if isinstance(exception, exceptions.NotFound):
raise http.Http404(exception)

if isinstance(exception, exceptions.Http302):
if exception.message:
messages.error(request, exception.message)
Expand Down
18 changes: 10 additions & 8 deletions horizon/horizon/templates/horizon/_subnav_list.html
@@ -1,14 +1,16 @@
{% load horizon %}

{% for heading, panels in components.iteritems %}
<h4>{{ heading }}</h4>
<ul class="main_nav">
{% for panel in panels %}
{% if user|can_haz:panel %}
<li>
{% with panels|can_haz_list:user as filtered_panels %}
{% if filtered_panels %}
<h4>{{ heading }}</h4>
<ul class="main_nav">
{% for panel in filtered_panels %}
<li>
<a href="{{ panel.get_absolute_url }}" {% if current == panel.slug %}class="active"{% endif %} tabindex='1'>{{ panel.name }}</a>
</li>
</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</ul>
{% endwith %}
{% endfor %}
20 changes: 18 additions & 2 deletions horizon/horizon/templatetags/horizon.py
Expand Up @@ -28,16 +28,32 @@

@register.filter
def can_haz(user, component):
""" Checks if the given user has the necessary roles for the component. """
"""
Checks if the given user meets the requirements for the component. This
includes both user roles and services in the service catalog.
"""
if hasattr(user, 'roles'):
user_roles = set([role['name'].lower() for role in user.roles])
else:
user_roles = set([])
if set(getattr(component, 'roles', [])) <= user_roles:
roles_statisfied = set(getattr(component, 'roles', [])) <= user_roles

if hasattr(user, 'roles'):
services = set([service['type'] for service in user.service_catalog])
else:
services = set([])
services_statisfied = set(getattr(component, 'services', [])) <= services

if roles_statisfied and services_statisfied:
return True
return False


@register.filter
def can_haz_list(components, user):
return [component for component in components if can_haz(user, component)]


@register.inclusion_tag('horizon/_nav_list.html', takes_context=True)
def horizon_main_nav(context):
""" Generates top-level dashboard navigation entries. """
Expand Down

0 comments on commit aed4766

Please sign in to comment.