From ff2d662b93aff4a0cda9ae9ce02a1fa1e7a6c0af 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/19] 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 | 86 ++++++++++++++++++- 4 files changed, 169 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 d81907ae9..d806c86a2 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"): @@ -552,3 +575,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 575423612..fb0adbc50 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -931,6 +931,21 @@ def add_req(self, user_item: UserItem) -> bytes: user_element.attrib["authSetting"] = user_item.auth_setting 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 a46624845..6f021b20f 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,13 +1,18 @@ +import csv +import io import os +from pathlib import Path import unittest +from defusedxml.ElementTree import fromstring import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_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_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml") @@ -233,3 +238,82 @@ def test_get_users_from_file(self): users, failures = self.server.users.create_from_file(USERS) assert users[0].name == "Cassie", users assert failures == [] + + 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 = 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 8143250123c27c41c9f354645104b451c8be805d 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/19] 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 d806c86a2..664083b7b 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -603,7 +603,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 6f021b20f..ba42c240d 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -240,12 +240,28 @@ def test_get_users_from_file(self): assert failures == [] 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()) @@ -310,8 +326,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 5714883b45f41d60324418ca13e4d3b815364efa 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/19] 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 ba42c240d..5e9556afd 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -240,7 +240,14 @@ def test_get_users_from_file(self): assert failures == [] 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 @@ -254,14 +261,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 fe3b50b788e741df40adc414fa0406fc3970273b 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/19] 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 5e9556afd..609fc421c 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -5,6 +5,7 @@ import unittest from defusedxml.ElementTree import fromstring +import pytest import requests_mock import tableauserverclient as TSC @@ -341,3 +342,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 92aca41170516d8f59e3d194c6b2b06e9f3e8e3f 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/19] 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 664083b7b..26921bef3 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 @@ -615,3 +623,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 fb0adbc50..15d07177d 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -946,6 +946,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 609fc421c..792b783a1 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -353,3 +353,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 aa0e3d71bd1317089fb42c65b6f92053694a735a 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/19] 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 792b783a1..ab7a2b249 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -222,6 +222,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") @@ -231,6 +232,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 a7b29cbc37bd28377837bc526038354756b86e94 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/19] 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 ab7a2b249..2a0646e61 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 +from tableauserverclient.server.endpoint.users_endpoint import create_users_csv, remove_users_csv TEST_ASSET_DIR = Path(__file__).resolve().parent / "assets" @@ -27,6 +28,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) @@ -242,26 +263,64 @@ def test_get_users_from_file(self): assert users[0].name == "Cassie", users assert failures == [] - 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"), @@ -305,45 +364,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 48266ea9faa67263a4b5263b61d8307114c8ef4a 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/19] 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 2a0646e61..f0220e184 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -404,5 +404,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 c2ba53cdeb9c6d80525190e637dcc396c1517d15 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/19] 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 f0220e184..1d59c7737 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -317,9 +317,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 = [ @@ -381,10 +378,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 dff7a3a3cde8dcef9e18db56288550e3d5befeec 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/19] 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 26921bef3..15dfd955c 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 3f61aab343ad219e39347cb292e0770b7c6c17ed 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/19] 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 1d59c7737..23f4e5178 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -243,23 +243,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 c651846a622e81b891ebcefa9abd6a0d2b40a9d3 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/19] 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 15dfd955c..d5a825104 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 a2a3ce0951dcc1f9a6eb8e054c3681f1fb2546c4 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/19] 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 d5a825104..e6ad056b1 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 114d2058ac46a45df6a855c56e7e0e2a86eb4a20 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/19] 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 e6ad056b1..46ba39fe0 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 @@ -604,9 +602,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 4b62d7c16be8fac5c96ad8c01897585fc6d51b03 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/19] 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 46ba39fe0..686abe25d 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 3c2e99af2d3c37ff6d0a45e327c2418a11b1a16e 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/19] 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 23f4e5178..0689043ad 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.ElementTree import fromstring import pytest @@ -403,3 +404,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 d46c7e1248487de5abebea769056a33dd357f2c2 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/19] 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 686abe25d..4700ec36a 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 = [] @@ -615,6 +697,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") @@ -654,6 +751,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 20d916951c2f7b7ac9623d048825d408413e1bbf 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/19] 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 4700ec36a..10894485c 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 cfc4b1cf5c45149a8d00267fe44e67b3ae352a19 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/19] 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 10894485c..bf89731c6 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.