mirror of
https://github.com/ZwareBear/awx.git
synced 2026-04-18 14:01:49 -05:00
Merge pull request #4291 from jladdjr/templated_messages
Templated notifications
Reviewed-by: Jim Ladd
https://github.com/jladdjr
This commit is contained in:
@@ -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');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user