You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by va...@apache.org on 2025/09/05 05:16:51 UTC

(superset) branch master updated: feat: Use dashboard name for screenshot download (#34988)

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

vavila 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 ce74ae095d feat: Use dashboard name for screenshot download (#34988)
ce74ae095d is described below

commit ce74ae095d41647206e2cf56c7bc8cc43ba4ea1f
Author: Vitor Avila <96...@users.noreply.github.com>
AuthorDate: Fri Sep 5 02:16:45 2025 -0300

    feat: Use dashboard name for screenshot download (#34988)
---
 superset-frontend/package-lock.json                | 10 ++-
 superset-frontend/package.json                     |  2 +
 .../src/dashboard/hooks/useDownloadScreenshot.ts   | 26 +++++++-
 superset/dashboards/api.py                         | 12 +++-
 tests/integration_tests/dashboards/api_tests.py    | 73 ++++++++++++++++++++++
 5 files changed, 118 insertions(+), 5 deletions(-)

diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index 786a059078..a728352117 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -57,6 +57,7 @@
         "antd": "^5.24.6",
         "chrono-node": "^2.7.8",
         "classnames": "^2.2.5",
+        "content-disposition": "^0.5.4",
         "d3-color": "^3.1.0",
         "d3-scale": "^2.1.2",
         "dayjs": "^1.11.13",
@@ -174,6 +175,7 @@
         "@testing-library/react": "^12.1.5",
         "@testing-library/react-hooks": "^8.0.1",
         "@testing-library/user-event": "^12.8.3",
+        "@types/content-disposition": "^0.5.9",
         "@types/dom-to-image": "^2.6.7",
         "@types/jest": "^29.5.14",
         "@types/js-levenshtein": "^1.1.3",
@@ -15250,6 +15252,13 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/content-disposition": {
+      "version": "0.5.9",
+      "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz",
+      "integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/conventional-commits-parser": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.1.tgz",
@@ -21928,7 +21937,6 @@
       "version": "0.5.4",
       "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
       "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "safe-buffer": "5.2.1"
diff --git a/superset-frontend/package.json b/superset-frontend/package.json
index 01b848564a..46b59362ba 100644
--- a/superset-frontend/package.json
+++ b/superset-frontend/package.json
@@ -125,6 +125,7 @@
     "antd": "^5.24.6",
     "chrono-node": "^2.7.8",
     "classnames": "^2.2.5",
+    "content-disposition": "^0.5.4",
     "d3-color": "^3.1.0",
     "d3-scale": "^2.1.2",
     "dayjs": "^1.11.13",
@@ -242,6 +243,7 @@
     "@testing-library/react": "^12.1.5",
     "@testing-library/react-hooks": "^8.0.1",
     "@testing-library/user-event": "^12.8.3",
+    "@types/content-disposition": "^0.5.9",
     "@types/dom-to-image": "^2.6.7",
     "@types/jest": "^29.5.14",
     "@types/js-levenshtein": "^1.1.3",
diff --git a/superset-frontend/src/dashboard/hooks/useDownloadScreenshot.ts b/superset-frontend/src/dashboard/hooks/useDownloadScreenshot.ts
index bc8ef7d1ff..f3b21691b3 100644
--- a/superset-frontend/src/dashboard/hooks/useDownloadScreenshot.ts
+++ b/superset-frontend/src/dashboard/hooks/useDownloadScreenshot.ts
@@ -20,6 +20,7 @@ import { useCallback, useEffect, useRef } from 'react';
 import { useSelector } from 'react-redux';
 import { useToasts } from 'src/components/MessageToasts/withToasts';
 import { last } from 'lodash';
+import contentDisposition from 'content-disposition';
 import {
   logging,
   t,
@@ -98,12 +99,31 @@ export const useDownloadScreenshot = (
           headers: { Accept: 'application/pdf, image/png' },
           parseMethod: 'raw',
         })
-          .then((response: Response) => response.blob())
-          .then(blob => {
+          .then((response: Response) => {
+            const disposition = response.headers.get('Content-Disposition');
+            let fileName = `screenshot.${format}`; // default filename
+
+            if (disposition) {
+              try {
+                const parsed = contentDisposition.parse(disposition);
+                if (parsed?.parameters?.filename) {
+                  fileName = parsed.parameters.filename;
+                }
+              } catch (error) {
+                console.warn(
+                  'Failed to parse Content-Disposition header:',
+                  error,
+                );
+              }
+            }
+
+            return response.blob().then(blob => ({ blob, fileName }));
+          })
+          .then(({ blob, fileName }) => {
             const url = window.URL.createObjectURL(blob);
             const a = document.createElement('a');
             a.href = url;
-            a.download = `screenshot.${format}`;
+            a.download = fileName;
             document.body.appendChild(a);
             a.click();
             document.body.removeChild(a);
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index a739e44c27..83387ee337 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -113,6 +113,7 @@ from superset.tasks.thumbnails import (
 from superset.tasks.utils import get_current_user
 from superset.utils import json
 from superset.utils.core import parse_boolean_string
+from superset.utils.file import get_filename
 from superset.utils.pdf import build_pdf_from_screenshots
 from superset.utils.screenshots import (
     DashboardScreenshot,
@@ -1204,6 +1205,10 @@ class DashboardRestApi(BaseSupersetModelRestApi):
                 image = cache_payload.get_image()
             except ScreenshotImageNotAvailableException:
                 return self.response_404()
+
+            filename = get_filename(
+                dashboard.dashboard_title or "screenshot", dashboard.id, skip_id=True
+            )
             if download_format == "pdf":
                 pdf_img = image.getvalue()
                 # Convert the screenshot to PDF
@@ -1212,13 +1217,18 @@ class DashboardRestApi(BaseSupersetModelRestApi):
                 return Response(
                     pdf_data,
                     mimetype="application/pdf",
-                    headers={"Content-Disposition": "inline; filename=dashboard.pdf"},
+                    headers={
+                        "Content-Disposition": f'attachment; filename="{filename}.pdf"'
+                    },
                     direct_passthrough=True,
                 )
             if download_format == "png":
                 return Response(
                     FileWrapper(image),
                     mimetype="image/png",
+                    headers={
+                        "Content-Disposition": f'attachment; filename="{filename}.png"'
+                    },
                     direct_passthrough=True,
                 )
         return self.response_404()
diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py
index 1e896d8854..a6551024eb 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -3180,6 +3180,79 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas
         response = self._get_screenshot(dashboard.id, cache_key, "invalid")
         assert response.status_code == 404
 
+    @with_feature_flags(THUMBNAILS=True, ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS=True)
+    @pytest.mark.usefixtures("create_dashboard_with_tag")
+    @patch("superset.dashboards.api.cache_dashboard_screenshot")
+    @patch("superset.dashboards.api.build_pdf_from_screenshots")
+    @patch("superset.dashboards.api.DashboardScreenshot.get_from_cache_key")
+    def test_screenshot_filename_in_header(
+        self, mock_get_from_cache_key, mock_build_pdf, mock_cache_task
+    ):
+        """
+        Dashboard API: Test that screenshot download includes proper filename in header
+        """
+        self.login(ADMIN_USERNAME)
+        mock_cache_task.return_value = None
+        mock_get_from_cache_key.return_value = ScreenshotCachePayload(
+            b"fake image data"
+        )
+        mock_build_pdf.return_value = b"fake pdf data"
+
+        dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.dashboard_title == "dash with tag")
+            .first()
+        )
+
+        cache_resp = self._cache_screenshot(dashboard.id)
+        assert cache_resp.status_code == 200
+        cache_key = json.loads(cache_resp.data.decode("utf-8"))["cache_key"]
+
+        for format, mt in {"png": "image/png", "pdf": "application/pdf"}.items():
+            response = self._get_screenshot(dashboard.id, cache_key, format)
+            assert response.status_code == 200
+            assert response.mimetype == mt
+            assert (
+                response.headers["Content-Disposition"]
+                == f'attachment; filename="dash_with_tag.{format}"'
+            )
+
+    @with_feature_flags(THUMBNAILS=True, ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS=True)
+    @pytest.mark.usefixtures("create_dashboard_with_tag")
+    @patch("superset.dashboards.api.cache_dashboard_screenshot")
+    @patch("superset.dashboards.api.build_pdf_from_screenshots")
+    @patch("superset.dashboards.api.DashboardScreenshot.get_from_cache_key")
+    def test_screenshot_filename_in_header_dashboard_with_no_title(
+        self, mock_get_from_cache_key, mock_build_pdf, mock_cache_task
+    ):
+        """
+        Dashboard API: Test that filename in header for screenshot download defaults
+        to screenshot if dashboard does not have a title.
+        """
+        self.login(ADMIN_USERNAME)
+        mock_cache_task.return_value = None
+        mock_get_from_cache_key.return_value = ScreenshotCachePayload(
+            b"fake image data"
+        )
+        mock_build_pdf.return_value = b"fake pdf data"
+
+        dashboard = Dashboard()
+        db.session.add(dashboard)
+        db.session.commit()
+
+        cache_resp = self._cache_screenshot(dashboard.id)
+        assert cache_resp.status_code == 200
+        cache_key = json.loads(cache_resp.data.decode("utf-8"))["cache_key"]
+
+        for format, mt in {"png": "image/png", "pdf": "application/pdf"}.items():
+            response = self._get_screenshot(dashboard.id, cache_key, format)
+            assert response.status_code == 200
+            assert response.mimetype == mt
+            assert (
+                response.headers["Content-Disposition"]
+                == f'attachment; filename="screenshot.{format}"'
+            )
+
     @with_feature_flags(THUMBNAILS=False, ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS=True)
     @pytest.mark.usefixtures("create_dashboard_with_tag")
     def test_cache_dashboard_screenshot_feature_thumbnails_ff_disabled(self):