From 7816d2efa9c7b7670e8eab8f05911713f95156f7 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 31 Jan 2018 14:24:32 -0800 Subject: [PATCH 01/17] Implement view filters on the populate* request options (#260) * Implement view filters on the populate* request options --- tableauserverclient/__init__.py | 2 +- tableauserverclient/server/__init__.py | 2 +- .../server/endpoint/views_endpoint.py | 2 +- tableauserverclient/server/request_options.py | 35 +++++++++++++++++-- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index c5840d7b6..30ec47981 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -4,7 +4,7 @@ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ SubscriptionItem -from .server import RequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ +from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager from ._version import get_versions __version__ = get_versions()['version'] diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 12a640723..704fdb66a 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -1,5 +1,5 @@ from .request_factory import RequestFactory -from .request_options import ImageRequestOptions, PDFRequestOptions, RequestOptions +from .request_options import CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions from .filter import Filter from .sort import Sort from .. import ConnectionItem, DatasourceItem, JobItem, \ diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 0335ce781..62cd3af50 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -105,7 +105,7 @@ def csv_fetcher(): def _get_view_csv(self, view_item, req_options): url = "{0}/{1}/data".format(self.baseurl, view_item.id) - with closing(self.get_request(url, parameters={"stream": True})) as server_response: + with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: csv = server_response.iter_content(1024) return csv diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 37f23f54c..b7d5c591d 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -62,12 +62,37 @@ def apply_query_params(self, url): return "{0}?{1}".format(url, '&'.join(params)) -class ImageRequestOptions(RequestOptionsBase): +class _FilterOptionsBase(RequestOptionsBase): + """ Provide a basic implementation of adding view filters to the url """ + def __init__(self): + self.view_filters = [] + + def apply_query_params(self, url): + raise NotImplementedError() + + def vf(self, name, value): + self.view_filters.append((name, value)) + return self + + def _append_view_filters(self, params): + for name, value in self.view_filters: + params.append('vf_{}={}'.format(name, value)) + + +class CSVRequestOptions(_FilterOptionsBase): + def apply_query_params(self, url): + params = [] + self._append_view_filters(params) + return "{0}?{1}".format(url, '&'.join(params)) + + +class ImageRequestOptions(_FilterOptionsBase): # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: High = 'high' def __init__(self, imageresolution=None): + super(ImageRequestOptions, self).__init__() self.image_resolution = imageresolution def apply_query_params(self, url): @@ -75,11 +100,12 @@ def apply_query_params(self, url): if self.image_resolution: params.append('resolution={0}'.format(self.image_resolution)) + self._append_view_filters(params) + return "{0}?{1}".format(url, '&'.join(params)) -class PDFRequestOptions(RequestOptionsBase): - # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution +class PDFRequestOptions(_FilterOptionsBase): class PageType: A3 = "a3" A4 = "a4" @@ -100,6 +126,7 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None): + super(PDFRequestOptions, self).__init__() self.page_type = page_type self.orientation = orientation @@ -111,4 +138,6 @@ def apply_query_params(self, url): if self.orientation: params.append('orientation={0}'.format(self.orientation)) + self._append_view_filters(params) + return "{0}?{1}".format(url, '&'.join(params)) From 2a2c898771d1acb9ee186aa4f6856b0ebff6c9db Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Fri, 16 Feb 2018 09:43:36 -0800 Subject: [PATCH 02/17] Export sample (#263) * Adding export sample/tool * Removing unused import * Addressing code review feedback * Missed one description --- samples/export.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++ samples/list.py | 7 +++-- 2 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 samples/export.py diff --git a/samples/export.py b/samples/export.py new file mode 100644 index 000000000..67b3319a8 --- /dev/null +++ b/samples/export.py @@ -0,0 +1,74 @@ +import argparse +import getpass +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Export a view as an image, pdf, or csv') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--site', '-S', default=None) + parser.add_argument('-p', default=None) + + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--pdf', dest='type', action='store_const', const=('populate_pdf', 'PDFRequestOptions', 'pdf', + 'pdf')) + group.add_argument('--png', dest='type', action='store_const', const=('populate_image', 'ImageRequestOptions', + 'image', 'png')) + group.add_argument('--csv', dest='type', action='store_const', const=('populate_csv', 'CSVRequestOptions', 'csv', + 'csv')) + + parser.add_argument('--file', '-f', help='filename to store the exported data') + parser.add_argument('--filter', '-vf', metavar='COLUMN:VALUE', + help='View filter to apply to the view') + parser.add_argument('resource_id', help='LUID for the view') + + args = parser.parse_args() + + if args.p is None: + password = getpass.getpass("Password: ") + else: + password = args.p + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.TableauAuth(args.username, password, args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + views = filter(lambda x: x.id == args.resource_id, + TSC.Pager(server.views.get)) + view = views.pop() + + # We have a number of different types and functions for each different export type. + # We encode that information above in the const=(...) parameter to the add_argument function to make + # the code automatically adapt for the type of export the user is doing. + # We unroll that information into methods we can call, or objects we can create by using getattr() + (populate_func_name, option_factory_name, member_name, extension) = args.type + populate = getattr(server.views, populate_func_name) + option_factory = getattr(TSC, option_factory_name) + + if args.filter: + options = option_factory().vf(*args.filter.split(':')) + else: + options = None + if args.file: + filename = args.file + else: + filename = 'out.{}'.format(extension) + + populate(view, options) + with file(filename, 'wb') as f: + if member_name == 'csv': + f.writelines(getattr(view, member_name)) + else: + f.write(getattr(view, member_name)) + + +if __name__ == '__main__': + main() diff --git a/samples/list.py b/samples/list.py index ec2ff9a6b..b53795d1a 100644 --- a/samples/list.py +++ b/samples/list.py @@ -12,7 +12,7 @@ def main(): - parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + parser = argparse.ArgumentParser(description='List out the names and LUIDs for different resource types') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--site', '-S', default=None) @@ -21,7 +21,7 @@ def main(): parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - parser.add_argument('resource_type', choices=['workbook', 'datasource']) + parser.add_argument('resource_type', choices=['workbook', 'datasource', 'view']) args = parser.parse_args() @@ -40,7 +40,8 @@ def main(): with server.auth.sign_in(tableau_auth): endpoint = { 'workbook': server.workbooks, - 'datasource': server.datasources + 'datasource': server.datasources, + 'view': server.views }.get(args.resource_type) for resource in TSC.Pager(endpoint.get): From 4e8bb798b40a19432febe43343f0b2ebba8d6a99 Mon Sep 17 00:00:00 2001 From: Sergey Date: Wed, 21 Feb 2018 20:45:05 +0300 Subject: [PATCH 03/17] Change to correct parent project id tag name (#267) * Change to correct parent project id tag name * Change to correct parent project id tag name --- tableauserverclient/models/project_item.py | 2 +- test/assets/project_create.xml | 2 +- test/assets/project_get.xml | 2 +- test/assets/project_update.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 91b82ef0e..92e0282ae 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -80,6 +80,6 @@ def _parse_element(project_xml): name = project_xml.get('name', None) description = project_xml.get('description', None) content_permissions = project_xml.get('contentPermissions', None) - parent_id = project_xml.get('parentId', None) + parent_id = project_xml.get('parentProjectId', None) return id, name, description, content_permissions, parent_id diff --git a/test/assets/project_create.xml b/test/assets/project_create.xml index ebfada762..5cd29d954 100644 --- a/test/assets/project_create.xml +++ b/test/assets/project_create.xml @@ -1,4 +1,4 @@ - + diff --git a/test/assets/project_get.xml b/test/assets/project_get.xml index 12133f432..777412b30 100644 --- a/test/assets/project_get.xml +++ b/test/assets/project_get.xml @@ -4,6 +4,6 @@ - + diff --git a/test/assets/project_update.xml b/test/assets/project_update.xml index 0307e7c18..eaa884627 100644 --- a/test/assets/project_update.xml +++ b/test/assets/project_update.xml @@ -1,4 +1,4 @@ - + From 86e463810be80c2b562845f7c14b775d604f2a86 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 21 Feb 2018 11:58:16 -0800 Subject: [PATCH 04/17] add export_wb sample for 'fullpdf' (#264) * add export_wb sample for 'fullpdf' * Addressing code review feedback from Lee * Fixing the dumb thing that I did where I was getting the view, then throwing it away and getting it again... * pep8 error --- samples/export_wb.py | 92 ++++++++++++++++++++++++++++++++++++++++++++ samples/list.py | 8 ++-- 2 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 samples/export_wb.py diff --git a/samples/export_wb.py b/samples/export_wb.py new file mode 100644 index 000000000..8d3640ab4 --- /dev/null +++ b/samples/export_wb.py @@ -0,0 +1,92 @@ +# +# This sample uses the PyPDF2 library for combining pdfs together to get the full pdf for all the views in a +# workbook. +# +# You will need to do `pip install PyPDF2` to use this sample. +# + +import argparse +import getpass +import logging +import tempfile +import shutil +import functools +import os.path + +import tableauserverclient as TSC +try: + import PyPDF2 +except ImportError: + print('Please `pip install PyPDF2` to use this sample') + import sys + sys.exit(1) + + +def get_views_for_workbook(server, workbook_id): # -> Iterable of views + workbook = server.workbooks.get_by_id(workbook_id) + server.workbooks.populate_views(workbook) + return workbook.views + + +def download_pdf(server, tempdir, view): # -> Filename to downloaded pdf + logging.info("Exporting {}".format(view.id)) + destination_filename = os.path.join(tempdir, view.id) + server.views.populate_pdf(view) + with file(destination_filename, 'wb') as f: + f.write(view.pdf) + + return destination_filename + + +def combine_into(dest_pdf, filename): # -> None + dest_pdf.append(filename) + return dest_pdf + + +def cleanup(tempdir): + shutil.rmtree(tempdir) + + +def main(): + parser = argparse.ArgumentParser(description='Export to PDF all of the views in a workbook') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-S', default=None, help='Site to log into, do not specify for default site') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--password', '-p', default=None, help='password for the user') + + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + parser.add_argument('--file', '-f', default='out.pdf', help='filename to store the exported data') + parser.add_argument('resource_id', help='LUID for the workbook') + + args = parser.parse_args() + + if args.password is None: + password = getpass.getpass("Password: ") + else: + password = args.password + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tempdir = tempfile.mkdtemp('tsc') + logging.debug("Saving to tempdir: %s", tempdir) + + tableau_auth = TSC.TableauAuth(args.username, password, args.site) + server = TSC.Server(args.server, use_server_version=True) + try: + with server.auth.sign_in(tableau_auth): + get_list = functools.partial(get_views_for_workbook, server) + download = functools.partial(download_pdf, server, tempdir) + + downloaded = (download(x) for x in get_list(args.resource_id)) + output = reduce(combine_into, downloaded, PyPDF2.PdfFileMerger()) + with file(args.file, 'wb') as f: + output.write(f) + finally: + cleanup(tempdir) + + +if __name__ == '__main__': + main() diff --git a/samples/list.py b/samples/list.py index b53795d1a..d1a25f08d 100644 --- a/samples/list.py +++ b/samples/list.py @@ -14,9 +14,9 @@ def main(): parser = argparse.ArgumentParser(description='List out the names and LUIDs for different resource types') parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-S', default=None, help='site to log into, do not specify for default site') parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--site', '-S', default=None) - parser.add_argument('-p', default=None) + parser.add_argument('--password', '-p', default=None, help='password for the user') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') @@ -25,10 +25,10 @@ def main(): args = parser.parse_args() - if args.p is None: + if args.password is None: password = getpass.getpass("Password: ") else: - password = args.p + password = args.password # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) From 6586931bce491622d9c14715f8f94324f9883c53 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 9 Mar 2018 15:32:37 -0800 Subject: [PATCH 05/17] Add a simple static method to strip non-XML responses from debug log entries. Tested manually, and a simple regression test added to the suite. --- samples/download_view_image.py | 2 +- tableauserverclient/server/endpoint/endpoint.py | 13 ++++++++++++- test/test_regression_tests.py | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/samples/download_view_image.py b/samples/download_view_image.py index 2da232061..b95a8628b 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -43,7 +43,7 @@ def main(): tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_id) server = TSC.Server(args.server) # The new endpoint was introduced in Version 2.5 - server.version = 2.5 + server.version = "2.5" with server.auth.sign_in(tableau_auth): # Step 2: Query for the view that we want an image of diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index deaa94a30..e78b2e0cd 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -27,6 +27,17 @@ def _make_common_headers(auth_token, content_type): return headers + @staticmethod + def _safe_to_log(server_response): + '''Checks if the server_response content is not xml (eg binary image or zip) + and and replaces it with a constant + ''' + ALLOWED_CONTENT_TYPES = ('application/xml',) + if server_response.headers.get('Content-Type', None) not in ALLOWED_CONTENT_TYPES: + return '[Truncated File Contents]' + else: + return server_response.content + def _make_request(self, method, url, content=None, request_object=None, auth_token=None, content_type=None, parameters=None): if request_object is not None: @@ -50,7 +61,7 @@ def _make_request(self, method, url, content=None, request_object=None, return server_response def _check_status(self, server_response): - logger.debug(server_response.content) + logger.debug(self._safe_to_log(server_response)) if server_response.status_code not in Success_codes: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 95bdceacb..8958c3cf8 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,8 +1,23 @@ import unittest import tableauserverclient.server.request_factory as factory +from tableauserverclient.server.endpoint import Endpoint class BugFix257(unittest.TestCase): def test_empty_request_works(self): result = factory.EmptyRequest().empty_req() self.assertEqual(b'', result) + + +class BugFix273(unittest.TestCase): + def test_binary_log_truncated(self): + + class FakeResponse(object): + + headers = {'Content-Type': 'application/octet-stream'} + content = b'\x1337' * 1000 + status_code = 200 + + server_response = FakeResponse() + + self.assertEqual(Endpoint._safe_to_log(server_response), '[Truncated File Contents]') From ad1be7d54ab5ccee02aa00942734e338477fb978 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 6 Apr 2018 09:57:02 -0700 Subject: [PATCH 06/17] Fix update datasource connection server port (#283) * addressing bug #281: update datasource connection not updating server port properly --- tableauserverclient/server/request_factory.py | 2 +- test/assets/datasource_connection_update.xml | 2 +- test/test_datasource.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c4f10d731..b09ecc3ce 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -365,7 +365,7 @@ def update_req(self, xml_request, connection_item): if connection_item.server_address: connection_element.attrib['serverAddress'] = connection_item.server_address.lower() if connection_item.server_port: - connection_element.attrib['port'] = str(connection_item.server_port) + connection_element.attrib['serverPort'] = str(connection_item.server_port) if connection_item.username: connection_element.attrib['userName'] = connection_item.username if connection_item.password: diff --git a/test/assets/datasource_connection_update.xml b/test/assets/datasource_connection_update.xml index 0e4d21ed0..5b84616dd 100644 --- a/test/assets/datasource_connection_update.xml +++ b/test/assets/datasource_connection_update.xml @@ -2,4 +2,4 @@ \ No newline at end of file + type="textscan" serverAddress="bar" serverPort="9876" userName="foo"/> \ No newline at end of file diff --git a/test/test_datasource.py b/test/test_datasource.py index ff1546d62..9de8ae375 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -161,10 +161,14 @@ def test_update_connection(self): self.server.datasources.populate_connections(single_datasource) connection = single_datasource.connections[0] + connection.server_address = 'bar' + connection.server_port = '9876' connection.username = 'foo' new_connection = self.server.datasources.update_connection(single_datasource, connection) self.assertEqual(connection.id, new_connection.id) self.assertEqual(connection.connection_type, new_connection.connection_type) + self.assertEquals('bar', new_connection.server_address) + self.assertEquals('9876', new_connection.server_port) self.assertEqual('foo', new_connection.username) def test_publish(self): From df44535e81e32a22d296244d4c451dd4535faf50 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 10 Apr 2018 16:50:59 -0700 Subject: [PATCH 07/17] Add ability to rename workbook using the 'update workbook' endpoint (#284) * adding ability to rename workbook using workbooks.update() * updating test --- tableauserverclient/server/request_factory.py | 2 ++ test/assets/workbook_update.xml | 2 +- test/test_workbook.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index b09ecc3ce..241f47985 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -334,6 +334,8 @@ def _generate_xml(self, workbook_item, connection_credentials=None): def update_req(self, workbook_item): xml_request = ET.Element('tsRequest') workbook_element = ET.SubElement(xml_request, 'workbook') + if workbook_item.name: + workbook_element.attrib['name'] = workbook_item.name if workbook_item.show_tabs: workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() if workbook_item.project_id: diff --git a/test/assets/workbook_update.xml b/test/assets/workbook_update.xml index 9c9674700..2470347a8 100644 --- a/test/assets/workbook_update.xml +++ b/test/assets/workbook_update.xml @@ -1,6 +1,6 @@ - + diff --git a/test/test_workbook.py b/test/test_workbook.py index 8c36f0229..de8f8fbaf 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -114,12 +114,14 @@ def test_update(self): single_workbook = TSC.WorkbookItem('1d0304cd-3796-429f-b815-7258370b9b74', show_tabs=True) single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' single_workbook.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + single_workbook.name = 'renamedWorkbook' single_workbook = self.server.workbooks.update(single_workbook) self.assertEqual('1f951daf-4061-451a-9df1-69a8062664f2', single_workbook.id) self.assertEqual(True, single_workbook.show_tabs) self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_workbook.project_id) self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_workbook.owner_id) + self.assertEqual('renamedWorkbook', single_workbook.name) def test_update_missing_id(self): single_workbook = TSC.WorkbookItem('test') From 728ec5c1c8af4c8425c3b48f094560cf7b1f958b Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 11 Apr 2018 13:18:21 -0700 Subject: [PATCH 08/17] adding project id field to view_item (#285) --- tableauserverclient/models/view_item.py | 12 +++++++++++- test/assets/view_get.xml | 2 ++ test/test_view.py | 2 ++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index a8c1a6988..1fc6d4e8e 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -11,6 +11,7 @@ def __init__(self): self._name = None self._owner_id = None self._preview_image = None + self._project_id = None self._pdf = None self._csv = None self._total_views = None @@ -59,6 +60,10 @@ def preview_image(self): raise UnpopulatedPropertyError(error) return self._preview_image() + @property + def project_id(self): + return self._project_id + @property def pdf(self): if self._pdf is None: @@ -97,6 +102,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): usage_elem = view_xml.find('.//t:usage', namespaces=ns) 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) view_item._id = view_xml.get('id', None) view_item._name = view_xml.get('name', None) view_item._content_url = view_xml.get('contentUrl', None) @@ -107,10 +113,14 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): if owner_elem is not None: view_item._owner_id = owner_elem.get('id', None) - all_view_items.append(view_item) + + if project_elem is not None: + view_item._project_id = project_elem.get('id', None) if workbook_id: view_item._workbook_id = workbook_id elif workbook_elem is not None: view_item._workbook_id = workbook_elem.get('id', None) + + all_view_items.append(view_item) return all_view_items diff --git a/test/assets/view_get.xml b/test/assets/view_get.xml index c8e0601bd..36f43e255 100644 --- a/test/assets/view_get.xml +++ b/test/assets/view_get.xml @@ -5,10 +5,12 @@ + + \ No newline at end of file diff --git a/test/test_view.py b/test/test_view.py index 09ce2f3d7..292f86887 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -39,12 +39,14 @@ def test_get(self): self.assertEqual('SafariSample/sheets/ENDANGEREDSAFARI', all_views[0].content_url) self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_views[0].workbook_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[0].owner_id) + self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_views[0].project_id) self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id) self.assertEqual('Overview', all_views[1].name) self.assertEqual('Superstore/sheets/Overview', all_views[1].content_url) self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_views[1].workbook_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[1].owner_id) + self.assertEqual('5b534f74-3226-11e8-b47a-cb2e00f738a3', all_views[1].project_id) def test_get_with_usage(self): with open(GET_XML_USAGE, 'rb') as f: From 844e0b1248d91cceffc595b90c16cca4b274b190 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Mon, 16 Apr 2018 11:54:10 -0700 Subject: [PATCH 09/17] adding more fields for filtering ability (#286) --- tableauserverclient/server/request_options.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index b7d5c591d..be00e8975 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -14,12 +14,22 @@ class Operator: class Field: CreatedAt = 'createdAt' + DomainName = 'domainName' + DomainNickname = 'domainNickname' + HitsTotal = 'hitsTotal' + IsLocal = 'isLocal' LastLogin = 'lastLogin' + MinimumSiteRole = 'minimumSiteRole' Name = 'name' + OwnerDomain = 'ownerDomain' + OwnerEmail = 'ownerEmail' OwnerName = 'ownerName' + ProjectName = 'projectName' SiteRole = 'siteRole' Tags = 'tags' + Type = 'type' UpdatedAt = 'updatedAt' + UserCount = 'userCount' class Direction: Desc = 'desc' From d28d5fc19458b9fca11b2534033f9fb332496886 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Tue, 17 Apr 2018 09:16:43 -0700 Subject: [PATCH 10/17] Refactor the refresh sample to be more explicit (#288) --- samples/refresh.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/samples/refresh.py b/samples/refresh.py index dd39bc6f6..73aa7fb2f 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -16,7 +16,7 @@ def main(): parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--site', '-S', default=None) - parser.add_argument('-p', default=None) + parser.add_argument('--password', '-p', default=None, help='if not specified, you will be prompted') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') @@ -26,10 +26,10 @@ def main(): args = parser.parse_args() - if args.p is None: + if args.password is None: password = getpass.getpass("Password: ") else: - password = args.p + password = args.password # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) @@ -39,15 +39,21 @@ def main(): tableau_auth = TSC.TableauAuth(args.username, password, args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - endpoint = { - 'workbook': server.workbooks, - 'datasource': server.datasources - }.get(args.resource_type) + if args.resource_type == "workbook": + # Get the workbook by its Id to make sure it exists + resource = server.workbooks.get_by_id(args.resource_id) - refresh_func = endpoint.refresh - resource = endpoint.get_by_id(args.resource_id) + # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done + results = server.workbooks.refresh(resource) + else: + # Get the datasource by its Id to make sure it exists + resource = server.datasources.get_by_id(args.resource_id) - print(refresh_func(resource)) + # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done + results = server.datasources.refresh(resource) + + print(results) + # TODO: Add a flag that will poll and wait for the returned job to be done if __name__ == '__main__': From 18372ba934e3ed4fe67455f552dd2dab41355724 Mon Sep 17 00:00:00 2001 From: Sergey Sotnichenko Date: Fri, 20 Apr 2018 00:48:05 +0300 Subject: [PATCH 11/17] 277 update group feature (#279) * Adding update method for groups * Add some docs * Add test for update group function --- docs/docs/api-ref.md | 46 +++++++++++++++++++ .../server/endpoint/groups_endpoint.py | 14 ++++++ tableauserverclient/server/request_factory.py | 11 +++++ test/assets/group_update.xml | 6 +++ test/test_group.py | 14 ++++++ 5 files changed, 91 insertions(+) create mode 100644 test/assets/group_update.xml diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md index 7b22c3517..81d1211dd 100644 --- a/docs/docs/api-ref.md +++ b/docs/docs/api-ref.md @@ -849,6 +849,52 @@ Error | Description

+#### groups.update + +```py +groups.update(group_item, default_site_role=UserItem.Roles.Unlicensed) +``` + +Updates the group on the site. +If domain_name = 'local' then update only the name of the group. +If not - update group from the Active Directory with domain_name. + +REST API: [Update Group](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Update_Group%3FTocPath%3DAPI%2520Reference%7C_____95){:target="_blank"} + + +**Parameters** + +Name | Description +:--- | :--- +`group_item` | the group_item specifies the group to update. +`default_site_role` | if group updates from Active Directory then this is the default role for the new users. + + +**Exceptions** + +Error | Description +:--- | :--- +`Group item missing ID` | Raises an exception if a valid `group_item.id` is not provided. + + +**Example** + +```py +# Update a group + +# import tableauserverclient as TSC +# tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') +# server = TSC.Server('http://SERVERURL') + + with server.auth.sign_in(tableau_auth): + all_groups, pagination_item = server.groups.get() + + for group in all_groups: + server.groups.update(group) +``` +
+
+ #### groups.get ```py diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index bee595b25..2428ff9be 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -7,6 +7,8 @@ logger = logging.getLogger('tableau.endpoint.groups') +UNLICENSED_USER = UserItem.Roles.Unlicensed + class Groups(Endpoint): @property @@ -55,6 +57,18 @@ def delete(self, group_id): self.delete_request(url) logger.info('Deleted single group (ID: {0})'.format(group_id)) + @api(version="2.0") + def update(self, group_item, default_site_role=UNLICENSED_USER): + if not group_item.id: + error = "Group item missing ID." + raise MissingRequiredFieldError(error) + url = "{0}/{1}".format(self.baseurl, group_item.id) + update_req = RequestFactory.Group.update_req(group_item, default_site_role) + server_response = self.put_request(url, update_req) + logger.info('Updated group item (ID: {0})'.format(group_item.id)) + updated_group = GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return updated_group + # Create a 'local' Tableau group @api(version="2.0") def create(self, group_item): diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 241f47985..277605fa7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -107,6 +107,17 @@ def create_req(self, group_item): group_element.attrib['name'] = group_item.name return ET.tostring(xml_request) + def update_req(self, group_item, default_site_role): + xml_request = ET.Element('tsRequest') + group_element = ET.SubElement(xml_request, 'group') + group_element.attrib['name'] = group_item.name + if group_item.domain_name != 'local': + project_element = ET.SubElement(group_element, 'import') + project_element.attrib['source'] = "ActiveDirectory" + project_element.attrib['domainName'] = group_item.domain_name + project_element.attrib['siteRole'] = default_site_role + return ET.tostring(xml_request) + class PermissionRequest(object): def _add_capability(self, parent_element, capability_set, mode): diff --git a/test/assets/group_update.xml b/test/assets/group_update.xml new file mode 100644 index 000000000..b5dba4bc6 --- /dev/null +++ b/test/assets/group_update.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/test/test_group.py b/test/test_group.py index 244ba47b8..7096ca408 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -14,6 +14,7 @@ ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, 'group_users_added.xml') CREATE_GROUP = os.path.join(TEST_ASSET_DIR, 'group_create.xml') CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, 'group_create_async.xml') +UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'group_update.xml') class GroupTests(unittest.TestCase): @@ -183,3 +184,16 @@ def test_create_group(self): group = self.server.groups.create(group_to_create) self.assertEqual(group.name, u'試供品') self.assertEqual(group.id, '3e4a9ea0-a07a-4fe6-b50f-c345c8c81034') + + def test_update(self): + with open(UPDATE_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.put(self.baseurl + '/ef8b19c0-43b6-11e6-af50-63f5805dbe3c', text=response_xml) + group = TSC.GroupItem(name='Test Group') + group._domain_name = 'local' + group._id = 'ef8b19c0-43b6-11e6-af50-63f5805dbe3c' + group = self.server.groups.update(group) + + self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', group.id) + self.assertEqual('Group updated name', group.name) From 48df0ef1402ae6db19656104199f7aac54a65edf Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 20 Apr 2018 10:07:39 -0700 Subject: [PATCH 12/17] Multi-Credential Support in TSC (#276) Taking @marianotn 's PR and updating it to retain backwards compatibility. Pre-implemented for datasources as well, though those don't support this server-side yet, I've set that version to 99.99. When support lands we can update it to the version it's introduced in. --- samples/publish_workbook.py | 19 ++++- .../models/connection_credentials.py | 12 +++ tableauserverclient/models/connection_item.py | 31 ++++++++ .../server/endpoint/datasources_endpoint.py | 9 ++- .../server/endpoint/endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 18 ++++- tableauserverclient/server/request_factory.py | 79 +++++++++++++------ test/test_datasource.py | 47 +++++++++++ test/test_workbook.py | 50 ++++++++++++ 9 files changed, 234 insertions(+), 33 deletions(-) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 37d66d2dc..6798a2106 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -19,6 +19,7 @@ import logging import tableauserverclient as TSC +from tableauserverclient import ConnectionCredentials, ConnectionItem def main(): @@ -50,10 +51,26 @@ def main(): all_projects, pagination_item = server.projects.get() default_project = next((project for project in all_projects if project.is_default()), None) + connection1 = ConnectionItem() + connection1.server_address = "mssql.test.com" + connection1.connection_credentials = ConnectionCredentials("test", "password", True) + + connection2 = ConnectionItem() + connection2.server_address = "postgres.test.com" + connection2.server_port = "5432" + connection2.connection_credentials = ConnectionCredentials("test", "password", True) + + all_connections = list() + all_connections.append(connection1) + all_connections.append(connection2) + # Step 3: If default project is found, form a new workbook item and publish. if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) - new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true) + new_workbook = server.workbooks.publish(new_workbook, + args.filepath, + overwrite_true, + connections=all_connections) print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index 8c3a77925..c883a515a 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -32,3 +32,15 @@ def oauth(self): @property_is_boolean def oauth(self, value): self._oauth = value + + @classmethod + def from_xml_element(cls, parsed_response, ns): + connection_creds_xml = parsed_response.find('.//t:connectionCredentials', namespaces=ns) + + name = connection_creds_xml.get('name', None) + password = connection_creds_xml.get('password', None) + embed = connection_creds_xml.get('embed', None) + oAuth = connection_creds_xml.get('oAuth', None) + + connection_creds = cls(name, password, embed, oAuth) + return connection_creds diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index a52d32e9e..894cabe62 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET +from .connection_credentials import ConnectionCredentials class ConnectionItem(object): @@ -12,6 +13,7 @@ def __init__(self): self.server_address = None self.server_port = None self.username = None + self.connection_credentials = None @property def datasource_id(self): @@ -51,3 +53,32 @@ def from_response(cls, resp, ns): connection_item._datasource_name = datasource_elem.get('name', None) all_connection_items.append(connection_item) return all_connection_items + + @classmethod + def from_xml_element(cls, parsed_response, ns): + ''' + + + + + + + + + ''' + all_connection_items = list() + all_connection_xml = parsed_response.findall('.//t:connection', namespaces=ns) + + for connection_xml in all_connection_xml: + connection_item = cls() + + 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) + + if connection_credentials is not None: + + connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials) + + return all_connection_items diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 03e261765..5e986f91c 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -151,7 +151,8 @@ def refresh(self, datasource_item): # Publish datasource @api(version="2.0") - def publish(self, datasource_item, file_path, mode, connection_credentials=None): + @parameter_added_in(connections="99.99") + def publish(self, datasource_item, file_path, mode, connection_credentials=None, connections=None): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -180,7 +181,8 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None) upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked(datasource_item, - connection_credentials) + connection_credentials, + connections) else: logger.info('Publishing {0} to server'.format(filename)) with open(file_path, 'rb') as f: @@ -188,7 +190,8 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None) xml_request, content_type = RequestFactory.Datasource.publish_req(datasource_item, filename, file_contents, - connection_credentials) + connection_credentials, + connections) server_response = self.post_request(url, xml_request, content_type) new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index e78b2e0cd..a19c32acd 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -32,7 +32,7 @@ def _safe_to_log(server_response): '''Checks if the server_response content is not xml (eg binary image or zip) and and replaces it with a constant ''' - ALLOWED_CONTENT_TYPES = ('application/xml',) + ALLOWED_CONTENT_TYPES = ('application/xml', 'application/xml;charset=utf-8') if server_response.headers.get('Content-Type', None) not in ALLOWED_CONTENT_TYPES: return '[Truncated File Contents]' else: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 4ce9983f3..537e3ec81 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -199,7 +199,14 @@ def _get_wb_preview_image(self, workbook_item): # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") - def publish(self, workbook_item, file_path, mode, connection_credentials=None): + @parameter_added_in(connections='2.8') + def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None): + + if connection_credentials is not None: + import warnings + warnings.warn("connection_credentials is being deprecated. Use connections instead", + DeprecationWarning) + if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -230,16 +237,21 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None): logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(filename)) upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(workbook_item, - connection_credentials) + connection_credentials=conn_creds, + connections=connections) else: logger.info('Publishing {0} to server'.format(filename)) with open(file_path, 'rb') as f: file_contents = f.read() + conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req(workbook_item, filename, file_contents, - connection_credentials) + connection_credentials=conn_creds, + connections=connections) + logger.debug('Request xml: {0} '.format(xml_request[:1000])) server_response = self.post_request(url, xml_request, content_type) new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 277605fa7..d8f66264d 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -25,6 +25,25 @@ def wrapper(self, *args, **kwargs): return wrapper +def _add_connections_element(connections_element, connection): + connection_element = ET.SubElement(connections_element, 'connection') + connection_element.attrib['serverAddress'] = connection.server_address + if connection.server_port: + connection_element.attrib['serverPort'] = connection.server_port + if connection.connection_credentials: + connection_credentials = connection.connection_credentials + _add_credentials_element(connection_element, connection_credentials) + + +def _add_credentials_element(parent_element, connection_credentials): + credentials_element = ET.SubElement(parent_element, 'connectionCredentials') + credentials_element.attrib['name'] = connection_credentials.name + credentials_element.attrib['password'] = connection_credentials.password + credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' + if connection_credentials.oauth: + credentials_element.attrib['oAuth'] = 'true' + + class AuthRequest(object): def signin_req(self, auth_item): xml_request = ET.Element('tsRequest') @@ -40,20 +59,23 @@ def signin_req(self, auth_item): class DatasourceRequest(object): - def _generate_xml(self, datasource_item, connection_credentials=None): + def _generate_xml(self, datasource_item, connection_credentials=None, connections=None): xml_request = ET.Element('tsRequest') datasource_element = ET.SubElement(xml_request, 'datasource') datasource_element.attrib['name'] = datasource_item.name project_element = ET.SubElement(datasource_element, 'project') project_element.attrib['id'] = datasource_item.project_id - if connection_credentials: - credentials_element = ET.SubElement(datasource_element, 'connectionCredentials') - credentials_element.attrib['name'] = connection_credentials.name - credentials_element.attrib['password'] = connection_credentials.password - credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' - - if connection_credentials.oauth: - credentials_element.attrib['oAuth'] = 'true' + + if connection_credentials is not None and connections is not None: + raise RuntimeError('You cannot set both `connections` and `connection_credentials`') + + if connection_credentials is not None: + _add_credentials_element(datasource_element, connection_credentials) + + if connections is not None: + connections_element = ET.SubElement(datasource_element, 'connections') + for connection in connections: + _add_connections_element(connections_element, connection) return ET.tostring(xml_request) def update_req(self, datasource_item): @@ -73,15 +95,15 @@ def update_req(self, datasource_item): return ET.tostring(xml_request) - def publish_req(self, datasource_item, filename, file_contents, connection_credentials=None): - xml_request = self._generate_xml(datasource_item, connection_credentials) + def publish_req(self, datasource_item, filename, file_contents, connection_credentials=None, connections=None): + xml_request = self._generate_xml(datasource_item, connection_credentials, connections) parts = {'request_payload': ('', xml_request, 'text/xml'), 'tableau_datasource': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) def publish_req_chunked(self, datasource_item, connection_credentials=None): - xml_request = self._generate_xml(datasource_item, connection_credentials) + xml_request = self._generate_xml(datasource_item, connection_credentials, connections) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) @@ -324,7 +346,7 @@ def add_req(self, user_item): class WorkbookRequest(object): - def _generate_xml(self, workbook_item, connection_credentials=None): + def _generate_xml(self, workbook_item, connection_credentials=None, connections=None): xml_request = ET.Element('tsRequest') workbook_element = ET.SubElement(xml_request, 'workbook') workbook_element.attrib['name'] = workbook_item.name @@ -332,14 +354,17 @@ def _generate_xml(self, workbook_item, connection_credentials=None): workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() project_element = ET.SubElement(workbook_element, 'project') project_element.attrib['id'] = workbook_item.project_id - if connection_credentials: - credentials_element = ET.SubElement(workbook_element, 'connectionCredentials') - credentials_element.attrib['name'] = connection_credentials.name - credentials_element.attrib['password'] = connection_credentials.password - credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' - - if connection_credentials.oauth: - credentials_element.attrib['oAuth'] = 'true' + + if connection_credentials is not None and connections is not None: + raise RuntimeError('You cannot set both `connections` and `connection_credentials`') + + if connection_credentials is not None: + _add_credentials_element(workbook_element, connection_credentials) + + if connections is not None: + connections_element = ET.SubElement(workbook_element, 'connections') + for connection in connections: + _add_connections_element(connections_element, connection) return ET.tostring(xml_request) def update_req(self, workbook_item): @@ -357,15 +382,19 @@ def update_req(self, workbook_item): owner_element.attrib['id'] = workbook_item.owner_id return ET.tostring(xml_request) - def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None): - xml_request = self._generate_xml(workbook_item, connection_credentials) + def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None, connections=None): + xml_request = self._generate_xml(workbook_item, + connection_credentials=connection_credentials, + connections=connections) 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): - xml_request = self._generate_xml(workbook_item, connection_credentials) + def publish_req_chunked(self, workbook_item, connections=None): + xml_request = self._generate_xml(workbook_item, + connection_credentials=connection_credentials, + connections=connections) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) diff --git a/test/test_datasource.py b/test/test_datasource.py index 9de8ae375..9ddf5a3c8 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -1,8 +1,10 @@ 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.request_factory import RequestFactory from ._utils import read_xml_asset, read_xml_assets, asset ADD_TAGS_XML = 'datasource_add_tags.xml' @@ -245,3 +247,48 @@ def test_publish_invalid_file_type(self): new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, asset('SampleWB.twbx'), self.server.PublishMode.Append) + + def test_publish_multi_connection(self): + new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection2 = TSC.ConnectionItem() + connection2.server_address = 'pgsql.test.com' + connection2.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Datasource._generate_xml(new_datasource, connections=[connection1, connection2]) + # Can't use ConnectionItem parser due to xml namespace problems + connection_results = ET.fromstring(response).findall('.//connection') + + self.assertEqual(connection_results[0].get('serverAddress', None), 'mysql.test.com') + self.assertEqual(connection_results[0].find('connectionCredentials').get('name', None), 'test') + self.assertEqual(connection_results[1].get('serverAddress', None), 'pgsql.test.com') + self.assertEqual(connection_results[1].find('connectionCredentials').get('password', None), 'secret') + + def test_publish_single_connection(self): + new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Datasource._generate_xml(new_datasource, connection_credentials=connection_creds) + # Can't use ConnectionItem parser due to xml namespace problems + credentials = ET.fromstring(response).findall('.//connectionCredentials') + + self.assertEqual(len(credentials), 1) + self.assertEqual(credentials[0].get('name', None), 'test') + self.assertEqual(credentials[0].get('password', None), 'secret') + self.assertEqual(credentials[0].get('embed', None), 'true') + + def test_credentials_and_multi_connect_raises_exception(self): + new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + with self.assertRaises(RuntimeError): + response = RequestFactory.Datasource._generate_xml(new_datasource, + connection_credentials=connection_creds, + connections=[connection1]) diff --git a/test/test_workbook.py b/test/test_workbook.py index de8f8fbaf..7aab1279b 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -2,7 +2,10 @@ import os import requests_mock import tableauserverclient as TSC +import xml.etree.ElementTree as ET + from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.request_factory import RequestFactory TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -316,3 +319,50 @@ def test_publish_invalid_file_type(self): self.assertRaises(ValueError, self.server.workbooks.publish, new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleDS.tds'), self.server.PublishMode.CreateNew) + + def test_publish_multi_connection(self): + new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection2 = TSC.ConnectionItem() + connection2.server_address = 'pgsql.test.com' + connection2.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2]) + # Can't use ConnectionItem parser due to xml namespace problems + connection_results = ET.fromstring(response).findall('.//connection') + + self.assertEqual(connection_results[0].get('serverAddress', None), 'mysql.test.com') + self.assertEqual(connection_results[0].find('connectionCredentials').get('name', None), 'test') + self.assertEqual(connection_results[1].get('serverAddress', None), 'pgsql.test.com') + self.assertEqual(connection_results[1].find('connectionCredentials').get('password', None), 'secret') + + def test_publish_single_connection(self): + new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds) + # Can't use ConnectionItem parser due to xml namespace problems + credentials = ET.fromstring(response).findall('.//connectionCredentials') + self.assertEqual(len(credentials), 1) + self.assertEqual(credentials[0].get('name', None), 'test') + self.assertEqual(credentials[0].get('password', None), 'secret') + self.assertEqual(credentials[0].get('embed', None), 'true') + + def test_credentials_and_multi_connect_raises_exception(self): + new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + with self.assertRaises(RuntimeError): + response = RequestFactory.Workbook._generate_xml(new_workbook, + connection_credentials=connection_creds, + connections=[connection1]) From 29a1c607b457a5a15da258bb37a89e0d5468c492 Mon Sep 17 00:00:00 2001 From: Anip Mehta Date: Wed, 30 May 2018 13:46:37 -0700 Subject: [PATCH 13/17] fixes issue #296 (#297) --- tableauserverclient/server/request_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index d8f66264d..d479807d2 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -190,7 +190,7 @@ def create_req(self, project_item): if project_item.content_permissions: project_element.attrib['contentPermissions'] = project_item.content_permissions if project_item.parent_id: - project_element.attrib['parentId'] = project_item.parent_id + project_element.attrib['parentProjectId'] = project_item.parent_id return ET.tostring(xml_request) From 6ddbb80e15101d0292352a45552561cba23f9fb6 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Thu, 31 May 2018 11:32:20 -0400 Subject: [PATCH 14/17] initial checkin for get background jobs (#298) * initial checkin for get background jobs * pep8 fixes * addressed code review feedback --- samples/list.py | 6 +- tableauserverclient/__init__.py | 2 +- tableauserverclient/models/__init__.py | 2 +- tableauserverclient/models/job_item.py | 85 +++++++++++++++++++ tableauserverclient/server/__init__.py | 2 +- .../server/endpoint/endpoint.py | 21 ++--- .../server/endpoint/jobs_endpoint.py | 17 +++- tableauserverclient/server/server.py | 14 +++ test/assets/job_get.xml | 10 +++ test/test_job.py | 45 ++++++++++ 10 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 test/assets/job_get.xml create mode 100644 test/test_job.py diff --git a/samples/list.py b/samples/list.py index d1a25f08d..090d7dfdf 100644 --- a/samples/list.py +++ b/samples/list.py @@ -21,7 +21,7 @@ def main(): parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - parser.add_argument('resource_type', choices=['workbook', 'datasource', 'view']) + parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job']) args = parser.parse_args() @@ -41,7 +41,9 @@ def main(): endpoint = { 'workbook': server.workbooks, 'datasource': server.datasources, - 'view': server.views + 'view': server.views, + 'job': server.jobs, + 'project': server.projects, }.get(args.resource_type) for resource in TSC.Pager(endpoint.get): diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 30ec47981..3f2970281 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,6 @@ from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .models import ConnectionCredentials, ConnectionItem, DatasourceItem,\ - GroupItem, JobItem, PaginationItem, ProjectItem, ScheduleItem, \ + GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ SubscriptionItem diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 1ff6be869..710831e07 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -4,7 +4,7 @@ from .exceptions import UnpopulatedPropertyError from .group_item import GroupItem from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval -from .job_item import JobItem +from .job_item import JobItem, BackgroundJobItem from .pagination_item import PaginationItem from .project_item import ProjectItem from .schedule_item import ScheduleItem diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index cc53765ed..f8b68d87f 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET +from ..datetime_helpers import parse_datetime from .target import Target @@ -58,3 +59,87 @@ def _parse_element(cls, element, ns): completed_at = element.get('completedAt', None) finish_code = element.get('finishCode', -1) return cls(id_, type_, created_at, started_at, completed_at, finish_code) + + +class BackgroundJobItem(object): + class Status: + Pending = "Pending" + InProgress = "InProgress" + Success = "Success" + Failed = "Failed" + Cancelled = "Cancelled" + + def __init__(self, id_, created_at, priority, job_type, status, title=None, subtitle=None, started_at=None, + ended_at=None): + self._id = id_ + self._type = job_type + self._status = status + self._created_at = created_at + self._started_at = started_at + self._ended_at = ended_at + self._priority = priority + self._title = title + self._subtitle = subtitle + + @property + def id(self): + return self._id + + @property + def name(self): + """For API consistency - all other resource endpoints have a name attribute which is used to display what + they are. Alias title as name to allow consistent handling of resources in the list sample.""" + return self._title + + @property + def status(self): + return self._status + + @property + def type(self): + return self._type + + @property + def created_at(self): + return self._created_at + + @property + def started_at(self): + return self._started_at + + @property + def ended_at(self): + return self._ended_at + + @property + def title(self): + return self._title + + @property + def subtitle(self): + return self._subtitle + + @property + def priority(self): + return self._priority + + @classmethod + def from_response(cls, xml, ns): + parsed_response = ET.fromstring(xml) + all_tasks_xml = parsed_response.findall( + './/t:backgroundJob', namespaces=ns) + return [cls._parse_element(x, ns) for x in all_tasks_xml] + + @classmethod + def _parse_element(cls, element, ns): + id_ = element.get('id', None) + type_ = element.get('jobType', None) + status = element.get('status', None) + created_at = parse_datetime(element.get('createdAt', None)) + started_at = parse_datetime(element.get('startedAt', None)) + ended_at = parse_datetime(element.get('endedAt', None)) + priority = element.get('priority', None) + title = element.get('title', None) + subtitle = element.get('subtitle', None) + + return cls(id_, created_at, priority, type_, status, title, subtitle, started_at, ended_at) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 704fdb66a..8c5cb314c 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -2,7 +2,7 @@ from .request_options import CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions from .filter import Filter from .sort import Sort -from .. import ConnectionItem, DatasourceItem, JobItem, \ +from .. import ConnectionItem, DatasourceItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index a19c32acd..1efb32f89 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -29,9 +29,9 @@ def _make_common_headers(auth_token, content_type): @staticmethod def _safe_to_log(server_response): - '''Checks if the server_response content is not xml (eg binary image or zip) - and and replaces it with a constant - ''' + """Checks if the server_response content is not xml (eg binary image or zip) + and replaces it with a constant + """ ALLOWED_CONTENT_TYPES = ('application/xml', 'application/xml;charset=utf-8') if server_response.headers.get('Content-Type', None) not in ALLOWED_CONTENT_TYPES: return '[Truncated File Contents]' @@ -90,7 +90,7 @@ def post_request(self, url, xml_request, content_type='text/xml'): def api(version): - '''Annotate the minimum supported version for an endpoint. + """Annotate the minimum supported version for an endpoint. Checks the version on the server object and compares normalized versions. It will raise an exception if the server version is > the version specified. @@ -106,23 +106,18 @@ def api(version): >>> @api(version="2.3") >>> def get(self, req_options=None): >>> ... - ''' + """ def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): - server_version = Version(self.parent_srv.version or "0.0") - minimum_supported = Version(version) - if server_version < minimum_supported: - error = "This endpoint is not available in API version {}. Requires {}".format( - server_version, minimum_supported) - raise EndpointUnavailableError(error) + self.parent_srv.assert_at_least_version(version) return func(self, *args, **kwargs) return wrapper return _decorator def parameter_added_in(**params): - '''Annotate minimum versions for new parameters or request options on an endpoint. + """Annotate minimum versions for new parameters or request options on an endpoint. The api decorator documents when an endpoint was added, this decorator annotates keyword arguments on endpoints that may control functionality added after an endpoint was introduced. @@ -142,7 +137,7 @@ def parameter_added_in(**params): >>> @parameter_added_in(no_extract='2.5') >>> def download(self, workbook_id, filepath=None, extract_only=False): >>> ... - ''' + """ def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 243b04d63..007f550ae 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,5 +1,5 @@ from .endpoint import Endpoint, api -from .. import JobItem +from .. import JobItem, BackgroundJobItem, PaginationItem import logging logger = logging.getLogger('tableau.endpoint.jobs') @@ -11,7 +11,20 @@ def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version='2.6') - def get(self, job_id): + def get(self, job_id=None, req_options=None): + # Backwards Compatibility fix until we rev the major version + if job_id is not None and isinstance(job_id, basestring): + import warnings + warnings.warn("Jobs.get(job_id) is deprecated, update code to use Jobs.get_by_id(job_id)") + return self.get_by_id(job_id) + self.parent_srv.assert_at_least_version('3.1') + server_response = self.get_request(self.baseurl, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + jobs = BackgroundJobItem.from_response(server_response.content, self.parent_srv.namespace) + return jobs, pagination_item + + @api(version='2.6') + def get_by_id(self, job_id): logger.info('Query for information about job ' + job_id) url = "{0}/{1}".format(self.baseurl, job_id) server_response = self.get_request(url) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 0c2b4f1c2..95ee564ee 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -4,9 +4,15 @@ from ..namespace import Namespace from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs +from .endpoint.exceptions import EndpointUnavailableError import requests +try: + from distutils2.version import NormalizedVersion as Version +except ImportError: + from distutils.version import LooseVersion as Version + _PRODUCT_TO_REST_VERSION = { '10.0': '2.3', '9.3': '2.2', @@ -94,6 +100,14 @@ def use_highest_version(self): import warnings warnings.warn("use use_server_version instead", DeprecationWarning) + def assert_at_least_version(self, version): + server_version = Version(self.version or "0.0") + minimum_supported = Version(version) + if server_version < minimum_supported: + error = "This endpoint is not available in API version {}. Requires {}".format( + server_version, minimum_supported) + raise EndpointUnavailableError(error) + @property def baseurl(self): return "{0}/api/{1}".format(self._server_address, str(self.version)) diff --git a/test/assets/job_get.xml b/test/assets/job_get.xml new file mode 100644 index 000000000..4a9f271cc --- /dev/null +++ b/test/assets/job_get.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/test/test_job.py b/test/test_job.py new file mode 100644 index 000000000..674e54c67 --- /dev/null +++ b/test/test_job.py @@ -0,0 +1,45 @@ +import unittest +import os +from datetime import datetime +import requests_mock +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import utc + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') + +GET_XML = os.path.join(TEST_ASSET_DIR, 'job_get.xml') + + +class JobTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.server.version = '3.1' + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = self.server.jobs.baseurl + + def test_get(self): + with open(GET_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_jobs, pagination_item = self.server.jobs.get() + job = all_jobs[0] + created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) + started_at = datetime(2018, 5, 22, 13, 0, 37, tzinfo=utc) + ended_at = datetime(2018, 5, 22, 13, 0, 45, tzinfo=utc) + self.assertEquals(1, pagination_item.total_available) + self.assertEquals('2eef4225-aa0c-41c4-8662-a76d89ed7336', job.id) + self.assertEquals('Success', job.status) + self.assertEquals('50', job.priority) + self.assertEquals('single_subscription_notify', job.type) + self.assertEquals(created_at, job.created_at) + self.assertEquals(started_at, job.started_at) + self.assertEquals(ended_at, job.ended_at) + + def test_get_before_signin(self): + self.server._auth_token = None + self.assertRaises(TSC.NotSignedInError, self.server.jobs.get) From dd87aa6e51aae277409ba9c530a549e0a6a50722 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Fri, 1 Jun 2018 12:06:36 -0400 Subject: [PATCH 15/17] Add cancel job (#299) * added cancel job * fixing whitespace issues * addressing small nits in the sample and the docs --- samples/kill_all_jobs.py | 47 +++++++++++++++++++ .../server/endpoint/endpoint.py | 2 +- .../server/endpoint/jobs_endpoint.py | 9 ++++ tableauserverclient/server/request_options.py | 7 +++ test/test_job.py | 6 +++ 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 samples/kill_all_jobs.py diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py new file mode 100644 index 000000000..9c5f52a50 --- /dev/null +++ b/samples/kill_all_jobs.py @@ -0,0 +1,47 @@ +#### +# 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. +#### + +import argparse +import getpass +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Cancel all of the running background jobs') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-S', default=None, help='site to log into, do not specify for default site') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--password', '-p', default=None, help='password for the user') + + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + + args = parser.parse_args() + + if args.password is None: + password = getpass.getpass("Password: ") + else: + password = args.password + + # 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 + tableau_auth = TSC.TableauAuth(args.username, password, args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + req = TSC.RequestOptions() + + req.filter.add(TSC.Filter("progress", TSC.RequestOptions.Operator.LessThanOrEqual, 0)) + for job in TSC.Pager(server.jobs, request_opts=req): + print(server.jobs.cancel(job.id), job.id, job.status, job.type) + + +if __name__ == '__main__': + main() diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 1efb32f89..994d2133d 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -76,7 +76,7 @@ def delete_request(self, url): # We don't return anything for a delete self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) - def put_request(self, url, xml_request, content_type='text/xml'): + def put_request(self, url, xml_request=None, content_type='text/xml'): return self._make_request(self.parent_srv.session.put, url, content=xml_request, auth_token=self.parent_srv.auth_token, diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 007f550ae..f3432d605 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,5 +1,6 @@ from .endpoint import Endpoint, api from .. import JobItem, BackgroundJobItem, PaginationItem +from ..request_options import RequestOptionsBase import logging logger = logging.getLogger('tableau.endpoint.jobs') @@ -17,12 +18,20 @@ def get(self, job_id=None, req_options=None): import warnings warnings.warn("Jobs.get(job_id) is deprecated, update code to use Jobs.get_by_id(job_id)") return self.get_by_id(job_id) + if isinstance(job_id, RequestOptionsBase): + req_options = job_id + self.parent_srv.assert_at_least_version('3.1') server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) jobs = BackgroundJobItem.from_response(server_response.content, self.parent_srv.namespace) return jobs, pagination_item + @api(version='3.1') + def cancel(self, job_id): + url = '{0}/{1}'.format(self.baseurl, job_id) + return self.put_request(url) + @api(version='2.6') def get_by_id(self, job_id): logger.info('Query for information about job ' + job_id) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index be00e8975..0e3601a25 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -13,20 +13,27 @@ class Operator: In = 'in' class Field: + Args = 'args' + CompletedAt = 'completedAt' CreatedAt = 'createdAt' DomainName = 'domainName' DomainNickname = 'domainNickname' HitsTotal = 'hitsTotal' IsLocal = 'isLocal' + JobType = 'jobType' LastLogin = 'lastLogin' MinimumSiteRole = 'minimumSiteRole' Name = 'name' + Notes = 'notes' OwnerDomain = 'ownerDomain' OwnerEmail = 'ownerEmail' OwnerName = 'ownerName' + Progress = 'progress' ProjectName = 'projectName' SiteRole = 'siteRole' + Subtitle = 'subtitle' Tags = 'tags' + Title = 'title' Type = 'type' UpdatedAt = 'updatedAt' UserCount = 'userCount' diff --git a/test/test_job.py b/test/test_job.py index 674e54c67..5da0f76fa 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -31,6 +31,7 @@ def test_get(self): created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) started_at = datetime(2018, 5, 22, 13, 0, 37, tzinfo=utc) ended_at = datetime(2018, 5, 22, 13, 0, 45, tzinfo=utc) + self.assertEquals(1, pagination_item.total_available) self.assertEquals('2eef4225-aa0c-41c4-8662-a76d89ed7336', job.id) self.assertEquals('Success', job.status) @@ -43,3 +44,8 @@ def test_get(self): def test_get_before_signin(self): self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.jobs.get) + + def test_cancel(self): + with requests_mock.mock() as m: + m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + self.server.jobs.cancel('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') From 3a00122b822b8223a7f32edcc737c3aca3b0ccfe Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Fri, 6 Jul 2018 08:28:40 -0700 Subject: [PATCH 16/17] Prep v0.7 (#310) * Adding chnagelog for 0.7 * Adding Sergey to contributors * Adding async to the changelog --- CHANGELOG.md | 17 +++++++++++++++++ CONTRIBUTORS.md | 1 + 2 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8950a4cb..77aab3ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## 0.7 (2 Jul 2018) + +* Added cancel job (#299) +* Added Get background jobs (#298) +* Added Multi-credential support (#276) +* Added Update Groups (#279) +* Adding project_id to view (#285) +* Added ability to rename workbook using `update workbook` (#284) +* Added Sample for exporting full pdf using pdf page combining (#267) +* Added Sample for exporting data, images, and single view pdfs (#263) +* Added view filters to the populate request options (#260) +* Add Async publishing for workbook and datasource endpoints (#311) +* Fixed ability to update datasource server connection port (#283) +* Fixed next project handling (#267) +* Cleanup debugging output to strip out non-xml response +* Improved refresh sample for readability (#288) + ## 0.6.1 (26 Jan 2018) * Fixed #257 where refreshing extracts does not work due to a missing "self" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4143570cf..25ac5718b 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,6 +15,7 @@ The following people have contributed to this project to make it possible, and w * [William Lang](https://github.com/williamlang) * [Jim Morris](https://github.com/jimbodriven) * [BingoDinkus](https://github.com/BingoDinkus) +* [Sergey Sotnichenko](https://github.com/sotnich) ## Core Team From 59bf8920730e9877675c31885d538748e7e36bfe Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Tue, 10 Jul 2018 13:48:31 -0700 Subject: [PATCH 17/17] Merge multicredential and as job (#313) Merging things since as_job was added in master by accident --- docs/docs/api-ref.md | 3 +- samples/publish_workbook.py | 14 ++++--- tableauserverclient/models/job_item.py | 19 ++++++--- tableauserverclient/models/user_item.py | 11 +++++ .../server/endpoint/datasources_endpoint.py | 20 ++++++--- .../server/endpoint/workbooks_endpoint.py | 17 ++++++-- test/assets/datasource_publish_async.xml | 4 ++ test/assets/workbook_publish_async.xml | 4 ++ test/test_datasource.py | 23 +++++++++- test/test_workbook.py | 42 ++++++++++++++++--- 10 files changed, 130 insertions(+), 27 deletions(-) create mode 100644 test/assets/datasource_publish_async.xml create mode 100644 test/assets/workbook_publish_async.xml diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md index 81d1211dd..68f86321f 100644 --- a/docs/docs/api-ref.md +++ b/docs/docs/api-ref.md @@ -1077,7 +1077,7 @@ The project resources for Tableau are defined in the `ProjectItem` class. The cl ```py -ProjectItem(name, description=None, content_permissions=None) +ProjectItem(name, description=None, content_permissions=None, parent_id=None) ``` The project resources for Tableau are defined in the `ProjectItem` class. The class corresponds to the project resources you can access using the Tableau Server REST API. @@ -1090,6 +1090,7 @@ Name | Description `name` | Name of the project. `description` | The description of the project. `id` | The project id. +`parent_id` | The parent project id. diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 6798a2106..2d460abaf 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -30,6 +30,7 @@ def main(): parser.add_argument('--filepath', '-f', required=True, help='filepath to the workbook 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('--as-job', '-a', help='Publishing asynchronously', action='store_true') args = parser.parse_args() @@ -67,11 +68,14 @@ def main(): # Step 3: If default project is found, form a new workbook item and publish. if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) - new_workbook = server.workbooks.publish(new_workbook, - args.filepath, - overwrite_true, - connections=all_connections) - print("Workbook published. ID: {0}".format(new_workbook.id)) + if args.as_job: + new_job = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, + connections=all_connections, as_job=args.as_job) + print("Workbook published. JOB ID: {0}".format(new_job.id)) + else: + new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, + connections=all_connections, as_job=args.as_job) + print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." raise LookupError(error) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index f8b68d87f..6ad7f0256 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,12 +1,14 @@ 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): - def __init__(self, id_, job_type, created_at, started_at=None, completed_at=None, finish_code=0): + def __init__(self, id_, job_type, progress, created_at, started_at=None, completed_at=None, finish_code=0): self._id = id_ self._type = job_type + self._progress = progress self._created_at = created_at self._started_at = started_at self._completed_at = completed_at @@ -20,6 +22,10 @@ def id(self): def type(self): return self._type + @property + def progress(self): + return self._progress + @property def created_at(self): return self._created_at @@ -38,7 +44,7 @@ def finish_code(self): def __repr__(self): return "".format(**self.__dict__) + " progress ({_progress}) finish_code({_finish_code})>".format(**self.__dict__) @classmethod def from_response(cls, xml, ns): @@ -54,11 +60,12 @@ def from_response(cls, xml, ns): def _parse_element(cls, element, ns): id_ = element.get('id', None) type_ = element.get('type', None) - created_at = element.get('createdAt', None) - started_at = element.get('startedAt', None) - completed_at = element.get('completedAt', None) + progress = element.get('progress', None) + created_at = parse_datetime(element.get('createdAt', None)) + started_at = parse_datetime(element.get('startedAt', None)) + completed_at = parse_datetime(element.get('completedAt', None)) finish_code = element.get('finishCode', -1) - return cls(id_, type_, created_at, started_at, completed_at, finish_code) + return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code) class BackgroundJobItem(object): diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index b0da7b3d0..47d12b662 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -16,6 +16,14 @@ class Roles: ViewerWithPublish = 'ViewerWithPublish' Guest = 'Guest' + Creator = 'Creator' + Explorer = 'Explorer' + ExplorerCanPublish = 'ExplorerCanPublish' + ReadOnly = 'ReadOnly' + SiteAdministratorCreator = 'SiteAdministratorCreator' + SiteAdministratorExplorer = 'SiteAdministratorExplorer' + UnlicensedWithPublish = 'UnlicensedWithPublish' + class Auth: SAML = 'SAML' ServerDefault = 'ServerDefault' @@ -147,3 +155,6 @@ def _parse_element(user_xml, ns): domain_name = domain_elem.get('name', None) return id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name + + def __repr__(self): + return "".format(self.id, self.name, self.site_role) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 5e986f91c..904d27144 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -151,8 +151,9 @@ def refresh(self, datasource_item): # Publish datasource @api(version="2.0") - @parameter_added_in(connections="99.99") - def publish(self, datasource_item, file_path, mode, connection_credentials=None, connections=None): + @parameter_added_in(connections="2.8") + @parameter_added_in(as_job='3.0') + def publish(self, datasource_item, file_path, mode, connection_credentials=None, connections=None, as_job=False): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -175,6 +176,9 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None, if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: url += '&{0}=true'.format(mode.lower()) + if as_job: + url += '&{0}=true'.format('asJob') + # Determine if chunking is required (64MB is the limit for single upload method) if os.path.getsize(file_path) >= FILESIZE_LIMIT: logger.info('Publishing {0} to server with chunking method (datasource over 64MB)'.format(filename)) @@ -193,6 +197,12 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None, connection_credentials, connections) server_response = self.post_request(url, xml_request, content_type) - new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) - return new_datasource + + if as_job: + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) + return new_job + else: + new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) + return new_datasource diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 537e3ec81..79b15f379 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -199,8 +199,9 @@ def _get_wb_preview_image(self, workbook_item): # Publishes workbook. Chunking method if file over 64MB @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): + def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None, as_job=False): if connection_credentials is not None: import warnings @@ -232,6 +233,9 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c error = 'Workbooks cannot be appended.' raise ValueError(error) + if as_job: + url += '&{0}=true'.format('asJob') + # Determine if chunking is required (64MB is the limit for single upload method) if os.path.getsize(file_path) >= FILESIZE_LIMIT: logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(filename)) @@ -253,6 +257,11 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c connections=connections) logger.debug('Request xml: {0} '.format(xml_request[:1000])) server_response = self.post_request(url, xml_request, content_type) - new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) - return new_workbook + if as_job: + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) + return new_job + else: + new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) + return new_workbook diff --git a/test/assets/datasource_publish_async.xml b/test/assets/datasource_publish_async.xml new file mode 100644 index 000000000..a32fccd2a --- /dev/null +++ b/test/assets/datasource_publish_async.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/assets/workbook_publish_async.xml b/test/assets/workbook_publish_async.xml new file mode 100644 index 000000000..21e4e83ed --- /dev/null +++ b/test/assets/workbook_publish_async.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index 9ddf5a3c8..1b21c0194 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -13,6 +13,7 @@ GET_BY_ID_XML = 'datasource_get_by_id.xml' POPULATE_CONNECTIONS_XML = 'datasource_populate_connections.xml' PUBLISH_XML = 'datasource_publish.xml' +PUBLISH_XML_ASYNC = 'datasource_publish_async.xml' UPDATE_XML = 'datasource_update.xml' UPDATE_CONNECTION_XML = 'datasource_connection_update.xml' @@ -178,9 +179,11 @@ def test_publish(self): with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + publish_mode = self.server.PublishMode.CreateNew + new_datasource = self.server.datasources.publish(new_datasource, asset('SampleDS.tds'), - mode=self.server.PublishMode.CreateNew) + mode=publish_mode) self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) self.assertEqual('SampleDS', new_datasource.name) @@ -192,6 +195,24 @@ def test_publish(self): self.assertEqual('default', new_datasource.project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) + def test_publish_async(self): + response_xml = read_xml_asset(PUBLISH_XML_ASYNC) + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + publish_mode = self.server.PublishMode.CreateNew + + new_job = self.server.datasources.publish(new_datasource, + asset('SampleDS.tds'), + mode=publish_mode, + as_job=True) + + self.assertEqual('9a373058-af5f-4f83-8662-98b3e0228a73', new_job.id) + self.assertEqual('PublishDatasource', new_job.type) + self.assertEqual('0', new_job.progress) + self.assertEqual('2018-06-30T00:54:54Z', format_datetime(new_job.created_at)) + self.assertEqual('1', new_job.finish_code) + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', status_code=204) diff --git a/test/test_workbook.py b/test/test_workbook.py index 7aab1279b..d4e2275f4 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -18,6 +18,7 @@ POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views.xml') POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views_usage.xml') PUBLISH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish.xml') +PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish_async.xml') UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') @@ -290,10 +291,17 @@ def test_publish(self): 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, + + new_workbook = TSC.WorkbookItem(name='Sample', + show_tabs=False, project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - new_workbook = self.server.workbooks.publish(new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx'), - self.server.PublishMode.CreateNew) + + sample_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + new_workbook = self.server.workbooks.publish(new_workbook, + sample_workbok, + publish_mode) self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) self.assertEqual('RESTAPISample', new_workbook.name) @@ -309,10 +317,34 @@ 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_async(self): + with open(PUBLISH_ASYNC_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_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + new_job = self.server.workbooks.publish(new_workbook, + sample_workbok, + publish_mode, + as_job=True) + + self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) + self.assertEqual('PublishWorkbook', new_job.type) + self.assertEqual('0', new_job.progress) + self.assertEqual('2018-06-29T23:22:32Z', format_datetime(new_job.created_at)) + self.assertEqual('1', new_job.finish_code) + def test_publish_invalid_file(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, - '.', self.server.PublishMode.CreateNew) + self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, '.', + self.server.PublishMode.CreateNew) def test_publish_invalid_file_type(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760')