This is an automated email from the ASF dual-hosted git repository. rusackas pushed a commit to branch fix/issue-36053-suggested-open-pr in repository https://gitbox.apache.org/repos/asf/superset.git
commit 462eb52ba87cab01ea62cad6711341b4204c91d0 Author: rusackas <[email protected]> AuthorDate: Tue Sep 8 20:53:08 2026 -0700 fix(dashboard): include native filter names in filter_state GET response The cached filter_state value is a DataMaskStateWithId keyed by filter id, not a flat object, so a client can't reliably read a "name"/label out of it. Cross-reference the filter ids present in the cached value against the dashboard's native_filter_configuration and return a `names` map (filter id -> label) alongside `value`. Fixes #36053 Co-Authored-By: Claude Sonnet 5 <[email protected]> --- superset/commands/dashboard/filter_state/get.py | 49 +++++++++++++++++++++- superset/dashboards/filter_state/api.py | 28 ++++++++++++- .../dashboards/filter_state/api_tests.py | 39 +++++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/superset/commands/dashboard/filter_state/get.py b/superset/commands/dashboard/filter_state/get.py index 1fee32e1b01..c832f230535 100644 --- a/superset/commands/dashboard/filter_state/get.py +++ b/superset/commands/dashboard/filter_state/get.py @@ -14,15 +14,17 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from typing import Optional +from typing import Any, Optional from flask import current_app as app from superset.commands.dashboard.filter_state.utils import check_access from superset.commands.temporary_cache.get import GetTemporaryCacheCommand from superset.commands.temporary_cache.parameters import CommandParameters +from superset.daos.dashboard import DashboardDAO from superset.extensions import cache_manager from superset.temporary_cache.utils import cache_key +from superset.utils import json class GetFilterStateCommand(GetTemporaryCacheCommand): @@ -40,3 +42,48 @@ class GetFilterStateCommand(GetTemporaryCacheCommand): if entry and self._refresh_timeout: cache_manager.filter_state_cache.set(key, entry) return entry.get("value") + + @staticmethod + def get_filter_names(resource_id: int, value: Optional[str]) -> dict[str, str]: + """ + Cross-reference the filter ids present in a cached filter_state + ``value`` (a ``DataMaskStateWithId``, i.e. a map keyed by filter id) + against the dashboard's ``native_filter_configuration`` to build a + map of filter id -> human-readable filter label. + + The cached blob itself has no notion of a filter's label: that only + lives in the dashboard's filter configuration metadata (#36053). + """ + if not value: + return {} + try: + parsed_value = json.loads(value) + except (json.JSONDecodeError, TypeError): + return {} + if not isinstance(parsed_value, dict): + return {} + + dashboard = DashboardDAO.get_by_id_or_slug(str(resource_id)) + try: + metadata = json.loads(dashboard.json_metadata or "{}") + except (json.JSONDecodeError, TypeError): + return {} + if not isinstance(metadata, dict): + return {} + + native_filters: list[dict[str, Any]] = metadata.get( + "native_filter_configuration", [] + ) + id_to_name = { + native_filter["id"]: native_filter["name"] + for native_filter in native_filters + if isinstance(native_filter, dict) + and native_filter.get("id") is not None + and native_filter.get("name") is not None + } + + return { + filter_id: id_to_name[filter_id] + for filter_id in parsed_value + if filter_id in id_to_name + } diff --git a/superset/dashboards/filter_state/api.py b/superset/dashboards/filter_state/api.py index 25fb7ad2095..dff9e8071bd 100644 --- a/superset/dashboards/filter_state/api.py +++ b/superset/dashboards/filter_state/api.py @@ -23,8 +23,13 @@ from superset.commands.dashboard.filter_state.create import CreateFilterStateCom from superset.commands.dashboard.filter_state.delete import DeleteFilterStateCommand from superset.commands.dashboard.filter_state.get import GetFilterStateCommand from superset.commands.dashboard.filter_state.update import UpdateFilterStateCommand +from superset.commands.temporary_cache.exceptions import ( + TemporaryCacheAccessDeniedError, + TemporaryCacheResourceNotFoundError, +) +from superset.commands.temporary_cache.parameters import CommandParameters from superset.extensions import event_logger -from superset.temporary_cache.api import TemporaryCacheRestApi +from superset.temporary_cache.api import CODEC, TemporaryCacheRestApi logger = logging.getLogger(__name__) @@ -256,6 +261,14 @@ class DashboardFilterStateRestApi(TemporaryCacheRestApi): value: type: string description: The stored value + names: + type: object + description: >- + A map of native filter id to that filter's + human-readable label, for the filter ids present in + `value`. Cross-referenced from the dashboard's + native filter configuration, since the cached + `value` itself has no notion of a filter's label. 400: $ref: '#/components/responses/400' 401: @@ -267,7 +280,18 @@ class DashboardFilterStateRestApi(TemporaryCacheRestApi): 500: $ref: '#/components/responses/500' """ - return super().get(pk, key) + try: + args = CommandParameters(resource_id=pk, key=key, codec=CODEC) + command = self.get_get_command()(args) + value = command.run() + if not value: + return self.response_404() + names = command.get_filter_names(pk, value) + return self.response(200, value=value, names=names) + except TemporaryCacheAccessDeniedError as ex: + return self.response(403, message=str(ex)) + except TemporaryCacheResourceNotFoundError as ex: + return self.response(404, message=str(ex)) @expose("/<int:pk>/filter_state/<string:key>", methods=("DELETE",)) @protect() diff --git a/tests/integration_tests/dashboards/filter_state/api_tests.py b/tests/integration_tests/dashboards/filter_state/api_tests.py index ef081efd4e1..6cdf40b350b 100644 --- a/tests/integration_tests/dashboards/filter_state/api_tests.py +++ b/tests/integration_tests/dashboards/filter_state/api_tests.py @@ -317,6 +317,45 @@ def test_get_dashboard_filter_state(test_client, login_as_admin, dashboard_id: i assert INITIAL_VALUE == data.get("value") +def test_get_dashboard_filter_state_includes_names( + test_client, login_as_admin, dashboard_id: int, admin_id: int +): + """ + The cached filter state is a DataMaskStateWithId, i.e. a map keyed by + filter id, not a flat object with a top-level "name" (#36053). The + human-readable label for each filter id lives in the dashboard's + native_filter_configuration, so the GET response should cross-reference + it and return a `names` map of filter id -> label alongside `value`. + """ + dashboard = db.session.query(Dashboard).filter_by(id=dashboard_id).one() + original_json_metadata = dashboard.json_metadata + filter_id = "NATIVE_FILTER-abc123" + try: + metadata = json.loads(dashboard.json_metadata or "{}") + metadata["native_filter_configuration"] = [ + {"id": filter_id, "name": "My Filter Label"} + ] + dashboard.json_metadata = json.dumps(metadata) + db.session.commit() + + filter_state_value = json.dumps( + {filter_id: {"id": filter_id, "extraFormData": {}}} + ) + cache_manager.filter_state_cache.set( + cache_key(dashboard_id, KEY), + {"owner": admin_id, "value": filter_state_value}, + ) + + resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}") + assert resp.status_code == 200 + data = json.loads(resp.data.decode("utf-8")) + assert data.get("value") == filter_state_value + assert data.get("names") == {filter_id: "My Filter Label"} + finally: + dashboard.json_metadata = original_json_metadata + db.session.commit() + + def test_get_access_denied(test_client, login_as, dashboard_id): login_as("gamma") resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
