feat: Dynamic currency (#36416)

This commit is contained in:
Richard Fogaca Nienkotter
2026-01-17 02:58:41 -03:00
committed by GitHub
parent 896947c787
commit f4474b2e3e
72 changed files with 3068 additions and 173 deletions

View File

@@ -0,0 +1,290 @@
# 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 unittest.mock import MagicMock, patch
import pandas as pd
import pytest
from superset.common.query_actions import _detect_currency
@pytest.fixture
def mock_query_context() -> MagicMock:
"""Create a mock QueryContext with AUTO currency format."""
context = MagicMock()
context.form_data = {"currency_format": {"symbol": "AUTO"}}
return context
@pytest.fixture
def mock_query_obj() -> MagicMock:
"""Create a mock QueryObject with filter attributes."""
obj = MagicMock()
obj.filter = []
obj.granularity = None
obj.from_dttm = None
obj.to_dttm = None
obj.extras = {}
return obj
@pytest.fixture
def mock_datasource() -> MagicMock:
"""Create a mock datasource with currency column."""
ds = MagicMock()
ds.currency_code_column = "currency_code"
return ds
def test_detect_currency_returns_none_when_form_data_is_none(
mock_query_obj: MagicMock,
mock_datasource: MagicMock,
) -> None:
"""Returns None when query context has no form_data."""
context = MagicMock()
context.form_data = None
result = _detect_currency(context, mock_query_obj, mock_datasource)
assert result is None
def test_detect_currency_returns_none_when_currency_format_not_dict(
mock_query_obj: MagicMock,
mock_datasource: MagicMock,
) -> None:
"""Returns None when currency_format is not a dict."""
context = MagicMock()
context.form_data = {"currency_format": "invalid"}
result = _detect_currency(context, mock_query_obj, mock_datasource)
assert result is None
def test_detect_currency_returns_none_when_symbol_not_auto(
mock_query_obj: MagicMock,
mock_datasource: MagicMock,
) -> None:
"""Returns None when currency_format.symbol is not AUTO."""
context = MagicMock()
context.form_data = {"currency_format": {"symbol": "USD"}}
result = _detect_currency(context, mock_query_obj, mock_datasource)
assert result is None
def test_detect_currency_returns_none_when_no_currency_column(
mock_query_context: MagicMock,
mock_query_obj: MagicMock,
) -> None:
"""Returns None when datasource has no currency_code_column."""
datasource = MagicMock()
datasource.currency_code_column = None
result = _detect_currency(mock_query_context, mock_query_obj, datasource)
assert result is None
@patch("superset.common.query_actions.detect_currency_from_df")
def test_detect_currency_uses_dataframe_when_column_present(
mock_detect_from_df: MagicMock,
mock_query_context: MagicMock,
mock_query_obj: MagicMock,
mock_datasource: MagicMock,
) -> None:
"""Uses detect_currency_from_df when df contains currency column."""
df = pd.DataFrame({"currency_code": ["USD", "USD"]})
mock_detect_from_df.return_value = "USD"
result = _detect_currency(mock_query_context, mock_query_obj, mock_datasource, df)
assert result == "USD"
mock_detect_from_df.assert_called_once_with(df, "currency_code")
@patch("superset.common.query_actions.detect_currency")
def test_detect_currency_queries_datasource_when_no_df(
mock_detect: MagicMock,
mock_query_context: MagicMock,
mock_query_obj: MagicMock,
mock_datasource: MagicMock,
) -> None:
"""Queries datasource when df is None."""
mock_detect.return_value = "EUR"
result = _detect_currency(mock_query_context, mock_query_obj, mock_datasource)
assert result == "EUR"
mock_detect.assert_called_once_with(
datasource=mock_datasource,
filters=mock_query_obj.filter,
granularity=mock_query_obj.granularity,
from_dttm=mock_query_obj.from_dttm,
to_dttm=mock_query_obj.to_dttm,
extras=mock_query_obj.extras,
)
@patch("superset.common.query_actions.detect_currency")
def test_detect_currency_queries_datasource_when_column_not_in_df(
mock_detect: MagicMock,
mock_query_context: MagicMock,
mock_query_obj: MagicMock,
mock_datasource: MagicMock,
) -> None:
"""Falls back to query when df doesn't have currency column."""
df = pd.DataFrame({"other_column": ["value"]})
mock_detect.return_value = "GBP"
result = _detect_currency(mock_query_context, mock_query_obj, mock_datasource, df)
assert result == "GBP"
mock_detect.assert_called_once()
# Tests for column_config AUTO detection (Table charts)
@pytest.fixture
def mock_query_context_with_column_config() -> MagicMock:
"""Create a mock QueryContext with column_config AUTO currency (Table charts)."""
context = MagicMock()
context.form_data = {
"column_config": {
"cost": {"currencyFormat": {"symbol": "AUTO", "symbolPosition": "prefix"}}
}
}
return context
@patch("superset.common.query_actions.detect_currency")
def test_detect_currency_checks_column_config_for_auto(
mock_detect: MagicMock,
mock_query_context_with_column_config: MagicMock,
mock_query_obj: MagicMock,
mock_datasource: MagicMock,
) -> None:
"""Runs detection when column_config has AUTO currency (Table charts)."""
mock_detect.return_value = "USD"
result = _detect_currency(
mock_query_context_with_column_config,
mock_query_obj,
mock_datasource,
)
assert result == "USD"
mock_detect.assert_called_once()
@patch("superset.common.query_actions.detect_currency_from_df")
def test_detect_currency_column_config_uses_dataframe(
mock_detect_from_df: MagicMock,
mock_query_context_with_column_config: MagicMock,
mock_query_obj: MagicMock,
mock_datasource: MagicMock,
) -> None:
"""Uses dataframe detection when column_config has AUTO and df has currency."""
df = pd.DataFrame({"currency_code": ["EUR", "EUR"]})
mock_detect_from_df.return_value = "EUR"
result = _detect_currency(
mock_query_context_with_column_config,
mock_query_obj,
mock_datasource,
df,
)
assert result == "EUR"
mock_detect_from_df.assert_called_once_with(df, "currency_code")
def test_detect_currency_skips_when_no_auto_in_column_config(
mock_query_obj: MagicMock,
mock_datasource: MagicMock,
) -> None:
"""Returns None when column_config has explicit currency (not AUTO)."""
context = MagicMock()
context.form_data = {
"column_config": {"cost": {"currencyFormat": {"symbol": "USD"}}}
}
result = _detect_currency(context, mock_query_obj, mock_datasource)
assert result is None
@patch("superset.common.query_actions.detect_currency")
def test_detect_currency_works_with_both_top_level_and_column_config(
mock_detect: MagicMock,
mock_query_obj: MagicMock,
mock_datasource: MagicMock,
) -> None:
"""Detects when both top-level and column_config have AUTO."""
context = MagicMock()
context.form_data = {
"currency_format": {"symbol": "AUTO"},
"column_config": {"cost": {"currencyFormat": {"symbol": "AUTO"}}},
}
mock_detect.return_value = "JPY"
result = _detect_currency(context, mock_query_obj, mock_datasource)
assert result == "JPY"
mock_detect.assert_called_once()
@patch("superset.common.query_actions.detect_currency")
def test_detect_currency_top_level_auto_triggers_detection(
mock_detect: MagicMock,
mock_query_obj: MagicMock,
mock_datasource: MagicMock,
) -> None:
"""Detects when only top-level currency_format has AUTO."""
context = MagicMock()
context.form_data = {
"currency_format": {"symbol": "AUTO"},
"column_config": {
"cost": {"currencyFormat": {"symbol": "USD"}} # explicit, not AUTO
},
}
mock_detect.return_value = "CAD"
result = _detect_currency(context, mock_query_obj, mock_datasource)
assert result == "CAD"
mock_detect.assert_called_once()
def test_detect_currency_column_config_no_currency_column_returns_none(
mock_query_context_with_column_config: MagicMock,
mock_query_obj: MagicMock,
) -> None:
"""Returns None when column_config has AUTO but datasource lacks currency column."""
datasource = MagicMock()
datasource.currency_code_column = None
result = _detect_currency(
mock_query_context_with_column_config,
mock_query_obj,
datasource,
)
assert result is None

View File

@@ -436,3 +436,109 @@ class TestQueryContextFactory:
self.factory._apply_filters(query_object)
assert query_object.filter[0]["val"] == "value"
def test_add_currency_column_no_form_data(self):
"""Test _add_currency_column when form_data is None."""
query_object = Mock(spec=QueryObject)
query_object.columns = ["col1"]
datasource = Mock()
self.factory._add_currency_column(query_object, None, datasource)
assert query_object.columns == ["col1"]
def test_add_currency_column_no_columns(self):
"""Test _add_currency_column when query_object has no columns."""
query_object = Mock(spec=QueryObject)
query_object.columns = []
form_data = {
"viz_type": "pivot_table_v2",
"currency_format": {"symbol": "AUTO"},
}
datasource = Mock()
datasource.currency_code_column = "currency_code"
self.factory._add_currency_column(query_object, form_data, datasource)
assert query_object.columns == []
def test_add_currency_column_unsupported_viz_type(self):
"""Test _add_currency_column with unsupported viz type."""
query_object = Mock(spec=QueryObject)
query_object.columns = ["col1"]
form_data = {"viz_type": "pie", "currency_format": {"symbol": "AUTO"}}
datasource = Mock()
datasource.currency_code_column = "currency_code"
self.factory._add_currency_column(query_object, form_data, datasource)
assert query_object.columns == ["col1"]
def test_add_currency_column_symbol_not_auto(self):
"""Test _add_currency_column when symbol is not AUTO."""
query_object = Mock(spec=QueryObject)
query_object.columns = ["col1"]
form_data = {"viz_type": "pivot_table_v2", "currency_format": {"symbol": "USD"}}
datasource = Mock()
datasource.currency_code_column = "currency_code"
self.factory._add_currency_column(query_object, form_data, datasource)
assert query_object.columns == ["col1"]
def test_add_currency_column_no_currency_column_on_datasource(self):
"""Test _add_currency_column when datasource has no currency column."""
query_object = Mock(spec=QueryObject)
query_object.columns = ["col1"]
form_data = {
"viz_type": "pivot_table_v2",
"currency_format": {"symbol": "AUTO"},
}
datasource = Mock()
datasource.currency_code_column = None
self.factory._add_currency_column(query_object, form_data, datasource)
assert query_object.columns == ["col1"]
def test_add_currency_column_already_in_query(self):
"""Test _add_currency_column when currency column already exists."""
query_object = Mock(spec=QueryObject)
query_object.columns = ["col1", "currency_code"]
form_data = {
"viz_type": "pivot_table_v2",
"currency_format": {"symbol": "AUTO"},
}
datasource = Mock()
datasource.currency_code_column = "currency_code"
self.factory._add_currency_column(query_object, form_data, datasource)
assert query_object.columns == ["col1", "currency_code"]
def test_add_currency_column_adds_column_for_pivot_table(self):
"""Test _add_currency_column adds column for pivot_table_v2 viz type"""
query_object = Mock(spec=QueryObject)
query_object.columns = ["col1"]
form_data = {
"viz_type": "pivot_table_v2",
"currency_format": {"symbol": "AUTO"},
}
datasource = Mock()
datasource.currency_code_column = "currency_code"
self.factory._add_currency_column(query_object, form_data, datasource)
assert query_object.columns == ["col1", "currency_code"]
def test_add_currency_column_skips_table_viz_type(self):
"""Test _add_currency_column does not add column for table viz type."""
query_object = Mock(spec=QueryObject)
query_object.columns = ["col1"]
form_data = {"viz_type": "table", "currency_format": {"symbol": "AUTO"}}
datasource = Mock()
datasource.currency_code_column = "currency_code"
self.factory._add_currency_column(query_object, form_data, datasource)
assert query_object.columns == ["col1"]

View File

@@ -842,3 +842,67 @@ def test_quoted_name_prevents_double_quoting(mocker: MockerFixture) -> None:
# Should have each part quoted separately:
# GOOD: "MY_DB"."MY_SCHEMA"."MY_TABLE"
assert '"MY_DB"."MY_SCHEMA"."MY_TABLE"' in compiled
def test_sqla_table_currency_code_column_property() -> None:
"""
Test currency_code_column property on SqlaTable.
"""
database = Database(database_name="my_db")
table = SqlaTable(
table_name="sales",
database=database,
currency_code_column="currency",
)
assert table.currency_code_column == "currency"
def test_sqla_table_data_includes_currency_code_column(mocker: MockerFixture) -> None:
"""
Test that data property includes currency_code_column.
"""
database = mocker.MagicMock()
database.get_sqla_engine.return_value.__enter__ = mocker.MagicMock()
database.get_sqla_engine.return_value.__exit__ = mocker.MagicMock()
table = SqlaTable(
table_name="sales",
database=database,
currency_code_column="currency_code",
main_dttm_col="ds",
)
table.columns = []
table.metrics = []
# Mock the columns property to return empty list
mocker.patch.object(SqlaTable, "columns", [])
mocker.patch.object(SqlaTable, "metrics", [])
data = table.data
assert data["currency_code_column"] == "currency_code"
assert data["main_dttm_col"] == "ds"
def test_sqla_table_link_escapes_url(mocker: MockerFixture) -> None:
"""
Test that link property properly escapes URL to prevent XSS.
"""
database = Database(database_name="my_db")
table = SqlaTable(
table_name='test<script>alert("xss")</script>',
database=database,
id=1,
)
# Mock explore_url to return a URL with special characters
mocker.patch.object(
SqlaTable,
"explore_url",
new_callable=mocker.PropertyMock,
return_value='/explore/?datasource_type=table&datasource_id=1&name=<script>alert("xss")</script>',
)
link = table.link
# Verify that special characters are escaped in both name and URL
assert "&lt;script&gt;" in str(link)
assert "<script>" not in str(link)

View File

@@ -155,6 +155,7 @@ def test_export(session: Session) -> None:
f"datasets/my_database/my_table_{sqla_table.id}.yaml",
f"""table_name: my_table
main_dttm_col: ds
currency_code_column: null
description: This is the description
default_endpoint: null
offset: -8

View File

@@ -46,3 +46,32 @@ def test_validate_python_date_format(payload) -> None:
def test_validate_python_date_format_raises(payload) -> None:
with pytest.raises(ValidationError):
validate_python_date_format(payload)
def test_dataset_put_schema_includes_currency_code_column() -> None:
"""Test that DatasetPutSchema properly handles currency_code_column field."""
from superset.datasets.schemas import DatasetPutSchema
schema = DatasetPutSchema()
# Dataset with currency code column
data = {
"currency_code_column": "currency",
}
result = schema.load(data)
assert result["currency_code_column"] == "currency"
def test_dataset_put_schema_currency_code_column_optional() -> None:
"""Test that currency_code_column is optional in DatasetPutSchema."""
from superset.datasets.schemas import DatasetPutSchema
schema = DatasetPutSchema()
# Dataset without currency code column (should not fail)
data: dict[str, str | None] = {}
result = schema.load(data)
assert (
"currency_code_column" not in result
or result.get("currency_code_column") is None
)

View File

@@ -0,0 +1,332 @@
# 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 unittest.mock import MagicMock
import pandas as pd
import pytest
from superset.common.db_query_status import QueryStatus
from superset.utils.currency import (
detect_currency,
detect_currency_from_df,
has_auto_currency_in_column_config,
)
@pytest.fixture
def mock_datasource() -> MagicMock:
"""Create a mock datasource with currency_code_column configured."""
datasource = MagicMock()
datasource.currency_code_column = "currency_code"
datasource.id = 1
return datasource
@pytest.fixture
def mock_query_result() -> MagicMock:
"""Create a mock query result."""
result = MagicMock()
result.status = QueryStatus.SUCCESS
return result
def test_detect_currency_returns_none_when_no_currency_column() -> None:
"""Returns None when datasource has no currency_code_column configured."""
datasource = MagicMock()
datasource.currency_code_column = None
result = detect_currency(datasource)
assert result is None
datasource.query.assert_not_called()
def test_detect_currency_returns_single_currency(
mock_datasource: MagicMock,
mock_query_result: MagicMock,
) -> None:
"""Returns currency code when all filtered data contains single currency."""
mock_query_result.df = pd.DataFrame({"currency_code": ["USD", "USD", "USD"]})
mock_datasource.query.return_value = mock_query_result
result = detect_currency(mock_datasource)
assert result == "USD"
def test_detect_currency_returns_none_for_multiple_currencies(
mock_datasource: MagicMock,
mock_query_result: MagicMock,
) -> None:
"""Returns None when filtered data contains multiple currencies."""
mock_query_result.df = pd.DataFrame({"currency_code": ["USD", "EUR", "GBP"]})
mock_datasource.query.return_value = mock_query_result
result = detect_currency(mock_datasource)
assert result is None
def test_detect_currency_returns_none_for_empty_dataframe(
mock_datasource: MagicMock,
mock_query_result: MagicMock,
) -> None:
"""Returns None when query returns empty dataframe."""
mock_query_result.df = pd.DataFrame()
mock_datasource.query.return_value = mock_query_result
result = detect_currency(mock_datasource)
assert result is None
def test_detect_currency_returns_none_on_query_failure(
mock_datasource: MagicMock,
mock_query_result: MagicMock,
) -> None:
"""Returns None when query fails."""
mock_query_result.status = QueryStatus.FAILED
mock_query_result.df = pd.DataFrame()
mock_datasource.query.return_value = mock_query_result
result = detect_currency(mock_datasource)
assert result is None
def test_detect_currency_handles_exception_gracefully(
mock_datasource: MagicMock,
) -> None:
"""Returns None and logs warning when exception occurs."""
mock_datasource.query.side_effect = Exception("Database error")
result = detect_currency(mock_datasource)
assert result is None
def test_detect_currency_normalizes_to_uppercase(
mock_datasource: MagicMock,
mock_query_result: MagicMock,
) -> None:
"""Normalizes currency codes to uppercase."""
mock_query_result.df = pd.DataFrame({"currency_code": ["usd", "Usd", "USD"]})
mock_datasource.query.return_value = mock_query_result
result = detect_currency(mock_datasource)
assert result == "USD"
def test_detect_currency_ignores_null_values(
mock_datasource: MagicMock,
mock_query_result: MagicMock,
) -> None:
"""Ignores null currency values when detecting single currency."""
mock_query_result.df = pd.DataFrame({"currency_code": ["USD", None, "USD", None]})
mock_datasource.query.return_value = mock_query_result
result = detect_currency(mock_datasource)
assert result == "USD"
def test_detect_currency_returns_none_when_column_missing_from_result(
mock_datasource: MagicMock,
mock_query_result: MagicMock,
) -> None:
"""Returns None when currency column is missing from query result."""
mock_query_result.df = pd.DataFrame({"other_column": ["value"]})
mock_datasource.query.return_value = mock_query_result
result = detect_currency(mock_datasource)
assert result is None
# Tests for detect_currency_from_df
def test_detect_currency_from_df_returns_single_currency() -> None:
"""Returns currency code when all data contains single currency."""
df = pd.DataFrame({"currency_code": ["USD", "USD", "USD"]})
result = detect_currency_from_df(df, "currency_code")
assert result == "USD"
def test_detect_currency_from_df_returns_none_for_multiple_currencies() -> None:
"""Returns None when data contains multiple currencies."""
df = pd.DataFrame({"currency_code": ["USD", "EUR", "GBP"]})
result = detect_currency_from_df(df, "currency_code")
assert result is None
def test_detect_currency_from_df_returns_none_for_empty_dataframe() -> None:
"""Returns None when dataframe is empty."""
df = pd.DataFrame()
result = detect_currency_from_df(df, "currency_code")
assert result is None
def test_detect_currency_from_df_returns_none_for_none_dataframe() -> None:
"""Returns None when dataframe is None."""
result = detect_currency_from_df(None, "currency_code")
assert result is None
def test_detect_currency_from_df_returns_none_when_column_missing() -> None:
"""Returns None when currency column is missing from dataframe."""
df = pd.DataFrame({"other_column": ["value"]})
result = detect_currency_from_df(df, "currency_code")
assert result is None
def test_detect_currency_from_df_normalizes_to_uppercase() -> None:
"""Normalizes currency codes to uppercase."""
df = pd.DataFrame({"currency_code": ["usd", "Usd", "USD"]})
result = detect_currency_from_df(df, "currency_code")
assert result == "USD"
def test_detect_currency_from_df_ignores_null_values() -> None:
"""Ignores null currency values when detecting single currency."""
df = pd.DataFrame({"currency_code": ["USD", None, "USD", None]})
result = detect_currency_from_df(df, "currency_code")
assert result == "USD"
def test_detect_currency_returns_none_when_query_not_callable() -> None:
"""Returns None when datasource query attribute is not callable."""
datasource = MagicMock()
datasource.currency_code_column = "currency_code"
datasource.query = "not_a_callable" # Set to a string instead of a method
result = detect_currency(datasource)
assert result is None
# Tests for has_auto_currency_in_column_config
def test_has_auto_currency_in_column_config_returns_true_when_auto() -> None:
"""Returns True when column_config has AUTO currency."""
form_data = {
"column_config": {
"cost": {"currencyFormat": {"symbol": "AUTO", "symbolPosition": "prefix"}}
}
}
result = has_auto_currency_in_column_config(form_data)
assert result is True
def test_has_auto_currency_in_column_config_returns_true_multiple_columns() -> None:
"""Returns True when any column in column_config has AUTO currency."""
form_data = {
"column_config": {
"revenue": {"currencyFormat": {"symbol": "USD"}},
"cost": {"currencyFormat": {"symbol": "AUTO"}},
"profit": {"d3NumberFormat": ",.0f"},
}
}
result = has_auto_currency_in_column_config(form_data)
assert result is True
def test_has_auto_currency_in_column_config_returns_false_for_explicit() -> None:
"""Returns False when column_config has explicit currency symbol."""
form_data = {"column_config": {"cost": {"currencyFormat": {"symbol": "USD"}}}}
result = has_auto_currency_in_column_config(form_data)
assert result is False
def test_has_auto_currency_in_column_config_returns_false_for_none() -> None:
"""Returns False when form_data is None."""
result = has_auto_currency_in_column_config(None)
assert result is False
def test_has_auto_currency_in_column_config_returns_false_for_empty() -> None:
"""Returns False when form_data is empty."""
result = has_auto_currency_in_column_config({})
assert result is False
def test_has_auto_currency_in_column_config_returns_false_no_column_config() -> None:
"""Returns False when column_config is not present."""
form_data = {"other_key": "value"}
result = has_auto_currency_in_column_config(form_data)
assert result is False
def test_has_auto_currency_in_column_config_handles_invalid_column_config() -> None:
"""Returns False when column_config is not a dict."""
form_data = {"column_config": "invalid"}
result = has_auto_currency_in_column_config(form_data)
assert result is False
def test_has_auto_currency_in_column_config_handles_invalid_config_entry() -> None:
"""Returns False when column config entry is not a dict."""
form_data = {"column_config": {"cost": "invalid"}}
result = has_auto_currency_in_column_config(form_data)
assert result is False
def test_has_auto_currency_in_column_config_handles_invalid_currency_format() -> None:
"""Returns False when currencyFormat is not a dict."""
form_data = {"column_config": {"cost": {"currencyFormat": "invalid"}}}
result = has_auto_currency_in_column_config(form_data)
assert result is False
def test_has_auto_currency_in_column_config_handles_no_currency_format() -> None:
"""Returns False when column has no currencyFormat."""
form_data = {"column_config": {"cost": {"d3NumberFormat": ",.0f"}}}
result = has_auto_currency_in_column_config(form_data)
assert result is False