From 10613091d3da1b178ed6dab8b006d1cb62756789 Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Thu, 20 Nov 2025 22:13:28 -0600
Subject: [PATCH 01/27] fix: black ci errors
---
test/test_datasource.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/test/test_datasource.py b/test/test_datasource.py
index 7f4cca75..56eb11ab 100644
--- a/test/test_datasource.py
+++ b/test/test_datasource.py
@@ -895,7 +895,8 @@ def test_publish_description(server: TSC.Server) -> None:
ds_elem = body.find(".//datasource")
assert ds_elem is not None
assert ds_elem.attrib["description"] == "Sample description"
-
+
+
def test_get_datasource_no_owner(server: TSC.Server) -> None:
with requests_mock.mock() as m:
m.get(server.datasources.baseurl, text=GET_NO_OWNER.read_text())
From 6bbd50d9054875438281a3204a6442747d820ea2 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 02/27] 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 17af21a0..90996099 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 66071bbe..7100ae13 100644
--- a/tableauserverclient/server/request_factory.py
+++ b/tableauserverclient/server/request_factory.py
@@ -975,6 +975,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 00000000..7301ac7d
--- /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 fa2ac3a1..47556cbe 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 8c79c4c8ccdb95a27b0f67c5a0ec2f14516fb00f 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 03/27] 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 90996099..03ace545 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 47556cbe..1aeeb857 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 e45af7a8080a505f9650902b6e89aac3bf72a2cc 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 04/27] 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 1aeeb857..1ebde5ad 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 5e1951bf64a5ecab05d5b30895c5e7afba6666fa 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 05/27] 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 1ebde5ad..b81ab3b1 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 95056b05f813bdb753426a7ad847d7ffa76b1239 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 06/27] 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 03ace545..e47d04cc 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 7100ae13..7cb9d0a4 100644
--- a/tableauserverclient/server/request_factory.py
+++ b/tableauserverclient/server/request_factory.py
@@ -990,6 +990,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 b81ab3b1..dba9fc11 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 3deec01ee608ad9db9ef4f6c8d16b9a0685fa9c6 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 07/27] 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 dba9fc11..080d9696 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 5d151f37c4e39f1b3cabb6a445eafb3dd829fc13 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 08/27] 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 080d9696..d05daaad 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 349b680a1fb689927666ff2043b614bf3dcf26e6 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 09/27] 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 d05daaad..4e38c4cc 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 fb2bb7587d4277829f865cf319733098ec4ee065 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 10/27] 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 4e38c4cc..dcfcd8c5 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 4fa15f0439dc853874f211c49cc37853a75f6b1b 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 11/27] 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 e47d04cc..070b0912 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 ceabbb8fee36936109d6232e87a98d95ba26ee78 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 12/27] 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 dcfcd8c5..08f34def 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 33e0e6ae1047c5423457b9f01ff2aa1ac505833f 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 13/27] 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 070b0912..1e700ac2 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 a9e1f02c7121b51688278b2da64da1b9eb257fc7 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 14/27] 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 1e700ac2..fbdc8a80 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 ccfebd46e0eb467b96438a4beb843e5e7dff8198 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 15/27] 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 fbdc8a80..544606ce 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 967fe4dff26246c533cfacdde8b4a964a8417292 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 16/27] 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 544606ce..f164ce94 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 32f4b3883a02e0cd1e9a33309e9f8558189624ff 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 17/27] 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 08f34def..23dcb94e 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 08ef73671affd91011549dd2023a40d268a4f797 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 18/27] 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 f164ce94..f5ff4c19 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 439189c155a11d0d57f1f14c81873e06266b9484 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 19/27] 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 f5ff4c19..a2b10aed 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 0d6eb27ca59169bf8aa7430c5daf5f77f87af851 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 20/27] 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 a2b10aed..1a0c3389 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 25a3a59e12f0a5eeb9c5ad7dac616a1ddaa5821a 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 21/27] 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 1a0c3389..850c4f92 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 7cb9d0a4..fe2a08a0 100644
--- a/tableauserverclient/server/request_factory.py
+++ b/tableauserverclient/server/request_factory.py
@@ -982,7 +982,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 23dcb94e..30bc3a2d 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 daf36d84d8f69bcfcf7af2377557d7f25170d82e Mon Sep 17 00:00:00 2001
From: Jordan Woods
Date: Thu, 15 May 2025 16:22:43 -0700
Subject: [PATCH 22/27] 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 850c4f92..53ff3dde 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 905d86e8b1f8281a010071031ddeff8cab054888 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 23/27] 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 30bc3a2d..54664a40 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 64a623004315acd16e38a11b1d2e0026c1ab1fe1 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 24/27] 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 53ff3dde..6deb7671 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.
From 15b3694f7022ed0ca20d1efdf658f9f7f1eb34c4 Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Fri, 5 Dec 2025 10:28:31 -0600
Subject: [PATCH 25/27] chore: pytestify test_user
---
test/test_user.py | 637 +++++++++++++++++++++++-----------------------
1 file changed, 325 insertions(+), 312 deletions(-)
diff --git a/test/test_user.py b/test/test_user.py
index fa2ac3a1..f2e778bc 100644
--- a/test/test_user.py
+++ b/test/test_user.py
@@ -1,322 +1,335 @@
-import os
-import unittest
+from pathlib import Path
from defusedxml import ElementTree as ET
+import pytest
import requests_mock
import tableauserverclient as TSC
from tableauserverclient.datetime_helpers import format_datetime, parse_datetime
-TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
-
-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")
-GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml")
-UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml")
-ADD_XML = os.path.join(TEST_ASSET_DIR, "user_add.xml")
-POPULATE_WORKBOOKS_XML = os.path.join(TEST_ASSET_DIR, "user_populate_workbooks.xml")
-GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, "favorites_get.xml")
-POPULATE_GROUPS_XML = os.path.join(TEST_ASSET_DIR, "user_populate_groups.xml")
-
-USERNAMES = os.path.join(TEST_ASSET_DIR, "Data", "usernames.csv")
-USERS = os.path.join(TEST_ASSET_DIR, "Data", "user_details.csv")
-
-
-class UserTests(unittest.TestCase):
- def setUp(self) -> None:
- self.server = TSC.Server("http://test", False)
-
- # Fake signin
- self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
- self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
-
- self.baseurl = self.server.users.baseurl
-
- def test_get(self) -> None:
- with open(GET_XML, "rb") as f:
- response_xml = f.read().decode("utf-8")
- with requests_mock.mock() as m:
- m.get(self.baseurl + "?fields=_all_", text=response_xml)
- all_users, pagination_item = self.server.users.get()
-
- self.assertEqual(2, pagination_item.total_available)
- self.assertEqual(2, len(all_users))
-
- self.assertTrue(any(user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794" for user in all_users))
- single_user = next(user for user in all_users if user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794")
- self.assertEqual("alice", single_user.name)
- self.assertEqual("Publisher", single_user.site_role)
- self.assertEqual("2016-08-16T23:17:06Z", format_datetime(single_user.last_login))
- self.assertEqual("alice cook", single_user.fullname)
- self.assertEqual("alicecook@test.com", single_user.email)
-
- self.assertTrue(any(user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3" for user in all_users))
- single_user = next(user for user in all_users if user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3")
- self.assertEqual("Bob", single_user.name)
- self.assertEqual("Interactor", single_user.site_role)
- self.assertEqual("Bob Smith", single_user.fullname)
- self.assertEqual("bob@test.com", single_user.email)
-
- def test_get_empty(self) -> None:
- with open(GET_EMPTY_XML, "rb") as f:
- response_xml = f.read().decode("utf-8")
- with requests_mock.mock() as m:
- m.get(self.baseurl, text=response_xml)
- all_users, pagination_item = self.server.users.get()
-
- self.assertEqual(0, pagination_item.total_available)
- self.assertEqual([], all_users)
-
- def test_get_before_signin(self) -> None:
- self.server._auth_token = None
- self.assertRaises(TSC.NotSignedInError, self.server.users.get)
-
- def test_get_by_id(self) -> None:
- with open(GET_BY_ID_XML, "rb") as f:
- response_xml = f.read().decode("utf-8")
- with requests_mock.mock() as m:
- m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml)
- single_user = self.server.users.get_by_id("dd2239f6-ddf1-4107-981a-4cf94e415794")
-
- self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_user.id)
- self.assertEqual("alice", single_user.name)
- self.assertEqual("Alice", single_user.fullname)
- self.assertEqual("Publisher", single_user.site_role)
- self.assertEqual("ServerDefault", single_user.auth_setting)
- self.assertEqual("2016-08-16T23:17:06Z", format_datetime(single_user.last_login))
- self.assertEqual("local", single_user.domain_name)
-
- def test_get_by_id_missing_id(self) -> None:
- self.assertRaises(ValueError, self.server.users.get_by_id, "")
-
- def test_update(self) -> None:
- with open(UPDATE_XML, "rb") as f:
- response_xml = f.read().decode("utf-8")
- with requests_mock.mock() as m:
- m.put(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml)
- single_user = TSC.UserItem("test", "Viewer")
- single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
- single_user.name = "Cassie"
- single_user.fullname = "Cassie"
- single_user.email = "cassie@email.com"
- single_user = self.server.users.update(single_user)
-
- self.assertEqual("Cassie", single_user.name)
- self.assertEqual("Cassie", single_user.fullname)
- self.assertEqual("cassie@email.com", single_user.email)
- self.assertEqual("Viewer", single_user.site_role)
-
- def test_update_missing_id(self) -> None:
+TEST_ASSET_DIR = Path(__file__).parent / "assets"
+
+GET_XML = TEST_ASSET_DIR / "user_get.xml"
+GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "user_get_all_fields.xml"
+GET_EMPTY_XML = TEST_ASSET_DIR / "user_get_empty.xml"
+GET_BY_ID_XML = TEST_ASSET_DIR / "user_get_by_id.xml"
+UPDATE_XML = TEST_ASSET_DIR / "user_update.xml"
+ADD_XML = TEST_ASSET_DIR / "user_add.xml"
+POPULATE_WORKBOOKS_XML = TEST_ASSET_DIR / "user_populate_workbooks.xml"
+GET_FAVORITES_XML = TEST_ASSET_DIR / "favorites_get.xml"
+POPULATE_GROUPS_XML = TEST_ASSET_DIR / "user_populate_groups.xml"
+
+USERNAMES = TEST_ASSET_DIR / "Data" / "usernames.csv"
+USERS = TEST_ASSET_DIR / "Data" / "user_details.csv"
+
+
+@pytest.fixture(scope="function")
+def server():
+ """Fixture to create a TSC.Server instance for testing."""
+ server = TSC.Server("http://test", False)
+
+ # Fake signin
+ server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
+ server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
+
+ return server
+
+
+def test_get(server: TSC.Server) -> None:
+ response_xml = GET_XML.read_text()
+ with requests_mock.mock() as m:
+ m.get(server.users.baseurl + "?fields=_all_", text=response_xml)
+ all_users, pagination_item = server.users.get()
+
+ assert 2 == pagination_item.total_available
+ assert 2 == len(all_users)
+
+ assert any(user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794" for user in all_users)
+ single_user = next(user for user in all_users if user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794")
+ assert "alice" == single_user.name
+ assert "Publisher" == single_user.site_role
+ assert "2016-08-16T23:17:06Z" == format_datetime(single_user.last_login)
+ assert "alice cook" == single_user.fullname
+ assert "alicecook@test.com" == single_user.email
+
+ assert any(user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3" for user in all_users)
+ single_user = next(user for user in all_users if user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3")
+ assert "Bob" == single_user.name
+ assert "Interactor" == single_user.site_role
+ assert "Bob Smith" == single_user.fullname
+ assert "bob@test.com" == single_user.email
+
+
+def test_get_empty(server: TSC.Server) -> None:
+ response_xml = GET_EMPTY_XML.read_text()
+ with requests_mock.mock() as m:
+ m.get(server.users.baseurl, text=response_xml)
+ all_users, pagination_item = server.users.get()
+
+ assert 0 == pagination_item.total_available
+ assert [] == all_users
+
+
+def test_get_before_signin(server: TSC.Server) -> None:
+ server._auth_token = None
+ with pytest.raises(TSC.NotSignedInError):
+ server.users.get()
+
+
+def test_get_by_id(server: TSC.Server) -> None:
+ response_xml = GET_BY_ID_XML.read_text()
+ with requests_mock.mock() as m:
+ m.get(server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml)
+ single_user = server.users.get_by_id("dd2239f6-ddf1-4107-981a-4cf94e415794")
+
+ assert "dd2239f6-ddf1-4107-981a-4cf94e415794" == single_user.id
+ assert "alice" == single_user.name
+ assert "Alice" == single_user.fullname
+ assert "Publisher" == single_user.site_role
+ assert "ServerDefault" == single_user.auth_setting
+ assert "2016-08-16T23:17:06Z" == format_datetime(single_user.last_login)
+ assert "local" == single_user.domain_name
+
+
+def test_get_by_id_missing_id(server: TSC.Server) -> None:
+ with pytest.raises(ValueError):
+ server.users.get_by_id("")
+
+
+def test_update(server: TSC.Server) -> None:
+ response_xml = UPDATE_XML.read_text()
+ with requests_mock.mock() as m:
+ m.put(server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml)
+ single_user = TSC.UserItem("test", "Viewer")
+ single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ single_user.name = "Cassie"
+ single_user.fullname = "Cassie"
+ single_user.email = "cassie@email.com"
+ single_user = server.users.update(single_user)
+
+ assert "Cassie" == single_user.name
+ assert "Cassie" == single_user.fullname
+ assert "cassie@email.com" == single_user.email
+ assert "Viewer" == single_user.site_role
+
+
+def test_update_missing_id(server: TSC.Server) -> None:
+ single_user = TSC.UserItem("test", "Interactor")
+ with pytest.raises(TSC.MissingRequiredFieldError):
+ server.users.update(single_user)
+
+
+def test_remove(server: TSC.Server) -> None:
+ with requests_mock.mock() as m:
+ m.delete(server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", status_code=204)
+ server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794")
+
+
+def test_remove_with_replacement(server: TSC.Server) -> None:
+ with requests_mock.mock() as m:
+ m.delete(
+ server.users.baseurl
+ + "/dd2239f6-ddf1-4107-981a-4cf94e415794"
+ + "?mapAssetsTo=4cc4c17f-898a-4de4-abed-a1681c673ced",
+ status_code=204,
+ )
+ server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794", "4cc4c17f-898a-4de4-abed-a1681c673ced")
+
+
+def test_remove_missing_id(server: TSC.Server) -> None:
+ with pytest.raises(ValueError):
+ server.users.remove("")
+
+
+def test_add(server: TSC.Server) -> None:
+ response_xml = ADD_XML.read_text()
+ with requests_mock.mock() as m:
+ m.post(server.users.baseurl + "", text=response_xml)
+ new_user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault")
+ new_user = server.users.add(new_user)
+
+ assert "4cc4c17f-898a-4de4-abed-a1681c673ced" == new_user.id
+ assert "Cassie" == new_user.name
+ assert "Viewer" == new_user.site_role
+ assert "ServerDefault" == new_user.auth_setting
+
+
+def test_populate_workbooks(server: TSC.Server) -> None:
+ response_xml = POPULATE_WORKBOOKS_XML.read_text()
+ with requests_mock.mock() as m:
+ m.get(server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks", text=response_xml)
single_user = TSC.UserItem("test", "Interactor")
- self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.update, single_user)
-
- def test_remove(self) -> None:
- with requests_mock.mock() as m:
- m.delete(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", status_code=204)
- self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794")
-
- def test_remove_with_replacement(self) -> None:
- with requests_mock.mock() as m:
- m.delete(
- self.baseurl
- + "/dd2239f6-ddf1-4107-981a-4cf94e415794"
- + "?mapAssetsTo=4cc4c17f-898a-4de4-abed-a1681c673ced",
- status_code=204,
- )
- self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794", "4cc4c17f-898a-4de4-abed-a1681c673ced")
-
- def test_remove_missing_id(self) -> None:
- self.assertRaises(ValueError, self.server.users.remove, "")
-
- def test_add(self) -> None:
- with open(ADD_XML, "rb") as f:
- response_xml = f.read().decode("utf-8")
- with requests_mock.mock() as m:
- m.post(self.baseurl + "", text=response_xml)
- new_user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault")
- new_user = self.server.users.add(new_user)
-
- self.assertEqual("4cc4c17f-898a-4de4-abed-a1681c673ced", new_user.id)
- self.assertEqual("Cassie", new_user.name)
- self.assertEqual("Viewer", new_user.site_role)
- self.assertEqual("ServerDefault", new_user.auth_setting)
-
- def test_populate_workbooks(self) -> None:
- with open(POPULATE_WORKBOOKS_XML, "rb") as f:
- response_xml = f.read().decode("utf-8")
- with requests_mock.mock() as m:
- m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks", text=response_xml)
- single_user = TSC.UserItem("test", "Interactor")
- single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
- self.server.users.populate_workbooks(single_user)
-
- workbook_list = list(single_user.workbooks)
- self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", workbook_list[0].id)
- self.assertEqual("SafariSample", workbook_list[0].name)
- self.assertEqual("SafariSample", workbook_list[0].content_url)
- self.assertEqual(False, workbook_list[0].show_tabs)
- self.assertEqual(26, workbook_list[0].size)
- self.assertEqual("2016-07-26T20:34:56Z", format_datetime(workbook_list[0].created_at))
- self.assertEqual("2016-07-26T20:35:05Z", format_datetime(workbook_list[0].updated_at))
- self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id)
- self.assertEqual("default", workbook_list[0].project_name)
- self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id)
- self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags)
-
- def test_populate_owned_workbooks(self) -> None:
- with open(POPULATE_WORKBOOKS_XML, "rb") as f:
- response_xml = f.read().decode("utf-8")
- # Query parameter ownedBy is case sensitive.
- with requests_mock.mock(case_sensitive=True) as m:
- m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks?ownedBy=true", text=response_xml)
- single_user = TSC.UserItem("test", "Interactor")
- single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
- self.server.users.populate_workbooks(single_user, owned_only=True)
- list(single_user.workbooks)
-
- request_history = m.request_history[0]
-
- assert "ownedBy" in request_history.qs, "ownedBy not in request history"
- assert "true" in request_history.qs["ownedBy"], "ownedBy not set to true in request history"
-
- def test_populate_workbooks_missing_id(self) -> None:
+ single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ server.users.populate_workbooks(single_user)
+
+ workbook_list = list(single_user.workbooks)
+ assert "3cc6cd06-89ce-4fdc-b935-5294135d6d42" == workbook_list[0].id
+ assert "SafariSample" == workbook_list[0].name
+ assert "SafariSample" == workbook_list[0].content_url
+ assert False == workbook_list[0].show_tabs
+ assert 26 == workbook_list[0].size
+ assert "2016-07-26T20:34:56Z" == format_datetime(workbook_list[0].created_at)
+ assert "2016-07-26T20:35:05Z" == format_datetime(workbook_list[0].updated_at)
+ assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == workbook_list[0].project_id
+ assert "default" == workbook_list[0].project_name
+ assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == workbook_list[0].owner_id
+ assert {"Safari", "Sample"} == workbook_list[0].tags
+
+
+def test_populate_owned_workbooks(server: TSC.Server) -> None:
+ response_xml = POPULATE_WORKBOOKS_XML.read_text()
+ # Query parameter ownedBy is case sensitive.
+ with requests_mock.mock(case_sensitive=True) as m:
+ m.get(server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks?ownedBy=true", text=response_xml)
single_user = TSC.UserItem("test", "Interactor")
- self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user)
-
- def test_populate_favorites(self) -> None:
- self.server.version = "2.5"
- baseurl = self.server.favorites.baseurl
+ single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ server.users.populate_workbooks(single_user, owned_only=True)
+ list(single_user.workbooks)
+
+ request_history = m.request_history[0]
+
+ assert "ownedBy" in request_history.qs, "ownedBy not in request history"
+ assert "true" in request_history.qs["ownedBy"], "ownedBy not set to true in request history"
+
+
+def test_populate_workbooks_missing_id(server: TSC.Server) -> None:
+ single_user = TSC.UserItem("test", "Interactor")
+ with pytest.raises(TSC.MissingRequiredFieldError):
+ server.users.populate_workbooks(single_user)
+
+
+def test_populate_favorites(server: TSC.Server) -> None:
+ server.version = "2.5"
+ baseurl = server.favorites.baseurl
+ single_user = TSC.UserItem("test", "Interactor")
+ response_xml = GET_FAVORITES_XML.read_text()
+ with requests_mock.mock() as m:
+ m.get(f"{baseurl}/{single_user.id}", text=response_xml)
+ server.users.populate_favorites(single_user)
+ assert single_user._favorites is not None
+ assert len(single_user.favorites["workbooks"]) == 1
+ assert len(single_user.favorites["views"]) == 1
+ assert len(single_user.favorites["projects"]) == 1
+ assert len(single_user.favorites["datasources"]) == 1
+
+ workbook = single_user.favorites["workbooks"][0]
+ view = single_user.favorites["views"][0]
+ datasource = single_user.favorites["datasources"][0]
+ project = single_user.favorites["projects"][0]
+
+ assert workbook.id == "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00"
+ assert view.id == "d79634e1-6063-4ec9-95ff-50acbf609ff5"
+ assert datasource.id == "e76a1461-3b1d-4588-bf1b-17551a879ad9"
+ assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74"
+
+
+def test_populate_groups(server: TSC.Server) -> None:
+ server.version = "3.7"
+ response_xml = POPULATE_GROUPS_XML.read_text()
+ with requests_mock.mock() as m:
+ m.get(server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/groups", text=response_xml)
single_user = TSC.UserItem("test", "Interactor")
- with open(GET_FAVORITES_XML, "rb") as f:
- response_xml = f.read().decode("utf-8")
- with requests_mock.mock() as m:
- m.get(f"{baseurl}/{single_user.id}", text=response_xml)
- self.server.users.populate_favorites(single_user)
- self.assertIsNotNone(single_user._favorites)
- self.assertEqual(len(single_user.favorites["workbooks"]), 1)
- self.assertEqual(len(single_user.favorites["views"]), 1)
- self.assertEqual(len(single_user.favorites["projects"]), 1)
- self.assertEqual(len(single_user.favorites["datasources"]), 1)
-
- workbook = single_user.favorites["workbooks"][0]
- view = single_user.favorites["views"][0]
- datasource = single_user.favorites["datasources"][0]
- project = single_user.favorites["projects"][0]
-
- self.assertEqual(workbook.id, "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00")
- self.assertEqual(view.id, "d79634e1-6063-4ec9-95ff-50acbf609ff5")
- self.assertEqual(datasource.id, "e76a1461-3b1d-4588-bf1b-17551a879ad9")
- self.assertEqual(project.id, "1d0304cd-3796-429f-b815-7258370b9b74")
-
- def test_populate_groups(self) -> None:
- self.server.version = "3.7"
- with open(POPULATE_GROUPS_XML, "rb") as f:
- response_xml = f.read().decode("utf-8")
- with requests_mock.mock() as m:
- m.get(self.server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/groups", text=response_xml)
- single_user = TSC.UserItem("test", "Interactor")
- single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
- self.server.users.populate_groups(single_user)
-
- group_list = list(single_user.groups)
-
- self.assertEqual(3, len(group_list))
- self.assertEqual("ef8b19c0-43b6-11e6-af50-63f5805dbe3c", group_list[0].id)
- self.assertEqual("All Users", group_list[0].name)
- self.assertEqual("local", group_list[0].domain_name)
-
- self.assertEqual("e7833b48-c6f7-47b5-a2a7-36e7dd232758", group_list[1].id)
- self.assertEqual("Another group", group_list[1].name)
- self.assertEqual("local", group_list[1].domain_name)
-
- self.assertEqual("86a66d40-f289-472a-83d0-927b0f954dc8", group_list[2].id)
- self.assertEqual("TableauExample", group_list[2].name)
- self.assertEqual("local", group_list[2].domain_name)
-
- 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)
- assert user_list[0].name == "Cassie", user_list
- assert failures == [], failures
-
- 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)
- assert users[0].name == "Cassie", users
- assert failures == []
-
- def test_get_users_all_fields(self) -> None:
- self.server.version = "3.7"
- baseurl = self.server.users.baseurl
- with open(GET_XML_ALL_FIELDS) as f:
- response_xml = f.read()
-
- with requests_mock.mock() as m:
- m.get(f"{baseurl}?fields=_all_", text=response_xml)
- all_users, _ = self.server.users.get()
-
- assert all_users[0].auth_setting == "TableauIDWithMFA"
- assert all_users[0].email == "bob@example.com"
- assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610"
- assert all_users[0].fullname == "Bob Smith"
- assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
- assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z")
- assert all_users[0].name == "bob@example.com"
- assert all_users[0].site_role == "SiteAdministratorCreator"
- assert all_users[0].locale is None
- assert all_users[0].language == "en"
- assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222"
- assert all_users[0].domain_name == "TABID_WITH_MFA"
- assert all_users[1].auth_setting == "TableauIDWithMFA"
- assert all_users[1].email == "alice@example.com"
- assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29"
- assert all_users[1].fullname == "Alice Jones"
- assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682"
- assert all_users[1].name == "alice@example.com"
- assert all_users[1].site_role == "ExplorerCanPublish"
- assert all_users[1].locale is None
- assert all_users[1].language == "en"
- assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222"
- assert all_users[1].domain_name == "TABID_WITH_MFA"
-
- def test_add_user_idp_configuration(self) -> None:
- with open(ADD_XML) as f:
- response_xml = f.read()
- user = TSC.UserItem(name="Cassie", site_role="Viewer")
- user.idp_configuration_id = "012345"
-
- with requests_mock.mock() as m:
- m.post(self.server.users.baseurl, text=response_xml)
- user = self.server.users.add(user)
-
- history = m.request_history[0]
-
- tree = ET.fromstring(history.text)
- user_elem = tree.find(".//user")
- assert user_elem is not None
- assert user_elem.attrib["idpConfigurationId"] == "012345"
-
- def test_update_user_idp_configuration(self) -> None:
- with open(ADD_XML) as f:
- response_xml = f.read()
- user = TSC.UserItem(name="Cassie", site_role="Viewer")
- user._id = "0123456789"
- user.idp_configuration_id = "012345"
-
- with requests_mock.mock() as m:
- m.put(f"{self.server.users.baseurl}/{user.id}", text=response_xml)
- user = self.server.users.update(user)
-
- history = m.request_history[0]
-
- tree = ET.fromstring(history.text)
- user_elem = tree.find(".//user")
- assert user_elem is not None
- assert user_elem.attrib["idpConfigurationId"] == "012345"
+ single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
+ server.users.populate_groups(single_user)
+
+ group_list = list(single_user.groups)
+
+ assert 3 == len(group_list)
+ assert "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" == group_list[0].id
+ assert "All Users" == group_list[0].name
+ assert "local" == group_list[0].domain_name
+
+ assert "e7833b48-c6f7-47b5-a2a7-36e7dd232758" == group_list[1].id
+ assert "Another group" == group_list[1].name
+ assert "local" == group_list[1].domain_name
+
+ assert "86a66d40-f289-472a-83d0-927b0f954dc8" == group_list[2].id
+ assert "TableauExample" == group_list[2].name
+ assert "local" == group_list[2].domain_name
+
+
+def test_get_usernames_from_file(server: TSC.Server):
+ response_xml = ADD_XML.read_text()
+ with requests_mock.mock() as m:
+ m.post(server.users.baseurl, text=response_xml)
+ user_list, failures = server.users.create_from_file(str(USERNAMES))
+ assert user_list[0].name == "Cassie", user_list
+ assert failures == [], failures
+
+
+def test_get_users_from_file(server: TSC.Server):
+ response_xml = ADD_XML.read_text()
+ with requests_mock.mock() as m:
+ m.post(server.users.baseurl, text=response_xml)
+ users, failures = server.users.create_from_file(str(USERS))
+ assert users[0].name == "Cassie", users
+ assert failures == []
+
+
+def test_get_users_all_fields(server: TSC.Server) -> None:
+ server.version = "3.7"
+ baseurl = server.users.baseurl
+ response_xml = GET_XML_ALL_FIELDS.read_text()
+
+ with requests_mock.mock() as m:
+ m.get(f"{baseurl}?fields=_all_", text=response_xml)
+ all_users, _ = server.users.get()
+
+ assert all_users[0].auth_setting == "TableauIDWithMFA"
+ assert all_users[0].email == "bob@example.com"
+ assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610"
+ assert all_users[0].fullname == "Bob Smith"
+ assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert all_users[0].name == "bob@example.com"
+ assert all_users[0].site_role == "SiteAdministratorCreator"
+ assert all_users[0].locale is None
+ assert all_users[0].language == "en"
+ assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222"
+ assert all_users[0].domain_name == "TABID_WITH_MFA"
+ assert all_users[1].auth_setting == "TableauIDWithMFA"
+ assert all_users[1].email == "alice@example.com"
+ assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29"
+ assert all_users[1].fullname == "Alice Jones"
+ assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682"
+ assert all_users[1].name == "alice@example.com"
+ assert all_users[1].site_role == "ExplorerCanPublish"
+ assert all_users[1].locale is None
+ assert all_users[1].language == "en"
+ assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222"
+ assert all_users[1].domain_name == "TABID_WITH_MFA"
+
+
+def test_add_user_idp_configuration(server: TSC.Server) -> None:
+ response_xml = ADD_XML.read_text()
+ user = TSC.UserItem(name="Cassie", site_role="Viewer")
+ user.idp_configuration_id = "012345"
+
+ with requests_mock.mock() as m:
+ m.post(server.users.baseurl, text=response_xml)
+ user = server.users.add(user)
+
+ history = m.request_history[0]
+
+ tree = ET.fromstring(history.text)
+ user_elem = tree.find(".//user")
+ assert user_elem is not None
+ assert user_elem.attrib["idpConfigurationId"] == "012345"
+
+
+def test_update_user_idp_configuration(server: TSC.Server) -> None:
+ response_xml = ADD_XML.read_text()
+ user = TSC.UserItem(name="Cassie", site_role="Viewer")
+ user._id = "0123456789"
+ user.idp_configuration_id = "012345"
+
+ with requests_mock.mock() as m:
+ m.put(f"{server.users.baseurl}/{user.id}", text=response_xml)
+ user = server.users.update(user)
+
+ history = m.request_history[0]
+
+ tree = ET.fromstring(history.text)
+ user_elem = tree.find(".//user")
+ assert user_elem is not None
+ assert user_elem.attrib["idpConfigurationId"] == "012345"
From 66d7b4f4035870ff3dff3640282d88320e11f912 Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Fri, 5 Dec 2025 10:38:00 -0600
Subject: [PATCH 26/27] chore: pytestify test_user_model
---
test/test_user_model.py | 247 +++++++++++++++++++++-------------------
1 file changed, 129 insertions(+), 118 deletions(-)
diff --git a/test/test_user_model.py b/test/test_user_model.py
index a8a2c51c..49e8dc25 100644
--- a/test/test_user_model.py
+++ b/test/test_user_model.py
@@ -1,5 +1,4 @@
import logging
-import unittest
from unittest.mock import *
import io
@@ -8,120 +7,132 @@
import tableauserverclient as TSC
-class UserModelTests(unittest.TestCase):
- def test_invalid_auth_setting(self):
- user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher)
- with self.assertRaises(ValueError):
- user.auth_setting = "Hello"
-
- def test_invalid_site_role(self):
- user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher)
- with self.assertRaises(ValueError):
- user.site_role = "Hello"
-
-
-class UserDataTest(unittest.TestCase):
- logger = logging.getLogger("UserDataTest")
-
- role_inputs = [
- ["creator", "system", "yes", "SiteAdministrator"],
- ["None", "system", "no", "SiteAdministrator"],
- ["explorer", "SysTEm", "no", "SiteAdministrator"],
- ["creator", "site", "yes", "SiteAdministratorCreator"],
- ["explorer", "site", "yes", "SiteAdministratorExplorer"],
- ["creator", "SITE", "no", "SiteAdministratorCreator"],
- ["creator", "none", "yes", "Creator"],
- ["explorer", "none", "yes", "ExplorerCanPublish"],
- ["viewer", "None", "no", "Viewer"],
- ["explorer", "no", "yes", "ExplorerCanPublish"],
- ["EXPLORER", "noNO", "yes", "ExplorerCanPublish"],
- ["explorer", "no", "no", "Explorer"],
- ["unlicensed", "none", "no", "Unlicensed"],
- ["Chef", "none", "yes", "Unlicensed"],
- ["yes", "yes", "yes", "Unlicensed"],
- ]
-
- valid_import_content = [
- "username, pword, fname, creator, site, yes, email",
- "username, pword, fname, explorer, none, no, email",
- "",
- "u",
- "p",
- ]
-
- valid_username_content = ["jfitzgerald@tableau.com"]
-
- usernames = [
- "valid",
- "valid@email.com",
- "domain/valid",
- "domain/valid@tmail.com",
- "va!@#$%^&*()lid",
- "in@v@lid",
- "in valid",
- "",
- ]
-
- def test_validate_usernames(self):
- TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[0])
- TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[1])
- TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[2])
- TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[3])
- TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[4])
- with self.assertRaises(AttributeError):
- TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[5])
- with self.assertRaises(AttributeError):
- TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[6])
-
- def test_evaluate_role(self):
- for line in UserDataTest.role_inputs:
- actual = TSC.UserItem.CSVImport._evaluate_site_role(line[0], line[1], line[2])
- assert actual == line[3], line + [actual]
-
- def test_get_user_detail_empty_line(self):
- test_line = ""
- test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line)
- assert test_user is None
-
- def test_get_user_detail_standard(self):
- test_line = "username, pword, fname, license, admin, pub, email"
- test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line)
- assert test_user.name == "username", test_user.name
- assert test_user.fullname == "fname", test_user.fullname
- assert test_user.site_role == "Unlicensed", test_user.site_role
- assert test_user.email == "email", test_user.email
-
- def test_get_user_details_only_username(self):
- test_line = "username"
- test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line)
-
- def test_populate_user_details_only_some(self):
- values = "username, , , creator, admin"
- user = TSC.UserItem.CSVImport.create_user_from_line(values)
- assert user.name == "username"
-
- def test_validate_user_detail_standard(self):
- test_line = "username, pword, fname, creator, site, 1, email"
- TSC.UserItem.CSVImport._validate_import_line_or_throw(test_line, UserDataTest.logger)
- TSC.UserItem.CSVImport.create_user_from_line(test_line)
-
- # for file handling
- def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper:
- # the empty string represents EOF
- # the tests run through the file twice, first to validate then to fetch
- mock = MagicMock(io.TextIOWrapper)
- content.append("") # EOF
- mock.readline.side_effect = content
- mock.name = "file-mock"
- return mock
-
- def test_validate_import_file(self):
- test_data = self._mock_file_content(UserDataTest.valid_import_content)
- valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger)
- assert valid == 2, f"Expected two lines to be parsed, got {valid}"
- assert invalid == [], f"Expected no failures, got {invalid}"
-
- def test_validate_usernames_file(self):
- test_data = self._mock_file_content(UserDataTest.usernames)
- valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger)
- assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + invalid}"
+def test_invalid_auth_setting():
+ user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher)
+ with pytest.raises(ValueError):
+ user.auth_setting = "Hello"
+
+
+def test_invalid_site_role():
+ user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher)
+ with pytest.raises(ValueError):
+ user.site_role = "Hello"
+
+
+logger = logging.getLogger("UserModelTest")
+
+
+role_inputs = [
+ ["creator", "system", "yes", "SiteAdministrator"],
+ ["None", "system", "no", "SiteAdministrator"],
+ ["explorer", "SysTEm", "no", "SiteAdministrator"],
+ ["creator", "site", "yes", "SiteAdministratorCreator"],
+ ["explorer", "site", "yes", "SiteAdministratorExplorer"],
+ ["creator", "SITE", "no", "SiteAdministratorCreator"],
+ ["creator", "none", "yes", "Creator"],
+ ["explorer", "none", "yes", "ExplorerCanPublish"],
+ ["viewer", "None", "no", "Viewer"],
+ ["explorer", "no", "yes", "ExplorerCanPublish"],
+ ["EXPLORER", "noNO", "yes", "ExplorerCanPublish"],
+ ["explorer", "no", "no", "Explorer"],
+ ["unlicensed", "none", "no", "Unlicensed"],
+ ["Chef", "none", "yes", "Unlicensed"],
+ ["yes", "yes", "yes", "Unlicensed"],
+]
+
+valid_import_content = [
+ "username, pword, fname, creator, site, yes, email",
+ "username, pword, fname, explorer, none, no, email",
+ "",
+ "u",
+ "p",
+]
+
+valid_username_content = ["jfitzgerald@tableau.com"]
+
+usernames = [
+ "valid",
+ "valid@email.com",
+ "domain/valid",
+ "domain/valid@tmail.com",
+ "va!@#$%^&*()lid",
+ "in@v@lid",
+ "in valid",
+ "",
+]
+
+
+def test_validate_usernames() -> None:
+ TSC.UserItem.validate_username_or_throw(usernames[0])
+ TSC.UserItem.validate_username_or_throw(usernames[1])
+ TSC.UserItem.validate_username_or_throw(usernames[2])
+ TSC.UserItem.validate_username_or_throw(usernames[3])
+ TSC.UserItem.validate_username_or_throw(usernames[4])
+ with pytest.raises(AttributeError):
+ TSC.UserItem.validate_username_or_throw(usernames[5])
+ with pytest.raises(AttributeError):
+ TSC.UserItem.validate_username_or_throw(usernames[6])
+
+
+def test_evaluate_role() -> None:
+ for line in role_inputs:
+ actual = TSC.UserItem.CSVImport._evaluate_site_role(line[0], line[1], line[2])
+ assert actual == line[3], line + [actual]
+
+
+def test_get_user_detail_empty_line() -> None:
+ test_line = ""
+ test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line)
+ assert test_user is None
+
+
+def test_get_user_detail_standard() -> None:
+ test_line = "username, pword, fname, license, admin, pub, email"
+ test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line)
+ assert test_user is not None
+ assert test_user.name == "username", test_user.name
+ assert test_user.fullname == "fname", test_user.fullname
+ assert test_user.site_role == "Unlicensed", test_user.site_role
+ assert test_user.email == "email", test_user.email
+
+
+def test_get_user_details_only_username() -> None:
+ test_line = "username"
+ test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line)
+
+
+def test_populate_user_details_only_some() -> None:
+ values = "username, , , creator, admin"
+ user = TSC.UserItem.CSVImport.create_user_from_line(values)
+ assert user is not None
+ assert user.name == "username"
+
+
+def test_validate_user_detail_standard() -> None:
+ test_line = "username, pword, fname, creator, site, 1, email"
+ TSC.UserItem.CSVImport._validate_import_line_or_throw(test_line, logger)
+ TSC.UserItem.CSVImport.create_user_from_line(test_line)
+
+
+# for file handling
+def _mock_file_content(content: list[str]) -> io.TextIOWrapper:
+ # the empty string represents EOF
+ # the tests run through the file twice, first to validate then to fetch
+ mock = MagicMock(io.TextIOWrapper)
+ content.append("") # EOF
+ mock.readline.side_effect = content
+ mock.name = "file-mock"
+ return mock
+
+
+def test_validate_import_file() -> None:
+ test_data = _mock_file_content(valid_import_content)
+ valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
+ assert valid == 2, f"Expected two lines to be parsed, got {valid}"
+ assert invalid == [], f"Expected no failures, got {invalid}"
+
+
+def test_validate_usernames_file() -> None:
+ test_data = _mock_file_content(usernames)
+ valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
+ assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + len(invalid)}"
From 6e95258a4596cdc55f8bff2360f76f2a1785872f Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Fri, 5 Dec 2025 10:54:11 -0600
Subject: [PATCH 27/27] style: black
---
test/test_user.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/test/test_user.py b/test/test_user.py
index 8462ff57..52c23fbf 100644
--- a/test/test_user.py
+++ b/test/test_user.py
@@ -286,6 +286,7 @@ def test_get_usernames_from_file(server: TSC.Server):
assert user_list[0].name == "Cassie", user_list
assert failures == [], failures
+
def test_get_users_from_file(server: TSC.Server):
response_xml = ADD_XML.read_text()
with requests_mock.mock() as m:
@@ -295,6 +296,7 @@ def test_get_users_from_file(server: TSC.Server):
assert users[0].name == "Cassie", users
assert failures == []
+
def test_get_users_all_fields(server: TSC.Server) -> None:
server.version = "3.7"
baseurl = server.users.baseurl
@@ -405,6 +407,7 @@ def test_create_users_csv() -> None:
assert admin_map.get(site_role, "") == csv_user["admin"]
assert publish_map[site_role] == int(csv_user["publish"])
+
def test_bulk_add(server: TSC.Server) -> None:
server.version = "3.15"
users = [
@@ -459,6 +462,7 @@ def test_bulk_add(server: TSC.Server) -> 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()
+
def test_bulk_add_no_name(server: TSC.Server) -> None:
server.version = "3.15"
users = [
@@ -470,6 +474,7 @@ def test_bulk_add_no_name(server: TSC.Server) -> None:
with pytest.raises(ValueError, match="User name must be populated."):
server.users.bulk_add(users)
+
def test_bulk_remove(server: TSC.Server) -> None:
server.version = "3.15"
users = [
@@ -514,6 +519,7 @@ def test_add_all(server: TSC.Server) -> None:
assert mock_add.call_count == len(users)
+
def test_add_idp_and_auth_error(server: TSC.Server) -> None:
server.version = "3.24"
users = [make_user("Alice", "Viewer", auth_setting="SAML", idp_id="01234")]
@@ -521,6 +527,7 @@ def test_add_idp_and_auth_error(server: TSC.Server) -> None:
with pytest.raises(ValueError, match="User cannot have both authSetting and idpConfigurationId."):
server.users.bulk_add(users)
+
def test_remove_users_csv(server: TSC.Server) -> None:
server.version = "3.15"
users = [