diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 957a820db..ad08e8ded 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -25,6 +25,7 @@ LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem, + LocationItem, MetricItem, MonthlyInterval, PaginationItem, @@ -101,6 +102,7 @@ "LinkedTaskFlowRunItem", "LinkedTaskItem", "LinkedTaskStepItem", + "LocationItem", "MetricItem", "MissingRequiredFieldError", "MonthlyInterval", diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index 75534103b..6ba4e48d9 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -1,6 +1,6 @@ from defusedxml.ElementTree import fromstring, tostring from functools import singledispatch -from typing import TypeVar +from typing import TypeVar, overload # the redact method can handle either strings or bytes, but it can't mix them. @@ -41,3 +41,27 @@ def _(xml: str) -> str: @redact_xml.register # type: ignore[no-redef] def _(xml: bytes) -> bytes: return _redact_any_type(bytearray(xml), b"password", b"..[redacted]") + + +@overload +def nullable_str_to_int(value: None) -> None: ... + + +@overload +def nullable_str_to_int(value: str) -> int: ... + + +def nullable_str_to_int(value): + return int(value) if value is not None else None + + +@overload +def nullable_str_to_bool(value: None) -> None: ... + + +@overload +def nullable_str_to_bool(value: str) -> bool: ... + + +def nullable_str_to_bool(value): + return str(value).lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e4131b720..5ef81dcf1 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -28,6 +28,7 @@ LinkedTaskStepItem, LinkedTaskFlowRunItem, ) +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission @@ -75,6 +76,7 @@ "MonthlyInterval", "HourlyInterval", "BackgroundJobItem", + "LocationItem", "MetricItem", "PaginationItem", "Permission", diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 2005edf7e..de976f359 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -6,9 +6,11 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.helpers.strings import nullable_str_to_bool, nullable_str_to_int from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.exceptions import UnpopulatedPropertyError from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.property_decorators import ( property_not_nullable, property_is_boolean, @@ -16,6 +18,7 @@ ) from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem class DatasourceItem: @@ -40,6 +43,9 @@ class DatasourceItem: specified, it will default to SiteDefault. See REST API Publish Datasource for more information about ask_data_enablement. + connected_workbooks_count : Optional[int] + The number of workbooks connected to the datasource. + connections : list[ConnectionItem] The list of data connections (ConnectionItem) for the specified data source. You must first call the populate_connections method to access @@ -67,6 +73,12 @@ class DatasourceItem: A Boolean value to determine if a datasource should be encrypted or not. See Extract and Encryption Methods for more information. + favorites_total : Optional[int] + The number of users who have marked the data source as a favorite. + + has_alert : Optional[bool] + A Boolean value that indicates whether the data source has an alert. + has_extracts : Optional[bool] A Boolean value that indicates whether the datasource has extracts. @@ -75,13 +87,22 @@ class DatasourceItem: specific data source or to delete a data source with the get_by_id and delete methods. + is_published : Optional[bool] + A Boolean value that indicates whether the data source is published. + name : Optional[str] The name of the data source. If not specified, the name of the published data source file is used. + owner: Optional[UserItem] + The owner of the data source. + owner_id : Optional[str] The identifier of the owner of the data source. + project : Optional[ProjectItem] + The project that the data source belongs to. + project_id : Optional[str] The identifier of the project associated with the data source. You must provide this identifier when you create an instance of a DatasourceItem. @@ -89,6 +110,9 @@ class DatasourceItem: project_name : Optional[str] The name of the project associated with the data source. + server_name : Optional[str] + The name of the server where the data source is published. + tags : Optional[set[str]] The tags (list of strings) that have been added to the data source. @@ -143,6 +167,13 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.owner_id: Optional[str] = None self.project_id: Optional[str] = project_id self.tags: set[str] = set() + self._connected_workbooks_count: Optional[int] = None + self._favorites_total: Optional[int] = None + self._has_alert: Optional[bool] = None + self._is_published: Optional[bool] = None + self._server_name: Optional[str] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None self._permissions = None self._data_quality_warnings = None @@ -274,6 +305,34 @@ def revisions(self) -> list[RevisionItem]: def size(self) -> Optional[int]: return self._size + @property + def connected_workbooks_count(self) -> Optional[int]: + return self._connected_workbooks_count + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + + @property + def has_alert(self) -> Optional[bool]: + return self._has_alert + + @property + def is_published(self) -> Optional[bool]: + return self._is_published + + @property + def server_name(self) -> Optional[str]: + return self._server_name + + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + def _set_connections(self, connections) -> None: self._connections = connections @@ -310,6 +369,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) = self._parse_element(datasource_xml, ns) self._set_values( ask_data_enablement, @@ -331,6 +397,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) return self @@ -355,6 +428,13 @@ def _set_values( use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ): if ask_data_enablement is not None: self._ask_data_enablement = ask_data_enablement @@ -394,6 +474,20 @@ def _set_values( self._webpage_url = webpage_url if size is not None: self._size = int(size) + if connected_workbooks_count is not None: + self._connected_workbooks_count = connected_workbooks_count + if favorites_total is not None: + self._favorites_total = favorites_total + if has_alert is not None: + self._has_alert = has_alert + if is_published is not None: + self._is_published = is_published + if server_name is not None: + self._server_name = server_name + if project is not None: + self._project = project + if owner is not None: + self._owner = owner @classmethod def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: @@ -428,6 +522,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) size = datasource_xml.get("size", None) + connected_workbooks_count = nullable_str_to_int(datasource_xml.get("connectedWorkbooksCount", None)) + favorites_total = nullable_str_to_int(datasource_xml.get("favoritesTotal", None)) + has_alert = nullable_str_to_bool(datasource_xml.get("hasAlert", None)) + is_published = nullable_str_to_bool(datasource_xml.get("isPublished", None)) + server_name = datasource_xml.get("serverName", None) tags = None tags_elem = datasource_xml.find(".//t:tags", namespaces=ns) @@ -438,12 +537,14 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: project_name = None project_elem = datasource_xml.find(".//t:project", namespaces=ns) if project_elem is not None: + project = ProjectItem.from_xml(project_elem, ns) project_id = project_elem.get("id", None) project_name = project_elem.get("name", None) owner_id = None owner_elem = datasource_xml.find(".//t:owner", namespaces=ns) if owner_elem is not None: + owner = UserItem.from_xml(owner_elem, ns) owner_id = owner_elem.get("id", None) ask_data_enablement = None @@ -471,4 +572,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 0afd5582c..00f35e518 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -44,6 +44,11 @@ class GroupItem: login to a site. When the mode is onSync, a license is granted for group members each time the domain is synced. + Attributes + ---------- + user_count: Optional[int] + The number of users in the group. + Examples -------- >>> # Create a new group item @@ -65,6 +70,7 @@ def __init__(self, name=None, domain_name=None) -> None: self._users: Optional[Callable[..., "Pager"]] = None self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name + self._user_count: Optional[int] = None def __repr__(self): return f"{self.__class__.__name__}({self.__dict__!r})" @@ -118,6 +124,10 @@ def users(self) -> "Pager": def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users + @property + def user_count(self) -> Optional[int]: + return self._user_count + @classmethod def from_response(cls, resp, ns) -> list["GroupItem"]: all_group_items = list() @@ -127,6 +137,7 @@ def from_response(cls, resp, ns) -> list["GroupItem"]: name = group_xml.get("name", None) group_item = cls(name) group_item._id = group_xml.get("id", None) + group_item._user_count = int(count) if (count := group_xml.get("userCount", None)) else None # Domain name is returned in a domain element for some calls domain_elem = group_xml.find(".//t:domain", namespaces=ns) diff --git a/tableauserverclient/models/location_item.py b/tableauserverclient/models/location_item.py new file mode 100644 index 000000000..fa7c2ff2c --- /dev/null +++ b/tableauserverclient/models/location_item.py @@ -0,0 +1,53 @@ +from typing import Optional +import xml.etree.ElementTree as ET + + +class LocationItem: + """ + Details of where an item is located, such as a personal space or project. + + Attributes + ---------- + id : str | None + The ID of the location. + + type : str | None + The type of location, such as PersonalSpace or Project. + + name : str | None + The name of the location. + """ + + class Type: + PersonalSpace = "PersonalSpace" + Project = "Project" + + def __init__(self): + self._id: Optional[str] = None + self._type: Optional[str] = None + self._name: Optional[str] = None + + def __repr__(self): + return f"{self.__class__.__name__}({self.__dict__!r})" + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def type(self) -> Optional[str]: + return self._type + + @property + def name(self) -> Optional[str]: + return self._name + + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "LocationItem": + if ns is None: + ns = {} + location = cls() + location._id = xml.get("id", None) + location._type = xml.get("type", None) + location._name = xml.get("name", None) + return location diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9be1196ba..1ab369ba7 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,11 +1,11 @@ -import logging import xml.etree.ElementTree as ET -from typing import Optional +from typing import Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.models.exceptions import UnpopulatedPropertyError -from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty +from tableauserverclient.models.property_decorators import property_is_enum +from tableauserverclient.models.user_item import UserItem class ProjectItem: @@ -39,12 +39,32 @@ class corresponds to the project resources you can access using the Tableau Attributes ---------- + datasource_count : int + The number of data sources in the project. + id : str The unique identifier for the project. + owner: Optional[UserItem] + The UserItem owner of the project. + owner_id : str The unique identifier for the UserItem owner of the project. + project_count : int + The number of projects in the project. + + top_level_project : bool + True if the project is a top-level project. + + view_count : int + The number of views in the project. + + workbook_count : int + The number of workbooks in the project. + + writeable : bool + True if the project is writeable. """ ERROR_MSG = "Project item must be populated with permissions first." @@ -75,6 +95,8 @@ def __init__( self.parent_id: Optional[str] = parent_id self._samples: Optional[bool] = samples self._owner_id: Optional[str] = None + self._top_level_project: Optional[bool] = None + self._writeable: Optional[bool] = None self._permissions = None self._default_workbook_permissions = None @@ -87,6 +109,13 @@ def __init__( self._default_database_permissions = None self._default_table_permissions = None + self._project_count: Optional[int] = None + self._workbook_count: Optional[int] = None + self._view_count: Optional[int] = None + self._datasource_count: Optional[int] = None + + self._owner: Optional[UserItem] = None + @property def content_permissions(self): return self._content_permissions @@ -176,25 +205,53 @@ def owner_id(self) -> Optional[str]: def owner_id(self, value: str) -> None: self._owner_id = value + @property + def top_level_project(self) -> Optional[bool]: + return self._top_level_project + + @property + def writeable(self) -> Optional[bool]: + return self._writeable + + @property + def project_count(self) -> Optional[int]: + return self._project_count + + @property + def workbook_count(self) -> Optional[int]: + return self._workbook_count + + @property + def view_count(self) -> Optional[int]: + return self._view_count + + @property + def datasource_count(self) -> Optional[int]: + return self._datasource_count + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + def is_default(self): return self.name.lower() == "default" - def _parse_common_tags(self, project_xml, ns): - if not isinstance(project_xml, ET.Element): - project_xml = fromstring(project_xml).find(".//t:project", namespaces=ns) - - if project_xml is not None: - ( - _, - name, - description, - content_permissions, - parent_id, - ) = self._parse_element(project_xml) - self._set_values(None, name, description, content_permissions, parent_id) - return self - - def _set_values(self, project_id, name, description, content_permissions, parent_id, owner_id): + def _set_values( + self, + project_id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ): if project_id is not None: self._id = project_id if name: @@ -207,6 +264,20 @@ def _set_values(self, project_id, name, description, content_permissions, parent self.parent_id = parent_id if owner_id: self._owner_id = owner_id + if project_count is not None: + self._project_count = project_count + if workbook_count is not None: + self._workbook_count = workbook_count + if view_count is not None: + self._view_count = view_count + if datasource_count is not None: + self._datasource_count = datasource_count + if top_level_project is not None: + self._top_level_project = top_level_project + if writeable is not None: + self._writeable = writeable + if owner is not None: + self._owner = owner def _set_permissions(self, permissions): self._permissions = permissions @@ -220,31 +291,71 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> list["ProjectItem"]: + def from_response(cls, resp: bytes, ns: Optional[dict]) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: - project_item = cls.from_xml(project_xml) + project_item = cls.from_xml(project_xml, namespace=ns) all_project_items.append(project_item) return all_project_items @classmethod - def from_xml(cls, project_xml, namespace=None) -> "ProjectItem": + def from_xml(cls, project_xml: ET.Element, namespace: Optional[dict] = None) -> "ProjectItem": project_item = cls() - project_item._set_values(*cls._parse_element(project_xml)) + project_item._set_values(*cls._parse_element(project_xml, namespace)) return project_item @staticmethod - def _parse_element(project_xml): + def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple: id = project_xml.get("id", None) name = project_xml.get("name", None) description = project_xml.get("description", None) content_permissions = project_xml.get("contentPermissions", None) parent_id = project_xml.get("parentProjectId", None) + top_level_project = str_to_bool(project_xml.get("topLevelProject", None)) + writeable = str_to_bool(project_xml.get("writeable", None)) owner_id = None - for owner in project_xml: - owner_id = owner.get("id", None) + owner = None + if (owner_elem := project_xml.find(".//t:owner", namespaces=namespace)) is not None: + owner = UserItem.from_xml(owner_elem, namespace) + owner_id = owner_elem.get("id", None) + + project_count = None + workbook_count = None + view_count = None + datasource_count = None + if (count_elem := project_xml.find(".//t:contentsCounts", namespaces=namespace)) is not None: + project_count = int(count_elem.get("projectCount", 0)) + workbook_count = int(count_elem.get("workbookCount", 0)) + view_count = int(count_elem.get("viewCount", 0)) + datasource_count = int(count_elem.get("dataSourceCount", 0)) + + return ( + id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ) + + +@overload +def str_to_bool(value: str) -> bool: ... + + +@overload +def str_to_bool(value: None) -> None: ... + - return id, name, description, content_permissions, parent_id, owner_id +def str_to_bool(value): + return value.lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 365e44c1d..4d0186c4c 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -37,6 +37,49 @@ class UserItem: auth_setting: str Required attribute for Tableau Cloud. How the user autenticates to the server. + + Attributes + ---------- + domain_name: Optional[str] + The name of the Active Directory domain ("local" if local authentication + is used). + + email: Optional[str] + The email address of the user. + + external_auth_user_id: Optional[str] + The unique identifier for the user in the external authentication system. + + id: Optional[str] + The unique identifier for the user. + + favorites: dict[str, list] + The favorites of the user. Must be populated with a call to + `populate_favorites()`. + + fullname: Optional[str] + The full name of the user. + + groups: Pager + The groups the user belongs to. Must be populated with a call to + `populate_groups()`. + + last_login: Optional[datetime] + The last time the user logged in. + + locale: Optional[str] + The locale of the user. + + language: Optional[str] + Language setting for the user. + + idp_configuration_id: Optional[str] + The ID of the identity provider configuration. + + workbooks: Pager + The workbooks owned by the user. Must be populated with a call to + `populate_workbooks()`. + """ tag_name: str = "user" @@ -94,6 +137,9 @@ def __init__( self.name: Optional[str] = name self.site_role: Optional[str] = site_role self.auth_setting: Optional[str] = auth_setting + self._locale: Optional[str] = None + self._language: Optional[str] = None + self._idp_configuration_id: Optional[str] = None return None @@ -184,6 +230,18 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() + @property + def locale(self) -> Optional[str]: + return self._locale + + @property + def language(self) -> Optional[str]: + return self._language + + @property + def idp_configuration_id(self) -> Optional[str]: + return self._idp_configuration_id + def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks @@ -204,8 +262,11 @@ def _parse_common_tags(self, user_xml, ns) -> "UserItem": email, auth_setting, _, + _, + _, + _, ) = self._parse_element(user_xml, ns) - self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None) + self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None, None, None) return self def _set_values( @@ -219,6 +280,9 @@ def _set_values( email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ): if id is not None: self._id = id @@ -238,6 +302,12 @@ def _set_values( self._auth_setting = auth_setting if domain_name: self._domain_name = domain_name + if locale: + self._locale = locale + if language: + self._language = language + if idp_configuration_id: + self._idp_configuration_id = idp_configuration_id @classmethod def from_response(cls, resp, ns) -> list["UserItem"]: @@ -249,6 +319,12 @@ def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "UserItem": + item = cls() + item._set_values(*cls._parse_element(xml, ns)) + return item + @classmethod def _parse_xml(cls, element_name, resp, ns): all_user_items = [] @@ -265,6 +341,9 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) = cls._parse_element(user_xml, ns) user_item = cls(name, site_role) user_item._set_values( @@ -277,6 +356,9 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) all_user_items.append(user_item) return all_user_items @@ -295,6 +377,9 @@ def _parse_element(user_xml, ns): fullname = user_xml.get("fullName", None) email = user_xml.get("email", None) auth_setting = user_xml.get("authSetting", None) + locale = user_xml.get("locale", None) + language = user_xml.get("language", None) + idp_configuration_id = user_xml.get("idpConfigurationId", None) domain_name = None domain_elem = user_xml.find(".//t:domain", namespaces=ns) @@ -311,6 +396,9 @@ def _parse_element(user_xml, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) class CSVImport: @@ -361,6 +449,9 @@ def create_user_from_line(line: str): values[UserItem.CSVImport.ColumnType.EMAIL], values[UserItem.CSVImport.ColumnType.AUTH], None, + None, + None, + None, ) return user diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 88cec7328..dc8eda9c8 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,15 +1,21 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Optional +from typing import TYPE_CHECKING, Callable, Optional, overload from collections.abc import Iterator from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem + +if TYPE_CHECKING: + from tableauserverclient.models.workbook_item import WorkbookItem class ViewItem: @@ -34,9 +40,16 @@ class ViewItem: The image of the view. You must first call the `views.populate_image` method to access the image. + location: Optional[LocationItem], default None + The location of the view. The location can be a personal space or a + project. + name: Optional[str], default None The name of the view. + owner: Optional[UserItem], default None + The owner of the view. + owner_id: Optional[str], default None The ID for the owner of the view. @@ -48,6 +61,9 @@ class ViewItem: The preview image of the view. You must first call the `views.populate_preview_image` method to access the preview image. + project: Optional[ProjectItem], default None + The project that contains the view. + project_id: Optional[str], default None The ID for the project that contains the view. @@ -60,9 +76,11 @@ class ViewItem: updated_at: Optional[datetime], default None The date and time when the view was last updated. + workbook: Optional[WorkbookItem], default None + The workbook that contains the view. + workbook_id: Optional[str], default None The ID for the workbook that contains the view. - """ def __init__(self) -> None: @@ -84,11 +102,18 @@ def __init__(self) -> None: self._workbook_id: Optional[str] = None self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None self.tags: set[str] = set() + self._favorites_total: Optional[int] = None + self._view_url_name: Optional[str] = None self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } + self._owner: Optional[UserItem] = None + self._project: Optional[ProjectItem] = None + self._workbook: Optional["WorkbookItem"] = None + self._location: Optional[LocationItem] = None + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id @@ -190,6 +215,14 @@ def updated_at(self) -> Optional[datetime]: def workbook_id(self) -> Optional[str]: return self._workbook_id + @property + def view_url_name(self) -> Optional[str]: + return self._view_url_name + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + @property def data_acceleration_config(self): return self._data_acceleration_config @@ -198,6 +231,22 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def project(self) -> Optional["ProjectItem"]: + return self._project + + @property + def workbook(self) -> Optional["WorkbookItem"]: + return self._workbook + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + @property def permissions(self) -> list[PermissionsRule]: if self._permissions is None: @@ -228,7 +277,7 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": workbook_elem = view_xml.find(".//t:workbook", namespaces=ns) owner_elem = view_xml.find(".//t:owner", namespaces=ns) project_elem = view_xml.find(".//t:project", namespaces=ns) - tags_elem = view_xml.find(".//t:tags", namespaces=ns) + tags_elem = view_xml.find("./t:tags", namespaces=ns) data_acceleration_config_elem = view_xml.find(".//t:dataAccelerationConfig", namespaces=ns) view_item._created_at = parse_datetime(view_xml.get("createdAt", None)) view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None)) @@ -236,22 +285,35 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": view_item._name = view_xml.get("name", None) view_item._content_url = view_xml.get("contentUrl", None) view_item._sheet_type = view_xml.get("sheetType", None) + view_item._favorites_total = string_to_int(view_xml.get("favoritesTotal", None)) + view_item._view_url_name = view_xml.get("viewUrlName", None) if usage_elem is not None: total_view = usage_elem.get("totalViewCount", None) if total_view: view_item._total_views = int(total_view) if owner_elem is not None: + user = UserItem.from_xml(owner_elem, ns) + view_item._owner = user view_item._owner_id = owner_elem.get("id", None) if project_elem is not None: - view_item._project_id = project_elem.get("id", None) + project_item = ProjectItem.from_xml(project_elem, ns) + view_item._project = project_item + view_item._project_id = project_item.id if workbook_id: view_item._workbook_id = workbook_id elif workbook_elem is not None: - view_item._workbook_id = workbook_elem.get("id", None) + from tableauserverclient.models.workbook_item import WorkbookItem + + workbook_item = WorkbookItem.from_xml(workbook_elem, ns) + view_item._workbook = workbook_item + view_item._workbook_id = workbook_item.id if tags_elem is not None: tags = TagItem.from_xml_element(tags_elem, ns) view_item.tags = tags view_item._initial_tags = copy.copy(tags) + if (location_elem := view_xml.find(".//t:location", namespaces=ns)) is not None: + location = LocationItem.from_xml(location_elem, ns) + view_item._location = location if data_acceleration_config_elem is not None: data_acceleration_config = parse_data_acceleration_config(data_acceleration_config_elem) view_item.data_acceleration_config = data_acceleration_config @@ -274,3 +336,15 @@ def parse_data_acceleration_config(data_acceleration_elem): def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 32ab413a4..a3ede65d6 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,11 +2,14 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Optional +from typing import Callable, Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.location_item import LocationItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.user_item import UserItem from .connection_item import ConnectionItem from .exceptions import UnpopulatedPropertyError from .permissions_item import PermissionsRule @@ -51,13 +54,31 @@ class as arguments. The workbook item specifies the project. created_at : Optional[datetime.datetime] The date and time the workbook was created. + default_view_id : Optional[str] + The identifier for the default view of the workbook. + description : Optional[str] User-defined description of the workbook. + encrypt_extracts : Optional[bool] + Indicates whether extracts are encrypted. + + has_extracts : Optional[bool] + Indicates whether the workbook has extracts. + id : Optional[str] The identifier for the workbook. You need this value to query a specific workbook or to delete a workbook with the get_by_id and delete methods. + last_published_at : Optional[datetime.datetime] + The date and time the workbook was last published. + + location : Optional[LocationItem] + The location of the workbook, such as a personal space or project. + + owner : Optional[UserItem] + The owner of the workbook. + owner_id : Optional[str] The identifier for the owner (UserItem) of the workbook. @@ -65,6 +86,9 @@ class as arguments. The workbook item specifies the project. The thumbnail image for the view. You must first call the workbooks.populate_preview_image method to access this data. + project: Optional[ProjectItem] + The project that contains the workbook. + project_name : Optional[str] The name of the project that contains the workbook. @@ -139,6 +163,15 @@ def __init__( self._permissions = None self.thumbnails_user_id = thumbnails_user_id self.thumbnails_group_id = thumbnails_group_id + self._sheet_count: Optional[int] = None + self._has_extracts: Optional[bool] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None + self._location: Optional[LocationItem] = None + self._encrypt_extracts: Optional[bool] = None + self._default_view_id: Optional[str] = None + self._share_description: Optional[str] = None + self._last_published_at: Optional[datetime.datetime] = None return None @@ -234,6 +267,14 @@ def show_tabs(self, value: bool): def size(self): return self._size + @property + def sheet_count(self) -> Optional[int]: + return self._sheet_count + + @property + def has_extracts(self) -> Optional[bool]: + return self._has_extracts + @property def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @@ -300,6 +341,34 @@ def thumbnails_group_id(self) -> Optional[str]: def thumbnails_group_id(self, value: str): self._thumbnails_group_id = value + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + + @property + def encrypt_extracts(self) -> Optional[bool]: + return self._encrypt_extracts + + @property + def default_view_id(self) -> Optional[str]: + return self._default_view_id + + @property + def share_description(self) -> Optional[str]: + return self._share_description + + @property + def last_published_at(self) -> Optional[datetime.datetime]: + return self._last_published_at + def _set_connections(self, connections): self._connections = connections @@ -342,6 +411,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) = self._parse_element(workbook_xml, ns) self._set_values( @@ -361,6 +439,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) return self @@ -383,6 +470,15 @@ def _set_values( views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ): if id is not None: self._id = id @@ -417,6 +513,24 @@ def _set_values( self.data_acceleration_config = data_acceleration_config if data_freshness_policy is not None: self.data_freshness_policy = data_freshness_policy + if sheet_count is not None: + self._sheet_count = sheet_count + if has_extracts is not None: + self._has_extracts = has_extracts + if project: + self._project = project + if owner: + self._owner = owner + if location: + self._location = location + if encrypt_extracts is not None: + self._encrypt_extracts = encrypt_extracts + if default_view_id is not None: + self._default_view_id = default_view_id + if share_description is not None: + self._share_description = share_description + if last_published_at is not None: + self._last_published_at = last_published_at @classmethod def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: @@ -443,6 +557,12 @@ def _parse_element(workbook_xml, ns): created_at = parse_datetime(workbook_xml.get("createdAt", None)) description = workbook_xml.get("description", None) updated_at = parse_datetime(workbook_xml.get("updatedAt", None)) + sheet_count = string_to_int(workbook_xml.get("sheetCount", None)) + has_extracts = string_to_bool(workbook_xml.get("hasExtracts", "")) + encrypt_extracts = string_to_bool(e) if (e := workbook_xml.get("encryptExtracts", None)) is not None else None + default_view_id = workbook_xml.get("defaultViewId", None) + share_description = workbook_xml.get("shareDescription", None) + last_published_at = parse_datetime(workbook_xml.get("lastPublishedAt", None)) size = workbook_xml.get("size", None) if size: @@ -452,14 +572,18 @@ def _parse_element(workbook_xml, ns): project_id = None project_name = None + project = None project_tag = workbook_xml.find(".//t:project", namespaces=ns) if project_tag is not None: + project = ProjectItem.from_xml(project_tag, ns) project_id = project_tag.get("id", None) project_name = project_tag.get("name", None) owner_id = None + owner = None owner_tag = workbook_xml.find(".//t:owner", namespaces=ns) if owner_tag is not None: + owner = UserItem.from_xml(owner_tag, ns) owner_id = owner_tag.get("id", None) tags = None @@ -473,6 +597,11 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) + location = None + location_elem = workbook_xml.find(".//t:location", namespaces=ns) + if location_elem is not None: + location = LocationItem.from_xml(location_elem, ns) + data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -505,6 +634,15 @@ def _parse_element(workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) @@ -535,3 +673,15 @@ def parse_data_acceleration_config(data_acceleration_elem): # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9e1160705..21462af5f 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -14,6 +14,7 @@ TypeVar, Union, ) +from typing_extensions import Self from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -353,3 +354,41 @@ def paginate(self, **kwargs) -> QuerySet[T]: @abc.abstractmethod def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") + + def fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) | set(("_default_",)) + return queryset + + def only_fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) + return queryset diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index d81907ae9..6489f2a79 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -87,7 +87,7 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt if req_options is None: req_options = RequestOptions() - req_options._all_fields = True + req_options.all_fields = True url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 801ad4a13..5137cee52 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -208,6 +208,42 @@ def paginate(self: Self, **kwargs) -> Self: self.request_options.pagesize = kwargs["page_size"] return self + def fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) | set(("_default_")) + return self + + def only_fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) + return self + @staticmethod def _parse_shorthand_filter(key: str) -> tuple[str, str]: tokens = key.split("__", 1) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 504f7f3ca..4a104255f 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,6 @@ import sys from typing import Optional +import warnings from typing_extensions import Self @@ -62,8 +63,21 @@ def __init__(self, pagenumber=1, pagesize=None): self.pagesize = pagesize or config.PAGE_SIZE self.sort = set() self.filter = set() + self.fields = set() # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False + self.all_fields = False + + @property + def _all_fields(self) -> bool: + return self.all_fields + + @_all_fields.setter + def _all_fields(self, value): + warnings.warn( + "Directly setting _all_fields is deprecated, please use the all_fields property instead.", + DeprecationWarning, + ) + self.all_fields = value def get_query_params(self) -> dict: params = {} @@ -75,12 +89,14 @@ def get_query_params(self) -> dict: filter_options = (str(filter_item) for filter_item in self.filter) ordered_filter_options = sorted(filter_options) params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: + if self.all_fields: params["fields"] = "_all_" if self.pagenumber: params["pageNumber"] = self.pagenumber if self.pagesize: params["pageSize"] = self.pagesize + if self.fields: + params["fields"] = ",".join(self.fields) return params def page_size(self, page_size): @@ -181,6 +197,116 @@ class Direction: Desc = "desc" Asc = "asc" + class SelectFields: + class Common: + All = "_all_" + Default = "_default_" + + class ContentsCounts: + ProjectCount = "contentsCounts.projectCount" + ViewCount = "contentsCounts.viewCount" + DatasourceCount = "contentsCounts.datasourceCount" + WorkbookCount = "contentsCounts.workbookCount" + + class Datasource: + ContentUrl = "datasource.contentUrl" + ID = "datasource.id" + Name = "datasource.name" + Type = "datasource.type" + Description = "datasource.description" + CreatedAt = "datasource.createdAt" + UpdatedAt = "datasource.updatedAt" + EncryptExtracts = "datasource.encryptExtracts" + IsCertified = "datasource.isCertified" + UseRemoteQueryAgent = "datasource.useRemoteQueryAgent" + WebPageURL = "datasource.webpageUrl" + Size = "datasource.size" + Tag = "datasource.tag" + FavoritesTotal = "datasource.favoritesTotal" + DatabaseName = "datasource.databaseName" + ConnectedWorkbooksCount = "datasource.connectedWorkbooksCount" + HasAlert = "datasource.hasAlert" + HasExtracts = "datasource.hasExtracts" + IsPublished = "datasource.isPublished" + ServerName = "datasource.serverName" + + class Favorite: + Label = "favorite.label" + ParentProjectName = "favorite.parentProjectName" + TargetOwnerName = "favorite.targetOwnerName" + + class Group: + ID = "group.id" + Name = "group.name" + DomainName = "group.domainName" + UserCount = "group.userCount" + MinimumSiteRole = "group.minimumSiteRole" + + class Job: + ID = "job.id" + Status = "job.status" + CreatedAt = "job.createdAt" + StartedAt = "job.startedAt" + EndedAt = "job.endedAt" + Priority = "job.priority" + JobType = "job.jobType" + Title = "job.title" + Subtitle = "job.subtitle" + + class Owner: + ID = "owner.id" + Name = "owner.name" + FullName = "owner.fullName" + SiteRole = "owner.siteRole" + LastLogin = "owner.lastLogin" + Email = "owner.email" + + class Project: + ID = "project.id" + Name = "project.name" + Description = "project.description" + CreatedAt = "project.createdAt" + UpdatedAt = "project.updatedAt" + ContentPermissions = "project.contentPermissions" + ParentProjectID = "project.parentProjectId" + TopLevelProject = "project.topLevelProject" + Writeable = "project.writeable" + + class User: + ExternalAuthUserId = "user.externalAuthUserId" + ID = "user.id" + Name = "user.name" + SiteRole = "user.siteRole" + LastLogin = "user.lastLogin" + FullName = "user.fullName" + Email = "user.email" + AuthSetting = "user.authSetting" + + class View: + ID = "view.id" + Name = "view.name" + ContentUrl = "view.contentUrl" + CreatedAt = "view.createdAt" + UpdatedAt = "view.updatedAt" + Tags = "view.tags" + SheetType = "view.sheetType" + Usage = "view.usage" + + class Workbook: + ID = "workbook.id" + Description = "workbook.description" + Name = "workbook.name" + ContentUrl = "workbook.contentUrl" + ShowTabs = "workbook.showTabs" + Size = "workbook.size" + CreatedAt = "workbook.createdAt" + UpdatedAt = "workbook.updatedAt" + SheetCount = "workbook.sheetCount" + HasExtracts = "workbook.hasExtracts" + Tags = "workbook.tags" + WebpageUrl = "workbook.webpageUrl" + DefaultViewId = "workbook.defaultViewId" + """ These options can be used by methods that are fetching data exported from a specific content item diff --git a/test/assets/datasource_get_all_fields.xml b/test/assets/datasource_get_all_fields.xml new file mode 100644 index 000000000..46c4396d3 --- /dev/null +++ b/test/assets/datasource_get_all_fields.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/assets/group_get_all_fields.xml b/test/assets/group_get_all_fields.xml new file mode 100644 index 000000000..0118250e1 --- /dev/null +++ b/test/assets/group_get_all_fields.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/project_get_all_fields.xml b/test/assets/project_get_all_fields.xml new file mode 100644 index 000000000..d71ebd922 --- /dev/null +++ b/test/assets/project_get_all_fields.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/user_get_all_fields.xml b/test/assets/user_get_all_fields.xml new file mode 100644 index 000000000..7e9a62568 --- /dev/null +++ b/test/assets/user_get_all_fields.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/assets/view_get_all_fields.xml b/test/assets/view_get_all_fields.xml new file mode 100644 index 000000000..236ebd726 --- /dev/null +++ b/test/assets/view_get_all_fields.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/workbook_get_all_fields.xml b/test/assets/workbook_get_all_fields.xml new file mode 100644 index 000000000..007b79338 --- /dev/null +++ b/test/assets/workbook_get_all_fields.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index b7e7e2721..a604ba8b0 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -10,7 +10,7 @@ import tableauserverclient as TSC from tableauserverclient import ConnectionItem -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.request_factory import RequestFactory @@ -20,6 +20,7 @@ GET_XML = "datasource_get.xml" GET_EMPTY_XML = "datasource_get_empty.xml" GET_BY_ID_XML = "datasource_get_by_id.xml" +GET_XML_ALL_FIELDS = "datasource_get_all_fields.xml" POPULATE_CONNECTIONS_XML = "datasource_populate_connections.xml" POPULATE_PERMISSIONS_XML = "datasource_populate_permissions.xml" PUBLISH_XML = "datasource_publish.xml" @@ -733,3 +734,39 @@ def test_bad_download_response(self) -> None: ) file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) + + def test_get_datasource_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=read_xml_asset(GET_XML_ALL_FIELDS)) + datasources, _ = self.server.datasources.get(req_options=ro) + + assert datasources[0].connected_workbooks_count == 0 + assert datasources[0].content_url == "SuperstoreDatasource" + assert datasources[0].created_at == parse_datetime("2024-02-14T04:42:13Z") + assert not datasources[0].encrypt_extracts + assert datasources[0].favorites_total == 0 + assert not datasources[0].has_alert + assert not datasources[0].has_extracts + assert datasources[0].id == "a71cdd15-3a23-4ec1-b3ce-9956f5e00bb7" + assert not datasources[0].certified + assert datasources[0].is_published + assert datasources[0].name == "Superstore Datasource" + assert datasources[0].size == 1 + assert datasources[0].datasource_type == "excel-direct" + assert datasources[0].updated_at == parse_datetime("2024-02-14T04:42:14Z") + assert not datasources[0].use_remote_query_agent + assert datasources[0].server_name == "localhost" + assert datasources[0].webpage_url == "https://10ax.online.tableau.com/#/site/example/datasources/3566752" + assert isinstance(datasources[0].project, TSC.ProjectItem) + assert datasources[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert datasources[0].project.name == "Samples" + assert datasources[0].project.description == "This project includes automatically uploaded samples." + assert datasources[0].owner.email == "bob@example.com" + assert isinstance(datasources[0].owner, TSC.UserItem) + assert datasources[0].owner.fullname == "Bob Smith" + assert datasources[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert datasources[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert datasources[0].owner.name == "bob@example.com" + assert datasources[0].owner.site_role == "SiteAdministratorCreator" diff --git a/test/test_group.py b/test/test_group.py index 41b5992be..b3de07963 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -10,6 +10,7 @@ # TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") +GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "group_get_all_fields.xml" POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") @@ -310,3 +311,25 @@ def test_update_ad_async(self) -> None: self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b") self.assertEqual(job.mode, "Asynchronous") self.assertEqual(job.type, "GroupSync") + + def test_get_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=GET_XML_ALL_FIELDS.read_text()) + groups, pages = self.server.groups.get(req_options=ro) + + assert pages.total_available == 3 + assert len(groups) == 3 + assert groups[0].id == "28c5b855-16df-482f-ad0b-428c1df58859" + assert groups[0].name == "All Users" + assert groups[0].user_count == 2 + assert groups[0].domain_name == "local" + assert groups[1].id == "ace1ee2d-e7dd-4d7a-9504-a1ccaa5212ea" + assert groups[1].name == "group1" + assert groups[1].user_count == 1 + assert groups[2].id == "baf0ed9d-c25d-4114-97ed-5232b8a732fd" + assert groups[2].name == "test" + assert groups[2].user_count == 0 diff --git a/test/test_project.py b/test/test_project.py index 56787efac..c51f2e1e6 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -10,6 +10,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = asset("project_get.xml") +GET_XML_ALL_FIELDS = asset("project_get_all_fields.xml") UPDATE_XML = asset("project_update.xml") SET_CONTENT_PERMISSIONS_XML = asset("project_content_permission.xml") CREATE_XML = asset("project_create.xml") @@ -410,3 +411,28 @@ def test_delete_virtualconnection_default_permimssions(self): m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204) self.server.projects.delete_virtualconnection_default_permissions(project, rule) + + def test_get_all_fields(self) -> None: + self.server.version = "3.23" + base_url = self.server.projects.baseurl + with open(GET_XML_ALL_FIELDS, "rb") as f: + response_xml = f.read().decode("utf-8") + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{base_url}?fields=_all_", text=response_xml) + all_projects, pagination_item = self.server.projects.get(req_options=ro) + + assert pagination_item.total_available == 3 + assert len(all_projects) == 1 + project: TSC.ProjectItem = all_projects[0] + assert isinstance(project, TSC.ProjectItem) + assert project.id == "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + assert project.name == "Samples" + assert project.description == "This project includes automatically uploaded samples." + assert project.top_level_project is True + assert project.content_permissions == "ManagedByOwner" + assert project.parent_id is None + assert project.writeable is True diff --git a/test/test_request_option.py b/test/test_request_option.py index 7405189a3..57dfdc2a0 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -251,7 +251,7 @@ def test_all_fields(self) -> None: m.get(requests_mock.ANY) url = self.baseurl + "/views/456/data" opts = TSC.RequestOptions() - opts._all_fields = True + opts.all_fields = True resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("fields=_all_", resp.request.query)) @@ -368,3 +368,13 @@ def test_language_export(self) -> None: resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("language=en-us", resp.request.query)) + + def test_queryset_fields(self) -> None: + loop = self.server.users.fields("id") + assert "id" in loop.request_options.fields + assert "_default_" in loop.request_options.fields + + def test_queryset_only_fields(self) -> None: + loop = self.server.users.only_fields("id") + assert "id" in loop.request_options.fields + assert "_default_" not in loop.request_options.fields diff --git a/test/test_user.py b/test/test_user.py index a46624845..6552fbf86 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -4,11 +4,12 @@ import requests_mock import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "user_get_all_fields.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") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml") @@ -233,3 +234,37 @@ def test_get_users_from_file(self): users, failures = self.server.users.create_from_file(USERS) assert users[0].name == "Cassie", users assert failures == [] + + def test_get_users_all_fields(self) -> None: + self.server.version = "3.7" + baseurl = self.server.users.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response_xml) + all_users, _ = self.server.users.get() + + assert all_users[0].auth_setting == "TableauIDWithMFA" + assert all_users[0].email == "bob@example.com" + assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610" + assert all_users[0].fullname == "Bob Smith" + assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z") + assert all_users[0].name == "bob@example.com" + assert all_users[0].site_role == "SiteAdministratorCreator" + assert all_users[0].locale is None + assert all_users[0].language == "en" + assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[0].domain_name == "TABID_WITH_MFA" + assert all_users[1].auth_setting == "TableauIDWithMFA" + assert all_users[1].email == "alice@example.com" + assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29" + assert all_users[1].fullname == "Alice Jones" + assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682" + assert all_users[1].name == "alice@example.com" + assert all_users[1].site_role == "ExplorerCanPublish" + assert all_users[1].locale is None + assert all_users[1].language == "en" + assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[1].domain_name == "TABID_WITH_MFA" diff --git a/test/test_view.py b/test/test_view.py index 3fdaf60e6..ee6d518de 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -5,13 +5,14 @@ import tableauserverclient as TSC from tableauserverclient import UserItem, GroupItem, PermissionsRule -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "view_add_tags.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "view_get_all_fields.xml") GET_XML_ID = os.path.join(TEST_ASSET_DIR, "view_get_id.xml") GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_usage.xml") GET_XML_ID_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_id_usage.xml") @@ -402,3 +403,116 @@ def test_pdf_errors(self) -> None: req_option = TSC.PDFRequestOptions(viz_width=1920) with self.assertRaises(ValueError): req_option.get_query_params() + + def test_view_get_all_fields(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.views.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=response_xml) + views, _ = self.server.views.get(req_options=ro) + + assert views[0].id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert views[0].name == "Overview" + assert views[0].content_url == "Superstore/sheets/Overview" + assert views[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].sheet_type == "dashboard" + assert views[0].favorites_total == 0 + assert views[0].view_url_name == "Overview" + assert isinstance(views[0].workbook, TSC.WorkbookItem) + assert views[0].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[0].workbook.name == "Superstore" + assert views[0].workbook.content_url == "Superstore" + assert views[0].workbook.show_tabs + assert views[0].workbook.size == 2 + assert views[0].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[0].workbook.sheet_count == 9 + assert not views[0].workbook.has_extracts + assert isinstance(views[0].owner, TSC.UserItem) + assert views[0].owner.email == "bob@example.com" + assert views[0].owner.fullname == "Bob" + assert views[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[0].owner.name == "bob@example.com" + assert views[0].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[0].project, TSC.ProjectItem) + assert views[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].project.name == "Samples" + assert views[0].project.description == "This project includes automatically uploaded samples." + assert views[0].total_views == 0 + assert isinstance(views[0].location, TSC.LocationItem) + assert views[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].location.type == "Project" + assert views[1].id == "2a3fd19d-9129-413d-9ff7-9dfc36bf7f7e" + assert views[1].name == "Product" + assert views[1].content_url == "Superstore/sheets/Product" + assert views[1].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].sheet_type == "dashboard" + assert views[1].favorites_total == 0 + assert views[1].view_url_name == "Product" + assert isinstance(views[1].workbook, TSC.WorkbookItem) + assert views[1].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[1].workbook.name == "Superstore" + assert views[1].workbook.content_url == "Superstore" + assert views[1].workbook.show_tabs + assert views[1].workbook.size == 2 + assert views[1].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[1].workbook.sheet_count == 9 + assert not views[1].workbook.has_extracts + assert isinstance(views[1].owner, TSC.UserItem) + assert views[1].owner.email == "bob@example.com" + assert views[1].owner.fullname == "Bob" + assert views[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[1].owner.name == "bob@example.com" + assert views[1].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[1].project, TSC.ProjectItem) + assert views[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].project.name == "Samples" + assert views[1].project.description == "This project includes automatically uploaded samples." + assert views[1].total_views == 0 + assert isinstance(views[1].location, TSC.LocationItem) + assert views[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].location.type == "Project" + assert views[2].id == "459eda9a-85e4-46bf-a2f2-62936bd2e99a" + assert views[2].name == "Customers" + assert views[2].content_url == "Superstore/sheets/Customers" + assert views[2].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].sheet_type == "dashboard" + assert views[2].favorites_total == 0 + assert views[2].view_url_name == "Customers" + assert isinstance(views[2].workbook, TSC.WorkbookItem) + assert views[2].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[2].workbook.name == "Superstore" + assert views[2].workbook.content_url == "Superstore" + assert views[2].workbook.show_tabs + assert views[2].workbook.size == 2 + assert views[2].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[2].workbook.sheet_count == 9 + assert not views[2].workbook.has_extracts + assert isinstance(views[2].owner, TSC.UserItem) + assert views[2].owner.email == "bob@example.com" + assert views[2].owner.fullname == "Bob" + assert views[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[2].owner.name == "bob@example.com" + assert views[2].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[2].project, TSC.ProjectItem) + assert views[2].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].project.name == "Samples" + assert views[2].project.description == "This project includes automatically uploaded samples." + assert views[2].total_views == 0 + assert isinstance(views[2].location, TSC.LocationItem) + assert views[2].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].location.type == "Project" diff --git a/test/test_workbook.py b/test/test_workbook.py index f3c2dd147..84afd7fcb 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -10,7 +10,7 @@ import pytest import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.models import UserItem, GroupItem, PermissionsRule from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError from tableauserverclient.server.request_factory import RequestFactory @@ -24,6 +24,7 @@ GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_empty.xml") GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_invalid_date.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "workbook_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "workbook_get_all_fields.xml") ODATA_XML = os.path.join(TEST_ASSET_DIR, "odata_connection.xml") POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_connections.xml") POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") @@ -978,3 +979,106 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) + + def test_get_workbook_all_fields(self) -> None: + self.server.version = "3.21" + baseurl = self.server.workbooks.baseurl + + with open(GET_XML_ALL_FIELDS) as f: + response = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response) + workbooks, _ = self.server.workbooks.get(req_options=ro) + + assert workbooks[0].id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert workbooks[0].name == "Superstore" + assert workbooks[0].content_url == "Superstore" + assert workbooks[0].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265605" + assert workbooks[0].show_tabs + assert workbooks[0].size == 2 + assert workbooks[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert workbooks[0].updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert workbooks[0].sheet_count == 9 + assert not workbooks[0].has_extracts + assert not workbooks[0].encrypt_extracts + assert workbooks[0].default_view_id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert workbooks[0].share_description == "Superstore" + assert workbooks[0].last_published_at == parse_datetime("2024-02-14T04:42:09Z") + assert isinstance(workbooks[0].project, TSC.ProjectItem) + assert workbooks[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].project.name == "Samples" + assert workbooks[0].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[0].location, TSC.LocationItem) + assert workbooks[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].location.type == "Project" + assert workbooks[0].location.name == "Samples" + assert isinstance(workbooks[0].owner, TSC.UserItem) + assert workbooks[0].owner.email == "bob@example.com" + assert workbooks[0].owner.fullname == "Bob Smith" + assert workbooks[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[0].owner.name == "bob@example.com" + assert workbooks[0].owner.site_role == "SiteAdministratorCreator" + assert workbooks[1].id == "6693cb26-9507-4174-ad3e-9de81a18c971" + assert workbooks[1].name == "World Indicators" + assert workbooks[1].content_url == "WorldIndicators" + assert workbooks[1].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265606" + assert workbooks[1].show_tabs + assert workbooks[1].size == 1 + assert workbooks[1].created_at == parse_datetime("2024-02-14T04:42:11Z") + assert workbooks[1].updated_at == parse_datetime("2024-02-14T04:42:12Z") + assert workbooks[1].sheet_count == 8 + assert not workbooks[1].has_extracts + assert not workbooks[1].encrypt_extracts + assert workbooks[1].default_view_id == "3d10dbcf-a206-47c7-91ba-ebab3ab33d7c" + assert workbooks[1].share_description == "World Indicators" + assert workbooks[1].last_published_at == parse_datetime("2024-02-14T04:42:11Z") + assert isinstance(workbooks[1].project, TSC.ProjectItem) + assert workbooks[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].project.name == "Samples" + assert workbooks[1].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[1].location, TSC.LocationItem) + assert workbooks[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].location.type == "Project" + assert workbooks[1].location.name == "Samples" + assert isinstance(workbooks[1].owner, TSC.UserItem) + assert workbooks[1].owner.email == "bob@example.com" + assert workbooks[1].owner.fullname == "Bob Smith" + assert workbooks[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[1].owner.name == "bob@example.com" + assert workbooks[1].owner.site_role == "SiteAdministratorCreator" + assert workbooks[2].id == "dbc0f162-909f-4edf-8392-0d12a80af955" + assert workbooks[2].name == "Superstore" + assert workbooks[2].description == "This is a superstore workbook" + assert workbooks[2].content_url == "Superstore_17078880698360" + assert workbooks[2].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265621" + assert not workbooks[2].show_tabs + assert workbooks[2].size == 1 + assert workbooks[2].created_at == parse_datetime("2024-02-14T05:21:09Z") + assert workbooks[2].updated_at == parse_datetime("2024-07-02T02:19:59Z") + assert workbooks[2].sheet_count == 7 + assert workbooks[2].has_extracts + assert not workbooks[2].encrypt_extracts + assert workbooks[2].default_view_id == "8c4b1d3e-3f31-4d2a-8b9f-492b92f27987" + assert workbooks[2].share_description == "Superstore" + assert workbooks[2].last_published_at == parse_datetime("2024-07-02T02:19:58Z") + assert isinstance(workbooks[2].project, TSC.ProjectItem) + assert workbooks[2].project.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].project.name == "default" + assert workbooks[2].project.description == "The default project that was automatically created by Tableau." + assert isinstance(workbooks[2].location, TSC.LocationItem) + assert workbooks[2].location.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].location.type == "Project" + assert workbooks[2].location.name == "default" + assert isinstance(workbooks[2].owner, TSC.UserItem) + assert workbooks[2].owner.email == "bob@example.com" + assert workbooks[2].owner.fullname == "Bob Smith" + assert workbooks[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[2].owner.name == "bob@example.com" + assert workbooks[2].owner.site_role == "SiteAdministratorCreator"