Improved message handling.

* Login page no longer displays user messages
    inappropriately. Instead, login errors are handled
    as form errors. Fixes bug 1012467.
  * Messages triggered during an AJAX call are no longer
    added to the standard message queue (which causes
    them to appear on subsequent unrelated requests).
    Instead, they are encoded and sent back with the AJAX
    response, allowing them to be displayed to the user
    client-side when they are relevant.
    Fixes bug 1008799.
  * Adds the last couple "compress" tags to the
    _scripts.html template to completely implement
    blueprint asset-compression.

Change-Id: I967f32b44603ded7ec95bd0b86e7d997c6a8b352
This commit is contained in:
Gabriel Hurley 2012-06-12 19:30:45 -07:00
parent 7b565fc983
commit a5a1e40135
13 changed files with 203 additions and 27 deletions

View File

@ -23,7 +23,6 @@ import os
import sys
from django.conf import settings
from django.contrib import messages
from django.http import HttpRequest
from django.utils import termcolors
from django.utils.translation import ugettext as _
@ -34,6 +33,7 @@ from glanceclient.common import exceptions as glanceclient
from keystoneclient import exceptions as keystoneclient
from novaclient import exceptions as novaclient
from horizon import messages
LOG = logging.getLogger(__name__)
PALETTE = termcolors.PALETTES[termcolors.DEFAULT_PALETTE]

View File

@ -22,6 +22,7 @@ from datetime import date
import logging
from django import forms
from django.forms.forms import NON_FIELD_ERRORS
from django.core.urlresolvers import reverse
from django.utils import dates
@ -55,6 +56,15 @@ class SelfHandlingForm(forms.Form):
kwargs['initial'] = initial
super(SelfHandlingForm, self).__init__(*args, **kwargs)
def api_error(self, message):
"""
Adds an error to the form's error dictionary after validation
based on problems reported via the API. This is useful when you
wish for API errors to appear as errors on the form rather than
using the messages framework.
"""
self._errors[NON_FIELD_ERRORS] = self.error_class([message])
def get_success_url(self, request=None):
"""
Returns the URL to redirect to after a successful handling.

75
horizon/messages.py Normal file
View File

@ -0,0 +1,75 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# Copyright 2012 Nebula, Inc.
#
# 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.
"""
Drop-in replacement for django.contrib.messages which handles Horizon's
messaging needs (e.g. AJAX communication, etc.).
"""
from django.contrib import messages as _messages
from django.contrib.messages import constants
def add_message(request, level, message, extra_tags='', fail_silently=False):
"""
Attempts to add a message to the request using the 'messages' app.
"""
if request.is_ajax():
tag = constants.DEFAULT_TAGS[level]
request.horizon['async_messages'].append([tag, message])
else:
return _messages.add_message(request, level, message,
extra_tags, fail_silently)
def debug(request, message, extra_tags='', fail_silently=False):
"""
Adds a message with the ``DEBUG`` level.
"""
add_message(request, constants.DEBUG, message, extra_tags=extra_tags,
fail_silently=fail_silently)
def info(request, message, extra_tags='', fail_silently=False):
"""
Adds a message with the ``INFO`` level.
"""
add_message(request, constants.INFO, message, extra_tags=extra_tags,
fail_silently=fail_silently)
def success(request, message, extra_tags='', fail_silently=False):
"""
Adds a message with the ``SUCCESS`` level.
"""
add_message(request, constants.SUCCESS, message, extra_tags=extra_tags,
fail_silently=fail_silently)
def warning(request, message, extra_tags='', fail_silently=False):
"""
Adds a message with the ``WARNING`` level.
"""
add_message(request, constants.WARNING, message, extra_tags=extra_tags,
fail_silently=fail_silently)
def error(request, message, extra_tags='', fail_silently=False):
"""
Adds a message with the ``ERROR`` level.
"""
add_message(request, constants.ERROR, message, extra_tags=extra_tags,
fail_silently=fail_silently)

View File

@ -33,6 +33,7 @@ from django.utils.encoding import iri_to_uri
from horizon import exceptions
from horizon import users
from horizon.openstack.common import jsonutils
LOG = logging.getLogger(__name__)
@ -59,7 +60,9 @@ class HorizonMiddleware(object):
request.__class__.user_logout = user_logout
request.__class__.user = users.LazyUser()
request.horizon = {'dashboard': None, 'panel': None}
request.horizon = {'dashboard': None,
'panel': None,
'async_messages': []}
def process_exception(self, request, exception):
"""
@ -101,4 +104,12 @@ class HorizonMiddleware(object):
redirect_response = http.HttpResponse()
redirect_response['X-Horizon-Location'] = response['location']
return redirect_response
if request.horizon['async_messages']:
messages = request.horizon['async_messages']
# TODO(gabriel): When we have an async connection to the
# client (e.g. websockets) this should be pushed to the
# socket queue rather than being sent via a header.
# The header method has notable drawbacks (length limits,
# etc.) and is not meant as a long-term solution.
response['X-Horizon-Messages'] = jsonutils.dumps(messages)
return response

View File

@ -23,6 +23,14 @@ var Horizon = function() {
// Load client-side template fragments and compile them.
horizon.templates.compile_templates();
// Bind AJAX message handling.
$("body").ajaxComplete(function(event, request, settings){
var message_array = $.parseJSON(horizon.ajax.get_messages(request));
$(message_array).each(function (index, item) {
horizon.alert(item[0], item[1]);
});
});
// Bind event handlers to confirm dangerous actions.
$("body").on("click", "form button.btn-danger", function (evt) {
horizon.datatables.confirm(this);
@ -323,7 +331,7 @@ var Horizon = function() {
params = {"type": type,
"type_capitalized": horizon.utils.capitalize(type),
"message": message};
return $(template.render(params)).prependTo("#main_content .messages");
return $(template.render(params)).hide().prependTo("#main_content .messages").fadeIn(100);
};
horizon.clearErrorMessages = function() {
@ -350,6 +358,9 @@ var Horizon = function() {
// This will be our jQuery queue container.
_queue: [],
_active: [],
get_messages: function (request) {
return request.getResponseHeader("X-Horizon-Messages");
},
// Function to add a new call to the queue.
queue: function(opts) {
var complete = opts.complete,

View File

@ -127,8 +127,10 @@ horizon.addInitFunction(function() {
}
}
else {
// Generic error handler. Really generic.
horizon.alert("error", "An error occurred. Please try again.");
if (!horizon.ajax.get_messages(jqXHR)) {
// Generic error handler. Really generic.
horizon.alert("error", "An error occurred. Please try again.");
}
}
},
success: horizon.modals.success

View File

@ -7,7 +7,6 @@
{% block form_action %}{% url horizon:auth_login %}{% endblock %}
{% block modal-body %}
{% include "horizon/_messages.html" %}
<fieldset>
{% if next %}<input type="hidden" name="{{ redirect_field_name }}" value="{{ next }}" />{% endif %}
{% include "horizon/common/_form_fields.html" %}

View File

@ -149,8 +149,11 @@ class AuthViewTests(test.TestCase):
res = self.client.post(reverse('horizon:auth_login'),
form_data,
follow=True)
self.assertTemplateUsed(res, 'horizon/auth/login.html')
# Verify that API error messages are rendered, but not using the
# messages framework.
self.assertContains(res, "Invalid user name or password.")
self.assertNotContains(res, 'class="messages"')
def test_login_exception(self):
self.mox.StubOutWithMock(api, 'token_create')

View File

@ -0,0 +1,37 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 Nebula, Inc.
#
# 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 import http
from horizon import messages
from horizon import middleware
from horizon import test
from horizon.openstack.common import jsonutils
class MessageTests(test.TestCase):
def test_middleware_header(self):
req = self.request
expected = ["error", "Giant ants are attacking San Francisco!"]
self.assertTrue("async_messages" in req.horizon)
self.assertItemsEqual(req.horizon['async_messages'], [])
req.META['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'
messages.error(req, expected[1])
self.assertItemsEqual(req.horizon['async_messages'], [expected])
res = http.HttpResponse()
res = middleware.HorizonMiddleware().process_response(req, res)
self.assertEqual(res['X-Horizon-Messages'],
jsonutils.dumps([expected]))

View File

@ -26,7 +26,6 @@ import logging
from django import shortcuts
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.utils.translation import ugettext as _
from django.views.decorators.debug import sensitive_variables
@ -80,6 +79,14 @@ class Login(forms.SelfHandlingForm):
@sensitive_variables("data")
def handle(self, request, data):
""" Process the user's login via Keystone.
Note: We don't use the messages framework here (including messages
created by ``exceptions.handle`` beause they will not be displayed
on the login page (intentionally). Instead we add all error messages
to the form's ``non_field_errors``, causing them to appear as
errors on the form itself.
"""
if 'user_name' in request.session:
if request.session['user_name'] != data['username']:
# To avoid reusing another user's session, create a
@ -110,9 +117,8 @@ class Login(forms.SelfHandlingForm):
tenants = api.tenant_list_for_token(request, token.id)
except:
msg = _('Unable to authenticate for that project.')
exceptions.handle(request,
message=msg,
escalate=True)
exceptions.handle(request, ignore=True)
return self.api_error(msg)
_set_session_data(request, token)
user = users.get_user_from_request(request)
redirect = redirect_to or base.Horizon.get_user_home(user)
@ -125,17 +131,18 @@ class Login(forms.SelfHandlingForm):
data['username'],
data['password'])
except keystone_exceptions.Unauthorized:
exceptions.handle(request,
_('Invalid user name or password.'))
msg = _('Invalid user name or password.')
exceptions.handle(request, ignore=True)
return self.api_error(msg)
except:
# If we get here we don't want to show a stack trace to the
# user. However, if we fail here, there may be bad session
# data that's been cached already.
request.user_logout()
exceptions.handle(request,
message=_("An error occurred authenticating."
" Please try again later."),
escalate=True)
msg = _("An error occurred authenticating. "
"Please try again later.")
exceptions.handle(request, ignore=True)
return self.api_error(msg)
# Unscoped token
request.session['unscoped_token'] = unscoped_token.id
@ -146,16 +153,13 @@ class Login(forms.SelfHandlingForm):
try:
tenants = api.tenant_list_for_token(request, unscoped_token.id)
except:
exceptions.handle(request)
exceptions.handle(request, ignore=True)
tenants = []
# Abort if there are no valid tenants for this user
if not tenants:
messages.error(request,
_('You are not authorized for any projects.') %
{"user": data['username']},
extra_tags="login")
return
msg = _('You are not authorized for any projects.')
return self.api_error(msg)
# Create a token.
# NOTE(gabriel): Keystone can return tenants that you're
@ -175,8 +179,8 @@ class Login(forms.SelfHandlingForm):
exceptions.handle(request, ignore=True)
token = None
if token is None:
raise exceptions.NotAuthorized(
_("You are not authorized for any available projects."))
msg = _("You are not authorized for any available projects.")
return self.api_error(msg)
_set_session_data(request, token)
user = users.get_user_from_request(request)

View File

@ -1,3 +1,5 @@
@import "../../bootstrap/less/bootstrap.less";
/* new clearfix */
.clearfix:after {
visibility: hidden;
@ -436,6 +438,25 @@ table form {
width: 1px;
}
.messages {
position: absolute;
z-index: 9999;
top: 20px;
right: 20px;
width: 300px;
.alert-block {
-webkit-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
-moz-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
&.alert-error {
border: 1px solid @red;
}
&.alert-success {
border: 1px solid @green;
}
}
}
.alert-block .alert-actions {
margin-top: -23px;
margin-right: -23px;

View File

@ -1,3 +1,6 @@
{% load compress %}
{% compress js %}
{% comment %} jQuery and Plugins {% endcomment %}
<script src='{{ STATIC_URL }}horizon/js/jquery/jquery.min.js' type='text/javascript' charset="utf-8"></script>
<script src="{{ STATIC_URL }}horizon/js/spin.js" type="text/javascript" charset="utf-8"></script>
@ -25,6 +28,7 @@
<script src='{{ STATIC_URL }}horizon/js/forms.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}horizon/js/form_examples.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}horizon/js/quotas.js' type='text/javascript' charset='utf-8'></script>
{% endcompress %}
{% comment %} Client-side Templates {% endcomment %}
{% comment %} Client-side Templates (These should *not* be inside the "compress" tag.) {% endcomment %}
{% include "horizon/client_side/templates.html" %}

View File

@ -1,7 +1,6 @@
{% load compress %}
{% compress css %}
<link href='{{ STATIC_URL }}bootstrap/less/bootstrap.less' type='text/less' media='screen' rel='stylesheet' />
<link href='{{ STATIC_URL }}dashboard/less/horizon.less' type='text/less' media='screen' rel='stylesheet' />
{% endcompress %}