You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by di...@apache.org on 2024/06/20 16:41:01 UTC

(superset) branch master updated: feat: Dashboard tabs api endpoint (#27962)

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

diegopucci 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 a5355d86fc feat: Dashboard tabs api endpoint (#27962)
a5355d86fc is described below

commit a5355d86fc402b589335fe01b453e312c9b980e0
Author: Jack <41...@users.noreply.github.com>
AuthorDate: Thu Jun 20 11:40:54 2024 -0500

    feat: Dashboard tabs api endpoint (#27962)
---
 superset/constants.py                           |   1 +
 superset/daos/dashboard.py                      |   5 +
 superset/dashboards/api.py                      |  62 ++++++++
 superset/dashboards/schemas.py                  |  12 ++
 superset/models/dashboard.py                    |  50 ++++++-
 tests/integration_tests/dashboards/api_tests.py | 179 ++++++++++++++++++++++++
 6 files changed, 308 insertions(+), 1 deletion(-)

diff --git a/superset/constants.py b/superset/constants.py
index 42cde6115a..edc82da398 100644
--- a/superset/constants.py
+++ b/superset/constants.py
@@ -152,6 +152,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
     "data_from_cache": "read",
     "get_charts": "read",
     "get_datasets": "read",
+    "get_tabs": "read",
     "function_names": "read",
     "available": "read",
     "validate_sql": "read",
diff --git a/superset/daos/dashboard.py b/superset/daos/dashboard.py
index 55288a11a8..6c973639b7 100644
--- a/superset/daos/dashboard.py
+++ b/superset/daos/dashboard.py
@@ -80,6 +80,11 @@ class DashboardDAO(BaseDAO[Dashboard]):
         dashboard = DashboardDAO.get_by_id_or_slug(id_or_slug)
         return dashboard.datasets_trimmed_for_slices()
 
+    @staticmethod
+    def get_tabs_for_dashboard(id_or_slug: str) -> dict[str, Any]:
+        dashboard = DashboardDAO.get_by_id_or_slug(id_or_slug)
+        return dashboard.tabs
+
     @staticmethod
     def get_charts_for_dashboard(id_or_slug: str) -> list[Slice]:
         return DashboardDAO.get_by_id_or_slug(id_or_slug).slices
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 5d3616361f..3fe557a684 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -76,6 +76,7 @@ from superset.dashboards.schemas import (
     get_fav_star_ids_schema,
     GetFavStarIdsSchema,
     openapi_spec_methods_override,
+    TabsPayloadSchema,
     thumbnail_query_schema,
 )
 from superset.extensions import event_logger
@@ -142,6 +143,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
         "remove_favorite",
         "get_charts",
         "get_datasets",
+        "get_tabs",
         "get_embedded",
         "set_embedded",
         "delete_embedded",
@@ -237,6 +239,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
     chart_entity_response_schema = ChartEntityResponseSchema()
     dashboard_get_response_schema = DashboardGetResponseSchema()
     dashboard_dataset_schema = DashboardDatasetSchema()
+    tab_schema = TabsPayloadSchema()
     embedded_response_schema = EmbeddedDashboardResponseSchema()
     embedded_config_schema = EmbeddedDashboardConfigSchema()
 
@@ -269,6 +272,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
         DashboardCopySchema,
         DashboardGetResponseSchema,
         DashboardDatasetSchema,
+        TabsPayloadSchema,
         GetFavStarIdsSchema,
         EmbeddedDashboardResponseSchema,
     )
@@ -396,6 +400,64 @@ class DashboardRestApi(BaseSupersetModelRestApi):
         except DashboardNotFoundError:
             return self.response_404()
 
+    @expose("/<id_or_slug>/tabs", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_tabs",
+        log_to_statsd=False,
+    )
+    def get_tabs(self, id_or_slug: str) -> Response:
+        """Get dashboard's tabs.
+        ---
+        get:
+          summary: Get dashboard's tabs
+          description: >-
+            Returns a list of a dashboard's tabs and dashboard's nested tree structure for associated tabs.
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: id_or_slug
+            description: Either the id of the dashboard, or its slug
+          responses:
+            200:
+              description: Dashboard tabs
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      result:
+                        type: object
+                        items:
+                          $ref: '#/components/schemas/TabsPayloadSchema'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        try:
+            tabs = DashboardDAO.get_tabs_for_dashboard(id_or_slug)
+            result = self.tab_schema.dump(tabs)
+            return self.response(200, result=result)
+
+        except (TypeError, ValueError) as err:
+            return self.response_400(
+                message=gettext(
+                    "Tab schema is invalid, caused by: %(error)s", error=str(err)
+                )
+            )
+        except DashboardAccessDeniedError:
+            return self.response_403()
+        except DashboardNotFoundError:
+            return self.response_404()
+
     @expose("/<id_or_slug>/charts", methods=("GET",))
     @protect()
     @safe
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index 33fafcb1d5..3d4d131b9e 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -267,6 +267,18 @@ class DashboardDatasetSchema(Schema):
         return serialized
 
 
+class TabSchema(Schema):
+    # pylint: disable=W0108
+    children = fields.List(fields.Nested(lambda: TabSchema()))
+    value = fields.Str()
+    title = fields.Str()
+
+
+class TabsPayloadSchema(Schema):
+    all_tabs = fields.Dict(keys=fields.String(), values=fields.String())
+    tab_tree = fields.List(fields.Nested(lambda: TabSchema))
+
+
 class BaseDashboardSchema(Schema):
     # pylint: disable=unused-argument
     @post_load
diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py
index e8f53dddb5..c2048f2a55 100644
--- a/superset/models/dashboard.py
+++ b/superset/models/dashboard.py
@@ -18,7 +18,7 @@ from __future__ import annotations
 
 import logging
 import uuid
-from collections import defaultdict
+from collections import defaultdict, deque
 from typing import Any, Callable
 
 import sqlalchemy as sqla
@@ -298,6 +298,54 @@ class Dashboard(AuditMixinNullable, ImportExportMixin, Model):
             return json.loads(self.position_json)
         return {}
 
+    @property
+    def tabs(self) -> dict[str, Any]:
+        if self.position == {}:
+            return {}
+
+        def get_node(node_id: str) -> dict[str, Any]:
+            """
+            Helper function for getting a node from the position_data
+            """
+            return self.position[node_id]
+
+        def build_tab_tree(
+            node: dict[str, Any], children: list[dict[str, Any]]
+        ) -> None:
+            """
+            Function for building the tab tree structure and list of all tabs
+            """
+
+            new_children: list[dict[str, Any]] = []
+            # new children to overwrite parent's children
+            for child_id in node.get("children", []):
+                child = get_node(child_id)
+                if node["type"] == "TABS":
+                    # if TABS add create a new list and append children to it
+                    # new_children.append(child)
+                    children.append(child)
+                    queue.append((child, new_children))
+                elif node["type"] in ["GRID", "ROOT"]:
+                    queue.append((child, children))
+                elif node["type"] == "TAB":
+                    queue.append((child, new_children))
+            if node["type"] == "TAB":
+                node["children"] = new_children
+                node["title"] = node["meta"]["text"]
+                node["value"] = node["id"]
+                all_tabs[node["id"]] = node["title"]
+
+        root = get_node("ROOT_ID")
+        tab_tree: list[dict[str, Any]] = []
+        all_tabs: dict[str, str] = {}
+        queue: deque[tuple[dict[str, Any], list[dict[str, Any]]]] = deque()
+        queue.append((root, tab_tree))
+        while queue:
+            node, children = queue.popleft()
+            build_tab_tree(node, children)
+
+        return {"all_tabs": all_tabs, "tab_tree": tab_tree}
+
     def update_thumbnail(self) -> None:
         cache_dashboard_thumbnail.delay(
             current_user=get_current_user(),
diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py
index bd7e230dbe..b4213039c4 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -918,6 +918,185 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas
             for key, value in expected_results[idx].items():
                 assert response_item[key] == value
 
+    def test_get_dashboard_tabs(self):
+        """
+        Dashboard API: Test get dashboard tabs
+        """
+        position_data = {
+            "GRID_ID": {"children": [], "id": "GRID_ID", "type": "GRID"},
+            "ROOT_ID": {
+                "children": ["TABS-tDGEcwZ82u"],
+                "id": "ROOT_ID",
+                "type": "ROOT",
+            },
+            "TAB-0TkqQRxzg7": {
+                "children": [],
+                "id": "TAB-0TkqQRxzg7",
+                "meta": {"text": "P2 - T1"},
+                "type": "TAB",
+            },
+            "TAB-1iG_yOlKA2": {
+                "children": [],
+                "id": "TAB-1iG_yOlKA2",
+                "meta": {"text": "P1 - T1"},
+                "type": "TAB",
+            },
+            "TAB-2dgADEurF": {
+                "children": ["TABS-LsyXZWG2rk"],
+                "id": "TAB-2dgADEurF",
+                "meta": {"text": "P1 - T2"},
+                "type": "TAB",
+            },
+            "TAB-BJIt5SdCx3": {
+                "children": [],
+                "id": "TAB-BJIt5SdCx3",
+                "meta": {"text": "P1 - T2 - T1"},
+                "type": "TAB",
+            },
+            "TAB-CjZlNL5Uz": {
+                "children": ["TABS-Ji_K1ZBE0M"],
+                "id": "TAB-CjZlNL5Uz",
+                "meta": {"text": "Parent Tab 2"},
+                "type": "TAB",
+            },
+            "TAB-Nct5fiHtn": {
+                "children": [],
+                "id": "TAB-Nct5fiHtn",
+                "meta": {"text": "P1 - T2 - T3"},
+                "type": "TAB",
+            },
+            "TAB-PumuDkWKq": {
+                "children": [],
+                "id": "TAB-PumuDkWKq",
+                "meta": {"text": "P2 - T2"},
+                "type": "TAB",
+            },
+            "TAB-hyTv5L7zz": {
+                "children": [],
+                "id": "TAB-hyTv5L7zz",
+                "meta": {"text": "P1 - T2 - T2"},
+                "type": "TAB",
+            },
+            "TAB-qL7fSzr3jl": {
+                "children": ["TABS-N8ODUqp2sE"],
+                "id": "TAB-qL7fSzr3jl",
+                "meta": {"text": "Parent Tab 1"},
+                "type": "TAB",
+            },
+            "TABS-Ji_K1ZBE0M": {
+                "children": ["TAB-0TkqQRxzg7", "TAB-PumuDkWKq"],
+                "id": "TABS-Ji_K1ZBE0M",
+                "meta": {},
+                "type": "TABS",
+            },
+            "TABS-LsyXZWG2rk": {
+                "children": ["TAB-BJIt5SdCx3", "TAB-hyTv5L7zz", "TAB-Nct5fiHtn"],
+                "id": "TABS-LsyXZWG2rk",
+                "meta": {},
+                "type": "TABS",
+            },
+            "TABS-N8ODUqp2sE": {
+                "children": ["TAB-1iG_yOlKA2", "TAB-2dgADEurF"],
+                "id": "TABS-N8ODUqp2sE",
+                "meta": {},
+                "type": "TABS",
+            },
+            "TABS-tDGEcwZ82u": {
+                "children": ["TAB-qL7fSzr3jl", "TAB-CjZlNL5Uz"],
+                "id": "TABS-tDGEcwZ82u",
+                "meta": {},
+                "type": "TABS",
+            },
+        }
+        admin_id = self.get_user("admin").id
+        dashboard = self.insert_dashboard(
+            "title", "slug", [admin_id], position_json=json.dumps(position_data)
+        )
+        self.login(ADMIN_USERNAME)
+        uri = f"api/v1/dashboard/{dashboard.id}/tabs"
+        rv = self.get_assert_metric(uri, "get_tabs")
+        response = json.loads(rv.data.decode("utf-8"))
+        expected_response = {
+            "result": {
+                "all_tabs": {
+                    "TAB-0TkqQRxzg7": "P2 - T1",
+                    "TAB-1iG_yOlKA2": "P1 - T1",
+                    "TAB-2dgADEurF": "P1 - T2",
+                    "TAB-BJIt5SdCx3": "P1 - T2 - T1",
+                    "TAB-CjZlNL5Uz": "Parent Tab 2",
+                    "TAB-Nct5fiHtn": "P1 - T2 - T3",
+                    "TAB-PumuDkWKq": "P2 - T2",
+                    "TAB-hyTv5L7zz": "P1 - T2 - T2",
+                    "TAB-qL7fSzr3jl": "Parent Tab 1",
+                },
+                "tab_tree": [
+                    {
+                        "children": [
+                            {
+                                "children": [],
+                                "title": "P1 - T1",
+                                "value": "TAB-1iG_yOlKA2",
+                            },
+                            {
+                                "children": [
+                                    {
+                                        "children": [],
+                                        "title": "P1 - T2 - T1",
+                                        "value": "TAB-BJIt5SdCx3",
+                                    },
+                                    {
+                                        "children": [],
+                                        "title": "P1 - T2 - T2",
+                                        "value": "TAB-hyTv5L7zz",
+                                    },
+                                    {
+                                        "children": [],
+                                        "title": "P1 - T2 - T3",
+                                        "value": "TAB-Nct5fiHtn",
+                                    },
+                                ],
+                                "title": "P1 - T2",
+                                "value": "TAB-2dgADEurF",
+                            },
+                        ],
+                        "title": "Parent Tab 1",
+                        "value": "TAB-qL7fSzr3jl",
+                    },
+                    {
+                        "children": [
+                            {
+                                "children": [],
+                                "title": "P2 - T1",
+                                "value": "TAB-0TkqQRxzg7",
+                            },
+                            {
+                                "children": [],
+                                "title": "P2 - T2",
+                                "value": "TAB-PumuDkWKq",
+                            },
+                        ],
+                        "title": "Parent Tab 2",
+                        "value": "TAB-CjZlNL5Uz",
+                    },
+                ],
+            }
+        }
+        self.assertEqual(rv.status_code, 200)
+        self.assertEqual(response, expected_response)
+        db.session.delete(dashboard)
+        db.session.commit()
+
+    @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+    def test_get_dashboard_tabs_not_found(self):
+        """
+        Dashboard API: Test get dashboard tabs not found
+        """
+        bad_id = self.get_nonexistent_numeric_id(Dashboard)
+        self.login(ADMIN_USERNAME)
+        uri = f"api/v1/dashboard/{bad_id}/tabs"
+        rv = self.get_assert_metric(uri, "get_tabs")
+        self.assertEqual(rv.status_code, 404)
+
     def create_dashboard_import(self):
         buf = BytesIO()
         with ZipFile(buf, "w") as bundle: