From 394f32148c32c8eb8790ff4239b7e2cb33b0d9f9 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 10 Nov 2026 19:36:45 -0800 Subject: [PATCH 1/3] Add CustomViews.get_default_users Wraps GET /customviews/{id}/default/users (API 3.21+, administrators only), which lists the users who have a custom view set as their default. Written with Claude Code --- .../server/endpoint/custom_views_endpoint.py | 46 ++++++++++++++++++- test/assets/custom_view_default_users.xml | 8 ++++ test/test_custom_view.py | 45 ++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 test/assets/custom_view_default_users.xml diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 55f0c7ea2..7820b46b3 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -10,7 +10,7 @@ from tableauserverclient.filesys_helpers import get_file_object_size from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError -from tableauserverclient.models import CustomViewItem, PaginationItem +from tableauserverclient.models import CustomViewItem, PaginationItem, UserItem from tableauserverclient.server import ( RequestFactory, RequestOptions, @@ -376,6 +376,50 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> CustomViewIte server_response = self.post_request(url, xml_request, content_type) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) + @api(version="3.21") + def get_default_users( + self, view_item: CustomViewItem | str, req_options: "RequestOptions | None" = None + ) -> tuple[list[UserItem], PaginationItem]: + """ + Get the users who have a custom view set as their default view. Only + users with an explicit default row for this custom view are returned. + + This is separate from the workbook's default view (WorkbookItem.default_view_id), + which is one value per workbook and the same for every user. + + Only Tableau administrators can call this method. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#list_users_with_custom_view_as_default + + Parameters + ---------- + view_item : CustomViewItem | str + The custom view, or its ID. + + req_options : RequestOptions, optional + Pagination options for the request, by default None + + Returns + ------- + tuple[list[UserItem], PaginationItem] + The users, each populated with only an ID, and the pagination details. + + Raises + ------ + MissingRequiredFieldError + If the custom view has no ID + """ + view_id = view_item.id if isinstance(view_item, CustomViewItem) else view_item + if not view_id: + error = "Custom view item missing ID." + raise MissingRequiredFieldError(error) + logger.info(f"Querying users with custom view (ID: {view_id}) as default") + url = f"{self.baseurl}/{view_id}/default/users" + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + users = UserItem.from_response(server_response.content, self.parent_srv.namespace) + return users, pagination_item + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> "QuerySet[CustomViewItem]": """ Queries the Tableau Server for items using the specified filters. Page diff --git a/test/assets/custom_view_default_users.xml b/test/assets/custom_view_default_users.xml new file mode 100644 index 000000000..6680fb144 --- /dev/null +++ b/test/assets/custom_view_default_users.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 6cbe4b454..fc46e6643 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -20,6 +20,7 @@ CUSTOM_VIEW_POPULATE_PDF = TEST_ASSET_DIR / "populate_pdf.pdf" CUSTOM_VIEW_POPULATE_CSV = TEST_ASSET_DIR / "populate_csv.csv" CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" +DEFAULT_USERS_XML = TEST_ASSET_DIR / "custom_view_default_users.xml" FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml" FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml" @@ -388,3 +389,47 @@ def test_pdf_height(server: TSC.Server) -> None: server.custom_views.populate_pdf(custom_view, req_option) assert response == custom_view.pdf + + +def test_get_default_users(server: TSC.Server) -> None: + view_id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + with requests_mock.mock() as m: + m.get(f"{server.custom_views.baseurl}/{view_id}/default/users", text=DEFAULT_USERS_XML.read_text()) + users, pagination_item = server.custom_views.get_default_users(view_id) + + assert 2 == pagination_item.total_available + assert ["5c9a81df-7ec0-4752-a057-82cd2bb22c44", "2a1d5dd2-6a64-4c3b-a4a9-2e5a8f8f0d11"] == [u.id for u in users] + + +def test_get_default_users_accepts_view_item(server: TSC.Server) -> None: + view = TSC.CustomViewItem(id="d79634e1-6063-4ec9-95ff-50acbf609ff5") + with requests_mock.mock() as m: + m.get(f"{server.custom_views.baseurl}/{view.id}/default/users", text=DEFAULT_USERS_XML.read_text()) + users, _ = server.custom_views.get_default_users(view) + + assert 2 == len(users) + + +def test_get_default_users_passes_pagination(server: TSC.Server) -> None: + view_id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + with requests_mock.mock() as m: + m.get(f"{server.custom_views.baseurl}/{view_id}/default/users", text=DEFAULT_USERS_XML.read_text()) + server.custom_views.get_default_users(view_id, TSC.RequestOptions(pagesize=50, pagenumber=2)) + + assert "pagesize=50" in m.request_history[0].query + assert "pagenumber=2" in m.request_history[0].query + + +def test_get_default_users_missing_id(server: TSC.Server) -> None: + with pytest.raises(TSC.MissingRequiredFieldError): + server.custom_views.get_default_users("") + with pytest.raises(TSC.MissingRequiredFieldError): + server.custom_views.get_default_users(TSC.CustomViewItem()) + + +def test_get_default_users_unavailable_before_3_21(server: TSC.Server) -> None: + server.version = "3.20" + with requests_mock.mock() as m: + with pytest.raises(TSC.server.exceptions.EndpointUnavailableError): + server.custom_views.get_default_users("d79634e1-6063-4ec9-95ff-50acbf609ff5") + assert m.call_count == 0 From 0659f06f2e2966ecd993c67f830e3fb265ec9b99 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 10 Nov 2026 20:24:20 -0800 Subject: [PATCH 2/3] Add CustomViews.set_as_default_for_users Wraps POST /customviews/{id}/default/users (API 3.21+, administrators only), which makes one custom view the default for up to 100 users. Returns the outcome for each user. strict=True raises DefaultViewAssignmentError if any user failed; users that succeeded are still updated. Accept any 2xx status, since this call returns 207 Multi-Status. Written with Claude Code --- tableauserverclient/__init__.py | 4 + tableauserverclient/models/__init__.py | 3 +- .../models/custom_view_item.py | 44 ++++++ tableauserverclient/server/__init__.py | 2 + .../server/endpoint/__init__.py | 7 +- .../server/endpoint/custom_views_endpoint.py | 74 ++++++++- .../server/endpoint/endpoint.py | 4 +- .../server/endpoint/exceptions.py | 21 +++ tableauserverclient/server/request_factory.py | 10 ++ .../custom_view_set_as_default_for_users.xml | 11 ++ ..._view_set_as_default_for_users_partial.xml | 14 ++ test/test_custom_view.py | 144 ++++++++++++++++-- 12 files changed, 320 insertions(+), 18 deletions(-) create mode 100644 test/assets/custom_view_set_as_default_for_users.xml create mode 100644 test/assets/custom_view_set_as_default_for_users_partial.xml diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 819cfcfa6..227255757 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -9,6 +9,7 @@ ColumnItem, ConnectionCredentials, ConnectionItem, + UserDefaultViewAssignment, CustomViewItem, DQWItem, DailyInterval, @@ -69,6 +70,7 @@ PPTXRequestOptions, RequestOptions, MissingRequiredFieldError, + DefaultViewAssignmentError, FailedSignInError, FlowRunCancelledException, FlowRunFailedException, @@ -92,6 +94,7 @@ "ConnectionCredentials", "ConnectionItem", "CSVRequestOptions", + "UserDefaultViewAssignment", "CustomViewItem", "DailyInterval", "DataAlertItem", @@ -127,6 +130,7 @@ "LocationItem", "MetricItem", "MissingRequiredFieldError", + "DefaultViewAssignmentError", "MonthlyInterval", "NotSignedInError", "Pager", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 5eb79f8e8..238288052 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -7,7 +7,7 @@ from tableauserverclient.models.column_item import ColumnItem from tableauserverclient.models.connection_credentials import ConnectionCredentials from tableauserverclient.models.connection_item import ConnectionItem -from tableauserverclient.models.custom_view_item import CustomViewItem +from tableauserverclient.models.custom_view_item import UserDefaultViewAssignment, CustomViewItem from tableauserverclient.models.data_acceleration_report_item import DataAccelerationReportItem from tableauserverclient.models.data_alert_item import DataAlertItem from tableauserverclient.models.database_item import DatabaseItem @@ -68,6 +68,7 @@ "ConnectionCredentials", "ConnectionItem", "Credentials", + "UserDefaultViewAssignment", "CustomViewItem", "DataAccelerationReportItem", "DataAlertItem", diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index d20df89e9..f389015e3 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from datetime import datetime from defusedxml import ElementTree @@ -12,6 +13,49 @@ from tableauserverclient.datetime_helpers import parse_datetime +@dataclass(frozen=True) +class UserDefaultViewAssignment: + """ + The outcome for one user from CustomViews.set_as_default_for_users. + + Attributes + ---------- + user_id : str | None + The ID of the user. + + success : bool + Whether the custom view was set as the user's default. + + error_code : str | None + The REST API error code, when success is False. + + summary : str | None + The server's description of the failure, when success is False. + """ + + user_id: str | None + success: bool + error_code: str | None = None + summary: str | None = None + + @classmethod + def list_from_response(cls, resp, ns) -> list["UserDefaultViewAssignment"]: + results = [] + for result_xml in fromstring(resp).findall(".//t:customViewAsUserDefaultViewResult", namespaces=ns): + user_xml = result_xml.find("t:user", namespaces=ns) + error_xml = result_xml.find("t:error", namespaces=ns) + summary_xml = error_xml.find("t:summary", namespaces=ns) if error_xml is not None else None + results.append( + cls( + user_id=user_xml.get("id") if user_xml is not None else None, + success=result_xml.get("success", "").lower() == "true", + error_code=error_xml.get("code") if error_xml is not None else None, + summary=summary_xml.text if summary_xml is not None else None, + ) + ) + return results + + class CustomViewItem: """ Represents a Custom View item on Tableau Server. diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 44020419b..4410b4a36 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -30,6 +30,7 @@ Datasources, QuerysetEndpoint, MissingRequiredFieldError, + DefaultViewAssignmentError, Endpoint, Favorites, Fileuploads, @@ -80,6 +81,7 @@ "Datasources", "QuerysetEndpoint", "MissingRequiredFieldError", + "DefaultViewAssignmentError", "Endpoint", "Favorites", "Fileuploads", diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index de2f7e8a1..5ac16bd42 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -6,7 +6,11 @@ from tableauserverclient.server.endpoint.databases_endpoint import Databases from tableauserverclient.server.endpoint.datasources_endpoint import Datasources from tableauserverclient.server.endpoint.endpoint import Endpoint, QuerysetEndpoint, DownloadableMixin -from tableauserverclient.server.endpoint.exceptions import ServerResponseError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.exceptions import ( + DefaultViewAssignmentError, + ServerResponseError, + MissingRequiredFieldError, +) from tableauserverclient.server.endpoint.extensions_endpoint import Extensions from tableauserverclient.server.endpoint.favorites_endpoint import Favorites from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads @@ -45,6 +49,7 @@ "DownloadableMixin", "QuerysetEndpoint", "MissingRequiredFieldError", + "DefaultViewAssignmentError", "Endpoint", "Extensions", "Favorites", diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 7820b46b3..46874c7ee 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -4,13 +4,13 @@ from contextlib import closing from pathlib import Path from typing import TYPE_CHECKING -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.filesys_helpers import get_file_object_size from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api -from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError -from tableauserverclient.models import CustomViewItem, PaginationItem, UserItem +from tableauserverclient.server.endpoint.exceptions import DefaultViewAssignmentError, MissingRequiredFieldError +from tableauserverclient.models import UserDefaultViewAssignment, CustomViewItem, PaginationItem, UserItem from tableauserverclient.server import ( RequestFactory, RequestOptions, @@ -32,6 +32,8 @@ update the name or owner of a custom view. """ +MAX_DEFAULT_USERS_PER_REQUEST = 100 + FilePath = str | os.PathLike FileObject = io.BufferedReader | io.BytesIO FileObjectR = io.BufferedReader | io.BytesIO @@ -377,7 +379,7 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> CustomViewIte return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="3.21") - def get_default_users( + def get_users_with_default( self, view_item: CustomViewItem | str, req_options: "RequestOptions | None" = None ) -> tuple[list[UserItem], PaginationItem]: """ @@ -420,6 +422,70 @@ def get_default_users( users = UserItem.from_response(server_response.content, self.parent_srv.namespace) return users, pagination_item + @api(version="3.21") + def set_as_default_for_users( + self, view_item: CustomViewItem | str, users: Iterable[UserItem | str], strict: bool = False + ) -> list[UserDefaultViewAssignment]: + """ + Set a custom view as the default view for up to 100 users. + + The server applies each user independently, so some users can succeed while others fail + (for example, a user without read permission on the custom view). The outcome for each user + is returned. A request that the server rejects outright, such as one made without administrator + permissions, raises ServerResponseError as usual. + + Only Tableau administrators can call this method. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#set_custom_view_as_default_for_users + + Parameters + ---------- + view_item : CustomViewItem | str + The custom view, or its ID. + + users : Iterable[UserItem | str] + The users, or their IDs. At most 100. + + strict : bool, optional + If True, raise DefaultViewAssignmentError after the request when any user failed. + Users that succeeded are still updated: the call is not all-or-nothing. By default + failures are only reported in the returned results. + + Returns + ------- + list[UserDefaultViewAssignment] + + Raises + ------ + MissingRequiredFieldError + If the custom view has no ID + ValueError + If there are no users, more than 100 users, or a user without an ID + DefaultViewAssignmentError + If strict is True and any user failed + """ + view_id = view_item.id if isinstance(view_item, CustomViewItem) else view_item + if not view_id: + error = "Custom view item missing ID." + raise MissingRequiredFieldError(error) + user_list = list(users) + if not user_list: + raise ValueError("At least one user is required.") + if len(user_list) > MAX_DEFAULT_USERS_PER_REQUEST: + raise ValueError(f"At most {MAX_DEFAULT_USERS_PER_REQUEST} users can be set per request.") + + url = f"{self.baseurl}/{view_id}/default/users" + request = RequestFactory.CustomView.set_as_default_for_users_req(user_list) + server_response = self.post_request(url, request) + results = UserDefaultViewAssignment.list_from_response(server_response.content, self.parent_srv.namespace) + failures = [result for result in results if not result.success] + logger.info(f"Set custom view (ID: {view_id}) as default for {len(results) - len(failures)} user(s)") + if failures: + logger.warning(f"Could not set custom view (ID: {view_id}) as default for {len(failures)} user(s)") + if strict: + raise DefaultViewAssignmentError(failures) + return results + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> "QuerySet[CustomViewItem]": """ Queries the Tableau Server for items using the specified filters. Page diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 10d3cba84..4b613893d 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -46,7 +46,9 @@ from requests import Response -Success_codes = [200, 201, 202, 204] +# Any 2xx is a success. Some calls use less common codes, for example 207 from Set Custom View as +# Default for Users, which reports success per user in the body. +Success_codes = range(200, 300) # 301/302/303/307/308 all indicate the caller should re-request at a new URL. # `requests`' default handler converts POST -> GET on 301/302/303, which drops diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 2a94a2969..73c2ed849 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -71,6 +71,27 @@ class MissingRequiredFieldError(TableauError): pass +class DefaultViewAssignmentError(TableauError): + """ + Raised by CustomViews.set_as_default_for_users(strict=True) when the server could not set the + custom view as the default for one or more users. The server applies each user + independently, so users not listed in `failures` have already been updated. + + Attributes + ---------- + failures : list[UserDefaultViewAssignment] + The per-user results that were not successful. + """ + + def __init__(self, failures: list) -> None: + self.failures = failures + super().__init__(str(self)) + + def __str__(self): + lines = [f"\t{f.user_id}: {f.error_code} {f.summary}" for f in self.failures] + return "\n\nCould not set the custom view as default for {} user(s):\n{}".format(len(lines), "\n".join(lines)) + + class NotSignedInError(TableauError): pass diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 517a26956..a9f41cb86 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1520,6 +1520,16 @@ def _publish_xml(self, xml_request: ET.Element, custom_view_item: CustomViewItem return ET.tostring(xml_request) + @_tsrequest_wrapped + def set_as_default_for_users_req(self, xml_request: ET.Element, users: Iterable[str | UserItem]) -> bytes: + users_element = ET.SubElement(xml_request, "users") + for user in users: + if not (user_id := user.id if isinstance(user, UserItem) else user): + raise ValueError("User ID must be populated") + ET.SubElement(users_element, "user", {"id": user_id}) + + return ET.tostring(xml_request) + def publish_req_chunked(self, custom_view_item: CustomViewItem): xml_request = self._publish_xml(custom_view_item) parts = {"request_payload": ("", xml_request, "text/xml")} diff --git a/test/assets/custom_view_set_as_default_for_users.xml b/test/assets/custom_view_set_as_default_for_users.xml new file mode 100644 index 000000000..ab837c285 --- /dev/null +++ b/test/assets/custom_view_set_as_default_for_users.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/assets/custom_view_set_as_default_for_users_partial.xml b/test/assets/custom_view_set_as_default_for_users_partial.xml new file mode 100644 index 000000000..5f96de843 --- /dev/null +++ b/test/assets/custom_view_set_as_default_for_users_partial.xml @@ -0,0 +1,14 @@ + + + + + + + + + + User '2a1d5dd2-6a64-4c3b-a4a9-2e5a8f8f0d11' has insufficient permission to the custom view 'd79634e1-6063-4ec9-95ff-50acbf609ff5' to set it as default view. + + + + diff --git a/test/test_custom_view.py b/test/test_custom_view.py index fc46e6643..ccbe8c24b 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -21,6 +21,8 @@ CUSTOM_VIEW_POPULATE_CSV = TEST_ASSET_DIR / "populate_csv.csv" CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" DEFAULT_USERS_XML = TEST_ASSET_DIR / "custom_view_default_users.xml" +SET_AS_DEFAULT_FOR_USERS_XML = TEST_ASSET_DIR / "custom_view_set_as_default_for_users.xml" +SET_AS_DEFAULT_FOR_USERS_PARTIAL_XML = TEST_ASSET_DIR / "custom_view_set_as_default_for_users_partial.xml" FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml" FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml" @@ -391,45 +393,165 @@ def test_pdf_height(server: TSC.Server) -> None: assert response == custom_view.pdf -def test_get_default_users(server: TSC.Server) -> None: +def test_get_users_with_default(server: TSC.Server) -> None: view_id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" with requests_mock.mock() as m: m.get(f"{server.custom_views.baseurl}/{view_id}/default/users", text=DEFAULT_USERS_XML.read_text()) - users, pagination_item = server.custom_views.get_default_users(view_id) + users, pagination_item = server.custom_views.get_users_with_default(view_id) assert 2 == pagination_item.total_available assert ["5c9a81df-7ec0-4752-a057-82cd2bb22c44", "2a1d5dd2-6a64-4c3b-a4a9-2e5a8f8f0d11"] == [u.id for u in users] -def test_get_default_users_accepts_view_item(server: TSC.Server) -> None: +def test_get_users_with_default_accepts_view_item(server: TSC.Server) -> None: view = TSC.CustomViewItem(id="d79634e1-6063-4ec9-95ff-50acbf609ff5") with requests_mock.mock() as m: m.get(f"{server.custom_views.baseurl}/{view.id}/default/users", text=DEFAULT_USERS_XML.read_text()) - users, _ = server.custom_views.get_default_users(view) + users, _ = server.custom_views.get_users_with_default(view) assert 2 == len(users) -def test_get_default_users_passes_pagination(server: TSC.Server) -> None: +def test_get_users_with_default_passes_pagination(server: TSC.Server) -> None: view_id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" with requests_mock.mock() as m: m.get(f"{server.custom_views.baseurl}/{view_id}/default/users", text=DEFAULT_USERS_XML.read_text()) - server.custom_views.get_default_users(view_id, TSC.RequestOptions(pagesize=50, pagenumber=2)) + server.custom_views.get_users_with_default(view_id, TSC.RequestOptions(pagesize=50, pagenumber=2)) assert "pagesize=50" in m.request_history[0].query assert "pagenumber=2" in m.request_history[0].query -def test_get_default_users_missing_id(server: TSC.Server) -> None: +def test_get_users_with_default_missing_id(server: TSC.Server) -> None: with pytest.raises(TSC.MissingRequiredFieldError): - server.custom_views.get_default_users("") + server.custom_views.get_users_with_default("") with pytest.raises(TSC.MissingRequiredFieldError): - server.custom_views.get_default_users(TSC.CustomViewItem()) + server.custom_views.get_users_with_default(TSC.CustomViewItem()) -def test_get_default_users_unavailable_before_3_21(server: TSC.Server) -> None: +def test_get_users_with_default_unavailable_before_3_21(server: TSC.Server) -> None: server.version = "3.20" with requests_mock.mock() as m: with pytest.raises(TSC.server.exceptions.EndpointUnavailableError): - server.custom_views.get_default_users("d79634e1-6063-4ec9-95ff-50acbf609ff5") + server.custom_views.get_users_with_default("d79634e1-6063-4ec9-95ff-50acbf609ff5") + assert m.call_count == 0 + + +VIEW_ID = "d79634e1-6063-4ec9-95ff-50acbf609ff5" +USER_A = "5c9a81df-7ec0-4752-a057-82cd2bb22c44" +USER_B = "2a1d5dd2-6a64-4c3b-a4a9-2e5a8f8f0d11" + + +def test_set_as_default_for_users(server: TSC.Server) -> None: + user_b = TSC.UserItem() + user_b._id = USER_B + with requests_mock.mock() as m: + m.post( + f"{server.custom_views.baseurl}/{VIEW_ID}/default/users", + text=SET_AS_DEFAULT_FOR_USERS_XML.read_text(), + status_code=207, + ) + results = server.custom_views.set_as_default_for_users(VIEW_ID, [USER_A, user_b]) + + assert f'' in m.request_history[0].text + assert f'' in m.request_history[0].text + assert [USER_A, USER_B] == [r.user_id for r in results] + assert all(r.success for r in results) + assert all(r.error_code is None and r.summary is None for r in results) + + +def test_set_as_default_for_users_partial_failure_is_reported(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.post( + f"{server.custom_views.baseurl}/{VIEW_ID}/default/users", + text=SET_AS_DEFAULT_FOR_USERS_PARTIAL_XML.read_text(), + status_code=207, + ) + results = server.custom_views.set_as_default_for_users(TSC.CustomViewItem(id=VIEW_ID), [USER_A, USER_B]) + + assert [True, False] == [r.success for r in results] + assert "403167" == results[1].error_code + assert results[1].summary is not None and "insufficient permission" in results[1].summary + + +def test_set_as_default_for_users_strict_raises_on_failure(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.post( + f"{server.custom_views.baseurl}/{VIEW_ID}/default/users", + text=SET_AS_DEFAULT_FOR_USERS_PARTIAL_XML.read_text(), + status_code=207, + ) + with pytest.raises(TSC.DefaultViewAssignmentError) as excinfo: + server.custom_views.set_as_default_for_users(VIEW_ID, [USER_A, USER_B], strict=True) + + assert [USER_B] == [f.user_id for f in excinfo.value.failures] + assert "403167" in str(excinfo.value) + + +def test_set_as_default_for_users_strict_passes_when_all_succeed(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.post( + f"{server.custom_views.baseurl}/{VIEW_ID}/default/users", + text=SET_AS_DEFAULT_FOR_USERS_XML.read_text(), + status_code=207, + ) + results = server.custom_views.set_as_default_for_users(VIEW_ID, [USER_A, USER_B], strict=True) + + assert 2 == len(results) + + +def test_set_as_default_for_users_accepts_200(server: TSC.Server) -> None: + # the REST docs list 200 for this call; the server implementation returns 207 + with requests_mock.mock() as m: + m.post( + f"{server.custom_views.baseurl}/{VIEW_ID}/default/users", + text=SET_AS_DEFAULT_FOR_USERS_XML.read_text(), + status_code=200, + ) + results = server.custom_views.set_as_default_for_users(VIEW_ID, [USER_A, USER_B]) + + assert 2 == len(results) + + +def test_set_as_default_for_users_forbidden_raises_server_error(server: TSC.Server) -> None: + error_xml = ( + '' + "ForbiddenNot an administrator." + ) + with requests_mock.mock() as m: + m.post(f"{server.custom_views.baseurl}/{VIEW_ID}/default/users", text=error_xml, status_code=403) + with pytest.raises(TSC.ServerResponseError): + server.custom_views.set_as_default_for_users(VIEW_ID, [USER_A]) + + +def test_set_as_default_for_users_validates_input(server: TSC.Server) -> None: + with requests_mock.mock() as m: + with pytest.raises(TSC.MissingRequiredFieldError): + server.custom_views.set_as_default_for_users("", [USER_A]) + with pytest.raises(ValueError): + server.custom_views.set_as_default_for_users(VIEW_ID, []) + with pytest.raises(ValueError): + server.custom_views.set_as_default_for_users(VIEW_ID, [f"user-{i}" for i in range(101)]) + with pytest.raises(ValueError): + server.custom_views.set_as_default_for_users(VIEW_ID, [TSC.UserItem()]) + assert m.call_count == 0 + + +def test_set_as_default_for_users_allows_100_users(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.post( + f"{server.custom_views.baseurl}/{VIEW_ID}/default/users", + text=SET_AS_DEFAULT_FOR_USERS_XML.read_text(), + status_code=207, + ) + server.custom_views.set_as_default_for_users(VIEW_ID, [f"user-{i}" for i in range(100)]) + + assert m.call_count == 1 + + +def test_set_as_default_for_users_unavailable_before_3_21(server: TSC.Server) -> None: + server.version = "3.20" + with requests_mock.mock() as m: + with pytest.raises(TSC.server.exceptions.EndpointUnavailableError): + server.custom_views.set_as_default_for_users(VIEW_ID, [USER_A]) assert m.call_count == 0 From ae5d56634d242ba65d2768f4eabb87b8b7eee523 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 11 Nov 2026 14:15:38 -0800 Subject: [PATCH 3/3] Harden set_as_default_for_users input and result handling - Reject a bare string for `users` with TypeError; list() would split it into one-character user IDs. - Warn when the server reports a different number of results than users requested. - DefaultViewAssignmentError no longer prints "None" for results that lack a user ID or error details. - Add tests for results without a user or error, a missing success attribute, an empty result list, and the Success_codes boundaries. Written with Claude Code --- .../server/endpoint/custom_views_endpoint.py | 9 +++ .../server/endpoint/exceptions.py | 5 +- test/test_custom_view.py | 56 ++++++++++++++++++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 46874c7ee..0bab4f05d 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -459,6 +459,8 @@ def set_as_default_for_users( ------ MissingRequiredFieldError If the custom view has no ID + TypeError + If users is a single string rather than a collection ValueError If there are no users, more than 100 users, or a user without an ID DefaultViewAssignmentError @@ -468,6 +470,8 @@ def set_as_default_for_users( if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) + if isinstance(users, str): + raise TypeError("users must be a collection of user IDs or UserItems, not a single string.") user_list = list(users) if not user_list: raise ValueError("At least one user is required.") @@ -478,6 +482,11 @@ def set_as_default_for_users( request = RequestFactory.CustomView.set_as_default_for_users_req(user_list) server_response = self.post_request(url, request) results = UserDefaultViewAssignment.list_from_response(server_response.content, self.parent_srv.namespace) + if len(results) != len(user_list): + logger.warning( + f"Requested custom view (ID: {view_id}) as default for {len(user_list)} user(s) " + f"but the server reported {len(results)} result(s)" + ) failures = [result for result in results if not result.success] logger.info(f"Set custom view (ID: {view_id}) as default for {len(results) - len(failures)} user(s)") if failures: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 73c2ed849..2a8751f26 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -88,7 +88,10 @@ def __init__(self, failures: list) -> None: super().__init__(str(self)) def __str__(self): - lines = [f"\t{f.user_id}: {f.error_code} {f.summary}" for f in self.failures] + lines = [ + f"\t{f.user_id or 'unknown user'}: {' '.join(part for part in (f.error_code, f.summary) if part) or 'no error details'}" + for f in self.failures + ] return "\n\nCould not set the custom view as default for {} user(s):\n{}".format(len(lines), "\n".join(lines)) diff --git a/test/test_custom_view.py b/test/test_custom_view.py index ccbe8c24b..d8a590691 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -10,6 +10,7 @@ import tableauserverclient as TSC from tableauserverclient.config import BYTES_PER_MB from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.endpoint import Success_codes TEST_ASSET_DIR = Path(__file__).parent / "assets" @@ -501,7 +502,7 @@ def test_set_as_default_for_users_strict_passes_when_all_succeed(server: TSC.Ser def test_set_as_default_for_users_accepts_200(server: TSC.Server) -> None: - # the REST docs list 200 for this call; the server implementation returns 207 + # the REST docs list 200 for this call; the server source declares 207 (Multi-Status) with requests_mock.mock() as m: m.post( f"{server.custom_views.baseurl}/{VIEW_ID}/default/users", @@ -513,6 +514,57 @@ def test_set_as_default_for_users_accepts_200(server: TSC.Server) -> None: assert 2 == len(results) +@pytest.mark.parametrize("code", [199, 300, 400, 500]) +def test_non_2xx_status_is_not_success(code: int) -> None: + assert code not in Success_codes + + +@pytest.mark.parametrize("code", [200, 201, 204, 207, 299]) +def test_2xx_status_is_success(code: int) -> None: + assert code in Success_codes + + +def test_set_as_default_for_users_result_without_user_or_error_details(server: TSC.Server) -> None: + body = ( + '' + '' + "" + ) + with requests_mock.mock() as m: + m.post(f"{server.custom_views.baseurl}/{VIEW_ID}/default/users", text=body, status_code=207) + results = server.custom_views.set_as_default_for_users(VIEW_ID, [USER_A]) + with pytest.raises(TSC.DefaultViewAssignmentError) as excinfo: + server.custom_views.set_as_default_for_users(VIEW_ID, [USER_A], strict=True) + + assert [(None, False, None, None)] == [(r.user_id, r.success, r.error_code, r.summary) for r in results] + assert "unknown user: no error details" in str(excinfo.value) + assert "None" not in str(excinfo.value) + + +def test_set_as_default_for_users_success_missing_attribute_is_failure(server: TSC.Server) -> None: + body = ( + '' + f'' + "" + ) + with requests_mock.mock() as m: + m.post(f"{server.custom_views.baseurl}/{VIEW_ID}/default/users", text=body, status_code=207) + results = server.custom_views.set_as_default_for_users(VIEW_ID, [USER_A]) + + assert [(USER_A, False)] == [(r.user_id, r.success) for r in results] + + +def test_set_as_default_for_users_empty_results_warns(server: TSC.Server, caplog: pytest.LogCaptureFixture) -> None: + body = '' + with requests_mock.mock() as m: + m.post(f"{server.custom_views.baseurl}/{VIEW_ID}/default/users", text=body, status_code=207) + with caplog.at_level("WARNING"): + results = server.custom_views.set_as_default_for_users(VIEW_ID, [USER_A, USER_B]) + + assert [] == results + assert "server reported 0 result(s)" in caplog.text + + def test_set_as_default_for_users_forbidden_raises_server_error(server: TSC.Server) -> None: error_xml = ( '' @@ -534,6 +586,8 @@ def test_set_as_default_for_users_validates_input(server: TSC.Server) -> None: server.custom_views.set_as_default_for_users(VIEW_ID, [f"user-{i}" for i in range(101)]) with pytest.raises(ValueError): server.custom_views.set_as_default_for_users(VIEW_ID, [TSC.UserItem()]) + with pytest.raises(TypeError): + server.custom_views.set_as_default_for_users(VIEW_ID, USER_A) assert m.call_count == 0