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 { 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

View File

@ -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;
`;

View File

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

View File

@ -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 = {

View File

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

View File

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

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
# 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] "

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",
"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",

View File

@ -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:

View File

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

View File

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