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

elizabeth 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 29e3f4bcc4 feat: allow exporting all tabs to a single PDF in report 
(#30694)
29e3f4bcc4 is described below

commit 29e3f4bcc4842ff19b6e6e420a09696b79341af0
Author: Steven Liu <[email protected]>
AuthorDate: Mon Nov 4 12:39:09 2024 +1100

    feat: allow exporting all tabs to a single PDF in report (#30694)
---
 .../src/features/alerts/AlertReportModal.tsx       |  25 +-
 superset/commands/report/create.py                 |  13 +-
 superset/commands/report/execute.py                | 108 +++-
 .../fixtures/dashboard_with_tabs.py                | 651 +++++++++++++++++++++
 tests/integration_tests/reports/api_tests.py       |  79 +++
 tests/unit_tests/commands/report/execute_test.py   | 145 +++++
 6 files changed, 995 insertions(+), 26 deletions(-)

diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx 
b/superset-frontend/src/features/alerts/AlertReportModal.tsx
index e136675e35..665d1af862 100644
--- a/superset-frontend/src/features/alerts/AlertReportModal.tsx
+++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx
@@ -823,10 +823,31 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
       })
         .then(response => {
           const { tab_tree: tabTree, all_tabs: allTabs } = 
response.json.result;
+          tabTree.push({
+            title: 'All Tabs',
+            // select tree only works with string value
+            value: JSON.stringify(Object.keys(allTabs)),
+          });
           setTabOptions(tabTree);
+
           const anchor = currentAlert?.extra?.dashboard?.anchor;
-          if (anchor && !(anchor in allTabs)) {
-            updateAnchorState(undefined);
+          if (anchor) {
+            try {
+              const parsedAnchor = JSON.parse(anchor);
+              if (Array.isArray(parsedAnchor)) {
+                // Check if all elements in parsedAnchor list are in allTabs
+                const isValidSubset = parsedAnchor.every(tab => tab in 
allTabs);
+                if (!isValidSubset) {
+                  updateAnchorState(undefined);
+                }
+              } else {
+                throw new Error('Parsed value is not an array');
+              }
+            } catch (error) {
+              if (!(anchor in allTabs)) {
+                updateAnchorState(undefined);
+              }
+            }
           }
         })
         .catch(() => {
diff --git a/superset/commands/report/create.py 
b/superset/commands/report/create.py
index 2a67f64002..9191e5a17b 100644
--- a/superset/commands/report/create.py
+++ b/superset/commands/report/create.py
@@ -143,10 +143,17 @@ class CreateReportScheduleCommand(CreateMixin, 
BaseReportScheduleCommand):
 
         position_data = json.loads(dashboard.position_json or "{}")
         active_tabs = dashboard_state.get("activeTabs") or []
-        anchor = dashboard_state.get("anchor")
         invalid_tab_ids = set(active_tabs) - set(position_data.keys())
-        if anchor and anchor not in position_data:
-            invalid_tab_ids.add(anchor)
+
+        if anchor := dashboard_state.get("anchor"):
+            try:
+                anchor_list: list[str] = json.loads(anchor)
+                if _invalid_tab_ids := set(anchor_list) - 
set(position_data.keys()):
+                    invalid_tab_ids.update(_invalid_tab_ids)
+            except json.JSONDecodeError:
+                if anchor not in position_data:
+                    invalid_tab_ids.add(anchor)
+
         if invalid_tab_ids:
             exceptions.append(
                 ValidationError(
diff --git a/superset/commands/report/execute.py 
b/superset/commands/report/execute.py
index afc488df56..c81750daba 100644
--- a/superset/commands/report/execute.py
+++ b/superset/commands/report/execute.py
@@ -49,6 +49,7 @@ from superset.daos.report import (
     REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER,
     ReportScheduleDAO,
 )
+from superset.dashboards.permalink.types import DashboardPermalinkState
 from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
 from superset.exceptions import SupersetErrorsException, SupersetException
 from superset.extensions import feature_flag_manager, 
machine_auth_provider_factory
@@ -206,11 +207,8 @@ class BaseReportState:
         if (
             dashboard_state := self._report_schedule.extra.get("dashboard")
         ) and feature_flag_manager.is_feature_enabled("ALERT_REPORT_TABS"):
-            permalink_key = CreateDashboardPermalinkCommand(
-                dashboard_id=str(self._report_schedule.dashboard.uuid),
-                state=dashboard_state,
-            ).run()
-            return get_url_path("Superset.dashboard_permalink", 
key=permalink_key)
+            return self._get_tab_url(dashboard_state)
+
         dashboard = self._report_schedule.dashboard
         dashboard_id_or_slug = (
             dashboard.uuid if dashboard and dashboard.uuid else dashboard.id
@@ -223,12 +221,70 @@ class BaseReportState:
             **kwargs,
         )
 
+    def get_dashboard_urls(
+        self, user_friendly: bool = False, **kwargs: Any
+    ) -> list[str]:
+        """
+        Retrieve the URL for the dashboard tabs, or return the dashboard URL 
if no tabs are available.
+        """
+        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"):
+            if anchor := dashboard_state.get("anchor"):
+                try:
+                    anchor_list: list[str] = json.loads(anchor)
+                    return self._get_tabs_urls(anchor_list)
+                except json.JSONDecodeError:
+                    logger.debug("Anchor value is not a list, Fall back to 
single tab")
+            return [self._get_tab_url(dashboard_state)]
+
+        dashboard = self._report_schedule.dashboard
+        dashboard_id_or_slug = (
+            dashboard.uuid if dashboard and dashboard.uuid else dashboard.id
+        )
+
+        return [
+            get_url_path(
+                "Superset.dashboard",
+                user_friendly=user_friendly,
+                dashboard_id_or_slug=dashboard_id_or_slug,
+                force=force,
+                **kwargs,
+            )
+        ]
+
+    def _get_tab_url(self, dashboard_state: DashboardPermalinkState) -> str:
+        """
+        Get one tab url
+        """
+        permalink_key = CreateDashboardPermalinkCommand(
+            dashboard_id=str(self._report_schedule.dashboard.uuid),
+            state=dashboard_state,
+        ).run()
+        return get_url_path("Superset.dashboard_permalink", key=permalink_key)
+
+    def _get_tabs_urls(self, tab_anchors: list[str]) -> list[str]:
+        """
+        Get multple tabs urls
+        """
+        return [
+            self._get_tab_url(
+                {
+                    "anchor": tab_anchor,
+                    "dataMask": None,
+                    "activeTabs": None,
+                    "urlParams": None,
+                }
+            )
+            for tab_anchor in tab_anchors
+        ]
+
     def _get_screenshots(self) -> list[bytes]:
         """
         Get chart or dashboard screenshots
         :raises: ReportScheduleScreenshotFailedError
         """
-        url = self._get_url()
         _, username = get_executor(
             executor_types=app.config["ALERT_REPORTS_EXECUTE_AS"],
             model=self._report_schedule,
@@ -236,31 +292,41 @@ class BaseReportState:
         user = security_manager.find_user(username)
 
         if self._report_schedule.chart:
+            url = self._get_url()
             window_width, window_height = 
app.config["WEBDRIVER_WINDOW"]["slice"]
             window_size = (
                 self._report_schedule.custom_width or window_width,
                 self._report_schedule.custom_height or window_height,
             )
-            screenshot: Union[ChartScreenshot, DashboardScreenshot] = 
ChartScreenshot(
-                url,
-                self._report_schedule.chart.digest,
-                window_size=window_size,
-                thumb_size=app.config["WEBDRIVER_WINDOW"]["slice"],
-            )
+            screenshots: list[Union[ChartScreenshot, DashboardScreenshot]] = [
+                ChartScreenshot(
+                    url,
+                    self._report_schedule.chart.digest,
+                    window_size=window_size,
+                    thumb_size=app.config["WEBDRIVER_WINDOW"]["slice"],
+                )
+            ]
         else:
+            urls = self.get_dashboard_urls()
             window_width, window_height = 
app.config["WEBDRIVER_WINDOW"]["dashboard"]
             window_size = (
                 self._report_schedule.custom_width or window_width,
                 self._report_schedule.custom_height or window_height,
             )
-            screenshot = DashboardScreenshot(
-                url,
-                self._report_schedule.dashboard.digest,
-                window_size=window_size,
-                thumb_size=app.config["WEBDRIVER_WINDOW"]["dashboard"],
-            )
+            screenshots = [
+                DashboardScreenshot(
+                    url,
+                    self._report_schedule.dashboard.digest,
+                    window_size=window_size,
+                    thumb_size=app.config["WEBDRIVER_WINDOW"]["dashboard"],
+                )
+                for url in urls
+            ]
         try:
-            image = screenshot.get_screenshot(user=user)
+            imges = []
+            for screenshot in screenshots:
+                if imge := screenshot.get_screenshot(user=user):
+                    imges.append(imge)
         except SoftTimeLimitExceeded as ex:
             logger.warning("A timeout occurred while taking a screenshot.")
             raise ReportScheduleScreenshotTimeout() from ex
@@ -268,9 +334,9 @@ class BaseReportState:
             raise ReportScheduleScreenshotFailedError(
                 f"Failed taking a screenshot {str(ex)}"
             ) from ex
-        if not image:
+        if not imges:
             raise ReportScheduleScreenshotFailedError()
-        return [image]
+        return imges
 
     def _get_pdf(self) -> bytes:
         """
diff --git a/tests/integration_tests/fixtures/dashboard_with_tabs.py 
b/tests/integration_tests/fixtures/dashboard_with_tabs.py
new file mode 100644
index 0000000000..44f10e1cc2
--- /dev/null
+++ b/tests/integration_tests/fixtures/dashboard_with_tabs.py
@@ -0,0 +1,651 @@
+# 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 json
+
+import pytest
+
+from tests.integration_tests.dashboard_utils import create_dashboard
+from tests.integration_tests.test_app import app
+
+MULTIPLE_TABS_TBL_NAME = "multiple_tabs"
+
+
[email protected](scope="session")
+def load_mutltiple_tabs_dashboard():
+    position_json = {
+        "CHART--0GPGmD-pO": {
+            "children": [],
+            "id": "CHART--0GPGmD-pO",
+            "meta": {
+                "chartId": 91,
+                "height": 56,
+                "sliceName": "Current Developers: Is this your first 
development job?",
+                "sliceNameOverride": "Is this your first development job?",
+                "uuid": "bfe5a8e6-146f-ef59-5e6c-13d519b236a8",
+                "width": 2,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-l_9I0aNYZ",
+                "ROW-b7USYEngT",
+            ],
+            "type": "CHART",
+        },
+        "CHART--w_Br1tPP3": {
+            "children": [],
+            "id": "CHART--w_Br1tPP3",
+            "meta": {
+                "chartId": 85,
+                "height": 51,
+                "sliceName": "\u2708\ufe0f Relocation ability",
+                "uuid": "a6dd2d5a-2cdc-c8ec-f30c-85920f4f8a65",
+                "width": 3,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-YT6eNksV-",
+                "ROW-DR80aHJA2c",
+            ],
+            "type": "CHART",
+        },
+        "CHART-0-zzTwBINh": {
+            "children": [],
+            "id": "CHART-0-zzTwBINh",
+            "meta": {
+                "chartId": 72,
+                "height": 55,
+                "sliceName": "Last Year Income Distribution",
+                "uuid": "a2ec5256-94b4-43c4-b8c7-b83f70c5d4df",
+                "width": 3,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-l_9I0aNYZ",
+                "ROW-b7USYEngT",
+            ],
+            "type": "CHART",
+        },
+        "CHART-37fu7fO6Z0": {
+            "children": [],
+            "id": "CHART-37fu7fO6Z0",
+            "meta": {
+                "chartId": 93,
+                "height": 69,
+                "sliceName": "Degrees vs Income",
+                "uuid": "02f546ae-1bf4-bd26-8bc2-14b9279c8a62",
+                "width": 7,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-l_9I0aNYZ",
+                "ROW-kNjtGVFpp",
+            ],
+            "type": "CHART",
+        },
+        "CHART-5QwNlSbXYU": {
+            "children": [],
+            "id": "CHART-5QwNlSbXYU",
+            "meta": {
+                "chartId": 90,
+                "height": 69,
+                "sliceName": "Commute Time",
+                "uuid": "097c05c9-2dd2-481d-813d-d6c0c12b4a3d",
+                "width": 5,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-l_9I0aNYZ",
+                "ROW-kNjtGVFpp",
+            ],
+            "type": "CHART",
+        },
+        "CHART-FKuVqq4kaA": {
+            "children": [],
+            "id": "CHART-FKuVqq4kaA",
+            "meta": {
+                "chartId": 50,
+                "height": 50,
+                "sliceName": "Work Location Preference",
+                "sliceNameOverride": "Work Location Preference",
+                "uuid": "e6b09c28-98cf-785f-4caf-320fd4fca802",
+                "width": 3,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-YT6eNksV-",
+                "ROW-DR80aHJA2c",
+            ],
+            "type": "CHART",
+        },
+        "CHART-JnpdZOhVer": {
+            "children": [],
+            "id": "CHART-JnpdZOhVer",
+            "meta": {
+                "chartId": 51,
+                "height": 50,
+                "sliceName": "Highest degree held",
+                "uuid": "9f7d2b9c-6b3a-69f9-f03e-d3a141514639",
+                "width": 2,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-YT6eNksV-",
+                "ROW--BIzjz9F0",
+                "COLUMN-IEKAo_QJlz",
+            ],
+            "type": "CHART",
+        },
+        "CHART-LjfhrUkEef": {
+            "children": [],
+            "id": "CHART-LjfhrUkEef",
+            "meta": {
+                "chartId": 86,
+                "height": 68,
+                "sliceName": "First Time Developer & Commute Time",
+                "uuid": "067c4a1e-ae03-4c0c-8e2a-d2c0f4bf43c3",
+                "width": 5,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-l_9I0aNYZ",
+                "ROW-s3l4os7YY",
+            ],
+            "type": "CHART",
+        },
+        "CHART-Q3pbwsH3id": {
+            "children": [],
+            "id": "CHART-Q3pbwsH3id",
+            "meta": {
+                "chartId": 79,
+                "height": 50,
+                "sliceName": "Are you an ethnic minority in your city?",
+                "sliceNameOverride": "Minority Status (in their city)",
+                "uuid": "def07750-b5c0-0b69-6228-cb2330916166",
+                "width": 3,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-AsMaxdYL_t",
+                "ROW-mOvr_xWm1",
+            ],
+            "type": "CHART",
+        },
+        "CHART-QVql08s5Bv": {
+            "children": [],
+            "id": "CHART-QVql08s5Bv",
+            "meta": {
+                "chartId": 92,
+                "height": 56,
+                "sliceName": "First Time Developer?",
+                "uuid": "edc75073-8f33-4123-a28d-cd6dfb33cade",
+                "width": 3,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-l_9I0aNYZ",
+                "ROW-b7USYEngT",
+            ],
+            "type": "CHART",
+        },
+        "CHART-UtSaz4pfV6": {
+            "children": [],
+            "id": "CHART-UtSaz4pfV6",
+            "meta": {
+                "chartId": 59,
+                "height": 50,
+                "sliceName": "Age distribution of respondents",
+                "uuid": "5f1ea868-604e-f69d-a241-5daa83ff33be",
+                "width": 3,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-AsMaxdYL_t",
+                "ROW-UsW-_RPAb",
+                "COLUMN-OJ5spdMmNh",
+            ],
+            "type": "CHART",
+        },
+        "CHART-VvFbGxi3X_": {
+            "children": [],
+            "id": "CHART-VvFbGxi3X_",
+            "meta": {
+                "chartId": 41,
+                "height": 62,
+                "sliceName": "Top 15 Languages Spoken at Home",
+                "uuid": "03a74c97-52fc-cf87-233c-d4275f8c550c",
+                "width": 3,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-AsMaxdYL_t",
+                "ROW-UsW-_RPAb",
+                "COLUMN-OJ5spdMmNh",
+            ],
+            "type": "CHART",
+        },
+        "CHART-XHncHuS5pZ": {
+            "children": [],
+            "id": "CHART-XHncHuS5pZ",
+            "meta": {
+                "chartId": 78,
+                "height": 41,
+                "sliceName": "Number of Aspiring Developers",
+                "sliceNameOverride": "What type of work would you prefer?",
+                "uuid": "a0e5329f-224e-6fc8-efd2-d37d0f546ee8",
+                "width": 2,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-YT6eNksV-",
+                "ROW-DR80aHJA2c",
+            ],
+            "type": "CHART",
+        },
+        "CHART-YSzS5GOOLf": {
+            "children": [],
+            "id": "CHART-YSzS5GOOLf",
+            "meta": {
+                "chartId": 49,
+                "height": 54,
+                "sliceName": "Ethnic Minority & Gender",
+                "uuid": "4880e4f4-b701-4be0-86f3-e7e89432e83b",
+                "width": 3,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-AsMaxdYL_t",
+                "ROW-mOvr_xWm1",
+            ],
+            "type": "CHART",
+        },
+        "CHART-ZECnzPz8Bi": {
+            "children": [],
+            "id": "CHART-ZECnzPz8Bi",
+            "meta": {
+                "chartId": 70,
+                "height": 74,
+                "sliceName": "Location of Current Developers",
+                "uuid": "5596e0f6-78a9-465d-8325-7139c794a06a",
+                "width": 7,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-l_9I0aNYZ",
+                "ROW-s3l4os7YY",
+            ],
+            "type": "CHART",
+        },
+        "CHART-aytwlT4GAq": {
+            "children": [],
+            "id": "CHART-aytwlT4GAq",
+            "meta": {
+                "chartId": 83,
+                "height": 30,
+                "sliceName": "Breakdown of Developer Type",
+                "uuid": "b8386be8-f44e-6535-378c-2aa2ba461286",
+                "width": 6,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-AsMaxdYL_t",
+                "ROW-y-GwJPgxLr",
+            ],
+            "type": "CHART",
+        },
+        "CHART-fLpTSAHpAO": {
+            "children": [],
+            "id": "CHART-fLpTSAHpAO",
+            "meta": {
+                "chartId": 60,
+                "height": 118,
+                "sliceName": "Country of Citizenship",
+                "uuid": "2ba66056-a756-d6a3-aaec-0c243fb7062e",
+                "width": 9,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-AsMaxdYL_t",
+                "ROW-UsW-_RPAb",
+            ],
+            "type": "CHART",
+        },
+        "CHART-lQVSAw0Or3": {
+            "children": [],
+            "id": "CHART-lQVSAw0Or3",
+            "meta": {
+                "chartId": 94,
+                "height": 100,
+                "sliceName": "How do you prefer to work?",
+                "sliceNameOverride": "Preferred Employment Style vs Degree",
+                "uuid": "cb8998ab-9f93-4f0f-4e4b-3bfe4b0dea9d",
+                "width": 4,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-YT6eNksV-",
+                "ROW--BIzjz9F0",
+            ],
+            "type": "CHART",
+        },
+        "CHART-o-JPAWMZK-": {
+            "children": [],
+            "id": "CHART-o-JPAWMZK-",
+            "meta": {
+                "chartId": 69,
+                "height": 50,
+                "sliceName": "Gender",
+                "uuid": "0f6b447c-828c-e71c-87ac-211bc412b214",
+                "width": 3,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-AsMaxdYL_t",
+                "ROW-mOvr_xWm1",
+            ],
+            "type": "CHART",
+        },
+        "CHART-v22McUFMtx": {
+            "children": [],
+            "id": "CHART-v22McUFMtx",
+            "meta": {
+                "chartId": 71,
+                "height": 52,
+                "sliceName": "How much do you expect to earn? ($0 - 100k)",
+                "sliceNameOverride": "\ud83d\udcb2Expected Income (excluding 
outliers)",
+                "uuid": "6d0ceb30-2008-d19c-d285-cf77dc764433",
+                "width": 4,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-YT6eNksV-",
+                "ROW--BIzjz9F0",
+                "COLUMN-IEKAo_QJlz",
+            ],
+            "type": "CHART",
+        },
+        "CHART-wxWVtlajRF": {
+            "children": [],
+            "id": "CHART-wxWVtlajRF",
+            "meta": {
+                "chartId": 82,
+                "height": 104,
+                "sliceName": "Preferred Employment Style",
+                "uuid": "bff88053-ccc4-92f2-d6f5-de83e950e8cd",
+                "width": 4,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-YT6eNksV-",
+                "ROW--BIzjz9F0",
+            ],
+            "type": "CHART",
+        },
+        "COLUMN-IEKAo_QJlz": {
+            "children": ["CHART-JnpdZOhVer", "CHART-v22McUFMtx"],
+            "id": "COLUMN-IEKAo_QJlz",
+            "meta": {"background": "BACKGROUND_TRANSPARENT", "width": 4},
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-YT6eNksV-",
+                "ROW--BIzjz9F0",
+            ],
+            "type": "COLUMN",
+        },
+        "COLUMN-OJ5spdMmNh": {
+            "children": ["CHART-VvFbGxi3X_", "CHART-UtSaz4pfV6"],
+            "id": "COLUMN-OJ5spdMmNh",
+            "meta": {"background": "BACKGROUND_TRANSPARENT", "width": 3},
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-AsMaxdYL_t",
+                "ROW-UsW-_RPAb",
+            ],
+            "type": "COLUMN",
+        },
+        "DASHBOARD_VERSION_KEY": "v2",
+        "GRID_ID": {
+            "children": ["TABS-L-d9eyOE-b"],
+            "id": "GRID_ID",
+            "parents": ["ROOT_ID"],
+            "type": "GRID",
+        },
+        "HEADER_ID": {
+            "id": "HEADER_ID",
+            "meta": {"text": "FCC New Coder Survey 2018"},
+            "type": "HEADER",
+        },
+        "MARKDOWN-BUmyHM2s0x": {
+            "children": [],
+            "id": "MARKDOWN-BUmyHM2s0x",
+            "meta": {
+                "code": "# Aspiring Developers\n\nThe mission of FreeCodeCamp 
is to \"help people learn to code for free\". With this in mind, it's no 
surprise that ~83% of this survey's respondents fall into the **Aspiring 
Developer** category.\n\nIn this tab, we use visualization to explore:\n\n- 
Interest in relocating for work\n- Preferences around work location & style\n- 
Distribution of expected income\n- Distribution of highest degree held\n- 
Heatmap of highest degree held vs emplo [...]
+                "height": 50,
+                "width": 4,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-YT6eNksV-",
+                "ROW-DR80aHJA2c",
+            ],
+            "type": "MARKDOWN",
+        },
+        "MARKDOWN-NQmSPDOtpl": {
+            "children": [],
+            "id": "MARKDOWN-NQmSPDOtpl",
+            "meta": {
+                "code": "# Current Developers\n\nWhile majority of the 
students on FCC are Aspiring developers, there's a nontrivial minority that's 
there to continue leveling up their skills (17% of the survey 
respondents).\n\nBased on how respondents self-identified in the start of the 
survey, they were asked different questions. In this tab, we use visualizations 
to explore:\n\n- The buckets of commute team these developers encounter\n- The 
proportion of developers whose current job i [...]
+                "height": 56,
+                "width": 4,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-l_9I0aNYZ",
+                "ROW-b7USYEngT",
+            ],
+            "type": "MARKDOWN",
+        },
+        "MARKDOWN-__u6CsUyfh": {
+            "children": [],
+            "id": "MARKDOWN-__u6CsUyfh",
+            "meta": {
+                "code": "## FreeCodeCamp New Coder Survey 2018\n\nEvery year, 
FCC surveys its user base (mostly budding software developers) to learn more 
about their interests, backgrounds, goals, job status, and socioeconomic 
features. This dashboard visualizes survey data from the 2018 survey.\n\n- 
[Survey link](https://freecodecamp.typeform.com/to/S3UeD9)\n- 
[Dataset](https://github.com/freeCodeCamp/2018-new-coder-survey)\n- [FCC Blog 
Post](https://www.freecodecamp.org/news/we-asked- [...]
+                "height": 30,
+                "width": 6,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-AsMaxdYL_t",
+                "ROW-y-GwJPgxLr",
+            ],
+            "type": "MARKDOWN",
+        },
+        "MARKDOWN-zc2mWxZeox": {
+            "children": [],
+            "id": "MARKDOWN-zc2mWxZeox",
+            "meta": {
+                "code": "# Demographics\n\nFreeCodeCamp is a completely-online 
community of people learning to code and consists of aspiring & current 
developers from all over the world. That doesn't necessarily mean that access 
to these types of opportunities are evenly distributed. \n\nThe following 
charts can begin to help us understand:\n\n- the original citizenship of the 
survey respondents\n- minority representation among both aspiring and current 
developers\n- their age distributi [...]
+                "height": 52,
+                "width": 3,
+            },
+            "parents": [
+                "ROOT_ID",
+                "GRID_ID",
+                "TABS-L-d9eyOE-b",
+                "TAB-AsMaxdYL_t",
+                "ROW-mOvr_xWm1",
+            ],
+            "type": "MARKDOWN",
+        },
+        "ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
+        "ROW--BIzjz9F0": {
+            "children": ["COLUMN-IEKAo_QJlz", "CHART-lQVSAw0Or3", 
"CHART-wxWVtlajRF"],
+            "id": "ROW--BIzjz9F0",
+            "meta": {"background": "BACKGROUND_TRANSPARENT"},
+            "parents": ["ROOT_ID", "GRID_ID", "TABS-L-d9eyOE-b", 
"TAB-YT6eNksV-"],
+            "type": "ROW",
+        },
+        "ROW-DR80aHJA2c": {
+            "children": [
+                "MARKDOWN-BUmyHM2s0x",
+                "CHART-XHncHuS5pZ",
+                "CHART--w_Br1tPP3",
+                "CHART-FKuVqq4kaA",
+            ],
+            "id": "ROW-DR80aHJA2c",
+            "meta": {"background": "BACKGROUND_TRANSPARENT"},
+            "parents": ["ROOT_ID", "GRID_ID", "TABS-L-d9eyOE-b", 
"TAB-YT6eNksV-"],
+            "type": "ROW",
+        },
+        "ROW-UsW-_RPAb": {
+            "children": ["COLUMN-OJ5spdMmNh", "CHART-fLpTSAHpAO"],
+            "id": "ROW-UsW-_RPAb",
+            "meta": {"background": "BACKGROUND_TRANSPARENT"},
+            "parents": ["ROOT_ID", "GRID_ID", "TABS-L-d9eyOE-b", 
"TAB-AsMaxdYL_t"],
+            "type": "ROW",
+        },
+        "ROW-b7USYEngT": {
+            "children": [
+                "MARKDOWN-NQmSPDOtpl",
+                "CHART--0GPGmD-pO",
+                "CHART-QVql08s5Bv",
+                "CHART-0-zzTwBINh",
+            ],
+            "id": "ROW-b7USYEngT",
+            "meta": {"background": "BACKGROUND_TRANSPARENT"},
+            "parents": ["ROOT_ID", "GRID_ID", "TABS-L-d9eyOE-b", 
"TAB-l_9I0aNYZ"],
+            "type": "ROW",
+        },
+        "ROW-kNjtGVFpp": {
+            "children": ["CHART-5QwNlSbXYU", "CHART-37fu7fO6Z0"],
+            "id": "ROW-kNjtGVFpp",
+            "meta": {"background": "BACKGROUND_TRANSPARENT"},
+            "parents": ["ROOT_ID", "GRID_ID", "TABS-L-d9eyOE-b", 
"TAB-l_9I0aNYZ"],
+            "type": "ROW",
+        },
+        "ROW-mOvr_xWm1": {
+            "children": [
+                "MARKDOWN-zc2mWxZeox",
+                "CHART-Q3pbwsH3id",
+                "CHART-o-JPAWMZK-",
+                "CHART-YSzS5GOOLf",
+            ],
+            "id": "ROW-mOvr_xWm1",
+            "meta": {"background": "BACKGROUND_TRANSPARENT"},
+            "parents": ["ROOT_ID", "GRID_ID", "TABS-L-d9eyOE-b", 
"TAB-AsMaxdYL_t"],
+            "type": "ROW",
+        },
+        "ROW-s3l4os7YY": {
+            "children": ["CHART-LjfhrUkEef", "CHART-ZECnzPz8Bi"],
+            "id": "ROW-s3l4os7YY",
+            "meta": {"background": "BACKGROUND_TRANSPARENT"},
+            "parents": ["ROOT_ID", "GRID_ID", "TABS-L-d9eyOE-b", 
"TAB-l_9I0aNYZ"],
+            "type": "ROW",
+        },
+        "ROW-y-GwJPgxLr": {
+            "children": ["MARKDOWN-__u6CsUyfh", "CHART-aytwlT4GAq"],
+            "id": "ROW-y-GwJPgxLr",
+            "meta": {"background": "BACKGROUND_TRANSPARENT"},
+            "parents": ["ROOT_ID", "GRID_ID", "TABS-L-d9eyOE-b", 
"TAB-AsMaxdYL_t"],
+            "type": "ROW",
+        },
+        "TAB-AsMaxdYL_t": {
+            "children": ["ROW-y-GwJPgxLr", "ROW-mOvr_xWm1", "ROW-UsW-_RPAb"],
+            "id": "TAB-AsMaxdYL_t",
+            "meta": {"text": "Overview"},
+            "parents": ["ROOT_ID", "GRID_ID", "TABS-L-d9eyOE-b"],
+            "type": "TAB",
+        },
+        "TAB-YT6eNksV-": {
+            "children": ["ROW-DR80aHJA2c", "ROW--BIzjz9F0"],
+            "id": "TAB-YT6eNksV-",
+            "meta": {"text": "\ud83d\ude80 Aspiring Developers"},
+            "parents": ["ROOT_ID", "GRID_ID", "TABS-L-d9eyOE-b"],
+            "type": "TAB",
+        },
+        "TAB-l_9I0aNYZ": {
+            "children": ["ROW-b7USYEngT", "ROW-kNjtGVFpp", "ROW-s3l4os7YY"],
+            "id": "TAB-l_9I0aNYZ",
+            "meta": {"text": "\ud83d\udcbb Current Developers"},
+            "parents": ["ROOT_ID", "GRID_ID", "TABS-L-d9eyOE-b"],
+            "type": "TAB",
+        },
+        "TABS-L-d9eyOE-b": {
+            "children": ["TAB-AsMaxdYL_t", "TAB-YT6eNksV-", "TAB-l_9I0aNYZ"],
+            "id": "TABS-L-d9eyOE-b",
+            "meta": {},
+            "parents": ["ROOT_ID", "GRID_ID"],
+            "type": "TABS",
+        },
+    }
+
+    with app.app_context():
+        dash = create_dashboard(
+            "multi_tabs_test", "multiple tabs Test", 
json.dumps(position_json), None
+        )
+    yield dash
diff --git a/tests/integration_tests/reports/api_tests.py 
b/tests/integration_tests/reports/api_tests.py
index 7664dc4584..55b333b813 100644
--- a/tests/integration_tests/reports/api_tests.py
+++ b/tests/integration_tests/reports/api_tests.py
@@ -49,6 +49,9 @@ from tests.integration_tests.fixtures.birth_names_dashboard 
import (
     load_birth_names_dashboard_with_slices,  # noqa: F401
     load_birth_names_data,  # noqa: F401
 )
+from tests.integration_tests.fixtures.dashboard_with_tabs import (
+    load_mutltiple_tabs_dashboard,  # noqa: F401
+)
 from tests.integration_tests.reports.utils import insert_report_schedule
 
 REPORTS_COUNT = 10
@@ -1972,3 +1975,79 @@ class TestReportSchedulesApi(SupersetTestCase):
         assert rv.status_code == 405
         rv = self.client.delete(uri)
         assert rv.status_code == 405
+
+    @with_feature_flags(ALERT_REPORT_TABS=True)
+    @pytest.mark.usefixtures(
+        "load_birth_names_dashboard_with_slices", "create_report_schedules"
+    )
+    def test_create_report_schedule_with_invalid_anchors(self):
+        """
+        ReportSchedule Api: Test get report schedule 404s when feature is 
disabled
+        """
+        report_schedule = db.session.query(Dashboard).first()
+        get_example_database()  # noqa: F841
+        anchors = ["TAB-AsMaxdYL_t", "TAB-YT6eNksV-", "TAB-l_9I0aNYZ"]
+        report_schedule_data = {
+            "type": ReportScheduleType.REPORT,
+            "name": "random_name1",
+            "description": "description",
+            "creation_method": ReportCreationMethod.ALERTS_REPORTS,
+            "crontab": "0 9 * * *",
+            "working_timeout": 3600,
+            "dashboard": report_schedule.id,
+            "extra": {"dashboard": {"anchor": json.dumps(anchors)}},
+        }
+
+        self.login(ADMIN_USERNAME)
+        uri = "api/v1/report/"
+        rv = self.post_assert_metric(uri, report_schedule_data, "post")
+        data = json.loads(rv.data.decode("utf-8"))
+        assert rv.status_code == 422
+        assert "message" in data
+        assert "extra" in data["message"]
+        assert all(anchor in data["message"]["extra"][0] for anchor in 
anchors) is True
+
+    @with_feature_flags(ALERT_REPORT_TABS=True)
+    @pytest.mark.usefixtures("load_mutltiple_tabs_dashboard", 
"create_report_schedules")
+    def test_create_report_schedule_with_multiple_anchors(self):
+        """
+        ReportSchedule Api: Test report schedule with all tabs
+        """
+        report_dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.slug == "multi_tabs_test")
+            .first()
+        )
+        get_example_database()  # noqa: F841
+
+        self.login(ADMIN_USERNAME)
+        tabs_uri = f"/api/v1/dashboard/{report_dashboard.id}/tabs"
+        rv = self.client.get(tabs_uri)
+        data = json.loads(rv.data.decode("utf-8"))
+
+        tabs_keys = list(data.get("result").get("all_tabs").keys())
+        extra_json = {"dashboard": {"anchor": json.dumps(tabs_keys)}}
+
+        report_schedule_data = {
+            "type": ReportScheduleType.REPORT,
+            "name": "random_name2",
+            "description": "description",
+            "creation_method": ReportCreationMethod.ALERTS_REPORTS,
+            "crontab": "0 9 * * *",
+            "working_timeout": 3600,
+            "dashboard": report_dashboard.id,
+            "extra": extra_json,
+        }
+
+        uri = "api/v1/report/"
+        rv = self.post_assert_metric(uri, report_schedule_data, "post")
+        data = json.loads(rv.data.decode("utf-8"))
+        assert rv.status_code == 201
+
+        report_schedule = (
+            db.session.query(ReportSchedule)
+            .filter(ReportSchedule.dashboard_id == report_dashboard.id)
+            .first()
+        )
+
+        assert json.loads(report_schedule.extra_json) == extra_json
diff --git a/tests/unit_tests/commands/report/execute_test.py 
b/tests/unit_tests/commands/report/execute_test.py
index b7b545fd4a..3d49bb0457 100644
--- a/tests/unit_tests/commands/report/execute_test.py
+++ b/tests/unit_tests/commands/report/execute_test.py
@@ -15,15 +15,21 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import json
+from unittest.mock import patch
+
+import pytest
 from pytest_mock import MockerFixture
 
 from superset.commands.report.execute import BaseReportState
+from superset.dashboards.permalink.types import DashboardPermalinkState
 from superset.reports.models import (
     ReportRecipientType,
     ReportSchedule,
     ReportSourceFormat,
 )
 from superset.utils.core import HeaderDataType
+from tests.integration_tests.conftest import with_feature_flags
 
 
 def test_log_data_with_chart(mocker: MockerFixture) -> None:
@@ -220,3 +226,142 @@ def test_log_data_with_missing_values(mocker: 
MockerFixture) -> None:
     }
 
     assert result == expected_result
+
+
[email protected](
+    "anchors, permalink_side_effect, expected_uris",
+    [
+        # Test user select multiple tabs to export in a dashboard report
+        (
+            ["mock_tab_anchor_1", "mock_tab_anchor_2"],
+            ["url1", "url2"],
+            [
+                "http://0.0.0.0:8080/superset/dashboard/p/url1/";,
+                "http://0.0.0.0:8080/superset/dashboard/p/url2/";,
+            ],
+        ),
+        # Test user select one tab to export in a dashboard report
+        (
+            "mock_tab_anchor_1",
+            ["url1"],
+            ["http://0.0.0.0:8080/superset/dashboard/p/url1/";],
+        ),
+    ],
+)
+@patch(
+    
"superset.commands.dashboard.permalink.create.CreateDashboardPermalinkCommand.run"
+)
+@with_feature_flags(ALERT_REPORT_TABS=True)
+def test_get_dashboard_urls_with_multiple_tabs(
+    mock_run, mocker: MockerFixture, anchors, permalink_side_effect, 
expected_uris
+) -> None:
+    mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
+    mock_report_schedule.chart = False
+    mock_report_schedule.chart_id = None
+    mock_report_schedule.dashboard_id = 123
+    mock_report_schedule.type = "report_type"
+    mock_report_schedule.report_format = "report_format"
+    mock_report_schedule.owners = [1, 2]
+    mock_report_schedule.recipients = []
+    mock_report_schedule.extra = {
+        "dashboard": {
+            "anchor": json.dumps(anchors) if isinstance(anchors, list) else 
anchors,
+            "dataMask": None,
+            "activeTabs": None,
+            "urlParams": None,
+        }
+    }
+
+    class_instance: BaseReportState = BaseReportState(
+        mock_report_schedule, "January 1, 2021", "execution_id_example"
+    )
+    class_instance._report_schedule = mock_report_schedule
+    mock_run.side_effect = permalink_side_effect
+
+    result: list[str] = class_instance.get_dashboard_urls()
+
+    assert result == expected_uris
+
+
+@patch(
+    
"superset.commands.dashboard.permalink.create.CreateDashboardPermalinkCommand.run"
+)
+@with_feature_flags(ALERT_REPORT_TABS=True)
+def test_get_dashboard_urls_with_exporting_dashboard_only(
+    mock_run,
+    mocker: MockerFixture,
+) -> None:
+    mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
+    mock_report_schedule.chart = False
+    mock_report_schedule.chart_id = None
+    mock_report_schedule.dashboard_id = 123
+    mock_report_schedule.type = "report_type"
+    mock_report_schedule.report_format = "report_format"
+    mock_report_schedule.owners = [1, 2]
+    mock_report_schedule.recipients = []
+    mock_report_schedule.extra = {
+        "dashboard": {
+            "anchor": "",
+            "dataMask": None,
+            "activeTabs": None,
+            "urlParams": None,
+        }
+    }
+    mock_run.return_value = "url1"
+
+    class_instance: BaseReportState = BaseReportState(
+        mock_report_schedule, "January 1, 2021", "execution_id_example"
+    )
+    class_instance._report_schedule = mock_report_schedule
+
+    result: list[str] = class_instance.get_dashboard_urls()
+
+    assert "http://0.0.0.0:8080/superset/dashboard/p/url1/"; == result[0]
+
+
+@patch(
+    
"superset.commands.dashboard.permalink.create.CreateDashboardPermalinkCommand.run"
+)
+def test_get_tab_urls(
+    mock_run,
+    mocker: MockerFixture,
+) -> None:
+    mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
+    mock_report_schedule.dashboard_id = 123
+
+    class_instance: BaseReportState = BaseReportState(
+        mock_report_schedule, "January 1, 2021", "execution_id_example"
+    )
+    class_instance._report_schedule = mock_report_schedule
+    mock_run.side_effect = ["uri1", "uri2"]
+    tab_anchors = ["1", "2"]
+    result: list[str] = class_instance._get_tabs_urls(tab_anchors)
+    assert result == [
+        "http://0.0.0.0:8080/superset/dashboard/p/uri1/";,
+        "http://0.0.0.0:8080/superset/dashboard/p/uri2/";,
+    ]
+
+
+@patch(
+    
"superset.commands.dashboard.permalink.create.CreateDashboardPermalinkCommand.run"
+)
+def test_get_tab_url(
+    mock_run,
+    mocker: MockerFixture,
+) -> None:
+    mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
+    mock_report_schedule.dashboard_id = 123
+
+    class_instance: BaseReportState = BaseReportState(
+        mock_report_schedule, "January 1, 2021", "execution_id_example"
+    )
+    class_instance._report_schedule = mock_report_schedule
+    mock_run.return_value = "uri"
+    dashboard_state = DashboardPermalinkState(
+        anchor="1",
+        dataMask=None,
+        activeTabs=None,
+        urlParams=None,
+    )
+    result: str = class_instance._get_tab_url(dashboard_state)
+    assert result == "http://0.0.0.0:8080/superset/dashboard/p/uri/";


Reply via email to