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 55f0c7ea2..0bab4f05d 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
+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
@@ -376,6 +378,123 @@ 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_users_with_default(
+ 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
+
+ @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
+ 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
+ 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)
+ 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.")
+ 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)
+ 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:
+ 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..2a8751f26 100644
--- a/tableauserverclient/server/endpoint/exceptions.py
+++ b/tableauserverclient/server/endpoint/exceptions.py
@@ -71,6 +71,30 @@ 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 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))
+
+
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_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/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 6cbe4b454..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"
@@ -20,6 +21,9 @@
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"
+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"
@@ -388,3 +392,220 @@ 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_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_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_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_users_with_default(view)
+
+ assert 2 == len(users)
+
+
+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_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_users_with_default_missing_id(server: TSC.Server) -> None:
+ with pytest.raises(TSC.MissingRequiredFieldError):
+ server.custom_views.get_users_with_default("")
+ with pytest.raises(TSC.MissingRequiredFieldError):
+ server.custom_views.get_users_with_default(TSC.CustomViewItem())
+
+
+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_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 source declares 207 (Multi-Status)
+ 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)
+
+
+@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 = (
+ ''
+ "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()])
+ with pytest.raises(TypeError):
+ server.custom_views.set_as_default_for_users(VIEW_ID, USER_A)
+ 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