From 12bef5e94bf047ec2563d3a3a82a6f3604eb87ef Mon Sep 17 00:00:00 2001 From: Christopher Bonilla Date: Tue, 12 Nov 2024 15:56:19 +0100 Subject: [PATCH] feat: add support for FilterView management Enabling management of FilterViews via pythonSDK JIRA: LX-428 risk: low --- gooddata-sdk/gooddata_sdk/__init__.py | 3 + .../gooddata_sdk/catalog/identifier.py | 15 +- .../declarative_model/workspace/workspace.py | 71 ++++++++ .../workspace/entity_model/filter_view.py | 147 +++++++++++++++ .../gooddata_sdk/catalog/workspace/service.py | 167 ++++++++++++++++++ 5 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 gooddata-sdk/gooddata_sdk/catalog/workspace/entity_model/filter_view.py diff --git a/gooddata-sdk/gooddata_sdk/__init__.py b/gooddata-sdk/gooddata_sdk/__init__.py index 248e866a4..ba8c0398f 100644 --- a/gooddata-sdk/gooddata_sdk/__init__.py +++ b/gooddata-sdk/gooddata_sdk/__init__.py @@ -71,6 +71,7 @@ from gooddata_sdk.catalog.identifier import ( CatalogAssigneeIdentifier, CatalogDatasetWorkspaceDataFilterIdentifier, + CatalogDeclarativeAnalyticalDashboardIdentifier, CatalogExportDefinitionIdentifier, CatalogNotificationChannelIdentifier, CatalogUserIdentifier, @@ -175,6 +176,8 @@ CatalogDeclarativeModel, ) from gooddata_sdk.catalog.workspace.declarative_model.workspace.workspace import ( + CatalogDeclarativeFilterView, + CatalogDeclarativeFilterViews, CatalogDeclarativeUserDataFilter, CatalogDeclarativeUserDataFilters, CatalogDeclarativeWorkspace, diff --git a/gooddata-sdk/gooddata_sdk/catalog/identifier.py b/gooddata-sdk/gooddata_sdk/catalog/identifier.py index 9de0a43ac..1b3beb78c 100644 --- a/gooddata-sdk/gooddata_sdk/catalog/identifier.py +++ b/gooddata-sdk/gooddata_sdk/catalog/identifier.py @@ -7,6 +7,9 @@ from attrs import define from gooddata_api_client.model.assignee_identifier import AssigneeIdentifier from gooddata_api_client.model.dataset_workspace_data_filter_identifier import DatasetWorkspaceDataFilterIdentifier +from gooddata_api_client.model.declarative_analytical_dashboard_identifier import ( + DeclarativeAnalyticalDashboardIdentifier, +) from gooddata_api_client.model.declarative_export_definition_identifier import DeclarativeExportDefinitionIdentifier from gooddata_api_client.model.declarative_notification_channel_identifier import ( DeclarativeNotificationChannelIdentifier, @@ -82,7 +85,7 @@ def client_class() -> builtins.type[DeclarativeUserIdentifier]: @attr.s(auto_attribs=True, kw_only=True) class CatalogLabelIdentifier(Base): id: str - type: str = attr.field(validator=value_in_allowed) + type: str = attr.field(default="analyticalDashboard", validator=value_in_allowed) @staticmethod def client_class() -> builtins.type[LabelIdentifier]: @@ -114,3 +117,13 @@ class CatalogNotificationChannelIdentifier(Base): @staticmethod def client_class() -> builtins.type[DeclarativeNotificationChannelIdentifier]: return DeclarativeNotificationChannelIdentifier + + +@attr.s(auto_attribs=True, kw_only=True) +class CatalogDeclarativeAnalyticalDashboardIdentifier(Base): + id: str + type: str = attr.field(validator=value_in_allowed) + + @staticmethod + def client_class() -> builtins.type[DeclarativeAnalyticalDashboardIdentifier]: + return DeclarativeAnalyticalDashboardIdentifier diff --git a/gooddata-sdk/gooddata_sdk/catalog/workspace/declarative_model/workspace/workspace.py b/gooddata-sdk/gooddata_sdk/catalog/workspace/declarative_model/workspace/workspace.py index 5641f80ec..7c52388a6 100644 --- a/gooddata-sdk/gooddata_sdk/catalog/workspace/declarative_model/workspace/workspace.py +++ b/gooddata-sdk/gooddata_sdk/catalog/workspace/declarative_model/workspace/workspace.py @@ -6,6 +6,7 @@ from typing import Any, Optional import attr +from gooddata_api_client.model.declarative_filter_view import DeclarativeFilterView from gooddata_api_client.model.declarative_user_data_filter import DeclarativeUserDataFilter from gooddata_api_client.model.declarative_user_data_filters import DeclarativeUserDataFilters from gooddata_api_client.model.declarative_workspace import DeclarativeWorkspace @@ -17,6 +18,7 @@ from gooddata_sdk.catalog.base import Base from gooddata_sdk.catalog.identifier import ( + CatalogDeclarativeAnalyticalDashboardIdentifier, CatalogDeclarativeUserGroupIdentifier, CatalogUserIdentifier, CatalogWorkspaceIdentifier, @@ -36,6 +38,7 @@ LAYOUT_WORKSPACES_DIR = "workspaces" LAYOUT_WORKSPACES_DATA_FILTERS_DIR = "workspaces_data_filters" LAYOUT_USER_DATA_FILTERS_DIR = "user_data_filters" +LAYOUT_FILTER_VIEWS_DIR = "filter_views" def get_workspace_folder(workspace_id: str, layout_organization_folder: Path) -> Path: @@ -85,6 +88,7 @@ class CatalogDeclarativeWorkspace(Base): user_data_filters: list[CatalogDeclarativeUserDataFilter] = attr.field(factory=list) custom_application_settings: list[CatalogDeclarativeCustomApplicationSetting] = attr.field(factory=list) automations: list[CatalogDeclarativeAutomation] = attr.field(factory=list) + filter_views: Optional[list[CatalogDeclarativeFilterView]] = None @staticmethod def client_class() -> type[DeclarativeWorkspace]: @@ -284,6 +288,69 @@ def from_dict(cls, data: dict[str, Any], camel_case: bool = True) -> CatalogDecl return cls.from_api(declarative_user_data_filter) +@attr.s(auto_attribs=True, kw_only=True) +class CatalogDeclarativeFilterViews(Base): + filter_views: list[CatalogDeclarativeFilterView] + + @staticmethod + def client_class() -> type[list[DeclarativeFilterView]]: + return list[DeclarativeFilterView] + + def store_to_disk(self, layout_organization_folder: Path) -> None: + filter_views_folder = CatalogDeclarativeWorkspaces.filter_views_folder(layout_organization_folder) + create_directory(filter_views_folder) + for filter_view in self.filter_views: + filter_view.store_to_disk(filter_views_folder) + + @classmethod + def load_from_disk(cls, layout_organization_folder: Path) -> CatalogDeclarativeFilterViews: + filter_views_files = get_sorted_yaml_files( + CatalogDeclarativeWorkspaces.filter_views_folder(layout_organization_folder) + ) + filter_views = [ + CatalogDeclarativeFilterView.load_from_disk(filter_views_file) for filter_views_file in filter_views_files + ] + return cls(filter_views=filter_views) + + +@attr.s(auto_attribs=True, kw_only=True) +class CatalogDeclarativeFilterView(Base): + id: str + title: str + analytical_dashboard: Optional[CatalogDeclarativeAnalyticalDashboardIdentifier] = None + content: Optional[dict[str, Any]] = None + description: Optional[str] = None + is_default: Optional[bool] = None + tags: Optional[list[str]] = None + user: Optional[CatalogUserIdentifier] = None + + @staticmethod + def client_class() -> type[DeclarativeFilterView]: + return DeclarativeFilterView + + def store_to_disk(self, filter_views_folder: Path) -> None: + filter_view_file = filter_views_folder / f"{self.id}.yaml" + write_layout_to_file(filter_view_file, self.to_api().to_dict(camel_case=True)) + + @classmethod + def load_from_disk(cls, filter_view_file: Path) -> CatalogDeclarativeFilterView: + filter_view = read_layout_from_file(filter_view_file) + return CatalogDeclarativeFilterView.from_dict(filter_view, camel_case=True) + + @classmethod + def from_dict(cls, data: dict[str, Any], camel_case: bool = True) -> CatalogDeclarativeFilterView: + """ + :param data: Data loaded for example from the file. + :param camel_case: True if the variable names in the input + data are serialized names as specified in the OpenAPI document. + False if the variables names in the input data are python + variable names in PEP-8 snake case. + :return: CatalogDeclarativeFilterView object. + """ + declarative_filter_view = DeclarativeFilterView.from_dict(data, camel_case) + return cls.from_api(declarative_filter_view) + + @attr.s(auto_attribs=True, kw_only=True) class CatalogDeclarativeWorkspaces(Base): workspaces: list[CatalogDeclarativeWorkspace] @@ -305,6 +372,10 @@ def workspace_data_filters_folder(layout_organization_folder: Path) -> Path: def user_data_filters_folder(layout_organization_folder: Path) -> Path: return layout_organization_folder / LAYOUT_USER_DATA_FILTERS_DIR + @staticmethod + def filter_views_folder(layout_organization_folder: Path) -> Path: + return layout_organization_folder / LAYOUT_FILTER_VIEWS_DIR + def store_to_disk(self, layout_organization_folder: Path) -> None: workspaces_folder = self.workspaces_folder(layout_organization_folder) workspaces_data_filters_folder = self.workspace_data_filters_folder(layout_organization_folder) diff --git a/gooddata-sdk/gooddata_sdk/catalog/workspace/entity_model/filter_view.py b/gooddata-sdk/gooddata_sdk/catalog/workspace/entity_model/filter_view.py new file mode 100644 index 000000000..1a6ce8489 --- /dev/null +++ b/gooddata-sdk/gooddata_sdk/catalog/workspace/entity_model/filter_view.py @@ -0,0 +1,147 @@ +# (C) 2024 GoodData Corporation +from __future__ import annotations + +from typing import Any, Optional + +import attr +from gooddata_api_client.model.json_api_filter_view_in import JsonApiFilterViewIn +from gooddata_api_client.model.json_api_filter_view_in_attributes import JsonApiFilterViewInAttributes +from gooddata_api_client.model.json_api_filter_view_in_document import JsonApiFilterViewInDocument +from gooddata_api_client.model.json_api_filter_view_in_relationships import JsonApiFilterViewInRelationships + +from gooddata_sdk.catalog.base import Base + + +@attr.s(auto_attribs=True, kw_only=True) +class CatalogFilterViewDocument(Base): + data: CatalogFilterView + + @staticmethod + def client_class() -> type[JsonApiFilterViewInDocument]: + return JsonApiFilterViewInDocument + + def to_api(self) -> JsonApiFilterViewInDocument: + return JsonApiFilterViewInDocument(data=self.data.to_api()) + + +def _data_entity(value: Any) -> dict[str, Any]: + return {"data": value} + + +@attr.s(auto_attribs=True, kw_only=True) +class CatalogFilterView(Base): + id: Optional[str] = None + attributes: CatalogFilterViewAttributes + relationships: Optional[CatalogFilterViewRelationships] = None + + @staticmethod + def client_class() -> type[JsonApiFilterViewIn]: + return JsonApiFilterViewIn + + @classmethod + def init( + cls, + filter_view_id: str, + content: dict[str, Any], + title: str, + are_relations_valid: Optional[bool] = None, + description: Optional[str] = None, + is_default: Optional[bool] = None, + tags: Optional[list[str]] = None, + user_id: Optional[str] = None, + analytical_dashboard_id: Optional[str] = None, + ) -> CatalogFilterView: + attributes = CatalogFilterViewAttributes( + content=content, + title=title, + are_relations_valid=are_relations_valid, + description=description, + is_default=is_default, + tags=tags, + ) + relationships = CatalogFilterViewRelationships.create_user_analytical_dashboard_relationship( + user_id=user_id, analytical_dashboard_id=analytical_dashboard_id + ) + return cls(id=filter_view_id, attributes=attributes, relationships=relationships) + + def to_api(self) -> JsonApiFilterViewIn: + attributes = self.attributes.to_api() + relationships = self.relationships.to_api() if self.relationships is not None else None + return JsonApiFilterViewIn(id=self.id, attributes=attributes, relationships=relationships) + + @property + def user_id(self) -> str | None: + if self.relationships and self.relationships.user: + return self.relationships.user["data"].id + return None + + @property + def analytical_dashboard_id(self) -> str | None: + if self.relationships and self.relationships.analytical_dashboard: + return self.relationships.analytical_dashboard["data"].id + return None + + def assign_user(self, user_id: str) -> None: + if self.relationships is None: + self.relationships = CatalogFilterViewRelationships.create_user_analytical_dashboard_relationship( + user_id=user_id + ) + else: + self.relationships.user = _data_entity(CatalogEntityIdentifier(id=user_id)) + + def assign_analytical_dashboard(self, analytical_dashboard_id: str) -> None: + if self.relationships is None: + self.relationships = CatalogFilterViewRelationships.create_user_analytical_dashboard_relationship( + analytical_dashboard_id=analytical_dashboard_id + ) + else: + self.relationships.analytical_dashboard = _data_entity(CatalogEntityIdentifier(id=analytical_dashboard_id)) + + def clean_assignments(self) -> None: + if self.relationships is not None: + self.relationships.user = None + self.relationships.analytical_dashboard = None + + +@attr.s(auto_attribs=True, kw_only=True) +class CatalogFilterViewAttributes(Base): + content: dict[str, Any] + title: str + are_relations_valid: Optional[bool] = None + description: Optional[str] = None + is_default: Optional[bool] = None + tags: Optional[list[str]] = None + + @staticmethod + def client_class() -> type[JsonApiFilterViewInAttributes]: + return JsonApiFilterViewInAttributes + + +@attr.s(auto_attribs=True, kw_only=True) +class CatalogFilterViewRelationships(Base): + user: Optional[dict[str, CatalogEntityIdentifier]] = None + analytical_dashboard: Optional[dict[str, CatalogEntityIdentifier]] = None + + @staticmethod + def client_class() -> type[JsonApiFilterViewInRelationships]: + return JsonApiFilterViewInRelationships + + @classmethod + def create_user_analytical_dashboard_relationship( + cls, user_id: Optional[str] = None, analytical_dashboard_id: Optional[str] = None + ) -> CatalogFilterViewRelationships | None: + if user_id is None and analytical_dashboard_id is None: + return None + assignee_user = _data_entity(CatalogEntityIdentifier(id=user_id, type="user")) if user_id else None + assignee_analytical_dashboard = ( + _data_entity(CatalogEntityIdentifier(id=analytical_dashboard_id, type="analyticalDashboard")) + if analytical_dashboard_id + else None + ) + return cls(user=assignee_user, analytical_dashboard=assignee_analytical_dashboard) + + +@attr.s(auto_attribs=True, kw_only=True) +class CatalogEntityIdentifier(Base): + id: str + type: Optional[str] = None diff --git a/gooddata-sdk/gooddata_sdk/catalog/workspace/service.py b/gooddata-sdk/gooddata_sdk/catalog/workspace/service.py index dd6fb8172..b0026b63e 100644 --- a/gooddata-sdk/gooddata_sdk/catalog/workspace/service.py +++ b/gooddata-sdk/gooddata_sdk/catalog/workspace/service.py @@ -21,6 +21,7 @@ from gooddata_sdk.catalog.catalog_service_base import CatalogServiceBase from gooddata_sdk.catalog.permission.service import CatalogPermissionService from gooddata_sdk.catalog.workspace.declarative_model.workspace.workspace import ( + CatalogDeclarativeFilterViews, CatalogDeclarativeUserDataFilters, CatalogDeclarativeWorkspaceDataFilters, CatalogDeclarativeWorkspaceModel, @@ -28,6 +29,10 @@ get_workspace_folder, ) from gooddata_sdk.catalog.workspace.entity_model.content_objects.workspace_setting import CatalogWorkspaceSetting +from gooddata_sdk.catalog.workspace.entity_model.filter_view import ( + CatalogFilterView, + CatalogFilterViewDocument, +) from gooddata_sdk.catalog.workspace.entity_model.user_data_filter import ( CatalogUserDataFilter, CatalogUserDataFilterDocument, @@ -1263,3 +1268,165 @@ def put_declarative_automations(self, workspace_id: str, automations: list[Catal """ api_automations = [automation.to_api() for automation in automations] self._layout_api.set_automations(workspace_id, api_automations) + + def list_filters_views(self, workspace_id: str) -> list[CatalogFilterView]: + """list all filter views. + + Args: + workspace_id (str): + String containing id of the workspace. + + Returns: + list[CatalogFilterView]: + List of filter view entities. + """ + get_filter_views = functools.partial( + self._entities_api.get_all_entities_filter_views, + workspace_id, + _check_return_type=False, + include=["ALL"], + ) + filter_views = load_all_entities_dict(get_filter_views, camel_case=False) + return [CatalogFilterView.from_dict(v, camel_case=False) for v in filter_views["data"]] + + def create_or_update_filter_view(self, workspace_id: str, filter_view: CatalogFilterView) -> None: + """Create a new filter view or overwrite an existing one. + + Args: + workspace_id (str): + String containing id of the workspace. + filter_view (CatalogFilterView): + FilterView entity object. + + Returns: + None + """ + filter_view_document = CatalogFilterViewDocument(data=filter_view) + if filter_view.id is None: + self._entities_api.create_entity_filter_views( + workspace_id=workspace_id, + json_api_filter_view_in_document=filter_view_document.to_api(), + ) + else: + try: + self.get_filter_view(workspace_id=workspace_id, filter_view_id=filter_view.id) + self._entities_api.update_entity_filter_views( + workspace_id=workspace_id, + object_id=filter_view.id, + json_api_filter_view_in_document=filter_view_document.to_api(), + ) + except NotFoundException: + self._entities_api.create_entity_filter_views( + workspace_id=workspace_id, + json_api_filter_view_in_document=filter_view_document.to_api(), + ) + + def get_filter_view(self, workspace_id: str, filter_view_id: str) -> CatalogFilterView: + """Get filter view by its id. + + Args: + workspace_id (str): + String containing id of the workspace. + filter_view_id (str): + String containing id of the filter view. + + Returns: + CatalogFilterView: + FilterView entity object. + """ + filter_view_dict = self._entities_api.get_entity_filter_views( + workspace_id=workspace_id, + object_id=filter_view_id, + include=["ALL"], + _check_return_type=False, + ).data + + return CatalogFilterView.from_dict(filter_view_dict, camel_case=True) + + def delete_filter_view(self, workspace_id: str, filter_view_id: str) -> None: + """Delete filter view. + + Args: + workspace_id (str): + String containing id of the workspace. + filter_view_id (str): + String containing id of the deleting filter view. + + Returns: + None + """ + self._entities_api.delete_entity_filter_views(workspace_id=workspace_id, object_id=filter_view_id) + + def get_declarative_filter_views(self, workspace_id: str) -> CatalogDeclarativeFilterViews: + """Retrieve a filter view. + + Args: + workspace_id (str): + String containing id of the workspace + + Returns: + CatalogDeclarativeFilterViews: + Object containing List of declarative filter views. + """ + return CatalogDeclarativeFilterViews.from_api(self._layout_api.get_filter_views(workspace_id)) + + def put_declarative_filter_views(self, workspace_id: str, filter_views: CatalogDeclarativeFilterViews) -> None: + """Set filter views layout. + + Args: + workspace_id (str): + String containing id of the workspace + filter_views (CatalogDeclarativeFilterViews): + Object containing List of declarative filter views. + + Returns: + None + """ + self._layout_api.set_filter_views(workspace_id=workspace_id, declarative_filter_view=filter_views.to_api()) + + def store_declarative_filter_views(self, workspace_id: str, layout_root_path: Path = Path.cwd()) -> None: + """Store filter views layout in a directory hierarchy. + + Args: + workspace_id (str): + id of the related workspace + layout_root_path (Path, optional): + Path to the root of the layout directory. Defaults to Path.cwd(). + + Returns: + None + """ + self.get_declarative_filter_views(workspace_id).store_to_disk(self.layout_organization_folder(layout_root_path)) + + def load_declarative_filter_views(self, layout_root_path: Path = Path.cwd()) -> CatalogDeclarativeFilterViews: + """Loads filter views layout, which was stored using `store_declarative_filter_views`. + + Args: + layout_root_path (Path, optional): + Path to the root of the layout directory. Defaults to Path.cwd(). + + Returns: + CatalogDeclarativeFilterViews: + Object containing List of declarative filter views. + """ + return CatalogDeclarativeFilterViews.load_from_disk(self.layout_organization_folder(layout_root_path)) + + def load_and_put_declarative_filter_views(self, workspace_id: str, layout_root_path: Path = Path.cwd()) -> None: + """Loads and sets the layouts stored using `store_declarative_filter_views`. + + This method combines `load_declarative_filter_views` and `put_declarative_filter_views` + methods to load and set layouts stored using `store_declarative_filter_views`. + + Args: + workspace_id (str): + String containing id of the workspace + layout_root_path (Path, optional): + Path to the root of the layout directory. Defaults to Path.cwd(). + + Returns: + None + """ + declarative_filter_views = CatalogDeclarativeFilterViews.load_from_disk( + self.layout_organization_folder(layout_root_path) + ) + self.put_declarative_filter_views(workspace_id, declarative_filter_views)