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 = [