You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by hu...@apache.org on 2026/07/02 23:21:31 UTC
(superset) 01/01: feat(dashboard): add "Export Images to Excel" mode for chart images
This is an automated email from the ASF dual-hosted git repository.
hughhhh pushed a commit to branch hughhhh/dashboard-excel-image-export
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 0a06f00b87d39096916862d7d7e62b67fc9be026
Author: Hugh A Miles II <hu...@gmail.com>
AuthorDate: Thu Jul 2 19:21:16 2026 -0400
feat(dashboard): add "Export Images to Excel" mode for chart images
Add an image-export mode to the async dashboard Excel export. A new
"Export Images to Excel" menu item posts mode="images"; the Celery task
then embeds non-table charts as rendered PNGs (via the same ChartScreenshot
path scheduled reports use, injecting the live dashboard filter state as
extra_form_data) while table charts stay tabular. mode="data" preserves the
existing behavior exactly. The change is fully additive and threads a `mode`
flag from the menu through the API, schema, and task.
Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
.../DownloadMenuItems/DownloadMenuItems.test.tsx | 26 +++-
.../components/menu/DownloadMenuItems/index.tsx | 11 +-
superset/config.py | 4 +
superset/dashboards/api.py | 1 +
superset/dashboards/excel_export/screenshot.py | 98 ++++++++++++++
superset/dashboards/schemas.py | 11 +-
superset/tasks/export_dashboard_excel.py | 77 ++++++++++-
superset/utils/excel_streaming.py | 22 ++++
.../dashboards/test_excel_export_screenshot.py | 146 +++++++++++++++++++++
.../tasks/test_export_dashboard_excel.py | 110 +++++++++++++++-
tests/unit_tests/utils/excel_streaming_tests.py | 47 +++++++
11 files changed, 536 insertions(+), 17 deletions(-)
diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
index 05a6db75efd..353dc786c92 100644
--- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
+++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
@@ -110,19 +110,21 @@ test('Should render all menu items', () => {
// Export options
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
+ expect(screen.getByText('Export Images to Excel')).toBeInTheDocument();
expect(screen.getByText('Export YAML')).toBeInTheDocument();
expect(screen.getByText('Export as Example')).toBeInTheDocument();
});
-test('Export Data to Excel is hidden when userCanExport is false', () => {
+test('Excel export items are hidden when userCanExport is false', () => {
render(<MenuWrapperWithProps userCanExport={false} />, { useRedux: true });
expect(screen.queryByText('Export Data to Excel')).not.toBeInTheDocument();
+ expect(screen.queryByText('Export Images to Excel')).not.toBeInTheDocument();
// YAML export is not gated and remains visible
expect(screen.getByText('Export YAML')).toBeInTheDocument();
});
-test('Export Data to Excel posts active_data_mask and shows a pending toast', async () => {
+test('Export Data to Excel posts mode "data" and shows a pending toast', async () => {
mockSupersetClient.post.mockResolvedValue({} as never);
render(<MenuWrapper />, { useRedux: true });
@@ -132,7 +134,25 @@ test('Export Data to Excel posts active_data_mask and shows a pending toast', as
await waitFor(() => {
expect(mockSupersetClient.post).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/123/export_xlsx/',
- jsonPayload: { active_data_mask: {} },
+ jsonPayload: { active_data_mask: {}, mode: 'data' },
+ });
+ expect(mockAddSuccessToast).toHaveBeenCalledWith(
+ "Your export is being prepared. You'll receive an email when it's ready.",
+ );
+ });
+});
+
+test('Export Images to Excel posts mode "images" and shows a pending toast', async () => {
+ mockSupersetClient.post.mockResolvedValue({} as never);
+
+ render(<MenuWrapper />, { useRedux: true });
+
+ await userEvent.click(screen.getByText('Export Images to Excel'));
+
+ await waitFor(() => {
+ expect(mockSupersetClient.post).toHaveBeenCalledWith({
+ endpoint: '/api/v1/dashboard/123/export_xlsx/',
+ jsonPayload: { active_data_mask: {}, mode: 'images' },
});
expect(mockAddSuccessToast).toHaveBeenCalledWith(
"Your export is being prepared. You'll receive an email when it's ready.",
diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
index a47ebf965df..01091176249 100644
--- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
+++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
@@ -167,11 +167,11 @@ export const useDownloadMenuItems = (
}
};
- const onExportXlsx = async () => {
+ const onExportXlsx = async (mode: 'data' | 'images') => {
try {
await SupersetClient.post({
endpoint: `/api/v1/dashboard/${dashboardId}/export_xlsx/`,
- jsonPayload: { active_data_mask: buildActiveDataMask() },
+ jsonPayload: { active_data_mask: buildActiveDataMask(), mode },
});
addSuccessToast(
t(
@@ -242,7 +242,12 @@ export const useDownloadMenuItems = (
{
key: 'export-xlsx',
label: t('Export Data to Excel'),
- onClick: onExportXlsx,
+ onClick: () => onExportXlsx('data'),
+ },
+ {
+ key: 'export-xlsx-images',
+ label: t('Export Images to Excel'),
+ onClick: () => onExportXlsx('images'),
},
]
: []),
diff --git a/superset/config.py b/superset/config.py
index d5453d1ac60..576a507686e 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1385,6 +1385,10 @@ EXCEL_EXPORT_LINK_TTL_SECONDS = 86400
# endpoint_url for S3-compatible stores (MinIO/LocalStack). Credentials
# otherwise resolve through the standard boto3 chain.
EXCEL_EXPORT_S3_CLIENT_KWARGS: dict[str, Any] = {}
+# Viz types treated as tables in the "Export Images to Excel" mode: these charts
+# stay tabular (one worksheet of data) while every other viz type is embedded as
+# a rendered image. Set to None to fall back to the built-in default.
+EXCEL_EXPORT_TABLE_VIZ_TYPES: set[str] | None = None
# ---------------------------------------------------
# Time grain configurations
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 3941beea326..78c52ca0688 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -1567,6 +1567,7 @@ class DashboardRestApi(
"user_id": g.user.id,
"active_data_mask": payload.get("active_data_mask", {}),
"job_id": job_id,
+ "mode": payload.get("mode", "data"),
},
task_id=job_id,
)
diff --git a/superset/dashboards/excel_export/screenshot.py b/superset/dashboards/excel_export/screenshot.py
new file mode 100644
index 00000000000..5e8f33e5f8a
--- /dev/null
+++ b/superset/dashboards/excel_export/screenshot.py
@@ -0,0 +1,98 @@
+# 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.
+"""
+Render a single dashboard chart to a PNG for the image-mode Excel export.
+
+This reuses the same headless render path scheduled reports use
+(:class:`~superset.utils.screenshots.ChartScreenshot`), but points it at an
+Explore URL whose ``form_data`` carries the live dashboard filter state — so an
+embedded image reflects the same filters the data path applies, rather than the
+chart's default saved state.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from flask import current_app
+
+from superset.charts.data.dashboard_filter_context import (
+ get_dashboard_filter_context,
+)
+from superset.utils import json
+from superset.utils.screenshots import ChartScreenshot
+from superset.utils.urls import get_url_path
+
+logger = logging.getLogger(__name__)
+
+
+def render_chart_image(
+ chart: Any,
+ dashboard_id: int,
+ active_data_mask: dict[str, Any],
+ user: Any,
+) -> bytes | None:
+ """
+ Render ``chart`` (as seen on ``dashboard_id``) to PNG bytes.
+
+ The chart is rendered through Explore in standalone mode with the live
+ dashboard filter state injected as ``extra_form_data`` — the same object the
+ data path merges into the query context — so the image and the data stay
+ consistent.
+
+ :param chart: The ``Slice`` to render
+ :param dashboard_id: The dashboard the chart is displayed on (for filter scope)
+ :param active_data_mask: Live dashboard filter state keyed by native filter id
+ :param user: The requesting user; the render runs with their permissions
+ :returns: PNG bytes, or ``None`` if the render failed (the caller skips and
+ notes the chart)
+ """
+ try:
+ filter_context = get_dashboard_filter_context(
+ dashboard_id=dashboard_id,
+ chart_id=chart.id,
+ active_data_mask=active_data_mask,
+ )
+
+ # Start from the chart's saved form data and force the slice id, then
+ # layer the live filters on top so the render matches the data path.
+ form_data: dict[str, Any] = json.loads(chart.params or "{}")
+ form_data["slice_id"] = chart.id
+ if filter_context.extra_form_data:
+ form_data["extra_form_data"] = filter_context.extra_form_data
+
+ url = get_url_path(
+ "ExploreView.root",
+ form_data=json.dumps(form_data),
+ )
+
+ window_size = current_app.config["WEBDRIVER_WINDOW"]["slice"]
+ screenshot = ChartScreenshot(
+ url,
+ chart.digest,
+ window_size=window_size,
+ thumb_size=window_size,
+ )
+ return screenshot.get_screenshot(user=user)
+ except Exception: # pylint: disable=broad-except
+ logger.exception(
+ "Failed to render image for chart %s in dashboard %s",
+ getattr(chart, "id", "?"),
+ dashboard_id,
+ )
+ return None
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index 60f85daab30..7bf3eda1b9a 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -18,7 +18,7 @@ import re
from typing import Any, Mapping, Union
from marshmallow import fields, post_dump, post_load, pre_load, Schema
-from marshmallow.validate import Length, ValidationError
+from marshmallow.validate import Length, OneOf, ValidationError
from superset import security_manager
from superset.tags.models import TagType
@@ -645,6 +645,15 @@ class DashboardExportXlsxPostSchema(Schema):
"each carrying an extraFormData object."
},
)
+ mode = fields.String(
+ load_default="data",
+ validate=OneOf(["data", "images"]),
+ metadata={
+ "description": "Export mode: 'data' streams each chart's tabular result "
+ "(default); 'images' embeds non-table charts as rendered images and "
+ "keeps table charts tabular."
+ },
+ )
class DashboardExportXlsxResponseSchema(Schema):
diff --git a/superset/tasks/export_dashboard_excel.py b/superset/tasks/export_dashboard_excel.py
index 664e45acb8c..22cc6cd5d6a 100644
--- a/superset/tasks/export_dashboard_excel.py
+++ b/superset/tasks/export_dashboard_excel.py
@@ -19,9 +19,12 @@ Celery task that exports every chart on a dashboard to a single multi-sheet
``.xlsx`` file, uploads it to S3, and emails the requesting user a pre-signed
download link.
-The task re-runs each chart's saved query context under the requesting user,
-applies the live dashboard filter state, and streams the results row-by-row into
-a constant-memory workbook so large dashboards never load all data at once.
+In ``"data"`` mode the task re-runs each chart's saved query context under the
+requesting user, applies the live dashboard filter state, and streams the results
+row-by-row into a constant-memory workbook so large dashboards never load all
+data at once. In ``"images"`` mode non-table charts are instead rendered to
+images (through the same headless path as scheduled reports, reflecting the live
+filters) and embedded, while table-like charts stay tabular.
"""
from __future__ import annotations
@@ -45,6 +48,7 @@ from superset.commands.chart.data.get_data_command import ChartDataCommand
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
from superset.dashboards.excel_export import email
from superset.dashboards.excel_export.layout import get_charts_in_layout_order
+from superset.dashboards.excel_export.screenshot import render_chart_image
from superset.extensions import celery_app
from superset.utils import json, s3
from superset.utils.core import override_user
@@ -52,6 +56,20 @@ from superset.utils.excel_streaming import StreamingXlsxWriter
logger = logging.getLogger(__name__)
+# Export modes: "data" streams every chart's tabular result (the default,
+# unchanged behavior); "images" embeds non-table charts as rendered images and
+# keeps only table-like charts tabular.
+EXPORT_MODE_DATA = "data"
+EXPORT_MODE_IMAGES = "images"
+
+# Viz types kept as tabular data in image mode; everything else is rendered as an
+# image. Operators can override the set via ``EXCEL_EXPORT_TABLE_VIZ_TYPES``.
+TABLE_VIZ_TYPES = {"table", "pivot_table_v2", "pivot_table"}
+
+
+class _ChartSkippedError(Exception):
+ """Signals a chart that could not be exported and should be listed as skipped."""
+
def _chart_label(chart: Any) -> str:
"""Human-readable label for a chart in the skipped-charts list."""
@@ -62,6 +80,34 @@ def _record_to_row(record: dict[str, Any], colnames: list[str]) -> list[Any]:
return [record.get(col) for col in colnames]
+def _table_viz_types() -> set[str]:
+ """Viz types kept tabular in image mode (config override or built-in default)."""
+ return current_app.config.get("EXCEL_EXPORT_TABLE_VIZ_TYPES") or TABLE_VIZ_TYPES
+
+
+def _renders_as_image(chart: Any, mode: str) -> bool:
+ """Whether this chart is embedded as an image rather than streamed as data."""
+ return mode == EXPORT_MODE_IMAGES and chart.viz_type not in _table_viz_types()
+
+
+def _write_chart_image_sheet(
+ writer: StreamingXlsxWriter,
+ chart: Any,
+ dashboard_id: int,
+ active_data_mask: dict[str, Any],
+ user: Any,
+) -> None:
+ """
+ Render a single chart to an image and embed it as its own sheet.
+
+ :raises _ChartSkippedError: if the chart could not be rendered
+ """
+ image = render_chart_image(chart, dashboard_id, active_data_mask, user)
+ if image is None:
+ raise _ChartSkippedError
+ writer.add_image_sheet(_chart_label(chart), image)
+
+
def _write_chart_sheets(
writer: StreamingXlsxWriter,
chart: Any,
@@ -116,17 +162,29 @@ def _build_workbook(
dashboard: Any,
active_data_mask: dict[str, Any],
job_id: str,
+ mode: str,
+ user: Any,
) -> list[str]:
"""Build the workbook on disk; return the list of skipped chart labels."""
skipped: list[str] = []
writer = StreamingXlsxWriter(path)
try:
for chart in get_charts_in_layout_order(dashboard):
- if not chart.query_context:
+ as_image = _renders_as_image(chart, mode)
+ # Image charts render from their saved params and don't need a query
+ # context; data (and table) charts still do.
+ if not as_image and not chart.query_context:
skipped.append(_chart_label(chart))
continue
try:
- _write_chart_sheets(writer, chart, dashboard.id, active_data_mask)
+ if as_image:
+ _write_chart_image_sheet(
+ writer, chart, dashboard.id, active_data_mask, user
+ )
+ else:
+ _write_chart_sheets(writer, chart, dashboard.id, active_data_mask)
+ except _ChartSkippedError:
+ skipped.append(_chart_label(chart))
except Exception: # pylint: disable=broad-except
logger.exception(
"Skipping chart %s in dashboard export %s", chart.id, job_id
@@ -171,14 +229,17 @@ def export_dashboard_excel(
user_id: int,
active_data_mask: dict[str, Any],
job_id: str,
+ mode: str = EXPORT_MODE_DATA,
) -> None:
"""
- Export a dashboard's chart data to an ``.xlsx`` and email a download link.
+ Export a dashboard's charts to an ``.xlsx`` and email a download link.
:param dashboard_id: The dashboard to export
:param user_id: The requesting user (the task runs with their permissions)
:param active_data_mask: Live dashboard filter state keyed by native filter id
:param job_id: Correlation id, also the Celery task id and S3 object name
+ :param mode: ``"data"`` streams every chart's tabular result; ``"images"``
+ embeds non-table charts as rendered images and keeps tables tabular
"""
# pylint: disable=import-outside-toplevel
from superset.models.dashboard import Dashboard
@@ -202,7 +263,9 @@ def export_dashboard_excel(
)
os.close(file_descriptor)
- skipped = _build_workbook(tmp_path, dashboard, active_data_mask, job_id)
+ skipped = _build_workbook(
+ tmp_path, dashboard, active_data_mask, job_id, mode, user
+ )
bucket = current_app.config["EXCEL_EXPORT_S3_BUCKET"]
key = (
diff --git a/superset/utils/excel_streaming.py b/superset/utils/excel_streaming.py
index e4fd17d52e9..e3ae8547074 100644
--- a/superset/utils/excel_streaming.py
+++ b/superset/utils/excel_streaming.py
@@ -33,6 +33,7 @@ import re
from collections.abc import Iterable, Sequence
from datetime import date, datetime
from decimal import Decimal
+from io import BytesIO
from typing import Any
import xlsxwriter
@@ -212,6 +213,27 @@ class StreamingXlsxWriter:
self.sheet_count += 1
return written
+ def add_image_sheet(self, name: str, image_bytes: bytes) -> None:
+ """
+ Write a single sheet holding a rendered chart image.
+
+ The image is embedded top-left; ``xlsxwriter`` buffers image data and
+ writes it at :meth:`close`, so this composes with ``constant_memory``
+ mode just like the row-streaming sheets.
+
+ :param name: Desired sheet name (sanitized/de-duplicated automatically)
+ :param image_bytes: PNG bytes to embed
+ """
+ sheet_name = sanitize_sheet_name(name, self._used_sheet_names)
+ worksheet = self._workbook.add_worksheet(sheet_name)
+ worksheet.insert_image(
+ 0,
+ 0,
+ f"{sheet_name}.png",
+ {"image_data": BytesIO(image_bytes)},
+ )
+ self.sheet_count += 1
+
def add_summary_sheet(self, name: str, lines: Sequence[str]) -> None:
"""
Write a single-column informational sheet (e.g. a list of skipped charts).
diff --git a/tests/unit_tests/dashboards/test_excel_export_screenshot.py b/tests/unit_tests/dashboards/test_excel_export_screenshot.py
new file mode 100644
index 00000000000..87513a8988d
--- /dev/null
+++ b/tests/unit_tests/dashboards/test_excel_export_screenshot.py
@@ -0,0 +1,146 @@
+# 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.
+from __future__ import annotations
+
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+from superset.charts.data.dashboard_filter_context import DashboardFilterContext
+from superset.dashboards.excel_export import screenshot as screenshot_module
+from superset.dashboards.excel_export.screenshot import render_chart_image
+from superset.utils import json
+
+MODULE = "superset.dashboards.excel_export.screenshot"
+
+
+def _chart(
+ chart_id: int = 42,
+ params: str = '{"viz_type": "pie"}',
+ digest: str = "abc123",
+) -> MagicMock:
+ chart = MagicMock()
+ chart.id = chart_id
+ chart.params = params
+ chart.digest = digest
+ return chart
+
+
+def _patched_app() -> Any:
+ app = MagicMock()
+ app.config = {"WEBDRIVER_WINDOW": {"slice": (3000, 1200)}}
+ return app
+
+
+def test_render_chart_image_builds_url_with_slice_id_and_filters() -> None:
+ chart = _chart()
+ active_mask = {"NATIVE_FILTER-1": {"extraFormData": {"filters": [{"col": "a"}]}}}
+ filter_context = DashboardFilterContext(
+ extra_form_data={"filters": [{"col": "a", "op": "IN", "val": ["x"]}]}
+ )
+
+ screenshot_instance = MagicMock()
+ screenshot_instance.get_screenshot.return_value = b"PNGBYTES"
+
+ with (
+ patch(
+ f"{MODULE}.get_dashboard_filter_context", return_value=filter_context
+ ) as mock_ctx,
+ patch(f"{MODULE}.get_url_path", return_value="/explore/url") as mock_url,
+ patch(
+ f"{MODULE}.ChartScreenshot", return_value=screenshot_instance
+ ) as mock_screenshot,
+ patch.object(screenshot_module, "current_app", _patched_app()),
+ ):
+ user = MagicMock()
+ result = render_chart_image(
+ chart, dashboard_id=7, active_data_mask=active_mask, user=user
+ )
+
+ assert result == b"PNGBYTES"
+
+ # Live filter state is resolved for this chart on this dashboard.
+ mock_ctx.assert_called_once_with(
+ dashboard_id=7, chart_id=42, active_data_mask=active_mask
+ )
+
+ # The Explore URL carries the slice id and the live extra_form_data.
+ _, url_kwargs = mock_url.call_args
+ form_data = json.loads(url_kwargs["form_data"])
+ assert form_data["slice_id"] == 42
+ assert form_data["viz_type"] == "pie"
+ assert form_data["extra_form_data"] == filter_context.extra_form_data
+
+ # ChartScreenshot is built with the chart digest + slice window sizing.
+ args, kwargs = mock_screenshot.call_args
+ assert args[0] == "/explore/url"
+ assert args[1] == "abc123"
+ assert kwargs["window_size"] == (3000, 1200)
+ assert kwargs["thumb_size"] == (3000, 1200)
+ screenshot_instance.get_screenshot.assert_called_once_with(user=user)
+
+
+def test_render_chart_image_omits_extra_form_data_when_no_filters() -> None:
+ chart = _chart()
+ filter_context = DashboardFilterContext(extra_form_data={})
+ screenshot_instance = MagicMock()
+ screenshot_instance.get_screenshot.return_value = b"PNG"
+
+ with (
+ patch(f"{MODULE}.get_dashboard_filter_context", return_value=filter_context),
+ patch(f"{MODULE}.get_url_path", return_value="/u") as mock_url,
+ patch(f"{MODULE}.ChartScreenshot", return_value=screenshot_instance),
+ patch.object(screenshot_module, "current_app", _patched_app()),
+ ):
+ render_chart_image(chart, dashboard_id=7, active_data_mask={}, user=MagicMock())
+
+ _, url_kwargs = mock_url.call_args
+ form_data = json.loads(url_kwargs["form_data"])
+ assert "extra_form_data" not in form_data
+
+
+def test_render_chart_image_returns_none_when_screenshot_fails() -> None:
+ chart = _chart()
+ screenshot_instance = MagicMock()
+ screenshot_instance.get_screenshot.return_value = None
+
+ with (
+ patch(
+ f"{MODULE}.get_dashboard_filter_context",
+ return_value=DashboardFilterContext(),
+ ),
+ patch(f"{MODULE}.get_url_path", return_value="/u"),
+ patch(f"{MODULE}.ChartScreenshot", return_value=screenshot_instance),
+ patch.object(screenshot_module, "current_app", _patched_app()),
+ ):
+ result = render_chart_image(
+ chart, dashboard_id=7, active_data_mask={}, user=MagicMock()
+ )
+
+ assert result is None
+
+
+def test_render_chart_image_returns_none_on_exception() -> None:
+ chart = _chart()
+
+ with patch(
+ f"{MODULE}.get_dashboard_filter_context", side_effect=ValueError("boom")
+ ):
+ result = render_chart_image(
+ chart, dashboard_id=7, active_data_mask={}, user=MagicMock()
+ )
+
+ assert result is None
diff --git a/tests/unit_tests/tasks/test_export_dashboard_excel.py b/tests/unit_tests/tasks/test_export_dashboard_excel.py
index 0ef3aa97ade..8fb859b110b 100644
--- a/tests/unit_tests/tasks/test_export_dashboard_excel.py
+++ b/tests/unit_tests/tasks/test_export_dashboard_excel.py
@@ -32,14 +32,36 @@ from superset.utils import json
MODULE = "superset.tasks.export_dashboard_excel"
-def _chart(chart_id: int, name: str, has_context: bool = True) -> mock.MagicMock:
+# A minimal valid 1x1 transparent PNG for image-mode tests.
+_PNG_1x1 = (
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06"
+ b"\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00"
+ b"\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+
+
+def _chart(
+ chart_id: int,
+ name: str,
+ has_context: bool = True,
+ viz_type: str = "line",
+) -> mock.MagicMock:
chart = mock.MagicMock()
chart.id = chart_id
chart.slice_name = name
+ chart.viz_type = viz_type
chart.query_context = json.dumps({"queries": [{}]}) if has_context else None
return chart
+def _media(path: str) -> list[str]:
+ """Embedded media entries of an xlsx (which is a zip archive)."""
+ import zipfile
+
+ with zipfile.ZipFile(path) as archive:
+ return [n for n in archive.namelist() if n.startswith("xl/media/")]
+
+
@pytest.fixture
def mocks() -> Iterator[dict[str, Any]]:
"""Patch every external dependency of the task; keep the real xlsx writer."""
@@ -58,6 +80,7 @@ def mocks() -> Iterator[dict[str, Any]]:
"get_dashboard_filter_context",
"ChartDataQueryContextSchema",
"ChartDataCommand",
+ "render_chart_image",
"s3",
"email",
)
@@ -83,11 +106,11 @@ def mocks() -> Iterator[dict[str, Any]]:
yield patched
-def _run(job_id: str = "job-1") -> None:
+def _run(job_id: str = "job-1", mode: str = "data") -> None:
from superset.tasks.export_dashboard_excel import export_dashboard_excel
export_dashboard_excel(
- dashboard_id=1, user_id=2, active_data_mask={}, job_id=job_id
+ dashboard_id=1, user_id=2, active_data_mask={}, job_id=job_id, mode=mode
)
@@ -210,3 +233,84 @@ def test_soft_time_limit_sends_failure_email(mocks: dict[str, Any]) -> None:
mocks["email"].build_failure_email.assert_called_once()
assert _no_temp_files_left("job-timeout")
+
+
+# --- image mode ---
+
+
+def test_images_mode_embeds_non_table_and_keeps_tables_tabular(
+ mocks: dict[str, Any],
+) -> None:
+ mocks["get_charts_in_layout_order"].return_value = [
+ _chart(10, "Line", viz_type="line"),
+ _chart(20, "Tbl", viz_type="table"),
+ ]
+ mocks["render_chart_image"].return_value = _PNG_1x1
+ mocks["ChartDataCommand"].return_value.run.return_value = {
+ "queries": [{"colnames": ["a"], "data": [{"a": 1}]}]
+ }
+
+ uploaded: dict[str, Any] = {}
+
+ def _capture(path: str, bucket: str, key: str) -> None:
+ uploaded["sheets"] = _read_sheets(path)
+ uploaded["media"] = _media(path)
+
+ mocks["s3"].upload_file_to_s3.side_effect = _capture
+
+ _run(mode="images")
+
+ # The non-table chart is rendered as an image (with the requesting user)...
+ mocks["render_chart_image"].assert_called_once()
+ render_args = mocks["render_chart_image"].call_args[0]
+ assert render_args[0].id == 10
+ assert render_args[3] is mocks["user"]
+ # ...and the table chart still goes through the data path.
+ mocks["ChartDataCommand"].return_value.run.assert_called_once()
+
+ assert set(uploaded["sheets"].keys()) == {"10 - Line", "20 - Tbl"}
+ # Exactly one embedded image (the non-table chart).
+ assert len(uploaded["media"]) == 1
+
+
+def test_images_mode_renders_chart_without_query_context(
+ mocks: dict[str, Any],
+) -> None:
+ # An image chart with no saved query context can still render.
+ mocks["get_charts_in_layout_order"].return_value = [
+ _chart(10, "Line", viz_type="line", has_context=False),
+ ]
+ mocks["render_chart_image"].return_value = _PNG_1x1
+
+ uploaded: dict[str, Any] = {}
+
+ def _capture(path: str, bucket: str, key: str) -> None:
+ uploaded["media"] = _media(path)
+
+ mocks["s3"].upload_file_to_s3.side_effect = _capture
+
+ _run(mode="images")
+
+ mocks["render_chart_image"].assert_called_once()
+ assert len(uploaded["media"]) == 1
+
+
+def test_images_mode_none_render_is_skipped(mocks: dict[str, Any]) -> None:
+ mocks["get_charts_in_layout_order"].return_value = [
+ _chart(10, "Line", viz_type="line"),
+ ]
+ mocks["render_chart_image"].return_value = None
+
+ uploaded: dict[str, Any] = {}
+
+ def _capture(path: str, bucket: str, key: str) -> None:
+ uploaded["sheets"] = _read_sheets(path)
+
+ mocks["s3"].upload_file_to_s3.side_effect = _capture
+
+ _run(mode="images")
+
+ _, kwargs = mocks["email"].build_success_email.call_args
+ assert kwargs["skipped_charts"] == ["10 - Line"]
+ # Nothing rendered → the summary sheet stands in for an empty workbook.
+ assert "Export Summary" in uploaded["sheets"]
diff --git a/tests/unit_tests/utils/excel_streaming_tests.py b/tests/unit_tests/utils/excel_streaming_tests.py
index b7b73f63f47..822db329d8b 100644
--- a/tests/unit_tests/utils/excel_streaming_tests.py
+++ b/tests/unit_tests/utils/excel_streaming_tests.py
@@ -218,3 +218,50 @@ def test_writer_summary_sheet(tmp_path: Path) -> None:
sheets = _read_workbook(path)
assert sheets["Export Summary"] == [["Skipped charts:"], ["10 - Broken"]]
+
+
+# --- add_image_sheet ---
+
+# A minimal valid 1x1 transparent PNG.
+_PNG_1x1 = (
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06"
+ b"\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00"
+ b"\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+
+
+def _read_media(path: str) -> list[str]:
+ """Return the embedded media entries of an xlsx (which is a zip archive)."""
+ import zipfile
+
+ with zipfile.ZipFile(path) as archive:
+ return [n for n in archive.namelist() if n.startswith("xl/media/")]
+
+
+def test_add_image_sheet_embeds_image_and_counts(tmp_path: Path) -> None:
+ path = str(tmp_path / "out.xlsx")
+ writer = StreamingXlsxWriter(path)
+ writer.add_image_sheet("10 - Chart", _PNG_1x1)
+ assert writer.sheet_count == 1
+ writer.close()
+
+ # The sheet exists (with a sanitized/unique name) and the PNG is embedded.
+ sheets = _read_workbook(path)
+ assert list(sheets.keys()) == ["10 - Chart"]
+ assert _read_media(path) == ["xl/media/image1.png"]
+
+
+def test_add_image_sheet_dedupes_and_composes_with_data_sheets(
+ tmp_path: Path,
+) -> None:
+ path = str(tmp_path / "out.xlsx")
+ writer = StreamingXlsxWriter(path)
+ writer.add_sheet("10 - Chart", ["a"], [[1]])
+ writer.add_image_sheet("10 - Chart", _PNG_1x1)
+ writer.close()
+
+ assert writer.sheet_count == 2
+ sheets = _read_workbook(path)
+ # The image sheet name is de-duplicated against the existing data sheet.
+ assert list(sheets.keys()) == ["10 - Chart", "10 - Chart~2"]
+ assert _read_media(path) == ["xl/media/image1.png"]