Skip to content
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

feat: add header_data into emails #20903

Merged
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import sys
from collections import OrderedDict
from datetime import timedelta
from email.mime.multipart import MIMEMultipart
from typing import (
Any,
Callable,
Expand Down Expand Up @@ -1066,6 +1067,15 @@ def SQL_QUERY_MUTATOR( # pylint: disable=invalid-name,unused-argument
return sql


# This allows for a user to add header data to any outgoing emails. For example,
# if you need to include metadata in the header or you want to change the specifications
# of the email title, header, or sender.
def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument
msg: MIMEMultipart, **kwargs: Any
) -> MIMEMultipart:
return msg
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not related, but there seems to be a slight discrepancy on callable config keys for example:

For example DATASET_HEALTH_CHECK default is None and is declared like this:

DATASET_HEALTH_CHECK: Optional[Callable[["SqlaTable"], str]] = None

Any thoughts @eschutho @AAfghahi

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree and see the benefits of both. I know that the SQL mutator uses the structure that i am using right now. Should we bring this up to the superset developers at large?



# This auth provider is used by background (offline) tasks that need to access
# protected resources. Can be overridden by end users in order to support
# custom auth mechanisms
Expand Down
29 changes: 29 additions & 0 deletions superset/reports/commands/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
ReportRecipientType,
ReportSchedule,
ReportScheduleType,
ReportSourceFormat,
ReportState,
)
from superset.reports.notifications import create_notification
Expand All @@ -73,6 +74,8 @@
from superset.utils.urls import get_url_path
from superset.utils.webdriver import DashboardStandaloneMode

from ...utils.core import HeaderDataType
Copy link
Member

@eschutho eschutho Aug 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you make this import absolute instead of relative?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I keep on doing this and the black reformats it. So annoying.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 please fix this format

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah latest commit fixes it.


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -305,6 +308,27 @@ def _update_query_context(self) -> None:
"Please try loading the chart and saving it again."
) from ex

def _get_log_data(self) -> HeaderDataType:
chart_id = None
dashboard_id = None
report_source = None
if self._report_schedule.chart:
report_source = ReportSourceFormat.CHART
chart_id = self._report_schedule.chart_id
else:
report_source = ReportSourceFormat.DASHBOARD
dashboard_id = self._report_schedule.dashboard_id

log_data: HeaderDataType = {
"notification_type": self._report_schedule.type,
"notification_source": report_source,
"notification_format": self._report_schedule.report_format,
"chart_id": chart_id,
"dashboard_id": dashboard_id,
"owners": self._report_schedule.owners,
}
return log_data

def _get_notification_content(self) -> NotificationContent:
"""
Gets a notification content, this is composed by a title and a screenshot
Expand All @@ -315,6 +339,7 @@ def _get_notification_content(self) -> NotificationContent:
embedded_data = None
error_text = None
screenshot_data = []

url = self._get_url(user_friendly=True)
if (
feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
Expand Down Expand Up @@ -352,13 +377,17 @@ def _get_notification_content(self) -> NotificationContent:
f"{self._report_schedule.name}: "
f"{self._report_schedule.dashboard.dashboard_title}"
)

header_data = self._get_log_data()

return NotificationContent(
name=name,
url=url,
screenshots=screenshot_data,
description=self._report_schedule.description,
csv=csv_data,
embedded_data=embedded_data,
header_data=header_data,
)

def _send(
Expand Down
5 changes: 5 additions & 0 deletions superset/reports/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ class ReportCreationMethod(str, enum.Enum):
ALERTS_REPORTS = "alerts_reports"


class ReportSourceFormat(str, enum.Enum):
CHART = "chart"
DASHBOARD = "dashboard"


report_schedule_user = Table(
"report_schedule_user",
metadata,
Expand Down
4 changes: 4 additions & 0 deletions superset/reports/notifications/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@
import pandas as pd

from superset.reports.models import ReportRecipients, ReportRecipientType
from superset.utils.core import HeaderDataType


@dataclass
class NotificationContent:
name: str
header_data: Optional[
HeaderDataType
] = None # this is optional to account for error states
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain why this is optional?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we also create notification_content when there is an error, should we be pushing header data into those cases as well? I assumed we wouldn't be but happy to make it not optional, and create header data in those instances as well.

csv: Optional[bytes] = None # bytes for csv file
screenshots: Optional[List[bytes]] = None # bytes for a list of screenshots
text: Optional[str] = None
Expand Down
11 changes: 9 additions & 2 deletions superset/reports/notifications/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from superset.reports.models import ReportRecipientType
from superset.reports.notifications.base import BaseNotification
from superset.reports.notifications.exceptions import NotificationError
from superset.utils.core import send_email_smtp
from superset.utils.core import HeaderDataType, send_email_smtp
from superset.utils.decorators import statsd_gauge
from superset.utils.urls import modify_url_query

Expand Down Expand Up @@ -67,6 +67,7 @@
@dataclass
class EmailContent:
body: str
header_data: Optional[HeaderDataType] = None
data: Optional[Dict[str, Any]] = None
images: Optional[Dict[str, bytes]] = None

Expand Down Expand Up @@ -170,7 +171,12 @@ def _get_content(self) -> EmailContent:

if self._content.csv:
csv_data = {__("%(name)s.csv", name=self._content.name): self._content.csv}
return EmailContent(body=body, images=images, data=csv_data)
return EmailContent(
body=body,
images=images,
data=csv_data,
header_data=self._content.header_data,
)

def _get_subject(self) -> str:
return __(
Expand Down Expand Up @@ -199,6 +205,7 @@ def send(self) -> None:
bcc="",
mime_subtype="related",
dryrun=False,
header_data=content.header_data,
)
logger.info("Report sent to email")
except Exception as ex:
Expand Down
17 changes: 15 additions & 2 deletions superset/utils/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ class DatasourceType(str, Enum):
VIEW = "view"


class HeaderDataType(TypedDict):
notification_format: str
owners: List[int]
notification_type: str
notification_source: Optional[str]
chart_id: Optional[int]
dashboard_id: Optional[int]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all of these are optional, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type and format shouldn't be since they are required in order to make a report. I can make all of them optional however.



class DatasourceDict(TypedDict):
type: str # todo(hugh): update this to be DatasourceType
id: int
Expand Down Expand Up @@ -904,6 +913,7 @@ def send_email_smtp( # pylint: disable=invalid-name,too-many-arguments,too-many
cc: Optional[str] = None,
bcc: Optional[str] = None,
mime_subtype: str = "mixed",
header_data: Optional[HeaderDataType] = None,
) -> None:
"""
Send an email with html content, eg:
Expand All @@ -917,6 +927,7 @@ def send_email_smtp( # pylint: disable=invalid-name,too-many-arguments,too-many
msg["Subject"] = subject
msg["From"] = smtp_mail_from
msg["To"] = ", ".join(smtp_mail_to)

msg.preamble = "This is a multi-part message in MIME format."

recipients = smtp_mail_to
Expand Down Expand Up @@ -963,8 +974,10 @@ def send_email_smtp( # pylint: disable=invalid-name,too-many-arguments,too-many
image.add_header("Content-ID", "<%s>" % msgid)
image.add_header("Content-Disposition", "inline")
msg.attach(image)

send_mime_email(smtp_mail_from, recipients, msg, config, dryrun=dryrun)
msg_mutator = config["EMAIL_HEADER_MUTATOR"]
# the base notification returns the message without any editing.
new_msg = msg_mutator(msg, **header_data or {})
eschutho marked this conversation as resolved.
Show resolved Hide resolved
send_mime_email(smtp_mail_from, recipients, new_msg, config, dryrun=dryrun)


def send_mime_email(
Expand Down
31 changes: 31 additions & 0 deletions tests/integration_tests/email_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,37 @@ def test_send_smtp(self, mock_send_mime):
mimeapp = MIMEApplication("attachment")
assert msg.get_payload()[-1].get_payload() == mimeapp.get_payload()

@mock.patch("superset.utils.core.send_mime_email")
def test_send_smtp_with_email_mutator(self, mock_send_mime):
attachment = tempfile.NamedTemporaryFile()
attachment.write(b"attachment")
attachment.seek(0)

# putting this into a variable so that we can reset after the test
base_email_mutator = app.config["EMAIL_HEADER_MUTATOR"]

def mutator(msg, **kwargs):
msg["foo"] = "bar"
return msg

app.config["EMAIL_HEADER_MUTATOR"] = mutator
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]
logger.debug(call_args)
assert call_args[0] == app.config["SMTP_MAIL_FROM"]
assert call_args[1] == ["to"]
msg = call_args[2]
assert msg["Subject"] == "subject"
assert msg["From"] == app.config["SMTP_MAIL_FROM"]
assert msg["foo"] == "bar"
assert len(msg.get_payload()) == 2
mimeapp = MIMEApplication("attachment")
assert msg.get_payload()[-1].get_payload() == mimeapp.get_payload()
app.config["EMAIL_HEADER_MUTATOR"] = base_email_mutator

@mock.patch("superset.utils.core.send_mime_email")
def test_send_smtp_data(self, mock_send_mime):
utils.send_email_smtp(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from superset.models.dashboard import Dashboard
from superset.reports.commands.execute import AsyncExecuteReportScheduleCommand
from superset.reports.models import ReportSourceFormat
from tests.integration_tests.fixtures.tabbed_dashboard import tabbed_dashboard
from tests.integration_tests.reports.utils import create_dashboard_report

Expand Down Expand Up @@ -66,3 +67,47 @@ def test_report_for_dashboard_with_tabs(
assert digest == dashboard.digest
assert send_email_smtp_mock.call_count == 1
assert len(send_email_smtp_mock.call_args.kwargs["images"]) == 1


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider adding unit tests for send_email_smtp with the actual use of NOTIFICATION_EMAIL_HEADER_MUTATOR

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would that exist in this repo or in one with an actual NOTIFICATION_EMAIL_HEADER_MUTATOR?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can mock an example NOTIFICATION_EMAIL_HEADER_MUTATOR and test it in this repo

@patch("superset.reports.notifications.email.send_email_smtp")
@patch(
"superset.reports.commands.execute.DashboardScreenshot",
)
@patch(
"superset.dashboards.permalink.commands.create.CreateDashboardPermalinkCommand.run"
)
def test_report_with_header_data(
create_dashboard_permalink_mock: MagicMock,
dashboard_screenshot_mock: MagicMock,
send_email_smtp_mock: MagicMock,
tabbed_dashboard: Dashboard,
) -> None:
create_dashboard_permalink_mock.return_value = "permalink"
dashboard_screenshot_mock.get_screenshot.return_value = b"test-image"
current_app.config["ALERT_REPORTS_NOTIFICATION_DRY_RUN"] = False

with create_dashboard_report(
dashboard=tabbed_dashboard,
extra={"active_tabs": ["TAB-L1B"]},
name="test report tabbed dashboard",
) as report_schedule:
dashboard: Dashboard = report_schedule.dashboard
AsyncExecuteReportScheduleCommand(
str(uuid4()), report_schedule.id, datetime.utcnow()
).run()
dashboard_state = report_schedule.extra.get("dashboard", {})
permalink_key = CreateDashboardPermalinkCommand(
dashboard.id, dashboard_state
).run()

assert dashboard_screenshot_mock.call_count == 1
(url, digest) = dashboard_screenshot_mock.call_args.args
assert url.endswith(f"/superset/dashboard/p/{permalink_key}/")
assert digest == dashboard.digest
assert send_email_smtp_mock.call_count == 1
header_data = send_email_smtp_mock.call_args.kwargs["header_data"]
assert header_data.get("dashboard_id") == dashboard.id
assert header_data.get("notification_format") == report_schedule.report_format
assert header_data.get("notification_source") == ReportSourceFormat.DASHBOARD
assert header_data.get("notification_type") == report_schedule.type
assert len(send_email_smtp_mock.call_args.kwargs["header_data"]) == 6
8 changes: 8 additions & 0 deletions tests/unit_tests/notifications/email_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ def test_render_description_with_html() -> None:
}
),
description='<p>This is <a href="#">a test</a> alert</p><br />',
header_data={
"notification_format": "PNG",
"notification_type": "Alert",
"owners": [1],
"notification_source": None,
"chart_id": None,
"dashboard_id": None,
},
)
email_body = (
EmailNotification(
Expand Down