Merge "Avoid duplicated message in Batch/DeleteAction"

This commit is contained in:
Zuul 2018-01-25 04:33:23 +00:00 committed by Gerrit Code Review
commit f1eba8073d
2 changed files with 175 additions and 4 deletions

View File

@ -15,6 +15,7 @@
from collections import defaultdict
from collections import OrderedDict
import copy
import functools
import logging
import types
@ -28,6 +29,7 @@ from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
import six
from horizon import exceptions
from horizon import messages
from horizon.utils import functions
from horizon.utils import html
@ -775,10 +777,22 @@ class BatchAction(Action):
{'action': self._get_action_name(past=True),
'datum_display': datum_display})
except Exception as ex:
# Handle the exception but silence it since we'll display
# an aggregate error message later. Otherwise we'd get
# multiple error messages displayed to the user.
action_failure.append(datum_display)
handled_exc = isinstance(ex, exceptions.HandledException)
if handled_exc:
# In case of HandledException, an error message should be
# handled in exceptions.handle() or other logic,
# so we don't need to handle the error message here.
# NOTE(amotoki): To raise HandledException from the logic,
# pass escalate=True and do not pass redirect argument
# to exceptions.handle().
# If an exception is handled, the original exception object
# is stored in ex.wrapped[1].
ex = ex.wrapped[1]
else:
# Handle the exception but silence it since we'll display
# an aggregate error message later. Otherwise we'd get
# multiple error messages displayed to the user.
action_failure.append(datum_display)
action_description = (
self._get_action_name(past=True).lower(), datum_display)
LOG.warning(
@ -879,6 +893,79 @@ class DeleteAction(BatchAction):
"""
class handle_exception_with_detail_message(object):
"""Decorator to allow special exception handling in BatchAction.action().
An exception from BatchAction.action() or DeleteAction.delete() is
normally caught by BatchAction.handle() and BatchAction.handle() displays
an aggregated error message. However, there are cases where we would like
to provide an error message which explains a failure reason if some
exception occurs so that users can understand situation better.
This decorator allows us to do this kind of special handling easily.
This can be applied to BatchAction.action() and DeleteAction.delete()
methods.
:param normal_log_message: Log message template when an exception other
than ``target_exception`` is detected. Keyword substituion "%(id)s"
and "%(exc)s" can be used.
:param target_exception: Exception class should be handled specially.
If this exception is caught, a log message will be logged using
``target_log_message`` and a user visible will be shown using
``target_user_message``. In this case, an aggregated error message
generated by BatchAction.handle() does not include an object which
causes this exception.
:param target_log_message: Log message template when an exception specified
in ``target_exception`` is detected. Keyword substituion "%(id)s"
and "%(exc)s" can be used.
:param target_user_message: User visible message template when an exception
specified in ``target_exception`` is detected. It is recommended to
use an internationalized string. Keyword substituion "%(name)s"
and "%(exc)s" can be used.
:param logger_name: (optional) Logger name to be used.
The usual pattern is to pass __name__ of a caller.
This allows us to show a module name of a caller in a logged message.
"""
def __init__(self, normal_log_message, target_exception,
target_log_message, target_user_message, logger_name=None):
self.logger = logging.getLogger(logger_name or __name__)
self.normal_log_message = normal_log_message
self.target_exception = target_exception
self.target_log_message = target_log_message
self.target_user_message = target_user_message
def __call__(self, fn):
@functools.wraps(fn)
def decorated(instance, request, obj_id):
try:
fn(instance, request, obj_id)
except self.target_exception as e:
self.logger.info(self.target_log_message,
{'id': obj_id, 'exc': e})
obj = instance.table.get_object_by_id(obj_id)
name = instance.table.get_object_display(obj)
msg = self.target_user_message % {'name': name, 'exc': e}
# 'escalate=True' is required to notify the caller
# (DeleteAction) of the failure. exceptions.handle() will
# raise a wrapped exception of HandledException and BatchAction
# will handle it. 'redirect' should not be passed here as
# 'redirect' has a priority over 'escalate' argument.
exceptions.handle(request, msg, escalate=True)
except Exception as e:
self.logger.info(self.normal_log_message,
{'id': obj_id, 'exc': e})
# NOTE: No exception handling is required here because
# BatchAction.handle() does it. What we need to do is
# just to re-raise the exception.
raise
return decorated
class Deprecated(type):
# TODO(lcastell) Replace class with similar functionality from
# oslo_log.versionutils when it's finally added in 11.0

View File

@ -15,6 +15,9 @@
# License for the specific language governing permissions and limitations
# under the License.
import unittest
import uuid
from django.core.urlresolvers import reverse
from django import forms
from django import http
@ -23,10 +26,13 @@ from django.template import defaultfilters
from django.test.utils import override_settings
from django.utils.translation import ungettext_lazy
import mock
from mox3.mox import IsA
import six
from horizon import exceptions
from horizon import tables
from horizon.tables import actions
from horizon.tables import formset as table_formset
from horizon.tables import views as table_views
from horizon.test import helpers as test
@ -1603,3 +1609,81 @@ class FormsetTableTests(test.TestCase):
form_data = form.initial
self.assertEqual('object_1', form_data['name'])
self.assertEqual(2, form_data['value'])
class MyException(Exception):
pass
class OtherException(Exception):
pass
class BatchActionDecoratorTests(unittest.TestCase):
def setUp(self):
obj = uuid.uuid4()
self.obj_id = 'id-%s' % str(obj)
self.obj_name = 'name-%s' % str(obj)
table = mock.Mock()
table.get_object_by_id.return_value = obj
table.get_object_display.return_value = self.obj_name
# getLogger is called insdie handle_exception_with_detail_message
# decorator, so this needs to be mocked before using the decorator.
self.logger = mock.Mock()
with mock.patch('logging.getLogger',
return_value=self.logger) as mock_getlogger:
class MyAction(object):
def __init__(self, table):
self.table = table
@actions.handle_exception_with_detail_message(
'normal log message %(id)s %(exc)s',
MyException,
'target log message %(id)s %(exc)s',
'target user message %(name)s %(exc)s',
'mylogger')
def action(self, request, obj_id):
self._to_be_mocked()
# This is required because if mock.patch replaces
# a decorated method. We are testing a decorated method
# so we need a separate method to mock,
def _to_be_mocked(self):
pass
self.action = MyAction(table)
self.mock_getlogger = mock_getlogger
def test_normal_exception(self):
myexc = OtherException()
with mock.patch.object(self.action, '_to_be_mocked',
side_effect=myexc):
self.assertRaises(OtherException,
self.action.action,
mock.sentinel.request, self.obj_id)
self.mock_getlogger.assert_called_once_with('mylogger')
self.logger.info.assert_called_once_with(
'normal log message %(id)s %(exc)s',
{'id': self.obj_id, 'exc': myexc})
def test_target_exception(self):
myexc = MyException()
handled = exceptions.HandledException(myexc)
with mock.patch.object(self.action, '_to_be_mocked',
side_effect=myexc), \
mock.patch.object(exceptions, 'handle',
side_effect=handled) as mocked_handle:
self.assertRaises(exceptions.HandledException,
self.action.action,
mock.sentinel.request, self.obj_id)
self.mock_getlogger.assert_called_once_with('mylogger')
self.logger.info.assert_called_once_with(
'target log message %(id)s %(exc)s',
{'id': self.obj_id, 'exc': myexc})
mocked_handle.assert_called_once_with(
mock.sentinel.request,
'target user message %(name)s %(exc)s' %
{'name': self.obj_name, 'exc': myexc},
escalate=True)