Skip to content

Commit f91dc78

Browse files
committed
feat(gooddata-sdk): [AUTO] Add DashboardMeasureValueFilter with comparison and range conditions
1 parent 1c4dfe4 commit f91dc78

3 files changed

Lines changed: 321 additions & 0 deletions

File tree

packages/gooddata-sdk/src/gooddata_sdk/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,13 @@
215215
CatalogDeclarativeMemoryItem,
216216
CatalogDeclarativeMetric,
217217
)
218+
from gooddata_sdk.catalog.workspace.declarative_model.workspace.analytics_model.dashboard_filters import (
219+
CatalogDashboardCompoundComparisonCondition,
220+
CatalogDashboardCompoundConditionItem,
221+
CatalogDashboardCompoundRangeCondition,
222+
CatalogDashboardMeasureValueFilter,
223+
CatalogDashboardMeasureValueFilterBody,
224+
)
218225
from gooddata_sdk.catalog.workspace.declarative_model.workspace.analytics_model.export_definition import (
219226
CatalogDeclarativeExportDefinition,
220227
CatalogDeclarativeExportDefinitionRequestPayload,
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
# (C) 2026 GoodData Corporation
2+
from __future__ import annotations
3+
4+
from typing import Any, Literal, Union
5+
6+
import attrs
7+
from cattrs import global_converter, structure
8+
from gooddata_api_client.model.dashboard_compound_comparison_condition import DashboardCompoundComparisonCondition
9+
from gooddata_api_client.model.dashboard_compound_range_condition import DashboardCompoundRangeCondition
10+
from gooddata_api_client.model.dashboard_measure_value_filter import DashboardMeasureValueFilter
11+
from gooddata_api_client.model.dashboard_measure_value_filter_measure_value_filter import (
12+
DashboardMeasureValueFilterMeasureValueFilter,
13+
)
14+
15+
from gooddata_sdk.catalog.base import Base
16+
17+
ComparisonOperator = Literal[
18+
"GREATER_THAN",
19+
"GREATER_THAN_OR_EQUAL_TO",
20+
"LESS_THAN",
21+
"LESS_THAN_OR_EQUAL_TO",
22+
"EQUAL_TO",
23+
"NOT_EQUAL_TO",
24+
]
25+
26+
RangeOperator = Literal["BETWEEN", "NOT_BETWEEN"]
27+
28+
29+
@attrs.define(kw_only=True)
30+
class CatalogDashboardCompoundComparisonCondition(Base):
31+
"""Comparison condition for a dashboard measure value filter.
32+
33+
Filters dashboard data where the measure satisfies a comparison
34+
relation (e.g. greater than, equal to) against a single value.
35+
"""
36+
37+
operator: str
38+
value: float
39+
40+
@staticmethod
41+
def client_class() -> type[DashboardCompoundComparisonCondition]:
42+
return DashboardCompoundComparisonCondition
43+
44+
def to_api(self) -> DashboardCompoundComparisonCondition:
45+
return DashboardCompoundComparisonCondition(
46+
operator=self.operator,
47+
value=self.value,
48+
_check_type=False,
49+
)
50+
51+
@classmethod
52+
def from_api(cls, entity: dict[str, Any]) -> CatalogDashboardCompoundComparisonCondition:
53+
return cls(
54+
operator=entity["operator"],
55+
value=float(entity["value"]),
56+
)
57+
58+
59+
@attrs.define(kw_only=True)
60+
class CatalogDashboardCompoundRangeCondition(Base):
61+
"""Range condition for a dashboard measure value filter.
62+
63+
Filters dashboard data where the measure falls within (or outside)
64+
a numeric range defined by ``from_value`` and ``to``.
65+
"""
66+
67+
from_value: float
68+
operator: str
69+
to: float
70+
71+
@staticmethod
72+
def client_class() -> type[DashboardCompoundRangeCondition]:
73+
return DashboardCompoundRangeCondition
74+
75+
def to_api(self) -> DashboardCompoundRangeCondition:
76+
return DashboardCompoundRangeCondition(
77+
_from=self.from_value,
78+
operator=self.operator,
79+
to=self.to,
80+
_check_type=False,
81+
)
82+
83+
@classmethod
84+
def from_api(cls, entity: dict[str, Any]) -> CatalogDashboardCompoundRangeCondition:
85+
# The JSON key is "from"; the API client Python attribute is "_from";
86+
# after to_dict() it appears as "from" (camelCase=True) or "_from" (camelCase=False).
87+
from_val = entity.get("from_value") or entity.get("from") or entity.get("_from")
88+
return cls(
89+
from_value=float(from_val),
90+
operator=entity["operator"],
91+
to=float(entity["to"]),
92+
)
93+
94+
95+
# Union type alias used as the element type for ``conditions``.
96+
CatalogDashboardCompoundConditionItem = Union[
97+
CatalogDashboardCompoundComparisonCondition,
98+
CatalogDashboardCompoundRangeCondition,
99+
]
100+
101+
102+
def _structure_condition_item(v: dict[str, Any], _: Any) -> CatalogDashboardCompoundConditionItem:
103+
"""Discriminate between comparison and range conditions.
104+
105+
Comparison conditions carry a ``value`` key; range conditions carry
106+
``from`` / ``_from`` / ``from_value`` instead.
107+
"""
108+
if "value" in v:
109+
return structure(v, CatalogDashboardCompoundComparisonCondition)
110+
return structure(v, CatalogDashboardCompoundRangeCondition)
111+
112+
113+
global_converter.register_structure_hook(
114+
CatalogDashboardCompoundConditionItem, # type: ignore[arg-type]
115+
_structure_condition_item,
116+
)
117+
118+
119+
@attrs.define(kw_only=True)
120+
class CatalogDashboardMeasureValueFilterBody(Base):
121+
"""Inner body of a :class:`CatalogDashboardMeasureValueFilter`.
122+
123+
Attributes:
124+
conditions: One or more compound conditions (comparison or range).
125+
Multiple conditions are combined with OR logic.
126+
measure: IdentifierRef dict pointing to the measure to filter on,
127+
e.g. ``{"identifier": {"id": "metric/revenue", "type": "metric"}}``.
128+
local_identifier: Optional local identifier for the filter within
129+
a dashboard layout.
130+
title: Optional human-readable label for the filter.
131+
"""
132+
133+
conditions: list[CatalogDashboardCompoundConditionItem]
134+
measure: dict[str, Any]
135+
local_identifier: str | None = None
136+
title: str | None = None
137+
138+
@staticmethod
139+
def client_class() -> type[DashboardMeasureValueFilterMeasureValueFilter]:
140+
return DashboardMeasureValueFilterMeasureValueFilter
141+
142+
def to_api(self) -> DashboardMeasureValueFilterMeasureValueFilter:
143+
kwargs: dict[str, Any] = {}
144+
if self.local_identifier is not None:
145+
kwargs["local_identifier"] = self.local_identifier
146+
if self.title is not None:
147+
kwargs["title"] = self.title
148+
return DashboardMeasureValueFilterMeasureValueFilter(
149+
conditions=[c.to_api() for c in self.conditions],
150+
measure=self.measure,
151+
_check_type=False,
152+
**kwargs,
153+
)
154+
155+
@classmethod
156+
def from_api(cls, entity: dict[str, Any]) -> CatalogDashboardMeasureValueFilterBody:
157+
local_id = entity.get("local_identifier") or entity.get("localIdentifier")
158+
return cls(
159+
conditions=[_condition_item_from_dict(c) for c in entity.get("conditions", [])],
160+
measure=entity["measure"],
161+
local_identifier=local_id,
162+
title=entity.get("title"),
163+
)
164+
165+
166+
def _condition_item_from_dict(v: dict[str, Any]) -> CatalogDashboardCompoundConditionItem:
167+
"""Construct the correct condition subtype from a plain dict."""
168+
if "value" in v:
169+
return CatalogDashboardCompoundComparisonCondition(
170+
operator=v["operator"],
171+
value=float(v["value"]),
172+
)
173+
from_val = v.get("from_value") or v.get("from") or v.get("_from")
174+
return CatalogDashboardCompoundRangeCondition(
175+
from_value=float(from_val),
176+
operator=v["operator"],
177+
to=float(v["to"]),
178+
)
179+
180+
181+
@attrs.define(kw_only=True)
182+
class CatalogDashboardMeasureValueFilter(Base):
183+
"""Dashboard filter that restricts visible data by a measure's value.
184+
185+
This filter type can appear as an element of
186+
``DashboardFilter.oneOf`` and may be used in dashboard filter
187+
contexts or as a filter override in tabular dashboard exports.
188+
189+
Example::
190+
191+
from gooddata_sdk import (
192+
CatalogDashboardCompoundComparisonCondition,
193+
CatalogDashboardMeasureValueFilter,
194+
CatalogDashboardMeasureValueFilterBody,
195+
)
196+
197+
mvf = CatalogDashboardMeasureValueFilter(
198+
measure_value_filter=CatalogDashboardMeasureValueFilterBody(
199+
conditions=[
200+
CatalogDashboardCompoundComparisonCondition(
201+
operator="GREATER_THAN",
202+
value=1000.0,
203+
)
204+
],
205+
measure={"identifier": {"id": "metric/revenue", "type": "metric"}},
206+
title="Revenue > 1 000",
207+
)
208+
)
209+
"""
210+
211+
measure_value_filter: CatalogDashboardMeasureValueFilterBody
212+
213+
@staticmethod
214+
def client_class() -> type[DashboardMeasureValueFilter]:
215+
return DashboardMeasureValueFilter
216+
217+
def to_api(self) -> DashboardMeasureValueFilter:
218+
return DashboardMeasureValueFilter(
219+
measure_value_filter=self.measure_value_filter.to_api(),
220+
_check_type=False,
221+
)
222+
223+
@classmethod
224+
def from_api(cls, entity: dict[str, Any]) -> CatalogDashboardMeasureValueFilter:
225+
# Accept both camelCase (from live API) and snake_case (from to_dict round-trip).
226+
raw_body = entity.get("measure_value_filter") or entity.get("measureValueFilter")
227+
return cls(measure_value_filter=CatalogDashboardMeasureValueFilterBody.from_api(raw_body))

packages/gooddata-sdk/tests/catalog/test_catalog_workspace_content.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
import attrs
1010
from gooddata_sdk import (
11+
CatalogDashboardCompoundComparisonCondition,
12+
CatalogDashboardCompoundRangeCondition,
13+
CatalogDashboardMeasureValueFilter,
14+
CatalogDashboardMeasureValueFilterBody,
1115
CatalogDatasetWorkspaceDataFilterIdentifier,
1216
CatalogDeclarativeAnalytics,
1317
CatalogDeclarativeExportDefinition,
@@ -502,3 +506,86 @@ def test_export_definition_analytics_layout(test_config):
502506
assert deep_eq(analytics_o.analytics.export_definitions, analytics_e.analytics.export_definitions)
503507
finally:
504508
safe_delete(_refresh_workspaces, sdk)
509+
510+
511+
@gd_vcr.use_cassette(str(_fixtures_dir / "test_dashboard_measure_value_filter.yaml"))
512+
def test_dashboard_measure_value_filter(test_config):
513+
"""Verify that DashboardMeasureValueFilter can be embedded in a dashboard and round-trips
514+
correctly through the declarative analytics model endpoints.
515+
516+
The test:
517+
1. Gets the current analytics model.
518+
2. Injects a measureValueFilter (comparison + range) into the first dashboard's content.
519+
3. Puts the updated model back.
520+
4. Gets it again and verifies the filters are preserved.
521+
5. Restores the original model in `finally`.
522+
"""
523+
sdk = GoodDataSdk.create(host_=test_config["host"], token_=test_config["token"])
524+
workspace_id = test_config["workspace"]
525+
526+
original = sdk.catalog_workspace_content.get_declarative_analytics_model(workspace_id, exclude=["ACTIVITY_INFO"])
527+
try:
528+
modified = copy.deepcopy(original)
529+
assert modified.analytics is not None and modified.analytics.analytical_dashboards, (
530+
"Workspace must have at least one analytical dashboard for this test"
531+
)
532+
533+
dashboard = modified.analytics.analytical_dashboards[0]
534+
535+
# Build a comparison-condition measure value filter (operator + single value).
536+
comparison_filter = CatalogDashboardMeasureValueFilter(
537+
measure_value_filter=CatalogDashboardMeasureValueFilterBody(
538+
conditions=[
539+
CatalogDashboardCompoundComparisonCondition(
540+
operator="GREATER_THAN",
541+
value=1000.0,
542+
)
543+
],
544+
measure={"identifier": {"id": "order_amount", "type": "metric"}},
545+
title="Order Amount > 1000",
546+
)
547+
)
548+
549+
# Build a range-condition measure value filter (from + operator + to).
550+
range_filter = CatalogDashboardMeasureValueFilter(
551+
measure_value_filter=CatalogDashboardMeasureValueFilterBody(
552+
conditions=[
553+
CatalogDashboardCompoundRangeCondition(
554+
from_value=500.0,
555+
operator="BETWEEN",
556+
to=2000.0,
557+
)
558+
],
559+
measure={"identifier": {"id": "order_amount", "type": "metric"}},
560+
title="Order Amount 500-2000",
561+
)
562+
)
563+
564+
# Verify SDK → API serialization before touching the server.
565+
comp_api = comparison_filter.to_api()
566+
assert comp_api.measure_value_filter.conditions[0].operator == "GREATER_THAN"
567+
assert comp_api.measure_value_filter.conditions[0].value == 1000.0
568+
569+
range_api = range_filter.to_api()
570+
assert range_api.measure_value_filter.conditions[0].operator == "BETWEEN"
571+
572+
# Inject the filters into the dashboard content.
573+
existing_filters = dashboard.content.get("filters", [])
574+
dashboard.content["filters"] = existing_filters + [
575+
comparison_filter.to_api().to_dict(camel_case=True),
576+
range_filter.to_api().to_dict(camel_case=True),
577+
]
578+
579+
sdk.catalog_workspace_content.put_declarative_analytics_model(workspace_id, modified)
580+
581+
retrieved = sdk.catalog_workspace_content.get_declarative_analytics_model(
582+
workspace_id, exclude=["ACTIVITY_INFO"]
583+
)
584+
retrieved_dashboard = retrieved.analytics.analytical_dashboards[0]
585+
retrieved_filters = retrieved_dashboard.content.get("filters", [])
586+
587+
# The two newly injected measure value filters must be present.
588+
mvf_filters = [f for f in retrieved_filters if "measureValueFilter" in f]
589+
assert len(mvf_filters) == 2, f"Expected 2 measureValueFilter entries, got {len(mvf_filters)}"
590+
finally:
591+
sdk.catalog_workspace_content.put_declarative_analytics_model(workspace_id, original)

0 commit comments

Comments
 (0)