diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx index f526b465c1edb..d015f21b9d7d2 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx @@ -506,6 +506,31 @@ test('does not show screenshot width when csv is selected', async () => { expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument(); }); +test('properly renders remove index checkbox for CSV reports', async () => { + render(, { + useRedux: true, + }); + userEvent.click(screen.getByTestId('contents-panel')); + await screen.findByText(/test dashboard/i); + const contentTypeSelector = screen.getByRole('combobox', { + name: /select content type/i, + }); + await comboboxSelect(contentTypeSelector, 'Chart', () => + screen.getByRole('combobox', { name: /chart/i }), + ); + const reportFormatSelector = screen.getByRole('combobox', { + name: /select format/i, + }); + await comboboxSelect( + reportFormatSelector, + 'CSV', + () => screen.getAllByText(/Send as CSV/i)[0], + ); + expect( + screen.getByRole('checkbox', { name: /remove index column/i }), + ).toBeInTheDocument(); +}); + // Schedule Section test('opens Schedule Section on click', async () => { render(, { diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx index f230a02b52fbc..d89a2de8cda00 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx @@ -442,6 +442,7 @@ const AlertReportModal: FunctionComponent = ({ DEFAULT_NOTIFICATION_FORMAT, ); const [forceScreenshot, setForceScreenshot] = useState(false); + const [removeIndex, setRemoveIndex] = useState(false); const [isScreenshot, setIsScreenshot] = useState(false); useEffect(() => { @@ -669,6 +670,7 @@ const AlertReportModal: FunctionComponent = ({ ...currentAlert, type: isReport ? 'Report' : 'Alert', force_screenshot: shouldEnableForceScreenshot || forceScreenshot, + remove_index: removeIndex, validator_type: conditionNotNull ? 'not null' : 'operator', validator_config_json: conditionNotNull ? {} @@ -1119,6 +1121,10 @@ const AlertReportModal: FunctionComponent = ({ setForceScreenshot(event.target.checked); }; + const onRemoveIndexChange = (event: any) => { + setRemoveIndex(event.target.checked); + }; + // Make sure notification settings has the required info const checkNotificationSettings = () => { if (!notificationSettings.length) { @@ -1810,6 +1816,20 @@ const AlertReportModal: FunctionComponent = ({ )} + {isReport && + contentType === ContentType.Chart && + reportFormat === 'CSV' && ( +
+ + {t('Remove index column')} + +
+ )} bytes: def _get_csv_data(self) -> bytes: url = self._get_url(result_format=ChartDataResultFormat.CSV) + if self._report_schedule.remove_index: + url = remove_post_processed(url) _, username = get_executor( executors=app.config["ALERT_REPORTS_EXECUTORS"], model=self._report_schedule, diff --git a/superset/commands/report/utils.py b/superset/commands/report/utils.py new file mode 100644 index 0000000000000..779ac8efb8297 --- /dev/null +++ b/superset/commands/report/utils.py @@ -0,0 +1,32 @@ +# 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. + + +def remove_post_processed(url: str) -> str: + """Remove the type=post_processed parameter from the URL query string. + Args: + url (str): The URL to process. + Returns: + str: The URL with the type=post_processed parameter removed.""" + if "?" not in url: + return url + base_url, query_string = url.split("?", 1) + params = query_string.split("&") + filtered_params = [param for param in params if param != "type=post_processed"] + filtered_query_string = "&".join(filtered_params) + filtered_url = f"{base_url}?{filtered_query_string}" + return filtered_url diff --git a/superset/migrations/versions/2025-02-24_17-52_3b86f563edbc_add_remove_index_to_report_schedule.py b/superset/migrations/versions/2025-02-24_17-52_3b86f563edbc_add_remove_index_to_report_schedule.py new file mode 100644 index 0000000000000..270716ab42a32 --- /dev/null +++ b/superset/migrations/versions/2025-02-24_17-52_3b86f563edbc_add_remove_index_to_report_schedule.py @@ -0,0 +1,41 @@ +# 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_remove_index_to_report_schedule + +Revision ID: 3b86f563edbc +Revises: 74ad1125881c +Create Date: 2025-02-24 17:52:02.369467 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "3b86f563edbc" +down_revision = "74ad1125881c" + + +def upgrade(): + op.add_column( + "report_schedule", + sa.Column("remove_index", sa.Boolean(), default=False), + ) + + +def downgrade(): + op.drop_column("report_schedule", "remove_index") diff --git a/superset/reports/api.py b/superset/reports/api.py index 320eb97c05133..1cdee1e5986f3 100644 --- a/superset/reports/api.py +++ b/superset/reports/api.py @@ -116,6 +116,7 @@ def ensure_alert_reports_enabled(self) -> Optional[Response]: "recipients.id", "recipients.recipient_config_json", "recipients.type", + "remove_index", "report_format", "sql", "timezone", @@ -175,6 +176,7 @@ def ensure_alert_reports_enabled(self) -> Optional[Response]: "owners", "recipients", "report_format", + "remove_index", "sql", "timezone", "type", diff --git a/superset/reports/models.py b/superset/reports/models.py index e4cdd7c9b4d0e..cb51fc9a74e15 100644 --- a/superset/reports/models.py +++ b/superset/reports/models.py @@ -169,6 +169,9 @@ class ReportSchedule(AuditMixinNullable, ExtraJSONMixin, Model): # (Reports) When generating a screenshot, bypass the cache? force_screenshot = Column(Boolean, default=False) + # (Reports) Remove index column in report + remove_index = Column(Boolean, default=False) + custom_width = Column(Integer, nullable=True) custom_height = Column(Integer, nullable=True) diff --git a/superset/reports/schemas.py b/superset/reports/schemas.py index 7078970b3815b..574fe5bd1c1d4 100644 --- a/superset/reports/schemas.py +++ b/superset/reports/schemas.py @@ -233,6 +233,13 @@ class ReportSchedulePostSchema(Schema): dump_default=None, ) force_screenshot = fields.Boolean(dump_default=False) + remove_index = fields.Boolean( + metadata={ + "description": _("Remove index column in report"), + "example": False, + }, + dump_default=False, + ) custom_width = fields.Integer( metadata={ "description": _("Custom width of the screenshot in pixels"), @@ -370,7 +377,14 @@ class ReportSchedulePutSchema(Schema): ) extra = fields.Dict(dump_default=None) force_screenshot = fields.Boolean(dump_default=False) - + remove_index = fields.Boolean( + metadata={ + "description": _("Remove index column in report"), + "example": False, + }, + dump_default=False, + required=False, + ) custom_width = fields.Integer( metadata={ "description": _("Custom width of the screenshot in pixels"), diff --git a/tests/unit_tests/commands/report/test_report_utils.py b/tests/unit_tests/commands/report/test_report_utils.py new file mode 100644 index 0000000000000..1313af047ca77 --- /dev/null +++ b/tests/unit_tests/commands/report/test_report_utils.py @@ -0,0 +1,48 @@ +# 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. + +from superset.commands.report.utils import remove_post_processed + + +def test_remove_post_processed(): + url = "https://superset.com/?param1=value1&type=post_processed¶m2=value2" + expected = "https://superset.com/?param1=value1¶m2=value2" + assert remove_post_processed(url) == expected + + +def test_retain_other_parameters(): + url = "https://superset.com/?param1=value1¶m2=value2" + expected = "https://superset.com/?param1=value1¶m2=value2" + assert remove_post_processed(url) == expected + + +def test_no_post_processed_present(): + url = "https://superset.com/?param1=value1¶m2=value2" + expected = "https://superset.com/?param1=value1¶m2=value2" + assert remove_post_processed(url) == expected + + +def test_empty_query_string(): + url = "https://superset.com/?" + expected = "https://superset.com/?" + assert remove_post_processed(url) == expected + + +def test_no_query_string(): + url = "https://superset.com" + expected = "https://superset.com" + assert remove_post_processed(url) == expected