diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 91205d810..22a0854ee 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -17,6 +17,7 @@ FlowRunItem, FileuploadItem, GroupItem, + GroupSetItem, HourlyInterval, IntervalItem, JobItem, @@ -79,6 +80,7 @@ "FlowRunItem", "FileuploadItem", "GroupItem", + "GroupSetItem", "HourlyInterval", "IntervalItem", "JobItem", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 5fdf3c2c3..de0c516b7 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -14,6 +14,7 @@ from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.flow_run_item import FlowRunItem from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.groupset_item import GroupSetItem from tableauserverclient.models.interval_item import ( IntervalItem, DailyInterval, @@ -60,6 +61,7 @@ "FlowItem", "FlowRunItem", "GroupItem", + "GroupSetItem", "IntervalItem", "JobItem", "DailyInterval", diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py new file mode 100644 index 000000000..879e8b02d --- /dev/null +++ b/tableauserverclient/models/groupset_item.py @@ -0,0 +1,48 @@ +from typing import Dict, List, Optional +import xml.etree.ElementTree as ET + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.models.group_item import GroupItem + + +class GroupSetItem: + tag_name: str = "groupSet" + + def __init__(self, name: Optional[str] = None) -> None: + self.name = name + self.id: Optional[str] = None + self.groups: List["GroupItem"] = [] + self.group_count: int = 0 + + def __str__(self) -> str: + name = self.name + id = self.id + return f"<{self.__class__.__qualname__}({name=}, {id=})>" + + def __repr__(self) -> str: + return self.__str__() + + @classmethod + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: + parsed_response = fromstring(response) + all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) + return [cls.from_xml(xml, ns) for xml in all_groupset_xml] + + @classmethod + def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": + def get_group(group_xml: ET.Element) -> GroupItem: + group_item = GroupItem() + group_item._id = group_xml.get("id") + group_item.name = group_xml.get("name") + return group_item + + group_set_item = cls() + group_set_item.name = groupset_xml.get("name") + group_set_item.id = groupset_xml.get("id") + group_set_item.group_count = int(count) if (count := groupset_xml.get("groupCount")) else 0 + group_set_item.groups = [ + get_group(group_xml) for group_xml in groupset_xml.findall(".//t:group", namespaces=ns) + ] + + return group_set_item diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 024350aaa..e6b50b27d 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -12,6 +12,7 @@ from tableauserverclient.server.endpoint.flows_endpoint import Flows from tableauserverclient.server.endpoint.flow_task_endpoint import FlowTasks from tableauserverclient.server.endpoint.groups_endpoint import Groups +from tableauserverclient.server.endpoint.groupsets_endpoint import GroupSets from tableauserverclient.server.endpoint.jobs_endpoint import Jobs from tableauserverclient.server.endpoint.metadata_endpoint import Metadata from tableauserverclient.server.endpoint.metrics_endpoint import Metrics @@ -43,6 +44,7 @@ "Flows", "FlowTasks", "Groups", + "GroupSets", "Jobs", "Metadata", "Metrics", diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py new file mode 100644 index 000000000..d24cab52c --- /dev/null +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -0,0 +1,87 @@ +from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union + +from tableauserverclient.helpers.logging import logger +from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.groupset_item import GroupSetItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint +from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.endpoint.endpoint import api + +if TYPE_CHECKING: + from tableauserverclient.server import Server + + +class GroupSets(QuerysetEndpoint[GroupSetItem]): + def __init__(self, parent_srv: "Server") -> None: + super().__init__(parent_srv) + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groupsets" + + @api(version="3.22") + def get( + self, + request_options: Optional[RequestOptions] = None, + result_level: Optional[Literal["members", "local"]] = None, + ) -> Tuple[List[GroupSetItem], PaginationItem]: + logger.info("Querying all group sets on site") + url = self.baseurl + if result_level: + url += f"?resultlevel={result_level}" + server_response = self.get_request(url, request_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_set_items, pagination_item + + @api(version="3.22") + def get_by_id(self, groupset_id: str) -> GroupSetItem: + logger.info(f"Querying group set (ID: {groupset_id})") + url = f"{self.baseurl}/{groupset_id}" + server_response = self.get_request(url) + all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_set_items[0] + + @api(version="3.22") + def create(self, groupset_item: GroupSetItem) -> GroupSetItem: + logger.info(f"Creating group set (name: {groupset_item.name})") + url = self.baseurl + request = RequestFactory.GroupSet.create_request(groupset_item) + server_response = self.post_request(url, request) + created_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return created_groupset[0] + + @api(version="3.22") + def add_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None: + group_id = group.id if isinstance(group, GroupItem) else group + logger.info(f"Adding group (ID: {group_id}) to group set (ID: {groupset_item.id})") + url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}" + _ = self.put_request(url) + return None + + @api(version="3.22") + def remove_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None: + group_id = group.id if isinstance(group, GroupItem) else group + logger.info(f"Removing group (ID: {group_id}) from group set (ID: {groupset_item.id})") + url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}" + _ = self.delete_request(url) + return None + + @api(version="3.22") + def delete(self, groupset: Union[GroupSetItem, str]) -> None: + groupset_id = groupset.id if isinstance(groupset, GroupSetItem) else groupset + logger.info(f"Deleting group set (ID: {groupset_id})") + url = f"{self.baseurl}/{groupset_id}" + _ = self.delete_request(url) + return None + + @api(version="3.22") + def update(self, groupset: GroupSetItem) -> GroupSetItem: + logger.info(f"Updating group set (ID: {groupset.id})") + url = f"{self.baseurl}/{groupset.id}" + request = RequestFactory.GroupSet.update_request(groupset) + server_response = self.put_request(url, request) + updated_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return updated_groupset[0] diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 87438ecde..d7f01d099 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1246,6 +1246,22 @@ def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): updating_element.attrib["name"] = custom_view_item.name +class GroupSetRequest: + @_tsrequest_wrapped + def create_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem") -> bytes: + group_set_element = ET.SubElement(xml_request, "groupSet") + if group_set_item.name is not None: + group_set_element.attrib["name"] = group_set_item.name + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def update_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem") -> bytes: + group_set_element = ET.SubElement(xml_request, "groupSet") + if group_set_item.name is not None: + group_set_element.attrib["name"] = group_set_item.name + return ET.tostring(xml_request) + + class RequestFactory(object): Auth = AuthRequest() Connection = Connection() @@ -1261,6 +1277,7 @@ class RequestFactory(object): Flow = FlowRequest() FlowTask = FlowTaskRequest() Group = GroupRequest() + GroupSet = GroupSetRequest() Metric = MetricRequest() Permission = PermissionRequest() Project = ProjectRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 10b1a53ad..18d67fa07 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -33,6 +33,7 @@ Metrics, Endpoint, CustomViews, + GroupSets, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -99,6 +100,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) self.custom_views = CustomViews(self) + self.group_sets = GroupSets(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/assets/groupsets_create.xml b/test/assets/groupsets_create.xml new file mode 100644 index 000000000..233b0f939 --- /dev/null +++ b/test/assets/groupsets_create.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/assets/groupsets_get.xml b/test/assets/groupsets_get.xml new file mode 100644 index 000000000..ff3bec1fb --- /dev/null +++ b/test/assets/groupsets_get.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/test/assets/groupsets_get_by_id.xml b/test/assets/groupsets_get_by_id.xml new file mode 100644 index 000000000..558e4d870 --- /dev/null +++ b/test/assets/groupsets_get_by_id.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/groupsets_update.xml b/test/assets/groupsets_update.xml new file mode 100644 index 000000000..b64fa6ea1 --- /dev/null +++ b/test/assets/groupsets_update.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/test_groupsets.py b/test/test_groupsets.py new file mode 100644 index 000000000..d3c9085a4 --- /dev/null +++ b/test/test_groupsets.py @@ -0,0 +1,130 @@ +from pathlib import Path +import unittest + +from defusedxml.ElementTree import fromstring +import requests_mock + +import tableauserverclient as TSC + +TEST_ASSET_DIR = Path(__file__).parent / "assets" +GROUPSET_CREATE = TEST_ASSET_DIR / "groupsets_create.xml" +GROUPSETS_GET = TEST_ASSET_DIR / "groupsets_get.xml" +GROUPSET_GET_BY_ID = TEST_ASSET_DIR / "groupsets_get_by_id.xml" +GROUPSET_UPDATE = TEST_ASSET_DIR / "groupsets_get_by_id.xml" + + +class TestGroupSets(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + self.server.version = "3.22" + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.group_sets.baseurl + + def test_get(self) -> None: + with requests_mock.mock() as m: + m.get(self.baseurl, text=GROUPSETS_GET.read_text()) + groupsets, pagination_item = self.server.group_sets.get() + + assert len(groupsets) == 3 + assert pagination_item.total_available == 3 + assert groupsets[0].id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupsets[0].name == "All Users" + assert groupsets[0].group_count == 1 + assert groupsets[0].groups[0].name == "group-one" + assert groupsets[0].groups[0].id == "gs-1" + + assert groupsets[1].id == "9a8a7b6b-5c4c-3d2d-1e0e-9a8a7b6b5b4b" + assert groupsets[1].name == "active-directory-group-import" + assert groupsets[1].group_count == 1 + assert groupsets[1].groups[0].name == "group-two" + assert groupsets[1].groups[0].id == "gs21" + + assert groupsets[2].id == "7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" + assert groupsets[2].name == "local-group-license-on-login" + assert groupsets[2].group_count == 1 + assert groupsets[2].groups[0].name == "group-three" + assert groupsets[2].groups[0].id == "gs-3" + + def test_get_by_id(self) -> None: + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", text=GROUPSET_GET_BY_ID.read_text()) + groupset = self.server.group_sets.get_by_id("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d") + + assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupset.name == "All Users" + assert groupset.group_count == 3 + assert len(groupset.groups) == 3 + + assert groupset.groups[0].name == "group-one" + assert groupset.groups[0].id == "gs-1" + assert groupset.groups[1].name == "group-two" + assert groupset.groups[1].id == "gs21" + assert groupset.groups[2].name == "group-three" + assert groupset.groups[2].id == "gs-3" + + def test_update(self) -> None: + id_ = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + groupset = TSC.GroupSetItem("All Users") + groupset.id = id_ + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{id_}", text=GROUPSET_UPDATE.read_text()) + groupset = self.server.group_sets.update(groupset) + + assert groupset.id == id_ + assert groupset.name == "All Users" + assert groupset.group_count == 3 + assert len(groupset.groups) == 3 + + assert groupset.groups[0].name == "group-one" + assert groupset.groups[0].id == "gs-1" + assert groupset.groups[1].name == "group-two" + assert groupset.groups[1].id == "gs21" + assert groupset.groups[2].name == "group-three" + assert groupset.groups[2].id == "gs-3" + + def test_create(self) -> None: + groupset = TSC.GroupSetItem("All Users") + with requests_mock.mock() as m: + m.post(self.baseurl, text=GROUPSET_CREATE.read_text()) + groupset = self.server.group_sets.create(groupset) + + assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupset.name == "All Users" + assert groupset.group_count == 0 + assert len(groupset.groups) == 0 + + def test_add_group(self) -> None: + groupset = TSC.GroupSetItem("All") + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + group = TSC.GroupItem("Example") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{groupset.id}/groups/{group._id}") + self.server.group_sets.add_group(groupset, group) + + history = m.request_history + + assert len(history) == 1 + assert history[0].method == "PUT" + assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}" + + def test_remove_group(self) -> None: + groupset = TSC.GroupSetItem("All") + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + group = TSC.GroupItem("Example") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + + with requests_mock.mock() as m: + m.delete(f"{self.baseurl}/{groupset.id}/groups/{group._id}") + self.server.group_sets.remove_group(groupset, group) + + history = m.request_history + + assert len(history) == 1 + assert history[0].method == "DELETE" + assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}"