Compare commits

...

4 Commits

Author SHA1 Message Date
Elizabeth Thompson
9701464fa5 address review feedback: preserve HTML 500 page for SupersetException
The new show_superset_exception handler always returned JSON, but the
uncaught-exception path it replaces (show_unexpected_exception) served
the branded 500.html page for HTML-accepting browser requests. Add the
same text/html branch used by show_command_errors/show_http_exception
so browser clients keep getting the branded error page instead of a
raw JSON payload.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 18:11:55 +00:00
Elizabeth Thompson
cfb0c6bd74 fix(errors): map uncaught SupersetException status/log-level correctly (SC-115609)
ChartDataRestApi.data() isn't decorated with @handle_api_exception, so a
correctly-classified QueryObjectValidationError (e.g. from a virtual
dataset's Jinja template indexing into an empty filter_values() list)
propagates uncaught to Flask's global catch-all, which always logs at
ERROR and returns HTTP 500 regardless of the exception's real status.

Add @app.errorhandler(SupersetException) mirroring the existing
handle_api_exception pattern: log level and HTTP status now follow
ex.status via get_logger_from_status, instead of being hardcoded to
ERROR/500. Subclasses with their own specific handler (SupersetErrorException,
SupersetErrorsException, CommandException) are unaffected by Flask's MRO
dispatch. 500-status SupersetException subclasses keep logging at ERROR.

Fixes SUPERSET-PYTHON-12EZ

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 15:19:10 +00:00
endimonan
7d2b184079 fix(explore): show selected current date range (#42616)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-07-31 10:55:09 +02:00
Amin Ghadersohi
7b351d53cf fix(mcp): fall back to a temporal dataset column (#42575) 2026-07-30 17:39:49 -04:00
7 changed files with 218 additions and 20 deletions

View File

@@ -49,6 +49,7 @@ export function CurrentCalendarFrame({ onChange, value }: FrameComponentProps) {
wrap: true,
}}
size="large"
value={value}
onChange={(e: any) => {
let newValue = e.target.value;
newValue = newValue.trim();

View File

@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render } from 'spec/helpers/testing-library';
import { render, screen } from 'spec/helpers/testing-library';
import { CurrentCalendarFrame } from '../components/CurrentCalendarFrame';
import { CurrentWeek } from '../types';
import { CurrentDay, CurrentWeek } from '../types';
const mockOnChange = jest.fn();
@@ -33,3 +33,9 @@ test('returns null if value is not a valid CurrentRangeType', () => {
);
expect(container.childNodes.length).toBe(0);
});
test('selects the radio button matching the current value', () => {
render(<CurrentCalendarFrame onChange={mockOnChange} value={CurrentDay} />);
expect(screen.getByRole('radio', { name: CurrentDay })).toBeChecked();
});

View File

@@ -27,6 +27,8 @@ import logging
from dataclasses import dataclass
from typing import Any, Dict, TYPE_CHECKING
from sqlalchemy.exc import SQLAlchemyError
if TYPE_CHECKING:
from superset.connectors.sqla.models import SqlaTable
@@ -1067,23 +1069,39 @@ def _resolve_big_number_temporal_column(
) -> str | None:
"""Resolve the column to bind a Big Number's TEMPORAL_RANGE filter to.
Falls back to the dataset's main_dttm_col when the caller didn't specify
temporal_column, and guards the result with is_column_truly_temporal (same
check map_xy_config applies to its x-axis) so a non-temporal column never
gets a TEMPORAL_RANGE filter. The dataset is fetched at most once here and
reused by is_column_truly_temporal instead of letting it re-query by
dataset_id.
Matches the Explore UI default: use the dataset's main_dttm_col, or its
first temporal column when no main column is configured. Guards candidates
with is_column_truly_temporal (the same check map_xy_config applies to its
x-axis) so a non-temporal column never gets a TEMPORAL_RANGE filter. The
dataset is fetched at most once here and reused by the temporal checks
instead of letting them re-query by dataset_id.
"""
dataset = None
if not config.temporal_column:
if config.temporal_column:
if is_column_truly_temporal(config.temporal_column, dataset_id):
return config.temporal_column
return None
try:
dataset = _find_dataset_by_id_or_uuid(dataset_id)
temporal_column = config.temporal_column or (
dataset.main_dttm_col if dataset else None
except SQLAlchemyError:
logger.warning(
"Unable to resolve a temporal column for dataset %s",
dataset_id,
exc_info=True,
)
return None
if not dataset:
return None
candidates: list[str] = []
if dataset.main_dttm_col:
candidates.append(dataset.main_dttm_col)
candidates.extend(
column.column_name for column in dataset.columns if column.column_name
)
if temporal_column and is_column_truly_temporal(
temporal_column, dataset_id, dataset=dataset
):
return temporal_column
for temporal_column in dict.fromkeys(candidates):
if is_column_truly_temporal(temporal_column, dataset_id, dataset=dataset):
return temporal_column
return None

View File

@@ -1392,7 +1392,7 @@ class BigNumberChartConfig(UnknownFieldCheckMixin):
"Temporal column for the trendline x-axis. Required when "
"show_trendline is True. Also used (whether or not a trendline is "
"shown) to bind the chart's dashboard time-range filter; when "
"omitted, the dataset's main temporal column is used instead."
"omitted, the dataset's default temporal column is used instead."
),
min_length=1,
max_length=255,

View File

@@ -171,6 +171,30 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901
logger.warning("SupersetErrorsException", exc_info=True)
return json_error_response(ex.errors, status=ex.status)
@app.errorhandler(SupersetException)
def show_superset_exception(ex: SupersetException) -> FlaskResponse:
logger_func, _ = get_logger_from_status(ex.status)
logger_func(ex.message, exc_info=True)
if "text/html" in request.accept_mimetypes and not app.config["DEBUG"]:
path = files("superset") / "static/assets/500.html"
# Try to serve HTML file; fall back to JSON if not built
try:
return send_file(path, max_age=0), ex.status
except FileNotFoundError:
pass
return json_error_response(
[
SupersetError(
message=ex.message,
error_type=SupersetErrorType.GENERIC_BACKEND_ERROR,
level=get_error_level_from_status(ex.status),
),
],
status=ex.status,
)
@app.errorhandler(CSRFError)
def refresh_csrf_token(ex: CSRFError) -> FlaskResponse:
"""Redirect to login if the CSRF token is expired"""

View File

@@ -21,6 +21,7 @@ from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
from sqlalchemy.exc import SQLAlchemyError
from superset.mcp_service.chart.chart_utils import (
_resolve_viz_type,
@@ -388,13 +389,13 @@ class TestMapBigNumberConfig:
mock_find_by_id_or_uuid.assert_called_once_with("42")
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
def test_total_no_dataset_main_dttm_col_skips_temporal_filter(
def test_total_without_temporal_columns_skips_temporal_filter(
self, mock_find_by_id_or_uuid: MagicMock
) -> None:
"""When the dataset has no temporal column, no TEMPORAL_RANGE filter
can be added — there's nothing for a dashboard filter to bind to."""
"""A dataset without temporal columns has nothing to bind to."""
mock_dataset = MagicMock()
mock_dataset.main_dttm_col = None
mock_dataset.columns = []
mock_find_by_id_or_uuid.return_value = mock_dataset
config = BigNumberChartConfig(
@@ -405,6 +406,84 @@ class TestMapBigNumberConfig:
assert "adhoc_filters" not in form_data
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
def test_total_falls_back_to_first_temporal_column_without_main_dttm_col(
self, mock_find_by_id_or_uuid: MagicMock
) -> None:
"""Match Explore when temporal columns exist but no main one is set."""
non_temporal_column = MagicMock(
column_name="revenue",
is_dttm=False,
)
temporal_column = MagicMock(
column_name="order_date",
is_dttm=True,
type=None,
)
mock_dataset = MagicMock(
main_dttm_col=None,
columns=[non_temporal_column, temporal_column],
)
mock_find_by_id_or_uuid.return_value = mock_dataset
config = BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
)
form_data = map_big_number_config(config, dataset_id=42)
assert form_data["adhoc_filters"][0]["subject"] == "order_date"
assert form_data["adhoc_filters"][0]["operator"] == "TEMPORAL_RANGE"
mock_find_by_id_or_uuid.assert_called_once_with("42")
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
def test_total_accepts_native_temporal_column_without_is_dttm(
self,
mock_find_by_id_or_uuid: MagicMock,
mock_is_temporal: MagicMock,
) -> None:
"""Use backend type classification instead of relying only on is_dttm."""
mock_dataset = MagicMock(
main_dttm_col=None,
columns=[
MagicMock(column_name="revenue", is_dttm=False),
MagicMock(column_name="order_date", is_dttm=False),
],
)
mock_find_by_id_or_uuid.return_value = mock_dataset
mock_is_temporal.side_effect = lambda column, *_args, **_kwargs: (
column == "order_date"
)
config = BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
)
form_data = map_big_number_config(config, dataset_id=42)
assert form_data["adhoc_filters"][0]["subject"] == "order_date"
assert [call.args[0] for call in mock_is_temporal.call_args_list] == [
"revenue",
"order_date",
]
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
def test_total_ignores_optional_temporal_binding_on_dataset_lookup_failure(
self, mock_find_by_id_or_uuid: MagicMock
) -> None:
"""A metadata failure must not make optional time binding abort mapping."""
mock_find_by_id_or_uuid.side_effect = SQLAlchemyError("metadata unavailable")
config = BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
)
form_data = map_big_number_config(config, dataset_id=42)
assert form_data["viz_type"] == "big_number_total"
assert "adhoc_filters" not in form_data
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
def test_total_non_temporal_column_skips_temporal_filter(
self, mock_is_temporal: MagicMock

View File

@@ -16,6 +16,7 @@
# under the License.
import logging
from typing import cast
from unittest.mock import patch
import pytest
import sshtunnel
@@ -23,6 +24,7 @@ from flask import Flask, Response
from flask_babel import Babel
from superset.errors import SupersetErrorType
from superset.exceptions import QueryObjectValidationError, SupersetException
from superset.superset_typing import FlaskResponse
from superset.utils import json
from superset.views.error_handling import handle_api_exception, set_app_error_handlers
@@ -110,3 +112,71 @@ class TestShowUnexpectedException:
== SupersetErrorType.GENERIC_BACKEND_ERROR.value
)
assert any(record.levelno >= logging.ERROR for record in caplog.records)
class TestShowSupersetException:
def _build_app_with_handlers(self) -> Flask:
# A fresh, minimal Flask app per test: `set_app_error_handlers` can
# only register handlers before the app has served its first
# request, so it can't share the module-scoped `app` fixture across
# tests in this class.
test_app = Flask(__name__)
test_app.config["DEBUG"] = False
Babel(test_app)
set_app_error_handlers(test_app)
@test_app.route("/query-validation-error")
def query_validation_error_view() -> FlaskResponse:
raise QueryObjectValidationError("list object has no element 0")
@test_app.route("/generic-superset-exception")
def generic_superset_exception_view() -> FlaskResponse:
raise SupersetException("boom")
return test_app
def test_4xx_superset_exception_returns_its_status_and_logs_at_warning(
self, caplog: pytest.LogCaptureFixture
):
client = self._build_app_with_handlers().test_client()
with caplog.at_level(logging.WARNING):
response = client.get("/query-validation-error")
assert response.status_code == 400
payload = json.loads(response.data)
assert payload["errors"][0]["message"] == "list object has no element 0"
assert not any(record.levelno >= logging.ERROR for record in caplog.records)
assert any(
record.levelno == logging.WARNING
and record.message == "list object has no element 0"
for record in caplog.records
)
def test_5xx_superset_exception_still_returns_500_and_logs_at_error(
self, caplog: pytest.LogCaptureFixture
):
client = self._build_app_with_handlers().test_client()
with caplog.at_level(logging.WARNING):
response = client.get("/generic-superset-exception")
assert response.status_code == 500
payload = json.loads(response.data)
assert payload["errors"][0]["message"] == "boom"
assert any(record.levelno >= logging.ERROR for record in caplog.records)
def test_html_accept_serves_branded_error_page_not_raw_json(self):
client = self._build_app_with_handlers().test_client()
with patch(
"superset.views.error_handling.send_file",
return_value=Response("<html>500</html>", mimetype="text/html"),
) as mock_send_file:
response = client.get(
"/generic-superset-exception", headers={"Accept": "text/html"}
)
assert response.status_code == 500
assert response.content_type.startswith("text/html")
mock_send_file.assert_called_once()