feat(api): Add filter_dashboard_id parameter to apply dashboard filters to chart/data endpoint (#38638)

Co-authored-by: Matthew Deadman <matthewdeadman@Matthews-MacBook-Pro-2.local>
Co-authored-by: Matthew Deadman <matthewdeadman@matthews-mbp-2.lan>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
This commit is contained in:
Deadman
2026-04-08 18:32:46 -04:00
committed by GitHub
parent d63308ca37
commit 4e0890ee1f
6 changed files with 4997 additions and 7922 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,10 @@ from superset import is_feature_enabled, security_manager
from superset.async_events.async_query_manager import AsyncQueryTokenException
from superset.charts.api import ChartRestApi
from superset.charts.client_processing import apply_client_processing
from superset.charts.data.dashboard_filter_context import (
DashboardFilterContext,
get_dashboard_filter_context,
)
from superset.charts.data.query_context_cache_loader import QueryContextCacheLoader
from superset.charts.schemas import ChartDataQueryContextSchema
from superset.commands.chart.data.create_async_job_command import (
@@ -46,9 +50,13 @@ from superset.commands.chart.exceptions import (
)
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
from superset.connectors.sqla.models import BaseDatasource
from superset.constants import CACHE_DISABLED_TIMEOUT
from superset.constants import (
CACHE_DISABLED_TIMEOUT,
EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS,
EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS,
)
from superset.daos.exceptions import DatasourceNotFound
from superset.exceptions import QueryObjectValidationError
from superset.exceptions import QueryObjectValidationError, SupersetSecurityException
from superset.extensions import event_logger
from superset.models.sql_lab import Query
from superset.utils import json
@@ -91,7 +99,9 @@ class ChartDataRestApi(ChartRestApi):
summary: Return payload data response for a chart
description: >-
Takes a chart ID and uses the query context stored when the chart was saved
to return payload data response.
to return payload data response. When filters_dashboard_id is provided,
the chart's compiled SQL includes in scope dashboard filter
default values.
parameters:
- in: path
schema:
@@ -113,6 +123,16 @@ class ChartDataRestApi(ChartRestApi):
description: Should the queries be forced to load from the source
schema:
type: boolean
- in: query
name: filters_dashboard_id
description: >-
Dashboard ID whose filter defaults should be applied to the
chart's query context. The chart must belong to the specified dashboard.
Only in scope filters with static default values are applied; filters that
require a database query (I.E. defaultToFirstItem) or have no default are
reported in the dashboard_filters response metadata.
schema:
type: integer
responses:
200:
description: Query result
@@ -130,6 +150,10 @@ class ChartDataRestApi(ChartRestApi):
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
@@ -156,6 +180,63 @@ class ChartDataRestApi(ChartRestApi):
json_body["result_type"] = request.args.get("type", ChartDataResultType.FULL)
json_body["force"] = request.args.get("force")
# Apply dashboard filter context when filters_dashboard_id is provided
dashboard_filter_context: DashboardFilterContext | None = None
if "filters_dashboard_id" in request.args:
raw = request.args.get("filters_dashboard_id")
try:
filters_dashboard_id = int(raw)
except (ValueError, TypeError):
return self.response_400(
message="filters_dashboard_id must be an integer"
)
else:
filters_dashboard_id = None
if filters_dashboard_id is not None:
try:
dashboard_filter_context = get_dashboard_filter_context(
dashboard_id=filters_dashboard_id,
chart_id=pk,
)
except ValueError as error:
return self.response_400(message=str(error))
except SupersetSecurityException:
return self.response_403()
if dashboard_filter_context.extra_form_data:
efd = dashboard_filter_context.extra_form_data
extra_filters = efd.get("filters", [])
for query in json_body.get("queries", []):
if extra_filters:
existing = query.get("filters") or []
query["filters"] = existing + [
{**f, "isExtra": True} for f in extra_filters
]
extras = query.get("extras") or {}
for key in EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS:
if key in efd:
extras[key] = efd[key]
if extras:
query["extras"] = extras
for (
src_key,
target_key,
) in EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS.items():
if src_key in efd:
query[target_key] = efd[src_key]
query["extra_form_data"] = efd
# We need to apply the form data to the global context as jinja
# templating pulls form data from the request globally, so this
# fallback ensures it has the filters and extra_form_data applied
# when used in get_sqla_query which constructs the final query.
g.form_data = json_body
try:
query_context = self._create_query_context_from_form(json_body)
command = ChartDataCommand(query_context)
@@ -194,6 +275,7 @@ class ChartDataRestApi(ChartRestApi):
form_data=form_data,
datasource=query_context.datasource,
add_extra_log_payload=add_extra_log_payload,
dashboard_filter_context=dashboard_filter_context,
)
@expose("/data", methods=("POST",))
@@ -394,6 +476,7 @@ class ChartDataRestApi(ChartRestApi):
datasource: BaseDatasource | Query | None = None,
filename: str | None = None,
expected_rows: int | None = None,
dashboard_filter_context: DashboardFilterContext | None = None,
) -> Response:
result_type = result["query_context"].result_type
result_format = result["query_context"].result_format
@@ -456,9 +539,14 @@ class ChartDataRestApi(ChartRestApi):
if security_manager.is_guest_user():
for query in queries:
query.pop("query", None)
payload: dict[str, Any] = {"result": queries}
if dashboard_filter_context is not None:
payload["dashboard_filters"] = dashboard_filter_context.to_dict()
with event_logger.log_context(f"{self.__class__.__name__}.json_dumps"):
response_data = json.dumps(
{"result": queries},
payload,
default=json.json_int_dttm_ser,
ignore_nan=True,
)
@@ -497,6 +585,7 @@ class ChartDataRestApi(ChartRestApi):
filename: str | None = None,
expected_rows: int | None = None,
add_extra_log_payload: Callable[..., None] | None = None,
dashboard_filter_context: DashboardFilterContext | None = None,
) -> Response:
"""Get data response and optionally log is_cached information."""
try:
@@ -512,7 +601,12 @@ class ChartDataRestApi(ChartRestApi):
add_extra_log_payload(is_cached=is_cached_values)
return self._send_chart_response(
result, form_data, datasource, filename, expected_rows
result,
form_data,
datasource,
filename,
expected_rows,
dashboard_filter_context=dashboard_filter_context,
)
def _extract_export_params_from_request(self) -> tuple[str | None, int | None]:

View File

@@ -0,0 +1,306 @@
# 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
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from flask_babel import gettext as _
from superset import db, security_manager
from superset.constants import (
EXTRA_FORM_DATA_APPEND_KEYS,
EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS,
EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS,
)
from superset.models.dashboard import Dashboard
from superset.utils import json
logger = logging.getLogger(__name__)
CHART_TYPE = "CHART"
class DashboardFilterStatus(str, Enum):
APPLIED = "applied"
NOT_APPLIED = "not_applied"
NOT_APPLIED_USES_DEFAULT_TO_FIRST_ITEM_PREQUERY = (
"not_applied_uses_default_to_first_item_prequery"
)
@dataclass
class DashboardFilterInfo:
id: str
name: str
status: DashboardFilterStatus
column: str | None = None
@dataclass
class DashboardFilterContext:
extra_form_data: dict[str, Any] = field(default_factory=dict)
filters: list[DashboardFilterInfo] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"filters": [
{
"id": f.id,
"name": f.name,
"status": f.status.value,
**({"column": f.column} if f.column else {}),
}
for f in self.filters
],
}
def _is_filter_in_scope_for_chart(
filter_config: dict[str, Any],
chart_id: int,
position_json: dict[str, Any],
) -> bool:
"""
Determines whether a native filter applies to a given chart. When
chartsInScope is present on the filter config, uses that directly.
Otherwise falls back to scope.rootPath and scope.excluded with
the dashboard layout.
"""
if (charts_in_scope := filter_config.get("chartsInScope")) is not None:
return chart_id in charts_in_scope
scope = filter_config.get("scope", {})
root_path: list[str] = scope.get("rootPath", [])
excluded: list[int] = scope.get("excluded", [])
if chart_id in excluded:
return False
chart_layout_item = _find_chart_layout_item(chart_id, position_json)
if not chart_layout_item:
return False
parents: list[str] = chart_layout_item.get("parents", [])
return any(parent in root_path for parent in parents)
def _find_chart_layout_item(
chart_id: int,
position_json: dict[str, Any],
) -> dict[str, Any] | None:
"""Find the layout item for a chart in the dashboard position JSON."""
for item in position_json.values():
if not isinstance(item, dict):
continue
if (
item.get("type") == CHART_TYPE
and item.get("meta", {}).get("chartId") == chart_id
):
return item
return None
def _merge_extra_form_data(
base: dict[str, Any],
new: dict[str, Any],
) -> dict[str, Any]:
"""
Merge two extra_form_data dicts, appending list-type keys (like filters,
adhoc_filters), merging dict-type keys (like custom_form_data), and overriding
scalar keys (like granularity_sqla, time_range).
"""
append_keys = EXTRA_FORM_DATA_APPEND_KEYS - {"custom_form_data"}
override_keys = (
set(EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS.keys())
| EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS
)
merged: dict[str, Any] = {}
for key in append_keys:
base_val = base.get(key, [])
new_val = new.get(key, [])
combined = list(base_val) + list(new_val)
if combined:
merged[key] = combined
# Merge custom_form_data as dicts so multiple filters' contributions combine
base_custom = base.get("custom_form_data") or {}
new_custom = new.get("custom_form_data") or {}
if isinstance(base_custom, dict) and isinstance(new_custom, dict):
merged_custom = dict(base_custom)
for key, value in new_custom.items():
if (
key in merged_custom
and isinstance(merged_custom[key], list)
and isinstance(value, list)
):
merged_custom[key] = merged_custom[key] + value
else:
merged_custom[key] = value
if merged_custom:
merged["custom_form_data"] = merged_custom
for key in override_keys:
if key in new:
merged[key] = new[key]
elif key in base:
merged[key] = base[key]
return merged
def _extract_filter_extra_form_data(
filter_config: dict[str, Any],
) -> tuple[dict[str, Any] | None, DashboardFilterStatus]:
"""
Extract extra_form_data from a native filter's defaultDataMask.
Mirrors frontend dashboard behavior except for defaultToFirstItem
filters: filters with a static default contribute their
extraFormData to the query. Filters without a default or with
defaultToFirstItem set to True are simply not applied.
Returns (extra_form_data, status).
"""
default_data_mask = filter_config.get("defaultDataMask", {})
control_values = filter_config.get("controlValues", {})
extra_form_data = default_data_mask.get("extraFormData")
filter_state = default_data_mask.get("filterState", {})
has_static_default = filter_state.get("value") is not None
if control_values.get("defaultToFirstItem"):
return (
None,
DashboardFilterStatus.NOT_APPLIED_USES_DEFAULT_TO_FIRST_ITEM_PREQUERY,
)
if has_static_default and extra_form_data:
return extra_form_data, DashboardFilterStatus.APPLIED
return None, DashboardFilterStatus.NOT_APPLIED
def _get_filter_target_column(filter_config: dict[str, Any]) -> str | None:
"""Extract the target column name from a native filter configuration."""
if targets := filter_config.get("targets", []):
column = targets[0].get("column", {})
if isinstance(column, dict):
return column.get("name")
if isinstance(column, str):
return column
return None
def _validate_chart_on_dashboard(
dashboard: Dashboard,
chart_id: int,
) -> None:
"""
Validate that a chart belongs to a dashboard.
:raises ValueError: if the chart is not found on the dashboard
"""
slice_ids = {slc.id for slc in dashboard.slices}
if chart_id not in slice_ids:
raise ValueError(
_(
"Chart %(chart_id)s is not on dashboard %(dashboard_id)s",
chart_id=chart_id,
dashboard_id=dashboard.id,
)
)
def _check_dashboard_access(dashboard: Dashboard) -> None:
"""
Check that the user has access to the dashboard.
Uses the security manager's raise_for_access which handles
guest users, admins, owners, and DASHBOARD_RBAC.
:raises SupersetSecurityException: if the user cannot access the dashboard
"""
security_manager.raise_for_access(dashboard=dashboard)
def get_dashboard_filter_context(
dashboard_id: int,
chart_id: int,
) -> DashboardFilterContext:
"""
Build a DashboardFilterContext for a chart on a dashboard.
Loads the dashboard's native filter configuration, determines which
filters are in scope for the given chart, extracts default filter values,
and returns the merged extra_form_data along with metadata about each filter.
:param dashboard_id: The ID of the dashboard
:param chart_id: The ID of the chart
:returns: DashboardFilterContext with merged extra_form_data and filter metadata
:raises ValueError: if dashboard not found or chart not on dashboard
:raises SupersetSecurityException: if the user cannot access the dashboard
"""
dashboard = db.session.query(Dashboard).filter_by(id=dashboard_id).one_or_none()
if not dashboard:
raise ValueError(
_("Dashboard %(dashboard_id)s not found", dashboard_id=dashboard_id)
)
_check_dashboard_access(dashboard)
_validate_chart_on_dashboard(dashboard, chart_id)
metadata = json.loads(dashboard.json_metadata or "{}")
native_filter_config: list[dict[str, Any]] = metadata.get(
"native_filter_configuration", []
)
position_json: dict[str, Any] = json.loads(dashboard.position_json or "{}")
context = DashboardFilterContext()
for flt in native_filter_config:
flt_type = flt.get("type", "")
if flt_type == "DIVIDER":
continue
if not _is_filter_in_scope_for_chart(flt, chart_id, position_json):
continue
flt_id = flt.get("id", "")
flt_name = flt.get("name", "")
target_column = _get_filter_target_column(flt)
extra_form_data, status = _extract_filter_extra_form_data(flt)
if extra_form_data and status == DashboardFilterStatus.APPLIED:
context.extra_form_data = _merge_extra_form_data(
context.extra_form_data, extra_form_data
)
context.filters.append(
DashboardFilterInfo(
id=flt_id,
name=flt_name,
status=status,
column=target_column,
)
)
return context

View File

@@ -1563,6 +1563,42 @@ class ChartDataResponseResult(Schema):
)
class DashboardFilterInfoSchema(Schema):
id = fields.String(
metadata={"description": "The native filter ID"},
required=True,
)
name = fields.String(
metadata={"description": "The native filter name"},
required=True,
)
status = fields.String(
metadata={
"description": "Filter status: 'applied' (default value was included "
"in the query), 'not_applied' (filter had no default value and was "
"omitted, matching dashboard initial-load behavior), or "
"'not_applied_uses_default_to_first_item_prequery' (filter uses "
"defaultToFirstItem which requires a pre-query to resolve and cannot "
"be applied server-side)",
},
required=True,
)
column = fields.String(
metadata={"description": "Target column name for the filter"},
allow_none=True,
)
class DashboardFiltersResponseSchema(Schema):
filters = fields.List(
fields.Nested(DashboardFilterInfoSchema),
metadata={
"description": "Metadata about each in-scope dashboard native filter "
"and whether its default value was applied to the query"
},
)
class ChartDataResponseSchema(Schema):
result = fields.List(
fields.Nested(ChartDataResponseResult),
@@ -1571,6 +1607,14 @@ class ChartDataResponseSchema(Schema):
"request."
},
)
dashboard_filters = fields.Nested(
DashboardFiltersResponseSchema,
metadata={
"description": "Metadata about dashboard native filters applied to "
"the query. Only present when filters_dashboard_id is provided."
},
required=False,
)
class ChartDataAsyncResponseSchema(Schema):
@@ -1711,6 +1755,7 @@ CHART_SCHEMAS = (
ChartCacheWarmUpRequestSchema,
ChartCacheWarmUpResponseSchema,
ChartDataQueryContextSchema,
DashboardFiltersResponseSchema,
ChartDataResponseSchema,
ChartDataAsyncResponseSchema,
# TODO: These should optimally be included in the QueryContext schema as an `anyOf`

View File

@@ -1810,3 +1810,218 @@ def test_chart_data_subquery_allowed(
rv = test_client.post(CHART_DATA_URI, json=physical_query_context)
assert rv.status_code == status_code
@pytest.mark.chart_data_flow
class TestGetChartDataWithDashboardFilter(BaseTestChartDataApi):
"""Tests for the filters_dashboard_id parameter on GET /api/v1/chart/<pk>/data/."""
def _setup_chart_with_query_context(self) -> Slice:
chart = db.session.query(Slice).filter_by(slice_name="Genders").one()
chart.query_context = json.dumps(
{
"datasource": {"id": chart.table.id, "type": "table"},
"force": False,
"queries": [
{
"time_range": "1900-01-01T00:00:00 : 2000-01-01T00:00:00",
"granularity": "ds",
"filters": [],
"extras": {"having": "", "where": ""},
"applied_time_extras": {},
"columns": ["gender"],
"metrics": ["sum__num"],
"orderby": [["sum__num", False]],
"annotation_layers": [],
"row_limit": 50000,
"timeseries_limit": 0,
"order_desc": True,
"url_params": {},
"custom_params": {},
"custom_form_data": {},
}
],
"result_format": "json",
"result_type": "full",
}
)
return chart
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@mock.patch("superset.charts.data.api.get_dashboard_filter_context")
def test_get_data_with_dashboard_filter_context(self, mock_get_filter_ctx):
"""
Chart data API: Test GET with filters_dashboard_id returns
dashboard_filters metadata in the response.
"""
from superset.charts.data.dashboard_filter_context import (
DashboardFilterContext,
DashboardFilterInfo,
DashboardFilterStatus,
)
chart = self._setup_chart_with_query_context()
mock_get_filter_ctx.return_value = DashboardFilterContext(
extra_form_data={},
filters=[
DashboardFilterInfo(
id="f1",
name="Region",
status=DashboardFilterStatus.APPLIED,
column="region",
),
DashboardFilterInfo(
id="f2",
name="City",
status=DashboardFilterStatus.NOT_APPLIED,
column="city",
),
],
)
rv = self.get_assert_metric(
f"api/v1/chart/{chart.id}/data/?filters_dashboard_id=1", "get_data"
)
data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
assert "dashboard_filters" in data
assert len(data["dashboard_filters"]["filters"]) == 2
assert data["dashboard_filters"]["filters"][0]["status"] == "applied"
assert data["dashboard_filters"]["filters"][1]["status"] == "not_applied"
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@mock.patch("superset.charts.data.api.get_dashboard_filter_context")
def test_get_data_with_dashboard_filter_applies_filters_to_query(
self, mock_get_filter_ctx
):
"""
Chart data API: Test GET with filters_dashboard_id merges
extra_form_data filters into the query so they appear in the
compiled SQL.
"""
from superset.charts.data.dashboard_filter_context import (
DashboardFilterContext,
DashboardFilterInfo,
DashboardFilterStatus,
)
chart = self._setup_chart_with_query_context()
mock_get_filter_ctx.return_value = DashboardFilterContext(
extra_form_data={
"filters": [{"col": "gender", "op": "IN", "val": ["boy"]}],
},
filters=[
DashboardFilterInfo(
id="f1",
name="Gender",
status=DashboardFilterStatus.APPLIED,
column="gender",
),
],
)
rv = self.get_assert_metric(
f"api/v1/chart/{chart.id}/data/?filters_dashboard_id=1&type=query",
"get_data",
)
data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
assert "dashboard_filters" in data
assert data["dashboard_filters"]["filters"][0]["status"] == "applied"
query_sql = data["result"][0]["query"]
assert "gender" in query_sql.lower()
assert "boy" in query_sql.lower()
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@mock.patch("superset.charts.data.api.get_dashboard_filter_context")
def test_get_data_without_dashboard_filter_has_no_metadata(
self, mock_get_filter_ctx
):
"""
Chart data API: Test GET without filters_dashboard_id does not
include dashboard_filters in the response.
"""
chart = self._setup_chart_with_query_context()
rv = self.get_assert_metric(f"api/v1/chart/{chart.id}/data/", "get_data")
data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
assert "dashboard_filters" not in data
mock_get_filter_ctx.assert_not_called()
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_get_data_invalid_filters_dashboard_id_returns_400(self):
"""
Chart data API: Test GET with non-integer filters_dashboard_id returns 400.
Invalid values (e.g. 'abc', '1.5', empty) are not silently ignored.
"""
chart = self._setup_chart_with_query_context()
rv = self.get_assert_metric(
f"api/v1/chart/{chart.id}/data/?filters_dashboard_id=abc", "get_data"
)
data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 400
assert "filters_dashboard_id" in data["message"].lower()
assert "integer" in data["message"].lower()
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@mock.patch("superset.charts.data.api.get_dashboard_filter_context")
def test_get_data_dashboard_not_found_returns_400(self, mock_get_filter_ctx):
"""
Chart data API: Test GET with invalid dashboard ID returns 400.
"""
chart = self._setup_chart_with_query_context()
mock_get_filter_ctx.side_effect = ValueError("Dashboard 999 not found")
rv = self.get_assert_metric(
f"api/v1/chart/{chart.id}/data/?filters_dashboard_id=999", "get_data"
)
assert rv.status_code == 400
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@mock.patch("superset.charts.data.api.get_dashboard_filter_context")
def test_get_data_dashboard_access_denied_returns_403(self, mock_get_filter_ctx):
"""
Chart data API: Test GET with inaccessible dashboard returns 403.
"""
from superset.errors import SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
chart = self._setup_chart_with_query_context()
mock_get_filter_ctx.side_effect = SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DASHBOARD_SECURITY_ACCESS_ERROR,
message="Access denied",
level="warning",
)
)
rv = self.get_assert_metric(
f"api/v1/chart/{chart.id}/data/?filters_dashboard_id=1", "get_data"
)
assert rv.status_code == 403
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@mock.patch("superset.charts.data.api.get_dashboard_filter_context")
def test_get_data_chart_not_on_dashboard_returns_400(self, mock_get_filter_ctx):
"""
Chart data API: Test GET where chart is not on the dashboard returns 400.
"""
chart = self._setup_chart_with_query_context()
mock_get_filter_ctx.side_effect = ValueError("Chart 10 is not on dashboard 42")
rv = self.get_assert_metric(
f"api/v1/chart/{chart.id}/data/?filters_dashboard_id=42", "get_data"
)
assert rv.status_code == 400
data = json.loads(rv.data.decode("utf-8"))
assert "not on dashboard" in data["message"]

View File

@@ -0,0 +1,525 @@
# 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 typing import Any
from unittest.mock import MagicMock, patch
import pytest
from superset.charts.data.dashboard_filter_context import (
_extract_filter_extra_form_data,
_find_chart_layout_item,
_is_filter_in_scope_for_chart,
_merge_extra_form_data,
_validate_chart_on_dashboard,
DashboardFilterContext,
DashboardFilterStatus,
get_dashboard_filter_context,
)
from superset.utils import json
SAMPLE_POSITION_JSON = {
"ROOT_ID": {
"id": "ROOT_ID",
"type": "ROOT",
"children": ["GRID_ID"],
},
"GRID_ID": {
"id": "GRID_ID",
"type": "GRID",
"children": ["ROW-abc"],
"parents": ["ROOT_ID"],
},
"ROW-abc": {
"id": "ROW-abc",
"type": "ROW",
"children": ["CHART-xyz"],
"parents": ["ROOT_ID", "GRID_ID"],
},
"CHART-xyz": {
"id": "CHART-xyz",
"type": "CHART",
"meta": {"chartId": 10},
"parents": ["ROOT_ID", "GRID_ID", "ROW-abc"],
},
"CHART-other": {
"id": "CHART-other",
"type": "CHART",
"meta": {"chartId": 20},
"parents": ["ROOT_ID", "GRID_ID", "ROW-abc"],
},
}
def _make_filter(
flt_id: str = "NATIVE_FILTER-1",
name: str = "Region Filter",
scope_root: list[str] | None = None,
scope_excluded: list[int] | None = None,
charts_in_scope: list[int] | None = None,
default_value: list[str] | None = None,
extra_form_data: dict[str, Any] | None = None,
default_to_first_item: bool = False,
target_column: str | None = "region",
flt_type: str = "NATIVE_FILTER",
) -> dict[str, Any]:
"""Helper to build a native filter config dict for testing."""
flt: dict[str, Any] = {
"id": flt_id,
"name": name,
"type": flt_type,
"filterType": "filter_select",
"targets": [{"datasetId": 1, "column": {"name": target_column}}]
if target_column
else [],
"scope": {
"rootPath": scope_root or ["ROOT_ID"],
"excluded": scope_excluded or [],
},
"controlValues": {
"defaultToFirstItem": default_to_first_item,
"multiSelect": True,
"enableEmptyFilter": False,
},
"defaultDataMask": {},
}
if charts_in_scope is not None:
flt["chartsInScope"] = charts_in_scope
if default_value is not None:
flt["defaultDataMask"]["filterState"] = {"value": default_value}
if extra_form_data is None:
extra_form_data = {
"filters": [{"col": target_column, "op": "IN", "val": default_value}]
}
if extra_form_data is not None:
flt["defaultDataMask"]["extraFormData"] = extra_form_data
return flt
# --- _find_chart_layout_item ---
def test_find_chart_layout_item_found() -> None:
result = _find_chart_layout_item(10, SAMPLE_POSITION_JSON)
assert result is not None
assert result["id"] == "CHART-xyz"
assert result["meta"]["chartId"] == 10
def test_find_chart_layout_item_not_found() -> None:
result = _find_chart_layout_item(999, SAMPLE_POSITION_JSON)
assert result is None
def test_find_chart_layout_item_skips_non_dict_entries() -> None:
position = {**SAMPLE_POSITION_JSON, "DASHBOARD_VERSION_KEY": "v2"}
result = _find_chart_layout_item(10, position)
assert result is not None
# --- _is_filter_in_scope_for_chart ---
def test_filter_in_scope_via_charts_in_scope() -> None:
flt = _make_filter(charts_in_scope=[10, 20])
assert _is_filter_in_scope_for_chart(flt, 10, SAMPLE_POSITION_JSON) is True
def test_filter_not_in_scope_via_charts_in_scope() -> None:
flt = _make_filter(charts_in_scope=[20, 30])
assert _is_filter_in_scope_for_chart(flt, 10, SAMPLE_POSITION_JSON) is False
def test_filter_empty_charts_in_scope_not_in_scope() -> None:
"""Empty chartsInScope means in scope for no charts; do not fall back to rootPath"""
flt = _make_filter(charts_in_scope=[], scope_root=["ROOT_ID"])
assert _is_filter_in_scope_for_chart(flt, 10, SAMPLE_POSITION_JSON) is False
def test_filter_in_scope_via_root_path() -> None:
flt = _make_filter(scope_root=["ROOT_ID"])
assert _is_filter_in_scope_for_chart(flt, 10, SAMPLE_POSITION_JSON) is True
def test_filter_excluded_from_scope() -> None:
flt = _make_filter(scope_root=["ROOT_ID"], scope_excluded=[10])
assert _is_filter_in_scope_for_chart(flt, 10, SAMPLE_POSITION_JSON) is False
def test_filter_not_in_scope_different_root() -> None:
flt = _make_filter(scope_root=["TABS-nonexistent"])
assert _is_filter_in_scope_for_chart(flt, 10, SAMPLE_POSITION_JSON) is False
def test_filter_in_scope_chart_not_in_layout() -> None:
flt = _make_filter(scope_root=["ROOT_ID"])
assert _is_filter_in_scope_for_chart(flt, 999, SAMPLE_POSITION_JSON) is False
# --- _extract_filter_extra_form_data ---
def test_extract_static_default_applied() -> None:
flt = _make_filter(default_value=["US", "UK"])
extra_form_data, status = _extract_filter_extra_form_data(flt)
assert status == DashboardFilterStatus.APPLIED
assert extra_form_data is not None
assert extra_form_data["filters"][0]["val"] == ["US", "UK"]
def test_extract_default_to_first_item_not_applied() -> None:
"""defaultToFirstItem filters require a pre-query and cannot be applied."""
flt = _make_filter(default_to_first_item=True)
extra_form_data, status = _extract_filter_extra_form_data(flt)
assert (
status == DashboardFilterStatus.NOT_APPLIED_USES_DEFAULT_TO_FIRST_ITEM_PREQUERY
)
assert extra_form_data is None
def test_extract_no_default_value_not_applied() -> None:
"""Filters with no default are not applied, matching dashboard initial load."""
flt = _make_filter()
extra_form_data, status = _extract_filter_extra_form_data(flt)
assert status == DashboardFilterStatus.NOT_APPLIED
assert extra_form_data is None
def test_extract_default_to_first_item_overrides_static_default() -> None:
"""defaultToFirstItem takes precedence even when a static default exists."""
flt = _make_filter(default_value=["US"], default_to_first_item=True)
extra_form_data, status = _extract_filter_extra_form_data(flt)
assert (
status == DashboardFilterStatus.NOT_APPLIED_USES_DEFAULT_TO_FIRST_ITEM_PREQUERY
)
assert extra_form_data is None
def test_extract_filter_state_value_but_no_extra_form_data() -> None:
"""Edge case: filterState.value set but extraFormData not persisted."""
flt = _make_filter()
flt["defaultDataMask"] = {"filterState": {"value": ["US"]}}
extra_form_data, status = _extract_filter_extra_form_data(flt)
assert status == DashboardFilterStatus.NOT_APPLIED
assert extra_form_data is None
# --- _merge_extra_form_data ---
def test_merge_extra_form_data_appends_filters() -> None:
base = {"filters": [{"col": "a", "op": "IN", "val": ["x"]}]}
new = {"filters": [{"col": "b", "op": "IN", "val": ["y"]}]}
merged = _merge_extra_form_data(base, new)
assert len(merged["filters"]) == 2
def test_merge_extra_form_data_overrides_scalars() -> None:
base = {"time_range": "last week"}
new = {"time_range": "last month"}
merged = _merge_extra_form_data(base, new)
assert merged["time_range"] == "last month"
def test_merge_extra_form_data_empty_inputs() -> None:
assert _merge_extra_form_data({}, {}) == {}
def test_merge_extra_form_data_merges_custom_form_data_dicts() -> None:
"""custom_form_data is a dict; multiple filters' contributions are merged."""
base = {"custom_form_data": {"groupby": ["col1"]}}
new = {"custom_form_data": {"foo": "bar"}}
merged = _merge_extra_form_data(base, new)
assert merged["custom_form_data"] == {"groupby": ["col1"], "foo": "bar"}
# --- _get_filter_target_column ---
def test_target_column_from_dict() -> None:
from superset.charts.data.dashboard_filter_context import _get_filter_target_column
flt = _make_filter(target_column="country")
assert _get_filter_target_column(flt) == "country"
def test_target_column_no_targets() -> None:
from superset.charts.data.dashboard_filter_context import _get_filter_target_column
flt = _make_filter(target_column=None)
flt["targets"] = []
assert _get_filter_target_column(flt) is None
# --- validate_chart_on_dashboard ---
def test_validate_chart_on_dashboard_success() -> None:
dashboard = MagicMock()
slice_obj = MagicMock()
slice_obj.id = 10
dashboard.slices = [slice_obj]
_validate_chart_on_dashboard(dashboard, 10)
def test_validate_chart_on_dashboard_fails() -> None:
dashboard = MagicMock()
dashboard.id = 42
slice_obj = MagicMock()
slice_obj.id = 10
dashboard.slices = [slice_obj]
with pytest.raises(ValueError, match="not on dashboard"):
_validate_chart_on_dashboard(dashboard, 999)
# --- DashboardFilterContext.to_dict ---
def test_dashboard_filter_context_to_dict() -> None:
from superset.charts.data.dashboard_filter_context import DashboardFilterInfo
ctx = DashboardFilterContext(
extra_form_data={"filters": [{"col": "a", "op": "IN", "val": ["x"]}]},
filters=[
DashboardFilterInfo(
id="f1",
name="Filter 1",
status=DashboardFilterStatus.APPLIED,
column="a",
),
DashboardFilterInfo(
id="f2",
name="Filter 2",
status=DashboardFilterStatus.NOT_APPLIED,
),
],
)
result = ctx.to_dict()
assert len(result["filters"]) == 2
assert result["filters"][0]["status"] == "applied"
assert result["filters"][0]["column"] == "a"
assert result["filters"][1]["status"] == "not_applied"
assert "column" not in result["filters"][1]
# --- get_dashboard_filter_context (integration with mocks) ---
@patch("superset.charts.data.dashboard_filter_context._check_dashboard_access")
@patch("superset.charts.data.dashboard_filter_context.db")
def test_get_dashboard_filter_context_dashboard_not_found(
mock_db: MagicMock,
mock_check_access: MagicMock,
) -> None:
(
mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value
) = None
with pytest.raises(ValueError, match="not found"):
get_dashboard_filter_context(dashboard_id=999, chart_id=10)
@patch("superset.charts.data.dashboard_filter_context._check_dashboard_access")
@patch("superset.charts.data.dashboard_filter_context.db")
def test_get_dashboard_filter_context_chart_not_on_dashboard(
mock_db: MagicMock,
mock_check_access: MagicMock,
) -> None:
dashboard = MagicMock()
dashboard.id = 42
slice_obj = MagicMock()
slice_obj.id = 20
dashboard.slices = [slice_obj]
dashboard.json_metadata = "{}"
dashboard.position_json = "{}"
(
mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value
) = dashboard
with pytest.raises(ValueError, match="not on dashboard"):
get_dashboard_filter_context(dashboard_id=42, chart_id=10)
@patch("superset.charts.data.dashboard_filter_context._check_dashboard_access")
@patch("superset.charts.data.dashboard_filter_context.db")
def test_get_dashboard_filter_context_static_defaults(
mock_db: MagicMock,
mock_check_access: MagicMock,
) -> None:
filter_config = [
_make_filter(
flt_id="f1",
name="Region",
scope_root=["ROOT_ID"],
default_value=["US", "UK"],
target_column="region",
),
]
metadata = {"native_filter_configuration": filter_config}
dashboard = MagicMock()
dashboard.id = 1
slice_obj = MagicMock()
slice_obj.id = 10
dashboard.slices = [slice_obj]
dashboard.json_metadata = json.dumps(metadata)
dashboard.position_json = json.dumps(SAMPLE_POSITION_JSON)
(
mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value
) = dashboard
ctx = get_dashboard_filter_context(dashboard_id=1, chart_id=10)
assert len(ctx.filters) == 1
assert ctx.filters[0].status == DashboardFilterStatus.APPLIED
assert ctx.filters[0].column == "region"
assert len(ctx.extra_form_data.get("filters", [])) == 1
assert ctx.extra_form_data["filters"][0]["val"] == ["US", "UK"]
@patch("superset.charts.data.dashboard_filter_context._check_dashboard_access")
@patch("superset.charts.data.dashboard_filter_context.db")
def test_get_dashboard_filter_context_mixed_filter_types(
mock_db: MagicMock,
mock_check_access: MagicMock,
) -> None:
"""Test with a mix of static, dynamic, and no-default filters."""
filter_config = [
_make_filter(
flt_id="f1",
name="Region",
scope_root=["ROOT_ID"],
default_value=["US"],
target_column="region",
),
_make_filter(
flt_id="f2",
name="City",
scope_root=["ROOT_ID"],
default_to_first_item=True,
target_column="city",
),
_make_filter(
flt_id="f3",
name="Status",
scope_root=["ROOT_ID"],
target_column="status",
),
]
metadata = {"native_filter_configuration": filter_config}
dashboard = MagicMock()
dashboard.id = 1
slice_obj = MagicMock()
slice_obj.id = 10
dashboard.slices = [slice_obj]
dashboard.json_metadata = json.dumps(metadata)
dashboard.position_json = json.dumps(SAMPLE_POSITION_JSON)
(
mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value
) = dashboard
ctx = get_dashboard_filter_context(dashboard_id=1, chart_id=10)
assert len(ctx.filters) == 3
assert ctx.filters[0].status == DashboardFilterStatus.APPLIED
assert (
ctx.filters[1].status
== DashboardFilterStatus.NOT_APPLIED_USES_DEFAULT_TO_FIRST_ITEM_PREQUERY
)
assert ctx.filters[2].status == DashboardFilterStatus.NOT_APPLIED
# Only the static-default filter should contribute to extra_form_data
assert len(ctx.extra_form_data.get("filters", [])) == 1
@patch("superset.charts.data.dashboard_filter_context._check_dashboard_access")
@patch("superset.charts.data.dashboard_filter_context.db")
def test_get_dashboard_filter_context_skips_dividers(
mock_db: MagicMock,
mock_check_access: MagicMock,
) -> None:
filter_config = [
{
"id": "div-1",
"type": "DIVIDER",
"name": "Separator",
},
_make_filter(
flt_id="f1",
name="Region",
scope_root=["ROOT_ID"],
default_value=["US"],
target_column="region",
),
]
metadata = {"native_filter_configuration": filter_config}
dashboard = MagicMock()
dashboard.id = 1
slice_obj = MagicMock()
slice_obj.id = 10
dashboard.slices = [slice_obj]
dashboard.json_metadata = json.dumps(metadata)
dashboard.position_json = json.dumps(SAMPLE_POSITION_JSON)
(
mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value
) = dashboard
ctx = get_dashboard_filter_context(dashboard_id=1, chart_id=10)
assert len(ctx.filters) == 1
assert ctx.filters[0].name == "Region"
@patch("superset.charts.data.dashboard_filter_context._check_dashboard_access")
@patch("superset.charts.data.dashboard_filter_context.db")
def test_get_dashboard_filter_context_out_of_scope_filter_excluded(
mock_db: MagicMock,
mock_check_access: MagicMock,
) -> None:
"""Filters not in scope for the chart should be excluded."""
filter_config = [
_make_filter(
flt_id="f1",
name="In-scope",
scope_root=["ROOT_ID"],
default_value=["US"],
target_column="region",
),
_make_filter(
flt_id="f2",
name="Out-of-scope",
scope_root=["TABS-nonexistent"],
default_value=["active"],
target_column="status",
),
]
metadata = {"native_filter_configuration": filter_config}
dashboard = MagicMock()
dashboard.id = 1
slice_obj = MagicMock()
slice_obj.id = 10
dashboard.slices = [slice_obj]
dashboard.json_metadata = json.dumps(metadata)
dashboard.position_json = json.dumps(SAMPLE_POSITION_JSON)
(
mock_db.session.query.return_value.filter_by.return_value.one_or_none.return_value
) = dashboard
ctx = get_dashboard_filter_context(dashboard_id=1, chart_id=10)
assert len(ctx.filters) == 1
assert ctx.filters[0].id == "f1"