feat: customize screenshot width for alerts/reports (#24547)

This commit is contained in:
Beto Dealmeida 2023-06-29 14:32:58 -07:00 committed by GitHub
parent 8ba0b81957
commit be9eb0f3a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 193 additions and 18 deletions

View File

@ -33,6 +33,7 @@ import LabeledErrorBoundInput from 'src/components/Form/LabeledErrorBoundInput';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { CronError } from 'src/components/CronPicker'; import { CronError } from 'src/components/CronPicker';
import { RadioChangeEvent } from 'src/components'; import { RadioChangeEvent } from 'src/components';
import { Input } from 'src/components/Input';
import withToasts from 'src/components/MessageToasts/withToasts'; import withToasts from 'src/components/MessageToasts/withToasts';
import { ChartState } from 'src/explore/types'; import { ChartState } from 'src/explore/types';
import { import {
@ -41,9 +42,14 @@ import {
NOTIFICATION_FORMATS, NOTIFICATION_FORMATS,
} from 'src/reports/types'; } from 'src/reports/types';
import { reportSelector } from 'src/views/CRUD/hooks'; import { reportSelector } from 'src/views/CRUD/hooks';
import {
TRANSLATIONS,
StyledInputContainer,
} from 'src/features/alerts/AlertReportModal';
import { CreationMethod } from './HeaderReportDropdown'; import { CreationMethod } from './HeaderReportDropdown';
import { import {
antDErrorAlertStyles, antDErrorAlertStyles,
CustomWidthHeaderStyle,
StyledModal, StyledModal,
StyledTopSection, StyledTopSection,
StyledBottomSection, StyledBottomSection,
@ -170,6 +176,7 @@ function ReportModal({
type: 'Report', type: 'Report',
active: true, active: true,
force_screenshot: false, force_screenshot: false,
custom_width: currentReport.custom_width,
creation_method: creationMethod, creation_method: creationMethod,
dashboard: dashboardId, dashboard: dashboardId,
chart: chart?.id, chart: chart?.id,
@ -257,6 +264,26 @@ function ReportModal({
</div> </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 ( return (
<StyledModal <StyledModal
@ -331,6 +358,7 @@ function ReportModal({
}} }}
/> />
{isChart && renderMessageContentSection} {isChart && renderMessageContentSection}
{(!isChart || !isTextBasedChart) && renderCustomWidthSection}
</StyledBottomSection> </StyledBottomSection>
{currentReport.error && ( {currentReport.error && (
<Alert <Alert

View File

@ -90,6 +90,10 @@ export const TimezoneHeaderStyle = (theme: SupersetTheme) => css`
margin: ${theme.gridUnit * 3}px 0 ${theme.gridUnit * 2}px; 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` export const SectionHeaderStyle = (theme: SupersetTheme) => css`
margin: ${theme.gridUnit * 3}px 0; margin: ${theme.gridUnit * 3}px 0;
`; `;

View File

@ -35,6 +35,7 @@ import rison from 'rison';
import { useSingleViewResource } from 'src/views/CRUD/hooks'; import { useSingleViewResource } from 'src/views/CRUD/hooks';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { Input } from 'src/components/Input';
import { Switch } from 'src/components/Switch'; import { Switch } from 'src/components/Switch';
import Modal from 'src/components/Modal'; import Modal from 'src/components/Modal';
import TimezoneSelector from 'src/components/TimezoneSelector'; import TimezoneSelector from 'src/components/TimezoneSelector';
@ -46,6 +47,7 @@ import Owner from 'src/types/Owner';
import { AntdCheckbox, AsyncSelect, Select } from 'src/components'; import { AntdCheckbox, AsyncSelect, Select } from 'src/components';
import TextAreaControl from 'src/explore/components/controls/TextAreaControl'; import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
import { useCommonConf } from 'src/features/databases/state'; import { useCommonConf } from 'src/features/databases/state';
import { CustomWidthHeaderStyle } from 'src/components/ReportModal/styles';
import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
import { import {
NotificationMethodOption, NotificationMethodOption,
@ -370,7 +372,7 @@ interface NotificationMethodAddProps {
onClick: () => void; onClick: () => void;
} }
const TRANSLATIONS = { export const TRANSLATIONS = {
ADD_NOTIFICATION_METHOD_TEXT: t('Add notification method'), ADD_NOTIFICATION_METHOD_TEXT: t('Add notification method'),
ADD_DELIVERY_METHOD_TEXT: t('Add delivery method'), ADD_DELIVERY_METHOD_TEXT: t('Add delivery method'),
SAVE_TEXT: t('Save'), SAVE_TEXT: t('Save'),
@ -406,7 +408,9 @@ const TRANSLATIONS = {
SEND_AS_PNG_TEXT: t('Send as PNG'), SEND_AS_PNG_TEXT: t('Send as PNG'),
SEND_AS_CSV_TEXT: t('Send as CSV'), SEND_AS_CSV_TEXT: t('Send as CSV'),
SEND_AS_TEXT: t('Send as text'), 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'), NOTIFICATION_METHOD_TEXT: t('Notification method'),
}; };
@ -466,6 +470,14 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
); );
const [forceScreenshot, setForceScreenshot] = useState<boolean>(false); const [forceScreenshot, setForceScreenshot] = useState<boolean>(false);
const [isScreenshot, setIsScreenshot] = useState<boolean>(false);
useEffect(() => {
setIsScreenshot(
contentType === 'dashboard' ||
(contentType === 'chart' && reportFormat === 'PNG'),
);
}, [contentType, reportFormat]);
// Dropdown options // Dropdown options
const [conditionNotNull, setConditionNotNull] = useState<boolean>(false); const [conditionNotNull, setConditionNotNull] = useState<boolean>(false);
const [sourceOptions, setSourceOptions] = useState<MetaObject[]>([]); const [sourceOptions, setSourceOptions] = useState<MetaObject[]>([]);
@ -853,12 +865,15 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
}).then(response => setChartVizType(response.json.result.viz_type)); }).then(response => setChartVizType(response.json.result.viz_type));
// Handle input/textarea updates // Handle input/textarea updates
const onTextChange = ( const onInputChange = (
event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>, 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 = ( const onTimeoutVerifyChange = (
@ -1180,7 +1195,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
? TRANSLATIONS.REPORT_NAME_TEXT ? TRANSLATIONS.REPORT_NAME_TEXT
: TRANSLATIONS.ALERT_NAME_TEXT : TRANSLATIONS.ALERT_NAME_TEXT
} }
onChange={onTextChange} onChange={onInputChange}
css={inputSpacer} css={inputSpacer}
/> />
</div> </div>
@ -1216,7 +1231,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
name="description" name="description"
value={currentAlert ? currentAlert.description || '' : ''} value={currentAlert ? currentAlert.description || '' : ''}
placeholder={TRANSLATIONS.DESCRIPTION_TEXT} placeholder={TRANSLATIONS.DESCRIPTION_TEXT}
onChange={onTextChange} onChange={onInputChange}
css={inputSpacer} css={inputSpacer}
/> />
</div> </div>
@ -1471,6 +1486,24 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
</div> </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') && ( {(isReport || contentType === 'dashboard') && (
<div className="inline-container"> <div className="inline-container">
<StyledCheckbox <StyledCheckbox

View File

@ -68,10 +68,12 @@ export type AlertObject = {
created_by?: user; created_by?: user;
created_on?: string; created_on?: string;
crontab?: string; crontab?: string;
custom_width?: number | null;
dashboard?: MetaObject; dashboard?: MetaObject;
dashboard_id?: number; dashboard_id?: number;
database?: MetaObject; database?: MetaObject;
description?: string; description?: string;
error?: string;
force_screenshot: boolean; force_screenshot: boolean;
grace_period?: number; grace_period?: number;
id: number; id: number;
@ -91,7 +93,6 @@ export type AlertObject = {
}; };
validator_type?: string; validator_type?: string;
working_timeout?: number; working_timeout?: number;
error?: string;
}; };
export type LogObject = { export type LogObject = {

View File

@ -56,5 +56,6 @@ export interface ReportObject {
working_timeout: number; working_timeout: number;
creation_method: string; creation_method: string;
force_screenshot: boolean; force_screenshot: boolean;
custom_width?: number | null;
error?: string; error?: string;
} }

View File

@ -82,7 +82,7 @@ from superset.extensions import event_logger
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.tasks.thumbnails import cache_chart_thumbnail from superset.tasks.thumbnails import cache_chart_thumbnail
from superset.tasks.utils import get_current_user 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.utils.urls import get_url_path
from superset.views.base_api import ( from superset.views.base_api import (
BaseSupersetModelRestApi, BaseSupersetModelRestApi,
@ -573,7 +573,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
$ref: '#/components/responses/500' $ref: '#/components/responses/500'
""" """
rison_dict = kwargs["rison"] 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 # Don't shrink the image if thumb_size is not specified
thumb_size = rison_dict.get("thumb_size") or window_size thumb_size = rison_dict.get("thumb_size") or window_size

View File

@ -1273,6 +1273,9 @@ ALERT_REPORTS_NOTIFICATION_DRY_RUN = False
# Max tries to run queries to prevent false errors caused by transient errors # 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. # being returned to users. Set to a value >1 to enable retries.
ALERT_REPORTS_QUERY_EXECUTION_MAX_TRIES = 1 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 # A custom prefix to use on all Alerts & Reports emails
EMAIL_REPORTS_SUBJECT_PREFIX = "[Report] " EMAIL_REPORTS_SUBJECT_PREFIX = "[Report] "

View File

@ -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")

View File

@ -93,6 +93,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
"context_markdown", "context_markdown",
"creation_method", "creation_method",
"crontab", "crontab",
"custom_width",
"dashboard.dashboard_title", "dashboard.dashboard_title",
"dashboard.id", "dashboard.id",
"database.database_name", "database.database_name",
@ -159,6 +160,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
"context_markdown", "context_markdown",
"creation_method", "creation_method",
"crontab", "crontab",
"custom_width",
"dashboard", "dashboard",
"database", "database",
"description", "description",

View File

@ -154,6 +154,9 @@ class ReportSchedule(Model, AuditMixinNullable, ExtraJSONMixin):
# (Reports) When generating a screenshot, bypass the cache? # (Reports) When generating a screenshot, bypass the cache?
force_screenshot = Column(Boolean, default=False) force_screenshot = Column(Boolean, default=False)
custom_width = Column(Integer, nullable=True)
custom_height = Column(Integer, nullable=True)
extra: ReportScheduleExtra # type: ignore extra: ReportScheduleExtra # type: ignore
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@ -17,8 +17,9 @@
from typing import Any, Union from typing import Any, Union
from croniter import croniter from croniter import croniter
from flask import current_app
from flask_babel import gettext as _ 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 marshmallow.validate import Length, Range, ValidationError
from pytz import all_timezones from pytz import all_timezones
@ -208,10 +209,34 @@ class ReportSchedulePostSchema(Schema):
dump_default=None, dump_default=None,
) )
force_screenshot = fields.Boolean(dump_default=False) 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 @validates_schema
def validate_report_references( # pylint: disable=unused-argument,no-self-use 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: ) -> None:
if data["type"] == ReportScheduleType.REPORT: if data["type"] == ReportScheduleType.REPORT:
if "database" in data: if "database" in data:
@ -307,3 +332,26 @@ class ReportSchedulePutSchema(Schema):
) )
extra = fields.Dict(dump_default=None) extra = fields.Dict(dump_default=None)
force_screenshot = fields.Boolean(dump_default=False) 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,
)
)

View File

@ -33,6 +33,12 @@ from superset.utils.webdriver import (
logger = logging.getLogger(__name__) 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: try:
from PIL import Image from PIL import Image
except ModuleNotFoundError: except ModuleNotFoundError:
@ -47,8 +53,8 @@ class BaseScreenshot:
driver_type = current_app.config["WEBDRIVER_TYPE"] driver_type = current_app.config["WEBDRIVER_TYPE"]
thumbnail_type: str = "" thumbnail_type: str = ""
element: str = "" element: str = ""
window_size: WindowSize = (800, 600) window_size: WindowSize = DEFAULT_SCREENSHOT_WINDOW_SIZE
thumb_size: WindowSize = (400, 300) thumb_size: WindowSize = DEFAULT_SCREENSHOT_THUMBNAIL_SIZE
def __init__(self, url: str, digest: str): def __init__(self, url: str, digest: str):
self.digest: str = digest self.digest: str = digest
@ -216,8 +222,8 @@ class ChartScreenshot(BaseScreenshot):
standalone=ChartStandaloneMode.HIDE_NAV.value, standalone=ChartStandaloneMode.HIDE_NAV.value,
) )
super().__init__(url, digest) super().__init__(url, digest)
self.window_size = window_size or (800, 600) self.window_size = window_size or DEFAULT_CHART_WINDOW_SIZE
self.thumb_size = thumb_size or (800, 600) self.thumb_size = thumb_size or DEFAULT_CHART_THUMBNAIL_SIZE
class DashboardScreenshot(BaseScreenshot): class DashboardScreenshot(BaseScreenshot):
@ -239,5 +245,5 @@ class DashboardScreenshot(BaseScreenshot):
) )
super().__init__(url, digest) super().__init__(url, digest)
self.window_size = window_size or (1600, 1200) self.window_size = window_size or DEFAULT_DASHBOARD_WINDOW_SIZE
self.thumb_size = thumb_size or (800, 600) self.thumb_size = thumb_size or DEFAULT_DASHBOARD_THUMBNAIL_SIZE