diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index 010c0cbb9ae..fb3ec30d8c5 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -26,6 +26,7 @@ export enum FeatureFlag { AlertReports = 'ALERT_REPORTS', AlertReportTabs = 'ALERT_REPORT_TABS', AlertReportSlackV2 = 'ALERT_REPORT_SLACK_V2', + AlertReportsFilter = 'ALERT_REPORTS_FILTER', AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT', AvoidColorsCollision = 'AVOID_COLORS_COLLISION', ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL', diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx index dbd7eef47ce..ecffcc42df9 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx @@ -452,7 +452,7 @@ test('renders tab selection when Dashboard is selected', async () => { expect( screen.getByRole('combobox', { name: /dashboard/i }), ).toBeInTheDocument(); - expect(screen.getByText(/select tab/i)).toBeInTheDocument(); + expect(screen.getAllByText(/select tab/i)).toHaveLength(1); }); test('changes to content options when chart is selected', async () => { @@ -666,3 +666,15 @@ test('removes notification method on clicking trash can', async () => { screen.getAllByRole('combobox', { name: /delivery method/i }).length, ).toBe(1); }); + +test('renders dashboard filter dropdowns', async () => { + render(, { + useRedux: true, + }); + + userEvent.click(screen.getByTestId('contents-panel')); + const filterOptionDropdown = screen.getByRole('combobox', { + name: /select filter/i, + }); + expect(filterOptionDropdown).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx index 4b1d76dc2e5..cd923b64a39 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx @@ -39,11 +39,16 @@ import { } from '@superset-ui/core'; import rison from 'rison'; import { useSingleViewResource } from 'src/views/CRUD/hooks'; +import withToasts from 'src/components/MessageToasts/withToasts'; +import Owner from 'src/types/Owner'; +// import { Form as AntdForm } from 'src/components/Form'; +import { propertyComparator } from '@superset-ui/core/components/Select/utils'; import { AsyncSelect, Checkbox, Collapse, CollapseLabelInModal, + Form as AntdForm, InfoTooltip, Input, InputNumber, @@ -52,10 +57,8 @@ import { TreeSelect, type CheckboxChangeEvent, } from '@superset-ui/core/components'; + import TimezoneSelector from '@superset-ui/core/components/TimezoneSelector'; -import { propertyComparator } from '@superset-ui/core/components/Select/utils'; -import withToasts from 'src/components/MessageToasts/withToasts'; -import Owner from 'src/types/Owner'; import TextAreaControl from 'src/explore/components/controls/TextAreaControl'; import { useCommonConf } from 'src/features/databases/state'; import { @@ -75,9 +78,14 @@ import { TabNode, SelectValue, ContentType, + ExtraNativeFilter, + NativeFilterObject, } from 'src/features/alerts/types'; +import { StatusMessage } from 'src/filters/components/common'; import { useSelector } from 'react-redux'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { getChartDataRequest } from 'src/components/Chart/chartAction'; +import DateFilterControl from 'src/explore/components/controls/DateFilterControl'; import { Icons } from '@superset-ui/core/components/Icons'; import { StandardModal, ModalFormField } from 'src/components/Modal'; import NumberInput from './components/NumberInput'; @@ -92,6 +100,14 @@ const TEXT_BASED_VISUALIZATION_TYPES = [ VizType.PairedTTest, ]; +const StyledDivider = styled.span` + margin: 0 ${({ theme }) => theme.sizeUnit * 3}px; + color: ${({ theme }) => theme.colorSplit}; + font-weight: ${({ theme }) => theme.fontWeightStrong}; + font-size: ${({ theme }) => theme.fontSize}px; + align-content: center; +`; + export interface AlertReportModalProps { addSuccessToast: (msg: string) => void; addDangerToast: (msg: string) => void; @@ -274,6 +290,94 @@ export const StyledInputContainer = styled.div` textarea { flex: 1 1 auto; } + + input[disabled] { + color: ${theme.colorTextDisabled}; + } + + textarea { + height: 300px; + resize: none; + } + + input::placeholder, + textarea::placeholder { + color: ${theme.colorTextPlaceholder}; + } + + textarea, + input[type='text'], + input[type='number'] { + padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px; + border-style: none; + border: 1px solid ${theme.colorBorder}; + border-radius: ${theme.borderRadius}px; + + &[name='description'] { + flex: 1 1 auto; + } + } + + .input-label { + margin-left: 10px; + } + + .filters { + margin: ${theme.sizeUnit * 3}px 0; + + .filters-container { + display: flex; + margin: ${theme.sizeUnit * 2}px 0; + } + + .filters-dash-container { + display: flex; + flex-direction: column; + max-width: 174px; + flex: 1; + margin-right: ${theme.sizeUnit * 4}px; + + .control-label { + flex: 1; + margin-bottom: ${theme.sizeUnit * 2}px; + + .label-with-tooltip { + margin-right: ${theme.sizeUnit * 2}px; + } + } + } + + .filters-dash-select { + flex: 1; + } + + .filters-dashvalue-container { + display: flex; + flex-direction: column; + flex: 1; + } + + .filters-delete { + display: flex; + margin-top: ${theme.sizeUnit * 8}px; + margin-left: ${theme.sizeUnit * 4}px; + } + + .filters-trashcan { + width: ${theme.sizeUnit * 10}px; + display: 'flex'; + color: ${theme.colorIcon}; + } + .filters-add-container { + flex: '.25'; + padding: '${theme.sizeUnit * 3} 0'; + + .filters-add-btn { + padding: ${theme.sizeUnit * 2}px; + color: ${theme.colorWhite}; + } + } + } `} `; @@ -334,6 +438,8 @@ export const TRANSLATIONS = { ERROR_TOOLTIP_MESSAGE: t( 'Not all required fields are complete. Please provide the following:', ), + NATIVE_FILTER_COLUMN_ERROR_TEXT: t('Native filter column is required'), + NATIVE_FILTER_NO_VALUES_ERROR_TEXT: t('Native filter values has no values'), }; const NotificationMethodAdd: FunctionComponent = ({ @@ -400,6 +506,25 @@ const AlertReportModal: FunctionComponent = ({ const [dashboardOptions, setDashboardOptions] = useState([]); const [chartOptions, setChartOptions] = useState([]); const [tabOptions, setTabOptions] = useState([]); + const [nativeFilterOptions, setNativeFilterOptions] = useState< + { + value: string; + label: string; + }[] + >([]); + const [tabNativeFilters, setTabNativeFilters] = useState({}); + const [nativeFilterData, setNativeFilterData] = useState( + [ + { + nativeFilterId: null, + filterName: '', + filterType: '', + columnLabel: '', + columnName: '', + filterValues: [], + }, + ], + ); // Validation const [validationStatus, setValidationStatus] = useState({ @@ -452,6 +577,7 @@ const AlertReportModal: FunctionComponent = ({ const formatOptionEnabled = isFeatureEnabled(FeatureFlag.AlertsAttachReports) || isReport; const tabsEnabled = isFeatureEnabled(FeatureFlag.AlertReportTabs); + const filtersEnabled = isFeatureEnabled(FeatureFlag.AlertReportsFilter); const [notificationAddState, setNotificationAddState] = useState('active'); @@ -522,6 +648,116 @@ const AlertReportModal: FunctionComponent = ({ grace_period: undefined, }; + const fetchDashboardFilterValues = async ( + dashboardId: number | string | undefined, + columnName: string, + datasetId: number | string, + vizType = 'filter_select', + adhocFilters = [], + ) => { + if (vizType === 'filter_time') { + return; + } + + const filterValues = { + formData: { + datasource: `${datasetId}__table`, + groupby: [columnName], + metrics: ['count'], + row_limit: 1000, + showSearch: true, + viz_type: vizType, + type: 'NATIVE_FILTER', + dashboardId, + adhoc_filters: adhocFilters, + }, + force: false, + ownState: {}, + }; + + const data = await getChartDataRequest(filterValues).then(response => { + const rawData = response.json.result[0].data; + let filteredData = rawData; + + if (vizType === 'filter_timecolumn') { + // filter for time columns types + filteredData = rawData.filter((item: any) => item.dtype === 2); + } + + return filteredData.map((item: any) => { + if (vizType === 'filter_timegrain') { + return { + value: item.duration, + label: item.name, + }; + } + + if (vizType === 'filter_timecolumn') { + return { + value: item.column_name, + label: item.verbose_name || item.column_name, + }; + } + + return { + value: item[columnName], + label: item[columnName], + }; + }); + }); + + // eslint-disable-next-line consistent-return + return data; + }; + + const addNativeFilterOptions = (nativeFilters: NativeFilterObject[]) => { + nativeFilterData.map(nativeFilter => { + if (!nativeFilter.nativeFilterId) return; + const filter = nativeFilters.filter( + (f: any) => f.id === nativeFilter.nativeFilterId, + )[0]; + + const { datasetId } = filter.targets[0]; + const filterName = filter.name; + const columnName = filter.targets[0].column?.name || filterName; + const dashboardId = currentAlert?.dashboard?.value; + const { filterType } = filter; + + if (filterType === 'filter_time') { + return; + } + + // eslint-disable-next-line consistent-return + return fetchDashboardFilterValues( + dashboardId, + columnName, + datasetId, + filterType, + ).then(optionFilterValues => { + setNativeFilterData(prev => + prev.map(filter => + filter.nativeFilterId === nativeFilter.nativeFilterId + ? { + ...filter, + filterType, + filterName, + optionFilterValues, + } + : filter, + ), + ); + }); + }); + }; + + const filterNativeFilterOptions = () => + nativeFilterOptions.filter( + option => + !nativeFilterData.some( + filter => filter.nativeFilterId === option.value, + ), + ); + const updateNotificationSetting = ( index: number, setting: NotificationSetting, @@ -548,7 +784,6 @@ const AlertReportModal: FunctionComponent = ({ setNotificationSettings(settings); } }; - const removeNotificationSetting = (index: number) => { const settings = notificationSettings.slice(); @@ -611,6 +846,37 @@ const AlertReportModal: FunctionComponent = ({ const shouldEnableForceScreenshot = contentType === ContentType.Chart && !isReport; + + if (currentAlert?.extra?.dashboard) { + // Filter out empty native filters (where both filter name and values are empty/null) + const validNativeFilters = nativeFilterData.filter(filter => { + const hasFilterName = + filter.filterName && filter.filterName.trim() !== ''; + const hasFilterValues = + filter.filterValues && filter.filterValues.length > 0; + // Keep filter if it has either a name or values (or both) + return hasFilterName || hasFilterValues; + }); + + currentAlert.extra.dashboard.nativeFilters = validNativeFilters.map( + ({ + columnName, + columnLabel, + nativeFilterId, + filterValues, + filterType, + filterName, + }) => ({ + filterName, + filterType, + columnName, + columnLabel, + nativeFilterId, + filterValues, + }), + ); + } + const data: any = { ...currentAlert, type: isReport ? 'Report' : 'Alert', @@ -771,7 +1037,11 @@ const AlertReportModal: FunctionComponent = ({ endpoint: `/api/v1/dashboard/${dashboard.value}/tabs`, }) .then(response => { - const { tab_tree: tabTree, all_tabs: allTabs } = response.json.result; + const { + tab_tree: tabTree, + all_tabs: allTabs, + native_filters: nativeFilters, + } = response.json.result; const allTabsWithOrder = tabTree.map( (tab: { value: string }) => tab.value, ); @@ -786,11 +1056,32 @@ const AlertReportModal: FunctionComponent = ({ } setTabOptions(tabTree); + setTabNativeFilters(nativeFilters); + if (isEditMode && nativeFilters.all) { + // update options for all filters + addNativeFilterOptions(nativeFilters.all); + // Also set the available filter options for the add button + setNativeFilterOptions( + nativeFilters.all.map((filter: any) => ({ + value: filter.id, + label: filter.name, + })), + ); + } const anchor = currentAlert?.extra?.dashboard?.anchor; if (anchor) { try { const parsedAnchor = JSON.parse(anchor); + if (!Array.isArray(parsedAnchor)) { + // only show filters scoped to anchor + setNativeFilterOptions( + nativeFilters[anchor].map((filter: any) => ({ + value: filter.id, + label: filter.name, + })), + ); + } if (Array.isArray(parsedAnchor)) { // Check if all elements in parsedAnchor list are in allTabs const isValidSubset = parsedAnchor.every(tab => tab in allTabs); @@ -805,9 +1096,16 @@ const AlertReportModal: FunctionComponent = ({ updateAnchorState(undefined); } } + } else if (nativeFilters.all) { + setNativeFilterOptions( + nativeFilters.all.map((filter: any) => ({ + value: filter.id, + label: filter.name, + })), + ); } }) - .catch(() => { + .catch(e => { addDangerToast(t('There was an error retrieving dashboard tabs.')); }); } @@ -961,6 +1259,24 @@ const AlertReportModal: FunctionComponent = ({ } }; + const handleAddFilterField = () => { + setNativeFilterData([ + ...nativeFilterData, + { + nativeFilterId: null, + columnLabel: '', + columnName: '', + filterValues: [], + }, + ]); + }; + + const handleRemoveFilterField = (filterIdx: number) => { + const filters = nativeFilterData || []; + filters.splice(filterIdx, 1); + setNativeFilterData(filters); + }; + const onCustomWidthChange = (value: number | string | null | undefined) => { const numValue = value === null || @@ -1005,8 +1321,21 @@ const AlertReportModal: FunctionComponent = ({ updateAlertState('chart', null); if (tabsEnabled) { setTabOptions([]); + setNativeFilterOptions([]); updateAnchorState(''); } + if (filtersEnabled) { + setNativeFilterData([ + { + filterName: '', + filterType: '', + nativeFilterId: null, + columnLabel: '', + columnName: '', + filterValues: [], + }, + ]); + } }; const onChartChange = (chart: SelectValue) => { @@ -1062,6 +1391,164 @@ const AlertReportModal: FunctionComponent = ({ setForceScreenshot(e.target.checked); }; + const onChangeDashboardFilter = (idx: number, nativeFilterId: string) => { + if ( + !nativeFilterId || + nativeFilterId === 'undefined' || + nativeFilterId === 'null' + ) + return; + + // find specific filter tied to the selected filter + const filters = Object.values(tabNativeFilters).flat(); + const filter = filters.filter((f: any) => f.id === nativeFilterId)[0]; + + const { filterType, adhoc_filters: adhocFilters } = filter; + const filterAlreadyExist = nativeFilterData.some( + filter => filter.nativeFilterId === nativeFilterId, + ); + + if (filterAlreadyExist) { + addDangerToast(t('This filter already exist on the report')); + return; + } + + const filterName = filter.name; + + let columnName: string; + if ( + filterType === 'filter_time' || + filterType === 'filter_timecolumn' || + filterType === 'filter_timegrain' + ) { + columnName = filter.name; + } else { + columnName = filter.targets[0].column.name; + } + + const datasetId = filter.targets[0].datasetId || null; + + const columnLabel = nativeFilterOptions.filter( + filter => filter.value === nativeFilterId, + )[0].label; + const dashboardId = currentAlert?.dashboard?.value; + + // Get values tied to the selected filter + const filterValues = { + formData: { + datasource: `${datasetId}__table`, + groupby: [columnName], + metrics: ['count'], + row_limit: 1000, + showSearch: true, + viz_type: 'filter_select', + type: 'NATIVE_FILTER', + dashboardId, + adhoc_filters: adhocFilters, + }, + force: false, + ownState: {}, + }; + + // todo(hugh): put this into another function + if ( + filterType === 'filter_time' || + filterType === 'filter_timecolumn' || + filterType === 'filter_timegrain' + ) { + fetchDashboardFilterValues( + dashboardId, + columnName, + datasetId, + filterType, + adhocFilters, + ).then(optionFilterValues => { + setNativeFilterData( + nativeFilterData.map((filter, index) => + index === idx + ? { + ...filter, + filterName, + filterType, + nativeFilterId, + columnLabel, + columnName, + optionFilterValues, + filterValues: [], // reset filter values on filter change + } + : filter, + ), + ); + }); + + setNativeFilterData( + nativeFilterData.map((filter, index) => + index === idx + ? { + ...filter, + filterName, + filterType, + nativeFilterId, + columnLabel, + columnName, + optionFilterValues: [], + filterValues: [], // reset filter values on filter change + } + : filter, + ), + ); + return; + } + + getChartDataRequest(filterValues).then(response => { + const newFilterValues = response.json.result[0].data.map((item: any) => ({ + value: item[columnName], + label: item[columnName], + })); + + setNativeFilterData( + nativeFilterData.map((filter, index) => + index === idx + ? { + ...filter, + filterName, + filterType, + nativeFilterId, + columnLabel, + columnName, + optionFilterValues: newFilterValues, + filterValues: [], // reset filter values on filter change + } + : filter, + ), + ); + }); + }; + + const onChangeDashboardFilterValue = ( + idx: number, + filterValues: + | SelectValue + | SelectValue[] + | string + | string[] + | number + | number[], + ) => { + let values: any; + if (typeof filterValues === 'string') { + values = [filterValues]; + } else { + values = filterValues; + } + + setNativeFilterData( + nativeFilterData.map((filter, index) => + index === idx ? { ...filter, filterValues: values } : filter, + ), + ); + }; + // Make sure notification settings has the required info const checkNotificationSettings = () => { if (!notificationSettings.length) { @@ -1104,6 +1591,105 @@ const AlertReportModal: FunctionComponent = ({ }); }; + const renderFilterValueSelect = (filter: ExtraNativeFilter, idx: number) => { + if (!filter) return null; + const { filterType, filterValues } = filter; + let mode = 'multiple'; + if (filterType === 'filter_time') { + return ( + { + setNativeFilterData( + nativeFilterData.map((f: any) => + filter.nativeFilterId === f.nativeFilterId + ? { + ...f, + filterValues: [timeRange], + } + : f, + ), + ); + }} + value={filterValues?.[0]} // only showing first value in the array for filter_time + /> + ); + } + if (filterType === 'filter_range') { + const min = filterValues?.[0]; + const max = filterValues?.[1]; + return ( +
+
+ { + setNativeFilterData( + nativeFilterData.map((f: any) => + f.nativeFilterId === filter.nativeFilterId + ? { ...f, filterValues: [value, filterValues?.[1]] } + : f, + ), + ); + }} + /> + - + { + setNativeFilterData( + nativeFilterData.map((f: any) => + f.nativeFilterId === filter.nativeFilterId + ? { ...f, filterValues: [filterValues?.[0], value] } + : f, + ), + ); + }} + /> +
+ + {t('Enter minimum and maximum values for the range filter')} + +
+ ); + } + + if ( + filterType === 'filter_timegrain' || + filterType === 'filter_timecolumn' + ) { + mode = 'single'; + } + + return ( + + onChangeDashboardFilter( + idx, + String(value), + ) + } + onClear={() => { + // reset filter values on filter clear + nativeFilterData[idx].columnName = ''; + nativeFilterData[idx].filterName = ''; + nativeFilterData[idx].filterValues = []; + }} + css={css` + flex: 1; + `} + oneLine + allowClear + /> + +
+
+ {t('Value')} +
+ {renderFilterValueSelect( + nativeFilterData[idx], + idx, + )} +
+ {(idx !== 0 || isEditMode) && ( +
+ { + handleRemoveFilterField(idx); + remove(idx); + }} + /> +
+ )} + + ))} + + + )} + + + + )} {isScreenshot && ( ; dataMask?: Object; anchor?: string; + nativeFilters?: Array; +}; + +export type ExtraNativeFilter = { + filterName?: string; + filterType?: string; + columnName?: string; + columnLabel?: string; + filterValues?: Array | []; + nativeFilterId?: string | null; + optionFilterValues?: Array | []; }; export type Extra = { @@ -191,3 +202,36 @@ export enum ContentType { Dashboard = 'dashboard', Chart = 'chart', } + +export type NativeFilterObject = { + cascadeParentIds: any[]; + chartsInScope: number[]; + controlValues: { + defaultToFirstItem: boolean; + enableEmptyFilter: boolean; + inverseSelection: boolean; + multiSelect: boolean; + searchAllOptions: boolean; + }; + defaultDataMask: { + extraFormData: Record; + filterState: Record; + ownState: Record; + }; + description: string; + filterType: string; + id: string; + name: string; + scope: { + excluded: any[]; + rootPath: string[]; + }; + tabsInScope: string[]; + targets: Array<{ + column: { + name: string; + }; + datasetId: number; + }>; + type: string; +}; diff --git a/superset/commands/report/execute.py b/superset/commands/report/execute.py index 1b19738bab9..e51b9684dd5 100644 --- a/superset/commands/report/execute.py +++ b/superset/commands/report/execute.py @@ -245,16 +245,34 @@ class BaseReportState: Retrieve the URL for the dashboard tabs, or return the dashboard URL if no tabs are available. """ # noqa: E501 force = "true" if self._report_schedule.force_screenshot else "false" + if ( dashboard_state := self._report_schedule.extra.get("dashboard") ) and feature_flag_manager.is_feature_enabled("ALERT_REPORT_TABS"): + native_filter_params = self._report_schedule.get_native_filters_params() if anchor := dashboard_state.get("anchor"): try: anchor_list: list[str] = json.loads(anchor) - return self._get_tabs_urls(anchor_list, user_friendly=user_friendly) + urls = self._get_tabs_urls( + anchor_list, + native_filter_params=native_filter_params, + user_friendly=user_friendly, + ) + return urls except json.JSONDecodeError: logger.debug("Anchor value is not a list, Fall back to single tab") - return [self._get_tab_url(dashboard_state)] + + return [ + self._get_tab_url( + { + "urlParams": [ + ["native_filters", native_filter_params] # type: ignore + ], + **dashboard_state, + }, + user_friendly=user_friendly, + ) + ] dashboard = self._report_schedule.dashboard dashboard_id_or_slug = ( @@ -281,6 +299,7 @@ class BaseReportState: dashboard_id=str(self._report_schedule.dashboard.uuid), state=dashboard_state, ).run() + return get_url_path( "Superset.dashboard_permalink", key=permalink_key, @@ -288,7 +307,10 @@ class BaseReportState: ) def _get_tabs_urls( - self, tab_anchors: list[str], user_friendly: bool = False + self, + tab_anchors: list[str], + native_filter_params: Optional[str] = None, + user_friendly: bool = False, ) -> list[str]: """ Get multple tabs urls @@ -299,7 +321,9 @@ class BaseReportState: "anchor": tab_anchor, "dataMask": None, "activeTabs": None, - "urlParams": None, + "urlParams": [ + ["native_filters", native_filter_params] # type: ignore + ], }, user_friendly=user_friendly, ) @@ -338,7 +362,6 @@ class BaseReportState: ] else: urls = self.get_dashboard_urls() - window_width, window_height = app.config["WEBDRIVER_WINDOW"]["dashboard"] width = min(max_width, self._report_schedule.custom_width or window_width) height = self._report_schedule.custom_height or window_height @@ -500,6 +523,7 @@ class BaseReportState: error_text = None 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 diff --git a/superset/config.py b/superset/config.py index dea23c8b432..856b60401af 100644 --- a/superset/config.py +++ b/superset/config.py @@ -535,6 +535,7 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = { # Enables Alerts and reports new implementation "ALERT_REPORTS": False, "ALERT_REPORT_TABS": False, + "ALERT_REPORTS_FILTER": False, "ALERT_REPORT_SLACK_V2": False, "DASHBOARD_RBAC": False, "ENABLE_ADVANCED_DATA_TYPES": False, diff --git a/superset/daos/dashboard.py b/superset/daos/dashboard.py index c9c119c54b9..1d6f366ab42 100644 --- a/superset/daos/dashboard.py +++ b/superset/daos/dashboard.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +from collections import defaultdict from datetime import datetime from typing import Any @@ -320,6 +321,23 @@ class DashboardDAO(BaseDAO[Dashboard]): db.session.add(dash) return dash + @classmethod + def get_native_filter_configuration( + cls, id: str + ) -> dict[str, list[dict[str, Any]]]: + dashboard = cls.get_by_id_or_slug(id) + metadata = json.loads(dashboard.json_metadata or "{}") + native_filter_configuration = metadata.get("native_filter_configuration", []) + + tab_filters = defaultdict(list) + for filter in native_filter_configuration: + if tabs_in_scope := filter.get("tabsInScope", []): + for tab_key in tabs_in_scope: + tab_filters[tab_key].append(filter) + tab_filters["all"].append(filter) + + return tab_filters + @classmethod def update_native_filters_config( cls, diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index c811e5a868b..79ce952f25c 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -479,7 +479,11 @@ class DashboardRestApi(BaseSupersetModelRestApi): """ # noqa: E501 try: tabs = DashboardDAO.get_tabs_for_dashboard(id_or_slug) + native_filters = DashboardDAO.get_native_filter_configuration(id_or_slug) + result = self.tab_schema.dump(tabs) + result["native_filters"] = native_filters + return self.response(200, result=result) except (TypeError, ValueError) as err: diff --git a/superset/reports/models.py b/superset/reports/models.py index e4cdd7c9b4d..20d3389a0c5 100644 --- a/superset/reports/models.py +++ b/superset/reports/models.py @@ -16,6 +16,9 @@ # under the License. """A collection of ORM sqlalchemy models for Superset""" +from typing import Any, Optional + +import prison from cron_descriptor import get_description from flask_appbuilder import Model from flask_appbuilder.models.decorators import renders @@ -183,6 +186,117 @@ class ReportSchedule(AuditMixinNullable, ExtraJSONMixin, Model): def crontab_humanized(self) -> str: return get_description(self.crontab) + def get_native_filters_params(self) -> str: + params: dict[str, Any] = {} + dashboard = self.extra.get("dashboard") + if dashboard and dashboard.get("nativeFilters"): + for filter in dashboard.get("nativeFilters") or []: # type: ignore + params = { + **params, + **self._generate_native_filter( + filter["nativeFilterId"], + filter["filterType"], + filter["columnName"], + filter["filterValues"], + ), + } + # hack(hughhh): workaround for escaping prison not handling quotes right + rison = prison.dumps(params) + rison = rison.replace("'", "%27") + return rison + + def _generate_native_filter( + self, + native_filter_id: str, + filter_type: str, + column_name: str, + values: list[Optional[str]], + ) -> dict[str, Any]: + if filter_type == "filter_time": + # For select filters, we need to use the "IN" operator + return { + native_filter_id or "": { + "id": native_filter_id or "", + "extraFormData": {"time_range": values[0]}, + "filterState": {"value": values[0]}, + "ownState": {}, + } + } + elif filter_type == "filter_timegrain": + return { + native_filter_id or "": { + "id": native_filter_id or "", + "extraFormData": { + "time_grain_sqla": values[0], # grain + }, + "filterState": { + # "label": "30 second", # grain_label + "value": values # grain + }, + "ownState": {}, + } + } + + elif filter_type == "filter_timecolumn": + return { + native_filter_id or "": { + "extraFormData": { + "granularity_sqla": values[0] # column_name + }, + "filterState": { + "value": values # column_name + }, + } + } + + elif filter_type == "filter_select": + return { + native_filter_id or "": { + "id": native_filter_id or "", + "extraFormData": { + "filters": [ + {"col": column_name or "", "op": "IN", "val": values or []} + ] + }, + "filterState": { + "label": column_name or "", + "validateStatus": False, + "value": values or [], + }, + "ownState": {}, + } + } + elif filter_type == "filter_range": + # For range filters, values should be [min, max] or [value] for single value + min_val = values[0] if len(values) > 0 else None + max_val = values[1] if len(values) > 1 else None + + filters = [] + if min_val is not None: + filters.append({"col": column_name or "", "op": ">=", "val": min_val}) + if max_val is not None: + filters.append({"col": column_name or "", "op": "<=", "val": max_val}) + + return { + native_filter_id or "": { + "id": native_filter_id or "", + "extraFormData": {"filters": filters}, + "filterState": { + "value": [min_val, max_val], + "label": f"{min_val} ≤ x ≤ {max_val}" + if min_val and max_val + else f"x ≥ {min_val}" + if min_val + else f"x ≤ {max_val}" + if max_val + else "", + }, + "ownState": {}, + } + } + + return {} + class ReportRecipients(Model, AuditMixinNullable): """ diff --git a/superset/views/core.py b/superset/views/core.py index d0c8d296171..c4955942167 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -847,17 +847,24 @@ class Superset(BaseSupersetView): return redirect(url_for("DashboardModelView.list")) if not value: return json_error_response(_("permalink state not found"), status=404) + dashboard_id, state = value["dashboardId"], value.get("state", {}) url = url_for( "Superset.dashboard", dashboard_id_or_slug=dashboard_id, permalink_key=key ) if url_params := state.get("urlParams"): - params = parse.urlencode(url_params) - url = f"{url}&{params}" + for param_key, param_val in url_params: + if param_key == "native_filters": + # native_filters doesnt need to be encoded here + url = f"{url}&native_filters={param_val}" + else: + params = parse.urlencode([param_key, param_val]) # type: ignore + url = f"{url}&{params}" if original_params := request.query_string.decode(): url = f"{url}&{original_params}" if hash_ := state.get("anchor", state.get("hash")): url = f"{url}#{hash_}" + return redirect(url) @api diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py index a6551024eb9..0ac973a5605 100644 --- a/tests/integration_tests/dashboards/api_tests.py +++ b/tests/integration_tests/dashboards/api_tests.py @@ -1177,6 +1177,7 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas "TAB-hyTv5L7zz": "P1 - T2 - T2", "TAB-qL7fSzr3jl": "Parent Tab 1", }, + "native_filters": {}, "tab_tree": [ { "children": [ diff --git a/tests/integration_tests/reports/commands/execute_dashboard_report_tests.py b/tests/integration_tests/reports/commands/execute_dashboard_report_tests.py index 318fd4977b1..6c1b607279e 100644 --- a/tests/integration_tests/reports/commands/execute_dashboard_report_tests.py +++ b/tests/integration_tests/reports/commands/execute_dashboard_report_tests.py @@ -47,10 +47,12 @@ def test_report_for_dashboard_with_tabs( ) -> None: dashboard_screenshot_mock.get_screenshot.return_value = b"test-image" current_app.config["ALERT_REPORTS_NOTIFICATION_DRY_RUN"] = False - with create_dashboard_report( dashboard=tabbed_dashboard, - extra={"dashboard": {"active_tabs": ["TAB-L1B", "TAB-L2BB"]}}, + extra={ + "activeTabs": ["TAB-L1B", "TAB-L2BB"], + "urlParams": [["native_filters", "()"]], + }, name="test report tabbed dashboard", ) as report_schedule: dashboard: Dashboard = report_schedule.dashboard @@ -59,7 +61,7 @@ def test_report_for_dashboard_with_tabs( ).run() dashboard_state = report_schedule.extra.get("dashboard", {}) permalink_key = CreateDashboardPermalinkCommand( - str(dashboard.id), dashboard_state + str(dashboard.uuid), dashboard_state ).run() expected_url = get_url_path("Superset.dashboard_permalink", key=permalink_key) @@ -90,7 +92,10 @@ def test_report_with_header_data( with create_dashboard_report( dashboard=tabbed_dashboard, - extra={"dashboard": {"active_tabs": ["TAB-L1B", "TAB-L2BB"]}}, + extra={ + "active_tabs": ["TAB-L1B", "TAB-L2BB"], + "urlParams": [["native_filters", "()"]], + }, name="test report tabbed dashboard", ) as report_schedule: dashboard: Dashboard = report_schedule.dashboard diff --git a/tests/integration_tests/reports/commands_tests.py b/tests/integration_tests/reports/commands_tests.py index 63ff86c44d4..ae1c855d85a 100644 --- a/tests/integration_tests/reports/commands_tests.py +++ b/tests/integration_tests/reports/commands_tests.py @@ -1205,7 +1205,14 @@ def test_email_dashboard_report_schedule_with_tab_anchor( report_schedule = create_report_notification( email_target="target@email.com", dashboard=dashboard, - extra={"dashboard": {"anchor": "TAB-L2AB"}}, + extra={ + "dashboard": { + "anchor": "TAB-L2AB", + "activeTabs": None, + "dataMask": None, + "urlParams": [["native_filters", "()"]], + } + }, ) AsyncExecuteReportScheduleCommand( TEST_ID, report_schedule.id, datetime.utcnow() @@ -1254,7 +1261,14 @@ def test_email_dashboard_report_schedule_disabled_tabs( report_schedule = create_report_notification( email_target="target@email.com", dashboard=dashboard, - extra={"dashboard": {"anchor": "TAB-L2AB"}}, + extra={ + "dashboard": { + "anchor": "TAB-L2AB", + "activeTabs": None, + "dataMask": None, + "urlParams": [["native_filters", "()"]], + } + }, ) AsyncExecuteReportScheduleCommand( TEST_ID, report_schedule.id, datetime.utcnow() diff --git a/tests/unit_tests/reports/model_test.py b/tests/unit_tests/reports/model_test.py new file mode 100644 index 00000000000..19e12e14072 --- /dev/null +++ b/tests/unit_tests/reports/model_test.py @@ -0,0 +1,242 @@ +# 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 pytest + +from superset.reports.models import ReportSchedule + + +def test_get_native_filters_params(): + """ + Test the ``get_native_filters_params`` method. + """ + report_schedule = ReportSchedule() + report_schedule.extra = { + "dashboard": { + "nativeFilters": [ + { + "nativeFilterId": "filter_id", + "columnName": "column_name", + "filterType": "filter_select", + "filterValues": ["value1", "value2"], + } + ] + } + } + + assert report_schedule.get_native_filters_params() == ( + "(filter_id:(extraFormData:(filters:!((col:column_name,op:IN,val:!(value1,value2)))),filterState:(label:column_name,validateStatus:!f,value:!(value1,value2)),id:filter_id,ownState:()))" + ) + + +def test_get_native_filters_params_multiple_filters(): + """ + Test the ``get_native_filters_params`` method with multiple native filters. + """ + report_schedule = ReportSchedule() + report_schedule.extra = { + "dashboard": { + "nativeFilters": [ + { + "nativeFilterId": "filter_id_1", + "filterType": "filter_select", + "columnName": "column_name_1", + "filterValues": ["value1", "value2"], + }, + { + "nativeFilterId": "filter_id_2", + "filterType": "filter_select", + "columnName": "column_name_2", + "filterValues": ["value3", "value4"], + }, + ] + } + } + + assert report_schedule.get_native_filters_params() == ( + "(filter_id_1:(extraFormData:(filters:!((col:column_name_1,op:IN,val:!(value1,value2)))),filterState:(label:column_name_1,validateStatus:!f,value:!(value1,value2)),id:filter_id_1,ownState:()),filter_id_2:(extraFormData:(filters:!((col:column_name_2,op:IN,val:!(value3,value4)))),filterState:(label:column_name_2,validateStatus:!f,value:!(value3,value4)),id:filter_id_2,ownState:()))" + ) + + +def test_report_generate_native_filter_no_values(): + """ + Test the ``_generate_native_filter`` method with no values. + """ + report_schedule = ReportSchedule() + native_filter_id = "filter_id" + column_name = "column_name" + filter_type = "filter_select" + values = None + + assert report_schedule._generate_native_filter( + native_filter_id, filter_type, column_name, values + ) == { + "filter_id": { + "id": "filter_id", + "extraFormData": { + "filters": [{"col": "column_name", "op": "IN", "val": []}] + }, + "filterState": { + "label": "column_name", + "validateStatus": False, + "value": [], + }, + "ownState": {}, + } + } + + +def test_get_native_filters_params_invalid_structure(): + """ + Test the ``get_native_filters_params`` method with invalid structure. + """ + report_schedule = ReportSchedule() + report_schedule.extra = { + "dashboard": { + "nativeFilters": [ + { + "nativeFilterId": "filter_id", + "columnName": "column_name", + "filterType": "filter_select", + # Missing "filterValues" key + } + ] + } + } + + with pytest.raises(KeyError, match="'filterValues'"): + report_schedule.get_native_filters_params() + + +# todo(hugh): how do we want to handle this case? +# def test_report_generate_native_filter_invalid_filter_id(): +# """ +# Test the ``_generate_native_filter`` method with invalid filter id. +# """ +# report_schedule = ReportSchedule() +# native_filter_id = None +# column_name = "column_name" +# values = ["value1", "value2"] + +# assert report_schedule._generate_native_filter( +# native_filter_id, column_name, values +# ) == {} + + +def test_report_generate_native_filter(): + """ + Test the ``_generate_native_filter`` method. + """ + report_schedule = ReportSchedule() + native_filter_id = "filter_id" + filter_type = "filter_select" + column_name = "column_name" + values = ["value1", "value2"] + + assert report_schedule._generate_native_filter( + native_filter_id, filter_type, column_name, values + ) == { + "filter_id": { + "extraFormData": { + "filters": [ + {"col": "column_name", "op": "IN", "val": ["value1", "value2"]} + ] + }, + "filterState": { + "label": "column_name", + "validateStatus": False, + "value": ["value1", "value2"], + }, + "id": "filter_id", + "ownState": {}, + } + } + + +def test_get_native_filters_params_empty(): + """ + Test the ``get_native_filters_params`` method with empty extra. + """ + report_schedule = ReportSchedule() + report_schedule.extra = {} + + assert report_schedule.get_native_filters_params() == "()" + + +def test_get_native_filters_params_no_native_filters(): + """ + Test the ``get_native_filters_params`` method with no native filters. + """ + report_schedule = ReportSchedule() + report_schedule.extra = {"dashboard": {"nativeFilters": []}} + + assert report_schedule.get_native_filters_params() == "()" + + +def test_report_generate_native_filter_empty_values(): + """ + Test the ``_generate_native_filter`` method with empty values. + """ + report_schedule = ReportSchedule() + native_filter_id = "filter_id" + filter_type = "filter_select" + column_name = "column_name" + values = [] + + assert report_schedule._generate_native_filter( + native_filter_id, filter_type, column_name, values + ) == { + "filter_id": { + "extraFormData": { + "filters": [{"col": "column_name", "op": "IN", "val": []}] + }, + "filterState": { + "label": "column_name", + "validateStatus": False, + "value": [], + }, + "id": "filter_id", + "ownState": {}, + } + } + + +def test_report_generate_native_filter_no_column_name(): + """ + Test the ``_generate_native_filter`` method with no column name. + """ + report_schedule = ReportSchedule() + native_filter_id = "filter_id" + filter_type = "filter_select" + column_name = "" + values = ["value1", "value2"] + + assert report_schedule._generate_native_filter( + native_filter_id, filter_type, column_name, values + ) == { + "filter_id": { + "extraFormData": { + "filters": [{"col": "", "op": "IN", "val": ["value1", "value2"]}] + }, + "filterState": { + "label": "", + "validateStatus": False, + "value": ["value1", "value2"], + }, + "id": "filter_id", + "ownState": {}, + } + }