mirror of
https://github.com/ansible/awx.git
synced 2026-01-10 15:32:07 -03:30
Merge pull request #4291 from jladdjr/templated_messages
Templated notifications
Reviewed-by: Jim Ladd
https://github.com/jladdjr
This commit is contained in:
commit
534c4e776a
@ -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'
|
||||
|
||||
@ -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):
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import awx
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
148
awx/main/tests/functional/models/test_notifications.py
Normal file
148
awx/main/tests/functional/models/test_notifications.py
Normal 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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
@ -485,6 +485,8 @@
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
min-height: initial !important;
|
||||
max-height: initial !important;
|
||||
border-radius: 5px;
|
||||
font-style: normal;
|
||||
color: @field-input-text;
|
||||
|
||||
@ -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)
|
||||
|
||||
8
awx/ui/client/lib/components/syntax-highlight/index.js
Normal file
8
awx/ui/client/lib/components/syntax-highlight/index.js
Normal 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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
@ -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 + '.')
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -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 + '.')
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
115
awx/ui/client/src/notifications/shared/message-utils.service.js
Normal file
115
awx/ui/client/src/notifications/shared/message-utils.service.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}];
|
||||
@ -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 = {};
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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'
|
||||
]
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user