mirror of
https://github.com/apache/superset.git
synced 2026-07-20 13:45:47 +00:00
Addresses @EnxDev's review on #41133: - Soft timeouts now abort the export instead of being caught per chart: the per-chart SoftTimeLimitExceeded handler (and screenshot.py's broad except) re-raise, so the outer handler emails a failure and runs cleanup rather than running to the hard limit (leaking temp files, holding the lock). Removes the now-dead ERROR_TIMEOUT reason. - Concurrency guard uses a shared, atomic DistributedLock (Redis when configured, metadata DB otherwise) instead of cache_manager.cache, which is a no-op under the default NullCache and process-local under SimpleCache. The lock is released if apply_async fails so a broker outage can't block exports until the TTL expires. - boto3 is declared via a new `excel-export` optional extra; superset.utils.s3 raises an actionable install hint when it is missing. - "Export Images to Excel" is gated on the webdriver screenshot feature flags (it renders via the headless webdriver); documents EXCEL_EXPORT_TABLE_VIZ_TYPES and the image mode in the config docs and UPDATING.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
5.2 KiB
Python
158 lines
5.2 KiB
Python
# 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 datetime import datetime
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from superset.dashboards.excel_export import email
|
|
|
|
REQUESTED = datetime(2026, 1, 1, 12, 0, 0)
|
|
EXPIRES = datetime(2026, 1, 2, 12, 0, 0)
|
|
|
|
|
|
def test_success_email_contains_link_and_expiry() -> None:
|
|
html = email.build_success_email(
|
|
dashboard_title="Sales",
|
|
download_url="https://signed.example/file.xlsx?sig=abc",
|
|
requested_at=REQUESTED,
|
|
expires_at=EXPIRES,
|
|
ttl_seconds=86400,
|
|
errored={},
|
|
)
|
|
assert "https://signed.example/file.xlsx?sig=abc" in html
|
|
assert "expires in 24 hours" in html
|
|
assert "2026-01-02 12:00:00 UTC" in html
|
|
assert "2026-01-01 12:00:00 UTC" in html
|
|
assert "<li>" not in html # no skipped section
|
|
|
|
|
|
def test_success_email_sub_hour_ttl_reports_minutes() -> None:
|
|
# A sub-hour TTL must not truncate to "0 hours"; it should report minutes.
|
|
html = email.build_success_email(
|
|
dashboard_title="Sales",
|
|
download_url="https://x",
|
|
requested_at=REQUESTED,
|
|
expires_at=EXPIRES,
|
|
ttl_seconds=900,
|
|
errored={},
|
|
)
|
|
assert "expires in 15 minutes" in html
|
|
assert "0 hours" not in html
|
|
|
|
|
|
def test_success_email_mixed_ttl_reports_hours_and_minutes() -> None:
|
|
html = email.build_success_email(
|
|
dashboard_title="Sales",
|
|
download_url="https://x",
|
|
requested_at=REQUESTED,
|
|
expires_at=EXPIRES,
|
|
ttl_seconds=5400,
|
|
errored={},
|
|
)
|
|
assert "expires in 1 hour 30 minutes" in html
|
|
|
|
|
|
def test_success_email_lists_charts_with_no_query_context() -> None:
|
|
html = email.build_success_email(
|
|
dashboard_title="Sales",
|
|
download_url="https://x",
|
|
requested_at=REQUESTED,
|
|
expires_at=EXPIRES,
|
|
ttl_seconds=86400,
|
|
errored={email.ERROR_NO_QUERY_CONTEXT: ["10 - Broken chart"]},
|
|
)
|
|
assert "no saved query context" in html
|
|
assert "<li>10 - Broken chart</li>" in html
|
|
|
|
|
|
def test_success_email_groups_errors_by_reason() -> None:
|
|
html = email.build_success_email(
|
|
dashboard_title="Sales",
|
|
download_url="https://x",
|
|
requested_at=REQUESTED,
|
|
expires_at=EXPIRES,
|
|
ttl_seconds=86400,
|
|
errored={
|
|
email.ERROR_NO_QUERY_CONTEXT: ["10 - NoContext"],
|
|
email.ERROR_GENERAL: ["30 - Boom"],
|
|
},
|
|
)
|
|
# Each reason renders its own labelled section with the right chart.
|
|
assert "no saved query context" in html
|
|
assert "an error occurred" in html
|
|
assert "<li>10 - NoContext</li>" in html
|
|
assert "<li>30 - Boom</li>" in html
|
|
|
|
|
|
def test_success_email_omits_empty_reason_groups() -> None:
|
|
html = email.build_success_email(
|
|
dashboard_title="Sales",
|
|
download_url="https://x",
|
|
requested_at=REQUESTED,
|
|
expires_at=EXPIRES,
|
|
ttl_seconds=86400,
|
|
errored={email.ERROR_GENERAL: ["30 - Boom"]},
|
|
)
|
|
assert "an error occurred" in html
|
|
assert "no saved query context" not in html
|
|
assert "<li>30 - Boom</li>" in html
|
|
|
|
|
|
def test_success_email_escapes_title() -> None:
|
|
html = email.build_success_email(
|
|
dashboard_title="<script>alert(1)</script>",
|
|
download_url="https://x",
|
|
requested_at=REQUESTED,
|
|
expires_at=EXPIRES,
|
|
ttl_seconds=86400,
|
|
errored={},
|
|
)
|
|
assert "<script>" not in html
|
|
assert "<script>" in html
|
|
|
|
|
|
def test_failure_email_body() -> None:
|
|
html = email.build_failure_email("Sales", REQUESTED)
|
|
assert "could not be completed" in html
|
|
assert "2026-01-01 12:00:00 UTC" in html
|
|
|
|
|
|
@patch("superset.dashboards.excel_export.email.current_app")
|
|
def test_build_subject(mock_app: MagicMock) -> None:
|
|
mock_app.config = {"EMAIL_REPORTS_SUBJECT_PREFIX": "[Report] "}
|
|
assert (
|
|
email.build_subject("Sales", success=True)
|
|
== "[Report] Your dashboard export is ready: Sales"
|
|
)
|
|
assert email.build_subject("Sales", success=False).startswith(
|
|
"[Report] Your dashboard export could not be completed"
|
|
)
|
|
|
|
|
|
@patch("superset.dashboards.excel_export.email.send_email_smtp")
|
|
@patch("superset.dashboards.excel_export.email.current_app")
|
|
def test_send_export_email(mock_app: MagicMock, mock_send: MagicMock) -> None:
|
|
mock_app.config = {"SMTP_HOST": "localhost"}
|
|
email.send_export_email("user@example.com", "subj", "<html></html>")
|
|
mock_send.assert_called_once_with(
|
|
to="user@example.com",
|
|
subject="subj",
|
|
html_content="<html></html>",
|
|
config=mock_app.config,
|
|
)
|