diff --git a/awx/api/metadata.py b/awx/api/metadata.py index cc44e6d0e9..bdc00796eb 100644 --- a/awx/api/metadata.py +++ b/awx/api/metadata.py @@ -114,6 +114,17 @@ class Metadata(metadata.SimpleMetadata): for (notification_type_name, notification_tr_name, notification_type_class) in NotificationTemplate.NOTIFICATION_TYPES: field_info[notification_type_name] = notification_type_class.init_parameters + # Special handling of notification messages where the required properties + # are conditional on the type selected. + try: + view_model = field.context['view'].model + except (AttributeError, KeyError): + view_model = None + if view_model == NotificationTemplate and field.field_name == 'messages': + for (notification_type_name, notification_tr_name, notification_type_class) in NotificationTemplate.NOTIFICATION_TYPES: + field_info[notification_type_name] = notification_type_class.default_messages + + # Update type of fields returned... if field.field_name == 'type': field_info['type'] = 'choice' diff --git a/awx/api/serializers.py b/awx/api/serializers.py index ddaf87cc84..5f38158cda 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -13,6 +13,10 @@ from datetime import timedelta from oauthlib import oauth2 from oauthlib.common import generate_token +# Jinja +from jinja2 import sandbox, StrictUndefined +from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError + # Django from django.conf import settings from django.contrib.auth import update_session_auth_hash @@ -46,16 +50,16 @@ from awx.main.constants import ( CENSOR_VALUE, ) from awx.main.models import ( - ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialInputSource, - CredentialType, CustomInventoryScript, Group, Host, Instance, - InstanceGroup, Inventory, InventorySource, InventoryUpdate, - InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobLaunchConfig, - JobTemplate, Label, Notification, NotificationTemplate, - OAuth2AccessToken, OAuth2Application, Organization, Project, - ProjectUpdate, ProjectUpdateEvent, RefreshToken, Role, Schedule, - SystemJob, SystemJobEvent, SystemJobTemplate, Team, UnifiedJob, - UnifiedJobTemplate, WorkflowJob, WorkflowJobNode, - WorkflowJobTemplate, WorkflowJobTemplateNode, StdoutMaxBytesExceeded + ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, + CredentialInputSource, CredentialType, CustomInventoryScript, + Group, Host, Instance, InstanceGroup, Inventory, InventorySource, + InventoryUpdate, InventoryUpdateEvent, Job, JobEvent, JobHostSummary, + JobLaunchConfig, JobNotificationMixin, JobTemplate, Label, Notification, + NotificationTemplate, OAuth2AccessToken, OAuth2Application, Organization, + Project, ProjectUpdate, ProjectUpdateEvent, RefreshToken, Role, Schedule, + StdoutMaxBytesExceeded, SystemJob, SystemJobEvent, SystemJobTemplate, + Team, UnifiedJob, UnifiedJobTemplate, WorkflowJob, WorkflowJobNode, + WorkflowJobTemplate, WorkflowJobTemplateNode ) from awx.main.models.base import VERBOSITY_CHOICES, NEW_JOB_TYPE_CHOICES from awx.main.models.rbac import ( @@ -4128,7 +4132,8 @@ class NotificationTemplateSerializer(BaseSerializer): class Meta: model = NotificationTemplate - fields = ('*', 'organization', 'notification_type', 'notification_configuration') + fields = ('*', 'organization', 'notification_type', 'notification_configuration', 'messages') + type_map = {"string": (str,), "int": (int,), @@ -4162,6 +4167,96 @@ class NotificationTemplateSerializer(BaseSerializer): d['recent_notifications'] = self._recent_notifications(obj) return d + def validate_messages(self, messages): + if messages is None: + return None + + error_list = [] + collected_messages = [] + + # 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)) + continue + event_messages = messages[event] + if event_messages is None: + continue + 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))) + continue + collected_messages.append(message) + + # Subclass to return name of undefined field + class DescriptiveUndefined(StrictUndefined): + # The parent class prevents _accessing attributes_ of an object + # but will render undefined objects with 'Undefined'. This + # prevents their use entirely. + __repr__ = __str__ = StrictUndefined._fail_with_undefined_error + + def __init__(self, *args, **kwargs): + super(DescriptiveUndefined, self).__init__(*args, **kwargs) + # When an undefined field is encountered, return the name + # of the undefined field in the exception message + # (StrictUndefined refers to the explicitly set exception + # message as the 'hint') + self._undefined_hint = self._undefined_name + + # Ensure messages can be rendered + for msg in collected_messages: + env = sandbox.ImmutableSandboxedEnvironment(undefined=DescriptiveUndefined) + try: + env.from_string(msg).render(JobNotificationMixin.context_stub()) + except TemplateSyntaxError as exc: + error_list.append(_("Unable to render message '{}': {}".format(msg, exc.message))) + except UndefinedError as exc: + error_list.append(_("Field '{}' unavailable".format(exc.message))) + except SecurityError as exc: + error_list.append(_("Security error due to field '{}'".format(exc.message))) + + # Ensure that if a webhook body was provided, that it can be rendered as a dictionary + notification_type = '' + if self.instance: + notification_type = getattr(self.instance, 'notification_type', '') + else: + notification_type = self.initial_data.get('notification_type', '') + + if notification_type == 'webhook': + for event in messages: + if not messages[event]: + continue + body = messages[event].get('body', {}) + if body: + try: + potential_body = json.loads(body) + if not isinstance(potential_body, dict): + error_list.append(_("Webhook body for '{}' should be a json dictionary. Found type '{}'." + .format(event, type(potential_body).__name__))) + except json.JSONDecodeError as exc: + error_list.append(_("Webhook body for '{}' is not a valid json dictionary ({}).".format(event, exc))) + + if error_list: + raise serializers.ValidationError(error_list) + + return messages + def validate(self, attrs): from awx.api.views import NotificationTemplateDetail @@ -4226,10 +4321,19 @@ class NotificationTemplateSerializer(BaseSerializer): class NotificationSerializer(BaseSerializer): + body = serializers.SerializerMethodField( + help_text=_('Notification body') + ) + class Meta: model = Notification fields = ('*', '-name', '-description', 'notification_template', 'error', 'status', 'notifications_sent', - 'notification_type', 'recipients', 'subject') + 'notification_type', 'recipients', 'subject', 'body') + + def get_body(self, obj): + if obj.notification_type == 'webhook' and 'body' in obj.body: + return obj.body['body'] + return obj.body def get_related(self, obj): res = super(NotificationSerializer, self).get_related(obj) @@ -4238,6 +4342,15 @@ class NotificationSerializer(BaseSerializer): )) return res + def to_representation(self, obj): + ret = super(NotificationSerializer, self).to_representation(obj) + + if obj.notification_type == 'webhook': + ret.pop('subject') + if obj.notification_type not in ('email', 'webhook', 'pagerduty'): + ret.pop('body') + return ret + class LabelSerializer(BaseSerializer): diff --git a/awx/main/migrations/0084_v360_token_description.py b/awx/main/migrations/0084_v360_token_description.py index b360b3e0f8..c93e2021cd 100644 --- a/awx/main/migrations/0084_v360_token_description.py +++ b/awx/main/migrations/0084_v360_token_description.py @@ -2,6 +2,7 @@ from django.db import migrations, models +import awx class Migration(migrations.Migration): diff --git a/awx/main/migrations/0085_v360_add_notificationtemplate_messages.py b/awx/main/migrations/0085_v360_add_notificationtemplate_messages.py new file mode 100644 index 0000000000..72917aff4c --- /dev/null +++ b/awx/main/migrations/0085_v360_add_notificationtemplate_messages.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-06-10 16:56 +from __future__ import unicode_literals + +from django.db import migrations, models + +import awx.main.fields +import awx.main.models.notifications + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0084_v360_token_description'), + ] + + operations = [ + migrations.AddField( + model_name='notificationtemplate', + name='messages', + field=awx.main.fields.JSONField(default=awx.main.models.notifications.NotificationTemplate.default_messages, + help_text='Optional custom messages for notification template.', + null=True, + blank=True), + ), + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('email', 'Email'), ('grafana', 'Grafana'), ('hipchat', 'HipChat'), ('irc', 'IRC'), ('mattermost', 'Mattermost'), ('pagerduty', 'Pagerduty'), ('rocketchat', 'Rocket.Chat'), ('slack', 'Slack'), ('twilio', 'Twilio'), ('webhook', 'Webhook')], max_length=32), + ), + migrations.AlterField( + model_name='notificationtemplate', + name='notification_type', + field=models.CharField(choices=[('email', 'Email'), ('grafana', 'Grafana'), ('hipchat', 'HipChat'), ('irc', 'IRC'), ('mattermost', 'Mattermost'), ('pagerduty', 'Pagerduty'), ('rocketchat', 'Rocket.Chat'), ('slack', 'Slack'), ('twilio', 'Twilio'), ('webhook', 'Webhook')], max_length=32), + ), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 51e62f55a3..d0ed13ef54 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -48,7 +48,10 @@ from awx.main.models.mixins import ( # noqa TaskManagerJobMixin, TaskManagerProjectUpdateMixin, TaskManagerUnifiedJobMixin, ) -from awx.main.models.notifications import Notification, NotificationTemplate # noqa +from awx.main.models.notifications import ( # noqa + Notification, NotificationTemplate, + JobNotificationMixin +) from awx.main.models.label import Label # noqa from awx.main.models.workflow import ( # noqa WorkflowJob, WorkflowJobNode, WorkflowJobOptions, WorkflowJobTemplate, diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index f3fc42bd46..7a48defb19 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -670,7 +670,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana data = super(Job, self).notification_data() all_hosts = {} # NOTE: Probably related to job event slowness, remove at some point -matburt - if block: + if block and self.status != 'running': summaries = self.job_host_summaries.all() while block > 0 and not len(summaries): time.sleep(1) @@ -684,7 +684,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana failures=h.failures, ok=h.ok, processed=h.processed, - skipped=h.skipped) + skipped=h.skipped) # TODO: update with rescued, ignored (see https://github.com/ansible/awx/issues/4394) data.update(dict(inventory=self.inventory.name if self.inventory else None, project=self.project.name if self.project else None, playbook=self.playbook, diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index fc99caf37c..e3b69128d8 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -2,7 +2,9 @@ # All Rights Reserved. from copy import deepcopy +import datetime import logging +import json from django.db import models from django.conf import settings @@ -10,6 +12,8 @@ from django.core.mail.message import EmailMessage from django.db import connection from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_str, force_text +from jinja2 import sandbox +from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError # AWX from awx.api.versioning import reverse @@ -45,7 +49,7 @@ class NotificationTemplate(CommonModelNameNotUnique): ('mattermost', _('Mattermost'), MattermostBackend), ('rocketchat', _('Rocket.Chat'), RocketChatBackend), ('irc', _('IRC'), IrcBackend)] - NOTIFICATION_TYPE_CHOICES = [(x[0], x[1]) for x in NOTIFICATION_TYPES] + NOTIFICATION_TYPE_CHOICES = sorted([(x[0], x[1]) for x in NOTIFICATION_TYPES]) CLASS_FOR_NOTIFICATION_TYPE = dict([(x[0], x[2]) for x in NOTIFICATION_TYPES]) class Meta: @@ -68,6 +72,45 @@ class NotificationTemplate(CommonModelNameNotUnique): notification_configuration = JSONField(blank=False) + def default_messages(): + return {'started': None, 'success': None, 'error': None} + + messages = JSONField( + null=True, + blank=True, + default=default_messages, + help_text=_('Optional custom messages for notification template.')) + + def has_message(self, condition): + potential_template = self.messages.get(condition, {}) + if potential_template == {}: + return False + if potential_template.get('message', {}) == {}: + return False + return True + + def get_message(self, condition): + return self.messages.get(condition, {}) + + def build_notification_message(self, event_type, context): + env = sandbox.ImmutableSandboxedEnvironment() + templates = self.get_message(event_type) + msg_template = templates.get('message', {}) + + try: + notification_subject = env.from_string(msg_template).render(**context) + except (TemplateSyntaxError, UndefinedError, SecurityError): + notification_subject = '' + + + msg_body = templates.get('body', {}) + try: + notification_body = env.from_string(msg_body).render(**context) + except (TemplateSyntaxError, UndefinedError, SecurityError): + notification_body = '' + + return (notification_subject, notification_body) + def get_absolute_url(self, request=None): return reverse('api:notification_template_detail', kwargs={'pk': self.pk}, request=request) @@ -78,6 +121,26 @@ class NotificationTemplate(CommonModelNameNotUnique): def save(self, *args, **kwargs): new_instance = not bool(self.pk) update_fields = kwargs.get('update_fields', []) + + # preserve existing notification messages if not overwritten by new messages + if not new_instance: + old_nt = NotificationTemplate.objects.get(pk=self.id) + old_messages = old_nt.messages + new_messages = self.messages + + if old_messages is not None and new_messages is not None: + for event in ['started', 'success', 'error']: + 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] + 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$"): @@ -201,56 +264,228 @@ class Notification(CreatedModifiedModel): class JobNotificationMixin(object): + STATUS_TO_TEMPLATE_TYPE = {'succeeded': 'success', + 'running': 'started', + 'failed': 'error'} + # Tree of fields that can be safely referenced in a notification message + JOB_FIELDS_WHITELIST = ['id', 'type', 'url', 'created', 'modified', 'name', 'description', 'job_type', 'playbook', + 'forks', 'limit', 'verbosity', 'job_tags', 'force_handlers', 'skip_tags', 'start_at_task', + 'timeout', 'use_fact_cache', 'launch_type', 'status', 'failed', 'started', 'finished', + 'elapsed', 'job_explanation', 'execution_node', 'controller_node', 'allow_simultaneous', + 'scm_revision', 'diff_mode', 'job_slice_number', 'job_slice_count', 'custom_virtualenv', + {'host_status_counts': ['skipped', 'ok', 'changed', 'failures', 'dark']}, + {'playbook_counts': ['play_count', 'task_count']}, + {'summary_fields': [{'inventory': ['id', 'name', 'description', 'has_active_failures', + 'total_hosts', 'hosts_with_active_failures', 'total_groups', + 'groups_with_active_failures', 'has_inventory_sources', + 'total_inventory_sources', 'inventory_sources_with_failures', + 'organization_id', 'kind']}, + {'project': ['id', 'name', 'description', 'status', 'scm_type']}, + {'project_update': ['id', 'name', 'description', 'status', 'failed']}, + {'job_template': ['id', 'name', 'description']}, + {'unified_job_template': ['id', 'name', 'description', 'unified_job_type']}, + {'instance_group': ['name', 'id']}, + {'created_by': ['id', 'username', 'first_name', 'last_name']}, + {'labels': ['count', 'results']}, + {'source_workflow_job': ['description', 'elapsed', 'failed', 'id', 'name', 'status']}]}] + + @classmethod + def context_stub(cls): + """Returns a stub context that can be used for validating notification messages. + Context has the same structure as the context that will actually be used to render + a notification message.""" + context = {'job': {'allow_simultaneous': False, + 'controller_node': 'foo_controller', + 'created': datetime.datetime(2018, 11, 13, 6, 4, 0, 0, tzinfo=datetime.timezone.utc), + 'custom_virtualenv': 'my_venv', + 'description': 'Sample job description', + 'diff_mode': False, + 'elapsed': 0.403018, + 'execution_node': 'awx', + 'failed': False, + 'finished': False, + 'force_handlers': False, + 'forks': 0, + 'host_status_counts': {'skipped': 1, 'ok': 5, 'changed': 3, 'failures': 0, 'dark': 0}, + 'id': 42, + 'job_explanation': 'Sample job explanation', + 'job_slice_count': 1, + 'job_slice_number': 0, + 'job_tags': '', + 'job_type': 'run', + 'launch_type': 'workflow', + 'limit': 'bar_limit', + 'modified': datetime.datetime(2018, 12, 13, 6, 4, 0, 0, tzinfo=datetime.timezone.utc), + 'name': 'Stub JobTemplate', + 'playbook_counts': {'play_count': 5, 'task_count': 10}, + 'playbook': 'ping.yml', + 'scm_revision': '', + 'skip_tags': '', + 'start_at_task': '', + 'started': '2019-07-29T17:38:14.137461Z', + 'status': 'running', + 'summary_fields': {'created_by': {'first_name': '', + 'id': 1, + 'last_name': '', + 'username': 'admin'}, + 'instance_group': {'id': 1, 'name': 'tower'}, + 'inventory': {'description': 'Sample inventory description', + 'groups_with_active_failures': 0, + 'has_active_failures': False, + 'has_inventory_sources': False, + 'hosts_with_active_failures': 0, + 'id': 17, + 'inventory_sources_with_failures': 0, + 'kind': '', + 'name': 'Stub Inventory', + 'organization_id': 121, + 'total_groups': 0, + 'total_hosts': 1, + 'total_inventory_sources': 0}, + 'job_template': {'description': 'Sample job template description', + 'id': 39, + 'name': 'Stub JobTemplate'}, + 'labels': {'count': 0, 'results': []}, + 'project': {'description': 'Sample project description', + 'id': 38, + 'name': 'Stub project', + 'scm_type': 'git', + 'status': 'successful'}, + 'project_update': {'id': 5, 'name': 'Stub Project Update', 'description': 'Project Update', + 'status': 'running', 'failed': False}, + 'unified_job_template': {'description': 'Sample unified job template description', + 'id': 39, + 'name': 'Stub Job Template', + 'unified_job_type': 'job'}, + 'source_workflow_job': {'description': 'Sample workflow job description', + 'elapsed': 0.000, + 'failed': False, + 'id': 88, + 'name': 'Stub WorkflowJobTemplate', + 'status': 'running'}}, + 'timeout': 0, + 'type': 'job', + 'url': '/api/v2/jobs/13/', + 'use_fact_cache': False, + 'verbosity': 0}, + 'job_friendly_name': 'Job', + 'url': 'https://towerhost/#/jobs/playbook/1010', + 'job_summary_dict': """{'url': 'https://towerhost/$/jobs/playbook/13', + 'traceback': '', + 'status': 'running', + 'started': '2019-08-07T21:46:38.362630+00:00', + 'project': 'Stub project', + 'playbook': 'ping.yml', + 'name': 'Stub Job Template', + 'limit': '', + 'inventory': 'Stub Inventory', + 'id': 42, + 'hosts': {}, + 'friendly_name': 'Job', + 'finished': False, + 'credential': 'Stub credential', + 'created_by': 'admin'}"""} + + 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 + (see JobNotificationMixin.JOB_FIELDS_WHITELIST), the job's friendly name, + and a url to the job run.""" + context = {'job': {}, + 'job_friendly_name': self.get_notification_friendly_name(), + 'url': self.get_ui_url(), + 'job_summary_dict': json.dumps(self.notification_data(), indent=4)} + + def build_context(node, fields, whitelisted_fields): + for safe_field in whitelisted_fields: + if type(safe_field) is dict: + field, whitelist_subnode = safe_field.copy().popitem() + # ensure content present in job serialization + if field not in fields: + continue + subnode = fields[field] + node[field] = {} + build_context(node[field], subnode, whitelist_subnode) + else: + # ensure content present in job serialization + if safe_field not in fields: + continue + node[safe_field] = fields[safe_field] + build_context(context['job'], serialized_job, self.JOB_FIELDS_WHITELIST) + + return context + def get_notification_templates(self): raise RuntimeError("Define me") def get_notification_friendly_name(self): raise RuntimeError("Define me") - def _build_notification_message(self, status_str): + def notification_data(self): + raise RuntimeError("Define me") + + def build_notification_message(self, nt, status): + env = sandbox.ImmutableSandboxedEnvironment() + + from awx.api.serializers import UnifiedJobSerializer + job_serialization = UnifiedJobSerializer(self).to_representation(self) + context = self.context(job_serialization) + + msg_template = body_template = None + + if nt.messages: + templates = nt.messages.get(self.STATUS_TO_TEMPLATE_TYPE[status], {}) or {} + msg_template = templates.get('message', {}) + body_template = templates.get('body', {}) + + if msg_template: + try: + notification_subject = env.from_string(msg_template).render(**context) + except (TemplateSyntaxError, UndefinedError, SecurityError): + notification_subject = '' + else: + notification_subject = u"{} #{} '{}' {}: {}".format(self.get_notification_friendly_name(), + self.id, + self.name, + status, + self.get_ui_url()) notification_body = self.notification_data() - notification_subject = u"{} #{} '{}' {}: {}".format(self.get_notification_friendly_name(), - self.id, - self.name, - status_str, - notification_body['url']) notification_body['friendly_name'] = self.get_notification_friendly_name() + if body_template: + try: + notification_body['body'] = env.from_string(body_template).render(**context) + except (TemplateSyntaxError, UndefinedError, SecurityError): + notification_body['body'] = '' + return (notification_subject, notification_body) - def build_notification_succeeded_message(self): - return self._build_notification_message('succeeded') - - def build_notification_failed_message(self): - return self._build_notification_message('failed') - - def build_notification_running_message(self): - return self._build_notification_message('running') - - def send_notification_templates(self, status_str): + def send_notification_templates(self, status): from awx.main.tasks import send_notifications # avoid circular import - if status_str not in ['succeeded', 'failed', 'running']: - raise ValueError(_("status_str must be either running, succeeded or failed")) + if status not in ['running', 'succeeded', 'failed']: + raise ValueError(_("status must be either running, succeeded or failed")) + try: notification_templates = self.get_notification_templates() except Exception: logger.warn("No notification template defined for emitting notification") - notification_templates = None - if notification_templates: - if status_str == 'succeeded': - notification_template_type = 'success' - elif status_str == 'running': - notification_template_type = 'started' - else: - notification_template_type = 'error' - all_notification_templates = set(notification_templates.get(notification_template_type, [])) - if len(all_notification_templates): - try: - (notification_subject, notification_body) = getattr(self, 'build_notification_%s_message' % status_str)() - except AttributeError: - raise NotImplementedError("build_notification_%s_message() does not exist" % status_str) + return - def send_it(): - send_notifications.delay([n.generate_notification(notification_subject, notification_body).id - for n in all_notification_templates], + if not notification_templates: + return + + for nt in set(notification_templates.get(self.STATUS_TO_TEMPLATE_TYPE[status], [])): + try: + (notification_subject, notification_body) = self.build_notification_message(nt, status) + except AttributeError: + raise NotImplementedError("build_notification_message() does not exist" % status) + + # Use kwargs to force late-binding + # https://stackoverflow.com/a/3431699/10669572 + def send_it(local_nt=nt, local_subject=notification_subject, local_body=notification_body): + def _func(): + send_notifications.delay([local_nt.generate_notification(local_subject, local_body).id], job_id=self.id) - connection.on_commit(send_it) + return _func + connection.on_commit(send_it()) diff --git a/awx/main/notifications/email_backend.py b/awx/main/notifications/email_backend.py index 1b30b344bb..8abce4fff1 100644 --- a/awx/main/notifications/email_backend.py +++ b/awx/main/notifications/email_backend.py @@ -19,6 +19,12 @@ class CustomEmailBackend(EmailBackend): "sender": {"label": "Sender Email", "type": "string"}, "recipients": {"label": "Recipient List", "type": "list"}, "timeout": {"label": "Timeout", "type": "int", "default": 30}} + + DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" + DEFAULT_BODY = smart_text(_("{{ job_friendly_name }} #{{ job.id }} had status {{ job.status }}, view details at {{ url }}\n\n{{ job_summary_dict }}")) + default_messages = {"started": {"message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}, + "success": {"message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}, + "error": {"message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}} recipient_parameter = "recipients" sender_parameter = "sender" diff --git a/awx/main/notifications/grafana_backend.py b/awx/main/notifications/grafana_backend.py index 0044a1b098..ccd176e4f4 100644 --- a/awx/main/notifications/grafana_backend.py +++ b/awx/main/notifications/grafana_backend.py @@ -21,6 +21,11 @@ class GrafanaBackend(AWXBaseEmailBackend): recipient_parameter = "grafana_url" sender_parameter = None + DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" + default_messages = {"started": {"message": DEFAULT_SUBJECT}, + "success": {"message": DEFAULT_SUBJECT}, + "error": {"message": DEFAULT_SUBJECT}} + def __init__(self, grafana_key,dashboardId=None, panelId=None, annotation_tags=None, grafana_no_verify_ssl=False, isRegion=True, fail_silently=False, **kwargs): super(GrafanaBackend, self).__init__(fail_silently=fail_silently) diff --git a/awx/main/notifications/hipchat_backend.py b/awx/main/notifications/hipchat_backend.py index 3d7505351b..0fa53b4471 100644 --- a/awx/main/notifications/hipchat_backend.py +++ b/awx/main/notifications/hipchat_backend.py @@ -23,6 +23,11 @@ class HipChatBackend(AWXBaseEmailBackend): recipient_parameter = "rooms" sender_parameter = "message_from" + DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" + default_messages = {"started": {"message": DEFAULT_SUBJECT}, + "success": {"message": DEFAULT_SUBJECT}, + "error": {"message": DEFAULT_SUBJECT}} + def __init__(self, token, color, api_url, notify, fail_silently=False, **kwargs): super(HipChatBackend, self).__init__(fail_silently=fail_silently) self.token = token diff --git a/awx/main/notifications/irc_backend.py b/awx/main/notifications/irc_backend.py index 2493451e77..037293a0d3 100644 --- a/awx/main/notifications/irc_backend.py +++ b/awx/main/notifications/irc_backend.py @@ -25,6 +25,11 @@ class IrcBackend(AWXBaseEmailBackend): recipient_parameter = "targets" sender_parameter = None + DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" + default_messages = {"started": {"message": DEFAULT_SUBJECT}, + "success": {"message": DEFAULT_SUBJECT}, + "error": {"message": DEFAULT_SUBJECT}} + def __init__(self, server, port, nickname, password, use_ssl, fail_silently=False, **kwargs): super(IrcBackend, self).__init__(fail_silently=fail_silently) self.server = server diff --git a/awx/main/notifications/mattermost_backend.py b/awx/main/notifications/mattermost_backend.py index 8d8755f9bf..41b3c4caa4 100644 --- a/awx/main/notifications/mattermost_backend.py +++ b/awx/main/notifications/mattermost_backend.py @@ -19,6 +19,11 @@ class MattermostBackend(AWXBaseEmailBackend): recipient_parameter = "mattermost_url" sender_parameter = None + DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" + default_messages = {"started": {"message": DEFAULT_SUBJECT}, + "success": {"message": DEFAULT_SUBJECT}, + "error": {"message": DEFAULT_SUBJECT}} + def __init__(self, mattermost_no_verify_ssl=False, mattermost_channel=None, mattermost_username=None, mattermost_icon_url=None, fail_silently=False, **kwargs): super(MattermostBackend, self).__init__(fail_silently=fail_silently) diff --git a/awx/main/notifications/pagerduty_backend.py b/awx/main/notifications/pagerduty_backend.py index 21eb2a9aae..55827a67de 100644 --- a/awx/main/notifications/pagerduty_backend.py +++ b/awx/main/notifications/pagerduty_backend.py @@ -20,6 +20,12 @@ class PagerDutyBackend(AWXBaseEmailBackend): recipient_parameter = "service_key" sender_parameter = "client_name" + DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" + DEFAULT_BODY = "{{ job_summary_dict }}" + default_messages = {"started": { "message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}, + "success": { "message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}, + "error": { "message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}} + def __init__(self, subdomain, token, fail_silently=False, **kwargs): super(PagerDutyBackend, self).__init__(fail_silently=fail_silently) self.subdomain = subdomain diff --git a/awx/main/notifications/rocketchat_backend.py b/awx/main/notifications/rocketchat_backend.py index c7fe84f0b2..e211708a8c 100644 --- a/awx/main/notifications/rocketchat_backend.py +++ b/awx/main/notifications/rocketchat_backend.py @@ -19,6 +19,11 @@ class RocketChatBackend(AWXBaseEmailBackend): recipient_parameter = "rocketchat_url" sender_parameter = None + DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" + default_messages = {"started": {"message": DEFAULT_SUBJECT}, + "success": {"message": DEFAULT_SUBJECT}, + "error": {"message": DEFAULT_SUBJECT}} + def __init__(self, rocketchat_no_verify_ssl=False, rocketchat_username=None, rocketchat_icon_url=None, fail_silently=False, **kwargs): super(RocketChatBackend, self).__init__(fail_silently=fail_silently) self.rocketchat_no_verify_ssl = rocketchat_no_verify_ssl diff --git a/awx/main/notifications/slack_backend.py b/awx/main/notifications/slack_backend.py index a78c2c4aeb..24b7876708 100644 --- a/awx/main/notifications/slack_backend.py +++ b/awx/main/notifications/slack_backend.py @@ -19,6 +19,11 @@ class SlackBackend(AWXBaseEmailBackend): recipient_parameter = "channels" sender_parameter = None + DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" + default_messages = {"started": {"message": DEFAULT_SUBJECT}, + "success": {"message": DEFAULT_SUBJECT}, + "error": {"message": DEFAULT_SUBJECT}} + def __init__(self, token, hex_color="", fail_silently=False, **kwargs): super(SlackBackend, self).__init__(fail_silently=fail_silently) self.token = token @@ -50,7 +55,7 @@ class SlackBackend(AWXBaseEmailBackend): if ret['ok']: sent_messages += 1 else: - raise RuntimeError("Slack Notification unable to send {}: {}".format(r, m.subject)) + raise RuntimeError("Slack Notification unable to send {}: {} ({})".format(r, m.subject, ret['error'])) except Exception as e: logger.error(smart_text(_("Exception sending messages: {}").format(e))) if not self.fail_silently: diff --git a/awx/main/notifications/twilio_backend.py b/awx/main/notifications/twilio_backend.py index 077ef7573b..21e98e6882 100644 --- a/awx/main/notifications/twilio_backend.py +++ b/awx/main/notifications/twilio_backend.py @@ -21,6 +21,11 @@ class TwilioBackend(AWXBaseEmailBackend): recipient_parameter = "to_numbers" sender_parameter = "from_number" + DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" + default_messages = {"started": {"message": DEFAULT_SUBJECT}, + "success": {"message": DEFAULT_SUBJECT}, + "error": {"message": DEFAULT_SUBJECT}} + def __init__(self, account_sid, account_token, fail_silently=False, **kwargs): super(TwilioBackend, self).__init__(fail_silently=fail_silently) self.account_sid = account_sid diff --git a/awx/main/notifications/webhook_backend.py b/awx/main/notifications/webhook_backend.py index 91a6f15118..92cddf4b3b 100644 --- a/awx/main/notifications/webhook_backend.py +++ b/awx/main/notifications/webhook_backend.py @@ -1,6 +1,7 @@ # Copyright (c) 2016 Ansible, Inc. # All Rights Reserved. +import json import logging import requests @@ -23,6 +24,11 @@ class WebhookBackend(AWXBaseEmailBackend): recipient_parameter = "url" sender_parameter = None + DEFAULT_BODY = "{{ job_summary_dict }}" + default_messages = {"started": {"body": DEFAULT_BODY}, + "success": {"body": DEFAULT_BODY}, + "error": {"body": DEFAULT_BODY}} + def __init__(self, http_method, headers, disable_ssl_verification=False, fail_silently=False, username=None, password=None, **kwargs): self.http_method = http_method self.disable_ssl_verification = disable_ssl_verification @@ -32,6 +38,15 @@ class WebhookBackend(AWXBaseEmailBackend): super(WebhookBackend, self).__init__(fail_silently=fail_silently) def format_body(self, body): + # If `body` has body field, attempt to use this as the main body, + # otherwise, leave it as a sub-field + if isinstance(body, dict) and 'body' in body and isinstance(body['body'], str): + try: + potential_body = json.loads(body['body']) + if isinstance(potential_body, dict): + body = potential_body + except json.JSONDecodeError: + pass return body def send_messages(self, messages): diff --git a/awx/main/tests/functional/models/test_notifications.py b/awx/main/tests/functional/models/test_notifications.py new file mode 100644 index 0000000000..e835f2d2dd --- /dev/null +++ b/awx/main/tests/functional/models/test_notifications.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +from copy import deepcopy +import datetime + +import pytest + +#from awx.main.models import NotificationTemplates, Notifications, JobNotificationMixin +from awx.main.models import (AdHocCommand, InventoryUpdate, Job, JobNotificationMixin, ProjectUpdate, + SystemJob, WorkflowJob) +from awx.api.serializers import UnifiedJobSerializer + + +class TestJobNotificationMixin(object): + CONTEXT_STRUCTURE = {'job': {'allow_simultaneous': bool, + 'custom_virtualenv': str, + 'controller_node': str, + 'created': datetime.datetime, + 'description': str, + 'diff_mode': bool, + 'elapsed': float, + 'execution_node': str, + 'failed': bool, + 'finished': bool, + 'force_handlers': bool, + 'forks': int, + 'host_status_counts': {'skipped': int, 'ok': int, 'changed': int, + 'failures': int, 'dark': int}, + 'id': int, + 'job_explanation': str, + 'job_slice_count': int, + 'job_slice_number': int, + 'job_tags': str, + 'job_type': str, + 'launch_type': str, + 'limit': str, + 'modified': datetime.datetime, + 'name': str, + 'playbook': str, + 'playbook_counts': {'play_count': int, 'task_count': int}, + 'scm_revision': str, + 'skip_tags': str, + 'start_at_task': str, + 'started': str, + 'status': str, + 'summary_fields': {'created_by': {'first_name': str, + 'id': int, + 'last_name': str, + 'username': str}, + 'instance_group': {'id': int, 'name': str}, + 'inventory': {'description': str, + 'groups_with_active_failures': int, + 'has_active_failures': bool, + 'has_inventory_sources': bool, + 'hosts_with_active_failures': int, + 'id': int, + 'inventory_sources_with_failures': int, + 'kind': str, + 'name': str, + 'organization_id': int, + 'total_groups': int, + 'total_hosts': int, + 'total_inventory_sources': int}, + 'job_template': {'description': str, + 'id': int, + 'name': str}, + 'labels': {'count': int, 'results': list}, + 'project': {'description': str, + 'id': int, + 'name': str, + 'scm_type': str, + 'status': str}, + 'project_update': {'id': int, 'name': str, 'description': str, 'status': str, 'failed': bool}, + 'unified_job_template': {'description': str, + 'id': int, + 'name': str, + 'unified_job_type': str}, + 'source_workflow_job': {'description': str, + 'elapsed': float, + 'failed': bool, + 'id': int, + 'name': str, + 'status': str}}, + + 'timeout': int, + 'type': str, + 'url': str, + 'use_fact_cache': bool, + 'verbosity': int}, + 'job_friendly_name': str, + 'job_summary_dict': str, + 'url': str} + + + @pytest.mark.django_db + @pytest.mark.parametrize('JobClass', [AdHocCommand, InventoryUpdate, Job, ProjectUpdate, SystemJob, WorkflowJob]) + def test_context(self, JobClass, sqlite_copy_expert, project, inventory_source): + """The Jinja context defines all of the fields that can be used by a template. Ensure that the context generated + for each job type has the expected structure.""" + def check_structure(expected_structure, obj): + if isinstance(expected_structure, dict): + assert isinstance(obj, dict) + for key in obj: + assert key in expected_structure + if obj[key] is None: + continue + if isinstance(expected_structure[key], dict): + assert isinstance(obj[key], dict) + check_structure(expected_structure[key], obj[key]) + else: + assert isinstance(obj[key], expected_structure[key]) + kwargs = {} + if JobClass is InventoryUpdate: + kwargs['inventory_source'] = inventory_source + elif JobClass is ProjectUpdate: + kwargs['project'] = project + + job = JobClass.objects.create(name='foo', **kwargs) + job_serialization = UnifiedJobSerializer(job).to_representation(job) + + context = job.context(job_serialization) + check_structure(TestJobNotificationMixin.CONTEXT_STRUCTURE, context) + + def test_context_stub(self): + """The context stub is a fake context used to validate custom notification messages. Ensure that + this also has the expected structure. Furthermore, ensure that the stub context contains + *all* fields that could possibly be included in a context.""" + def check_structure_and_completeness(expected_structure, obj): + expected_structure = deepcopy(expected_structure) + if isinstance(expected_structure, dict): + assert isinstance(obj, dict) + for key in obj: + assert key in expected_structure + # Context stub should not have any undefined fields + assert obj[key] is not None + if isinstance(expected_structure[key], dict): + assert isinstance(obj[key], dict) + check_structure_and_completeness(expected_structure[key], obj[key]) + expected_structure.pop(key) + else: + assert isinstance(obj[key], expected_structure[key]) + expected_structure.pop(key) + # Ensure all items in expected structure were present + assert not len(expected_structure) + + context_stub = JobNotificationMixin.context_stub() + check_structure_and_completeness(TestJobNotificationMixin.CONTEXT_STRUCTURE, context_stub) + + diff --git a/awx/main/tests/functional/test_notifications.py b/awx/main/tests/functional/test_notifications.py index 308e52c2c7..4059d0b9a9 100644 --- a/awx/main/tests/functional/test_notifications.py +++ b/awx/main/tests/functional/test_notifications.py @@ -42,6 +42,8 @@ def test_basic_parameterization(get, post, user, organization): assert 'notification_configuration' in response.data assert 'url' in response.data['notification_configuration'] assert 'headers' in response.data['notification_configuration'] + assert 'messages' in response.data + assert response.data['messages'] == {'started': None, 'success': None, 'error': None} @pytest.mark.django_db diff --git a/awx/main/tests/unit/api/serializers/test_notification_template_serializers.py b/awx/main/tests/unit/api/serializers/test_notification_template_serializers.py new file mode 100644 index 0000000000..afd29820d2 --- /dev/null +++ b/awx/main/tests/unit/api/serializers/test_notification_template_serializers.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +import pytest +from rest_framework.serializers import ValidationError + +# AWX +from awx.api.serializers import NotificationTemplateSerializer + + +class StubNotificationTemplate(): + notification_type = 'email' + + +class TestNotificationTemplateSerializer(): + + @pytest.mark.parametrize('valid_messages', + [None, + {'started': None}, + {'started': {'message': None}}, + {'started': {'message': 'valid'}}, + {'started': {'body': 'valid'}}, + {'started': {'message': 'valid', 'body': 'valid'}}, + {'started': None, 'success': None, 'error': None}, + {'started': {'message': None, 'body': None}, + 'success': {'message': None, 'body': None}, + 'error': {'message': None, 'body': None}}, + {'started': {'message': '{{ job.id }}', 'body': '{{ job.status }}'}, + 'success': {'message': None, 'body': '{{ job_friendly_name }}'}, + 'error': {'message': '{{ url }}', 'body': None}}, + {'started': {'body': '{{ job_summary_dict }}'}}, + {'started': {'body': '{{ job.summary_fields.inventory.total_hosts }}'}}, + {'started': {'body': u'Iñtërnâtiônàlizætiøn'}} + ]) + def test_valid_messages(self, valid_messages): + serializer = NotificationTemplateSerializer() + serializer.instance = StubNotificationTemplate() + serializer.validate_messages(valid_messages) + + @pytest.mark.parametrize('invalid_messages', + [1, + [], + '', + {'invalid_event': ''}, + {'started': 'should_be_dict'}, + {'started': {'bad_message_type': ''}}, + {'started': {'message': 1}}, + {'started': {'message': []}}, + {'started': {'message': {}}}, + {'started': {'message': '{{ unclosed_braces'}}, + {'started': {'message': '{{ undefined }}'}}, + {'started': {'message': '{{ job.undefined }}'}}, + {'started': {'message': '{{ job.id | bad_filter }}'}}, + {'started': {'message': '{{ job.__class__ }}'}}, + {'started': {'message': 'Newlines \n not allowed\n'}}, + ]) + def test_invalid__messages(self, invalid_messages): + serializer = NotificationTemplateSerializer() + serializer.instance = StubNotificationTemplate() + with pytest.raises(ValidationError): + serializer.validate_messages(invalid_messages) diff --git a/awx/ui/client/legacy/styles/forms.less b/awx/ui/client/legacy/styles/forms.less index 9f31db8a7f..f36914df31 100644 --- a/awx/ui/client/legacy/styles/forms.less +++ b/awx/ui/client/legacy/styles/forms.less @@ -485,6 +485,8 @@ } .CodeMirror { + min-height: initial !important; + max-height: initial !important; border-radius: 5px; font-style: normal; color: @field-input-text; diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 8080175627..1e5767fd36 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -42,6 +42,7 @@ import toolbar from '~components/list/list-toolbar.directive'; import topNavItem from '~components/layout/top-nav-item.directive'; import truncate from '~components/truncate/truncate.directive'; import atCodeMirror from '~components/code-mirror'; +import atSyntaxHighlight from '~components/syntax-highlight'; import card from '~components/cards/card.directive'; import cardGroup from '~components/cards/group.directive'; import atSwitch from '~components/switch/switch.directive'; @@ -54,7 +55,8 @@ const MODULE_NAME = 'at.lib.components'; angular .module(MODULE_NAME, [ atLibServices, - atCodeMirror + atCodeMirror, + atSyntaxHighlight, ]) .directive('atActionGroup', actionGroup) .directive('atActionButton', actionButton) diff --git a/awx/ui/client/lib/components/syntax-highlight/index.js b/awx/ui/client/lib/components/syntax-highlight/index.js new file mode 100644 index 0000000000..4936e5ff81 --- /dev/null +++ b/awx/ui/client/lib/components/syntax-highlight/index.js @@ -0,0 +1,8 @@ +import syntaxHighlight from './syntax-highlight.directive'; + +const MODULE_NAME = 'at.syntax.highlight'; + +angular.module(MODULE_NAME, []) + .directive('atSyntaxHighlight', syntaxHighlight); + +export default MODULE_NAME; diff --git a/awx/ui/client/lib/components/syntax-highlight/syntax-highlight.directive.js b/awx/ui/client/lib/components/syntax-highlight/syntax-highlight.directive.js new file mode 100644 index 0000000000..8ef0be94e0 --- /dev/null +++ b/awx/ui/client/lib/components/syntax-highlight/syntax-highlight.directive.js @@ -0,0 +1,98 @@ +const templateUrl = require('~components/syntax-highlight/syntax-highlight.partial.html'); + +function atSyntaxHighlightController ($scope, AngularCodeMirror) { + const vm = this; + const varName = `${$scope.name}_codemirror`; + + function init () { + if ($scope.disabled === 'true') { + $scope.disabled = true; + } else if ($scope.disabled === 'false') { + $scope.disabled = false; + } + $scope.value = $scope.value || $scope.default; + + initCodeMirror(); + + $scope.$watch(varName, () => { + $scope.value = $scope[varName]; + if ($scope.oneLine && $scope.value && $scope.value.includes('\n')) { + $scope.hasNewlineError = true; + } else { + $scope.hasNewlineError = false; + } + }); + } + + function initCodeMirror () { + $scope.varName = varName; + $scope[varName] = $scope.value; + const codeMirror = AngularCodeMirror(!!$scope.disabled); + codeMirror.addModes({ + jinja2: { + mode: $scope.mode, + matchBrackets: true, + autoCloseBrackets: true, + styleActiveLine: true, + lineNumbers: true, + gutters: ['CodeMirror-lint-markers'], + lint: true, + scrollbarStyle: null, + } + }); + if (document.querySelector(`.ng-hide #${$scope.name}_codemirror`)) { + return; + } + codeMirror.showTextArea({ + scope: $scope, + model: varName, + element: `${$scope.name}_codemirror`, + lineNumbers: true, + mode: $scope.mode, + }); + } + + vm.name = $scope.name; + vm.rows = $scope.rows || 6; + if ($scope.init) { + $scope.init = init; + } + angular.element(document).ready(() => { + init(); + }); + $scope.$on('reset-code-mirror', () => { + setImmediate(initCodeMirror); + }); +} + +atSyntaxHighlightController.$inject = [ + '$scope', + 'AngularCodeMirror' +]; + +function atCodeMirrorTextarea () { + return { + restrict: 'E', + replace: true, + transclude: true, + templateUrl, + controller: atSyntaxHighlightController, + controllerAs: 'vm', + scope: { + disabled: '@', + label: '@', + labelClass: '@', + tooltip: '@', + tooltipPlacement: '@', + value: '=', + name: '@', + init: '=', + default: '@', + rows: '@', + oneLine: '@', + mode: '@', + } + }; +} + +export default atCodeMirrorTextarea; diff --git a/awx/ui/client/lib/components/syntax-highlight/syntax-highlight.partial.html b/awx/ui/client/lib/components/syntax-highlight/syntax-highlight.partial.html new file mode 100644 index 0000000000..595021035a --- /dev/null +++ b/awx/ui/client/lib/components/syntax-highlight/syntax-highlight.partial.html @@ -0,0 +1,33 @@ +
{{ job_friendly_name }}, ' +
+ '{{ url }}, or attributes of the job such as ' +
+ '{{ job.status }}. You may apply a number of possible ' +
+ 'variables in the message. Refer to the ' +
+ 'Ansible Tower documentation for more details.'),
+ closeable: false
+ },
+ started_message: {
+ label: i18n._('Start Message'),
+ class: 'Form-formGroup--fullWidth',
+ type: 'syntax_highlight',
+ mode: 'jinja2',
+ default: '',
+ ngShow: "customize_messages && notification_type.value != 'webhook'",
+ rows: 2,
+ oneLine: 'true',
+ ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
+ },
+ started_body: {
+ label: i18n._('Start Message Body'),
+ class: 'Form-formGroup--fullWidth',
+ type: 'syntax_highlight',
+ mode: 'jinja2',
+ default: '',
+ ngShow: "customize_messages && " +
+ "(notification_type.value == 'email' " +
+ "|| notification_type.value == 'pagerduty' " +
+ "|| notification_type.value == 'webhook')",
+ ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
+ },
+ success_message: {
+ label: i18n._('Success Message'),
+ class: 'Form-formGroup--fullWidth',
+ type: 'syntax_highlight',
+ mode: 'jinja2',
+ default: '',
+ ngShow: "customize_messages && notification_type.value != 'webhook'",
+ rows: 2,
+ oneLine: 'true',
+ ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
+ },
+ success_body: {
+ label: i18n._('Success Message Body'),
+ class: 'Form-formGroup--fullWidth',
+ type: 'syntax_highlight',
+ mode: 'jinja2',
+ default: '',
+ ngShow: "customize_messages && " +
+ "(notification_type.value == 'email' " +
+ "|| notification_type.value == 'pagerduty' " +
+ "|| notification_type.value == 'webhook')",
+ ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
+ },
+ error_message: {
+ label: i18n._('Error Message'),
+ class: 'Form-formGroup--fullWidth',
+ type: 'syntax_highlight',
+ mode: 'jinja2',
+ default: '',
+ ngShow: "customize_messages && notification_type.value != 'webhook'",
+ rows: 2,
+ oneLine: 'true',
+ ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
+ },
+ error_body: {
+ label: i18n._('Error Message Body'),
+ class: 'Form-formGroup--fullWidth',
+ type: 'syntax_highlight',
+ mode: 'jinja2',
+ default: '',
+ ngShow: "customize_messages && " +
+ "(notification_type.value == 'email' " +
+ "|| notification_type.value == 'pagerduty' " +
+ "|| notification_type.value == 'webhook')",
+ ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
+ },
},
buttons: { //for now always generates