Skip to content

Commit

Permalink
Add email functionality (#1914)
Browse files Browse the repository at this point in the history
* Add email functionality

* Add email templates.

* Test notifications

* Move email to utils
  • Loading branch information
bkyryliuk authored Jan 14, 2017
1 parent a96024d commit 495f646
Show file tree
Hide file tree
Showing 9 changed files with 320 additions and 24 deletions.
10 changes: 10 additions & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,5 +266,15 @@ class CeleryConfig(object):
except ImportError:
pass

# smtp server configuration
EMAIL_NOTIFICATIONS = False # all the emails are sent using dryrun
SMTP_HOST = 'localhost'
SMTP_STARTTLS = True
SMTP_SSL = False
SMTP_USER = 'superset'
SMTP_PORT = 25
SMTP_PASSWORD = 'superset'
SMTP_MAIL_FROM = 'superset@superset.com'

if not CACHE_DEFAULT_TIMEOUT:
CACHE_DEFAULT_TIMEOUT = CACHE_CONFIG.get('CACHE_DEFAULT_TIMEOUT')
14 changes: 14 additions & 0 deletions superset/templates/email/role_extended.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Dear {{ user.username }},
<br>
<a href={{ url_for('Superset.profile', username=granter.username, _external=True) }}>
{{ granter.username }}</a> has extended the role {{ role.name }} to include
<a href={{ url_for('Superset.explore', datasource_type=datasource.type, datasource_id=datasource.id, _external=True) }}>
{{datasource.full_name}}</a> and granted you access to it.
<br>
<br>
To see all your permissions please visit your
<a href={{ url_for('Superset.profile', username=user.username, _external=True) }}>
profile page</a>.
<br>
<br>
Regards, Superset Admin.
18 changes: 18 additions & 0 deletions superset/templates/email/role_granted.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Dear {{ user.username }},
<br>
<a href={{ url_for('Superset.profile', username=granter.username, _external=True) }}>
{{ granter.username }}</a> has granted you the role {{ role.name }}
that gives access to the
<a href={{ url_for('Superset.explore', datasource_type=datasource.type, datasource_id=datasource.id, _external=True) }}>
{{datasource.full_name}}</a>
<br>
<br>
In addition to that role grants you access to the: {{ role.permissions }}.
<br>
<br>
To see all your permissions please visit your
<a href={{ url_for('Superset.profile', username=user.username, _external=True) }}>
profile page</a>.
<br>
<br>
Regards, Superset Admin.
113 changes: 104 additions & 9 deletions superset/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,33 @@
from __future__ import print_function
from __future__ import unicode_literals

from builtins import object
from datetime import date, datetime, time
import decimal
import functools
import json
import logging
import pytz
import markdown as md
import numpy
import os
import parsedatetime
import pytz
import smtplib
import sqlalchemy as sa
import signal
import uuid

from sqlalchemy import event, exc
import parsedatetime
import sqlalchemy as sa
from builtins import object
from datetime import date, datetime, time
from dateutil.parser import parse
from flask import flash, Markup
import markdown as md
from sqlalchemy.types import TypeDecorator, TEXT
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from email.utils import formatdate
from flask import flash, Markup, render_template
from flask_babel import gettext as __
from past.builtins import basestring
from pydruid.utils.having import Having
from sqlalchemy import event, exc
from sqlalchemy.types import TypeDecorator, TEXT

logging.getLogger('MARKDOWN').setLevel(logging.INFO)

Expand Down Expand Up @@ -418,3 +426,90 @@ class QueryStatus:
SCHEDULED = 'scheduled'
SUCCESS = 'success'
TIMED_OUT = 'timed_out'


def notify_user_about_perm_udate(
granter, user, role, datasource, tpl_name, config):
msg = render_template(tpl_name, granter=granter, user=user, role=role,
datasource=datasource)
logging.info(msg)
subject = __('[Superset] Access to the datasource %(name)s was granted',
name=datasource.full_name)
send_email_smtp(user.email, subject, msg, config, bcc=granter.email,
dryrun=config.get('EMAIL_NOTIFICATIONS'))


def send_email_smtp(to, subject, html_content, config, files=None,
dryrun=False, cc=None, bcc=None, mime_subtype='mixed'):
"""
Send an email with html content, eg:
send_email_smtp(
'test@example.com', 'foo', '<b>Foo</b> bar',['/dev/null'], dryrun=True)
"""
smtp_mail_from = config.get('SMTP_MAIL_FROM')

to = get_email_address_list(to)

msg = MIMEMultipart(mime_subtype)
msg['Subject'] = subject
msg['From'] = smtp_mail_from
msg['To'] = ", ".join(to)
recipients = to
if cc:
cc = get_email_address_list(cc)
msg['CC'] = ", ".join(cc)
recipients = recipients + cc

if bcc:
# don't add bcc in header
bcc = get_email_address_list(bcc)
recipients = recipients + bcc

msg['Date'] = formatdate(localtime=True)
mime_text = MIMEText(html_content, 'html')
msg.attach(mime_text)

for fname in files or []:
basename = os.path.basename(fname)
with open(fname, "rb") as f:
msg.attach(MIMEApplication(
f.read(),
Content_Disposition='attachment; filename="%s"' % basename,
Name=basename
))

send_MIME_email(smtp_mail_from, recipients, msg, config, dryrun)


def send_MIME_email(e_from, e_to, mime_msg, config, dryrun=False):
SMTP_HOST = config.get('SMTP_HOST')
SMTP_PORT = config.get('SMTP_PORT')
SMTP_USER = config.get('SMTP_USER')
SMTP_PASSWORD = config.get('SMTP_PASSWORD')
SMTP_STARTTLS = config.get('SMTP_STARTTLS')
SMTP_SSL = config.get('SMTP_SSL')

if not dryrun:
s = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT) if SMTP_SSL else \
smtplib.SMTP(SMTP_HOST, SMTP_PORT)
if SMTP_STARTTLS:
s.starttls()
if SMTP_USER and SMTP_PASSWORD:
s.login(SMTP_USER, SMTP_PASSWORD)
logging.info("Sent an alert email to " + str(e_to))
s.sendmail(e_from, e_to, mime_msg.as_string())
s.quit()
else:
logging.info('Dryrun enabled, email notification content is below:')
logging.info(mime_msg.as_string())


def get_email_address_list(address_string):
if isinstance(address_string, basestring):
if ',' in address_string:
address_string = address_string.split(',')
elif ';' in address_string:
address_string = address_string.split(';')
else:
address_string = [address_string]
return address_string
29 changes: 20 additions & 9 deletions superset/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@

import superset
from superset import (
appbuilder, cache, db, models, viz, utils, app,
sm, sql_lab, sql_parse, results_backend, security,
app, appbuilder, cache, db, models, sm, sql_lab, sql_parse,
results_backend, security, viz, utils,
)
from superset.source_registry import SourceRegistry
from superset.models import DatasourceAccessRequest as DAR
Expand Down Expand Up @@ -1324,20 +1324,29 @@ def approve(self):
if role_to_grant:
role = sm.find_role(role_to_grant)
requested_by.roles.append(role)
flash(__(
msg = __(
"%(user)s was granted the role %(role)s that gives access "
"to the %(datasource)s",
user=requested_by.username,
role=role_to_grant,
datasource=datasource.full_name), "info")
datasource=datasource.full_name)
utils.notify_user_about_perm_udate(
g.user, requested_by, role, datasource,
'email/role_granted.txt', app.config)
flash(msg, "info")

if role_to_extend:
perm_view = sm.find_permission_view_menu(
'datasource_access', datasource.perm)
sm.add_permission_role(sm.find_role(role_to_extend), perm_view)
flash(__("Role %(r)s was extended to provide the access to"
" the datasource %(ds)s",
r=role_to_extend, ds=datasource.full_name), "info")
'email/datasource_access', datasource.perm)
role = sm.find_role(role_to_extend)
sm.add_permission_role(role, perm_view)
msg = __("Role %(r)s was extended to provide the access to "
"the datasource %(ds)s", r=role_to_extend,
ds=datasource.full_name)
utils.notify_user_about_perm_udate(
g.user, requested_by, role, datasource,
'email/role_extended.txt', app.config)
flash(msg, "info")

else:
flash(__("You have no permission to approve this request"),
Expand Down Expand Up @@ -2693,6 +2702,8 @@ def welcome(self):
@expose("/profile/<username>/")
def profile(self, username):
"""User profile page"""
if not username and g.user:
username = g.user.username
user = (
db.session.query(ab_models.User)
.filter_by(username=username)
Expand Down
30 changes: 28 additions & 2 deletions tests/access_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from __future__ import unicode_literals

import json
import mock
import unittest

from superset import db, models, sm
Expand Down Expand Up @@ -146,8 +147,8 @@ def test_override_role_permissions_drops_absent_perms(self):
'datasource_access',
updated_override_me.permissions[0].permission.name)


def test_approve(self):
@mock.patch('superset.utils.send_MIME_email')
def test_approve(self, mock_send_mime):
session = db.session
TEST_ROLE_NAME = 'table_role'
sm.add_role(TEST_ROLE_NAME)
Expand Down Expand Up @@ -188,6 +189,18 @@ def create_access_request(ds_type, ds_name, role_name):
self.get_resp(GRANT_ROLE_REQUEST.format(
'table', ds_1_id, 'gamma', TEST_ROLE_NAME))

# Test email content.
self.assertTrue(mock_send_mime.called)
call_args = mock_send_mime.call_args[0]
self.assertEqual([sm.find_user(username='gamma').email,
sm.find_user(username='admin').email],
call_args[1])
self.assertEqual(
'[Superset] Access to the datasource {} was granted'.format(
self.get_table(ds_1_id).full_name), call_args[2]['Subject'])
self.assertIn(TEST_ROLE_NAME, call_args[2].as_string())
self.assertIn('unicode_test', call_args[2].as_string())

access_requests = self.get_access_requests('gamma', 'table', ds_1_id)
# request was removed
self.assertFalse(access_requests)
Expand All @@ -204,6 +217,19 @@ def create_access_request(ds_type, ds_name, role_name):
self.client.get(EXTEND_ROLE_REQUEST.format(
'table', access_request2.datasource_id, 'gamma', TEST_ROLE_NAME))
access_requests = self.get_access_requests('gamma', 'table', ds_2_id)

# Test email content.
self.assertTrue(mock_send_mime.called)
call_args = mock_send_mime.call_args[0]
self.assertEqual([sm.find_user(username='gamma').email,
sm.find_user(username='admin').email],
call_args[1])
self.assertEqual(
'[Superset] Access to the datasource {} was granted'.format(
self.get_table(ds_2_id).full_name), call_args[2]['Subject'])
self.assertIn(TEST_ROLE_NAME, call_args[2].as_string())
self.assertIn('long_lat', call_args[2].as_string())

# request was removed
self.assertFalse(access_requests)
# table_role was extended to grant access to the long_lat table/
Expand Down
4 changes: 4 additions & 0 deletions tests/base_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ def __init__(self, *args, **kwargs):
session.add(druid_datasource2)
session.commit()

def get_table(self, table_id):
return db.session.query(models.SqlaTable).filter_by(
id=table_id).first()

def get_or_create(self, cls, criteria, session):
obj = session.query(cls).filter_by(**criteria).first()
if not obj:
Expand Down
Loading

0 comments on commit 495f646

Please sign in to comment.