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.