From f4389551013809a1a3163e48d764312e1823aa95 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 7 Jul 2026 12:33:45 -0700 Subject: [PATCH 1/2] fix: add auth column to create_users_csv, fix create_from_file extension check and debug prints - create_users_csv was producing 7-column CSV, silently dropping auth_setting; bulk_add roundtrip would lose auth type - create_from_file extension check used filepath.find("csv") which evaluates as falsy only when "csv" is at index 0, letting all other paths through; fixed to "csv" not in filepath - remove two debug print() calls left in create_from_file Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/server/endpoint/users_endpoint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 48c77da66..b134bda73 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -527,7 +527,7 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning) created = [] failed = [] - if not filepath.find("csv"): + if "csv" not in filepath: raise ValueError("Only csv files are accepted") with open(filepath) as csv_file: @@ -536,11 +536,9 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us while line and line != "": user: UserItem = UserItem.CSVImport.create_user_from_line(line) try: - print(user) result = self.add(user) created.append(result) except ServerResponseError as serverError: - print("failed") failed.append((user, serverError)) line = csv_file.readline() return created, failed @@ -751,6 +749,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes: - Admin Level - Publish capability - Email + - Auth setting Parameters ---------- @@ -790,6 +789,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes: admin_level, publish, user.email, + user.auth_setting or "", ) ) output.seek(0) From 6402e1d9b9c0aa2a583d8411b8b2e100fa35d81f Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 7 Jul 2026 12:40:39 -0700 Subject: [PATCH 2/2] refactor: extract _decompose_site_role into CSVImport, symmetrical with _evaluate_site_role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the ad-hoc site role → (license, admin_level, publish) logic from create_users_csv into UserItem.CSVImport._decompose_site_role, making it the explicit inverse of _evaluate_site_role. Also fixes pre-existing bugs in the decomposition: - ExplorerCanPublish was emitted as license="ExplorerCanPublish" (not a valid CSV license value); now correctly "Explorer" with publish=1 - SiteAdministrator (legacy role) was emitting license="" via str.replace; now maps to ("Explorer", "Site", "1") - Non-admin roles now emit admin_level="None" (explicit CSV spec value) rather than "" (empty string); both are accepted by the server but "None" is consistent with the spec and _evaluate_site_role input Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/models/user_item.py | 21 +++++++++++++++++++ .../server/endpoint/users_endpoint.py | 17 +-------------- test/test_user.py | 5 +++-- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 05e0f5916..0f1aa57bf 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -548,6 +548,27 @@ def _validate_attribute_value(item: str, possible_values: list[str], column_type return raise AttributeError(f"Invalid value {item} for {column_type}") + # Inverse of _evaluate_site_role: decompose a site role back to (license, admin_level, publish) + # for writing the CSV import format. + @staticmethod + def _decompose_site_role(site_role: str) -> tuple[str, str, str]: + """Return (license, admin_level, publish) CSV column values for a given site role.""" + _role_map: dict[str, tuple[str, str, str]] = { + "ServerAdministrator": ("Creator", "System", "1"), + "SiteAdministratorCreator": ("Creator", "Site", "1"), + "SiteAdministratorExplorer": ("Explorer", "Site", "1"), + "SiteAdministrator": ("Explorer", "Site", "1"), # legacy role, treat as SiteAdministratorExplorer + "Creator": ("Creator", "None", "1"), + "ExplorerCanPublish": ("Explorer", "None", "1"), + "Explorer": ("Explorer", "None", "0"), + "Viewer": ("Viewer", "None", "0"), + "Unlicensed": ("Unlicensed", "None", "0"), + "ReadOnly": ("Viewer", "None", "0"), + "Publisher": ("Explorer", "None", "1"), + "Interactor": ("Explorer", "None", "0"), + } + return _role_map.get(site_role, ("Unlicensed", "None", "0")) + # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles # This logic is hardcoded to match the existing rules for import csv files @staticmethod diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index b134bda73..099dc3531 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -764,22 +764,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes: 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 - + license, admin_level, publish = UserItem.CSVImport._decompose_site_role(user.site_role or "Unlicensed") writer.writerow( ( f"{user.domain_name}\\{user.name}" if user.domain_name else user.name, diff --git a/test/test_user.py b/test/test_user.py index fec21d25f..316c2a6f1 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -405,7 +405,7 @@ def test_create_users_csv() -> None: "ServerAdministrator": "System", } - csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email"] + csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email", "auth"] csv_data = create_users_csv(users) csv_file = io.StringIO(csv_data.decode("utf-8")) csv_reader = csv.reader(csv_file) @@ -417,8 +417,9 @@ def test_create_users_csv() -> None: 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 admin_map.get(site_role, "None") == csv_user["admin"] assert publish_map[site_role] == int(csv_user["publish"]) + assert (user.auth_setting or "") == csv_user["auth"] def test_bulk_add(server: TSC.Server) -> None: