From be9eb0f3a3c2d33ab6a1794ff36a4ee3f6b3a28b Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Thu, 29 Jun 2023 14:32:58 -0700 Subject: [PATCH] feat: customize screenshot width for alerts/reports (#24547) --- .../src/components/ReportModal/index.tsx | 28 ++++++++++ .../src/components/ReportModal/styles.tsx | 4 ++ .../src/features/alerts/AlertReportModal.tsx | 47 ++++++++++++++--- .../src/features/alerts/types.ts | 3 +- superset-frontend/src/reports/types.ts | 1 + superset/charts/api.py | 4 +- superset/config.py | 3 ++ ...85b9a_add_custom_size_columns_to_report.py | 46 ++++++++++++++++ superset/reports/api.py | 2 + superset/reports/models.py | 3 ++ superset/reports/schemas.py | 52 ++++++++++++++++++- superset/utils/screenshots.py | 18 ++++--- 12 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 superset/migrations/versions/2023-06-27_16-54_8e5b0fb85b9a_add_custom_size_columns_to_report.py diff --git a/superset-frontend/src/components/ReportModal/index.tsx b/superset-frontend/src/components/ReportModal/index.tsx index 5bf4c2c4d2..f98cddd66c 100644 --- a/superset-frontend/src/components/ReportModal/index.tsx +++ b/superset-frontend/src/components/ReportModal/index.tsx @@ -33,6 +33,7 @@ import LabeledErrorBoundInput from 'src/components/Form/LabeledErrorBoundInput'; import Icons from 'src/components/Icons'; import { CronError } from 'src/components/CronPicker'; import { RadioChangeEvent } from 'src/components'; +import { Input } from 'src/components/Input'; import withToasts from 'src/components/MessageToasts/withToasts'; import { ChartState } from 'src/explore/types'; import { @@ -41,9 +42,14 @@ import { NOTIFICATION_FORMATS, } from 'src/reports/types'; import { reportSelector } from 'src/views/CRUD/hooks'; +import { + TRANSLATIONS, + StyledInputContainer, +} from 'src/features/alerts/AlertReportModal'; import { CreationMethod } from './HeaderReportDropdown'; import { antDErrorAlertStyles, + CustomWidthHeaderStyle, StyledModal, StyledTopSection, StyledBottomSection, @@ -170,6 +176,7 @@ function ReportModal({ type: 'Report', active: true, force_screenshot: false, + custom_width: currentReport.custom_width, creation_method: creationMethod, dashboard: dashboardId, chart: chart?.id, @@ -257,6 +264,26 @@ function ReportModal({ ); + const renderCustomWidthSection = ( + +
+ {TRANSLATIONS.CUSTOM_SCREENSHOT_WIDTH_TEXT} +
+
+ ) => { + setCurrentReport({ + custom_width: parseInt(event.target.value, 10) || null, + }); + }} + /> +
+
+ ); return ( {isChart && renderMessageContentSection} + {(!isChart || !isTextBasedChart) && renderCustomWidthSection} {currentReport.error && ( css` margin: ${theme.gridUnit * 3}px 0 ${theme.gridUnit * 2}px; `; +export const CustomWidthHeaderStyle = (theme: SupersetTheme) => css` + margin: ${theme.gridUnit * 3}px 0 ${theme.gridUnit * 2}px; +`; + export const SectionHeaderStyle = (theme: SupersetTheme) => css` margin: ${theme.gridUnit * 3}px 0; `; diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx index 8ff344c97e..ce46eb69f5 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx @@ -35,6 +35,7 @@ import rison from 'rison'; import { useSingleViewResource } from 'src/views/CRUD/hooks'; import Icons from 'src/components/Icons'; +import { Input } from 'src/components/Input'; import { Switch } from 'src/components/Switch'; import Modal from 'src/components/Modal'; import TimezoneSelector from 'src/components/TimezoneSelector'; @@ -46,6 +47,7 @@ import Owner from 'src/types/Owner'; import { AntdCheckbox, AsyncSelect, Select } from 'src/components'; import TextAreaControl from 'src/explore/components/controls/TextAreaControl'; import { useCommonConf } from 'src/features/databases/state'; +import { CustomWidthHeaderStyle } from 'src/components/ReportModal/styles'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import { NotificationMethodOption, @@ -370,7 +372,7 @@ interface NotificationMethodAddProps { onClick: () => void; } -const TRANSLATIONS = { +export const TRANSLATIONS = { ADD_NOTIFICATION_METHOD_TEXT: t('Add notification method'), ADD_DELIVERY_METHOD_TEXT: t('Add delivery method'), SAVE_TEXT: t('Save'), @@ -406,7 +408,9 @@ const TRANSLATIONS = { SEND_AS_PNG_TEXT: t('Send as PNG'), SEND_AS_CSV_TEXT: t('Send as CSV'), SEND_AS_TEXT: t('Send as text'), - IGNORE_CACHE_TEXT: t('Ignore cache when generating screenshot'), + IGNORE_CACHE_TEXT: t('Ignore cache when generating report'), + CUSTOM_SCREENSHOT_WIDTH_TEXT: t('Screenshot width'), + CUSTOM_SCREENSHOT_WIDTH_PLACEHOLDER_TEXT: t('Input custom width in pixels'), NOTIFICATION_METHOD_TEXT: t('Notification method'), }; @@ -466,6 +470,14 @@ const AlertReportModal: FunctionComponent = ({ ); const [forceScreenshot, setForceScreenshot] = useState(false); + const [isScreenshot, setIsScreenshot] = useState(false); + useEffect(() => { + setIsScreenshot( + contentType === 'dashboard' || + (contentType === 'chart' && reportFormat === 'PNG'), + ); + }, [contentType, reportFormat]); + // Dropdown options const [conditionNotNull, setConditionNotNull] = useState(false); const [sourceOptions, setSourceOptions] = useState([]); @@ -853,12 +865,15 @@ const AlertReportModal: FunctionComponent = ({ }).then(response => setChartVizType(response.json.result.viz_type)); // Handle input/textarea updates - const onTextChange = ( + const onInputChange = ( event: React.ChangeEvent, ) => { - const { target } = event; + const { + target: { type, value, name }, + } = event; + const parsedValue = type === 'number' ? parseInt(value, 10) || null : value; - updateAlertState(target.name, target.value); + updateAlertState(name, parsedValue); }; const onTimeoutVerifyChange = ( @@ -1180,7 +1195,7 @@ const AlertReportModal: FunctionComponent = ({ ? TRANSLATIONS.REPORT_NAME_TEXT : TRANSLATIONS.ALERT_NAME_TEXT } - onChange={onTextChange} + onChange={onInputChange} css={inputSpacer} /> @@ -1216,7 +1231,7 @@ const AlertReportModal: FunctionComponent = ({ name="description" value={currentAlert ? currentAlert.description || '' : ''} placeholder={TRANSLATIONS.DESCRIPTION_TEXT} - onChange={onTextChange} + onChange={onInputChange} css={inputSpacer} /> @@ -1471,6 +1486,24 @@ const AlertReportModal: FunctionComponent = ({ )} + {isScreenshot && ( + +
+ {TRANSLATIONS.CUSTOM_SCREENSHOT_WIDTH_TEXT} +
+
+ +
+
+ )} {(isReport || contentType === 'dashboard') && (
1 to enable retries. ALERT_REPORTS_QUERY_EXECUTION_MAX_TRIES = 1 +# Custom width for screenshots +ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH = 600 +ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH = 2400 # A custom prefix to use on all Alerts & Reports emails EMAIL_REPORTS_SUBJECT_PREFIX = "[Report] " diff --git a/superset/migrations/versions/2023-06-27_16-54_8e5b0fb85b9a_add_custom_size_columns_to_report.py b/superset/migrations/versions/2023-06-27_16-54_8e5b0fb85b9a_add_custom_size_columns_to_report.py new file mode 100644 index 0000000000..16b46254d4 --- /dev/null +++ b/superset/migrations/versions/2023-06-27_16-54_8e5b0fb85b9a_add_custom_size_columns_to_report.py @@ -0,0 +1,46 @@ +# 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. +"""Add custom size columns to report schedule + +Revision ID: 8e5b0fb85b9a +Revises: 6fbe660cac39 +Create Date: 2023-06-27 16:54:57.161475 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8e5b0fb85b9a" +down_revision = "6fbe660cac39" + + +def upgrade(): + op.add_column( + "report_schedule", + sa.Column("custom_width", sa.Integer(), nullable=True), + ) + op.add_column( + "report_schedule", + sa.Column("custom_height", sa.Integer(), nullable=True), + ) + + +def downgrade(): + op.drop_column("report_schedule", "custom_width") + op.drop_column("report_schedule", "custom_height") diff --git a/superset/reports/api.py b/superset/reports/api.py index 125a3e6763..3686ab74bd 100644 --- a/superset/reports/api.py +++ b/superset/reports/api.py @@ -93,6 +93,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi): "context_markdown", "creation_method", "crontab", + "custom_width", "dashboard.dashboard_title", "dashboard.id", "database.database_name", @@ -159,6 +160,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi): "context_markdown", "creation_method", "crontab", + "custom_width", "dashboard", "database", "description", diff --git a/superset/reports/models.py b/superset/reports/models.py index 24d4657b7d..2cbcbe0daa 100644 --- a/superset/reports/models.py +++ b/superset/reports/models.py @@ -154,6 +154,9 @@ class ReportSchedule(Model, AuditMixinNullable, ExtraJSONMixin): # (Reports) When generating a screenshot, bypass the cache? force_screenshot = Column(Boolean, default=False) + custom_width = Column(Integer, nullable=True) + custom_height = Column(Integer, nullable=True) + extra: ReportScheduleExtra # type: ignore def __repr__(self) -> str: diff --git a/superset/reports/schemas.py b/superset/reports/schemas.py index fbe681be36..7bdbf34f12 100644 --- a/superset/reports/schemas.py +++ b/superset/reports/schemas.py @@ -17,8 +17,9 @@ from typing import Any, Union from croniter import croniter +from flask import current_app from flask_babel import gettext as _ -from marshmallow import fields, Schema, validate, validates_schema +from marshmallow import fields, Schema, validate, validates, validates_schema from marshmallow.validate import Length, Range, ValidationError from pytz import all_timezones @@ -208,10 +209,34 @@ class ReportSchedulePostSchema(Schema): dump_default=None, ) force_screenshot = fields.Boolean(dump_default=False) + custom_width = fields.Integer( + metadata={ + "description": _("Custom width of the screenshot in pixels"), + "example": 1000, + }, + allow_none=True, + required=False, + default=None, + ) + + @validates("custom_width") + def validate_custom_width(self, value: int) -> None: # pylint: disable=no-self-use + min_width = current_app.config["ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH"] + max_width = current_app.config["ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH"] + if not min_width <= value <= max_width: + raise ValidationError( + _( + "Screenshot width must be between %(min)spx and %(max)spx", + min=min_width, + max=max_width, + ) + ) @validates_schema def validate_report_references( # pylint: disable=unused-argument,no-self-use - self, data: dict[str, Any], **kwargs: Any + self, + data: dict[str, Any], + **kwargs: Any, ) -> None: if data["type"] == ReportScheduleType.REPORT: if "database" in data: @@ -307,3 +332,26 @@ class ReportSchedulePutSchema(Schema): ) extra = fields.Dict(dump_default=None) force_screenshot = fields.Boolean(dump_default=False) + + custom_width = fields.Integer( + metadata={ + "description": _("Custom width of the screenshot in pixels"), + "example": 1000, + }, + allow_none=True, + required=False, + default=None, + ) + + @validates("custom_width") + def validate_custom_width(self, value: int) -> None: # pylint: disable=no-self-use + min_width = current_app.config["ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH"] + max_width = current_app.config["ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH"] + if not min_width <= value <= max_width: + raise ValidationError( + _( + "Screenshot width must be between %(min)spx and %(max)spx", + min=min_width, + max=max_width, + ) + ) diff --git a/superset/utils/screenshots.py b/superset/utils/screenshots.py index 5c699e9e19..2743f85195 100644 --- a/superset/utils/screenshots.py +++ b/superset/utils/screenshots.py @@ -33,6 +33,12 @@ from superset.utils.webdriver import ( logger = logging.getLogger(__name__) +DEFAULT_SCREENSHOT_WINDOW_SIZE = 800, 600 +DEFAULT_SCREENSHOT_THUMBNAIL_SIZE = 400, 300 +DEFAULT_CHART_WINDOW_SIZE = DEFAULT_CHART_THUMBNAIL_SIZE = 800, 600 +DEFAULT_DASHBOARD_WINDOW_SIZE = 1600, 1200 +DEFAULT_DASHBOARD_THUMBNAIL_SIZE = 800, 600 + try: from PIL import Image except ModuleNotFoundError: @@ -47,8 +53,8 @@ class BaseScreenshot: driver_type = current_app.config["WEBDRIVER_TYPE"] thumbnail_type: str = "" element: str = "" - window_size: WindowSize = (800, 600) - thumb_size: WindowSize = (400, 300) + window_size: WindowSize = DEFAULT_SCREENSHOT_WINDOW_SIZE + thumb_size: WindowSize = DEFAULT_SCREENSHOT_THUMBNAIL_SIZE def __init__(self, url: str, digest: str): self.digest: str = digest @@ -216,8 +222,8 @@ class ChartScreenshot(BaseScreenshot): standalone=ChartStandaloneMode.HIDE_NAV.value, ) super().__init__(url, digest) - self.window_size = window_size or (800, 600) - self.thumb_size = thumb_size or (800, 600) + self.window_size = window_size or DEFAULT_CHART_WINDOW_SIZE + self.thumb_size = thumb_size or DEFAULT_CHART_THUMBNAIL_SIZE class DashboardScreenshot(BaseScreenshot): @@ -239,5 +245,5 @@ class DashboardScreenshot(BaseScreenshot): ) super().__init__(url, digest) - self.window_size = window_size or (1600, 1200) - self.thumb_size = thumb_size or (800, 600) + self.window_size = window_size or DEFAULT_DASHBOARD_WINDOW_SIZE + self.thumb_size = thumb_size or DEFAULT_DASHBOARD_THUMBNAIL_SIZE