diff --git a/superset/constants.py b/superset/constants.py index 42cde6115..edc82da39 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 55288a11a..6c973639b 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 5d3616361..3fe557a68 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("//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("//charts", methods=("GET",)) @protect() @safe diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index 33fafcb1d..3d4d131b9 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 e8f53dddb..c2048f2a5 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 bd7e230db..b4213039c 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: