diff --git a/superset/config.py b/superset/config.py
index 0ddf6d03c7427..f8771eb28b68b 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -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')
diff --git a/superset/templates/email/role_extended.txt b/superset/templates/email/role_extended.txt
new file mode 100644
index 0000000000000..82dd3d03f1346
--- /dev/null
+++ b/superset/templates/email/role_extended.txt
@@ -0,0 +1,14 @@
+Dear {{ user.username }},
+
+
+ {{ granter.username }} has extended the role {{ role.name }} to include
+
+ {{datasource.full_name}} and granted you access to it.
+
+
+To see all your permissions please visit your
+
+ profile page.
+
+
+Regards, Superset Admin.
\ No newline at end of file
diff --git a/superset/templates/email/role_granted.txt b/superset/templates/email/role_granted.txt
new file mode 100644
index 0000000000000..37c063abdeb08
--- /dev/null
+++ b/superset/templates/email/role_granted.txt
@@ -0,0 +1,18 @@
+Dear {{ user.username }},
+
+
+ {{ granter.username }} has granted you the role {{ role.name }}
+ that gives access to the
+
+ {{datasource.full_name}}
+
+
+In addition to that role grants you access to the: {{ role.permissions }}.
+
+
+To see all your permissions please visit your
+
+ profile page.
+
+
+Regards, Superset Admin.
\ No newline at end of file
diff --git a/superset/utils.py b/superset/utils.py
index df8431b402a10..cf104fabe26fd 100644
--- a/superset/utils.py
+++ b/superset/utils.py
@@ -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)
@@ -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', 'Foo 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
diff --git a/superset/views.py b/superset/views.py
index 155570b014293..4e5cd7be53935 100755
--- a/superset/views.py
+++ b/superset/views.py
@@ -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
@@ -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"),
@@ -2693,6 +2702,8 @@ def welcome(self):
@expose("/profile//")
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)
diff --git a/tests/access_tests.py b/tests/access_tests.py
index 383f7c6501255..e12408d2f76e1 100644
--- a/tests/access_tests.py
+++ b/tests/access_tests.py
@@ -5,6 +5,7 @@
from __future__ import unicode_literals
import json
+import mock
import unittest
from superset import db, models, sm
@@ -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)
@@ -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)
@@ -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/
diff --git a/tests/base_tests.py b/tests/base_tests.py
index 2dbb7c2520cb7..6b38aab169e82 100644
--- a/tests/base_tests.py
+++ b/tests/base_tests.py
@@ -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:
diff --git a/tests/email_tests.py b/tests/email_tests.py
new file mode 100644
index 0000000000000..9996d00f6ef19
--- /dev/null
+++ b/tests/email_tests.py
@@ -0,0 +1,122 @@
+"""Unit tests for email service in Superset"""
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+from __future__ import unicode_literals
+
+import logging
+import mock
+import tempfile
+import unittest
+
+from superset import utils, app
+
+from email.mime.multipart import MIMEMultipart
+from email.mime.application import MIMEApplication
+
+send_email_test = mock.Mock()
+
+
+class EmailSmtpTest(unittest.TestCase):
+ def setUp(self):
+ app.config['smtp_ssl'] = False
+
+ @mock.patch('superset.utils.send_MIME_email')
+ def test_send_smtp(self, mock_send_mime):
+ attachment = tempfile.NamedTemporaryFile()
+ attachment.write(b'attachment')
+ attachment.seek(0)
+ utils.send_email_smtp(
+ 'to', 'subject', 'content', app.config, files=[attachment.name])
+ assert mock_send_mime.called
+ call_args = mock_send_mime.call_args[0]
+ logging.debug(call_args)
+ assert call_args[0] == app.config.get('SMTP_MAIL_FROM')
+ assert call_args[1] == ['to']
+ msg = call_args[2]
+ assert msg['Subject'] == 'subject'
+ assert msg['From'] == app.config.get('SMTP_MAIL_FROM')
+ assert len(msg.get_payload()) == 2
+ mimeapp = MIMEApplication('attachment')
+ assert msg.get_payload()[-1].get_payload() == mimeapp.get_payload()
+
+ @mock.patch('superset.utils.send_MIME_email')
+ def test_send_bcc_smtp(self, mock_send_mime):
+ attachment = tempfile.NamedTemporaryFile()
+ attachment.write(b'attachment')
+ attachment.seek(0)
+ utils.send_email_smtp(
+ 'to', 'subject', 'content', app.config, files=[attachment.name],
+ cc='cc', bcc='bcc')
+ assert mock_send_mime.called
+ call_args = mock_send_mime.call_args[0]
+ assert call_args[0] == app.config.get('SMTP_MAIL_FROM')
+ assert call_args[1] == ['to', 'cc', 'bcc']
+ msg = call_args[2]
+ assert msg['Subject'] == 'subject'
+ assert msg['From'] == app.config.get('SMTP_MAIL_FROM')
+ assert len(msg.get_payload()) == 2
+ mimeapp = MIMEApplication('attachment')
+ assert msg.get_payload()[-1].get_payload() == mimeapp.get_payload()
+
+
+ @mock.patch('smtplib.SMTP_SSL')
+ @mock.patch('smtplib.SMTP')
+ def test_send_mime(self, mock_smtp, mock_smtp_ssl):
+ mock_smtp.return_value = mock.Mock()
+ mock_smtp_ssl.return_value = mock.Mock()
+ msg = MIMEMultipart()
+ utils.send_MIME_email('from', 'to', msg, app.config, dryrun=False)
+ mock_smtp.assert_called_with(
+ app.config.get('SMTP_HOST'),
+ app.config.get('SMTP_PORT'),
+ )
+ assert mock_smtp.return_value.starttls.called
+ mock_smtp.return_value.login.assert_called_with(
+ app.config.get('SMTP_USER'),
+ app.config.get('SMTP_PASSWORD'),
+ )
+ mock_smtp.return_value.sendmail.assert_called_with(
+ 'from', 'to', msg.as_string())
+ assert mock_smtp.return_value.quit.called
+
+ @mock.patch('smtplib.SMTP_SSL')
+ @mock.patch('smtplib.SMTP')
+ def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
+ app.config['SMTP_SSL'] = True
+ mock_smtp.return_value = mock.Mock()
+ mock_smtp_ssl.return_value = mock.Mock()
+ utils.send_MIME_email(
+ 'from', 'to', MIMEMultipart(), app.config, dryrun=False)
+ assert not mock_smtp.called
+ mock_smtp_ssl.assert_called_with(
+ app.config.get('SMTP_HOST'),
+ app.config.get('SMTP_PORT'),
+ )
+
+ @mock.patch('smtplib.SMTP_SSL')
+ @mock.patch('smtplib.SMTP')
+ def test_send_mime_noauth(self, mock_smtp, mock_smtp_ssl):
+ app.config['SMTP_USER'] = None
+ app.config['SMTP_PASSWORD'] = None
+ mock_smtp.return_value = mock.Mock()
+ mock_smtp_ssl.return_value = mock.Mock()
+ utils.send_MIME_email(
+ 'from', 'to', MIMEMultipart(), app.config, dryrun=False)
+ assert not mock_smtp_ssl.called
+ mock_smtp.assert_called_with(
+ app.config.get('SMTP_HOST'),
+ app.config.get('SMTP_PORT'),
+ )
+ assert not mock_smtp.login.called
+
+ @mock.patch('smtplib.SMTP_SSL')
+ @mock.patch('smtplib.SMTP')
+ def test_send_mime_dryrun(self, mock_smtp, mock_smtp_ssl):
+ utils.send_MIME_email(
+ 'from', 'to', MIMEMultipart(), app.config, dryrun=True)
+ assert not mock_smtp.called
+ assert not mock_smtp_ssl.called
+
+if __name__ == '__main__':
+ unittest.main()
\ No newline at end of file
diff --git a/tests/import_export_tests.py b/tests/import_export_tests.py
index 6c7d797537297..2120fce9d6095 100644
--- a/tests/import_export_tests.py
+++ b/tests/import_export_tests.py
@@ -135,10 +135,6 @@ def get_dash_by_slug(self, dash_slug):
return db.session.query(models.Dashboard).filter_by(
slug=dash_slug).first()
- def get_table(self, table_id):
- return db.session.query(models.SqlaTable).filter_by(
- id=table_id).first()
-
def get_datasource(self, datasource_id):
return db.session.query(models.DruidDatasource).filter_by(
id=datasource_id).first()