From abc9ed8f0e624aefabfe4155274cd35a03fc2a53 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 27 Jun 2024 17:05:31 -0500 Subject: [PATCH 01/23] feat: enable bulk adding users --- .../server/endpoint/users_endpoint.py | 67 ++++++++++++++- tableauserverclient/server/request_factory.py | 15 ++++ test/assets/users_bulk_add_job.xml | 4 + test/test_user.py | 85 ++++++++++++++++++- 4 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 test/assets/users_bulk_add_job.xml diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 17af21a03..909960997 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,14 +1,20 @@ +from collections.abc import Iterable import copy +import csv +import io +import itertools import logging from typing import Optional +from pathlib import Path +import re from tableauserverclient.server.query import QuerySet from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError, ServerResponseError from tableauserverclient.server import RequestFactory, RequestOptions -from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem -from ..pager import Pager +from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem, JobItem +from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger @@ -357,8 +363,25 @@ def add_all(self, users: list[UserItem]): # helping the user by parsing a file they could have used to add users through the UI # line format: Username [required], password, display name, license, admin, publish + @api(version="3.15") + def bulk_add(self, users: Iterable[UserItem]) -> JobItem: + """ + line format: Username [required], password, display name, license, admin, publish + """ + url = f"{self.baseurl}/import" + # Allow for iterators to be passed into the function + csv_users, xml_users = itertools.tee(users, 2) + csv_content = create_users_csv(csv_users) + + xml_request, content_type = RequestFactory.User.import_from_csv_req(csv_content, xml_users) + server_response = self.post_request(url, xml_request, content_type) + return JobItem.from_response(server_response.content, self.parent_srv.namespace).pop() + @api(version="2.0") def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: + import warnings + + warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning) created = [] failed = [] if not filepath.find("csv"): @@ -569,3 +592,43 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) + +def create_users_csv(users: Iterable[UserItem], identity_pool=None) -> bytes: + """ + Create a CSV byte string from an Iterable of UserItem objects + """ + if identity_pool is not None: + raise NotImplementedError("Identity pool is not supported in this version") + with io.StringIO() as output: + writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL) + for user in users: + site_role = user.site_role or "Unlicensed" + if site_role == "ServerAdministrator": + license = "Creator" + admin_level = "System" + elif site_role.startswith("SiteAdministrator"): + admin_level = "Site" + license = site_role.replace("SiteAdministrator", "") + else: + license = site_role + admin_level = "" + + if any(x in site_role for x in ("Creator", "Admin", "Publish")): + publish = 1 + else: + publish = 0 + + writer.writerow( + ( + user.name, + getattr(user, "password", ""), + user.fullname, + license, + admin_level, + publish, + user.email, + ) + ) + output.seek(0) + result = output.read().encode("utf-8") + return result diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 877a18c39..391148f34 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -967,6 +967,21 @@ def add_req(self, user_item: UserItem) -> bytes: user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) + def import_from_csv_req(self, csv_content: bytes, users: Iterable[UserItem]): + xml_request = ET.Element("tsRequest") + for user in users: + if user.name is None: + raise ValueError("User name must be populated.") + user_element = ET.SubElement(xml_request, "user") + user_element.attrib["name"] = user.name + user_element.attrib["authSetting"] = user.auth_setting or "ServerDefault" + + parts = { + "tableau_user_import": ("tsc_users_file.csv", csv_content, "file"), + "request_payload": ("", ET.tostring(xml_request), "text/xml"), + } + return _add_multipart(parts) + class WorkbookRequest: def _generate_xml( diff --git a/test/assets/users_bulk_add_job.xml b/test/assets/users_bulk_add_job.xml new file mode 100644 index 000000000..7301ac7d3 --- /dev/null +++ b/test/assets/users_bulk_add_job.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/test_user.py b/test/test_user.py index fa2ac3a12..47556cbee 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,4 +1,7 @@ +import csv +import io import os +from pathlib import Path import unittest from defusedxml import ElementTree as ET @@ -7,8 +10,9 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime, parse_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).resolve().parent / "assets" +BULK_ADD_XML = TEST_ASSET_DIR / "users_bulk_add_job.xml" GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml") GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "user_get_all_fields.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml") @@ -320,3 +324,82 @@ def test_update_user_idp_configuration(self) -> None: user_elem = tree.find(".//user") assert user_elem is not None assert user_elem.attrib["idpConfigurationId"] == "012345" + + def test_bulk_add(self): + self.server.version = "3.15" + users = [ + TSC.UserItem( + "test", + "Viewer", + ) + ] + with requests_mock.mock() as m: + m.post(f"{self.server.users.baseurl}/import", text=BULK_ADD_XML.read_text()) + + job = self.server.users.bulk_add(users) + + assert m.last_request.method == "POST" + assert m.last_request.url == f"{self.server.users.baseurl}/import" + + body = m.last_request.body.replace(b"\r\n", b"\n") + assert body.startswith(b"--") # Check if it's a multipart request + boundary = body.split(b"\n")[0].strip() + + # Body starts and ends with a boundary string. Split the body into + # segments and ignore the empty sections at the start and end. + segments = [seg for s in body.split(boundary) if (seg := s.strip()) not in [b"", b"--"]] + assert len(segments) == 2 # Check if there are two segments + + # Check if the first segment is the csv file and the second segment is the xml + assert b'Content-Disposition: form-data; name="tableau_user_import"' in segments[0] + assert b'Content-Disposition: form-data; name="request_payload"' in segments[1] + assert b"Content-Type: file" in segments[0] + assert b"Content-Type: text/xml" in segments[1] + + xml_string = segments[1].split(b"\n\n")[1].strip() + xml = ET.fromstring(xml_string) + xml_users = xml.findall(".//user", namespaces={}) + assert len(xml_users) == len(users) + + for user, xml_user in zip(users, xml_users): + assert user.name == xml_user.get("name") + assert xml_user.get("authSetting") == (user.auth_setting or "ServerDefault") + + license_map = { + "Viewer": "Viewer", + "Explorer": "Explorer", + "ExplorerCanPublish": "Explorer", + "Creator": "Creator", + "SiteAdministratorExplorer": "Explorer", + "SiteAdministratorCreator": "Creator", + "ServerAdministrator": "Creator", + "Unlicensed": "Unlicensed", + } + publish_map = { + "Unlicensed": 0, + "Viewer": 0, + "Explorer": 0, + "Creator": 1, + "ExplorerCanPublish": 1, + "SiteAdministratorExplorer": 1, + "SiteAdministratorCreator": 1, + "ServerAdministrator": 1, + } + admin_map = { + "SiteAdministratorExplorer": "Site", + "SiteAdministratorCreator": "Site", + "ServerAdministrator": "System", + } + + csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email"] + csv_file = io.StringIO(segments[0].split(b"\n\n")[1].decode("utf-8")) + csv_reader = csv.reader(csv_file) + for user, row in zip(users, csv_reader): + site_role = user.site_role or "Unlicensed" + csv_user = dict(zip(csv_columns, row)) + assert user.name == csv_user["name"] + assert (user.fullname or "") == csv_user["fullname"] + assert (user.email or "") == csv_user["email"] + assert license_map[site_role] == csv_user["license"] + assert admin_map.get(site_role, "") == csv_user["admin"] + assert publish_map[site_role] == int(csv_user["publish"]) From 10d8dbf426085c4609d51a2aa92ee129382442b3 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 27 Jun 2024 17:24:57 -0500 Subject: [PATCH 02/23] feat: ensure domain name is included if provided --- .../server/endpoint/users_endpoint.py | 2 +- test/test_user.py | 27 +++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 909960997..03ace545d 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -620,7 +620,7 @@ def create_users_csv(users: Iterable[UserItem], identity_pool=None) -> bytes: writer.writerow( ( - user.name, + f"{user.domain_name}\\{user.name}" if user.domain_name else user.name, getattr(user, "password", ""), user.fullname, license, diff --git a/test/test_user.py b/test/test_user.py index 47556cbee..1aeeb8575 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -326,12 +326,28 @@ def test_update_user_idp_configuration(self) -> None: assert user_elem.attrib["idpConfigurationId"] == "012345" def test_bulk_add(self): + def make_user(name: str, site_role: str = "", auth_setting: str = "", domain: str = "", fullname: str = "", email: str = "") -> TSC.UserItem: + user = TSC.UserItem(name, site_role or None) + if auth_setting: + user.auth_setting = auth_setting + if domain: + user._domain_name = domain + if fullname: + user.fullname = fullname + if email: + user.email = email + return user + self.server.version = "3.15" users = [ - TSC.UserItem( - "test", - "Viewer", - ) + make_user("Alice", "Viewer"), + make_user("Bob", "Explorer"), + make_user("Charlie", "Creator", "SAML"), + make_user("Dave"), + make_user("Eve", "ServerAdministrator", "OpenID", "example.com", "Eve Example", "Eve@example.com"), + make_user("Frank", "SiteAdministratorExplorer", "TableauIDWithMFA", email="Frank@example.com"), + make_user("Grace", "SiteAdministratorCreator", "SAML", "example.com", "Grace Example", "gex@example.com"), + make_user("Hank", "Unlicensed") ] with requests_mock.mock() as m: m.post(f"{self.server.users.baseurl}/import", text=BULK_ADD_XML.read_text()) @@ -396,8 +412,9 @@ def test_bulk_add(self): csv_reader = csv.reader(csv_file) for user, row in zip(users, csv_reader): site_role = user.site_role or "Unlicensed" + name = f"{user.domain_name}\\{user.name}" if user.domain_name else user.name csv_user = dict(zip(csv_columns, row)) - assert user.name == csv_user["name"] + assert name == csv_user["name"] assert (user.fullname or "") == csv_user["fullname"] assert (user.email or "") == csv_user["email"] assert license_map[site_role] == csv_user["license"] From 1588893ba9403edb00cba90c12a74ac69b33558e Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 27 Jun 2024 18:32:42 -0500 Subject: [PATCH 03/23] style: black --- test/test_user.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/test/test_user.py b/test/test_user.py index 1aeeb8575..1ebde5ad0 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -326,7 +326,14 @@ def test_update_user_idp_configuration(self) -> None: assert user_elem.attrib["idpConfigurationId"] == "012345" def test_bulk_add(self): - def make_user(name: str, site_role: str = "", auth_setting: str = "", domain: str = "", fullname: str = "", email: str = "") -> TSC.UserItem: + def make_user( + name: str, + site_role: str = "", + auth_setting: str = "", + domain: str = "", + fullname: str = "", + email: str = "", + ) -> TSC.UserItem: user = TSC.UserItem(name, site_role or None) if auth_setting: user.auth_setting = auth_setting @@ -340,14 +347,14 @@ def make_user(name: str, site_role: str = "", auth_setting: str = "", domain: st self.server.version = "3.15" users = [ - make_user("Alice", "Viewer"), - make_user("Bob", "Explorer"), - make_user("Charlie", "Creator", "SAML"), - make_user("Dave"), - make_user("Eve", "ServerAdministrator", "OpenID", "example.com", "Eve Example", "Eve@example.com"), - make_user("Frank", "SiteAdministratorExplorer", "TableauIDWithMFA", email="Frank@example.com"), - make_user("Grace", "SiteAdministratorCreator", "SAML", "example.com", "Grace Example", "gex@example.com"), - make_user("Hank", "Unlicensed") + make_user("Alice", "Viewer"), + make_user("Bob", "Explorer"), + make_user("Charlie", "Creator", "SAML"), + make_user("Dave"), + make_user("Eve", "ServerAdministrator", "OpenID", "example.com", "Eve Example", "Eve@example.com"), + make_user("Frank", "SiteAdministratorExplorer", "TableauIDWithMFA", email="Frank@example.com"), + make_user("Grace", "SiteAdministratorCreator", "SAML", "example.com", "Grace Example", "gex@example.com"), + make_user("Hank", "Unlicensed"), ] with requests_mock.mock() as m: m.post(f"{self.server.users.baseurl}/import", text=BULK_ADD_XML.read_text()) From c4f5bddc241ff5ae7b8ec21fc71b169cb3b5b1a3 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 27 Jun 2024 21:27:18 -0500 Subject: [PATCH 04/23] chore: test missing user name --- test/test_user.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/test_user.py b/test/test_user.py index 1ebde5ad0..b81ab3b1a 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -5,6 +5,7 @@ import unittest from defusedxml import ElementTree as ET +import pytest import requests_mock import tableauserverclient as TSC @@ -427,3 +428,14 @@ def make_user( assert license_map[site_role] == csv_user["license"] assert admin_map.get(site_role, "") == csv_user["admin"] assert publish_map[site_role] == int(csv_user["publish"]) + + def test_bulk_add_no_name(self): + self.server.version = "3.15" + users = [ + TSC.UserItem(site_role="Viewer"), + ] + with requests_mock.mock() as m: + m.post(f"{self.server.users.baseurl}/import", text=BULK_ADD_XML.read_text()) + + with pytest.raises(ValueError, match="User name must be populated."): + self.server.users.bulk_add(users) From cbc39f71deffe5ba80520cc3de71d5083af53bc4 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 27 Jun 2024 22:07:58 -0500 Subject: [PATCH 05/23] feat: implement users bulk_remove --- .../server/endpoint/users_endpoint.py | 28 ++++++++++++++++++ tableauserverclient/server/request_factory.py | 6 ++++ test/test_user.py | 29 +++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 03ace545d..e47d04cc5 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -377,6 +377,14 @@ def bulk_add(self, users: Iterable[UserItem]) -> JobItem: server_response = self.post_request(url, xml_request, content_type) return JobItem.from_response(server_response.content, self.parent_srv.namespace).pop() + @api(version="3.15") + def bulk_remove(self, users: Iterable[UserItem]) -> None: + url = f"{self.baseurl}/delete" + csv_content = remove_users_csv(users) + request, content_type = RequestFactory.User.delete_csv_req(csv_content) + server_response = self.post_request(url, request, content_type) + return None + @api(version="2.0") def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: import warnings @@ -632,3 +640,23 @@ def create_users_csv(users: Iterable[UserItem], identity_pool=None) -> bytes: output.seek(0) result = output.read().encode("utf-8") return result + + +def remove_users_csv(users: Iterable[UserItem]) -> bytes: + with io.StringIO() as output: + writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL) + for user in users: + writer.writerow( + ( + f"{user.domain_name}\\{user.name}" if user.domain_name else user.name, + None, + None, + None, + None, + None, + None, + ) + ) + output.seek(0) + result = output.read().encode("utf-8") + return result diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 391148f34..638cf0612 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -982,6 +982,12 @@ def import_from_csv_req(self, csv_content: bytes, users: Iterable[UserItem]): } return _add_multipart(parts) + def delete_csv_req(self, csv_content: bytes): + parts = { + "tableau_user_delete": ("tsc_users_file.csv", csv_content, "file"), + } + return _add_multipart(parts) + class WorkbookRequest: def _generate_xml( diff --git a/test/test_user.py b/test/test_user.py index b81ab3b1a..dba9fc11b 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -439,3 +439,32 @@ def test_bulk_add_no_name(self): with pytest.raises(ValueError, match="User name must be populated."): self.server.users.bulk_add(users) + + def test_bulk_remove(self): + self.server.version = "3.15" + users = [ + TSC.UserItem("Alice"), + TSC.UserItem("Bob"), + ] + users[1]._domain_name = "example.com" + with requests_mock.mock() as m: + m.post(f"{self.server.users.baseurl}/delete") + + self.server.users.bulk_remove(users) + + assert m.last_request.method == "POST" + assert m.last_request.url == f"{self.server.users.baseurl}/delete" + + body = m.last_request.body.replace(b"\r\n", b"\n") + assert body.startswith(b"--") # Check if it's a multipart request + boundary = body.split(b"\n")[0].strip() + + content = next(seg for seg in body.split(boundary) if seg.strip()) + assert b'Content-Disposition: form-data; name="tableau_user_delete"' in content + assert b"Content-Type: file" in content + + content = content.replace(b"\r\n", b"\n") + csv_data = content.split(b"\n\n")[1].decode("utf-8") + for user, row in zip(users, csv_data.split("\n")): + name, *_ = row.split(",") + assert name == f"{user.domain_name}\\{user.name}" if user.domain_name else user.name From 32452892bd0a0f7fdf29d8228e07a08658079434 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 27 Jun 2024 22:14:19 -0500 Subject: [PATCH 06/23] chore: suppress deprecation warning in test --- test/test_user.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/test_user.py b/test/test_user.py index dba9fc11b..080d9696f 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -239,6 +239,7 @@ def test_populate_groups(self) -> None: self.assertEqual("TableauExample", group_list[2].name) self.assertEqual("local", group_list[2].domain_name) + @pytest.mark.filterwarnings("ignore:This method is deprecated, use bulk_add instead") def test_get_usernames_from_file(self): with open(ADD_XML, "rb") as f: response_xml = f.read().decode("utf-8") @@ -248,6 +249,7 @@ def test_get_usernames_from_file(self): assert user_list[0].name == "Cassie", user_list assert failures == [], failures + @pytest.mark.filterwarnings("ignore:This method is deprecated, use bulk_add instead") def test_get_users_from_file(self): with open(ADD_XML, "rb") as f: response_xml = f.read().decode("utf-8") From 8e77be028f681c1be521c61c211254871ee160fa Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 28 Jun 2024 06:55:37 -0500 Subject: [PATCH 07/23] chore: split csv add creation to own test --- test/test_user.py | 138 +++++++++++++++++++++++++++------------------- 1 file changed, 80 insertions(+), 58 deletions(-) diff --git a/test/test_user.py b/test/test_user.py index 080d9696f..d05daaad0 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -10,6 +10,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime, parse_datetime +from tableauserverclient.server.endpoint.users_endpoint import create_users_csv, remove_users_csv TEST_ASSET_DIR = Path(__file__).resolve().parent / "assets" @@ -28,6 +29,26 @@ USERS = os.path.join(TEST_ASSET_DIR, "Data", "user_details.csv") +def make_user( + name: str, + site_role: str = "", + auth_setting: str = "", + domain: str = "", + fullname: str = "", + email: str = "", +) -> TSC.UserItem: + user = TSC.UserItem(name, site_role or None) + if auth_setting: + user.auth_setting = auth_setting + if domain: + user._domain_name = domain + if fullname: + user.fullname = fullname + if email: + user.email = email + return user + + class UserTests(unittest.TestCase): def setUp(self) -> None: self.server = TSC.Server("http://test", False) @@ -328,26 +349,64 @@ def test_update_user_idp_configuration(self) -> None: assert user_elem is not None assert user_elem.attrib["idpConfigurationId"] == "012345" - def test_bulk_add(self): - def make_user( - name: str, - site_role: str = "", - auth_setting: str = "", - domain: str = "", - fullname: str = "", - email: str = "", - ) -> TSC.UserItem: - user = TSC.UserItem(name, site_role or None) - if auth_setting: - user.auth_setting = auth_setting - if domain: - user._domain_name = domain - if fullname: - user.fullname = fullname - if email: - user.email = email - return user + def test_create_users_csv(self): + users = [ + make_user("Alice", "Viewer"), + make_user("Bob", "Explorer"), + make_user("Charlie", "Creator", "SAML"), + make_user("Dave"), + make_user("Eve", "ServerAdministrator", "OpenID", "example.com", "Eve Example", "Eve@example.com"), + make_user("Frank", "SiteAdministratorExplorer", "TableauIDWithMFA", email="Frank@example.com"), + make_user("Grace", "SiteAdministratorCreator", "SAML", "example.com", "Grace Example", "gex@example.com"), + make_user("Hank", "Unlicensed"), + ] + + license_map = { + "Viewer": "Viewer", + "Explorer": "Explorer", + "ExplorerCanPublish": "Explorer", + "Creator": "Creator", + "SiteAdministratorExplorer": "Explorer", + "SiteAdministratorCreator": "Creator", + "ServerAdministrator": "Creator", + "Unlicensed": "Unlicensed", + } + publish_map = { + "Unlicensed": 0, + "Viewer": 0, + "Explorer": 0, + "Creator": 1, + "ExplorerCanPublish": 1, + "SiteAdministratorExplorer": 1, + "SiteAdministratorCreator": 1, + "ServerAdministrator": 1, + } + admin_map = { + "SiteAdministratorExplorer": "Site", + "SiteAdministratorCreator": "Site", + "ServerAdministrator": "System", + } + + csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email"] + csv_data = create_users_csv(users) + csv_file = io.StringIO(csv_data.decode("utf-8")) + csv_reader = csv.reader(csv_file) + for user, row in zip(users, csv_reader): + with self.subTest(user=user): + site_role = user.site_role or "Unlicensed" + name = f"{user.domain_name}\\{user.name}" if user.domain_name else user.name + csv_user = dict(zip(csv_columns, row)) + assert name == csv_user["name"] + assert (user.fullname or "") == csv_user["fullname"] + assert (user.email or "") == csv_user["email"] + assert license_map[site_role] == csv_user["license"] + assert admin_map.get(site_role, "") == csv_user["admin"] + assert publish_map[site_role] == int(csv_user["publish"]) + + + + def test_bulk_add(self): self.server.version = "3.15" users = [ make_user("Alice", "Viewer"), @@ -391,45 +450,8 @@ def make_user( assert user.name == xml_user.get("name") assert xml_user.get("authSetting") == (user.auth_setting or "ServerDefault") - license_map = { - "Viewer": "Viewer", - "Explorer": "Explorer", - "ExplorerCanPublish": "Explorer", - "Creator": "Creator", - "SiteAdministratorExplorer": "Explorer", - "SiteAdministratorCreator": "Creator", - "ServerAdministrator": "Creator", - "Unlicensed": "Unlicensed", - } - publish_map = { - "Unlicensed": 0, - "Viewer": 0, - "Explorer": 0, - "Creator": 1, - "ExplorerCanPublish": 1, - "SiteAdministratorExplorer": 1, - "SiteAdministratorCreator": 1, - "ServerAdministrator": 1, - } - admin_map = { - "SiteAdministratorExplorer": "Site", - "SiteAdministratorCreator": "Site", - "ServerAdministrator": "System", - } - - csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email"] - csv_file = io.StringIO(segments[0].split(b"\n\n")[1].decode("utf-8")) - csv_reader = csv.reader(csv_file) - for user, row in zip(users, csv_reader): - site_role = user.site_role or "Unlicensed" - name = f"{user.domain_name}\\{user.name}" if user.domain_name else user.name - csv_user = dict(zip(csv_columns, row)) - assert name == csv_user["name"] - assert (user.fullname or "") == csv_user["fullname"] - assert (user.email or "") == csv_user["email"] - assert license_map[site_role] == csv_user["license"] - assert admin_map.get(site_role, "") == csv_user["admin"] - assert publish_map[site_role] == int(csv_user["publish"]) + csv_data = create_users_csv(users).replace(b"\r\n", b"\n") + assert csv_data.strip() == segments[0].split(b"\n\n")[1].strip() def test_bulk_add_no_name(self): self.server.version = "3.15" From fb686dfea4e2722ac54c8035cad0beb88f0d02c7 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 28 Jun 2024 06:56:53 -0500 Subject: [PATCH 08/23] chore: use subTests in remove_users --- test/test_user.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/test_user.py b/test/test_user.py index d05daaad0..4e38c4cce 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -490,5 +490,6 @@ def test_bulk_remove(self): content = content.replace(b"\r\n", b"\n") csv_data = content.split(b"\n\n")[1].decode("utf-8") for user, row in zip(users, csv_data.split("\n")): - name, *_ = row.split(",") - assert name == f"{user.domain_name}\\{user.name}" if user.domain_name else user.name + with self.subTest(user=user): + name, *_ = row.split(",") + assert name == f"{user.domain_name}\\{user.name}" if user.domain_name else user.name From f254bf1bd9c8627d5cc34ffde0c3e8a43a7f91b1 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 28 Jun 2024 06:59:19 -0500 Subject: [PATCH 09/23] chore: user factory function in make_user --- test/test_user.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/test_user.py b/test/test_user.py index 4e38c4cce..dcfcd8c50 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -403,9 +403,6 @@ def test_create_users_csv(self): assert admin_map.get(site_role, "") == csv_user["admin"] assert publish_map[site_role] == int(csv_user["publish"]) - - - def test_bulk_add(self): self.server.version = "3.15" users = [ @@ -467,10 +464,9 @@ def test_bulk_add_no_name(self): def test_bulk_remove(self): self.server.version = "3.15" users = [ - TSC.UserItem("Alice"), - TSC.UserItem("Bob"), + make_user("Alice"), + make_user("Bob", domain="example.com"), ] - users[1]._domain_name = "example.com" with requests_mock.mock() as m: m.post(f"{self.server.users.baseurl}/delete") From c91f86eda80a0d7f215e3eabfbd58474b6ef701a Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 28 Jun 2024 22:07:29 -0500 Subject: [PATCH 10/23] docs: bulk_add docstring --- tableauserverclient/server/endpoint/users_endpoint.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index e47d04cc5..070b0912a 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -366,7 +366,16 @@ def add_all(self, users: list[UserItem]): @api(version="3.15") def bulk_add(self, users: Iterable[UserItem]) -> JobItem: """ - line format: Username [required], password, display name, license, admin, publish + When adding users in bulk, the server will return a job item that can be used to track the progress of the + operation. This method will return the job item that was created when the users were added. + + For each user, name is required, and other fields are optional. If connected to activte directory and + the user name is not unique across domains, then the domain attribute must be populated on + the UserItem. + + The user's display name is read from the fullname attribute. + + Email is optional, but if provided, it must be a valid email address. """ url = f"{self.baseurl}/import" # Allow for iterators to be passed into the function From 5826ca695801d69a465439c23caab44b0b743308 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 30 Jun 2024 06:56:20 -0500 Subject: [PATCH 11/23] fix: assert on warning instead of ignore --- test/test_user.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/test_user.py b/test/test_user.py index dcfcd8c50..08f34defb 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -260,23 +260,23 @@ def test_populate_groups(self) -> None: self.assertEqual("TableauExample", group_list[2].name) self.assertEqual("local", group_list[2].domain_name) - @pytest.mark.filterwarnings("ignore:This method is deprecated, use bulk_add instead") def test_get_usernames_from_file(self): with open(ADD_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.server.users.baseurl, text=response_xml) - user_list, failures = self.server.users.create_from_file(USERNAMES) + with pytest.warns(DeprecationWarning, match="This method is deprecated, use bulk_add instead"): + user_list, failures = self.server.users.create_from_file(USERNAMES) assert user_list[0].name == "Cassie", user_list assert failures == [], failures - @pytest.mark.filterwarnings("ignore:This method is deprecated, use bulk_add instead") def test_get_users_from_file(self): with open(ADD_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.server.users.baseurl, text=response_xml) - users, failures = self.server.users.create_from_file(USERS) + with pytest.warns(DeprecationWarning, match="This method is deprecated, use bulk_add instead"): + users, failures = self.server.users.create_from_file(USERS) assert users[0].name == "Cassie", users assert failures == [] From 870484e9749735568f37342023359ebd52abf346 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 7 Jul 2024 19:39:51 -0500 Subject: [PATCH 12/23] chore: missed an absolute import --- tableauserverclient/server/endpoint/users_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 070b0912a..1e700ac2e 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -10,8 +10,8 @@ from tableauserverclient.server.query import QuerySet -from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError, ServerResponseError +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError, ServerResponseError from tableauserverclient.server import RequestFactory, RequestOptions from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem, JobItem from tableauserverclient.server.pager import Pager From 93c28d44ad3f2c4b1bfb264be208ee654dca2a48 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 10 Jul 2024 21:09:28 -0500 Subject: [PATCH 13/23] docs: bulk_add docstring --- tableauserverclient/server/endpoint/users_endpoint.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 1e700ac2e..fbdc8a803 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -376,6 +376,17 @@ def bulk_add(self, users: Iterable[UserItem]) -> JobItem: The user's display name is read from the fullname attribute. Email is optional, but if provided, it must be a valid email address. + + If auth_setting is not provided, the default is ServerDefault. + + If site_role is not provided, the default is Unlicensed. + + Password is optional, and only used if the server is using local + authentication. If using any other authentication method, the password + should not be provided. + + Details about administrator level and publishing capability are + inferred from the site_role. """ url = f"{self.baseurl}/import" # Allow for iterators to be passed into the function From 60aea128854fc4b18475aa80fedcb537b0a2b69b Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 10 Jul 2024 21:22:16 -0500 Subject: [PATCH 14/23] docs: create_users_csv docstring --- .../server/endpoint/users_endpoint.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index fbdc8a803..544606ce3 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -5,8 +5,6 @@ import itertools import logging from typing import Optional -from pathlib import Path -import re from tableauserverclient.server.query import QuerySet @@ -621,9 +619,19 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe return super().filter(*invalid, page_size=page_size, **kwargs) + def create_users_csv(users: Iterable[UserItem], identity_pool=None) -> bytes: """ - Create a CSV byte string from an Iterable of UserItem objects + Create a CSV byte string from an Iterable of UserItem objects. The CSV will + have the following columns, and no header row: + + - Username + - Password + - Display Name + - License + - Admin Level + - Publish capability + - Email """ if identity_pool is not None: raise NotImplementedError("Identity pool is not supported in this version") From e17cd925831a078c6d64aa9b1d26fc110cab9621 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 2 Oct 2024 09:52:00 -0500 Subject: [PATCH 15/23] chore: deprecate add_all method --- tableauserverclient/server/endpoint/users_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 544606ce3..f164ce946 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -5,6 +5,7 @@ import itertools import logging from typing import Optional +import warnings from tableauserverclient.server.query import QuerySet @@ -349,6 +350,7 @@ def add(self, user_item: UserItem) -> UserItem: # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar @api(version="2.0") def add_all(self, users: list[UserItem]): + warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning) created = [] failed = [] for user in users: @@ -405,8 +407,6 @@ def bulk_remove(self, users: Iterable[UserItem]) -> None: @api(version="2.0") def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: - import warnings - warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning) created = [] failed = [] From 6f49d82501068762491f6fe7acafc88efcbf268d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 2 Oct 2024 17:59:05 -0500 Subject: [PATCH 16/23] test: test add_all and check DeprecationWarning --- test/test_user.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/test_user.py b/test/test_user.py index 08f34defb..23dcb94e5 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -3,6 +3,7 @@ import os from pathlib import Path import unittest +from unittest.mock import patch from defusedxml import ElementTree as ET import pytest @@ -489,3 +490,18 @@ def test_bulk_remove(self): with self.subTest(user=user): name, *_ = row.split(",") assert name == f"{user.domain_name}\\{user.name}" if user.domain_name else user.name + + def test_add_all(self) -> None: + self.server.version = "2.0" + users = [ + make_user("Alice", "Viewer"), + make_user("Bob", "Explorer"), + make_user("Charlie", "Creator", "SAML"), + make_user("Dave"), + ] + + with patch("tableauserverclient.server.endpoint.users_endpoint.Users.add", autospec=True) as mock_add: + with pytest.warns(DeprecationWarning): + self.server.users.add_all(users) + + assert mock_add.call_count == len(users) From 1f8626f9e4f4d31b56515c44cacbbd87bbec3b99 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:00:04 -0500 Subject: [PATCH 17/23] docs: docstring updates for bulk add operations --- .../server/endpoint/users_endpoint.py | 130 +++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index f164ce946..f5ff4c197 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -349,8 +349,34 @@ def add(self, user_item: UserItem) -> UserItem: # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar @api(version="2.0") - def add_all(self, users: list[UserItem]): - warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning) + def add_all(self, users: list[UserItem]) -> tuple[list[UserItem], list[UserItem]]: + """ + Syntactic sugar for calling users.add multiple times. This method has + been deprecated in favor of using the bulk_add which accomplishes the + same task in one API call. + + .. deprecated:: v0.34.0 + `add_all` will be removed as its functionality is replicated via + the `bulk_add` method. + + Parameters + ---------- + users: list[UserItem] + A list of UserItem objects to add to the site. Each UserItem object + will be passed to the `add` method individually. + + Returns + ------- + tuple[list[UserItem], list[UserItem]] + The first element of the tuple is a list of UserItem objects that + were successfully added to the site. The second element is a list + of UserItem objects that failed to be added to the site. + + Warnings + -------- + This method is deprecated. Use the `bulk_add` method instead. + """ + warnings.warn("This method is deprecated, use bulk_add method instead.", DeprecationWarning) created = [] failed = [] for user in users: @@ -387,6 +413,17 @@ def bulk_add(self, users: Iterable[UserItem]) -> JobItem: Details about administrator level and publishing capability are inferred from the site_role. + + Parameters + ---------- + users: Iterable[UserItem] + An iterable of UserItem objects to add to the site. See above for + what fields are required and optional. + + Returns + ------- + JobItem + The job that is started for adding the users in bulk. """ url = f"{self.baseurl}/import" # Allow for iterators to be passed into the function @@ -399,6 +436,22 @@ def bulk_add(self, users: Iterable[UserItem]) -> JobItem: @api(version="3.15") def bulk_remove(self, users: Iterable[UserItem]) -> None: + """ + Remove multiple users from the site. The users are identified by their + domain and name. The users are removed in bulk, so the server will not + return a job item to track the progress of the operation nor a response + for each user that was removed. + + Parameters + ---------- + users: Iterable[UserItem] + An iterable of UserItem objects to remove from the site. Each + UserItem object should have the domain and name attributes set. + + Returns + ------- + None + """ url = f"{self.baseurl}/delete" csv_content = remove_users_csv(users) request, content_type = RequestFactory.User.delete_csv_req(csv_content) @@ -407,6 +460,35 @@ def bulk_remove(self, users: Iterable[UserItem]) -> None: @api(version="2.0") def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: + """ + Syntactic sugar for calling users.add multiple times. This method has + been deprecated in favor of using the bulk_add which accomplishes the + same task in one API call. + + .. deprecated:: v0.34.0 + `add_all` will be removed as its functionality is replicated via + the `bulk_add` method. + + Parameters + ---------- + filepath: str + The path to the CSV file containing the users to add to the site. + The file is read in line by line and each line is passed to the + `add` method. + + Returns + ------- + tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]] + The first element of the tuple is a list of UserItem objects that + were successfully added to the site. The second element is a list + of tuples where the first element is the UserItem object that failed + to be added to the site and the second element is the ServerResponseError + that was raised when attempting to add the user. + + Warnings + -------- + This method is deprecated. Use the `bulk_add` method instead. + """ warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning) created = [] failed = [] @@ -632,6 +714,21 @@ def create_users_csv(users: Iterable[UserItem], identity_pool=None) -> bytes: - Admin Level - Publish capability - Email + + Parameters + ---------- + users: Iterable[UserItem] + An iterable of UserItem objects to create the CSV from. + + identity_pool: Optional[str] + The identity pool to use when adding the users. This parameter is not + yet supported in this version of the Tableau Server Client, and should + be left as None. + + Returns + ------- + bytes + A byte string containing the CSV data. """ if identity_pool is not None: raise NotImplementedError("Identity pool is not supported in this version") @@ -671,6 +768,35 @@ def create_users_csv(users: Iterable[UserItem], identity_pool=None) -> bytes: def remove_users_csv(users: Iterable[UserItem]) -> bytes: + """ + Create a CSV byte string from an Iterable of UserItem objects. This function + only consumes the domain and name attributes of the UserItem objects. The + CSV will have space for the following columns, though only the first column + will be populated, and no header row: + + - Username + - Password + - Display Name + - License + - Admin Level + - Publish capability + - Email + + Parameters + ---------- + users: Iterable[UserItem] + An iterable of UserItem objects to create the CSV from. + + identity_pool: Optional[str] + The identity pool to use when adding the users. This parameter is not + yet supported in this version of the Tableau Server Client, and should + be left as None. + + Returns + ------- + bytes + A byte string containing the CSV data. + """ with io.StringIO() as output: writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL) for user in users: From aa501755895cebc87abc0779a3bff54e31e00877 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 2 Oct 2024 21:14:19 -0500 Subject: [PATCH 18/23] docs: add examples to docstrings --- .../server/endpoint/users_endpoint.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index f5ff4c197..a2b10aedd 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -424,6 +424,27 @@ def bulk_add(self, users: Iterable[UserItem]) -> JobItem: ------- JobItem The job that is started for adding the users in bulk. + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('http://localhost') + >>> # Login to the server + + >>> # Create a list of UserItem objects to add to the site + >>> users = [ + >>> TSC.UserItem(name="user1", site_role="Unlicensed"), + >>> TSC.UserItem(name="user2", site_role="Explorer"), + >>> TSC.UserItem(name="user3", site_role="Creator"), + >>> ] + + >>> # Set the domain name for the users + >>> for user in users: + >>> user.domain_name = "example.com" + + >>> # Add the users to the site + >>> job = server.users.bulk_add(users) + """ url = f"{self.baseurl}/import" # Allow for iterators to be passed into the function @@ -451,6 +472,16 @@ def bulk_remove(self, users: Iterable[UserItem]) -> None: Returns ------- None + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('http://localhost') + >>> # Login to the server + + >>> # Find the users to remove + >>> example_users = server.users.filter(domain_name="example.com") + >>> server.users.bulk_remove(example_users) """ url = f"{self.baseurl}/delete" csv_content = remove_users_csv(users) From b604a8e12ca1b4699f5e33af55adaec5a56829ad Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 27 Jan 2025 06:29:01 -0600 Subject: [PATCH 19/23] chore: update deprecated version # --- tableauserverclient/server/endpoint/users_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index a2b10aedd..1a0c33890 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -355,7 +355,7 @@ def add_all(self, users: list[UserItem]) -> tuple[list[UserItem], list[UserItem] been deprecated in favor of using the bulk_add which accomplishes the same task in one API call. - .. deprecated:: v0.34.0 + .. deprecated:: v0.37.0 `add_all` will be removed as its functionality is replicated via the `bulk_add` method. @@ -496,7 +496,7 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us been deprecated in favor of using the bulk_add which accomplishes the same task in one API call. - .. deprecated:: v0.34.0 + .. deprecated:: v0.37.0 `add_all` will be removed as its functionality is replicated via the `bulk_add` method. From fa4899b75a9e9c35b4ffd6cc30e2dce5c3a25b76 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 15 May 2025 18:01:46 -0500 Subject: [PATCH 20/23] feat: enable idp_configuration_id in bulk_add --- .../server/endpoint/users_endpoint.py | 11 +++++++---- tableauserverclient/server/request_factory.py | 7 ++++++- test/test_user.py | 17 ++++++++++++++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 1a0c33890..850c4f92e 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -403,7 +403,8 @@ def bulk_add(self, users: Iterable[UserItem]) -> JobItem: Email is optional, but if provided, it must be a valid email address. - If auth_setting is not provided, the default is ServerDefault. + If auth_setting is not provided, and idp_configuration_id is None, then + default is ServerDefault. If site_role is not provided, the default is Unlicensed. @@ -414,6 +415,10 @@ def bulk_add(self, users: Iterable[UserItem]) -> JobItem: Details about administrator level and publishing capability are inferred from the site_role. + If the user belongs to a different IDP configuration, the UserItem's + idp_configuration_id attribute must be set to the IDP configuration ID + that the user belongs to. + Parameters ---------- users: Iterable[UserItem] @@ -733,7 +738,7 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe return super().filter(*invalid, page_size=page_size, **kwargs) -def create_users_csv(users: Iterable[UserItem], identity_pool=None) -> bytes: +def create_users_csv(users: Iterable[UserItem]) -> bytes: """ Create a CSV byte string from an Iterable of UserItem objects. The CSV will have the following columns, and no header row: @@ -761,8 +766,6 @@ def create_users_csv(users: Iterable[UserItem], identity_pool=None) -> bytes: bytes A byte string containing the CSV data. """ - if identity_pool is not None: - raise NotImplementedError("Identity pool is not supported in this version") with io.StringIO() as output: writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL) for user in users: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 638cf0612..2db257713 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -974,7 +974,12 @@ def import_from_csv_req(self, csv_content: bytes, users: Iterable[UserItem]): raise ValueError("User name must be populated.") user_element = ET.SubElement(xml_request, "user") user_element.attrib["name"] = user.name - user_element.attrib["authSetting"] = user.auth_setting or "ServerDefault" + if user.auth_setting is not None and user.idp_configuration_id is not None: + raise ValueError("User cannot have both authSetting and idpConfigurationId.") + elif user.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user.idp_configuration_id + else: + user_element.attrib["authSetting"] = user.auth_setting or "ServerDefault" parts = { "tableau_user_import": ("tsc_users_file.csv", csv_content, "file"), diff --git a/test/test_user.py b/test/test_user.py index 23dcb94e5..30bc3a2d8 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -37,6 +37,7 @@ def make_user( domain: str = "", fullname: str = "", email: str = "", + idp_id: str = "", ) -> TSC.UserItem: user = TSC.UserItem(name, site_role or None) if auth_setting: @@ -47,6 +48,8 @@ def make_user( user.fullname = fullname if email: user.email = email + if idp_id: + user.idp_configuration_id = idp_id return user @@ -415,6 +418,7 @@ def test_bulk_add(self): make_user("Frank", "SiteAdministratorExplorer", "TableauIDWithMFA", email="Frank@example.com"), make_user("Grace", "SiteAdministratorCreator", "SAML", "example.com", "Grace Example", "gex@example.com"), make_user("Hank", "Unlicensed"), + make_user("Ivy", "Unlicensed", idp_id="0123456789"), ] with requests_mock.mock() as m: m.post(f"{self.server.users.baseurl}/import", text=BULK_ADD_XML.read_text()) @@ -446,7 +450,11 @@ def test_bulk_add(self): for user, xml_user in zip(users, xml_users): assert user.name == xml_user.get("name") - assert xml_user.get("authSetting") == (user.auth_setting or "ServerDefault") + if user.idp_configuration_id is None: + assert xml_user.get("authSetting") == (user.auth_setting or "ServerDefault") + else: + assert xml_user.get("idpConfigurationId") == user.idp_configuration_id + assert xml_user.get("authSetting") is None csv_data = create_users_csv(users).replace(b"\r\n", b"\n") assert csv_data.strip() == segments[0].split(b"\n\n")[1].strip() @@ -505,3 +513,10 @@ def test_add_all(self) -> None: self.server.users.add_all(users) assert mock_add.call_count == len(users) + + def test_add_idp_and_auth_error(self) -> None: + self.server.version = "3.24" + users = [make_user("Alice", "Viewer", auth_setting="SAML", idp_id="01234")] + + with pytest.raises(ValueError, match="User cannot have both authSetting and idpConfigurationId."): + self.server.users.bulk_add(users) From 51bb02a2cf509de8e68883f5c01fb9d50ec5e9c0 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 15 May 2025 16:22:43 -0700 Subject: [PATCH 21/23] chore: remove outdated docstring text --- tableauserverclient/server/endpoint/users_endpoint.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 850c4f92e..53ff3dde6 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -756,11 +756,6 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes: users: Iterable[UserItem] An iterable of UserItem objects to create the CSV from. - identity_pool: Optional[str] - The identity pool to use when adding the users. This parameter is not - yet supported in this version of the Tableau Server Client, and should - be left as None. - Returns ------- bytes @@ -821,11 +816,6 @@ def remove_users_csv(users: Iterable[UserItem]) -> bytes: users: Iterable[UserItem] An iterable of UserItem objects to create the CSV from. - identity_pool: Optional[str] - The identity pool to use when adding the users. This parameter is not - yet supported in this version of the Tableau Server Client, and should - be left as None. - Returns ------- bytes From 3b96c900bfa4d7ccaf6f3a2b373da85972059a86 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 15 May 2025 22:25:07 -0500 Subject: [PATCH 22/23] test: remove_users_csv --- test/test_user.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/test_user.py b/test/test_user.py index 30bc3a2d8..54664a407 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -2,6 +2,7 @@ import io import os from pathlib import Path +import re import unittest from unittest.mock import patch @@ -520,3 +521,34 @@ def test_add_idp_and_auth_error(self) -> None: with pytest.raises(ValueError, match="User cannot have both authSetting and idpConfigurationId."): self.server.users.bulk_add(users) + + def test_remove_users_csv(self) -> None: + self.server.version = "3.15" + users = [ + make_user("Alice", "Viewer"), + make_user("Bob", "Explorer"), + make_user("Charlie", "Creator", "SAML"), + make_user("Dave"), + make_user("Eve", "ServerAdministrator", "OpenID", "example.com", "Eve Example", "Eve@example.com"), + make_user("Frank", "SiteAdministratorExplorer", "TableauIDWithMFA", email="Frank@example.com"), + make_user("Grace", "SiteAdministratorCreator", "SAML", "example.com", "Grace Example", "gex@example.com"), + make_user("Hank", "Unlicensed"), + make_user("Ivy", "Unlicensed", idp_id="0123456789"), + ] + + data = remove_users_csv(users) + assert isinstance(data, bytes), "remove_users_csv should return bytes" + csv_data = data.decode("utf-8") + records = re.split(r"\r?\n", csv_data.strip()) + assert len(records) == len(users), "Number of records in csv does not match number of users" + + for user, record in zip(users, records): + name, *rest = record.strip().split(",") + assert len(rest) == 6, "Number of fields in csv does not match expected number" + assert all([f == "" for f in rest]), "All fields except name should be empty" + if user.domain_name is None: + assert name == user.name, f"Name in csv does not match expected name: {user.name}" + else: + assert ( + name == f"{user.domain_name}\\{user.name}" + ), f"Name in csv does not match expected name: {user.domain_name}\\{user.name}" From 475beedc5d5b762f121b1158452be32bf8608328 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 5 Aug 2025 06:57:52 -0500 Subject: [PATCH 23/23] chore: update deprecated version number --- tableauserverclient/server/endpoint/users_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 53ff3dde6..6deb76716 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -355,7 +355,7 @@ def add_all(self, users: list[UserItem]) -> tuple[list[UserItem], list[UserItem] been deprecated in favor of using the bulk_add which accomplishes the same task in one API call. - .. deprecated:: v0.37.0 + .. deprecated:: v0.41.0 `add_all` will be removed as its functionality is replicated via the `bulk_add` method. @@ -501,7 +501,7 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us been deprecated in favor of using the bulk_add which accomplishes the same task in one API call. - .. deprecated:: v0.37.0 + .. deprecated:: v0.41.0 `add_all` will be removed as its functionality is replicated via the `bulk_add` method.