Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions tableauserverclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ColumnItem,
ConnectionCredentials,
ConnectionItem,
UserDefaultViewAssignment,
CustomViewItem,
DQWItem,
DailyInterval,
Expand Down Expand Up @@ -69,6 +70,7 @@
PPTXRequestOptions,
RequestOptions,
MissingRequiredFieldError,
DefaultViewAssignmentError,
FailedSignInError,
FlowRunCancelledException,
FlowRunFailedException,
Expand All @@ -92,6 +94,7 @@
"ConnectionCredentials",
"ConnectionItem",
"CSVRequestOptions",
"UserDefaultViewAssignment",
"CustomViewItem",
"DailyInterval",
"DataAlertItem",
Expand Down Expand Up @@ -127,6 +130,7 @@
"LocationItem",
"MetricItem",
"MissingRequiredFieldError",
"DefaultViewAssignmentError",
"MonthlyInterval",
"NotSignedInError",
"Pager",
Expand Down
3 changes: 2 additions & 1 deletion tableauserverclient/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,6 +68,7 @@
"ConnectionCredentials",
"ConnectionItem",
"Credentials",
"UserDefaultViewAssignment",
"CustomViewItem",
"DataAccelerationReportItem",
"DataAlertItem",
Expand Down
44 changes: 44 additions & 0 deletions tableauserverclient/models/custom_view_item.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import datetime

from defusedxml import ElementTree
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions tableauserverclient/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
Datasources,
QuerysetEndpoint,
MissingRequiredFieldError,
DefaultViewAssignmentError,
Endpoint,
Favorites,
Fileuploads,
Expand Down Expand Up @@ -80,6 +81,7 @@
"Datasources",
"QuerysetEndpoint",
"MissingRequiredFieldError",
"DefaultViewAssignmentError",
"Endpoint",
"Favorites",
"Fileuploads",
Expand Down
7 changes: 6 additions & 1 deletion tableauserverclient/server/endpoint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,6 +49,7 @@
"DownloadableMixin",
"QuerysetEndpoint",
"MissingRequiredFieldError",
"DefaultViewAssignmentError",
"Endpoint",
"Extensions",
"Favorites",
Expand Down
125 changes: 122 additions & 3 deletions tableauserverclient/server/endpoint/custom_views_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tableauserverclient/server/endpoint/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions tableauserverclient/server/endpoint/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions tableauserverclient/server/request_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
Expand Down
8 changes: 8 additions & 0 deletions test/assets/custom_view_default_users.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-3.21.xsd">
<pagination pageNumber="1" pageSize="100" totalAvailable="2" />
<users>
<user id="5c9a81df-7ec0-4752-a057-82cd2bb22c44" />
<user id="2a1d5dd2-6a64-4c3b-a4a9-2e5a8f8f0d11" />
</users>
</tsResponse>
11 changes: 11 additions & 0 deletions test/assets/custom_view_set_as_default_for_users.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-3.21.xsd">
<customViewAsUserDefaultResults>
<customViewAsUserDefaultViewResult success="true">
<user id="5c9a81df-7ec0-4752-a057-82cd2bb22c44" />
</customViewAsUserDefaultViewResult>
<customViewAsUserDefaultViewResult success="true">
<user id="2a1d5dd2-6a64-4c3b-a4a9-2e5a8f8f0d11" />
</customViewAsUserDefaultViewResult>
</customViewAsUserDefaultResults>
</tsResponse>
14 changes: 14 additions & 0 deletions test/assets/custom_view_set_as_default_for_users_partial.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-3.21.xsd">
<customViewAsUserDefaultResults>
<customViewAsUserDefaultViewResult success="true">
<user id="5c9a81df-7ec0-4752-a057-82cd2bb22c44" />
</customViewAsUserDefaultViewResult>
<customViewAsUserDefaultViewResult success="false">
<user id="2a1d5dd2-6a64-4c3b-a4a9-2e5a8f8f0d11" />
<error code="403167">
<summary>User '2a1d5dd2-6a64-4c3b-a4a9-2e5a8f8f0d11' has insufficient permission to the custom view 'd79634e1-6063-4ec9-95ff-50acbf609ff5' to set it as default view.</summary>
</error>
</customViewAsUserDefaultViewResult>
</customViewAsUserDefaultResults>
</tsResponse>
Loading
Loading