diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index d81907ae..10894485 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,14 +1,19 @@ +from collections.abc import Iterable import copy +import csv +import io +import itertools import logging from typing import Optional +import warnings 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 -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 @@ -344,7 +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]): + 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: @@ -357,8 +389,138 @@ 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: + """ + 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. + + 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. + + 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. + + 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 + 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="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 + + 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) + 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]]]: + """ + 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 = [] if not filepath.find("csv"): @@ -552,3 +714,117 @@ 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. The CSV will + have the following columns, 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. + """ + 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( + ( + f"{user.domain_name}\\{user.name}" if user.domain_name else user.name, + getattr(user, "password", ""), + user.fullname, + license, + admin_level, + publish, + user.email, + ) + ) + output.seek(0) + result = output.read().encode("utf-8") + return result + + +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: + 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 f7bd139d..cabd7059 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -931,6 +931,27 @@ def add_req(self, user_item: UserItem) -> bytes: user_element.attrib["authSetting"] = user_item.auth_setting return ET.tostring(xml_request) + def import_from_csv_req(self, csv_content: bytes, users: Iterable[UserItem]): + xml_request = ET.Element("tsRequest") + for user in users: + if user.name is None: + raise ValueError("User name must be populated.") + user_element = ET.SubElement(xml_request, "user") + user_element.attrib["name"] = user.name + user_element.attrib["authSetting"] = user.auth_setting or "ServerDefault" + + parts = { + "tableau_user_import": ("tsc_users_file.csv", csv_content, "file"), + "request_payload": ("", ET.tostring(xml_request), "text/xml"), + } + return _add_multipart(parts) + + 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/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 a4662484..0689043a 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,13 +1,21 @@ +import csv +import io import os +from pathlib import Path import unittest +from unittest.mock import patch +from defusedxml.ElementTree import fromstring +import pytest import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.users_endpoint import create_users_csv, remove_users_csv -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).resolve().parent / "assets" +BULK_ADD_XML = TEST_ASSET_DIR / "users_bulk_add_job.xml" GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml") @@ -21,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) @@ -221,7 +249,8 @@ def test_get_usernames_from_file(self): 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 @@ -230,6 +259,163 @@ def test_get_users_from_file(self): 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 == [] + + 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"), + 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()) + + job = self.server.users.bulk_add(users) + + assert m.last_request.method == "POST" + assert m.last_request.url == f"{self.server.users.baseurl}/import" + + body = m.last_request.body.replace(b"\r\n", b"\n") + assert body.startswith(b"--") # Check if it's a multipart request + boundary = body.split(b"\n")[0].strip() + + # Body starts and ends with a boundary string. Split the body into + # segments and ignore the empty sections at the start and end. + segments = [seg for s in body.split(boundary) if (seg := s.strip()) not in [b"", b"--"]] + assert len(segments) == 2 # Check if there are two segments + + # Check if the first segment is the csv file and the second segment is the xml + assert b'Content-Disposition: form-data; name="tableau_user_import"' in segments[0] + assert b'Content-Disposition: form-data; name="request_payload"' in segments[1] + assert b"Content-Type: file" in segments[0] + assert b"Content-Type: text/xml" in segments[1] + + xml_string = segments[1].split(b"\n\n")[1].strip() + xml = fromstring(xml_string) + xml_users = xml.findall(".//user", namespaces={}) + assert len(xml_users) == len(users) + + for user, xml_user in zip(users, xml_users): + assert user.name == xml_user.get("name") + assert xml_user.get("authSetting") == (user.auth_setting or "ServerDefault") + + 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" + 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) + + def test_bulk_remove(self): + self.server.version = "3.15" + users = [ + make_user("Alice"), + make_user("Bob", domain="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")): + 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)