mirror of
https://github.com/apache/superset.git
synced 2026-08-03 20:42:30 +00:00
Compare commits
4 Commits
mobile-das
...
sentry-12e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9701464fa5 | ||
|
|
cfb0c6bd74 | ||
|
|
7d2b184079 | ||
|
|
7b351d53cf |
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user