diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbf46748..aa01dac19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.12 (10 July 2020) + +* Added hidden_views parameter to workbook publish method (#614) +* Added simple paging endpoint for GraphQL/Metadata API (#623) +* Added endpoints to Metadata API for retrieving backfill/eventing status (#626) +* Added maxage parameter to CSV and PDF export options (#635) +* Added support for querying, adding, and deleting favorites (#638) +* Added a sample for publishing datasources (#644) + ## 0.11 (1 May 2020) * Added more fields to Data Acceleration config (#588) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index cc92fc435..e5c80d4ac 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -36,6 +36,7 @@ The following people have contributed to this project to make it possible, and w * [Geraldine Zanolli](https://github.com/illonage) * [Jordan Woods](https://github.com/jorwoods) * [Reba Magier](https://github.com/rmagier1) +* [Stephen Mitchell](https://github.com/scuml) ## Core Team diff --git a/samples/create_group.py b/samples/create_group.py index 3b7892fdf..c6865bc56 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -2,7 +2,7 @@ # This script demonstrates how to create groups using the Tableau # Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### diff --git a/samples/create_project.py b/samples/create_project.py index 744b056d4..ac55da17e 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -4,7 +4,7 @@ # parent_id. # # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/create_schedules.py b/samples/create_schedules.py index c8d32b087..c1bcb712f 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -2,7 +2,7 @@ # This script demonstrates how to create schedules using the Tableau # Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### diff --git a/samples/download_view_image.py b/samples/download_view_image.py index df2331596..ce6dd3165 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -5,7 +5,7 @@ # For more information, refer to the documentations on 'Query View Image' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 3b585327d..fa0c2318e 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -2,7 +2,7 @@ # This script demonstrates how to filter groups using the Tableau # Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index d247c44dc..91633f38f 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -3,7 +3,7 @@ # to filter and sort on the name of the projects present on site. # # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 9c5f52a50..9d3c7836a 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to kill all of the running jobs # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/list.py b/samples/list.py index e103eb862..84b3c70d2 100644 --- a/samples/list.py +++ b/samples/list.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to list all of the workbooks or datasources # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/login.py b/samples/login.py index d3862503d..57a929f6b 100644 --- a/samples/login.py +++ b/samples/login.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to log in to Tableau Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index 8bb1b4e50..c31425f25 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -4,7 +4,7 @@ # a workbook that matches a given name and update it to be in # the desired project. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 40f0350e5..08bde0ec6 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -4,7 +4,7 @@ # a workbook that matches a given name, download the workbook, # and then publish it to the destination site. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py new file mode 100644 index 000000000..fa0fe2a95 --- /dev/null +++ b/samples/publish_datasource.py @@ -0,0 +1,85 @@ +#### +# This script demonstrates how to use the Tableau Server Client +# to publish a datasource to a Tableau server. It will publish +# a specified datasource to the 'default' project of the provided site. +# +# Some optional arguments are provided to demonstrate async publishing, +# as well as providing connection credentials when publishing. If the +# provided datasource file is over 64MB in size, TSC will automatically +# publish the datasource using the chunking method. +# +# For more information, refer to the documentations: +# (https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_datasources.htm#publish_data_source) +# +# For signing into server, this script uses personal access tokens. For +# more information on personal access tokens, refer to the documentations: +# (https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm) +# +# To run the script, you must have installed Python 3.5 or later. +#### + +import argparse +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Publish a datasource to server.') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-i', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') + parser.add_argument('--filepath', '-f', required=True, help='filepath to the datasource to publish') + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + parser.add_argument('--async', '-a', help='Publishing asynchronously', dest='async_', action='store_true') + parser.add_argument('--conn-username', help='connection username') + parser.add_argument('--conn-password', help='connection password') + parser.add_argument('--conn-embed', help='embed connection password to datasource', action='store_true') + parser.add_argument('--conn-oauth', help='connection is configured to use oAuth', action='store_true') + + args = parser.parse_args() + + # Ensure that both the connection username and password are provided, or none at all + if (args.conn_username and not args.conn_password) or (not args.conn_username and args.conn_password): + parser.error("Both the connection username and password must be provided") + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + # Sign in to server + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + # Create a new datasource item to publish - empty project_id field + # will default the publish to the site's default project + new_datasource = TSC.DatasourceItem(project_id="") + + # Create a connection_credentials item if connection details are provided + new_conn_creds = None + if args.conn_username: + new_conn_creds = TSC.ConnectionCredentials(args.conn_username, args.conn_password, + embed=args.conn_embed, oauth=args.conn_oauth) + + # Define publish mode - Overwrite, Append, or CreateNew + publish_mode = TSC.Server.PublishMode.Overwrite + + # Publish datasource + if args.async_: + # Async publishing, returns a job_item + new_job = server.datasources.publish(new_datasource, args.filepath, publish_mode, + connection_credentials=new_conn_creds, as_job=True) + print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) + else: + # Normal publishing, returns a datasource_item + new_datasource = server.datasources.publish(new_datasource, args.filepath, publish_mode, + connection_credentials=new_conn_creds) + print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) + + +if __name__ == '__main__': + main() diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 2d460abaf..927e9c3ad 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -11,7 +11,7 @@ # For more information, refer to the documentations on 'Publish Workbook' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/refresh.py b/samples/refresh.py index 58e3110f3..ba3a2f183 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to use trigger a refresh on a datasource or workbook # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 214a2131b..f722adb30 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to query extract refresh tasks and run them as needed. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/set_http_options.py b/samples/set_http_options.py index fb5ce2441..9316dfdde 100644 --- a/samples/set_http_options.py +++ b/samples/set_http_options.py @@ -2,7 +2,7 @@ # This script demonstrates how to set http options. It will set the option # to not verify SSL certificate, and query all workbooks on site. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/update_connection.py b/samples/update_connection.py index 69e4e6377..3449441a4 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to update a connections credentials on a server to embed the credentials # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index d15a3a801..95041f8e1 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -1,6 +1,8 @@ import datetime -# This code below is from the python documentation for tzinfo: https://docs.python.org/2.3/lib/datetime-tzinfo.html +# This code below is from the python documentation for +# tzinfo: https://docs.python.org/2.3/lib/datetime-tzinfo.html + ZERO = datetime.timedelta(0) HOUR = datetime.timedelta(hours=1) diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index b5b50fe59..c86057a3d 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -5,6 +5,7 @@ from .datasource_item import DatasourceItem from .database_item import DatabaseItem from .exceptions import UnpopulatedPropertyError +from .favorites_item import FavoriteItem from .group_item import GroupItem from .flow_item import FlowItem from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index 475dd0e2a..9bf198220 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -1,7 +1,6 @@ import xml.etree.ElementTree as ET -from .property_decorators import property_is_enum, property_not_empty -from .exceptions import UnpopulatedPropertyError +from .property_decorators import property_not_empty class ColumnItem(object): diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 829564839..8f923fecb 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -32,8 +32,10 @@ def connection_type(self): return self._connection_type def __repr__(self): - return ""\ - .format(**self.__dict__) + return ( + "".format(**self.__dict__) + ) @classmethod def from_response(cls, resp, ns): @@ -76,11 +78,13 @@ def from_xml_element(cls, parsed_response, ns): connection_item.server_address = connection_xml.get('serverAddress', None) connection_item.server_port = connection_xml.get('serverPort', None) - connection_credentials = connection_xml.find('.//t:connectionCredentials', namespaces=ns) + connection_credentials = connection_xml.find( + './/t:connectionCredentials', namespaces=ns) if connection_credentials is not None: - connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials) + connection_item.connection_credentials = ConnectionCredentials.from_xml_element( + connection_credentials) return all_connection_items diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 2f056d0c4..2b443a3d1 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -21,10 +21,6 @@ def site(self): def sheet_uri(self): return self._sheet_uri - @property - def site(self): - return self._site - @property def unaccelerated_session_count(self): return self._unaccelerated_session_count diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 9aecca6cc..5a7e74737 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -1,7 +1,5 @@ import xml.etree.ElementTree as ET -from .permissions_item import Permission - from .property_decorators import property_is_enum, property_not_empty, property_is_boolean from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py new file mode 100644 index 000000000..7d2408f93 --- /dev/null +++ b/tableauserverclient/models/favorites_item.py @@ -0,0 +1,49 @@ +import xml.etree.ElementTree as ET +import logging +from .workbook_item import WorkbookItem +from .view_item import ViewItem +from .project_item import ProjectItem +from .datasource_item import DatasourceItem + +logger = logging.getLogger('tableau.models.favorites_item') + + +class FavoriteItem: + class Type: + Workbook = 'workbook' + Datasource = 'datasource' + View = 'view' + Project = 'project' + + @classmethod + def from_response(cls, xml, namespace): + favorites = { + 'datasources': [], + 'projects': [], + 'views': [], + 'workbooks': [], + } + + parsed_response = ET.fromstring(xml) + for workbook in parsed_response.findall('.//t:favorite/t:workbook', namespace): + fav_workbook = WorkbookItem('') + fav_workbook._set_values(*fav_workbook._parse_element(workbook, namespace)) + if fav_workbook: + favorites['workbooks'].append(fav_workbook) + for view in parsed_response.findall('.//t:favorite[t:view]', namespace): + fav_views = ViewItem.from_xml_element(view, namespace) + if fav_views: + for fav_view in fav_views: + favorites['views'].append(fav_view) + for datasource in parsed_response.findall('.//t:favorite/t:datasource', namespace): + fav_datasource = DatasourceItem('') + fav_datasource._set_values(*fav_datasource._parse_element(datasource, namespace)) + if fav_datasource: + favorites['datasources'].append(fav_datasource) + for project in parsed_response.findall('.//t:favorite/t:project', namespace): + fav_project = ProjectItem('p') + fav_project._set_values(*fav_project._parse_element(project)) + if fav_project: + favorites['projects'].append(fav_project) + + return favorites diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 790000df2..c978d8175 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean +from .property_decorators import property_not_nullable from .tag_item import TagItem from ..datetime_helpers import parse_datetime import copy diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 484ee709f..cbc148e88 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -173,7 +173,7 @@ def interval(self, interval_value): try: if not (1 <= int(interval_value) <= 31): raise ValueError(error) - except ValueError as e: + except ValueError: if interval_value != "LastDay": raise ValueError(error) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 6ad7f0256..58d1f1396 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,7 +1,5 @@ import xml.etree.ElementTree as ET from ..datetime_helpers import parse_datetime -from .target import Target -from ..datetime_helpers import parse_datetime class JobItem(object): diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 98d6b42f9..a1f5409e3 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -31,10 +31,10 @@ def from_response(cls, resp, ns): return pagination_item @classmethod - def from_single_page_list(cls, l): + def from_single_page_list(cls, single_page_list): item = cls() item._page_number = 1 - item._page_size = len(l) - item._total_available = len(l) + item._page_size = len(single_page_list) + item._total_available = len(single_page_list) return item diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py index 875f68c48..13a2391b8 100644 --- a/tableauserverclient/models/personal_access_token_auth.py +++ b/tableauserverclient/models/personal_access_token_auth.py @@ -8,7 +8,12 @@ def __init__(self, token_name, personal_access_token, site_id=''): @property def credentials(self): - return {'personalAccessTokenName': self.token_name, 'personalAccessTokenSecret': self.personal_access_token} + return { + 'personalAccessTokenName': self.token_name, + 'personalAccessTokenSecret': self.personal_access_token + } def __repr__(self): - return "".format(self.token_name, self.personal_access_token) + return "".format( + self.token_name, self.personal_access_token + ) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 15223e695..d6aece83b 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -77,9 +77,9 @@ def name(self, value): def is_default(self): return self.name.lower() == 'default' - def _parse_common_tags(self, project_xml): + def _parse_common_tags(self, project_xml, ns): if not isinstance(project_xml, ET.Element): - project_xml = ET.fromstring(project_xml).find('.//t:project', namespaces=NAMESPACE) + project_xml = ET.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) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 2a47c889a..f1625d112 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -84,7 +84,7 @@ def property_is_int(range, allowed=None): def property_type_decorator(func): @wraps(func) def wrapper(self, value): - error = "Invalid priority defined: {}.".format(value) + error = "Invalid property defined: '{}'. Integer value expected.".format(value) if range is None: if isinstance(value, int): diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 5a99fefc2..1a93c60d2 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,5 +1,4 @@ import xml.etree.ElementTree as ET -from .exceptions import UnpopulatedPropertyError from .target import Target diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 8d8f63674..2f00ef2b7 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -1,9 +1,6 @@ import xml.etree.ElementTree as ET -from .permissions_item import Permission -from .column_item import ColumnItem - -from .property_decorators import property_is_enum, property_not_empty, property_is_boolean +from .property_decorators import property_not_empty, property_is_boolean from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 780412af9..2f3e6f3aa 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -36,7 +36,6 @@ def from_response(cls, xml, ns, task_type=Type.ExtractRefresh): @classmethod def _parse_element(cls, element, ns): - schedule_id = None schedule_item = None target = None last_run_at = None diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 3df2004bf..9be38210f 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -42,6 +42,7 @@ def __init__(self, name=None, site_role=None, auth_setting=None): self._id = None self._last_login = None self._workbooks = None + self._favorites = None self.email = None self.fullname = None self.name = name @@ -99,6 +100,13 @@ def workbooks(self): raise UnpopulatedPropertyError(error) return self._workbooks() + @property + def favorites(self): + if self._favorites is None: + error = "User item must be populated with favorites first." + raise UnpopulatedPropertyError(error) + return self._favorites + def to_reference(self): return ResourceReference(id_=self.id, tag_name=self.tag_name) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 90fdd4ba2..57bcfeaa4 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,10 +1,5 @@ import xml.etree.ElementTree as ET -from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean, property_is_data_acceleration_config -from .tag_item import TagItem -from .view_item import ViewItem -from .permissions_item import PermissionsRule -from ..datetime_helpers import parse_datetime + import re @@ -86,4 +81,5 @@ def _parse_element(webhook_xml, ns): return id, name, url, event, owner_id def __repr__(self): - return "".format(self.id, self.name, self.url, self.event) + return "".format( + self.id, self.name, self.url, self.event) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index f382d0dba..aff549559 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -8,7 +8,7 @@ PermissionsRule, Permission, ColumnItem, FlowItem, WebhookItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ - MissingRequiredFieldError, Flows + MissingRequiredFieldError, Flows, Favorites from .server import Server from .pager import Pager from .exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index fce86f98d..1341ecd3f 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -3,6 +3,7 @@ from .datasources_endpoint import Datasources from .databases_endpoint import Databases from .endpoint import Endpoint +from .favorites_endpoint import Favorites from .flows_endpoint import Flows from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError from .groups_endpoint import Groups diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index b84a38643..fcc2806c6 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -18,7 +18,8 @@ def __init__(self, parent_srv): @property def baseurl(self): - return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return "{0}/sites/{1}/dataAccelerationReport".format( + self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.8") def get(self, req_options=None): diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index d0fd24c78..85dd406ef 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -3,7 +3,7 @@ from .permissions_endpoint import _PermissionsEndpoint from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .. import RequestFactory, DatabaseItem, PaginationItem, PermissionsRule, Permission +from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Permission import logging diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 44dea28df..7a00157fe 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,14 +1,12 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError -from .endpoint import api, parameter_added_in, Endpoint from .permissions_endpoint import _PermissionsEndpoint -from .exceptions import MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem from ...filesys_helpers import to_filename, make_download_path -from ...models.tag_item import TagItem from ...models.job_item import JobItem + import os import logging import copy @@ -129,7 +127,8 @@ def update(self, datasource_item): server_response = self.put_request(url, update_req) logger.info('Updated datasource item (ID: {0})'.format(datasource_item.id)) updated_datasource = copy.copy(datasource_item) - return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace) + return updated_datasource._parse_common_elements( + server_response.content, self.parent_srv.namespace) # Update datasource connections @api(version="2.3") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 0dff025a1..d435a03d6 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -3,7 +3,7 @@ from .. import RequestFactory from ...models import PermissionsRule -from .endpoint import Endpoint, api +from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -11,13 +11,14 @@ class _DefaultPermissionsEndpoint(Endpoint): - ''' Adds default-permission model to another endpoint + """ Adds default-permission model to another endpoint Tableau default-permissions model applies only to databases and projects and then takes an object type in the uri to set the defaults. This class is meant to be instantated inside a parent endpoint which has these supported endpoints - ''' + """ + def __init__(self, parent_srv, owner_baseurl): super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 2b2bca229..5e48b5cc2 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -74,7 +74,7 @@ def _check_status(self, server_response): # we convert this to a better exception and pass through the raw # response body raise NonXMLResponseError(server_response.content) - except Exception as e: + except Exception: # anything else re-raise here raise diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 757ca5552..3c9226f0f 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -50,6 +50,10 @@ class NonXMLResponseError(Exception): pass +class InvalidGraphQLQuery(Exception): + pass + + class GraphQLError(Exception): def __init__(self, error_payload): self.error = error_payload diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py new file mode 100644 index 000000000..b1a90ba00 --- /dev/null +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -0,0 +1,77 @@ +from .endpoint import Endpoint, api +from .exceptions import MissingRequiredFieldError +from .. import RequestFactory +from ...models import FavoriteItem +from ..pager import Pager +import xml.etree.ElementTree as ET +import logging +import copy + +logger = logging.getLogger('tableau.endpoint.favorites') + + +class Favorites(Endpoint): + @property + def baseurl(self): + return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + # Gets all favorites + @api(version="2.5") + def get(self, user_item, req_options=None): + logger.info('Querying all favorites for user {0}'.format(user_item.name)) + url = '{0}/{1}'.format(self.baseurl, user_item.id) + server_response = self.get_request(url, req_options) + + user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) + + @api(version="2.0") + def add_favorite_workbook(self, user_item, workbook_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(workbook_item.name, user_item.id)) + + @api(version="2.0") + def add_favorite_view(self, user_item, view_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(view_item.name, user_item.id)) + + @api(version="2.3") + def add_favorite_datasource(self, user_item, datasource_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(datasource_item.name, user_item.id)) + + @api(version="3.1") + def add_favorite_project(self, user_item, project_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(project_item.name, user_item.id)) + + @api(version="2.0") + def delete_favorite_workbook(self, user_item, workbook_item): + url = '{0}/{1}/workbooks/{2}'.format(self.baseurl, user_item.id, workbook_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(workbook_item.id, user_item.id)) + self.delete_request(url) + + @api(version="2.0") + def delete_favorite_view(self, user_item, view_item): + url = '{0}/{1}/views/{2}'.format(self.baseurl, user_item.id, view_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(view_item.id, user_item.id)) + self.delete_request(url) + + @api(version="2.3") + def delete_favorite_datasource(self, user_item, datasource_item): + url = '{0}/{1}/datasources/{2}'.format(self.baseurl, user_item.id, datasource_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(datasource_item.id, user_item.id)) + self.delete_request(url) + + @api(version="3.1") + def delete_favorite_project(self, user_item, project_item): + url = '{0}/{1}/projects/{2}'.format(self.baseurl, user_item.id, project_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(project_item.id, user_item.id)) + self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 7bad807e4..44a110e7e 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,14 +1,12 @@ -from .endpoint import Endpoint, api, parameter_added_in +from .endpoint import Endpoint, api from .exceptions import InternalServerError, MissingRequiredFieldError -from .endpoint import api, parameter_added_in, Endpoint from .permissions_endpoint import _PermissionsEndpoint -from .exceptions import MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem from ...filesys_helpers import to_filename, make_download_path -from ...models.tag_item import TagItem from ...models.job_item import JobItem + import os import logging import copy diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 2428ff9be..e0acb4477 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -1,8 +1,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from ...models.exceptions import UnpopulatedPropertyError from .. import RequestFactory, GroupItem, UserItem, PaginationItem from ..pager import Pager + import logging logger = logging.getLogger('tableau.endpoint.groups') diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index e70c9c313..d8bbe39c7 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,6 +1,7 @@ from .endpoint import Endpoint, api from .. import JobItem, BackgroundJobItem, PaginationItem from ..request_options import RequestOptionsBase + import logging try: diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 002379407..ac111d6ef 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -1,26 +1,68 @@ from .endpoint import Endpoint, api -from .exceptions import GraphQLError +from .exceptions import GraphQLError, InvalidGraphQLQuery import logging import json + logger = logging.getLogger('tableau.endpoint.metadata') +def is_valid_paged_query(parsed_query): + """Check that the required $first and $afterToken variables are present in the query. + Also check that we are asking for the pageInfo object, so we get the endCursor. There + is no way to do this relilably without writing a GraphQL parser, so simply check that + that the string contains 'hasNextPage' and 'endCursor'""" + return all(k in parsed_query['variables'] for k in ('first', 'afterToken')) and \ + 'hasNextPage' in parsed_query['query'] and \ + 'endCursor' in parsed_query['query'] + + +def extract_values(obj, key): + """Pull all values of specified key from nested JSON. + Taken from: https://hackersandslackers.com/extract-data-from-complex-json-python/""" + arr = [] + + def extract(obj, arr, key): + """Recursively search for values of key in JSON tree.""" + if isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(v, (dict, list)): + extract(v, arr, key) + elif k == key: + arr.append(v) + elif isinstance(obj, list): + for item in obj: + extract(item, arr, key) + return arr + + results = extract(obj, arr, key) + return results + + +def get_page_info(result): + next_page = extract_values(result, 'hasNextPage').pop() + cursor = extract_values(result, 'endCursor').pop() + return next_page, cursor + + class Metadata(Endpoint): @property def baseurl(self): return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) - @api("3.2") + @property + def control_baseurl(self): + return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) + + @api("3.5") def query(self, query, variables=None, abort_on_error=False): logger.info('Querying Metadata API') url = self.baseurl try: graphql_query = json.dumps({'query': query, 'variables': variables}) - except Exception: - # Place holder for now - raise Exception('Must provide a string') + except Exception as e: + raise InvalidGraphQLQuery('Must provide a string') # Setting content type because post_reuqest defaults to text/xml server_response = self.post_request(url, graphql_query, content_type='text/json') @@ -30,3 +72,67 @@ def query(self, query, variables=None, abort_on_error=False): raise GraphQLError(results['errors']) return results + + @api("3.9") + def backfill_status(self): + url = self.control_baseurl + "/backfill/status" + response = self.get_request(url) + return response.json() + + @api("3.9") + def eventing_status(self): + url = self.control_baseurl + "/eventing/status" + response = self.get_request(url) + return response.json() + + @api("3.5") + def paginated_query(self, query, variables=None, abort_on_error=False): + logger.info('Querying Metadata API using a Paged Query') + url = self.baseurl + + if variables is None: + # default paramaters + variables = {'first': 100, 'afterToken': None} + elif (('first' in variables) and ('afterToken' not in variables)): + # they passed a page size but not a token, probably because they're starting at `null` token + variables.update({'afterToken': None}) + + graphql_query = json.dumps({'query': query, 'variables': variables}) + parsed_query = json.loads(graphql_query) + + if not is_valid_paged_query(parsed_query): + raise InvalidGraphQLQuery('Paged queries must have a `$first` and `$afterToken` variables as well as ' + 'a pageInfo object with `endCursor` and `hasNextPage`') + + results_dict = {'pages': []} + paginated_results = results_dict['pages'] + + # get first page + server_response = self.post_request(url, graphql_query, content_type='text/json') + results = server_response.json() + + if abort_on_error and results.get('errors', None): + raise GraphQLError(results['errors']) + + paginated_results.append(results) + + # repeat + has_another_page, cursor = get_page_info(results) + + while has_another_page: + # Update the page + variables.update({'afterToken': cursor}) + # make the call + logger.debug("Calling Token: " + cursor) + graphql_query = json.dumps({'query': query, 'variables': variables}) + server_response = self.post_request(url, graphql_query, content_type='text/json') + results = server_response.json() + # verify response + if abort_on_error and results.get('errors', None): + raise GraphQLError(results['errors']) + # save results and repeat + paginated_results.append(results) + has_another_page, cursor = get_page_info(results) + + logger.info('Sucessfully got all results for paged query') + return results_dict diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index b28d6fa17..585fd0052 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -2,7 +2,7 @@ from .. import RequestFactory, PermissionsRule -from .endpoint import Endpoint, api +from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -10,13 +10,14 @@ class _PermissionsEndpoint(Endpoint): - ''' Adds permission model to another endpoint + """ Adds permission model to another endpoint Tableau permissions model is identical between objects but they are nested under the parent object endpoint (i.e. permissions for workbooks are under /workbooks/:id/permission). This class is meant to be instantated inside a parent endpoint which has these supported endpoints - ''' + """ + def __init__(self, parent_srv, owner_baseurl): super(_PermissionsEndpoint, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index f0eb92626..a7f22795c 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -3,7 +3,7 @@ from .permissions_endpoint import _PermissionsEndpoint from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .. import RequestFactory, ProjectItem, PaginationItem, PermissionsRule, Permission +from .. import RequestFactory, ProjectItem, PaginationItem, Permission import logging diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 06fb7e408..29389c693 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, PaginationItem, ScheduleItem, WorkbookItem, DatasourceItem, TaskItem +from .. import RequestFactory, PaginationItem, ScheduleItem, TaskItem import logging import copy from collections import namedtuple diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 6d67fe69e..8a6212a28 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -1,8 +1,9 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import RequestFactory, SiteItem, PaginationItem -import logging + import copy +import logging logger = logging.getLogger('tableau.endpoint.sites') diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index 70422e208..00a7c6856 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import Endpoint, api -from .exceptions import MissingRequiredFieldError from .. import RequestFactory, SubscriptionItem, PaginationItem + import logging logger = logging.getLogger('tableau.endpoint.subscriptions') diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index b8430a124..032f13016 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,10 +1,9 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .default_permissions_endpoint import _DefaultPermissionsEndpoint from ..pager import Pager -from .. import RequestFactory, TableItem, ColumnItem, PaginationItem, PermissionsRule, Permission +from .. import RequestFactory, TableItem, ColumnItem, PaginationItem import logging diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index d08209769..a3e5e7b34 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,6 +1,7 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import TaskItem, PaginationItem, RequestFactory + import logging logger = logging.getLogger('tableau.endpoint.tasks') diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 0949a5e5b..3ce1f16ab 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -2,8 +2,9 @@ from .exceptions import MissingRequiredFieldError from .. import RequestFactory, UserItem, WorkbookItem, PaginationItem from ..pager import Pager -import logging + import copy +import logging logger = logging.getLogger('tableau.endpoint.users') diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 85ae70f93..7c8a4768e 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -2,10 +2,10 @@ from .exceptions import MissingRequiredFieldError from .resource_tagger import _ResourceTagger from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, ViewItem, PaginationItem -from ...models.tag_item import TagItem -import logging +from .. import ViewItem, PaginationItem + from contextlib import closing +import logging logger = logging.getLogger('tableau.endpoint.views') diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 4e69974d1..fe108a27d 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -1,8 +1,9 @@ -from .endpoint import Endpoint, api, parameter_added_in +from .endpoint import Endpoint, api from ...models import WebhookItem, PaginationItem from .. import RequestFactory import logging + logger = logging.getLogger('tableau.endpoint.webhooks') diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 1559bc41b..82a5f9cd0 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,11 +1,9 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .exceptions import MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem -from ...models.tag_item import TagItem from ...models.job_item import JobItem from ...filesys_helpers import to_filename, make_download_path @@ -234,7 +232,11 @@ def delete_permission(self, item, capability_item): @api(version="2.0") @parameter_added_in(as_job='3.0') @parameter_added_in(connections='2.8') - def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None, as_job=False): + def publish( + self, workbook_item, file_path, mode, + connection_credentials=None, connections=None, as_job=False, + hidden_views=None + ): if connection_credentials is not None: import warnings @@ -277,7 +279,8 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(workbook_item, connection_credentials=conn_creds, - connections=connections) + connections=connections, + hidden_views=hidden_views) else: logger.info('Publishing {0} to server'.format(filename)) with open(file_path, 'rb') as f: @@ -287,7 +290,8 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c filename, file_contents, connection_credentials=conn_creds, - connections=connections) + connections=connections, + hidden_views=hidden_views) logger.debug('Request xml: {0} '.format(xml_request[:1000])) # Send the publishing request to server diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 75ac8be4e..0e2382fae 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,7 +1,6 @@ from functools import partial from . import RequestOptions -from . import Sort class Pager(object): diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 87529b84f..9c869c686 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,11 +1,9 @@ -from ..datetime_helpers import format_datetime import xml.etree.ElementTree as ET -from functools import wraps from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata -from ..models import TaskItem, UserItem, GroupItem, PermissionsRule +from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem def _add_multipart(parts): @@ -38,6 +36,12 @@ def _add_connections_element(connections_element, connection): _add_credentials_element(connection_element, connection_credentials) +def _add_hiddenview_element(views_element, view_name): + view_element = ET.SubElement(views_element, 'view') + view_element.attrib['name'] = view_name + view_element.attrib['hidden'] = "true" + + def _add_credentials_element(parent_element, connection_credentials): credentials_element = ET.SubElement(parent_element, 'connectionCredentials') credentials_element.attrib['name'] = connection_credentials.name @@ -145,6 +149,34 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) +class FavoriteRequest(object): + def _add_to_req(self, id_, target_type, label): + ''' + + + + ''' + xml_request = ET.Element('tsRequest') + favorite_element = ET.SubElement(xml_request, 'favorite') + target = ET.SubElement(favorite_element, target_type) + favorite_element.attrib['label'] = label + target.attrib['id'] = id_ + + return ET.tostring(xml_request) + + def add_datasource_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.Datasource, name) + + def add_project_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.Project, name) + + def add_view_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.View, name) + + def add_workbook_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.Workbook, name) + + class FileuploadRequest(object): def chunk_req(self, chunk): parts = {'request_payload': ('', '', 'text/xml'), @@ -448,7 +480,11 @@ def add_req(self, user_item): class WorkbookRequest(object): - def _generate_xml(self, workbook_item, connection_credentials=None, connections=None): + def _generate_xml( + self, workbook_item, + connection_credentials=None, connections=None, + hidden_views=None + ): xml_request = ET.Element('tsRequest') workbook_element = ET.SubElement(xml_request, 'workbook') workbook_element.attrib['name'] = workbook_item.name @@ -467,6 +503,12 @@ def _generate_xml(self, workbook_item, connection_credentials=None, connections= connections_element = ET.SubElement(workbook_element, 'connections') for connection in connections: _add_connections_element(connections_element, connection) + + if hidden_views is not None: + views_element = ET.SubElement(workbook_element, 'views') + for view_name in hidden_views: + _add_hiddenview_element(views_element, view_name) + return ET.tostring(xml_request) def update_req(self, workbook_item): @@ -494,19 +536,27 @@ def update_req(self, workbook_item): return ET.tostring(xml_request) - def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None, connections=None): + def publish_req( + self, workbook_item, filename, file_contents, + connection_credentials=None, connections=None, hidden_views=None + ): xml_request = self._generate_xml(workbook_item, connection_credentials=connection_credentials, - connections=connections) + connections=connections, + hidden_views=hidden_views) parts = {'request_payload': ('', xml_request, 'text/xml'), 'tableau_workbook': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) - def publish_req_chunked(self, workbook_item, connection_credentials=None, connections=None): + def publish_req_chunked( + self, workbook_item, connection_credentials=None, connections=None, + hidden_views=None + ): xml_request = self._generate_xml(workbook_item, connection_credentials=connection_credentials, - connections=connections) + connections=connections, + hidden_views=hidden_views) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) @@ -583,6 +633,7 @@ class RequestFactory(object): Datasource = DatasourceRequest() Database = DatabaseRequest() Empty = EmptyRequest() + Favorite = FavoriteRequest() Fileupload = FileuploadRequest() Flow = FlowRequest() Group = GroupRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index af2ed27de..530d7d1f0 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,3 +1,6 @@ +from ..models.property_decorators import property_is_int + + class RequestOptionsBase(object): def apply_query_params(self, url): raise NotImplementedError() @@ -83,6 +86,7 @@ def apply_query_params(self, url): class _FilterOptionsBase(RequestOptionsBase): """ Provide a basic implementation of adding view filters to the url """ + def __init__(self): self.view_filters = [] @@ -99,8 +103,24 @@ def _append_view_filters(self, params): class CSVRequestOptions(_FilterOptionsBase): + def __init__(self, maxage=-1): + super(CSVRequestOptions, self).__init__() + self.max_age = maxage + + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value + def apply_query_params(self, url): params = [] + if self.max_age != -1: + params.append('maxAge={0}'.format(self.max_age)) + self._append_view_filters(params) return "{0}?{1}".format(url, '&'.join(params)) @@ -110,16 +130,25 @@ class ImageRequestOptions(_FilterOptionsBase): class Resolution: High = 'high' - def __init__(self, imageresolution=None, maxage=None): + def __init__(self, imageresolution=None, maxage=-1): super(ImageRequestOptions, self).__init__() self.image_resolution = imageresolution self.max_age = maxage + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value + def apply_query_params(self, url): params = [] if self.image_resolution: params.append('resolution={0}'.format(self.image_resolution)) - if self.max_age: + if self.max_age != -1: params.append('maxAge={0}'.format(self.max_age)) self._append_view_filters(params) @@ -147,10 +176,20 @@ class Orientation: Portrait = "portrait" Landscape = "landscape" - def __init__(self, page_type=None, orientation=None): + def __init__(self, page_type=None, orientation=None, maxage=-1): super(PDFRequestOptions, self).__init__() self.page_type = page_type self.orientation = orientation + self.max_age = maxage + + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value def apply_query_params(self, url): params = [] @@ -160,6 +199,9 @@ def apply_query_params(self, url): if self.orientation: params.append('orientation={0}'.format(self.orientation)) + if self.max_age != -1: + params.append('maxAge={0}'.format(self.max_age)) + self._append_view_filters(params) return "{0}?{1}".format(url, '&'.join(params)) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 42accf722..c36ee0f4b 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -3,8 +3,8 @@ from .exceptions import NotSignedInError from ..namespace import Namespace from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ - Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs, Metadata,\ - Databases, Tables, Flows, Webhooks, DataAccelerationReport + Schedules, ServerInfo, Tasks, Subscriptions, Jobs, Metadata,\ + Databases, Tables, Flows, Webhooks, DataAccelerationReport, Favorites from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -46,6 +46,7 @@ def __init__(self, server_address, use_server_version=False): self.jobs = Jobs(self) self.workbooks = Workbooks(self) self.datasources = Datasources(self) + self.favorites = Favorites(self) self.flows = Flows(self) self.projects = Projects(self) self.schedules = Schedules(self) diff --git a/test/assets/favorites_add_datasource.xml b/test/assets/favorites_add_datasource.xml new file mode 100644 index 000000000..a1f47ab4f --- /dev/null +++ b/test/assets/favorites_add_datasource.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_add_project.xml b/test/assets/favorites_add_project.xml new file mode 100644 index 000000000..699e6a4cd --- /dev/null +++ b/test/assets/favorites_add_project.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_add_view.xml b/test/assets/favorites_add_view.xml new file mode 100644 index 000000000..f6fc15c9a --- /dev/null +++ b/test/assets/favorites_add_view.xml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_add_workbook.xml b/test/assets/favorites_add_workbook.xml new file mode 100644 index 000000000..c8008c9b8 --- /dev/null +++ b/test/assets/favorites_add_workbook.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_get.xml b/test/assets/favorites_get.xml new file mode 100644 index 000000000..3d2e2ee6a --- /dev/null +++ b/test/assets/favorites_get.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/metadata_paged_1.json b/test/assets/metadata_paged_1.json new file mode 100644 index 000000000..c1cc0318e --- /dev/null +++ b/test/assets/metadata_paged_1.json @@ -0,0 +1,15 @@ +{ + "data": { + "publishedDatasourcesConnection": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwMzllNWQ1LTI1ZmEtMTk2Yi1jNjZlLWMwNjc1ODM5ZTBiMCJ9fQ==" + }, + "nodes": [ + { + "id": "0039e5d5-25fa-196b-c66e-c0675839e0b0" + } + ] + } + } +} \ No newline at end of file diff --git a/test/assets/metadata_paged_2.json b/test/assets/metadata_paged_2.json new file mode 100644 index 000000000..af9601d59 --- /dev/null +++ b/test/assets/metadata_paged_2.json @@ -0,0 +1,15 @@ +{ + "data": { + "publishedDatasourcesConnection": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwYjE5MWNlLTYwNTUtYWZmNS1lMjc1LWMyNjYxMGM4YzRkNiJ9fQ==" + }, + "nodes": [ + { + "id": "00b191ce-6055-aff5-e275-c26610c8c4d6" + } + ] + } + } +} \ No newline at end of file diff --git a/test/assets/metadata_paged_3.json b/test/assets/metadata_paged_3.json new file mode 100644 index 000000000..958a408ea --- /dev/null +++ b/test/assets/metadata_paged_3.json @@ -0,0 +1,15 @@ +{ + "data": { + "publishedDatasourcesConnection": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAyZjNlNGQ4LTg1NmEtZGEzNi1mNmM1LWM5MDA5NDVjNTdiOSJ9fQ==" + }, + "nodes": [ + { + "id": "02f3e4d8-856a-da36-f6c5-c900945c57b9" + } + ] + } + } +} \ No newline at end of file diff --git a/test/assets/metadata_query_expected_dict.dict b/test/assets/metadata_query_expected_dict.dict new file mode 100644 index 000000000..241b333d4 --- /dev/null +++ b/test/assets/metadata_query_expected_dict.dict @@ -0,0 +1,9 @@ +{'pages': [{'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '0039e5d5-25fa-196b-c66e-c0675839e0b0'}], + 'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwMzllNWQ1LTI1ZmEtMTk2Yi1jNjZlLWMwNjc1ODM5ZTBiMCJ9fQ==', + 'hasNextPage': True}}}}, + {'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '00b191ce-6055-aff5-e275-c26610c8c4d6'}], + 'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwYjE5MWNlLTYwNTUtYWZmNS1lMjc1LWMyNjYxMGM4YzRkNiJ9fQ==', + 'hasNextPage': True}}}}, + {'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '02f3e4d8-856a-da36-f6c5-c900945c57b9'}], + 'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAyZjNlNGQ4LTg1NmEtZGEzNi1mNmM1LWM5MDA5NDVjNTdiOSJ9fQ==', + 'hasNextPage': False}}}}]} \ No newline at end of file diff --git a/test/test_favorites.py b/test/test_favorites.py new file mode 100644 index 000000000..f76517b64 --- /dev/null +++ b/test/test_favorites.py @@ -0,0 +1,129 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_FAVORITES_XML = 'favorites_get.xml' +ADD_FAVORITE_WORKBOOK_XML = 'favorites_add_workbook.xml' +ADD_FAVORITE_VIEW_XML = 'favorites_add_view.xml' +ADD_FAVORITE_DATASOURCE_XML = 'favorites_add_datasource.xml' +ADD_FAVORITE_PROJECT_XML = 'favorites_add_project.xml' + + +class FavoritesTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.server.version = '2.5' + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = self.server.favorites.baseurl + self.user = TSC.UserItem('alice', TSC.UserItem.Roles.Viewer) + self.user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + + def test_get(self): + response_xml = read_xml_asset(GET_FAVORITES_XML) + with requests_mock.mock() as m: + m.get('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.get(self.user) + self.assertIsNotNone(self.user._favorites) + self.assertEqual(len(self.user.favorites['workbooks']), 1) + self.assertEqual(len(self.user.favorites['views']), 1) + self.assertEqual(len(self.user.favorites['projects']), 1) + self.assertEqual(len(self.user.favorites['datasources']), 1) + + workbook = self.user.favorites['workbooks'][0] + view = self.user.favorites['views'][0] + datasource = self.user.favorites['datasources'][0] + project = self.user.favorites['projects'][0] + + self.assertEqual(workbook.id, '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00') + self.assertEqual(view.id, 'd79634e1-6063-4ec9-95ff-50acbf609ff5') + self.assertEqual(datasource.id, 'e76a1461-3b1d-4588-bf1b-17551a879ad9') + self.assertEqual(project.id, '1d0304cd-3796-429f-b815-7258370b9b74') + + def test_add_favorite_workbook(self): + response_xml = read_xml_asset(ADD_FAVORITE_WORKBOOK_XML) + workbook = TSC.WorkbookItem('') + workbook._id = '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00' + workbook.name = 'Superstore' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_workbook(self.user, workbook) + + def test_add_favorite_view(self): + response_xml = read_xml_asset(ADD_FAVORITE_VIEW_XML) + view = TSC.ViewItem() + view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + view._name = 'ENDANGERED SAFARI' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_view(self.user, view) + + def test_add_favorite_datasource(self): + response_xml = read_xml_asset(ADD_FAVORITE_DATASOURCE_XML) + datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + datasource._id = 'e76a1461-3b1d-4588-bf1b-17551a879ad9' + datasource.name = 'SampleDS' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_datasource(self.user, datasource) + + def test_add_favorite_project(self): + self.server.version = '3.1' + baseurl = self.server.favorites.baseurl + response_xml = read_xml_asset(ADD_FAVORITE_PROJECT_XML) + project = TSC.ProjectItem('Tableau') + project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_project(self.user, project) + + def test_delete_favorite_workbook(self): + workbook = TSC.WorkbookItem('') + workbook._id = '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00' + workbook.name = 'Superstore' + with requests_mock.mock() as m: + m.delete('{0}/{1}/workbooks/{2}'.format(self.baseurl, self.user.id, + workbook.id)) + self.server.favorites.delete_favorite_workbook(self.user, workbook) + + def test_delete_favorite_view(self): + view = TSC.ViewItem() + view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + view._name = 'ENDANGERED SAFARI' + with requests_mock.mock() as m: + m.delete('{0}/{1}/views/{2}'.format(self.baseurl, self.user.id, + view.id)) + self.server.favorites.delete_favorite_view(self.user, view) + + def test_delete_favorite_datasource(self): + datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + datasource._id = 'e76a1461-3b1d-4588-bf1b-17551a879ad9' + datasource.name = 'SampleDS' + with requests_mock.mock() as m: + m.delete('{0}/{1}/datasources/{2}'.format(self.baseurl, self.user.id, + datasource.id)) + self.server.favorites.delete_favorite_datasource(self.user, datasource) + + def test_delete_favorite_project(self): + self.server.version = '3.1' + baseurl = self.server.favorites.baseurl + project = TSC.ProjectItem('Tableau') + project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + with requests_mock.mock() as m: + m.delete('{0}/{1}/projects/{2}'.format(baseurl, self.user.id, + project.id)) + self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_metadata.py b/test/test_metadata.py index e2a44734c..1c0846d73 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -10,6 +10,11 @@ METADATA_QUERY_SUCCESS = os.path.join(TEST_ASSET_DIR, 'metadata_query_success.json') METADATA_QUERY_ERROR = os.path.join(TEST_ASSET_DIR, 'metadata_query_error.json') +EXPECTED_PAGED_DICT = os.path.join(TEST_ASSET_DIR, 'metadata_query_expected_dict.dict') + +METADATA_PAGE_1 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_1.json') +METADATA_PAGE_2 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_2.json') +METADATA_PAGE_3 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_3.json') EXPECTED_DICT = {'publishedDatasources': [{'id': '01cf92b2-2d17-b656-fc48-5c25ef6d5352', 'name': 'Batters (TestV1)'}, @@ -30,7 +35,7 @@ class MetadataTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') self.baseurl = self.server.metadata.baseurl - self.server.version = "3.2" + self.server.version = "3.5" self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' @@ -46,6 +51,30 @@ def test_metadata_query(self): self.assertDictEqual(EXPECTED_DICT, datasources) + def test_paged_metadata_query(self): + with open(EXPECTED_PAGED_DICT, 'rb') as f: + expected = eval(f.read()) + + # prepare the 3 pages of results + with open(METADATA_PAGE_1, 'rb') as f: + result_1 = f.read().decode() + with open(METADATA_PAGE_2, 'rb') as f: + result_2 = f.read().decode() + with open(METADATA_PAGE_3, 'rb') as f: + result_3 = f.read().decode() + + with requests_mock.mock() as m: + m.post(self.baseurl, [{'text': result_1, 'status_code': 200}, + {'text': result_2, 'status_code': 200}, + {'text': result_3, 'status_code': 200}]) + + # validation checks for endCursor and hasNextPage, + # but the query text doesn't matter for the test + actual = self.server.metadata.paginated_query('fake query endCursor hasNextPage', + variables={'first': 1, 'afterToken': None}) + + self.assertDictEqual(expected, actual) + def test_metadata_query_ignore_error(self): with open(METADATA_QUERY_ERROR, 'rb') as f: response_json = json.loads(f.read().decode()) diff --git a/test/test_project.py b/test/test_project.py index b57d52df5..5e9869c6e 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -100,14 +100,14 @@ def test_update_datasource_default_permission(self): new_rules = self.server.projects.update_datasource_default_permissions(project, rules) - self.assertEquals('b4488bce-80f0-11ea-af1c-976d0c1dab39', new_rules[0].grantee.id) + self.assertEqual('b4488bce-80f0-11ea-af1c-976d0c1dab39', new_rules[0].grantee.id) updated_capabilities = new_rules[0].capabilities - self.assertEquals(4, len(updated_capabilities)) - self.assertEquals('Deny', updated_capabilities['ExportXml']) - self.assertEquals('Allow', updated_capabilities['Read']) - self.assertEquals('Allow', updated_capabilities['Write']) - self.assertEquals('Allow', updated_capabilities['Connect']) + self.assertEqual(4, len(updated_capabilities)) + self.assertEqual('Deny', updated_capabilities['ExportXml']) + self.assertEqual('Allow', updated_capabilities['Read']) + self.assertEqual('Allow', updated_capabilities['Write']) + self.assertEqual('Allow', updated_capabilities['Connect']) def test_update_missing_id(self): single_project = TSC.ProjectItem('test') diff --git a/test/test_view.py b/test/test_view.py index 350be83fd..1bd88995a 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -129,14 +129,15 @@ def test_populate_image(self): self.server.views.populate_image(single_view) self.assertEqual(response, single_view.image) - def test_populate_image_high_resolution(self): + def test_populate_image_with_options(self): with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high', content=response) + m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10', + content=response) single_view = TSC.ViewItem() single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High) + req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) self.server.views.populate_image(single_view, req_option) self.assertEqual(response, single_view.image) @@ -144,19 +145,32 @@ def test_populate_pdf(self): with open(POPULATE_PDF, 'rb') as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait', + m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5', content=response) single_view = TSC.ViewItem() single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' size = TSC.PDFRequestOptions.PageType.Letter orientation = TSC.PDFRequestOptions.Orientation.Portrait - req_option = TSC.PDFRequestOptions(size, orientation) + req_option = TSC.PDFRequestOptions(size, orientation, 5) self.server.views.populate_pdf(single_view, req_option) self.assertEqual(response, single_view.pdf) def test_populate_csv(self): + with open(POPULATE_CSV, 'rb') as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1', content=response) + single_view = TSC.ViewItem() + single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + request_option = TSC.CSVRequestOptions(maxage=1) + self.server.views.populate_csv(single_view, request_option) + + csv_file = b"".join(single_view.csv) + self.assertEqual(response, csv_file) + + def test_populate_csv_default_maxage(self): with open(POPULATE_CSV, 'rb') as f: response = f.read() with requests_mock.mock() as m: diff --git a/test/test_workbook.py b/test/test_workbook.py index 1a62f4fc5..f1d9df9e0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -4,6 +4,7 @@ import tableauserverclient as TSC import xml.etree.ElementTree as ET + from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.request_factory import RequestFactory @@ -436,6 +437,28 @@ def test_publish(self): self.assertEqual('GDP per capita', new_workbook.views[0].name) self.assertEqual('RESTAPISample_0/sheets/GDPpercapita', new_workbook.views[0].content_url) + def test_publish_with_hidden_view(self): + with open(PUBLISH_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + + new_workbook = TSC.WorkbookItem(name='Sample', + show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + new_workbook = self.server.workbooks.publish(new_workbook, + sample_workbook, + publish_mode, + hidden_views=['GDP per capita']) + + request_body = m._adapter.request_history[0]._request.body + self.assertIn( + b'', request_body) + def test_publish_async(self): self.server.version = '3.0' baseurl = self.server.workbooks.baseurl