mirror of
https://github.com/apache/superset.git
synced 2026-05-07 17:04:58 +00:00
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:
11724
docs/static/resources/openapi.json
vendored
11724
docs/static/resources/openapi.json
vendored
File diff suppressed because it is too large
Load Diff
@@ -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]:
|
||||
|
||||
306
superset/charts/data/dashboard_filter_context.py
Normal file
306
superset/charts/data/dashboard_filter_context.py
Normal 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
|
||||
@@ -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`
|
||||
|
||||
@@ -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"]
|
||||
|
||||
525
tests/unit_tests/charts/test_dashboard_filter_context.py
Normal file
525
tests/unit_tests/charts/test_dashboard_filter_context.py
Normal 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"
|
||||
Reference in New Issue
Block a user