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
commit 534c4e776a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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');
});
}
});
});
});

View File

@ -16,7 +16,9 @@ notification_types = (
'slack',
'twilio',
'webhook',
'mattermost')
'mattermost',
'grafana',
'rocketchat')
class NotificationTemplate(HasCopy, HasCreate, base.Base):
@ -48,7 +50,7 @@ class NotificationTemplate(HasCopy, HasCreate, base.Base):
except (exc.MethodNotAllowed):
pass
def payload(self, organization, notification_type='slack', **kwargs):
def payload(self, organization, notification_type='slack', messages=not_provided, **kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'NotificationTemplate ({0}) - {1}' .format(
notification_type,
@ -56,6 +58,8 @@ class NotificationTemplate(HasCopy, HasCreate, base.Base):
description=kwargs.get('description') or random_title(10),
organization=organization.id,
notification_type=notification_type)
if messages != not_provided:
payload['messages'] = messages
notification_configuration = kwargs.get(
'notification_configuration', {})
@ -108,6 +112,14 @@ class NotificationTemplate(HasCopy, HasCreate, base.Base):
'mattermost_icon_url',
'mattermost_no_verify_ssl')
cred = services.mattermost
elif notification_type == 'grafana':
fields = ('grafana_url',
'grafana_key')
cred = services.grafana
elif notification_type == 'rocketchat':
fields = ('rocketchat_url',
'rocketchat_no_verify_ssl')
cred = services.rocketchat
else:
raise ValueError(
'Unknown notification_type {0}'.format(notification_type))
@ -129,6 +141,7 @@ class NotificationTemplate(HasCopy, HasCreate, base.Base):
description='',
notification_type='slack',
organization=Organization,
messages=not_provided,
**kwargs):
if notification_type not in notification_types:
raise ValueError(
@ -140,6 +153,7 @@ class NotificationTemplate(HasCopy, HasCreate, base.Base):
notification_type=notification_type,
name=name,
description=description,
messages=messages,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
@ -150,12 +164,14 @@ class NotificationTemplate(HasCopy, HasCreate, base.Base):
description='',
notification_type='slack',
organization=Organization,
messages=not_provided,
**kwargs):
payload = self.create_payload(
name=name,
description=description,
notification_type=notification_type,
organization=organization,
messages=messages,
**kwargs)
return self.update_identity(
NotificationTemplates(

View File

@ -11,6 +11,39 @@ At a high level, the typical notification task flow is:
* User creates a `NotificationTemplate` at `/api/v2/notification_templates/`.
* User assigns the notification to any of the various objects that support it (all variants of Job Templates as well as organizations and projects) and at the appropriate trigger level for which they want the notification (error, success, or any). For example, a user may wish to assign a particular Notification Template to trigger when `Job Template 1` fails.
## Templated notification messages
When creating a notification template, the user can optionally provide their own custom messages for each notification event (start, success, error). If a message is not provided, the default message generated by Tower will be used.
The notification message can include templated fields written using Jinja templates. The templates may reference a set of white-listed fields found in the associated job's serialization.
HTTP POST /api/v2/notification_templates/
{
"name": "E-mail notification",
"description": "Custom e-mail notification",
"organization": 1,
"notification_type": "email",
"notification_configuration": {
..
},
"messages": {
"started": {
"message": "{{ job.name }} completed successfully in {{ job.elapsed }} seconds using instance group {{ job.summary_fields.instance_group.name }}",
"body": null # default body will be used
},
"success": {
"message": null, # default message will be used
"body": null # default body will be used
},
"error": {
"message": "{{ job.name }} was unsuccessful ({{ job.status }})",
"body": "{{ job.job_explanation }}"
}
}
}
## Notification Hierarchy
Notification templates assigned at certain levels will inherit notifications defined on parent objects as such:
@ -28,7 +61,7 @@ Notifications can succeed or fail but that will _not_ cause its associated job t
## Testing Notifications Before Using Them
Once a Notification Template is created, its configuration can be tested by utilizing the endpoint at `/api/v2/notification_templates/<n>/test` This will emit a test notification given the configuration defined by the notification. These test notifications will also appear in the notifications list at `/api/v2/notifications`
Once a Notification Template has been created, its configuration can be tested by utilizing the endpoint at `/api/v2/notification_templates/<n>/test`. This will emit a test notification given the configuration defined by the notification. These test notifications will also appear in the notifications list at `/api/v2/notifications`
# Notification Types
@ -47,7 +80,6 @@ The currently defined Notification Types are:
Each of these have their own configuration and behavioral semantics and testing them may need to be approached in different ways. The following sections will give as much detail as possible.
## Email
The email notification type supports a wide variety of SMTP servers and has support for SSL/TLS connections and timeouts.
@ -59,17 +91,16 @@ The following should be performed for good acceptance:
* Test plain authentication.
* Test SSL and TLS authentication.
* Verify single and multiple recipients.
* Verify message subject and contents are formatted sanely. They should be plaintext but readable.
* Verify message subject and contents are formatted sanely. They should be plaintext but readable.
### Test Service
Set up a local SMTP mail service. Some options are listed below:
Set up a local SMTP mail service. Some options are listed below:
* Postfix service on galaxy: https://galaxy.ansible.com/debops/postfix/
* Mailtrap has a good free plan that should provide all of the features necessary: https://mailtrap.io/
* Another option is to use a Docker container: `docker run --network="tools_default" -p 25:25 -e maildomain=mail.example.com -e smtp_user=user:pwd --name postfix -d catatnight/postfix`
## Slack
Slack is simple to configure; it requires a token, which you can get from creating a bot in the integrations settings for the Slack team.
@ -80,10 +111,9 @@ The following should be performed for good acceptance:
* Test single and multiple channels and good formatting of the message. Note that slack notifications only contain the minimal information.
## Mattermost
The Mattermost notification integration uses Incoming Webhooks. A password is not required because the webhook URL itself is the secret. Webhooks must be enabled in the System Console of Mattermost. If the user wishes to allow Ansible Tower notifications to modify the Icon URL and username of the notification, then they must enabled these options as well.
The Mattermost notification integration uses Incoming Webhooks. A password is not required because the webhook URL itself is the secret. Webhooks must be enabled in the System Console of Mattermost. If the user wishes to allow Ansible Tower notifications to modify the Icon URL and username of the notification, then they must enable these options as well.
In order to enable these settings in Mattermost:
1. Go to System Console > Integrations > Custom Integrations. Check "Enable Incoming Webhooks".
@ -107,7 +137,6 @@ In order to enable these settings in Mattermost:
* Utilize an existing Mattermost installation or use their Docker container here: `docker run --name mattermost-preview -d --publish 8065:8065 mattermost/mattermost-preview`
* Turn on Incoming Webhooks and optionally allow Integrations to override usernames and icons in the System Console.
## Rocket.Chat
The Rocket.Chat notification integration uses Incoming Webhooks. A password is not required because the webhook URL itself is the secret. An integration must be created in the Administration section of the Rocket.Chat settings.
@ -124,16 +153,15 @@ The following fields are available for the Rocket.Chat notification type:
### Test Service
* Utilize an existing Rocket.Chat installation or use their Docker containers from https://rocket.chat/docs/installation/docker-containers/
* Utilize an existing Rocket.Chat installation or use a Docker container from https://rocket.chat/docs/installation/docker-containers/
* Create an Incoming Webhook in the Integrations section of the Administration settings
## Pagerduty
Pagerduty is a fairly straightforward integration. The user will create an API Key in the Pagerduty system (this will be the token that is given to Tower) and then create a "Service" which will provide an "Integration Key" that will also be given to Tower. The other options of note are:
* `subdomain`: When you sign up for the Pagerduty account, you will get a unique subdomain to communicate with. For instance, if you signed up as "towertest", the web dashboard will be at *towertest.pagerduty.com* and you will give the Tower API "towertest" as the subdomain (not the full domain).
* `client_name`: This will be sent along with the alert content to the Pagerduty service to help identify the service that is using the API key/service. This is helpful if multiple integrations are using the same API key and service.
* `client_name`: This will be sent along with the alert content to the Pagerduty service to help identify the service that is using the API key/service. This is helpful if multiple integrations are using the same API key and service.
### Testing considerations
@ -144,36 +172,34 @@ Pagerduty is a fairly straightforward integration. The user will create an API K
Pagerduty allows you to sign up for a free trial with the service.
## Twilio
Twilio is a Voice and SMS automation service. Once you are signed in, you'll need to create a phone number from which the message will be sent. You'll then define a "Messaging Service" under Programmable SMS and associate the number (the one you created for this purpose) with it. Note that you may need to verify this number or some other information before you are allowed to use it to send to any numbers. The Messaging Service does not need a status callback URL nor does it need the ability to process inbound messages.
Twilio is a Voice and SMS automation service. Once you are signed in, you'll need to create a phone number from which the message will be sent. You'll then define a "Messaging Service" under Programmable SMS and associate the number (the one you created for this purpose) with it. Note that you may need to verify this number or some other information before you are allowed to use it to send to any numbers. The Messaging Service does not need a status callback URL nor does it need the ability to process inbound messages.
Under your individual (or sub) account settings, you will have API credentials. The Account SID and AuthToken are what will be given to Tower. There are a couple of other important fields:
* `from_number`: This is the number associated with the messaging service above and must be given in the form of "+15556667777".
* `from_number`: This is the number associated with the messaging service above and must be given in the form of "+15556667777".
* `to_numbers`: This will be the list of numbers to receive the SMS and should be the 10-digit phone number.
### Testing Considerations
* Test notifications with single and multiple recipients.
* Verify that the minimal information is displayed for the notification. Note that this notification type does not display the full detailed notification.
* Verify that the minimal information is displayed for the notification. Note that this notification type does not display the full detailed notification.
### Test Service
Twilio is fairly straightforward to sign up for but there may not be a free trial offered; a credit card will be needed to sign up for it though the charges are fairly minimal per message.
## IRC
The Tower IRC notification takes the form of an IRC bot that will connect, deliver its messages to channel(s) or individual user(s), and then disconnect. The Tower notification bot also supports SSL authentication. The Tower bot does not currently support Nickserv identification. If a channel or user does not exist or is not online, then the Notification will not fail; the failure scenario is reserved specifically for connectivity.
The Tower IRC notification takes the form of an IRC bot that will connect, deliver its messages to channel(s) or individual user(s), and then disconnect. The Tower notification bot also supports SSL authentication. The Tower bot does not currently support Nickserv identification. If a channel or user does not exist or is not online, then the Notification will not fail; the failure scenario is reserved specifically for connectivity.
Connectivity information is straightforward:
* `server`: The host name or address of the IRC server.
* `port`: The IRC server port.
* `nickname`: The bot's nickname once it connects to the server.
* `password`: IRC servers can require a password to connect. If the server doesn't require one, then this should be an empty string.
* `password`: IRC servers can require a password to connect. If the server doesn't require one, then this should be an empty string.
* `use_ssl`: If you want the bot to use SSL when connecting.
* `targets`: A list of users and/or channels to send the notification to.
@ -184,12 +210,11 @@ Connectivity information is straightforward:
### Test Service
There are a few modern IRC servers to choose from. [InspIRCd](http://www.inspircd.org/) is recommended because it is actively maintained and pretty straightforward to configure.
There are a few modern IRC servers to choose from. [InspIRCd](http://www.inspircd.org/) is recommended because it is actively maintained and pretty straightforward to configure.
## Webhook
The webhook notification type in Ansible Tower provides a simple interface to sending `POST`s to a predefined web service. Tower will `POST` to this address using `application/json` content type with the data payload containing all relevant details in json format.
The webhook notification type in Ansible Tower provides a simple interface for sending `POST`s to a predefined web service. Tower will `POST` to this address using `application/json` content type with the data payload containing all relevant details in json format.
The parameters are fairly straightforward:
@ -251,7 +276,7 @@ The configurable options of the Grafana notification type are:
* Make sure the annotation gets created on the desired dashboard and/or panel and with the configured tags.
### Test Service
* Utilize an existing Grafana installation or use their Docker containers from http://docs.grafana.org/installation/docker/.
* Utilize an existing Grafana installation or use Docker containers from http://docs.grafana.org/installation/docker/.
* Create an API Key in the Grafana configuration settings.
* (Optional) Lookup `dashboardId` and/or `panelId` if needed.
* (Optional) Define tags for the annotation.