rusackas commented on code in PR #40912:
URL: https://github.com/apache/superset/pull/40912#discussion_r3382791366


##########
superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx:
##########
@@ -240,6 +241,24 @@ export const useHeaderActionsMenu = ({
         key: MenuKeys.EditProperties,
         label: t('Edit properties'),
       });
+
+      // View lineage
+      if (dashboardId) {
+        menuItems.push(
+          createModalMenuItem(
+            MenuKeys.ViewLineage,
+            <LineageModal
+              entityType="dashboard"
+              entityId={dashboardId}
+              triggerNode={
+                <div data-test="view-lineage-menu-item">
+                  {t('View lineage')}
+                </div>
+              }
+            />,
+          ),
+        );
+      }

Review Comment:
   Fixed. "View lineage" is now added outside the edit-only branch so it's 
available in dashboard view mode as well (lineage is read-only information).



##########
superset/dashboards/api.py:
##########
@@ -524,6 +525,114 @@ def get(
         )
         return self.response(200, result=result)
 
+    @expose("/<id_or_slug>/lineage", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @with_dashboard
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.lineage",
+        log_to_statsd=False,
+    )
+    # pylint: disable=arguments-differ,arguments-renamed
+    def lineage(self, dash: Dashboard) -> Response:
+        """Get lineage information for a dashboard.
+        ---
+        get:
+          summary: Get lineage information for a dashboard
+          description: >-
+            Returns upstream (charts, datasets, databases) lineage information
+            for a dashboard
+          parameters:
+          - in: path
+            name: id_or_slug
+            schema:
+              type: string
+            description: Either the id of the dashboard, or its slug
+          responses:
+            200:
+              description: Lineage information
+              content:
+                application/json:
+                  schema:
+                    $ref: "#/components/schemas/DashboardLineageResponseSchema"

Review Comment:
   Fixed. `DashboardLineageResponseSchema` is now imported and registered in 
`openapi_spec_component_schemas` for `DashboardRestApi`, resolving the dangling 
$ref.



##########
superset/dashboards/api.py:
##########
@@ -524,6 +525,114 @@ def get(
         )
         return self.response(200, result=result)
 
+    @expose("/<id_or_slug>/lineage", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @with_dashboard
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.lineage",
+        log_to_statsd=False,
+    )
+    # pylint: disable=arguments-differ,arguments-renamed
+    def lineage(self, dash: Dashboard) -> Response:
+        """Get lineage information for a dashboard.
+        ---
+        get:
+          summary: Get lineage information for a dashboard
+          description: >-
+            Returns upstream (charts, datasets, databases) lineage information
+            for a dashboard
+          parameters:
+          - in: path
+            name: id_or_slug
+            schema:
+              type: string
+            description: Either the id of the dashboard, or its slug
+          responses:
+            200:
+              description: Lineage information
+              content:
+                application/json:
+                  schema:
+                    $ref: "#/components/schemas/DashboardLineageResponseSchema"
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        dashboard_info = {
+            "id": dash.id,
+            "title": dash.dashboard_title,
+            "slug": dash.slug,
+            "published": dash.published,
+        }
+
+        # Get upstream (charts, datasets, databases) information
+        charts = []
+        dataset_map = {}
+        database_map = {}
+
+        for chart in dash.slices:
+            charts.append(
+                {
+                    "id": chart.id,
+                    "slice_name": chart.slice_name,
+                    "viz_type": chart.viz_type,
+                    "dataset_id": chart.datasource_id,
+                }
+            )
+
+            # Collect dataset information
+            dataset = chart.datasource
+            if dataset and dataset.id not in dataset_map:
+                dataset_map[dataset.id] = {
+                    "id": dataset.id,
+                    "name": dataset.name,
+                    "database_id": dataset.database_id,
+                    "database_name": dataset.database.database_name
+                    if dataset.database
+                    else None,
+                    "schema": dataset.schema,
+                    "table_name": dataset.table_name,
+                    "chart_ids": [],

Review Comment:
   Good catch. The dashboard lineage endpoint now gates dataset schema/table 
and database metadata behind `security_manager.can_access_datasource`, 
redacting those fields (and omitting inaccessible databases) when the user 
lacks access, while keeping the dataset id/name so the graph still renders. 
Fixed.



##########
superset/datasets/api.py:
##########
@@ -846,6 +849,119 @@ def related_objects(self, id_or_uuid: str) -> Response:
             dashboards={"count": len(dashboards), "result": dashboards},
         )
 
+    @expose("/<id_or_uuid>/lineage", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.lineage",
+        log_to_statsd=False,
+    )
+    def lineage(self, id_or_uuid: str) -> Response:
+        """Get lineage information for a dataset.
+        ---
+        get:
+          summary: Get lineage information for a dataset
+          description: >-
+            Returns upstream (database) and downstream (charts, dashboards) 
lineage
+            information for a dataset
+          parameters:
+          - in: path
+            name: id_or_uuid
+            schema:
+              type: string
+            description: Either the id of the dataset, or its uuid
+          responses:
+            200:
+              description: Lineage information
+              content:
+                application/json:
+                  schema:
+                    $ref: "#/components/schemas/DatasetLineageResponseSchema"
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        dataset = DatasetDAO.find_by_id_or_uuid(id_or_uuid)
+        if not dataset:
+            return self.response_404()
+
+        dataset_info = {
+            "id": dataset.id,
+            "name": dataset.name,
+            "database_id": dataset.database_id,
+            "database_name": (
+                dataset.database.database_name if dataset.database else None
+            ),
+            "schema": dataset.schema,
+            "table_name": dataset.table_name,
+        }
+
+        # Get upstream (database) information
+        upstream: dict[str, Any] = {}
+        if dataset.database:
+            upstream["database"] = {
+                "id": dataset.database.id,
+                "database_name": dataset.database.database_name,
+                "backend": dataset.database.backend,
+            }
+        else:
+            upstream["database"] = None
+
+        # Get downstream (charts and dashboards) information
+        related_data = DatasetDAO.get_related_objects(dataset.id)
+
+        # Build chart information with dashboard IDs
+        charts = []
+        for chart in related_data["charts"]:
+            dashboard_ids = [d.id for d in chart.dashboards]

Review Comment:
   Fixed. The per-chart `dashboard_ids` list in the dataset lineage response is 
now filtered through `can_access_dashboard`.



##########
superset/datasets/api.py:
##########
@@ -846,6 +849,119 @@ def related_objects(self, id_or_uuid: str) -> Response:
             dashboards={"count": len(dashboards), "result": dashboards},
         )
 
+    @expose("/<id_or_uuid>/lineage", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.lineage",
+        log_to_statsd=False,
+    )
+    def lineage(self, id_or_uuid: str) -> Response:
+        """Get lineage information for a dataset.
+        ---
+        get:
+          summary: Get lineage information for a dataset
+          description: >-
+            Returns upstream (database) and downstream (charts, dashboards) 
lineage
+            information for a dataset
+          parameters:
+          - in: path
+            name: id_or_uuid
+            schema:
+              type: string
+            description: Either the id of the dataset, or its uuid
+          responses:
+            200:
+              description: Lineage information
+              content:
+                application/json:
+                  schema:
+                    $ref: "#/components/schemas/DatasetLineageResponseSchema"
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        dataset = DatasetDAO.find_by_id_or_uuid(id_or_uuid)
+        if not dataset:
+            return self.response_404()
+
+        dataset_info = {
+            "id": dataset.id,
+            "name": dataset.name,
+            "database_id": dataset.database_id,
+            "database_name": (
+                dataset.database.database_name if dataset.database else None
+            ),
+            "schema": dataset.schema,
+            "table_name": dataset.table_name,
+        }
+
+        # Get upstream (database) information
+        upstream: dict[str, Any] = {}
+        if dataset.database:
+            upstream["database"] = {
+                "id": dataset.database.id,
+                "database_name": dataset.database.database_name,
+                "backend": dataset.database.backend,
+            }
+        else:
+            upstream["database"] = None
+
+        # Get downstream (charts and dashboards) information
+        related_data = DatasetDAO.get_related_objects(dataset.id)
+
+        # Build chart information with dashboard IDs
+        charts = []
+        for chart in related_data["charts"]:
+            dashboard_ids = [d.id for d in chart.dashboards]
+            charts.append(
+                {
+                    "id": chart.id,
+                    "slice_name": chart.slice_name,
+                    "viz_type": chart.viz_type,
+                    "dashboard_ids": dashboard_ids,
+                }
+            )
+
+        # Build dashboard information with chart IDs
+        dashboards = []
+        for dashboard in related_data["dashboards"]:
+            chart_ids = [
+                chart.id
+                for chart in dashboard.slices
+                if chart.datasource_id == dataset.id
+            ]
+            dashboards.append(
+                {
+                    "id": dashboard.id,
+                    "title": dashboard.dashboard_title,
+                    "slug": dashboard.slug,
+                    "chart_ids": chart_ids,

Review Comment:
   Fixed. The downstream dashboards list in dataset lineage now filters through 
`security_manager.can_access_dashboard`, matching `related_objects`.



##########
tests/integration_tests/charts/api_tests.py:
##########
@@ -2444,3 +2449,30 @@ def test_related_owners_allowed_for_write_user(self):
         self.login(ADMIN_USERNAME)
         rv = self.client.get("api/v1/chart/related/owners")
         assert rv.status_code == 200
+
+    @pytest.mark.usefixtures("inject_expected_chart_lineage")
+    def test_get_chart_lineage(self):
+        """
+        Chart API: Test get chart lineage
+        """
+        self.login(ADMIN_USERNAME)
+        chart_id = self.chart_lineage["chart_id"]
+        expected = self.chart_lineage["expected"]
+
+        uri = f"api/v1/chart/{chart_id}/lineage"
+        rv = self.get_assert_metric(uri, "lineage")
+        assert rv.status_code == 200
+
+        data = json.loads(rv.data.decode("utf-8"))
+
+        # Assert the entire response matches expected structure
+        assert data == expected

Review Comment:
   Fixed. The lineage payload is wrapped under `result`, so the assertion now 
compares `data["result"]` to `expected` (applied consistently to the chart, 
dataset, and dashboard lineage tests).



##########
superset-frontend/src/hooks/apiResources/lineage.ts:
##########
@@ -0,0 +1,134 @@
+/**
+ * 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 { useApiV1Resource } from './apiResources';
+
+// Database entity type
+export type DatabaseEntity = {
+  id: number;
+  database_name: string;
+  backend: string;
+};
+
+// Dataset entity type
+export type DatasetEntity = {
+  id: number;
+  name: string;
+  schema: string | null;
+  table_name: string;
+  database_id: number;
+  database_name: string;
+  chart_ids?: number[];
+};
+
+// Chart entity type
+export type ChartEntity = {
+  id: number;
+  slice_name: string;
+  viz_type: string;
+  dashboard_ids?: number[];
+  dataset_id?: number;
+};
+
+// Dashboard entity type
+export type DashboardEntity = {
+  id: number;
+  title: string;
+  slug: string;
+  chart_ids?: number[];
+};
+
+// Dataset lineage response type
+export type DatasetLineage = {
+  dataset: DatasetEntity;
+  upstream: {
+    database: DatabaseEntity;
+  };
+  downstream: {
+    charts: {
+      count: number;
+      result: ChartEntity[];
+    };
+    dashboards: {
+      count: number;
+      result: DashboardEntity[];
+    };
+  };
+};
+
+// Chart lineage response type
+export type ChartLineage = {
+  chart: ChartEntity & {
+    datasource_id: number;
+    datasource_type: string;
+  };
+  upstream: {
+    dataset: DatasetEntity;
+    database: DatabaseEntity;
+  };
+  downstream: {
+    dashboards: {
+      count: number;
+      result: DashboardEntity[];
+    };
+  };
+};
+
+// Dashboard lineage response type
+export type DashboardLineage = {
+  dashboard: DashboardEntity & {
+    published: boolean;
+  };
+  upstream: {
+    charts: {
+      count: number;
+      result: ChartEntity[];
+    };
+    datasets: {
+      count: number;
+      result: DatasetEntity[];
+    };
+    databases: {
+      count: number;
+      result: DatabaseEntity[];
+    };
+  };
+  downstream: null;
+};
+
+/**
+ * Hook to fetch lineage data for a dataset
+ * @param idOrUuid Dataset ID or UUID
+ */
+export const useDatasetLineage = (idOrUuid: string | number) =>
+  useApiV1Resource<DatasetLineage>(`/api/v1/dataset/${idOrUuid}/lineage`);
+
+/**
+ * Hook to fetch lineage data for a chart
+ * @param idOrUuid Chart ID or UUID
+ */
+export const useChartLineage = (idOrUuid: string | number) =>
+  useApiV1Resource<ChartLineage>(`/api/v1/chart/${idOrUuid}/lineage`);
+
+/**
+ * Hook to fetch lineage data for a dashboard
+ * @param idOrSlug Dashboard ID or slug
+ */
+export const useDashboardLineage = (idOrSlug: string | number) =>
+  useApiV1Resource<DashboardLineage>(`/api/v1/dashboard/${idOrSlug}/lineage`);

Review Comment:
   Fixed. The hooks now support a skip token: they skip automatically when the 
id is empty (and callers can pass an explicit skip), so inactive lineage 
requests against invalid endpoints are no longer fired.



##########
superset/charts/api.py:
##########
@@ -313,6 +314,103 @@ def get(self, id_or_uuid: str) -> Response:
         except ChartNotFoundError:
             return self.response_404()
 
+    @expose("/<id_or_uuid>/lineage", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.lineage",
+        log_to_statsd=False,
+    )
+    def lineage(self, id_or_uuid: str) -> Response:
+        """Get lineage information for a chart.
+        ---
+        get:
+          summary: Get lineage information for a chart
+          description: >-
+            Returns upstream (dataset, database) and downstream (dashboards) 
lineage
+            information for a chart
+          parameters:
+          - in: path
+            name: id_or_uuid
+            schema:
+              type: string
+            description: Either the id of the chart, or its uuid
+          responses:
+            200:
+              description: Lineage information
+              content:
+                application/json:
+                  schema:
+                    $ref: "#/components/schemas/ChartLineageResponseSchema"
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        try:
+            chart = ChartDAO.get_by_id_or_uuid(id_or_uuid)
+        except ChartNotFoundError:
+            return self.response_404()
+
+        chart_info = {
+            "id": chart.id,
+            "slice_name": chart.slice_name,
+            "viz_type": chart.viz_type,
+        }
+
+        # Get upstream (dataset and database) information
+        upstream: dict[str, Any] = {}
+        if dataset := chart.datasource:
+            upstream["dataset"] = {
+                "id": dataset.id,
+                "name": dataset.name,
+                "database_id": dataset.database_id,
+                "database_name": dataset.database.database_name
+                if dataset.database
+                else None,
+                "schema": dataset.schema,
+                "table_name": dataset.table_name,
+            }
+            if dataset.database:
+                upstream["database"] = {
+                    "id": dataset.database.id,
+                    "database_name": dataset.database.database_name,
+                    "backend": dataset.database.backend,
+                }
+            else:
+                upstream["database"] = None
+        else:
+            upstream["dataset"] = None
+            upstream["database"] = None
+
+        # Get downstream (dashboards) information
+        dashboards = []
+        for dashboard in chart.dashboards:
+            dashboards.append(
+                {
+                    "id": dashboard.id,
+                    "title": dashboard.dashboard_title,
+                    "slug": dashboard.slug,
+                }
+            )

Review Comment:
   Fixed. The chart lineage endpoint now filters `chart.dashboards` through 
`security_manager.can_access_dashboard` before adding them to the response, 
consistent with the dataset lineage endpoint.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to