Add better (jinja-based) messaging to the notifications and add some fixes for the email templates

This commit is contained in:
Joseph Schorr 2014-10-22 19:01:56 -04:00
parent ea96dbb2ad
commit 5db9cd948b
13 changed files with 216 additions and 128 deletions

View file

@ -579,6 +579,13 @@ def get_user(username):
return None return None
def get_namespace_user(username):
try:
return User.get(User.username == username)
except User.DoesNotExist:
return None
def get_user_or_org(username): def get_user_or_org(username):
try: try:
return User.get(User.username == username, User.robot == False) return User.get(User.username == username, User.robot == False)

View file

@ -1,7 +1,9 @@
import logging import logging
from notificationhelper import build_event_data from notificationhelper import build_event_data
from util.jinjautil import get_template_env
template_env = get_template_env("events")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class InvalidNotificationEventException(Exception): class InvalidNotificationEventException(Exception):
@ -14,7 +16,7 @@ class NotificationEvent(object):
def get_level(self, event_data, notification_data): def get_level(self, event_data, notification_data):
""" """
Returns a 'level' representing the severity of the event. Returns a 'level' representing the severity of the event.
Valid values are: 'info', 'warning', 'error', 'primary' Valid values are: 'info', 'warning', 'error', 'primary', 'success'
""" """
raise NotImplementedError raise NotImplementedError
@ -28,7 +30,10 @@ class NotificationEvent(object):
""" """
Returns a human readable HTML message for the given notification data. Returns a human readable HTML message for the given notification data.
""" """
raise NotImplementedError return template_env.get_template(self.event_name() + '.html').render({
'event_data': event_data,
'notification_data': notification_data
})
def get_sample_data(self, repository=None): def get_sample_data(self, repository=None):
""" """
@ -59,28 +64,11 @@ class RepoPushEvent(NotificationEvent):
return 'repo_push' return 'repo_push'
def get_level(self, event_data, notification_data): def get_level(self, event_data, notification_data):
return 'info' return 'primary'
def get_summary(self, event_data, notification_data): def get_summary(self, event_data, notification_data):
return 'Repository %s updated' % (event_data['repository']) return 'Repository %s updated' % (event_data['repository'])
def get_message(self, event_data, notification_data):
if not event_data.get('updated_tags', {}).keys():
html = """
Repository <a href="%s">%s</a> has been updated via a push.
""" % (event_data['homepage'],
event_data['repository'])
else:
html = """
Repository <a href="%s">%s</a> has been updated via a push.
<br><br>
Tags Updated: %s
""" % (event_data['homepage'],
event_data['repository'],
', '.join(event_data['updated_tags'].keys()))
return html
def get_sample_data(self, repository): def get_sample_data(self, repository):
return build_event_data(repository, { return build_event_data(repository, {
'updated_tags': {'latest': 'someimageid', 'foo': 'anotherimage'}, 'updated_tags': {'latest': 'someimageid', 'foo': 'anotherimage'},
@ -110,25 +98,6 @@ class BuildQueueEvent(NotificationEvent):
def get_summary(self, event_data, notification_data): def get_summary(self, event_data, notification_data):
return 'Build queued for repository %s' % (event_data['repository']) return 'Build queued for repository %s' % (event_data['repository'])
def get_message(self, event_data, notification_data):
is_manual = event_data['is_manual']
if is_manual:
html = """
A <a href="%s">new build</a> has been manually queued to start on repository %s.
<br><br>
Build ID: %s
""" % (event_data['homepage'], event_data['repository'], event_data['build_id'])
else:
html = """
A <a href="%s">new build</a> has been queued via a %s trigger to start on repository %s.
<br><br>
Build ID: %s
""" % (event_data['homepage'], event_data['trigger_kind'],
event_data['repository'], event_data['build_id'])
return html
class BuildStartEvent(NotificationEvent): class BuildStartEvent(NotificationEvent):
@classmethod @classmethod
@ -151,15 +120,6 @@ class BuildStartEvent(NotificationEvent):
def get_summary(self, event_data, notification_data): def get_summary(self, event_data, notification_data):
return 'Build started for repository %s' % (event_data['repository']) return 'Build started for repository %s' % (event_data['repository'])
def get_message(self, event_data, notification_data):
html = """
A <a href="%s">new build</a> has started on repository %s.
<br><br>
Build ID: %s
""" % (event_data['homepage'], event_data['repository'], event_data['build_id'])
return html
class BuildSuccessEvent(NotificationEvent): class BuildSuccessEvent(NotificationEvent):
@classmethod @classmethod
@ -167,7 +127,7 @@ class BuildSuccessEvent(NotificationEvent):
return 'build_success' return 'build_success'
def get_level(self, event_data, notification_data): def get_level(self, event_data, notification_data):
return 'primary' return 'success'
def get_sample_data(self, repository): def get_sample_data(self, repository):
build_uuid = 'fake-build-id' build_uuid = 'fake-build-id'
@ -182,15 +142,6 @@ class BuildSuccessEvent(NotificationEvent):
def get_summary(self, event_data, notification_data): def get_summary(self, event_data, notification_data):
return 'Build succeeded for repository %s' % (event_data['repository']) return 'Build succeeded for repository %s' % (event_data['repository'])
def get_message(self, event_data, notification_data):
html = """
A <a href="%s">build</a> has finished on repository %s.
<br><br>
Build ID: %s
""" % (event_data['homepage'], event_data['repository'], event_data['build_id'])
return html
class BuildFailureEvent(NotificationEvent): class BuildFailureEvent(NotificationEvent):
@classmethod @classmethod
@ -214,13 +165,3 @@ class BuildFailureEvent(NotificationEvent):
def get_summary(self, event_data, notification_data): def get_summary(self, event_data, notification_data):
return 'Build failure for repository %s' % (event_data['repository']) return 'Build failure for repository %s' % (event_data['repository'])
def get_message(self, event_data, notification_data):
html = """
A <a href="%s">build</a> has failed on repository %s.
<br><br>
Reason: %s<br>
Build ID: %s<br>
""" % (event_data['homepage'], event_data['repository'],
event_data['error_message'], event_data['build_id'])
return html

View file

@ -1,5 +1,6 @@
from app import app, notification_queue from app import app, notification_queue
from data import model from data import model
from auth.auth_context import get_authenticated_user, get_validated_oauth_token
import json import json
@ -27,21 +28,37 @@ def build_event_data(repo, extra_data={}, subpage=None):
event_data.update(extra_data) event_data.update(extra_data)
return event_data return event_data
def build_notification_data(notification, event_data): def build_notification_data(notification, event_data, performer_data=None):
if not performer_data:
performer_data = {}
oauth_token = get_validated_oauth_token()
if oauth_token:
performer_data['oauth_token_id'] = oauth_token.id
performer_data['oauth_token_application_id'] = oauth_token.application.client_id
performer_data['oauth_token_application'] = oauth_token.application.name
performer_user = get_authenticated_user()
if performer_user:
performer_data['entity_id'] = performer_user.id
performer_data['entity_name'] = performer_user.username
return { return {
'notification_uuid': notification.uuid, 'notification_uuid': notification.uuid,
'repository_namespace': notification.repository.namespace_user.username, 'repository_namespace': notification.repository.namespace_user.username,
'repository_name': notification.repository.name, 'repository_name': notification.repository.name,
'event_data': event_data 'event_data': event_data,
'performer_data': performer_data
} }
def spawn_notification(repo, event_name, extra_data={}, subpage=None, pathargs=[]): def spawn_notification(repo, event_name, extra_data={}, subpage=None, pathargs=[],
performer_data=None):
event_data = build_event_data(repo, extra_data=extra_data, subpage=subpage) event_data = build_event_data(repo, extra_data=extra_data, subpage=subpage)
notifications = model.list_repo_notifications(repo.namespace_user.username, repo.name, notifications = model.list_repo_notifications(repo.namespace_user.username, repo.name,
event_name=event_name) event_name=event_name)
for notification in notifications: for notification in list(notifications):
notification_data = build_notification_data(notification, event_data) notification_data = build_notification_data(notification, event_data, performer_data)
path = [repo.namespace_user.username, repo.name, event_name] + pathargs path = [repo.namespace_user.username, repo.name, event_name] + pathargs
notification_queue.put(path, json.dumps(notification_data)) notification_queue.put(path, json.dumps(notification_data))

View file

@ -279,6 +279,7 @@ class HipchatMethod(NotificationMethod):
'info': 'gray', 'info': 'gray',
'warning': 'yellow', 'warning': 'yellow',
'error': 'red', 'error': 'red',
'success': 'green',
'primary': 'purple' 'primary': 'purple'
}.get(level, 'gray') }.get(level, 'gray')
@ -303,6 +304,56 @@ class HipchatMethod(NotificationMethod):
raise NotificationMethodPerformException(ex.message) raise NotificationMethodPerformException(ex.message)
from HTMLParser import HTMLParser
class SlackAdjuster(HTMLParser):
def __init__(self):
self.reset()
self.result = []
def handle_data(self, d):
self.result.append(d)
def get_attr(self, attrs, name):
for attr in attrs:
if attr[0] == name:
return attr[1]
return ''
def handle_starttag(self, tag, attrs):
if tag == 'a':
self.result.append('<%s|' % (self.get_attr(attrs, 'href'), ))
if tag == 'i':
self.result.append('_')
if tag == 'b' or tag == 'strong':
self.result.append('*')
if tag == 'img':
self.result.append(self.get_attr(attrs, 'alt'))
self.result.append(' ')
def handle_endtag(self, tag):
if tag == 'a':
self.result.append('>')
if tag == 'b' or tag == 'strong':
self.result.append('*')
if tag == 'i':
self.result.append('_')
def get_data(self):
return ''.join(self.result)
def adjust_tags(html):
s = SlackAdjuster()
s.feed(html)
return s.get_data()
class SlackMethod(NotificationMethod): class SlackMethod(NotificationMethod):
""" Method for sending notifications to Slack via the API: """ Method for sending notifications to Slack via the API:
https://api.slack.com/docs/attachments https://api.slack.com/docs/attachments
@ -318,12 +369,11 @@ class SlackMethod(NotificationMethod):
if not config_data.get('subdomain', '').isalnum(): if not config_data.get('subdomain', '').isalnum():
raise CannotValidateNotificationMethodException('Missing Slack Subdomain Name') raise CannotValidateNotificationMethodException('Missing Slack Subdomain Name')
def formatForSlack(self, message): def format_for_slack(self, message):
message = message.replace('\n', '') message = message.replace('\n', '')
message = re.sub(r'\s+', ' ', message) message = re.sub(r'\s+', ' ', message)
message = message.replace('<br>', '\n') message = message.replace('<br>', '\n')
message = re.sub(r'<a href="(.+)">(.+)</a>', '<\\1|\\2>', message) return adjust_tags(message)
return message
def perform(self, notification, event_handler, notification_data): def perform(self, notification, event_handler, notification_data):
config_data = json.loads(notification.config_json) config_data = json.loads(notification.config_json)
@ -346,6 +396,7 @@ class SlackMethod(NotificationMethod):
'info': '#ffffff', 'info': '#ffffff',
'warning': 'warning', 'warning': 'warning',
'error': 'danger', 'error': 'danger',
'success': 'good',
'primary': 'good' 'primary': 'good'
}.get(level, '#ffffff') }.get(level, '#ffffff')
@ -359,8 +410,9 @@ class SlackMethod(NotificationMethod):
'attachments': [ 'attachments': [
{ {
'fallback': summary, 'fallback': summary,
'text': self.formatForSlack(message), 'text': self.format_for_slack(message),
'color': color 'color': color,
'mrkdwn_in': ["text"]
} }
] ]
} }

View file

@ -0,0 +1,2 @@
<a href="{{ event_data.homepage }}">Build</a> failed for repository
{{ event_data.repository | repository_reference }} ({{ event_data.build_id }}): {{ event_data.error_message }}

9
events/build_queued.html Normal file
View file

@ -0,0 +1,9 @@
{% if event_data.is_manual and notification_data.performer_data.entity_name %}
{{ notification_data.performer_data.entity_name | user_reference }} queued a
<a href="{{ event_data.homepage }}">build</a>
{% elif event_data.trigger_kind %}
<a href="{{ event_data.homepage }}">Build</a> queued via a {{ event_data.trigger_kind }} trigger
{% else %}
<a href="{{ event_data.homepage }}">Build</a> queued
{% endif %}
for repository {{ event_data.repository | repository_reference }} ({{ event_data.build_id }})

2
events/build_start.html Normal file
View file

@ -0,0 +1,2 @@
<a href="{{ event_data.homepage }}">Build</a> started for repository
{{ event_data.repository | repository_reference }} ({{ event_data.build_id }})

View file

@ -0,0 +1,2 @@
<a href="{{ event_data.homepage }}">Build</a> completed for repository
{{ event_data.repository | repository_reference }} ({{ event_data.build_id }})

12
events/repo_push.html Normal file
View file

@ -0,0 +1,12 @@
{% if notification_data.performer_data.entity_name %}
{{ notification_data.performer_data.entity_name | user_reference }} pushed
{% else %}
Push of
{% endif %}
{% if event_data.updated_tags %}
{{ 'tags' | icon_image }}
{% for tag in event_data.updated_tags %}{%if loop.index > 1 %}, {% endif %}{{ (event_data.repository, tag) | repository_tag_reference }}{% endfor %} in
{% endif %}
repository {{ event_data.repository | repository_reference }}

BIN
static/img/icons/tags.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

BIN
static/img/icons/wrench.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

91
util/jinjautil.py Normal file
View file

@ -0,0 +1,91 @@
from app import get_app_url
from data import model
from util.gravatar import compute_hash
from util.names import parse_robot_username
from jinja2 import Template, Environment, FileSystemLoader, contextfilter
def icon_path(icon_name):
return '%s/static/img/icons/%s.png' % (get_app_url(), icon_name)
def icon_image(icon_name):
return '<img src="%s" alt="%s">' % (icon_path(icon_name), icon_name)
def user_reference(username):
user = model.get_namespace_user(username)
if not user:
return username
is_robot = False
if user.robot:
parts = parse_robot_username(username)
user = model.get_namespace_user(parts[0])
return """<span><img src="%s" alt="Robot"> <b>%s</b></span>""" % (icon_path('wrench'), username)
alt = 'Organization' if user.organization else 'User'
return """
<span>
<img src="http://www.gravatar.com/avatar/%s?s=16&amp;d=identicon"
style="vertical-align: middle; margin-left: 6px; margin-right: 4px;" alt="%s">
<b>%s</b>
</span>""" % (compute_hash(user.email), alt, username)
def repository_tag_reference(repository_path_and_tag):
(repository_path, tag) = repository_path_and_tag
(namespace, repository) = repository_path.split('/')
owner = model.get_namespace_user(namespace)
if not owner:
return tag
return """<a href="%s/repository/%s/%s?tag=%s">%s</a>""" % (get_app_url(), namespace, repository,
tag, tag)
def repository_reference(pair):
if isinstance(pair, tuple):
(namespace, repository) = pair
else:
pair = pair.split('/')
namespace = pair[0]
repository = pair[1]
owner = model.get_namespace_user(namespace)
if not owner:
return "%s/%s" % (namespace, repository)
return """
<span style="white-space: nowrap;">
<img src="http://www.gravatar.com/avatar/%s?s=16&amp;d=identicon" style="vertical-align: middle; margin-left: 6px; margin-right: 4px;">
<a href="%s/repository/%s/%s">%s/%s</a>
</span>
""" % (compute_hash(owner.email), get_app_url(), namespace, repository, namespace, repository)
def admin_reference(username):
user = model.get_user_or_org(username)
if not user:
return 'account settings'
if user.organization:
return """
<a href="%s/organization/%s/admin">organization's admin setting</a>
""" % (get_app_url(), username)
else:
return """
<a href="%s/user/">account settings</a>
""" % (get_app_url())
def get_template_env(searchpath):
template_loader = FileSystemLoader(searchpath=searchpath)
template_env = Environment(loader=template_loader)
add_filters(template_env)
return template_env
def add_filters(template_env):
template_env.filters['icon_image'] = icon_image
template_env.filters['user_reference'] = user_reference
template_env.filters['admin_reference'] = admin_reference
template_env.filters['repository_reference'] = repository_reference
template_env.filters['repository_tag_reference'] = repository_tag_reference

View file

@ -1,58 +1,11 @@
from flask.ext.mail import Message from flask.ext.mail import Message
from app import mail, app, get_app_url from app import mail, app, get_app_url
from jinja2 import Template, Environment, FileSystemLoader, contextfilter
from data import model from data import model
from util.gravatar import compute_hash from util.gravatar import compute_hash
from util.jinjautil import get_template_env
def user_reference(username): template_env = get_template_env("emails")
user = model.get_user_or_org(username)
if not user:
return username
return """
<span>
<img src="http://www.gravatar.com/avatar/%s?s=16&amp;d=identicon" style="vertical-align: middle; margin-left: 6px; margin-right: 4px;">
<b>%s</b>
</span>""" % (compute_hash(user.email), username)
def repository_reference(pair):
(namespace, repository) = pair
owner = model.get_user(namespace)
if not owner:
return "%s/%s" % (namespace, repository)
return """
<span style="white-space: nowrap;">
<img src="http://www.gravatar.com/avatar/%s?s=16&amp;d=identicon" style="vertical-align: middle; margin-left: 6px; margin-right: 4px;">
<a href="%s/repository/%s/%s">%s/%s</a>
</span>
""" % (compute_hash(owner.email), get_app_url(), namespace, repository, namespace, repository)
def admin_reference(username):
user = model.get_user(username)
if not user:
return 'account settings'
if user.organization:
return """
<a href="%s/organization/%s/admin">organization's admin setting</a>
""" % (get_app_url(), username)
else:
return """
<a href="%s/user/">account settings</a>
""" % (get_app_url())
template_loader = FileSystemLoader(searchpath="emails")
template_env = Environment(loader=template_loader)
template_env.filters['user_reference'] = user_reference
template_env.filters['admin_reference'] = admin_reference
template_env.filters['repository_reference'] = repository_reference
def send_email(recipient, subject, template_file, parameters): def send_email(recipient, subject, template_file, parameters):
app_title = app.config['REGISTRY_TITLE_SHORT'] app_title = app.config['REGISTRY_TITLE_SHORT']