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}"