-
Notifications
You must be signed in to change notification settings - Fork 3.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Templated notifications #4291
Templated notifications #4291
Changes from all commits
adf25c6
8a04cf0
7b828d7
0f19d98
fc4c9af
b80ca62
a56a6d7
0398ce0
03ebe44
885c505
191d18c
37b44fe
3c4862a
1c79d21
1470fa6
56f04e0
150de6a
965dc79
62f31d6
15e6117
5468624
efbaf46
cb411cc
616db6b
ccdbd05
8ca79e3
0ddc32a
7bf250e
24c3903
3bb0aa4
13b9679
1a1eab4
8158632
ec20081
2b79257
d068fef
7a6e62c
4872766
24a383c
c8805cc
774a310
a10ad58
901d41e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,6 +13,10 @@ | |
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 @@ | |
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 ( | ||
|
@@ -4125,7 +4129,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,), | ||
|
@@ -4159,6 +4164,96 @@ def get_summary_fields(self, obj): | |
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)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As a knee jerk reaction, I don't love reflecting the users bad input. But I don't see any real problem in this case.
|
||
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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI:
vs. |
||
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): | ||
This comment was marked as resolved.
Sorry, something went wrong. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This class definition doesn't seem to depend on anything inside the function scope of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can, but I defaulted to keeping the class close to the section where it was used since it's currently not relevant anywhere else in the code. Thought that if we come across another use case then we could move it out. Happy to go either way, though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is fine, given its brevity and how specific its use is here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤷♂️ 👍 Just a suggestion. If this function looks good to you two that works for me! |
||
# 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) | ||
jakemcdermott marked this conversation as resolved.
Show resolved
Hide resolved
|
||
try: | ||
env.from_string(msg).render(JobNotificationMixin.context_stub()) | ||
except TemplateSyntaxError as exc: | ||
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
Sorry, something went wrong. |
||
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))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
|
||
if error_list: | ||
raise serializers.ValidationError(error_list) | ||
|
||
return messages | ||
|
||
def validate(self, attrs): | ||
from awx.api.views import NotificationTemplateDetail | ||
|
||
|
@@ -4223,10 +4318,19 @@ def validate(self, attrs): | |
|
||
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): | ||
jakemcdermott marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) | ||
|
@@ -4235,6 +4339,15 @@ def get_related(self, obj): | |
)) | ||
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): | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,6 +2,7 @@ | |
|
||
from django.db import migrations, models | ||
|
||
import awx | ||
|
||
class Migration(migrations.Migration): | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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), | ||
), | ||
] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From what I recall of discussions this is for generating sane/useful OPTIONS defaults for when the user has read-only access.
I'd love if the comment better reflected that. This pattern will be useful for future use. Especially since our CLI is now driven by the OPTIONS