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()