Merge pull request #4291 from jladdjr/templated_messages

Templated notifications

Reviewed-by: Jim Ladd
             https://github.com/jladdjr
This commit is contained in:
softwarefactory-project-zuul[bot]
2019-08-27 16:29:21 +00:00
committed by GitHub
37 changed files with 1252 additions and 96 deletions

View File

@@ -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'

View File

@@ -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):

View File

@@ -2,6 +2,7 @@
from django.db import migrations, models
import awx
class Migration(migrations.Migration):

View File

@@ -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),
),
]

View File

@@ -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,

View File

@@ -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,

View File

@@ -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())

View File

@@ -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"

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -485,6 +485,8 @@
}
.CodeMirror {
min-height: initial !important;
max-height: initial !important;
border-radius: 5px;
font-style: normal;
color: @field-input-text;

View File

@@ -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)

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,33 @@
<div>
<div class="atCodeMirror-label">
<div class="atCodeMirror-labelLeftSide">
<span class="atCodeMirror-labelText" ng-class="labelClass">
{{ label || vm.strings.get('code_mirror.label.VARIABLES') }}
</span>
<a
id=""
href=""
aw-pop-over="{{ tooltip || vm.strings.get('code_mirror.tooltip.TOOLTIP') }}"
data-placement="{{ tooltipPlacement || 'top' }}"
data-container="body"
over-title="{{ label || vm.strings.get('code_mirror.label.VARIABLES') }}"
class="help-link"
data-original-title="{{ label || vm.strings.get('code_mirror.label.VARIABLES') }}"
title="{{ label || vm.strings.get('code_mirror.label.VARIABLES') }}"
tabindex="-1"
ng-if="!!tooltip"
>
<i class="fa fa-question-circle"></i>
</a>
</div>
</div>
<textarea
ng-disabled="disabled"
rows="{{ vm.rows }}"
ng-model="codeMirrorValue"
name="{{ vm.name }}_codemirror"
class="form-control Form-textArea"
id="{{ vm.name }}_codemirror">
</textarea>
<div class="error" ng-show="hasNewlineError">New lines are not supported in this field</div>
</div>

View File

@@ -7,21 +7,24 @@
export default ['Rest', 'Wait', 'NotificationsFormObject',
'ProcessErrors', 'GetBasePath', 'Alert',
'GenerateForm', '$scope', '$state', 'CreateSelect2', 'GetChoices',
'NotificationsTypeChange', 'ParseTypeChange', 'i18n',
'NotificationsTypeChange', 'ParseTypeChange', 'i18n', 'MessageUtils', '$filter',
function(
Rest, Wait, NotificationsFormObject,
ProcessErrors, GetBasePath, Alert,
GenerateForm, $scope, $state, CreateSelect2, GetChoices,
NotificationsTypeChange, ParseTypeChange, i18n
NotificationsTypeChange, ParseTypeChange, i18n,
MessageUtils, $filter
) {
var generator = GenerateForm,
form = NotificationsFormObject,
url = GetBasePath('notification_templates');
url = GetBasePath('notification_templates'),
defaultMessages = {};
init();
function init() {
$scope.customize_messages = false;
Rest.setUrl(GetBasePath('notification_templates'));
Rest.options()
.then(({data}) => {
@@ -29,6 +32,8 @@ export default ['Rest', 'Wait', 'NotificationsFormObject',
$state.go("^");
Alert('Permission Error', 'You do not have permission to add a notification template.', 'alert-info');
}
defaultMessages = data.actions.GET.messages;
MessageUtils.setMessagesOnScope($scope, null, defaultMessages);
});
// apply form definition's default field values
GenerateForm.applyDefaults(form, $scope);
@@ -153,6 +158,29 @@ export default ['Rest', 'Wait', 'NotificationsFormObject',
});
};
$scope.$watch('customize_messages', (value) => {
if (value) {
$scope.$broadcast('reset-code-mirror', {
customize_messages: $scope.customize_messages,
});
}
});
$scope.toggleForm = function(key) {
$scope[key] = !$scope[key];
};
$scope.$watch('notification_type', (newValue, oldValue = {}) => {
if (newValue) {
MessageUtils.updateDefaultsOnScope(
$scope,
defaultMessages[oldValue.value],
defaultMessages[newValue.value]
);
$scope.$broadcast('reset-code-mirror', {
customize_messages: $scope.customize_messages,
});
}
});
$scope.emailOptionsChange = function () {
if ($scope.email_options === 'use_ssl') {
if ($scope.use_ssl) {
@@ -186,6 +214,7 @@ export default ['Rest', 'Wait', 'NotificationsFormObject',
"name": $scope.name,
"description": $scope.description,
"organization": $scope.organization,
"messages": MessageUtils.getMessagesObj($scope, defaultMessages),
"notification_type": v,
"notification_configuration": {}
};
@@ -238,10 +267,14 @@ export default ['Rest', 'Wait', 'NotificationsFormObject',
$state.go('notifications', {}, { reload: true });
Wait('stop');
})
.catch(({data, status}) => {
.catch(({ data, status }) => {
let description = 'POST returned status: ' + status;
if (data && data.messages && data.messages.length > 0) {
description = _.uniq(data.messages).join(', ');
}
ProcessErrors($scope, data, status, form, {
hdr: 'Error!',
msg: 'Failed to add new notifier. POST returned status: ' + status
msg: $filter('sanitize')('Failed to add new notifier. ' + description + '.')
});
});
};

View File

@@ -10,19 +10,22 @@ export default ['Rest', 'Wait',
'notification_template',
'$scope', '$state', 'GetChoices', 'CreateSelect2', 'Empty',
'NotificationsTypeChange', 'ParseTypeChange', 'i18n',
'MessageUtils', '$filter',
function(
Rest, Wait,
NotificationsFormObject, ProcessErrors, GetBasePath,
GenerateForm,
notification_template,
$scope, $state, GetChoices, CreateSelect2, Empty,
NotificationsTypeChange, ParseTypeChange, i18n
NotificationsTypeChange, ParseTypeChange, i18n,
MessageUtils, $filter
) {
var generator = GenerateForm,
id = notification_template.id,
form = NotificationsFormObject,
master = {},
url = GetBasePath('notification_templates');
url = GetBasePath('notification_templates'),
defaultMessages = {};
init();
@@ -35,6 +38,12 @@ export default ['Rest', 'Wait',
}
});
Rest.setUrl(GetBasePath('notification_templates'));
Rest.options()
.then(({data}) => {
defaultMessages = data.actions.GET.messages;
});
GetChoices({
scope: $scope,
url: url,
@@ -165,6 +174,9 @@ export default ['Rest', 'Wait',
field_id: 'notification_template_headers',
readOnly: !$scope.notification_template.summary_fields.user_capabilities.edit
});
MessageUtils.setMessagesOnScope($scope, data.messages, defaultMessages);
Wait('stop');
})
.catch(({data, status}) => {
@@ -175,8 +187,6 @@ export default ['Rest', 'Wait',
});
});
$scope.$watch('headers', function validate_headers(str) {
try {
let headers = JSON.parse(str);
@@ -237,6 +247,29 @@ export default ['Rest', 'Wait',
});
};
$scope.$watch('customize_messages', (value) => {
if (value) {
$scope.$broadcast('reset-code-mirror', {
customize_messages: $scope.customize_messages,
});
}
});
$scope.toggleForm = function(key) {
$scope[key] = !$scope[key];
};
$scope.$watch('notification_type', (newValue, oldValue = {}) => {
if (newValue) {
MessageUtils.updateDefaultsOnScope(
$scope,
defaultMessages[oldValue.value],
defaultMessages[newValue.value]
);
$scope.$broadcast('reset-code-mirror', {
customize_messages: $scope.customize_messages,
});
}
});
$scope.emailOptionsChange = function () {
if ($scope.email_options === 'use_ssl') {
if ($scope.use_ssl) {
@@ -269,6 +302,7 @@ export default ['Rest', 'Wait',
"name": $scope.name,
"description": $scope.description,
"organization": $scope.organization,
"messages": MessageUtils.getMessagesObj($scope, defaultMessages),
"notification_type": v,
"notification_configuration": {}
};
@@ -316,10 +350,14 @@ export default ['Rest', 'Wait',
$state.go('notifications', {}, { reload: true });
Wait('stop');
})
.catch(({data, status}) => {
.catch(({ data, status }) => {
let description = 'PUT returned status: ' + status;
if (data && data.messages && data.messages.length > 0) {
description = _.uniq(data.messages).join(', ');
}
ProcessErrors($scope, data, status, form, {
hdr: 'Error!',
msg: 'Failed to add new notification template. POST returned status: ' + status
msg: $filter('sanitize')('Failed to update notifier. ' + description + '.')
});
});
};

View File

@@ -15,6 +15,7 @@ import notificationsList from './notifications.list';
import toggleNotification from './shared/toggle-notification.factory';
import notificationsListInit from './shared/notification-list-init.factory';
import typeChange from './shared/type-change.service';
import messageUtils from './shared/message-utils.service';
import { N_ } from '../i18n';
export default
@@ -29,6 +30,7 @@ angular.module('notifications', [
.factory('ToggleNotification', toggleNotification)
.factory('NotificationsListInit', notificationsListInit)
.service('NotificationsTypeChange', typeChange)
.service('MessageUtils', messageUtils)
.config(['$stateProvider', 'stateDefinitionsProvider',
function($stateProvider, stateDefinitionsProvider) {
let stateDefinitions = stateDefinitionsProvider.$get();

View File

@@ -428,7 +428,7 @@ export default ['i18n', function(i18n) {
dataTitle: i18n._('HTTP Method'),
type: 'select',
ngOptions: 'choice.id as choice.name for choice in httpMethodChoices',
default: 'post',
default: 'POST',
awPopOver: i18n._('Specify an HTTP method for the webhook. Acceptable choices are: POST or PUT'),
awRequiredWhen: {
reqExpression: "webhook_required",
@@ -581,7 +581,96 @@ export default ['i18n', function(i18n) {
ngShow: "notification_type.value == 'slack' ",
ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
awPopOver: i18n._('Specify a notification color. Acceptable colors are hex color code (example: #3af or #789abc) .')
}
},
customize_messages: {
label: i18n._('Customize messages…'),
type: 'toggleSwitch',
toggleSource: 'customize_messages',
class: 'Form-formGroup--fullWidth',
ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)',
},
custom_message_description: {
type: 'alertblock',
ngShow: "customize_messages",
alertTxt: i18n._('Use custom messages to change the content of notifications ' +
'sent when a job starts, succeeds, or fails. Use curly braces to access ' +
'information about the job: <code ng-non-bindable>{{ job_friendly_name }}</code>, ' +
'<code ng-non-bindable>{{ url }}</code>, or attributes of the job such as ' +
'<code ng-non-bindable>{{ job.status }}</code>. You may apply a number of possible ' +
'variables in the message. Refer to the ' +
'<a href="https://docs.ansible.com/ansible-tower/latest/html/userguide/notifications.html#create-a-notification-template" ' +
'target="_blank">Ansible Tower documentation</a> 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 <button> tags

View File

@@ -0,0 +1,115 @@
const emptyDefaults = {
started: {
message: '',
body: '',
},
success: {
message: '',
body: '',
},
error: {
message: '',
body: '',
},
};
export default [function() {
return {
getMessagesObj: function ($scope, defaultMessages) {
if (!$scope.customize_messages) {
return null;
}
const defaults = defaultMessages[$scope.notification_type.value] || {};
return {
started: {
message: $scope.started_message === defaults.started.message ?
null : $scope.started_message,
body: $scope.started_body === defaults.started.body ?
null : $scope.started_body,
},
success: {
message: $scope.success_message === defaults.success.message ?
null : $scope.success_message,
body: $scope.success_body === defaults.success.body ?
null : $scope.success_body,
},
error: {
message: $scope.error_message === defaults.error.message ?
null : $scope.error_message,
body: $scope.error_body === defaults.error.body ?
null : $scope.error_body,
}
};
},
setMessagesOnScope: function ($scope, messages, defaultMessages) {
let defaults;
if ($scope.notification_type) {
defaults = defaultMessages[$scope.notification_type.value] || emptyDefaults;
} else {
defaults = emptyDefaults;
}
$scope.started_message = defaults.started.message;
$scope.started_body = defaults.started.body;
$scope.success_message = defaults.success.message;
$scope.success_body = defaults.success.body;
$scope.error_message = defaults.error.message;
$scope.error_body = defaults.error.body;
if (!messages) {
return;
}
let isCustomized = false;
if (messages.started.message) {
isCustomized = true;
$scope.started_message = messages.started.message;
}
if (messages.started.body) {
isCustomized = true;
$scope.started_body = messages.started.body;
}
if (messages.success.message) {
isCustomized = true;
$scope.success_message = messages.success.message;
}
if (messages.success.body) {
isCustomized = true;
$scope.success_body = messages.success.body;
}
if (messages.error.message) {
isCustomized = true;
$scope.error_message = messages.error.message;
}
if (messages.error.body) {
isCustomized = true;
$scope.error_body = messages.error.body;
}
$scope.customize_messages = isCustomized;
},
updateDefaultsOnScope: function(
$scope,
oldDefaults = emptyDefaults,
newDefaults = emptyDefaults
) {
if ($scope.started_message === oldDefaults.started.message) {
$scope.started_message = newDefaults.started.message;
}
if ($scope.started_body === oldDefaults.started.body) {
$scope.started_body = newDefaults.started.body;
}
if ($scope.success_message === oldDefaults.success.message) {
$scope.success_message = newDefaults.success.message;
}
if ($scope.success_body === oldDefaults.success.body) {
$scope.success_body = newDefaults.success.body;
}
if ($scope.error_message === oldDefaults.error.message) {
$scope.error_message = newDefaults.error.message;
}
if ($scope.error_body === oldDefaults.error.body) {
$scope.error_body = newDefaults.error.body;
}
}
};
}];

View File

@@ -697,7 +697,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
html += `<div id='${form.name}_${fld}_group' class='form-group Form-formGroup `;
html += (field.disabled) ? `Form-formGroup--disabled ` : ``;
html += (field.type === "checkbox") ? "Form-formGroup--checkbox" : "";
html += (field.type === "checkbox") ? "Form-formGroup--checkbox " : "";
html += (field['class']) ? (field['class']) : "";
html += "'";
html += (field.ngShow) ? this.attr(field, 'ngShow') : "";
@@ -1359,6 +1359,22 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
html += '></at-code-mirror>';
}
if (field.type === 'syntax_highlight') {
html += '<at-syntax-highlight ';
html += `id="${form.name}_${fld}" `;
html += `class="${field.class}" `;
html += `label="${field.label}" `;
html += `tooltip="${field.awPopOver || ''}" `;
html += `name="${fld}" `;
html += `value="${fld}" `;
html += `default="${field.default || ''}" `;
html += `rows="${field.rows || 6}" `;
html += `one-line="${field.oneLine || ''}"`;
html += `mode="${field.mode}" `;
html += `ng-disabled="${field.ngDisabled}" `;
html += '></at-syntax-highlight>';
}
if (field.type === 'custom') {
let labelOptions = {};

View File

@@ -1,6 +1,7 @@
import 'codemirror/lib/codemirror.js';
import 'codemirror/mode/javascript/javascript.js';
import 'codemirror/mode/yaml/yaml.js';
import 'codemirror/mode/jinja2/jinja2.js';
import 'codemirror/addon/lint/lint.js';
import 'angular-codemirror/lib/yaml-lint.js';
import 'codemirror/addon/edit/closebrackets.js';

View File

@@ -72,3 +72,4 @@ require('ng-toast');
require('lr-infinite-scroll');
require('codemirror/mode/yaml/yaml');
require('codemirror/mode/javascript/javascript');
require('codemirror/mode/jinja2/jinja2');

View File

@@ -19,7 +19,7 @@ const details = createFormSection({
'#notification_template_form input[type="radio"]',
'#notification_template_form .ui-spinner-input',
'#notification_template_form .Form-textArea',
'#notification_template_form .atSwitch-inner',
'#notification_template_form .atSwitch-outer',
'#notification_template_form .Form-lookupButton'
]
}

View File

@@ -86,9 +86,16 @@ function checkAllFieldsDisabled () {
selectors.forEach(selector => {
client.elements('css selector', selector, inputs => {
inputs.value.map(o => o.ELEMENT).forEach(id => {
client.elementIdAttribute(id, 'disabled', ({ value }) => {
client.assert.equal(value, 'true');
});
if (selector.includes('atSwitch')) {
client.elementIdAttribute(id, 'class', ({ value }) => {
const isDisabled = value && value.includes('atSwitch-disabled');
client.assert.equal(isDisabled, true);
});
} else {
client.elementIdAttribute(id, 'disabled', ({ value }) => {
client.assert.equal(value, 'true');
});
}
});
});
});