Merge "Avoid duplicated message in Batch/DeleteAction"
This commit is contained in:
commit
f1eba8073d
@ -15,6 +15,7 @@
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
import copy
|
import copy
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import types
|
import types
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
from django.utils.translation import ungettext_lazy
|
from django.utils.translation import ungettext_lazy
|
||||||
import six
|
import six
|
||||||
|
|
||||||
|
from horizon import exceptions
|
||||||
from horizon import messages
|
from horizon import messages
|
||||||
from horizon.utils import functions
|
from horizon.utils import functions
|
||||||
from horizon.utils import html
|
from horizon.utils import html
|
||||||
@ -775,10 +777,22 @@ class BatchAction(Action):
|
|||||||
{'action': self._get_action_name(past=True),
|
{'action': self._get_action_name(past=True),
|
||||||
'datum_display': datum_display})
|
'datum_display': datum_display})
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
# Handle the exception but silence it since we'll display
|
handled_exc = isinstance(ex, exceptions.HandledException)
|
||||||
# an aggregate error message later. Otherwise we'd get
|
if handled_exc:
|
||||||
# multiple error messages displayed to the user.
|
# In case of HandledException, an error message should be
|
||||||
action_failure.append(datum_display)
|
# 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 = (
|
action_description = (
|
||||||
self._get_action_name(past=True).lower(), datum_display)
|
self._get_action_name(past=True).lower(), datum_display)
|
||||||
LOG.warning(
|
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):
|
class Deprecated(type):
|
||||||
# TODO(lcastell) Replace class with similar functionality from
|
# TODO(lcastell) Replace class with similar functionality from
|
||||||
# oslo_log.versionutils when it's finally added in 11.0
|
# oslo_log.versionutils when it's finally added in 11.0
|
||||||
|
@ -15,6 +15,9 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from django import forms
|
from django import forms
|
||||||
from django import http
|
from django import http
|
||||||
@ -23,10 +26,13 @@ from django.template import defaultfilters
|
|||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from django.utils.translation import ungettext_lazy
|
from django.utils.translation import ungettext_lazy
|
||||||
|
|
||||||
|
import mock
|
||||||
from mox3.mox import IsA
|
from mox3.mox import IsA
|
||||||
import six
|
import six
|
||||||
|
|
||||||
|
from horizon import exceptions
|
||||||
from horizon import tables
|
from horizon import tables
|
||||||
|
from horizon.tables import actions
|
||||||
from horizon.tables import formset as table_formset
|
from horizon.tables import formset as table_formset
|
||||||
from horizon.tables import views as table_views
|
from horizon.tables import views as table_views
|
||||||
from horizon.test import helpers as test
|
from horizon.test import helpers as test
|
||||||
@ -1603,3 +1609,81 @@ class FormsetTableTests(test.TestCase):
|
|||||||
form_data = form.initial
|
form_data = form.initial
|
||||||
self.assertEqual('object_1', form_data['name'])
|
self.assertEqual('object_1', form_data['name'])
|
||||||
self.assertEqual(2, form_data['value'])
|
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)
|
||||||
|
Loading…
Reference in New Issue
Block a user