You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by be...@apache.org on 2023/06/29 21:33:06 UTC

[superset] branch master updated: feat: customize screenshot width for alerts/reports (#24547)

This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new be9eb0f3a3 feat: customize screenshot width for alerts/reports (#24547)
be9eb0f3a3 is described below

commit be9eb0f3a3c2d33ab6a1794ff36a4ee3f6b3a28b
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Jun 29 14:32:58 2023 -0700

    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 ++++++++++++++++---
 superset-frontend/src/features/alerts/types.ts     |  3 +-
 superset-frontend/src/reports/types.ts             |  1 +
 superset/charts/api.py                             |  4 +-
 superset/config.py                                 |  3 ++
 ...5b0fb85b9a_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(-)

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({
       </div>
     </>
   );
+  const renderCustomWidthSection = (
+    <StyledInputContainer>
+      <div className="control-label" css={CustomWidthHeaderStyle}>
+        {TRANSLATIONS.CUSTOM_SCREENSHOT_WIDTH_TEXT}
+      </div>
+      <div className="input-container">
+        <Input
+          type="number"
+          name="custom_width"
+          value={currentReport?.custom_width || ''}
+          placeholder={TRANSLATIONS.CUSTOM_SCREENSHOT_WIDTH_PLACEHOLDER_TEXT}
+          onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+            setCurrentReport({
+              custom_width: parseInt(event.target.value, 10) || null,
+            });
+          }}
+        />
+      </div>
+    </StyledInputContainer>
+  );
 
   return (
     <StyledModal
@@ -331,6 +358,7 @@ function ReportModal({
           }}
         />
         {isChart && renderMessageContentSection}
+        {(!isChart || !isTextBasedChart) && renderCustomWidthSection}
       </StyledBottomSection>
       {currentReport.error && (
         <Alert
diff --git a/superset-frontend/src/components/ReportModal/styles.tsx b/superset-frontend/src/components/ReportModal/styles.tsx
index 960da9b10e..dd0a410ef5 100644
--- a/superset-frontend/src/components/ReportModal/styles.tsx
+++ b/superset-frontend/src/components/ReportModal/styles.tsx
@@ -90,6 +90,10 @@ export const TimezoneHeaderStyle = (theme: SupersetTheme) => 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<AlertReportModalProps> = ({
   );
   const [forceScreenshot, setForceScreenshot] = useState<boolean>(false);
 
+  const [isScreenshot, setIsScreenshot] = useState<boolean>(false);
+  useEffect(() => {
+    setIsScreenshot(
+      contentType === 'dashboard' ||
+        (contentType === 'chart' && reportFormat === 'PNG'),
+    );
+  }, [contentType, reportFormat]);
+
   // Dropdown options
   const [conditionNotNull, setConditionNotNull] = useState<boolean>(false);
   const [sourceOptions, setSourceOptions] = useState<MetaObject[]>([]);
@@ -853,12 +865,15 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
     }).then(response => setChartVizType(response.json.result.viz_type));
 
   // Handle input/textarea updates
-  const onTextChange = (
+  const onInputChange = (
     event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>,
   ) => {
-    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<AlertReportModalProps> = ({
                     ? TRANSLATIONS.REPORT_NAME_TEXT
                     : TRANSLATIONS.ALERT_NAME_TEXT
                 }
-                onChange={onTextChange}
+                onChange={onInputChange}
                 css={inputSpacer}
               />
             </div>
@@ -1216,7 +1231,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
                 name="description"
                 value={currentAlert ? currentAlert.description || '' : ''}
                 placeholder={TRANSLATIONS.DESCRIPTION_TEXT}
-                onChange={onTextChange}
+                onChange={onInputChange}
                 css={inputSpacer}
               />
             </div>
@@ -1471,6 +1486,24 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
                 </div>
               </>
             )}
+            {isScreenshot && (
+              <StyledInputContainer>
+                <div className="control-label" css={CustomWidthHeaderStyle}>
+                  {TRANSLATIONS.CUSTOM_SCREENSHOT_WIDTH_TEXT}
+                </div>
+                <div className="input-container">
+                  <Input
+                    type="number"
+                    name="custom_width"
+                    value={currentAlert?.custom_width || ''}
+                    placeholder={
+                      TRANSLATIONS.CUSTOM_SCREENSHOT_WIDTH_PLACEHOLDER_TEXT
+                    }
+                    onChange={onInputChange}
+                  />
+                </div>
+              </StyledInputContainer>
+            )}
             {(isReport || contentType === 'dashboard') && (
               <div className="inline-container">
                 <StyledCheckbox
diff --git a/superset-frontend/src/features/alerts/types.ts b/superset-frontend/src/features/alerts/types.ts
index 36d2b1d35a..34eb7fd261 100644
--- a/superset-frontend/src/features/alerts/types.ts
+++ b/superset-frontend/src/features/alerts/types.ts
@@ -68,10 +68,12 @@ export type AlertObject = {
   created_by?: user;
   created_on?: string;
   crontab?: string;
+  custom_width?: number | null;
   dashboard?: MetaObject;
   dashboard_id?: number;
   database?: MetaObject;
   description?: string;
+  error?: string;
   force_screenshot: boolean;
   grace_period?: number;
   id: number;
@@ -91,7 +93,6 @@ export type AlertObject = {
   };
   validator_type?: string;
   working_timeout?: number;
-  error?: string;
 };
 
 export type LogObject = {
diff --git a/superset-frontend/src/reports/types.ts b/superset-frontend/src/reports/types.ts
index 38cb3865cf..b67a7bac7b 100644
--- a/superset-frontend/src/reports/types.ts
+++ b/superset-frontend/src/reports/types.ts
@@ -56,5 +56,6 @@ export interface ReportObject {
   working_timeout: number;
   creation_method: string;
   force_screenshot: boolean;
+  custom_width?: number | null;
   error?: string;
 }
diff --git a/superset/charts/api.py b/superset/charts/api.py
index c87b7bdda8..e8ccfa0fff 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -82,7 +82,7 @@ from superset.extensions import event_logger
 from superset.models.slice import Slice
 from superset.tasks.thumbnails import cache_chart_thumbnail
 from superset.tasks.utils import get_current_user
-from superset.utils.screenshots import ChartScreenshot
+from superset.utils.screenshots import ChartScreenshot, DEFAULT_CHART_WINDOW_SIZE
 from superset.utils.urls import get_url_path
 from superset.views.base_api import (
     BaseSupersetModelRestApi,
@@ -573,7 +573,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
               $ref: '#/components/responses/500'
         """
         rison_dict = kwargs["rison"]
-        window_size = rison_dict.get("window_size") or (800, 600)
+        window_size = rison_dict.get("window_size") or DEFAULT_CHART_WINDOW_SIZE
 
         # Don't shrink the image if thumb_size is not specified
         thumb_size = rison_dict.get("thumb_size") or window_size
diff --git a/superset/config.py b/superset/config.py
index e781862395..0f9fafb68e 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1273,6 +1273,9 @@ ALERT_REPORTS_NOTIFICATION_DRY_RUN = False
 # Max tries to run queries to prevent false errors caused by transient errors
 # being returned to users. Set to a value >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