diff --git a/cluster-info.txt b/cluster-info.txt new file mode 100644 index 000000000..8189157f8 --- /dev/null +++ b/cluster-info.txt @@ -0,0 +1,3 @@ +- `af3e42d` Merge pull request #21504 from gooddata/dho/cq-2114-automation-filters +- `0468f7f` Merge pull request #21536 from gooddata/dho/cq-2114-prop +- `c9c966b` Merge pull request #21589 from gooddata/dho/cq-2114-flag \ No newline at end of file diff --git a/cluster.json b/cluster.json new file mode 100644 index 000000000..0a7cd1137 --- /dev/null +++ b/cluster.json @@ -0,0 +1,33 @@ +{ + "id": "C002", + "title": "Add new dashboard filter types: Arbitrary and Match attribute filters", + "services": [ + "gooddata-afm-client", + "gooddata-automation-client", + "gooddata-export-client", + "gooddata-metadata-client" + ], + "commits": [ + { + "sha": "af3e42ddf340409122c7c5c8d08395c1944a090f", + "author": "Dan Homola", + "author_email": "dan.homola@gooddata.com", + "message": "Merge pull request #21504 from gooddata/dho/cq-2114-automation-filters" + }, + { + "sha": "0468f7fde58088d792640d31b82a9a71c96d1f71", + "author": "Dan Homola", + "author_email": "dan.homola@gooddata.com", + "message": "Merge pull request #21536 from gooddata/dho/cq-2114-prop" + }, + { + "sha": "c9c966ba79b69c33f7cf09af252d4e2081308147", + "author": "Dan Homola", + "author_email": "dan.homola@gooddata.com", + "message": "Merge pull request #21589 from gooddata/dho/cq-2114-flag" + } + ], + "diff": "--- a/gooddata-automation-client.json\n+++ b/gooddata-automation-client.json\n+ \"DashboardArbitraryAttributeFilter\": {\n+ \"properties\": {\n+ \"arbitraryAttributeFilter\": {\n+ \"properties\": {\n+ \"displayForm\": { \"$ref\": \"#/components/schemas/IdentifierRef\" },\n+ \"negativeSelection\": { \"type\": \"boolean\" },\n+ \"values\": { \"items\": { \"type\": \"string\" }, \"type\": \"array\" }\n+ },\n+ \"required\": [\"displayForm\",\"negativeSelection\",\"values\"]\n+ }\n+ },\n+ \"required\": [\"arbitraryAttributeFilter\"]\n+ },\n+ \"DashboardMatchAttributeFilter\": {\n+ \"properties\": {\n+ \"matchAttributeFilter\": {\n+ \"properties\": {\n+ \"operator\": { \"enum\": [\"contains\",\"startsWith\",\"endsWith\"] },\n+ \"literal\": { \"type\": \"string\" }\n+ },\n+ \"required\": [\"caseSensitive\",\"displayForm\",\"literal\",\"negativeSelection\",\"operator\"]\n+ }\n+ },\n+ \"required\": [\"matchAttributeFilter\"]\n+ },\n+ \"usesArbitraryValues\": { \"description\": \"If true, values in notInElements were filled free-form.\", \"type\": \"boolean\" }", + "jira_tickets": [], + "sdk_impact": "new_feature" +} \ No newline at end of file diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index 77397b92d..7034203f4 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -210,9 +210,15 @@ CatalogDeclarativeExportDefinitionRequestPayload, ) from gooddata_sdk.catalog.workspace.declarative_model.workspace.automation import ( + CatalogAutomationDashboardTabularExport, CatalogAutomationSchedule, + CatalogDashboardTabularExportRequestV2, CatalogDeclarativeAutomation, ) +from gooddata_sdk.catalog.workspace.declarative_model.workspace.dashboard_filter import ( + CatalogDashboardArbitraryAttributeFilter, + CatalogDashboardMatchAttributeFilter, +) from gooddata_sdk.catalog.workspace.declarative_model.workspace.logical_model.data_filter_references import ( CatalogDeclarativeWorkspaceDataFilterReferences, ) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/declarative_model/workspace/automation.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/declarative_model/workspace/automation.py index 079fd90a8..295d3d57b 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/declarative_model/workspace/automation.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/declarative_model/workspace/automation.py @@ -3,9 +3,11 @@ from typing import Any from attrs import define, field +from gooddata_api_client.model.automation_dashboard_tabular_export import AutomationDashboardTabularExport from gooddata_api_client.model.automation_schedule import AutomationSchedule from gooddata_api_client.model.automation_tabular_export import AutomationTabularExport from gooddata_api_client.model.automation_visual_export import AutomationVisualExport +from gooddata_api_client.model.dashboard_tabular_export_request_v2 import DashboardTabularExportRequestV2 from gooddata_api_client.model.declarative_automation import DeclarativeAutomation from gooddata_sdk.catalog.base import Base @@ -51,6 +53,60 @@ def client_class() -> builtins.type[AutomationVisualExport]: return AutomationVisualExport +@define(kw_only=True) +class CatalogDashboardTabularExportRequestV2(Base): + """SDK wrapper for DashboardTabularExportRequestV2. + + Represents the request payload for a dashboard tabular export, used within + automation ``dashboard_tabular_exports``. + + The ``dashboard_filters_override`` field accepts raw filter dicts. Each dict + must be a serialised ``DashboardFilter`` (one of: ``attributeFilter``, + ``dateFilter``, ``arbitraryAttributeFilter``, ``matchAttributeFilter``). + Use :class:`~gooddata_sdk.CatalogDashboardArbitraryAttributeFilter` or + :class:`~gooddata_sdk.CatalogDashboardMatchAttributeFilter` to build these dicts. + + Attributes: + dashboard_id: Dashboard identifier. + file_name: Output filename without extension. + format: Export format – ``"XLSX"`` or ``"PDF"``. + dashboard_filters_override: Optional list of filter override dicts. + dashboard_tabs_filters_overrides: Optional per-tab filter overrides. + settings: Optional export settings dict. + widget_ids: Optional list of widget IDs to export (max 1). + """ + + dashboard_id: str + file_name: str + format: str + dashboard_filters_override: list[dict] | None = None + dashboard_tabs_filters_overrides: dict | None = None + settings: dict | None = None + widget_ids: list[str] | None = None + + @staticmethod + def client_class() -> builtins.type[DashboardTabularExportRequestV2]: + return DashboardTabularExportRequestV2 + + +@define(kw_only=True) +class CatalogAutomationDashboardTabularExport(Base): + """SDK wrapper for AutomationDashboardTabularExport. + + Wraps a :class:`CatalogDashboardTabularExportRequestV2` for use in + :class:`CatalogDeclarativeAutomation`. + + Attributes: + request_payload: The dashboard tabular export request. + """ + + request_payload: CatalogDashboardTabularExportRequestV2 + + @staticmethod + def client_class() -> builtins.type[AutomationDashboardTabularExport]: + return AutomationDashboardTabularExport + + @define(kw_only=True) class CatalogDeclarativeAutomation(CatalogAnalyticsBaseMeta): description: str | None = None @@ -65,6 +121,7 @@ class CatalogDeclarativeAutomation(CatalogAnalyticsBaseMeta): schedule: CatalogAutomationSchedule | None = None tabular_exports: list[CatalogAutomationTabularExport] | None = None visual_exports: list[CatalogAutomationVisualExport] | None = None + dashboard_tabular_exports: list[CatalogAutomationDashboardTabularExport] | None = None @staticmethod def client_class() -> builtins.type[DeclarativeAutomation]: diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/declarative_model/workspace/dashboard_filter.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/declarative_model/workspace/dashboard_filter.py new file mode 100644 index 000000000..a4038b98f --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/declarative_model/workspace/dashboard_filter.py @@ -0,0 +1,87 @@ +# (C) 2024 GoodData Corporation +from __future__ import annotations + +import builtins +from typing import Literal + +import attrs +from gooddata_api_client.model.dashboard_arbitrary_attribute_filter_arbitrary_attribute_filter import ( + DashboardArbitraryAttributeFilterArbitraryAttributeFilter, +) +from gooddata_api_client.model.dashboard_match_attribute_filter_match_attribute_filter import ( + DashboardMatchAttributeFilterMatchAttributeFilter, +) + +from gooddata_sdk.catalog.base import Base + +DashboardMatchOperator = Literal["contains", "startsWith", "endsWith"] + + +@attrs.define(kw_only=True) +class CatalogDashboardArbitraryAttributeFilter(Base): + """SDK wrapper for the body of DashboardArbitraryAttributeFilter. + + Represents a free-form attribute filter for dashboard filter overrides. + The ``display_form`` field should be an ``IdentifierRef`` dict, e.g.:: + + {"identifier": {"id": "label.my_label", "type": "label"}} + + To build a dashboard filter override dict, wrap the result of + :meth:`to_dict` in the outer key:: + + {"arbitrary_attribute_filter": filter.to_dict(camel_case=False)} + + Attributes: + display_form: IdentifierRef dict identifying the display form. + negative_selection: When ``True``, the filter excludes the listed values. + values: List of string values to filter on. + local_identifier: Optional local identifier for the filter. + title: Optional human-readable title. + """ + + display_form: dict + negative_selection: bool + values: list[str] = attrs.field(factory=list) + local_identifier: str | None = None + title: str | None = None + + @staticmethod + def client_class() -> builtins.type[DashboardArbitraryAttributeFilterArbitraryAttributeFilter]: + return DashboardArbitraryAttributeFilterArbitraryAttributeFilter + + +@attrs.define(kw_only=True) +class CatalogDashboardMatchAttributeFilter(Base): + """SDK wrapper for the body of DashboardMatchAttributeFilter. + + Represents a string-match attribute filter for dashboard filter overrides. + The ``display_form`` field should be an ``IdentifierRef`` dict, e.g.:: + + {"identifier": {"id": "label.my_label", "type": "label"}} + + To build a dashboard filter override dict, wrap the result of + :meth:`to_dict` in the outer key:: + + {"match_attribute_filter": filter.to_dict(camel_case=False)} + + Attributes: + display_form: IdentifierRef dict identifying the display form. + case_sensitive: When ``True``, the string match is case-sensitive. + literal: The string literal to match against. + negative_selection: When ``True``, the filter excludes matches. + operator: Match operator – one of ``"contains"``, ``"startsWith"``, ``"endsWith"``. + local_identifier: Optional local identifier for the filter. + title: Optional human-readable title. + """ + + display_form: dict + case_sensitive: bool + literal: str + negative_selection: bool + operator: DashboardMatchOperator + local_identifier: str | None = None + title: str | None = None + + @staticmethod + def client_class() -> builtins.type[DashboardMatchAttributeFilterMatchAttributeFilter]: + return DashboardMatchAttributeFilterMatchAttributeFilter diff --git a/packages/gooddata-sdk/tests/catalog/unit_tests/test_dashboard_filter.py b/packages/gooddata-sdk/tests/catalog/unit_tests/test_dashboard_filter.py new file mode 100644 index 000000000..fbc85f590 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/unit_tests/test_dashboard_filter.py @@ -0,0 +1,243 @@ +# (C) 2024 GoodData Corporation +"""Unit tests for the new dashboard filter types and related automation classes.""" + +from __future__ import annotations + +from gooddata_sdk import ( + CatalogAutomationDashboardTabularExport, + CatalogDashboardArbitraryAttributeFilter, + CatalogDashboardMatchAttributeFilter, + CatalogDashboardTabularExportRequestV2, + CatalogDeclarativeAutomation, +) + +_DISPLAY_FORM = {"identifier": {"id": "label.region", "type": "label"}} + + +class TestCatalogDashboardArbitraryAttributeFilter: + def test_basic_construction(self): + f = CatalogDashboardArbitraryAttributeFilter( + display_form=_DISPLAY_FORM, + negative_selection=False, + values=["East", "West"], + ) + assert f.display_form == _DISPLAY_FORM + assert f.negative_selection is False + assert f.values == ["East", "West"] + assert f.local_identifier is None + assert f.title is None + + def test_empty_values_default(self): + f = CatalogDashboardArbitraryAttributeFilter( + display_form=_DISPLAY_FORM, + negative_selection=True, + ) + assert f.values == [] + + def test_to_dict_snake_case(self): + f = CatalogDashboardArbitraryAttributeFilter( + display_form=_DISPLAY_FORM, + negative_selection=False, + values=["v1"], + local_identifier="loc1", + ) + d = f.to_dict(camel_case=False) + assert d["display_form"] == _DISPLAY_FORM + assert d["negative_selection"] is False + assert d["values"] == ["v1"] + assert d["local_identifier"] == "loc1" + + def test_to_dict_camel_case(self): + f = CatalogDashboardArbitraryAttributeFilter( + display_form=_DISPLAY_FORM, + negative_selection=False, + values=["v1"], + ) + d = f.to_dict(camel_case=True) + # camelCase keys expected from API model serialization + assert "displayForm" in d + assert "negativeSelection" in d + assert "values" in d + + def test_client_class(self): + from gooddata_api_client.model.dashboard_arbitrary_attribute_filter_arbitrary_attribute_filter import ( + DashboardArbitraryAttributeFilterArbitraryAttributeFilter, + ) + + assert ( + CatalogDashboardArbitraryAttributeFilter.client_class() + is DashboardArbitraryAttributeFilterArbitraryAttributeFilter + ) + + def test_dashboard_filter_override_dict(self): + """Demonstrate building a dashboard_filters_override entry.""" + f = CatalogDashboardArbitraryAttributeFilter( + display_form=_DISPLAY_FORM, + negative_selection=False, + values=["East"], + ) + override = {"arbitrary_attribute_filter": f.to_dict(camel_case=False)} + assert override["arbitrary_attribute_filter"]["display_form"] == _DISPLAY_FORM + + +class TestCatalogDashboardMatchAttributeFilter: + def test_basic_construction(self): + f = CatalogDashboardMatchAttributeFilter( + display_form=_DISPLAY_FORM, + case_sensitive=False, + literal="test", + negative_selection=False, + operator="contains", + ) + assert f.display_form == _DISPLAY_FORM + assert f.case_sensitive is False + assert f.literal == "test" + assert f.negative_selection is False + assert f.operator == "contains" + + def test_optional_fields_default_to_none(self): + f = CatalogDashboardMatchAttributeFilter( + display_form=_DISPLAY_FORM, + case_sensitive=True, + literal="prefix", + negative_selection=False, + operator="startsWith", + ) + assert f.local_identifier is None + assert f.title is None + + def test_to_dict_snake_case(self): + f = CatalogDashboardMatchAttributeFilter( + display_form=_DISPLAY_FORM, + case_sensitive=False, + literal="suffix", + negative_selection=True, + operator="endsWith", + ) + d = f.to_dict(camel_case=False) + assert d["display_form"] == _DISPLAY_FORM + assert d["case_sensitive"] is False + assert d["literal"] == "suffix" + assert d["negative_selection"] is True + assert d["operator"] == "endsWith" + + def test_client_class(self): + from gooddata_api_client.model.dashboard_match_attribute_filter_match_attribute_filter import ( + DashboardMatchAttributeFilterMatchAttributeFilter, + ) + + assert CatalogDashboardMatchAttributeFilter.client_class() is DashboardMatchAttributeFilterMatchAttributeFilter + + def test_dashboard_filter_override_dict(self): + """Demonstrate building a dashboard_filters_override entry.""" + f = CatalogDashboardMatchAttributeFilter( + display_form=_DISPLAY_FORM, + case_sensitive=False, + literal="East", + negative_selection=False, + operator="contains", + ) + override = {"match_attribute_filter": f.to_dict(camel_case=False)} + assert override["match_attribute_filter"]["operator"] == "contains" + + +class TestCatalogDashboardTabularExportRequestV2: + def test_basic_construction(self): + req = CatalogDashboardTabularExportRequestV2( + dashboard_id="my-dashboard", + file_name="export", + format="XLSX", + ) + assert req.dashboard_id == "my-dashboard" + assert req.file_name == "export" + assert req.format == "XLSX" + assert req.dashboard_filters_override is None + + def test_with_filters(self): + req = CatalogDashboardTabularExportRequestV2( + dashboard_id="my-dashboard", + file_name="export", + format="XLSX", + dashboard_filters_override=[ + { + "arbitrary_attribute_filter": { + "display_form": _DISPLAY_FORM, + "negative_selection": False, + "values": ["East"], + } + } + ], + ) + assert req.dashboard_filters_override is not None + assert len(req.dashboard_filters_override) == 1 + + def test_to_dict_snake_case(self): + req = CatalogDashboardTabularExportRequestV2( + dashboard_id="dash1", + file_name="out", + format="PDF", + ) + d = req.to_dict(camel_case=False) + assert d["dashboard_id"] == "dash1" + assert d["file_name"] == "out" + assert d["format"] == "PDF" + assert "dashboard_filters_override" not in d # None fields excluded + + def test_client_class(self): + from gooddata_api_client.model.dashboard_tabular_export_request_v2 import DashboardTabularExportRequestV2 + + assert CatalogDashboardTabularExportRequestV2.client_class() is DashboardTabularExportRequestV2 + + +class TestCatalogAutomationDashboardTabularExport: + def test_basic_construction(self): + req = CatalogDashboardTabularExportRequestV2( + dashboard_id="dash1", + file_name="out", + format="XLSX", + ) + export = CatalogAutomationDashboardTabularExport(request_payload=req) + assert export.request_payload is req + + def test_client_class(self): + from gooddata_api_client.model.automation_dashboard_tabular_export import AutomationDashboardTabularExport + + assert CatalogAutomationDashboardTabularExport.client_class() is AutomationDashboardTabularExport + + +class TestCatalogDeclarativeAutomationWithDashboardExports: + def test_dashboard_tabular_exports_field_exists(self): + automation = CatalogDeclarativeAutomation(id="a1") + assert automation.dashboard_tabular_exports is None + + def test_with_dashboard_tabular_exports(self): + export = CatalogAutomationDashboardTabularExport( + request_payload=CatalogDashboardTabularExportRequestV2( + dashboard_id="dash1", + file_name="out", + format="XLSX", + ) + ) + automation = CatalogDeclarativeAutomation( + id="a1", + dashboard_tabular_exports=[export], + ) + assert automation.dashboard_tabular_exports is not None + assert len(automation.dashboard_tabular_exports) == 1 + assert automation.dashboard_tabular_exports[0] is export + + def test_snake_dict_includes_dashboard_tabular_exports(self): + export = CatalogAutomationDashboardTabularExport( + request_payload=CatalogDashboardTabularExportRequestV2( + dashboard_id="dash1", + file_name="out", + format="XLSX", + ) + ) + automation = CatalogDeclarativeAutomation( + id="a1", + dashboard_tabular_exports=[export], + ) + d = automation._get_snake_dict() + assert "dashboard_tabular_exports" in d + assert len(d["dashboard_tabular_exports"]) == 1 diff --git a/result.json b/result.json new file mode 100644 index 000000000..490cb5145 --- /dev/null +++ b/result.json @@ -0,0 +1,13 @@ +{ + "status": "implemented", + "cluster_id": "C002", + "summary": "Implemented SDK support for new dashboard filter types (DashboardArbitraryAttributeFilter and DashboardMatchAttributeFilter) and the associated dashboard tabular export chain in the automation module.\n\nChanges:\n1. Created new file `dashboard_filter.py` with `CatalogDashboardArbitraryAttributeFilter` and `CatalogDashboardMatchAttributeFilter` — SDK wrappers for the new filter bodies, enabling users to construct filter override dicts for dashboard exports.\n2. Updated `automation.py` to add `CatalogDashboardTabularExportRequestV2` (wraps `DashboardTabularExportRequestV2`) and `CatalogAutomationDashboardTabularExport` (wraps `AutomationDashboardTabularExport`).\n3. Added `dashboard_tabular_exports: list[CatalogAutomationDashboardTabularExport] | None = None` to `CatalogDeclarativeAutomation`, enabling round-trip preservation of dashboard tabular export configurations.\n4. Exported all four new public classes in `gooddata_sdk/__init__.py`.\n5. Added 20 unit tests in `tests/catalog/unit_tests/test_dashboard_filter.py` covering construction, serialization, and integration with `CatalogDeclarativeAutomation`.\n\nThe `usesArbitraryValues` property from the diff is an optional boolean field already present in the generated API client's `NegativeAttributeFilterNegativeAttributeFilter` and `PositiveAttributeFilterPositiveAttributeFilter` models. The SDK's `NegativeAttributeFilter` and `PositiveAttributeFilter` wrappers pass through the API call without exposing this optional field — no change needed there since the field is optional and the wrappers use `_check_type=False`.", + "files_changed": [ + "gooddata-sdk/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/declarative_model/workspace/dashboard_filter.py", + "gooddata-sdk/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/declarative_model/workspace/automation.py", + "gooddata-sdk/packages/gooddata-sdk/src/gooddata_sdk/__init__.py", + "gooddata-sdk/packages/gooddata-sdk/tests/catalog/unit_tests/test_dashboard_filter.py" + ], + "reason": "", + "cost_usd": 5.23876245 +} \ No newline at end of file