feat(alerts-reports): adding pdf filetype to email and slack reports (#27497)

This commit is contained in:
Jack 2024-03-22 11:54:30 -05:00 committed by GitHub
parent cd7972d05b
commit 30b497e758
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 130 additions and 41 deletions

View File

@ -159,6 +159,10 @@ const CONTENT_TYPE_OPTIONS = [
},
];
const FORMAT_OPTIONS = {
pdf: {
label: t('Send as PDF'),
value: 'PDF',
},
png: {
label: t('Send as PNG'),
value: 'PNG',
@ -427,11 +431,8 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
const [isScreenshot, setIsScreenshot] = useState<boolean>(false);
useEffect(() => {
setIsScreenshot(
contentType === 'dashboard' ||
(contentType === 'chart' && reportFormat === 'PNG'),
);
}, [contentType, reportFormat]);
setIsScreenshot(reportFormat === 'PNG');
}, [reportFormat]);
// Dropdown options
const [conditionNotNull, setConditionNotNull] = useState<boolean>(false);
@ -487,8 +488,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
const reportOrAlert = isReport ? 'report' : 'alert';
const isEditMode = alert !== null;
const formatOptionEnabled =
contentType === 'chart' &&
(isFeatureEnabled(FeatureFlag.AlertsAttachReports) || isReport);
isFeatureEnabled(FeatureFlag.AlertsAttachReports) || isReport;
const [notificationAddState, setNotificationAddState] =
useState<NotificationAddStatus>('active');
@ -616,10 +616,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
owner => (owner as MetaObject).value || owner.id,
),
recipients,
report_format:
contentType === 'dashboard'
? DEFAULT_NOTIFICATION_FORMAT
: reportFormat || DEFAULT_NOTIFICATION_FORMAT,
report_format: reportFormat || DEFAULT_NOTIFICATION_FORMAT,
};
if (data.recipients && !data.recipients.length) {
@ -1128,11 +1125,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
: 'active',
);
setContentType(resource.chart ? 'chart' : 'dashboard');
setReportFormat(
resource.chart
? resource.report_format || DEFAULT_NOTIFICATION_FORMAT
: DEFAULT_NOTIFICATION_FORMAT,
);
setReportFormat(resource.report_format || DEFAULT_NOTIFICATION_FORMAT);
const validatorConfig =
typeof resource.validator_config_json === 'string'
? JSON.parse(resource.validator_config_json)
@ -1516,7 +1509,9 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
)}
</StyledInputContainer>
<StyledInputContainer
css={['TEXT', 'CSV'].includes(reportFormat) && noMarginBottom}
css={
['PDF', 'TEXT', 'CSV'].includes(reportFormat) && noMarginBottom
}
>
{formatOptionEnabled && (
<>
@ -1529,11 +1524,13 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
onChange={onFormatChange}
value={reportFormat}
options={
/* If chart is of text based viz type: show text
contentType === 'dashboard'
? ['pdf', 'png'].map(key => FORMAT_OPTIONS[key])
: /* If chart is of text based viz type: show text
format option */
TEXT_BASED_VISUALIZATION_TYPES.includes(chartVizType)
? Object.values(FORMAT_OPTIONS)
: ['png', 'csv'].map(key => FORMAT_OPTIONS[key])
TEXT_BASED_VISUALIZATION_TYPES.includes(chartVizType)
? Object.values(FORMAT_OPTIONS)
: ['pdf', 'png', 'csv'].map(key => FORMAT_OPTIONS[key])
}
placeholder={t('Select format')}
/>

View File

@ -149,6 +149,10 @@ class ReportScheduleScreenshotFailedError(CommandException):
message = _("Report Schedule execution failed when generating a screenshot.")
class ReportSchedulePdfFailedError(CommandException):
message = _("Report Schedule execution failed when generating a pdf.")
class ReportScheduleCsvFailedError(CommandException):
message = _("Report Schedule execution failed when generating a csv.")

View File

@ -70,6 +70,7 @@ from superset.tasks.utils import get_executor
from superset.utils.core import HeaderDataType, override_user
from superset.utils.csv import get_chart_csv_data, get_chart_dataframe
from superset.utils.decorators import logs_context
from superset.utils.pdf import build_pdf_from_screenshots
from superset.utils.screenshots import ChartScreenshot, DashboardScreenshot
from superset.utils.urls import get_url_path
@ -238,6 +239,16 @@ class BaseReportState:
raise ReportScheduleScreenshotFailedError()
return [image]
def _get_pdf(self) -> bytes:
"""
Get chart or dashboard pdf
:raises: ReportSchedulePdfFailedError
"""
screenshots = self._get_screenshots()
pdf = build_pdf_from_screenshots(screenshots)
return pdf
def _get_csv_data(self) -> bytes:
url = self._get_url(result_format=ChartDataResultFormat.CSV)
_, username = get_executor(
@ -342,22 +353,27 @@ class BaseReportState:
:raises: ReportScheduleScreenshotFailedError
"""
csv_data = None
screenshot_data = []
pdf_data = None
embedded_data = None
error_text = None
screenshot_data = []
header_data = self._get_log_data()
url = self._get_url(user_friendly=True)
if (
feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
or self._report_schedule.type == ReportScheduleType.REPORT
):
if self._report_schedule.report_format == ReportDataFormat.VISUALIZATION:
if self._report_schedule.report_format == ReportDataFormat.PNG:
screenshot_data = self._get_screenshots()
if not screenshot_data:
error_text = "Unexpected missing screenshot"
elif self._report_schedule.report_format == ReportDataFormat.PDF:
pdf_data = self._get_pdf()
if not pdf_data:
error_text = "Unexpected missing pdf"
elif (
self._report_schedule.chart
and self._report_schedule.report_format == ReportDataFormat.DATA
and self._report_schedule.report_format == ReportDataFormat.CSV
):
csv_data = self._get_csv_data()
if not csv_data:
@ -390,6 +406,7 @@ class BaseReportState:
name=name,
url=url,
screenshots=screenshot_data,
pdf=pdf_data,
description=self._report_schedule.description,
csv=csv_data,
embedded_data=embedded_data,

View File

@ -72,8 +72,9 @@ class ReportState(StrEnum):
class ReportDataFormat(StrEnum):
VISUALIZATION = "PNG"
DATA = "CSV"
PDF = "PDF"
PNG = "PNG"
CSV = "CSV"
TEXT = "TEXT"
@ -127,7 +128,7 @@ class ReportSchedule(AuditMixinNullable, ExtraJSONMixin, Model):
String(255), server_default=ReportCreationMethod.ALERTS_REPORTS
)
timezone = Column(String(100), default="UTC", nullable=False)
report_format = Column(String(50), default=ReportDataFormat.VISUALIZATION)
report_format = Column(String(50), default=ReportDataFormat.PNG)
sql = Column(MediumText())
# (Alerts/Reports) M-O to chart
chart_id = Column(Integer, ForeignKey("slices.id"), nullable=True)

View File

@ -28,6 +28,7 @@ class NotificationContent:
name: str
header_data: HeaderDataType # this is optional to account for error states
csv: Optional[bytes] = None # bytes for csv file
pdf: Optional[bytes] = None # bytes for PDF file
screenshots: Optional[list[bytes]] = None # bytes for a list of screenshots
text: Optional[str] = None
description: Optional[str] = ""

View File

@ -69,6 +69,7 @@ class EmailContent:
body: str
header_data: Optional[HeaderDataType] = None
data: Optional[dict[str, Any]] = None
pdf: Optional[dict[str, bytes]] = None
images: Optional[dict[str, bytes]] = None
@ -97,7 +98,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
return EmailContent(body=self._error_template(self._content.text))
# Get the domain from the 'From' address ..
# and make a message id without the < > in the end
csv_data = None
domain = self._get_smtp_domain()
images = {}
@ -165,12 +166,18 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
</html>
"""
)
csv_data = None
if self._content.csv:
csv_data = {__("%(name)s.csv", name=self._content.name): self._content.csv}
pdf_data = None
if self._content.pdf:
pdf_data = {__("%(name)s.pdf", name=self._content.name): self._content.pdf}
return EmailContent(
body=body,
images=images,
pdf=pdf_data,
data=csv_data,
header_data=self._content.header_data,
)
@ -198,6 +205,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
app.config,
files=[],
data=content.data,
pdf=content.pdf,
images=content.images,
bcc="",
mime_subtype="related",

View File

@ -161,21 +161,24 @@ Error: %(text)s
return self._message_template(table)
def _get_inline_files(self) -> Sequence[Union[str, IOBase, bytes]]:
def _get_inline_files(
self,
) -> tuple[Union[str, None], Sequence[Union[str, IOBase, bytes]]]:
if self._content.csv:
return [self._content.csv]
return ("csv", [self._content.csv])
if self._content.screenshots:
return self._content.screenshots
return []
return ("png", self._content.screenshots)
if self._content.pdf:
return ("pdf", [self._content.pdf])
return (None, [])
@backoff.on_exception(backoff.expo, SlackApiError, factor=10, base=2, max_tries=5)
@statsd_gauge("reports.slack.send")
def send(self) -> None:
files = self._get_inline_files()
file_type, files = self._get_inline_files()
title = self._content.name
channel = self._get_channel()
body = self._get_body()
file_type = "csv" if self._content.csv else "png"
global_logs_context = getattr(g, "logs_context", {}) or {}
try:
token = app.config["SLACK_API_TOKEN"]

View File

@ -204,7 +204,7 @@ class ReportSchedulePostSchema(Schema):
recipients = fields.List(fields.Nested(ReportRecipientSchema))
report_format = fields.String(
dump_default=ReportDataFormat.VISUALIZATION,
dump_default=ReportDataFormat.PNG,
validate=validate.OneOf(choices=tuple(key.value for key in ReportDataFormat)),
)
extra = fields.Dict(
@ -335,7 +335,7 @@ class ReportSchedulePutSchema(Schema):
)
recipients = fields.List(fields.Nested(ReportRecipientSchema), required=False)
report_format = fields.String(
dump_default=ReportDataFormat.VISUALIZATION,
dump_default=ReportDataFormat.PNG,
validate=validate.OneOf(choices=tuple(key.value for key in ReportDataFormat)),
)
extra = fields.Dict(dump_default=None)

View File

@ -822,6 +822,7 @@ def send_email_smtp( # pylint: disable=invalid-name,too-many-arguments,too-many
config: dict[str, Any],
files: list[str] | None = None,
data: dict[str, str] | None = None,
pdf: dict[str, bytes] | None = None,
images: dict[str, bytes] | None = None,
dryrun: bool = False,
cc: str | None = None,
@ -879,6 +880,15 @@ def send_email_smtp( # pylint: disable=invalid-name,too-many-arguments,too-many
)
)
for name, body_pdf in (pdf or {}).items():
msg.attach(
MIMEApplication(
body_pdf,
Content_Disposition=f"attachment; filename='{name}'",
Name=name,
)
)
# Attach any inline images, which may be required for display in
# HTML content (inline)
for msgid, imgdata in (images or {}).items():

48
superset/utils/pdf.py Normal file
View File

@ -0,0 +1,48 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from io import BytesIO
from superset.commands.report.exceptions import ReportSchedulePdfFailedError
logger = logging.getLogger(__name__)
try:
from PIL import Image
except ModuleNotFoundError:
logger.info("No PIL installation found")
def build_pdf_from_screenshots(snapshots: list[bytes]) -> bytes:
images = []
for snap in snapshots:
img = Image.open(BytesIO(snap))
if img.mode == "RGBA":
img = img.convert("RGB")
images.append(img)
logger.info("building pdf")
try:
new_pdf = BytesIO()
images[0].save(new_pdf, "PDF", save_all=True, append_images=images[1:])
new_pdf.seek(0)
except Exception as ex:
raise ReportSchedulePdfFailedError(
f"Failed converting screenshots to pdf {str(ex)}"
) from ex
return new_pdf.read()

View File

@ -205,7 +205,7 @@ def create_report_email_chart_with_csv():
report_schedule = create_report_notification(
email_target="target@email.com",
chart=chart,
report_format=ReportDataFormat.DATA,
report_format=ReportDataFormat.CSV,
)
yield report_schedule
cleanup_report_schedule(report_schedule)
@ -233,7 +233,7 @@ def create_report_email_chart_with_csv_no_query_context():
report_schedule = create_report_notification(
email_target="target@email.com",
chart=chart,
report_format=ReportDataFormat.DATA,
report_format=ReportDataFormat.CSV,
name="report_csv_no_query_context",
)
yield report_schedule
@ -284,7 +284,7 @@ def create_report_slack_chart_with_csv():
report_schedule = create_report_notification(
slack_channel="slack_channel",
chart=chart,
report_format=ReportDataFormat.DATA,
report_format=ReportDataFormat.CSV,
)
yield report_schedule

View File

@ -158,7 +158,7 @@ def create_report_notification(
validator_type=validator_type,
validator_config_json=validator_config_json,
grace_period=grace_period,
report_format=report_format or ReportDataFormat.VISUALIZATION,
report_format=report_format or ReportDataFormat.PNG,
extra=extra,
force_screenshot=force_screenshot,
)