You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/18 07:50:33 UTC
(superset) branch fix/issue-37487-chart-export-filename created (now b4788d2e213)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a change to branch fix/issue-37487-chart-export-filename
in repository https://gitbox.apache.org/repos/asf/superset.git
at b4788d2e213 fix(charts): pass filename through to non-streaming CSV/XLSX/zip export headers
This branch includes the following new commits:
new b4788d2e213 fix(charts): pass filename through to non-streaming CSV/XLSX/zip export headers
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: fix(charts): pass filename through to non-streaming CSV/XLSX/zip export headers
Posted by ru...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch fix/issue-37487-chart-export-filename
in repository https://gitbox.apache.org/repos/asf/superset.git
commit b4788d2e2138190077bab9249b016f623668b977
Author: Evan <ev...@preset.io>
AuthorDate: Sat Jul 18 00:47:23 2026 -0700
fix(charts): pass filename through to non-streaming CSV/XLSX/zip export headers
The /data endpoint already extracts a sanitized filename (falling back to
a chart-name-based default) for the streaming CSV export path, but the
non-streaming CSV, XLSX, and multi-query zip branches of
_send_chart_response ignored it and always called
generate_download_headers() with no filename, producing generic
timestamp-only download names.
Fixes #37487
---
superset/charts/data/api.py | 43 ++++++++----
tests/unit_tests/charts/test_chart_data_api.py | 94 +++++++++++++++++++++++++-
2 files changed, 123 insertions(+), 14 deletions(-)
diff --git a/superset/charts/data/api.py b/superset/charts/data/api.py
index a896fedaeba..3a212ef6e92 100644
--- a/superset/charts/data/api.py
+++ b/superset/charts/data/api.py
@@ -495,13 +495,19 @@ class ChartDataRestApi(ChartRestApi):
result, form_data, filename=filename, expected_rows=expected_rows
)
+ export_filename = filename or self._get_default_export_filename(form_data)
+
if len(result["queries"]) == 1:
# return single query results
data = result["queries"][0]["data"]
if is_csv_format:
- return CsvResponse(data, headers=generate_download_headers("csv"))
+ return CsvResponse(
+ data, headers=generate_download_headers("csv", export_filename)
+ )
- return XlsxResponse(data, headers=generate_download_headers("xlsx"))
+ return XlsxResponse(
+ data, headers=generate_download_headers("xlsx", export_filename)
+ )
# return multi-query results bundled as a zip file
def _process_data(query_data: Any) -> Any:
@@ -519,7 +525,7 @@ class ChartDataRestApi(ChartRestApi):
}
return Response(
create_zip(files),
- headers=generate_download_headers("zip"),
+ headers=generate_download_headers("zip", export_filename),
mimetype="application/zip",
)
@@ -545,6 +551,26 @@ class ChartDataRestApi(ChartRestApi):
return self.response_400(message=f"Unsupported result_format: {result_format}")
+ @staticmethod
+ def _get_default_export_filename(form_data: dict[str, Any] | None) -> str:
+ """
+ Build a fallback export filename (without extension) from the chart's
+ name so downloaded files are easy to identify, instead of the
+ generic timestamp-only default used by ``generate_download_headers``.
+
+ Used whenever the client hasn't supplied an explicit filename, by
+ both the streaming and non-streaming chart data export responses.
+ """
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ chart_name = "export"
+
+ if form_data and form_data.get("slice_name"):
+ chart_name = form_data["slice_name"]
+ elif form_data and form_data.get("viz_type"):
+ chart_name = form_data["viz_type"]
+
+ return secure_filename(f"superset_{chart_name}_{timestamp}")
+
def _log_is_cached(
self,
result: dict[str, Any],
@@ -709,16 +735,7 @@ class ChartDataRestApi(ChartRestApi):
# Use filename from frontend if provided, otherwise generate one
if not filename:
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
- chart_name = "export"
-
- if form_data and form_data.get("slice_name"):
- chart_name = form_data["slice_name"]
- elif form_data and form_data.get("viz_type"):
- chart_name = form_data["viz_type"]
-
- # Sanitize chart name for filename
- filename = secure_filename(f"superset_{chart_name}_{timestamp}.csv")
+ filename = f"{self._get_default_export_filename(form_data)}.csv"
else:
# Sanitize the client-provided filename before placing it in the
# Content-Disposition header to avoid header/path injection.
diff --git a/tests/unit_tests/charts/test_chart_data_api.py b/tests/unit_tests/charts/test_chart_data_api.py
index a1334c3471f..de4ef8da6d5 100644
--- a/tests/unit_tests/charts/test_chart_data_api.py
+++ b/tests/unit_tests/charts/test_chart_data_api.py
@@ -17,7 +17,7 @@
from __future__ import annotations
from typing import Any, TYPE_CHECKING
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
from flask import Flask, g
@@ -236,3 +236,95 @@ def test_extract_export_filename_preserves_normal_name() -> None:
def test_extract_export_filename_all_special_falls_back_to_none() -> None:
"""A name with no usable characters becomes None (generated downstream)."""
assert _extract_filename("***") is None
+
+
+def test_send_chart_response_uses_chart_name_for_csv_filename() -> None:
+ """
+ Regression test: the non-streaming CSV export branch of
+ _send_chart_response must include the chart's name in the
+ Content-Disposition header, not just a bare timestamp, mirroring the
+ streaming CSV export path.
+ """
+ from superset.charts.data.api import ChartDataRestApi
+ from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
+
+ query_context = MagicMock()
+ query_context.result_type = ChartDataResultType.FULL
+ query_context.result_format = ChartDataResultFormat.CSV
+
+ result = {
+ "query_context": query_context,
+ "queries": [{"data": "col_a,col_b\n1,2\n"}],
+ }
+
+ api = ChartDataRestApi()
+ with (
+ patch("superset.charts.data.api.security_manager") as mock_security_manager,
+ patch("superset.charts.data.api.is_feature_enabled", return_value=False),
+ ):
+ mock_security_manager.can_access.return_value = True
+ response = api._send_chart_response(
+ result, form_data={"slice_name": "My Chart", "row_limit": 10}
+ )
+
+ content_disposition = response.headers["Content-Disposition"]
+ assert "My_Chart" in content_disposition
+
+
+def test_send_chart_response_uses_chart_name_for_xlsx_filename() -> None:
+ """Same regression as above, for the XLSX export branch."""
+ from superset.charts.data.api import ChartDataRestApi
+ from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
+
+ query_context = MagicMock()
+ query_context.result_type = ChartDataResultType.FULL
+ query_context.result_format = ChartDataResultFormat.XLSX
+
+ result = {
+ "query_context": query_context,
+ "queries": [{"data": b"fake-xlsx-bytes"}],
+ }
+
+ api = ChartDataRestApi()
+ with (
+ patch("superset.charts.data.api.security_manager") as mock_security_manager,
+ patch("superset.charts.data.api.is_feature_enabled", return_value=False),
+ ):
+ mock_security_manager.can_access.return_value = True
+ response = api._send_chart_response(
+ result, form_data={"slice_name": "My Chart", "row_limit": 10}
+ )
+
+ content_disposition = response.headers["Content-Disposition"]
+ assert "My_Chart" in content_disposition
+
+
+def test_send_chart_response_uses_chart_name_for_zip_filename() -> None:
+ """Same regression as above, for the multi-query zip export branch."""
+ from superset.charts.data.api import ChartDataRestApi
+ from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
+
+ query_context = MagicMock()
+ query_context.result_type = ChartDataResultType.FULL
+ query_context.result_format = ChartDataResultFormat.CSV
+
+ result = {
+ "query_context": query_context,
+ "queries": [
+ {"data": "col_a\n1\n"},
+ {"data": "col_a\n2\n"},
+ ],
+ }
+
+ api = ChartDataRestApi()
+ with (
+ patch("superset.charts.data.api.security_manager") as mock_security_manager,
+ patch("superset.charts.data.api.is_feature_enabled", return_value=False),
+ ):
+ mock_security_manager.can_access.return_value = True
+ response = api._send_chart_response(
+ result, form_data={"slice_name": "My Chart", "row_limit": 10}
+ )
+
+ content_disposition = response.headers["Content-Disposition"]
+ assert "My_Chart" in content_disposition