Improved region switcher.

Adds the ability to live-switch regions, and bakes in support at a
lower level. Cleans up login-related code.

Makes the login view capable of being used as a modal dialog.

Overall UX improvements for region support.

Fixes a bug where having one region would still show the region
switcher inappropriately. Fixed bug 929886.

BACKWARDS INCOMPATIBLE CHANGE: If you were an early adopter of
the region switcher, you will need to reverse the order of the
settings tuples from the previous ("region name", "endpoint")
order to the new ("endpoint", "region name") style. This change
was done to better suit Django's "choices" syntax since the
original ordering was arbitrary.

Change-Id: I79db4ec1e608ee0f35916966c018d2a76b5ff662
This commit is contained in:
Gabriel Hurley 2012-02-09 15:16:22 -08:00
parent 95970b701c
commit 4d8a924862
13 changed files with 97 additions and 82 deletions

View File

@ -11,7 +11,8 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys, os
import sys
import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
HORIZON_DIR = os.path.abspath(os.path.join(BASE_DIR, "..", "..", "horizon"))

View File

@ -75,10 +75,12 @@ def horizon(request):
context['network_configured'] = getattr(settings, 'QUANTUM_ENABLED', None)
# Region context/support
available_regions = getattr(settings, 'AVAILABLE_REGIONS', None)
regions = {'support': available_regions > 1,
'endpoint': request.session.get('region_endpoint'),
'name': request.session.get('region_name')}
context['region'] = regions
available_regions = getattr(settings, 'AVAILABLE_REGIONS', [])
regions = {'support': len(available_regions) > 1,
'current': {'endpoint': request.session.get('region_endpoint'),
'name': request.session.get('region_name')},
'available': [{'endpoint': region[0], 'name':region[1]} for
region in available_regions]}
context['regions'] = regions
return context

View File

@ -20,10 +20,12 @@
from django.conf.urls.defaults import patterns, url, include
from horizon.views.auth import LoginView
urlpatterns = patterns('horizon.views.auth',
url(r'home/$', 'user_home', name='user_home'),
url(r'auth/login/$', 'login', name='auth_login'),
url(r'auth/login/$', LoginView.as_view(), name='auth_login'),
url(r'auth/logout/$', 'logout', name='auth_logout'),
url(r'auth/switch/(?P<tenant_id>[^/]+)/$', 'switch_tenants',
name='auth_switch'))

View File

@ -2,9 +2,9 @@
{% load i18n %}
{% block modal-header %}Log In{% endblock %}
{% block modal_class %}modal{% endblock %}
{% block modal_class %}modal login{% endblock %}
{% block form-action %}{% url horizon:auth_login %}{% endblock %}
{% block form_action %}{% url horizon:auth_login %}{% endblock %}
{% block modal-body %}
{% include "horizon/_messages.html" %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Login" %}{% endblock %}
{% block body_id %}splash{% endblock %}
{% block content %}
{% include '_login.html' %}
{% endblock %}

View File

@ -1,11 +1,13 @@
{% if region.support %}
{% if regions.support %}
<div id="region_switcher" class="dropdown switcher_bar" tabindex='1'>
<a class="dropdown-toggle" data-toggle="dropdown" href="#region_switcher">
{{ region.name }}
{{ regions.current.name }}
</a>
<ul id="region_list" class="dropdown-menu">
<li class='divider'></li>
<li><a href="{% url horizon:auth_logout %}">Change Regions</a></li>
{% for region in regions.available %}
<li><a class="ajax-modal" href="{% url horizon:auth_login %}?region={{ region.endpoint }}">{{ region.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}

View File

@ -21,6 +21,7 @@
from django import http
from django.contrib import messages
from django.core.urlresolvers import reverse
from keystoneclient.v2_0 import tenants as keystone_tenants
from keystoneclient import exceptions as keystone_exceptions
from mox import IsA
@ -37,25 +38,29 @@ class AuthViewTests(test.BaseViewTests):
super(AuthViewTests, self).setUp()
self.setActiveUser()
self.PASSWORD = 'secret'
self.tenant = keystone_tenants.Tenant(keystone_tenants.TenantManager,
{'id': '6',
'name': 'FAKENAME'})
self.tenants = [self.tenant]
def test_login_index(self):
res = self.client.get(reverse('horizon:auth_login'))
self.assertTemplateUsed(res, 'splash.html')
self.assertTemplateUsed(res, 'horizon/auth/login.html')
def test_login_user_logged_in(self):
self.setActiveUser(self.TEST_TOKEN, self.TEST_USER, self.TEST_TENANT,
False, self.TEST_SERVICE_CATALOG)
# Hitting the login URL directly should always give you a login page.
res = self.client.get(reverse('horizon:auth_login'))
self.assertRedirectsNoFollow(res, DASH_INDEX_URL)
self.assertTemplateUsed(res, 'horizon/auth/login.html')
def test_login_no_tenants(self):
NEW_TENANT_ID = '6'
NEW_TENANT_NAME = 'FAKENAME'
TOKEN_ID = 1
form_data = {'method': 'Login',
'region': 'http://localhost:5000/v2.0,local',
'region': 'http://localhost:5000/v2.0',
'password': self.PASSWORD,
'username': self.TEST_USER}
@ -70,10 +75,6 @@ class AuthViewTests(test.BaseViewTests):
api.token_create(IsA(http.HttpRequest), "", self.TEST_USER,
self.PASSWORD).AndReturn(aToken)
aTenant = self.mox.CreateMock(api.Token)
aTenant.id = NEW_TENANT_ID
aTenant.name = NEW_TENANT_NAME
self.mox.StubOutWithMock(api, 'tenant_list_for_token')
api.tenant_list_for_token(IsA(http.HttpRequest), aToken.id).\
AndReturn([])
@ -87,46 +88,39 @@ class AuthViewTests(test.BaseViewTests):
res = self.client.post(reverse('horizon:auth_login'), form_data)
self.assertTemplateUsed(res, 'splash.html')
self.assertTemplateUsed(res, 'horizon/auth/login.html')
def test_login(self):
NEW_TENANT_ID = '6'
NEW_TENANT_NAME = 'FAKENAME'
TOKEN_ID = 1
form_data = {'method': 'Login',
'region': 'http://localhost:5000/v2.0,local',
'password': self.PASSWORD,
'username': self.TEST_USER}
'region': 'http://localhost:5000/v2.0',
'password': self.PASSWORD,
'username': self.TEST_USER}
self.mox.StubOutWithMock(api, 'token_create')
self.mox.StubOutWithMock(api, 'tenant_list_for_token')
self.mox.StubOutWithMock(api, 'token_create_scoped')
class FakeToken(object):
id = TOKEN_ID,
id = 1,
user = {"id": "1",
"roles": [{"id": "1", "name": "fake"}], "name": "user"}
serviceCatalog = {}
tenant = None
aToken = api.Token(FakeToken())
bToken = aToken
bToken.tenant = {'id': self.tenant.id, 'name': self.tenant.name}
api.token_create(IsA(http.HttpRequest), "", self.TEST_USER,
self.PASSWORD).AndReturn(aToken)
aTenant = self.mox.CreateMock(api.Token)
aTenant.id = NEW_TENANT_ID
aTenant.name = NEW_TENANT_NAME
bToken.tenant = {'id': aTenant.id, 'name': aTenant.name}
self.mox.StubOutWithMock(api, 'tenant_list_for_token')
api.tenant_list_for_token(IsA(http.HttpRequest), aToken.id).\
AndReturn([aTenant])
self.mox.StubOutWithMock(api, 'token_create_scoped')
api.token_create_scoped(IsA(http.HttpRequest), aTenant.id,
aToken.id).AndReturn(bToken)
api.tenant_list_for_token(IsA(http.HttpRequest),
aToken.id).AndReturn(self.tenants)
api.token_create_scoped(IsA(http.HttpRequest),
self.tenant.id,
aToken.id).AndReturn(bToken)
self.mox.ReplayAll()
res = self.client.post(reverse('horizon:auth_login'), form_data)
self.assertRedirectsNoFollow(res, DASH_INDEX_URL)
@ -139,14 +133,14 @@ class AuthViewTests(test.BaseViewTests):
self.mox.ReplayAll()
form_data = {'method': 'Login',
'region': 'http://localhost:5000/v2.0,local',
'region': 'http://localhost:5000/v2.0',
'password': self.PASSWORD,
'username': self.TEST_USER}
res = self.client.post(reverse('horizon:auth_login'),
form_data,
follow=True)
self.assertTemplateUsed(res, 'splash.html')
self.assertTemplateUsed(res, 'horizon/auth/login.html')
def test_login_exception(self):
self.mox.StubOutWithMock(api, 'token_create')
@ -159,12 +153,12 @@ class AuthViewTests(test.BaseViewTests):
self.mox.ReplayAll()
form_data = {'method': 'Login',
'region': 'http://localhost:5000/v2.0,local',
'region': 'http://localhost:5000/v2.0',
'password': self.PASSWORD,
'username': self.TEST_USER}
res = self.client.post(reverse('horizon:auth_login'), form_data)
self.assertTemplateUsed(res, 'splash.html')
self.assertTemplateUsed(res, 'horizon/auth/login.html')
def test_switch_tenants_index(self):
res = self.client.get(reverse('horizon:auth_switch',
@ -207,7 +201,7 @@ class AuthViewTests(test.BaseViewTests):
self.mox.ReplayAll()
form_data = {'method': 'LoginWithTenant',
'region': 'http://localhost:5000/v2.0,local',
'region': 'http://localhost:5000/v2.0',
'password': self.PASSWORD,
'tenant': NEW_TENANT_ID,
'username': self.TEST_USER}

View File

@ -101,8 +101,8 @@ SWIFT_PASS = 'testing'
SWIFT_AUTHURL = 'http://swift/swiftapi/v1.0'
AVAILABLE_REGIONS = [
('local', 'http://localhost:5000/v2.0'),
('remote', 'http://remote:5000/v2.0'),
('http://localhost:5000/v2.0', 'local'),
('http://remote:5000/v2.0', 'remote'),
]
OPENSTACK_ADDRESS = "localhost"

View File

@ -27,6 +27,7 @@ from django.utils.translation import ugettext as _
import horizon
from horizon import api
from horizon import exceptions
from horizon import forms
from horizon import users
from horizon.base import Horizon
from horizon.views.auth_forms import Login, LoginWithTenant, _set_session_data
@ -40,21 +41,22 @@ def user_home(request):
return shortcuts.redirect(horizon.get_user_home(request.user))
def login(request):
class LoginView(forms.ModalFormView):
"""
Logs in a user and redirects them to the URL specified by
:func:`horizon.get_user_home`.
"""
if request.user.is_authenticated():
user = users.User(users.get_user_from_request(request))
return shortcuts.redirect(Horizon.get_user_home(user))
form_class = Login
template_name = "horizon/auth/login.html"
form, handled = Login.maybe_handle(request)
if handled:
return handled
# FIXME(gabriel): we don't ship a template named splash.html
return shortcuts.render(request, 'splash.html', {'form': form})
def get_initial(self):
initial = super(LoginView, self).get_initial()
current_region = self.request.session.get('region_endpoint', None)
requested_region = self.request.GET.get('region', None)
regions = dict(getattr(settings, "AVAILABLE_REGIONS", []))
if requested_region in regions and requested_region != current_region:
initial.update({'region': requested_region})
return initial
def switch_tenants(request, tenant_id):

View File

@ -50,16 +50,6 @@ def _set_session_data(request, token):
request.session['roles'] = token.user['roles']
def _regions_supported():
if len(getattr(settings, 'AVAILABLE_REGIONS', [])) > 1:
return True
region_field = forms.ChoiceField(widget=forms.Select,
choices=[('%s,%s' % (region[1], region[0]), region[0])
for region in getattr(settings, 'AVAILABLE_REGIONS', [])])
class Login(forms.SelfHandlingForm):
""" Form used for logging in a user.
@ -69,17 +59,27 @@ class Login(forms.SelfHandlingForm):
Subclass of :class:`~horizon.forms.SelfHandlingForm`.
"""
if _regions_supported():
region = region_field
region = forms.ChoiceField(label=_("Region"))
username = forms.CharField(max_length="20", label=_("User Name"))
password = forms.CharField(max_length="20", label=_("Password"),
widget=forms.PasswordInput(render_value=False))
def __init__(self, *args, **kwargs):
super(Login, self).__init__(*args, **kwargs)
# FIXME(gabriel): When we switch to region-only settings, we can
# remove this default region business.
default_region = (settings.OPENSTACK_KEYSTONE_URL, "Default Region")
regions = getattr(settings, 'AVAILABLE_REGIONS', [default_region])
self.fields['region'].choices = regions
if len(regions) == 1:
self.fields['region'].initial = default_region[0]
self.fields['region'].widget = forms.widgets.HiddenInput()
def handle(self, request, data):
region = data.get('region', '').split(',')
if len(region) > 1:
request.session['region_endpoint'] = region[0]
request.session['region_name'] = region[1]
endpoint = data.get('region')
region_name = dict(self.fields['region'].choices)[endpoint]
request.session['region_endpoint'] = endpoint
request.session['region_name'] = region_name
if data.get('tenant', None):
try:
@ -163,8 +163,7 @@ class LoginWithTenant(Login):
Exactly like :class:`.Login` but includes the tenant id as a field
so that the process of choosing a default tenant is bypassed.
"""
if _regions_supported():
region = region_field
region = forms.ChoiceField(required=False)
username = forms.CharField(max_length="20",
widget=forms.TextInput(attrs={'readonly': 'readonly'}))
tenant = forms.CharField(widget=forms.HiddenInput())

View File

@ -684,6 +684,9 @@ td.actions_column .row_actions .hide {
.dropdown-menu li:hover {
background: none;
}
.dropdown-menu li.divider:hover {
background-color: #E5E5E5;
}
td.actions_column .dropdown-menu a:hover,
td.actions_column .dropdown-menu button:hover {
background-color: #CDCDCD;

View File

@ -34,7 +34,7 @@
{% block headerjs %}{% endblock %}
{% block headercss %}{% endblock %}
</head>
<body>
<body id="{% block body_id %}{% endblock %}">
{% block content %}
<div id="container" class="fluid-container sidebar-left">
{% block sidebar %}{% endblock %}

View File

@ -36,10 +36,10 @@ HORIZON_CONFIG = {
'user_home': 'dashboard.views.user_home',
}
# For multiple regions uncomment this configuration, and add (title, endpoint).
# For multiple regions uncomment this configuration, and add (endpoint, title).
# AVAILABLE_REGIONS = [
# ('cluster1', 'http://cluster1.com:5000/v2.0'),
# ('cluster2', 'http://cluster2.com:5000/v2.0'),
# ('http://cluster1.example.com:5000/v2.0', 'cluster1'),
# ('http://cluster2.example.com:5000/v2.0', 'cluster2'),
# ]
# FIXME: This is only here so quantum still works. It must be refactored to