diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 818160ac3b..3931654a95 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4338,13 +4338,30 @@ class NotificationTemplateSerializer(BaseSerializer): error_list = [] collected_messages = [] + def check_messages(messages): + for message_type in messages: + if message_type not in ('message', 'body'): + error_list.append(_("Message type '{}' invalid, must be either 'message' or 'body'").format(message_type)) + continue + message = messages[message_type] + if message is None: + continue + if not isinstance(message, str): + error_list.append(_("Expected string for '{}', found {}, ").format(message_type, type(message))) + continue + if message_type == 'message': + if '\n' in message: + error_list.append(_("Messages cannot contain newlines (found newline in {} event)".format(event))) + continue + collected_messages.append(message) + # Validate structure / content types if not isinstance(messages, dict): error_list.append(_("Expected dict for 'messages' field, found {}".format(type(messages)))) else: for event in messages: - if event not in ['started', 'success', 'error']: - error_list.append(_("Event '{}' invalid, must be one of 'started', 'success', or 'error'").format(event)) + if event not in ('started', 'success', 'error', 'workflow_approval'): + error_list.append(_("Event '{}' invalid, must be one of 'started', 'success', 'error', or 'workflow_approval'").format(event)) continue event_messages = messages[event] if event_messages is None: @@ -4352,21 +4369,21 @@ class NotificationTemplateSerializer(BaseSerializer): if not isinstance(event_messages, dict): error_list.append(_("Expected dict for event '{}', found {}").format(event, type(event_messages))) continue - for message_type in event_messages: - if message_type not in ['message', 'body']: - error_list.append(_("Message type '{}' invalid, must be either 'message' or 'body'").format(message_type)) - continue - message = event_messages[message_type] - if message is None: - continue - if not isinstance(message, str): - error_list.append(_("Expected string for '{}', found {}, ").format(message_type, type(message))) - continue - if message_type == 'message': - if '\n' in message: - error_list.append(_("Messages cannot contain newlines (found newline in {} event)".format(event))) + if event == 'workflow_approval': + for subevent in event_messages: + if subevent not in ('running', 'approved', 'timed_out', 'denied'): + error_list.append(_("Workflow Approval event '{}' invalid, must be one of " + "'running', 'approved', 'timed_out', or 'denied'").format(subevent)) continue - collected_messages.append(message) + subevent_messages = event_messages[subevent] + if subevent_messages is None: + continue + if not isinstance(subevent_messages, dict): + error_list.append(_("Expected dict for workflow approval event '{}', found {}").format(subevent, type(subevent_messages))) + continue + check_messages(subevent_messages) + else: + check_messages(event_messages) # Subclass to return name of undefined field class DescriptiveUndefined(StrictUndefined): diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index 9585a404ec..3d422d5e25 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -73,7 +73,7 @@ class NotificationTemplate(CommonModelNameNotUnique): notification_configuration = prevent_search(JSONField(blank=False)) def default_messages(): - return {'started': None, 'success': None, 'error': None} + return {'started': None, 'success': None, 'error': None, 'workflow_approval': None} messages = JSONField( null=True, @@ -109,19 +109,34 @@ class NotificationTemplate(CommonModelNameNotUnique): old_messages = old_nt.messages new_messages = self.messages + def merge_messages(local_old_messages, local_new_messages, local_event): + if local_new_messages.get(local_event, {}) and local_old_messages.get(local_event, {}): + local_old_event_msgs = local_old_messages[local_event] + local_new_event_msgs = local_new_messages[local_event] + for msg_type in ['message', 'body']: + if msg_type not in local_new_event_msgs and local_old_event_msgs.get(msg_type, None): + local_new_event_msgs[msg_type] = local_old_event_msgs[msg_type] if old_messages is not None and new_messages is not None: - for event in ['started', 'success', 'error']: + for event in ('started', 'success', 'error', 'workflow_approval'): if not new_messages.get(event, {}) and old_messages.get(event, {}): new_messages[event] = old_messages[event] continue - if new_messages.get(event, {}) and old_messages.get(event, {}): - old_event_msgs = old_messages[event] - new_event_msgs = new_messages[event] - for msg_type in ['message', 'body']: - if msg_type not in new_event_msgs and old_event_msgs.get(msg_type, None): - new_event_msgs[msg_type] = old_event_msgs[msg_type] + + if event == 'workflow_approval' and old_messages.get('workflow_approval', None): + new_messages.setdefault('workflow_approval', {}) + for subevent in ('running', 'approved', 'timed_out', 'denied'): + old_wfa_messages = old_messages['workflow_approval'] + new_wfa_messages = new_messages['workflow_approval'] + if not new_wfa_messages.get(subevent, {}) and old_wfa_messages.get(subevent, {}): + new_wfa_messages[subevent] = old_wfa_messages[subevent] + continue + if old_wfa_messages: + merge_messages(old_wfa_messages, new_wfa_messages, subevent) + else: + merge_messages(old_messages, new_messages, event) new_messages.setdefault(event, None) + for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password", self.notification_class.init_parameters): if self.notification_configuration[field].startswith("$encrypted$"): @@ -370,8 +385,8 @@ class JobNotificationMixin(object): return context def context(self, serialized_job): - """Returns a context that can be used for rendering notification messages. - Context contains whitelisted content retrieved from a serialized job object + """Returns a dictionary that can be used for rendering notification messages. + The context will contain whitelisted content retrieved from a serialized job object (see JobNotificationMixin.JOB_FIELDS_WHITELIST), the job's friendly name, and a url to the job run.""" context = {'job': {}, @@ -419,14 +434,15 @@ class JobNotificationMixin(object): # Use custom template if available if nt.messages: - templates = nt.messages.get(self.STATUS_TO_TEMPLATE_TYPE[status], {}) or {} - msg_template = templates.get('message', None) - body_template = templates.get('body', None) + template = nt.messages.get(self.STATUS_TO_TEMPLATE_TYPE[status], {}) or {} + msg_template = template.get('message', None) + body_template = template.get('body', None) # If custom template not provided, look up default template + default_template = nt.notification_class.default_messages[self.STATUS_TO_TEMPLATE_TYPE[status]] if not msg_template: - msg_template = getattr(nt.notification_class, 'DEFAULT_MSG', None) + msg_template = default_template.get('message', None) if not body_template: - body_template = getattr(nt.notification_class, 'DEFAULT_BODY', None) + body_template = default_template.get('body', None) if msg_template: try: diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index bcfee585b8..83a7c91ad3 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -2,6 +2,7 @@ # All Rights Reserved. # Python +import json import logging from copy import copy from urllib.parse import urljoin @@ -16,6 +17,9 @@ from django.core.exceptions import ObjectDoesNotExist # Django-CRUM from crum import get_current_user +from jinja2 import sandbox +from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError + # AWX from awx.api.versioning import reverse from awx.main.models import (prevent_search, accepts_json, UnifiedJobTemplate, @@ -763,22 +767,45 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin): connection.on_commit(send_it()) def build_approval_notification_message(self, nt, approval_status): - subject = [] - workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id)) - subject.append(('The approval node "{}"').format(self.workflow_approval_template.name)) - if approval_status == 'running': - subject.append(('needs review. This node can be viewed at: {}').format(workflow_url)) - if approval_status == 'approved': - subject.append(('was approved. {}').format(workflow_url)) - if approval_status == 'timed_out': - subject.append(('has timed out. {}').format(workflow_url)) - elif approval_status == 'denied': - subject.append(('was denied. {}').format(workflow_url)) - subject = " ".join(subject) - body = self.notification_data() - body['body'] = subject + env = sandbox.ImmutableSandboxedEnvironment() - return subject, body + context = self.context(approval_status) + + msg_template = body_template = None + msg = body = '' + + # Use custom template if available + if nt.messages and nt.messages.get('workflow_approval', None): + template = nt.messages['workflow_approval'].get(approval_status, {}) + msg_template = template.get('message', None) + body_template = template.get('body', None) + # If custom template not provided, look up default template + default_template = nt.notification_class.default_messages['workflow_approval'][approval_status] + if not msg_template: + msg_template = default_template.get('message', None) + if not body_template: + body_template = default_template.get('body', None) + + if msg_template: + try: + msg = env.from_string(msg_template).render(**context) + except (TemplateSyntaxError, UndefinedError, SecurityError): + msg = '' + + if body_template: + try: + body = env.from_string(body_template).render(**context) + except (TemplateSyntaxError, UndefinedError, SecurityError): + body = '' + + return (msg, body) + + def context(self, approval_status): + workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id)) + return {'approval_status': approval_status, + 'approval_node_name': self.workflow_approval_template.name, + 'workflow_url': workflow_url, + 'job_summary_dict': json.dumps(self.notification_data(), indent=4)} @property def workflow_job_template(self): diff --git a/awx/main/notifications/custom_notification_base.py b/awx/main/notifications/custom_notification_base.py index 58443cd5d7..a99c710e95 100644 --- a/awx/main/notifications/custom_notification_base.py +++ b/awx/main/notifications/custom_notification_base.py @@ -11,4 +11,13 @@ class CustomNotificationBase(object): default_messages = {"started": {"message": DEFAULT_MSG, "body": None}, "success": {"message": DEFAULT_MSG, "body": None}, - "error": {"message": DEFAULT_MSG, "body": None}} + "error": {"message": DEFAULT_MSG, "body": None}, + "workflow_approval": {"running": {"message": 'The approval node "{{ approval_node_name }}" needs review. ' + 'This node can be viewed at: {{ workflow_url }}', + "body": None}, + "approved": {"message": 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}', + "body": None}, + "timed_out": {"message": 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}', + "body": None}, + "denied": {"message": 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}', + "body": None}}} diff --git a/awx/main/notifications/email_backend.py b/awx/main/notifications/email_backend.py index b8210c86b0..2b9c7d8d58 100644 --- a/awx/main/notifications/email_backend.py +++ b/awx/main/notifications/email_backend.py @@ -4,7 +4,9 @@ from django.core.mail.backends.smtp import EmailBackend from awx.main.notifications.custom_notification_base import CustomNotificationBase -from CustomNotificationBase import DEFAULT_MSG, DEFAULT_BODY + +DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG +DEFAULT_BODY = CustomNotificationBase.DEFAULT_BODY class CustomEmailBackend(EmailBackend, CustomNotificationBase): @@ -23,7 +25,11 @@ class CustomEmailBackend(EmailBackend, CustomNotificationBase): default_messages = {"started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, "success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, - "error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}} + "error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "workflow_approval": {"running": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "approved": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "timed_out": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "denied": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}}} def format_body(self, body): # leave body unchanged (expect a string) diff --git a/awx/main/notifications/pagerduty_backend.py b/awx/main/notifications/pagerduty_backend.py index c1625523f4..a2399b9777 100644 --- a/awx/main/notifications/pagerduty_backend.py +++ b/awx/main/notifications/pagerduty_backend.py @@ -10,7 +10,9 @@ from django.utils.translation import ugettext_lazy as _ from awx.main.notifications.base import AWXBaseEmailBackend from awx.main.notifications.custom_notification_base import CustomNotificationBase -from CustomNotificationBase import DEFAULT_MSG + +DEFAULT_BODY = CustomNotificationBase.DEFAULT_BODY +DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG logger = logging.getLogger('awx.main.notifications.pagerduty_backend') @@ -25,9 +27,13 @@ class PagerDutyBackend(AWXBaseEmailBackend, CustomNotificationBase): sender_parameter = "client_name" DEFAULT_BODY = "{{ job_summary_dict }}" - default_messages = {"started": { "message": DEFAULT_MSG, "body": DEFAULT_BODY}, - "success": { "message": DEFAULT_MSG, "body": DEFAULT_BODY}, - "error": { "message": DEFAULT_MSG, "body": DEFAULT_BODY}} + default_messages = {"started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "workflow_approval": {"running": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "approved": {"message": DEFAULT_MSG,"body": DEFAULT_BODY}, + "timed_out": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "denied": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}}} def __init__(self, subdomain, token, fail_silently=False, **kwargs): super(PagerDutyBackend, self).__init__(fail_silently=fail_silently) diff --git a/awx/main/notifications/webhook_backend.py b/awx/main/notifications/webhook_backend.py index b10f6557d3..f05012c4d6 100644 --- a/awx/main/notifications/webhook_backend.py +++ b/awx/main/notifications/webhook_backend.py @@ -29,7 +29,13 @@ class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase): DEFAULT_BODY = "{{ job_summary_dict }}" default_messages = {"started": {"body": DEFAULT_BODY}, "success": {"body": DEFAULT_BODY}, - "error": {"body": DEFAULT_BODY}} + "error": {"body": DEFAULT_BODY}, + "workflow_approval": { + "running": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" needs review. ' + 'This node can be viewed at: {{ workflow_url }}"}'}, + "approved": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" was approved. {{ workflow_url }}"}'}, + "timed_out": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" has timed out. {{ workflow_url }}"}'}, + "denied": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" was denied. {{ workflow_url }}"}'}}} def __init__(self, http_method, headers, disable_ssl_verification=False, fail_silently=False, username=None, password=None, **kwargs): self.http_method = http_method