From 0e025d9d71a3bad44fe1403b4e5ac645cc4286b1 Mon Sep 17 00:00:00 2001 From: lin-hua-cheng Date: Thu, 3 Dec 2015 12:34:01 -0800 Subject: [PATCH] Add message of the day option Allow user to configure messages to display to the user after they login. Change-Id: I6dc0318708d0f964e52c8b127718297fc723651c Implements: blueprint message-of-the-day --- doc/source/topics/settings.rst | 22 +++ horizon/exceptions.py | 5 + horizon/notifications.py | 150 ++++++++++++++++++ horizon/test/messages/test_info.json | 1 + horizon/test/messages/test_invalid.json | 1 + horizon/test/messages/test_warning.json | 1 + horizon/test/tests/notifications.py | 59 +++++++ .../local/local_settings.py.example | 5 + openstack_dashboard/views.py | 9 ++ .../message-of-the-day-19eb745a147ca56d.yaml | 9 ++ 10 files changed, 262 insertions(+) create mode 100644 horizon/notifications.py create mode 100644 horizon/test/messages/test_info.json create mode 100644 horizon/test/messages/test_invalid.json create mode 100644 horizon/test/messages/test_warning.json create mode 100644 horizon/test/tests/notifications.py create mode 100644 releasenotes/notes/message-of-the-day-19eb745a147ca56d.yaml diff --git a/doc/source/topics/settings.rst b/doc/source/topics/settings.rst index e53042a5f..4b89bc2d9 100644 --- a/doc/source/topics/settings.rst +++ b/doc/source/topics/settings.rst @@ -508,6 +508,28 @@ This setting can be used in the case where a separate panel is used for managing a custom property or if a certain custom property should never be edited. + +``MESSAGES_PATH`` +----------------- + +.. versionadded:: 9.0.0(Mitaka) + +Default: ``None`` + +The absolute path to the directory where message files are collected. + +When the user logins to horizon, the message files collected are processed +and displayed to the user. Each message file should contain a JSON formatted +data and must have a .json file extension. For example:: + + { + "level": "info", + "message": "message of the day here" + } + +Possible values for level are: success, info, warning and error. + + ``OPENSTACK_API_VERSIONS`` -------------------------- diff --git a/horizon/exceptions.py b/horizon/exceptions.py index 68ac98cd7..5b9ad9f6d 100644 --- a/horizon/exceptions.py +++ b/horizon/exceptions.py @@ -217,6 +217,11 @@ class WorkflowValidationError(HorizonException): pass +class MessageFailure(HorizonException): + """Exception raised during message notification.""" + pass + + class HandledException(HorizonException): """Used internally to track exceptions that have gone through :func:`horizon.exceptions.handle` more than once. diff --git a/horizon/notifications.py b/horizon/notifications.py new file mode 100644 index 000000000..31f2b5aab --- /dev/null +++ b/horizon/notifications.py @@ -0,0 +1,150 @@ +# Copyright (C) 2015 Yahoo! Inc. All Rights Reserved. +# +# 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. + +import glob +import json +import logging +import os + +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import messages + + +LOG = logging.getLogger(__name__) + +_MESSAGES_CACHE = None +_MESSAGES_MTIME = None + + +class JSONMessage(object): + + INFO = messages.info + SUCCESS = messages.success + WARNING = messages.warning + ERROR = messages.error + + MESSAGE_LEVELS = { + 'info': INFO, + 'success': SUCCESS, + 'warning': WARNING, + 'error': ERROR + } + + def __init__(self, path, fail_silently=False): + self._path = path + self._data = '' + + self.failed = False + self.fail_silently = fail_silently + self.message = '' + self.level = self.INFO + self.level_name = 'info' + + def _read(self): + with open(self._path, 'rb') as file_obj: + self._data = file_obj.read() + + def _parse(self): + attrs = {} + try: + data = self._data.decode('utf-8') + attrs = json.loads(data) + except ValueError as exc: + self.failed = True + + msg = _("Message json file '%(path)s' is malformed." + " %(exception)s") + msg = msg % {'path': self._path, 'exception': str(exc)} + if self.fail_silently: + LOG.warning(msg) + else: + raise exceptions.MessageFailure(msg) + else: + level_name = attrs.get('level', 'info') + if level_name in self.MESSAGE_LEVELS: + self.level_name = level_name + + self.level = self.MESSAGE_LEVELS.get(self.level_name, self.INFO) + self.message = attrs.get('message', '') + + def load(self): + """Read and parse the message file.""" + try: + self._read() + self._parse() + except Exception as exc: + self.failed = True + + msg = _("Error processing message json file '%(path)s': " + "%(exception)s") + msg = msg % {'path': self._path, 'exception': str(exc)} + if self.fail_silently: + LOG.warning(msg) + else: + raise exceptions.MessageFailure(msg) + + def send_message(self, request): + if self.failed: + return + self.level(request, mark_safe(self.message)) + + +def _is_path(path): + if os.path.exists(path) and os.path.isdir(path): + return True + else: + return False + + +def _get_processed_messages(messages_path): + msgs = list() + + if not _is_path(messages_path): + LOG.error('%s is not a valid messages path.', messages_path) + return msgs + + # Get all files from messages_path with .json extension + for fname in glob.glob(os.path.join(messages_path, '*.json')): + fpath = os.path.join(messages_path, fname) + + msg = JSONMessage(fpath, fail_silently=True) + msg.load() + + if not msg.failed: + msgs.append(msg) + + return msgs + + +def process_message_notification(request, messages_path): + """Process all the msg file found in the message directory""" + if not messages_path: + return + + global _MESSAGES_CACHE + global _MESSAGES_MTIME + + # NOTE (lhcheng): Cache the processed messages to avoid parsing + # the files every time. Check directory modification time if + # reload is necessary. + if (_MESSAGES_CACHE is None + or _MESSAGES_MTIME != os.path.getmtime(messages_path)): + _MESSAGES_CACHE = _get_processed_messages(messages_path) + _MESSAGES_MTIME = os.path.getmtime(messages_path) + + for msg in _MESSAGES_CACHE: + msg.send_message(request) diff --git a/horizon/test/messages/test_info.json b/horizon/test/messages/test_info.json new file mode 100644 index 000000000..c4e466989 --- /dev/null +++ b/horizon/test/messages/test_info.json @@ -0,0 +1 @@ +{"level": "info", "message": "info message"} \ No newline at end of file diff --git a/horizon/test/messages/test_invalid.json b/horizon/test/messages/test_invalid.json new file mode 100644 index 000000000..1299eecda --- /dev/null +++ b/horizon/test/messages/test_invalid.json @@ -0,0 +1 @@ +invalid msg file \ No newline at end of file diff --git a/horizon/test/messages/test_warning.json b/horizon/test/messages/test_warning.json new file mode 100644 index 000000000..00b4bfb08 --- /dev/null +++ b/horizon/test/messages/test_warning.json @@ -0,0 +1 @@ +{"level": "warning", "message": "warning message"} \ No newline at end of file diff --git a/horizon/test/tests/notifications.py b/horizon/test/tests/notifications.py new file mode 100644 index 000000000..c89aabb04 --- /dev/null +++ b/horizon/test/tests/notifications.py @@ -0,0 +1,59 @@ +# Copyright (C) 2015 Yahoo! Inc. All Rights Reserved. +# +# 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. + +import os + +from django.conf import settings + +from horizon import exceptions +from horizon.notifications import JSONMessage +from horizon.test import helpers as test + + +class NotificationTests(test.TestCase): + + MESSAGES_PATH = os.path.abspath(os.path.join(settings.ROOT_PATH, + 'messages')) + + def _test_msg(self, path, expected_level, expected_msg=''): + msg = JSONMessage(path) + msg.load() + + self.assertEqual(expected_level, msg.level_name) + self.assertEqual(expected_msg, msg.message) + + def test_warning_msg(self): + path = self.MESSAGES_PATH + '/test_warning.json' + + self._test_msg(path, 'warning', 'warning message') + + def test_info_msg(self): + path = self.MESSAGES_PATH + '/test_info.json' + + self._test_msg(path, 'info', 'info message') + + def test_invalid_msg_file(self): + path = self.MESSAGES_PATH + '/test_invalid.json' + + with self.assertRaises(exceptions.MessageFailure): + msg = JSONMessage(path) + msg.load() + + def test_invalid_msg_file_fail_silently(self): + path = self.MESSAGES_PATH + '/test_invalid.json' + + msg = JSONMessage(path, fail_silently=True) + msg.load() + + self.assertTrue(msg.failed) diff --git a/openstack_dashboard/local/local_settings.py.example b/openstack_dashboard/local/local_settings.py.example index 25ac1c132..7e0dd91da 100644 --- a/openstack_dashboard/local/local_settings.py.example +++ b/openstack_dashboard/local/local_settings.py.example @@ -40,6 +40,11 @@ WEBROOT = '/' #CSRF_COOKIE_SECURE = True #SESSION_COOKIE_SECURE = True +# The absolute path to the directory where message files are collected. +# The message file must have a .json file extension. When the user logins to +# horizon, the message files collected are processed and displayed to the user. +#MESSAGES_PATH=None + # Overrides for OpenStack API versions. Use this setting to force the # OpenStack dashboard to use a specific API version for a given service API. # Versions specified here should be integers or floats, not strings. diff --git a/openstack_dashboard/views.py b/openstack_dashboard/views.py index 6a252ec9e..365e47a4e 100644 --- a/openstack_dashboard/views.py +++ b/openstack_dashboard/views.py @@ -12,12 +12,17 @@ # License for the specific language governing permissions and limitations # under the License. +from django.conf import settings from django import shortcuts import django.views.decorators.vary import horizon from horizon import base from horizon import exceptions +from horizon import notifications + + +MESSAGES_PATH = getattr(settings, 'MESSAGES_PATH', None) def get_user_home(user): @@ -42,4 +47,8 @@ def splash(request): response = shortcuts.redirect(horizon.get_user_home(request.user)) if 'logout_reason' in request.COOKIES: response.delete_cookie('logout_reason') + # Display Message of the Day message from the message files + # located in MESSAGES_PATH + if MESSAGES_PATH: + notifications.process_message_notification(request, MESSAGES_PATH) return response diff --git a/releasenotes/notes/message-of-the-day-19eb745a147ca56d.yaml b/releasenotes/notes/message-of-the-day-19eb745a147ca56d.yaml new file mode 100644 index 000000000..ceb550e5b --- /dev/null +++ b/releasenotes/notes/message-of-the-day-19eb745a147ca56d.yaml @@ -0,0 +1,9 @@ +--- +features: + - > + [`blueprint message-of-the-day `_] + Message of the day can now be configured in horizon, this will be displayed + to the user whenever they login. To enable the feature set ``MESSAGES_PATH`` + in the local_settting.py to the directory where message files are located. + The message file must have a .json file extension. +