Files
superset2/tests/unit_tests/dashboards/test_excel_export_email.py
Hugh A Miles II cbbf5382e8 feat(dashboard): address review feedback on Excel export (i18n, grouped errors, throttle)
Resolves betodealmeida's review comments on PR #41133:

- i18n: wrap all user-facing email strings in gettext with named
  placeholders (build_subject / errored section / success + failure bodies).
- Human-readable link expiry via a pluralized, translatable helper, fixing
  the sub-hour "0 hours" underreporting.
- Group charts that could not be exported by reason
  (no-query-context / timeout / general-exception) instead of a flat
  "skipped" list; render one labelled section per reason in the email.
- Replace deprecated datetime.utcnow() with datetime.now(tz=timezone.utc).
- Throttle concurrent exports per user+dashboard via a cache lock: return
  202 "already in progress" if one is in flight, clear the lock in the
  task's finally block (TTL is the backstop).
- Tests: switch the export API tests to the @with_config decorator; add
  unit coverage for grouped errors and the throttle-lock cleanup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:20:36 -04:00

161 lines
5.3 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_TIMEOUT: ["20 - Slow"],
email.ERROR_GENERAL: ["30 - Boom"],
},
)
# Each reason renders its own labelled section with the right chart.
assert "no saved query context" in html
assert "timed out" in html
assert "an error occurred" in html
assert "<li>10 - NoContext</li>" in html
assert "<li>20 - Slow</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_TIMEOUT: ["20 - Slow"]},
)
assert "timed out" in html
assert "no saved query context" not in html
assert "<li>20 - Slow</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 "&lt;script&gt;" 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,
)