diff --git a/README.md b/README.md index 4eeac0a..dd87c44 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ The TableauDatasource class uses the `TDEFileGenerator` and/or the `HyperFileGen * 4.5.0: 2018.1 (API 3.0) compatibility. All requests for a given connection using a single HTTP session, and other improvements to the RestXmlRequest class. * 4.7.0: Dropping API 2.0 (Tableau 9.0) compatibility. Any method that is overwritten in a later version will not be updated in the TableauRestApiConnection class going forward. Also implemented a direct_xml_request parameter for Add and Update methods, allowing direct submission of an ElementTree.Element request to the endpoints, particularly for replication. * 4.8.0 Introduces the RestJsonRequest object and _json plural querying methods for passing JSON responses to other systems +* 4.9.0 API 3.3 (2019.1) compatibility, as well as ability to swap in static files using TableauDocument and other bug fixes. ## --- Table(au) of Contents --- ------ @@ -1121,6 +1122,23 @@ ex. print(file_2) # u'A Workbook (2).twb' +#### 2.2.1 Replacing Static Data Files +`TableauFile` has an optional argument on the save_new_file method to allow swapping in new data files (CSV, XLS or Hyper) into an existing TWBX or TDSX. + + TableauFile.save_new_file(new_filename_no_extension, data_file_replacement_map=None) # returns new filename + +data_file_replacement_map accepts a dict in format { 'TableauFileFilename' : 'FilenameOfNewFileOnDisk' }. To find out the TableauFileFilename, print out the `other_files` property of the TableauFile object: + + t_file = TableauFile('My AmazingWorkbook.twbx') + for file in t_file.other_files: + print(file) + +You should be able to find the exact naming of the data file you want to replace. Copy that exactly and use it as the key in your dictionary. For the value, use a fully qualified filename on your machine: + + t_file = TableauFile('My AmazingWorkbook.twbx') + file_map = { 'Data/en_US-US/Sample - Superstore.xls' : '/Users/bhowell/Documents/My Tableau Repository/Datasources/2018.3/en_US-EU/Sample - EU Superstore.xls'} + t_file.save_new_file('My AmazingWorkbook - Updated', data_file_replacement_map=file_map) + ### 2.3 TableauDocument Class The TableauDocument class helps map the differences between `TableauWorkbook` and `TableauDatasource`. It only implements two properties: diff --git a/setup.py b/setup.py index e5496b8..1856c04 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='tableau_tools', - version='4.8.3', + version='4.9.0', packages=['tableau_tools', 'tableau_tools.tableau_rest_api', 'tableau_tools.tableau_documents', 'tableau_tools.examples'], url='https://github.com/bryantbhowell/tableau_tools', license='', diff --git a/tableau_base.py b/tableau_base.py index 03085ce..244400d 100644 --- a/tableau_base.py +++ b/tableau_base.py @@ -10,13 +10,13 @@ class TableauBase(object): def __init__(self): # In reverse order to work down until the acceptable version is found on the server, through login process - self.supported_versions = (u'2018.3', u'2018.2', u'2018.1', u"10.5", u"10.4", u"10.3", u"10.2", u"10.1", u"10.0", u"9.3", u"9.2", u"9.1", u"9.0") + self.supported_versions = (u'2019.1', u'2018.3', u'2018.2', u'2018.1', u"10.5", u"10.4", u"10.3", u"10.2", u"10.1", u"10.0", u"9.3", u"9.2", u"9.1", u"9.0") self.logger = None self.luid_pattern = r"[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*" # Defaults, will get updated with each update. Overwritten by set_tableau_server_version - self.version = u"10.5" - self.api_version = u"2.8" + self.version = u"2018.2" + self.api_version = u"3.1" self.tableau_namespace = u'http://tableau.com/api' self.ns_map = {'t': 'http://tableau.com/api'} self.ns_prefix = '{' + self.ns_map['t'] + '}' @@ -89,7 +89,8 @@ def __init__(self): u"2.8": server_content_roles_2_1, u'3.0': server_content_roles_2_1, u'3.1': server_content_roles_2_1, - u'3.2': server_content_roles_2_1 + u'3.2': server_content_roles_2_1, + u'3.3': server_content_roles_2_1 } self.server_to_rest_capability_map = { @@ -220,6 +221,43 @@ def __init__(self): ) } + capabilities_3_3 = { + u"project": (u"Read", u"Write", u'ProjectLeader', u'InheritedProjectLeader'), + u"workbook": ( + u'Read', + u'ExportImage', + u'ExportData', + u'ViewComments', + u'AddComment', + u'Filter', + u'ViewUnderlyingData', + u'ShareView', + u'WebAuthoring', + u'Write', + u'ExportXml', + u'ChangeHierarchy', + u'Delete', + u'ChangePermissions', + + ), + u"datasource": ( + u'Read', + u'Connect', + u'Write', + u'ExportXml', + u'Delete', + u'ChangePermissions' + ), + u'flow': ( + u'ChangeHierarchy', + u'ChangePermissions', + u'Delete', + u'ExportXml', + u'Read', + u'Write' + ) + } + self.available_capabilities = { u"2.0": capabilities_2_0, u"2.1": capabilities_2_1, @@ -232,7 +270,8 @@ def __init__(self): u'2.8': capabilities_2_8, u'3.0': capabilities_2_8, u'3.1': capabilities_2_8, - u'3.2': capabilities_2_8 + u'3.2': capabilities_2_8, + u'3.3': capabilities_3_3 } @@ -283,7 +322,7 @@ def __init__(self): u"Hyper": u'hyper' } - self.permissionable_objects = (u'datasource', u'project', u'workbook') + self.permissionable_objects = (u'datasource', u'project', u'workbook', u'flow') def set_tableau_server_version(self, tableau_server_version): """ @@ -314,6 +353,8 @@ def set_tableau_server_version(self, tableau_server_version): self.api_version = u'3.1' elif unicode(tableau_server_version) == u'2018.3': self.api_version = u'3.2' + elif unicode(tableau_server_version) == u'2019.1': + self.api_version = u'3.3' self.tableau_namespace = u'http://tableau.com/api' self.ns_map = {'t': 'http://tableau.com/api'} self.version = tableau_server_version diff --git a/tableau_documents/tableau_file.py b/tableau_documents/tableau_file.py index 5586746..6f33972 100644 --- a/tableau_documents/tableau_file.py +++ b/tableau_documents/tableau_file.py @@ -118,9 +118,10 @@ def tableau_document(self): return self._tableau_document # Appropriate extension added if needed - def save_new_file(self, new_filename_no_extension): + def save_new_file(self, new_filename_no_extension, data_file_replacement_map=None): """ :type new_filename_no_extension: unicode + :type data_file_replacement_map: dict :rtype: unicode """ self.start_log_block() @@ -194,9 +195,16 @@ def save_new_file(self, new_filename_no_extension): self.log(u'File {} is from an extract that has been replaced, skipping'.format(filename)) continue - o_zf.extract(filename) - new_zf.write(filename) - os.remove(filename) + # If file is listed in the data_file_replacement_map, write data from the mapped in file + if filename in data_file_replacement_map: + #data_file_obj = open(filename, mode='wb') + #data_file_obj.write(data_file_replacement_map[filename]) + #data_file_obj.close() + new_zf.write(data_file_replacement_map[filename], u"/" + filename) + else: + o_zf.extract(filename) + new_zf.write(filename) + os.remove(filename) self.log(u'Removed file {}'.format(filename)) lowest_level = filename.split('/') temp_directories_to_remove[lowest_level[0]] = True @@ -211,7 +219,11 @@ def save_new_file(self, new_filename_no_extension): # Cleanup all the temporary directories for directory in temp_directories_to_remove: self.log(u'Removing directory {}'.format(directory)) - shutil.rmtree(directory) + try: + shutil.rmtree(directory) + except OSError as e: + # Just means that directory didn't exist for some reason, probably a swap occurred + pass new_zf.close() return save_filename diff --git a/tableau_repository.py b/tableau_repository.py index a2b918f..d1d264f 100644 --- a/tableau_repository.py +++ b/tableau_repository.py @@ -268,52 +268,3 @@ def query_site_id_from_datasource_luid(self, datasource_luid): datasource_id = row[0] return datasource_id - def set_workbook_on_schedule(self, workbook_luid, schedule_name): - if TableauBase.is_luid(workbook_luid) is False: - raise InvalidOptionException(u'Workbook luid must be a luid. You passed in {}'.format(workbook_luid)) - wb_id = self.query_workbook_id_from_luid(workbook_luid) - site_id = self.query_site_id_from_workbook_luid(workbook_luid) - schedule_id = self.get_extract_schedule_id_by_name(schedule_name) - - insert_query = """ - INSERT INTO tasks - VALUES( - DEFAULT -- id will auto-increment if you pass DEFAULT - , %s --schedule_id from _schedules - , 'RefreshExtractTask' -- or 'IncrementExtractTask' for incremental - ,1 --priority, can be lower if you want. Workbooks seem to default to 50 - ,%s --obj_id from _datasources or _workbooks - ,NOW() --created_at - ,NOW() --created_at - ,%s --site_id - ,'Workbook' -- 'Datasource' or 'Workbook' - ,NULL --luid will autogenerate correctly when NULL, based on trigger function - , 0 -- this starts as 0 - ) -""" - self.query(insert_query, [schedule_id, wb_id, site_id]) - - def set_datasource_on_schedule(self, datasource_luid, schedule_name): - if TableauBase.is_luid(datasource_luid) is False: - raise InvalidOptionException(u'Workbook luid must be a luid. You passed in {}'.format(datasource_luid)) - ds_id = self.query_datasource_id_from_luid(datasource_luid) - site_id = self.query_site_id_from_datasource_luid(datasource_luid) - schedule_id = self.get_extract_schedule_id_by_name(schedule_name) - - insert_query = """ - INSERT INTO tasks - VALUES( - DEFAULT -- id will auto-increment if you pass DEFAULT - , %s --schedule_id from _schedules - , 'RefreshExtractTask' -- or 'IncrementExtractTask' for incremental - ,1 --priority, can be lower if you want. Workbooks seem to default to 50 - ,%s --obj_id from _datasources or _workbooks - ,NOW() --created_at - ,NOW() --created_at - ,%s --site_id - ,'Datasource' -- 'Datasource' or 'Workbook' - ,NULL --luid will autogenerate correctly when NULL, based on trigger function - , 0 -- this starts as 0 - ) -""" - self.query(insert_query, [schedule_id, ds_id, site_id]) diff --git a/tableau_rest_api/permissions.py b/tableau_rest_api/permissions.py index d1f7a5f..69c3f1a 100644 --- a/tableau_rest_api/permissions.py +++ b/tableau_rest_api/permissions.py @@ -386,3 +386,13 @@ def __init__(self, group_or_user, group_or_user_luid): u'all': u'Allow' } } + + +class FlowPermissions33(Permissions): + def __init__(self, group_or_user, group_or_user_luid): + Permissions.__init__(self, group_or_user, group_or_user_luid, u'flow') + for cap in self.available_capabilities[u'3.3'][u'flow']: + if cap != u'all': + self.capabilities[cap] = None + # Unclear that there are any defined roles for Prep Conductor flows + self.role_set = {} \ No newline at end of file diff --git a/tableau_rest_api/published_content.py b/tableau_rest_api/published_content.py index 0313611..a326bac 100644 --- a/tableau_rest_api/published_content.py +++ b/tableau_rest_api/published_content.py @@ -1190,4 +1190,13 @@ def convert_capabilities_xml_into_obj_list(self, xml_obj): obj_list.append(perms_obj) self.log(u'Permissions object list has {} items'.format(unicode(len(obj_list)))) self.end_log_block() - return obj_list \ No newline at end of file + return obj_list + + +class Flow(PublishedContent): + def __init__(self, luid, tableau_rest_api_obj, tableau_server_version, default=False, logger_obj=None, + content_xml_obj=None): + PublishedContent.__init__(self, luid, u"flow", tableau_rest_api_obj, tableau_server_version, + default=default, logger_obj=logger_obj, content_xml_obj=content_xml_obj) + self.__available_capabilities = self.available_capabilities[self.api_version][u"flow"] + self.log(u"Flow object initiating") \ No newline at end of file diff --git a/tableau_rest_api/tableau_rest_api_connection.py b/tableau_rest_api/tableau_rest_api_connection.py index 237dd03..d9e7531 100644 --- a/tableau_rest_api/tableau_rest_api_connection.py +++ b/tableau_rest_api/tableau_rest_api_connection.py @@ -1219,16 +1219,17 @@ def query_job(self, job_luid): # Start of download / save methods # - # Do not include file extension - def save_workbook_view_preview_image(self, wb_name_or_luid, view_name_or_luid, filename_no_extension, + # You must pass in the wb name because the endpoint needs it (although, you could potentially look up the + # workbook LUID from the view LUID + def query_view_preview_image(self, wb_name_or_luid, view_name_or_luid, proj_name_or_luid=None): """ :type wb_name_or_luid: unicode :type view_name_or_luid: unicode :type proj_name_or_luid: unicode :type filename_no_extension: unicode - :rtype: - """ + :rtype: bytes + """ self.start_log_block() if self.is_luid(wb_name_or_luid): wb_luid = wb_name_or_luid @@ -1241,33 +1242,66 @@ def save_workbook_view_preview_image(self, wb_name_or_luid, view_name_or_luid, f view_luid = self.query_workbook_view_luid(wb_name_or_luid, view_name=view_name_or_luid, proj_name_or_luid=proj_name_or_luid) try: - if filename_no_extension.find('.png') == -1: - filename_no_extension += '.png' - save_file = open(filename_no_extension, 'wb') + url = self.build_api_url(u"workbooks/{}/views/{}/previewImage".format(wb_luid, view_luid)) image = self.send_binary_get_request(url) - save_file.write(image) - save_file.close() + self.end_log_block() + return image # You might be requesting something that doesn't exist except RecoverableHTTPException as e: - self.log(u"Attempt to request preview image results in HTTP error {}, Tableau Code {}".format(e.http_code, e.tableau_error_code)) + self.log(u"Attempt to request preview image results in HTTP error {}, Tableau Code {}".format(e.http_code, + e.tableau_error_code)) self.end_log_block() raise + + + # Do not include file extension + + # Just an alias but it matches the naming of the current reference guide (2019.1) + def save_view_preview_image(self, wb_name_or_luid, view_name_or_luid, filename_no_extension, + proj_name_or_luid=None): + """ + :type wb_name_or_luid: unicode + :type view_name_or_luid: unicode + :type proj_name_or_luid: unicode + :type filename_no_extension: unicode + :rtype: + """ + self.save_workbook_view_preview_image(wb_name_or_luid, view_name_or_luid, filename_no_extension, + proj_name_or_luid) + + def save_workbook_view_preview_image(self, wb_name_or_luid, view_name_or_luid, filename_no_extension, + proj_name_or_luid=None): + """ + :type wb_name_or_luid: unicode + :type view_name_or_luid: unicode + :type proj_name_or_luid: unicode + :type filename_no_extension: unicode + :rtype: + """ + self.start_log_block() + image = self.query_view_preview_image(wb_name_or_luid=wb_name_or_luid, view_name_or_luid=view_name_or_luid, + proj_name_or_luid=proj_name_or_luid) + if filename_no_extension.find('.png') == -1: + filename_no_extension += '.png' + try: + save_file = open(filename_no_extension, 'wb') + save_file.write(image) + save_file.close() + self.end_log_block() + except IOError: self.log(u"Error: File '{}' cannot be opened to save to".format(filename_no_extension)) self.end_log_block() raise - # Do not include file extension - def save_workbook_preview_image(self, wb_name_or_luid, filename_no_extension, proj_name_or_luid=None): + def query_workbook_preview_image(self, wb_name_or_luid, proj_name_or_luid=None): """ :type wb_name_or_luid: unicode - :param filename_no_extension: Correct extension will be added automatically - :type filename_no_extension: unicode :type proj_name_or_luid: unicode - :rtype: + :rtype: bytes """ self.start_log_block() if self.is_luid(wb_name_or_luid): @@ -1275,20 +1309,38 @@ def save_workbook_preview_image(self, wb_name_or_luid, filename_no_extension, pr else: wb_luid = self.query_workbook_luid(wb_name_or_luid, proj_name_or_luid) try: - if filename_no_extension.find('.png') == -1: - filename_no_extension += '.png' - save_file = open(filename_no_extension, 'wb') + url = self.build_api_url(u"workbooks/{}/previewImage".format(wb_luid)) image = self.send_binary_get_request(url) - save_file.write(image) - save_file.close() self.end_log_block() + return image # You might be requesting something that doesn't exist, but unlikely except RecoverableHTTPException as e: self.log(u"Attempt to request preview image results in HTTP error {}, Tableau Code {}".format(e.http_code, e.tableau_error_code)) self.end_log_block() raise + + + # Do not include file extension + def save_workbook_preview_image(self, wb_name_or_luid, filename_no_extension, proj_name_or_luid=None): + """ + :type wb_name_or_luid: unicode + :param filename_no_extension: Correct extension will be added automatically + :type filename_no_extension: unicode + :type proj_name_or_luid: unicode + :rtype: + """ + self.start_log_block() + image = self.query_workbook_preview_image(wb_name_or_luid=wb_name_or_luid, proj_name_or_luid=proj_name_or_luid) + if filename_no_extension.find('.png') == -1: + filename_no_extension += '.png' + try: + save_file = open(filename_no_extension, 'wb') + save_file.write(image) + save_file.close() + self.end_log_block() + except IOError: self.log(u"Error: File '{}' cannot be opened to save to".format(filename_no_extension)) self.end_log_block() @@ -2288,7 +2340,8 @@ def publish_datasource(self, ds_filename, ds_name, project_obj, overwrite=False, # If a TableauDatasource or TableauWorkbook is passed, will upload from its content def publish_content(self, content_type, content_filename, content_name, project_luid, url_params=None, connection_username=None, connection_password=None, save_credentials=True, show_tabs=False, - check_published_ds=True, oauth_flag=False): + check_published_ds=True, oauth_flag=False, generate_thumbnails_as_username_or_luid=None, + description=None, views_to_hide_list=None): # Single upload limit in MB single_upload_limit = 20 @@ -2296,14 +2349,14 @@ def publish_content(self, content_type, content_filename, content_name, project_ temp_wb_filename = None # Must be 'workbook' or 'datasource' - if content_type not in [u'workbook', u'datasource']: - raise InvalidOptionException(u"content_type must be 'workbook' or 'datasource'") + if content_type not in [u'workbook', u'datasource', u'flow']: + raise InvalidOptionException(u"content_type must be 'workbook', 'datasource', or 'flow' ") file_extension = None final_filename = None cleanup_temp_file = False - for ending in [u'.twb', u'.twbx', u'.tde', u'.tdsx', u'.tds', u'.tde', u'.hyper']: + for ending in [u'.twb', u'.twbx', u'.tde', u'.tdsx', u'.tds', u'.tde', u'.hyper', u'.tfl', u'.tflx']: if content_filename.endswith(ending): file_extension = ending[1:] @@ -2339,11 +2392,17 @@ def publish_content(self, content_type, content_filename, content_name, project_ # Build publish request in ElementTree then convert at publish publish_request_xml = etree.Element(u'tsRequest') - # could be either workbook or datasource + # could be either workbook, datasource, or flow t1 = etree.Element(content_type) t1.set(u'name', content_name) if show_tabs is not False: t1.set(u'showTabs', str(show_tabs).lower()) + if generate_thumbnails_as_username_or_luid is not None: + if self.is_luid(generate_thumbnails_as_username_or_luid): + thumbnail_user_luid = generate_thumbnails_as_username_or_luid + else: + thumbnail_user_luid = self.query_user_luid(generate_thumbnails_as_username_or_luid) + t1.set(u'generateThumbnailsAsUser', thumbnail_user_luid) if connection_username is not None: cc = etree.Element(u'connectionCredentials') @@ -2355,6 +2414,19 @@ def publish_content(self, content_type, content_filename, content_name, project_ cc.set(u'embed', str(save_credentials).lower()) t1.append(cc) + # Views to Hide in Workbooks from 3.2 + if views_to_hide_list is not None: + if len(views_to_hide_list) > 0: + vs = etree.Element(u'views') + for view_name in views_to_hide_list: + v = etree.Element(u'view') + v.set(u'name', view_name) + v.set(u'hidden', u'true') + t1.append(vs) + + # Description only allowed for Flows as of 3.3 + if description is not None: + t1.set(u'description', description) p = etree.Element(u'project') p.set(u'id', project_luid) t1.append(p) diff --git a/tableau_rest_api/tableau_rest_api_connection_25.py b/tableau_rest_api/tableau_rest_api_connection_25.py index 440af95..2ec801e 100644 --- a/tableau_rest_api/tableau_rest_api_connection_25.py +++ b/tableau_rest_api/tableau_rest_api_connection_25.py @@ -1,5 +1,5 @@ from tableau_rest_api_connection_24 import * - +import urllib class TableauRestApiConnection25(TableauRestApiConnection24): def __init__(self, server, username, password, site_content_url=u""): @@ -120,12 +120,13 @@ def update_project(self, name_or_luid, new_project_name=None, new_project_descri self.end_log_block() return self.get_published_project_object(project_luid, response) - def query_view_image(self, view_name_or_luid, save_filename_no_extension, high_resolution=False, + # Generic implementation of all the CSV/PDF/PNG requests + def _query_data_file(self, download_type, view_name_or_luid, high_resolution=None, view_filter_map=None, wb_name_or_luid=None, proj_name_or_luid=None): """ :type view_name_or_luid: unicode - :type save_filename_no_extension: unicode :type high_resolution: bool + :type view_filter_map: dict :type wb_name_or_luid: unicode :type proj_name_or_luid :rtype: @@ -137,7 +138,85 @@ def query_view_image(self, view_name_or_luid, save_filename_no_extension, high_r view_luid = self.query_workbook_view_luid(wb_name_or_luid, view_name=view_name_or_luid, proj_name_or_luid=proj_name_or_luid) + if view_filter_map is not None: + final_filter_map = {} + for key in view_filter_map: + new_key = u"vf_{}".format(key) + # Check if this just a string + if isinstance(view_filter_map[key], basestring): + value = view_filter_map[key] + else: + value = ",".join(map(unicode,view_filter_map[key])) + final_filter_map[new_key] = value + + additional_url_params = u"?" + urllib.urlencode(final_filter_map) + if high_resolution is True: + additional_url_params += u"&resolution=high" + + else: + additional_url_params = u"" + if high_resolution is True: + additional_url_params += u"?resolution=high" + try: + + url = self.build_api_url(u"views/{}/{}{}".format(view_luid, download_type, additional_url_params)) + binary_result = self.send_binary_get_request(url) + + self.end_log_block() + return binary_result + except RecoverableHTTPException as e: + self.log(u"Attempt to request results in HTTP error {}, Tableau Code {}".format(e.http_code, e.tableau_error_code)) + self.end_log_block() + raise + + def query_view_image(self, view_name_or_luid, high_resolution=False, view_filter_map=None, + wb_name_or_luid=None, proj_name_or_luid=None): + """ + :type view_name_or_luid: unicode + :type high_resolution: bool + :type view_filter_map: dict + :type wb_name_or_luid: unicode + :type proj_name_or_luid + :rtype: + """ + self.start_log_block() + image = self._query_data_file(u'image', view_name_or_luid=view_name_or_luid, high_resolution=high_resolution, + view_filter_map=view_filter_map, wb_name_or_luid=wb_name_or_luid, + proj_name_or_luid=proj_name_or_luid) self.end_log_block() + return image + + def save_view_image(self, wb_name_or_luid=None, view_name_or_luid=None, filename_no_extension=None, + proj_name_or_luid=None, view_filter_map=None): + """ + :type wb_name_or_luid: unicode + :type view_name_or_luid: unicode + :type proj_name_or_luid: unicode + :type filename_no_extension: unicode + :type view_filter_map: dict + :rtype: + """ + self.start_log_block() + data = self.query_view_image(wb_name_or_luid=wb_name_or_luid, view_name_or_luid=view_name_or_luid, + proj_name_or_luid=proj_name_or_luid, view_filter_map=view_filter_map) + + if filename_no_extension is not None: + if filename_no_extension.find('.png') == -1: + filename_no_extension += '.png' + try: + save_file = open(filename_no_extension, 'wb') + save_file.write(data) + save_file.close() + self.end_log_block() + return + except IOError: + self.log(u"Error: File '{}' cannot be opened to save to".format(filename_no_extension)) + self.end_log_block() + raise + else: + raise InvalidOptionException( + u'This method is for saving response to file. Must include filename_no_extension parameter') + ### ### Fields can be used to limit or expand details can be brought in diff --git a/tableau_rest_api/tableau_rest_api_connection_28.py b/tableau_rest_api/tableau_rest_api_connection_28.py index b8374da..bda1bcb 100644 --- a/tableau_rest_api/tableau_rest_api_connection_28.py +++ b/tableau_rest_api/tableau_rest_api_connection_28.py @@ -208,6 +208,14 @@ def add_datasource_to_schedule(self, ds_name_or_luid, schedule_name_or_luid, pro self.end_log_block() + def query_view_pdf(self, wb_name_or_luid, view_name_or_luid, proj_name_or_luid=None, + view_filter_map=None): + self.start_log_block() + pdf = self._query_data_file(u'pdf', view_name_or_luid=view_name_or_luid, wb_name_or_luid=wb_name_or_luid, + proj_name_or_luid=proj_name_or_luid, view_filter_map=view_filter_map) + self.end_log_block() + return pdf + # Do not include file extension def save_view_pdf(self, wb_name_or_luid, view_name_or_luid, filename_no_extension, proj_name_or_luid=None, view_filter_map=None): @@ -220,134 +228,66 @@ def save_view_pdf(self, wb_name_or_luid, view_name_or_luid, filename_no_extensio :rtype: """ self.start_log_block() + pdf = self.query_view_pdf(view_name_or_luid=view_name_or_luid, wb_name_or_luid=wb_name_or_luid, + proj_name_or_luid=proj_name_or_luid, view_filter_map=view_filter_map) - if self.is_luid(view_name_or_luid): - view_luid = view_name_or_luid - else: - if wb_name_or_luid is None: - raise InvalidOptionException(u'If looking up view by name, must include workbook') - view_luid = self.query_workbook_view_luid(wb_name_or_luid, view_name=view_name_or_luid, - proj_name_or_luid=proj_name_or_luid) + if filename_no_extension.find(u'.pdf') == -1: + filename_no_extension += u'.pdf' try: - if filename_no_extension.find(u'.pdf') == -1: - filename_no_extension += u'.pdf' save_file = open(filename_no_extension, 'wb') - if view_filter_map is not None: - final_filter_map = {} - for key in view_filter_map: - new_key = u"vf_{}".format(key) - final_filter_map[new_key] = view_filter_map[key] - - additional_url_params = u"?" + urllib.urlencode(final_filter_map) - else: - additional_url_params = u"" - url = self.build_api_url(u"views/{}/pdf{}".format(view_luid, additional_url_params)) - image = self.send_binary_get_request(url) - save_file.write(image) + save_file.write(pdf) save_file.close() self.end_log_block() - - # You might be requesting something that doesn't exist - except RecoverableHTTPException as e: - self.log(u"Attempt to request preview image results in HTTP error {}, Tableau Code {}".format(e.http_code, e.tableau_error_code)) - self.end_log_block() - raise except IOError: self.log(u"Error: File '{}' cannot be opened to save to".format(filename_no_extension)) self.end_log_block() raise - def save_view_data_as_csv(self, wb_name_or_luid=None, view_name_or_luid=None, filename_no_extension=None, - proj_name_or_luid=None, view_filter_map=None): + def query_view_data(self, wb_name_or_luid=None, view_name_or_luid=None, proj_name_or_luid=None, + view_filter_map=None): """ :type wb_name_or_luid: unicode :type view_name_or_luid: unicode :type proj_name_or_luid: unicode - :type filename_no_extension: unicode :type view_filter_map: dict :rtype: """ self.start_log_block() + csv = self._query_data_file(u'data', view_name_or_luid=view_name_or_luid, wb_name_or_luid=wb_name_or_luid, + proj_name_or_luid=proj_name_or_luid, view_filter_map=view_filter_map) + self.end_log_block() + return csv - if self.is_luid(view_name_or_luid): - view_luid = view_name_or_luid - else: - if wb_name_or_luid is None: - raise InvalidOptionException(u'If looking up view by name, must include workbook') - view_luid = self.query_workbook_view_luid(wb_name_or_luid, view_name=view_name_or_luid, - proj_name_or_luid=proj_name_or_luid) - try: - if view_filter_map is not None: - final_filter_map = {} - for key in view_filter_map: - new_key = u"vf_{}".format(key) - final_filter_map[new_key] = view_filter_map[key] - - additional_url_params = u"?" + urllib.urlencode(final_filter_map) - else: - additional_url_params = u"" - url = self.build_api_url(u"views/{}/data{}".format(view_luid, additional_url_params)) - data = self.send_binary_get_request(url) - if filename_no_extension is not None: - if filename_no_extension.find('.csv') == -1: - filename_no_extension += '.csv' - save_file = open(filename_no_extension, 'wb') - save_file.write(data) - save_file.close() - self.end_log_block() - return - else: - raise InvalidOptionException(u'This method is for saving response to file. Must include filename_no_extension parameter') - - # You might be requesting something that doesn't exist - except RecoverableHTTPException as e: - self.log(u"Attempt to request data results in HTTP error {}, Tableau Code {}".format(e.http_code, e.tableau_error_code)) - self.end_log_block() - raise - except IOError: - self.log(u"Error: File '{}' cannot be opened to save to".format(filename_no_extension)) - self.end_log_block() - raise - - def query_view_data(self, wb_name_or_luid=None, view_name_or_luid=None, proj_name_or_luid=None, - view_filter_map=None): + def save_view_data_as_csv(self, wb_name_or_luid=None, view_name_or_luid=None, filename_no_extension=None, + proj_name_or_luid=None, view_filter_map=None): """ :type wb_name_or_luid: unicode :type view_name_or_luid: unicode :type proj_name_or_luid: unicode + :type filename_no_extension: unicode :type view_filter_map: dict - :rtype: csv + :rtype: """ self.start_log_block() + data = self.query_view_data(wb_name_or_luid=wb_name_or_luid, view_name_or_luid=view_name_or_luid, + proj_name_or_luid=proj_name_or_luid, view_filter_map=view_filter_map) - if self.is_luid(view_name_or_luid): - view_luid = view_name_or_luid + if filename_no_extension is not None: + if filename_no_extension.find('.csv') == -1: + filename_no_extension += '.csv' + try: + save_file = open(filename_no_extension, 'wb') + save_file.write(data) + save_file.close() + self.end_log_block() + return + except IOError: + self.log(u"Error: File '{}' cannot be opened to save to".format(filename_no_extension)) + self.end_log_block() + raise else: - if wb_name_or_luid is None: - raise InvalidOptionException(u'If looking up view by name, must include workbook') - view_luid = self.query_workbook_view_luid(wb_name_or_luid, view_name=view_name_or_luid, - proj_name_or_luid=proj_name_or_luid) - try: - if view_filter_map is not None: - final_filter_map = {} - for key in view_filter_map: - new_key = u"vf_{}".format(key) - final_filter_map[new_key] = view_filter_map[key] - - additional_url_params = u"?" + urllib.urlencode(final_filter_map) - else: - additional_url_params = u"" - url = self.build_api_url(u"views/{}/data{}".format(view_luid, additional_url_params)) - # Raw response should be UTF-8 encoded plain text CSV - data = self.send_binary_get_request(url) - # Convert to CSV object? - return data - - # You might be requesting something that doesn't exist - except RecoverableHTTPException as e: - self.log(u"Attempt to request data results in HTTP error {}, Tableau Code {}".format(e.http_code, e.tableau_error_code)) - self.end_log_block() - raise + raise InvalidOptionException( + u'This method is for saving response to file. Must include filename_no_extension parameter') def update_datasource_now(self, ds_name_or_luid, project_name_or_luid=False): """ diff --git a/tableau_rest_api/tableau_rest_api_connection_32.py b/tableau_rest_api/tableau_rest_api_connection_32.py index bdcca14..abae737 100644 --- a/tableau_rest_api/tableau_rest_api_connection_32.py +++ b/tableau_rest_api/tableau_rest_api_connection_32.py @@ -97,3 +97,36 @@ def delete_user_from_data_driven_alert(self, data_alert_luid, username_or_luid): self.send_delete_request(url) self.end_log_block() + # In 3.2, you can hide views from publishing + def publish_workbook(self, workbook_filename, workbook_name, project_obj, overwrite=False, async_publish=False, connection_username=None, + connection_password=None, save_credentials=True, show_tabs=True, check_published_ds=True, + oauth_flag=False, views_to_hide_list=None): + """ + :type workbook_filename: unicode + :type workbook_name: unicode + :type project_obj: Project20 or Project21 + :type overwrite: bool + :type connection_username: unicode + :type connection_password: unicode + :type save_credentials: bool + :type show_tabs: bool + :param check_published_ds: Set to False to improve publish speed if you KNOW there are no published data sources + :type check_published_ds: bool + :type oauth_flag: bool: + :type views_to_hide_list: list[unicode] + : + :rtype: unicode + """ + + project_luid = project_obj.luid + xml = self.publish_content(u'workbook', workbook_filename, workbook_name, project_luid, + {u"overwrite": overwrite, u"asJob": async_publish}, connection_username, + connection_password, save_credentials, show_tabs=show_tabs, + check_published_ds=check_published_ds, oauth_flag=oauth_flag, + views_to_hide_list=views_to_hide_list) + if async_publish is True: + job = xml.findall(u'.//t:job', self.ns_map) + return job[0].get(u'id') + else: + workbook = xml.findall(u'.//t:workbook', self.ns_map) + return workbook[0].get(u'id') diff --git a/tableau_rest_api/tableau_rest_api_connection_33.py b/tableau_rest_api/tableau_rest_api_connection_33.py new file mode 100644 index 0000000..9a8b138 --- /dev/null +++ b/tableau_rest_api/tableau_rest_api_connection_33.py @@ -0,0 +1,423 @@ +from tableau_rest_api_connection_32 import * +from url_filter import UrlFilter33 + +class TableauRestApiConnection32(TableauRestApiConnection31): + def __init__(self, server, username, password, site_content_url=u""): + """ + :type server: unicode + :type username: unicode + :type password: unicode + :type site_content_url: unicode + """ + TableauRestApiConnection31.__init__(self, server, username, password, site_content_url) + self.set_tableau_server_version(u"2019.1") + + def publish_workbook(self, workbook_filename, workbook_name, project_obj, overwrite=False, async_publish=False, connection_username=None, + connection_password=None, save_credentials=True, show_tabs=True, check_published_ds=True, + oauth_flag=False, views_to_hide_list=None, generate_thumbnails_as_username_or_luid=None): + """ + :type workbook_filename: unicode + :type workbook_name: unicode + :type project_obj: Project20 or Project21 + :type overwrite: bool + :type connection_username: unicode + :type connection_password: unicode + :type save_credentials: bool + :type show_tabs: bool + :param check_published_ds: Set to False to improve publish speed if you KNOW there are no published data sources + :type check_published_ds: bool + :type oauth_flag: bool: + :type generate_thumbnails_as_username_or_luid: unicode + :rtype: unicode + """ + + project_luid = project_obj.luid + xml = self.publish_content(u'workbook', workbook_filename, workbook_name, project_luid, + {u"overwrite": overwrite, u"asJob": async_publish}, connection_username, + connection_password, save_credentials, show_tabs=show_tabs, + check_published_ds=check_published_ds, oauth_flag=oauth_flag, + views_to_hide_list=views_to_hide_list, + generate_thumbnails_as_username_or_luid=generate_thumbnails_as_username_or_luid) + if async_publish is True: + job = xml.findall(u'.//t:job', self.ns_map) + return job[0].get(u'id') + else: + workbook = xml.findall(u'.//t:workbook', self.ns_map) + return workbook[0].get(u'id') + + + # Flow Methods Start + + def query_flow_luid(self, flow_name, project_name_or_luid=None): + """ + :type flow_name: unicode + :type project_name_or_luid: unicode + :rtype: unicode + """ + self.start_log_block() + + flow_name_filter = UrlFilter33.create_name_filter(flow_name) + + flows = self.query_flows_for_a_site(flow_name_filter=flow_name_filter, + project_name_or_luid=project_name_or_luid) + # There should only be one flow here if any found + if len(flows) == 1: + self.end_log_block() + return flows[0].get(u"id") + else: + self.end_log_block() + raise NoMatchFoundException(u"No {} found with name {}".format(flows, flow_name)) + + def query_flows_for_a_site(self, project_name_or_luid=None, all_fields=True, updated_at_filter=None, + created_at_filter=None, flow_name_filter=None, owner_name_filter=None, sorts=None, + fields=None): + """ + :type project_name_or_luid: unicode + :type all_fields: bool + :type updated_at_filter: UrlFilter + :type created_at_filter: UrlFilter + :type flow_name_filter: UrlFilter + :type owner_name_filter: UrlFilter + :type sorts: list[Sort] + :type fields: list[unicode] + :rtype: etree.Element + """ + self.start_log_block() + if fields is None: + if all_fields is True: + fields = [u'_all_'] + + # If create a ProjectName filter inherently if necessary + project_name_filter = None + if project_name_or_luid is not None: + if not self.is_luid(project_name_or_luid): + project_name = project_name_or_luid + else: + project = self.query_project_xml_object(project_name_or_luid) + project_name = project.get(u'name') + project_name_filter = UrlFilter33.create_project_name_equals_filter(project_name) + + filter_checks = {u'updatedAt': updated_at_filter, u'createdAt': created_at_filter, u'name': flow_name_filter, + u'ownerName': owner_name_filter, u'projectName': project_name_filter} + filters = self._check_filter_objects(filter_checks) + + flows = self.query_resource(u'flows', filters=filters, sorts=sorts, fields=fields) + + self.end_log_block() + return flows + + def query_flows_for_a_user(self, username_or_luid, is_owner_flag=False): + """ + :type username_or_luid: unicode + :type is_owner_flag: bool + :rtype: etree.Element + """ + self.start_log_block() + if self.is_luid(username_or_luid): + user_luid = username_or_luid + else: + user_luid = self.query_user_luid(username_or_luid) + additional_url_params = u"" + if is_owner_flag is True: + additional_url_params += u"?ownedBy=true" + + flows = self.query_resource(u'users/{}/flows{}'.format(user_luid, additional_url_params)) + self.end_log_block() + return flows + + def query_flow(self, flow_name_or_luid, project_name_or_luid=None): + """ + :type flow_name_or_luid: unicode + :type project_name_or_luid: unicode + :rtype: etree.Element + """ + self.start_log_block() + if self.is_luid(flow_name_or_luid): + flow_luid = flow_name_or_luid + else: + flow_luid = self.query_flow_luid(flow_name_or_luid, project_name_or_luid=project_name_or_luid) + + flow = self.query_resource(u'flows/{}'.format(flow_luid)) + + self.end_log_block() + return flow + + def query_flow_connections(self, flow_name_or_luid, project_name_or_luid=None): + """ + :type flow_name_or_luid: unicode + :type project_name_or_luid: unicode + :rtype: etree.Element + """ + self.start_log_block() + if self.is_luid(flow_name_or_luid): + flow_luid = flow_name_or_luid + else: + flow_luid = self.query_flow_luid(flow_name_or_luid, project_name_or_luid=project_name_or_luid) + + connections = self.query_resource(u'flows/{}/connections'.format(flow_luid)) + + self.end_log_block() + return connections + + + def get_flow_run_tasks(self): + """ + :rtype: etree.Element + """ + self.start_log_block() + tasks = self.query_resource(u'tasks/runFlow') + self.end_log_block() + return tasks + + def get_flow_run_task(self, task_luid): + """ + :type task_luid: unicode + :rtype: unicode + """ + self.start_log_block() + task = self.query_resource(u'tasks/runFlow/{}'.format(task_luid)) + self.end_log_block() + return task + + def run_flow_now(self, flow_name_or_luid, flow_output_step_ids=None): + """ + :type flow_name_or_luid: unicode + :type flow_output_step_ids: list[unicode] + :rtype unicode + """ + self.start_log_block() + if self.is_luid(flow_name_or_luid): + flow_luid = flow_name_or_luid + else: + flow_luid = self.query_flow_luid(flow_name_or_luid) + + additional_url_params = u"" + + # Implement once documentation is back up and going + if flow_output_step_ids is not None: + pass + + tsr = etree.Element(u'tsRequest') + url = self.build_api_url(u"flows/{}/run{}".format(flow_luid, additional_url_params)) + job_luid = self.send_add_request(url, tsr) + self.end_log_block() + return job_luid + + def run_flow_task(self, task_luid): + """ + :type task_luid: unicode + :rtype: etree.Element + """ + self.start_log_block() + url = self.build_api_url(u'tasks/runFlow/{}/runNow'.format(task_luid)) + response = self.send_post_request(url) + self.end_log_block() + return response + + + def update_flow(self, flow_name_or_luid, project_name_or_luid=None, owner_username_or_luid=None): + """ + :type flow_name_or_luid: unicode + :type project_name_or_luid: unicode + :type owner_username_or_luid: unicode + :return: + """ + self.start_log_block() + if project_name_or_luid is None and owner_username_or_luid is None: + raise InvalidOptionException(u'Must include at least one change, either project or owner or both') + + if self.is_luid(flow_name_or_luid): + flow_luid = flow_name_or_luid + else: + flow_luid = self.query_flow_luid(flow_name_or_luid) + + tsr = etree.Element(u'tsRequest') + f = etree.Element(u'flow') + if project_name_or_luid is not None: + if self.is_luid(project_name_or_luid): + proj_luid = project_name_or_luid + else: + proj_luid = self.query_project_luid(project_name_or_luid) + p = etree.Element(u'project') + p.set(u'id', proj_luid) + f.append(p) + + if owner_username_or_luid is not None: + if self.is_luid(owner_username_or_luid): + owner_luid = owner_username_or_luid + else: + owner_luid = self.query_user_luid(owner_username_or_luid) + + o = etree.Element(u'owner') + o.set(u'id', owner_luid) + f.append(o) + + tsr.append(f) + + url = self.build_api_url(u'flows/{}'.format(flow_luid)) + response = self.send_update_request(url, tsr) + + self.end_log_block() + return response + + def update_flow_connection(self, flow_luid, flow_connection_luid, server_address=None, port=None, connection_username=None, + connection_password=None, embed_password=False): + """ + :type flow_luid: unicode + :type flow_connection_luid: unicode + :type server_address: unicode + :type port: unicode + :type connection_username: unicode + :type connection_password: unicode + :type embed_password: unicode + :rtype: etree.Element + """ + self.start_log_block() + + tsr = etree.Element(u'tsRequest') + c = etree.Element(u'connection') + updates_count = 0 + if server_address is not None: + c.set(u'serverAddress', server_address) + updates_count += 1 + if port is not None: + c.set(u'port', port) + updates_count += 1 + if connection_username is not None: + c.set(u'userName', connection_username) + updates_count += 1 + if connection_password is not None: + c.set(u'password', connection_password) + updates_count += 1 + if embed_password is True: + c.set(u'embedPassword', u'true') + updates_count += 1 + + if updates_count == 0: + return InvalidOptionException(u'Must specify at least one element to update') + + tsr.append(c) + url = self.build_api_url(u'flows/{}/connections/{}'.format(flow_luid, flow_connection_luid)) + response = self.send_update_request(url, tsr) + + self.end_log_block() + return response + + def delete_flow(self, flow_name_or_luid): + """ + :type flow_name_or_luid: unicode + :return: + """ + self.start_log_block() + if self.is_luid(flow_name_or_luid): + flow_luid = flow_name_or_luid + else: + flow_luid = self.query_flow_luid(flow_name_or_luid) + url = self.build_api_url(u"flows/{}".format(flow_luid)) + self.send_delete_request(url) + self.end_log_block() + + def add_flow_task_to_schedule(self, flow_name_or_luid, schedule_name_or_luid): + """ + :type flow_name_or_luid: unicode + :type schedule_name_or_luid: unicode + :rtype: etree.Element + """ + self.start_log_block() + if self.is_luid(flow_name_or_luid): + flow_luid = flow_name_or_luid + else: + flow_luid = self.query_flow_luid(flow_name_or_luid) + + if self.is_luid(schedule_name_or_luid): + sched_luid = schedule_name_or_luid + else: + sched_luid = self.query_schedule_luid(schedule_name_or_luid) + + tsr = etree.Element(u'tsRequest') + t = etree.Element(u'task') + fr = etree.Element(u'flowRun') + f = etree.Element(u'flow') + f.set(u'id', flow_luid) + fr.append(f) + t.append(fr) + tsr.append(t) + + url = self.build_api_url(u"schedules/{}/flows".format(sched_luid)) + response = self.send_update_request(url, tsr) + + self.end_log_block() + return response + + # Do not include file extension, added automatically. Without filename, only returns the response + # Use no_obj_return for save without opening and processing + def download_flow(self, flow_name_or_luid, filename_no_extension, proj_name_or_luid=None): + """ + :type flow_name_or_luid: unicode + :type filename_no_extension: unicode + :type proj_name_or_luid: unicode + :return Filename of the save workbook + :rtype: unicode + """ + self.start_log_block() + if self.is_luid(flow_name_or_luid): + flow_luid = flow_name_or_luid + else: + flow_luid = self.query_workbook_luid(flow_name_or_luid, proj_name_or_luid) + try: + + url = self.build_api_url(u"flows/{}/content".format(flow_luid)) + flow = self.send_binary_get_request(url) + extension = None + if self._last_response_content_type.find(u'application/xml') != -1: + extension = u'.tfl' + elif self._last_response_content_type.find(u'application/octet-stream') != -1: + extension = u'.tflx' + if extension is None: + raise IOError(u'File extension could not be determined') + self.log( + u'Response type was {} so extension will be {}'.format(self._last_response_content_type, extension)) + except RecoverableHTTPException as e: + self.log(u"download_workbook resulted in HTTP error {}, Tableau Code {}".format(e.http_code, e.tableau_error_code)) + self.end_log_block() + raise + except: + self.end_log_block() + raise + try: + + save_filename = filename_no_extension + extension + + save_file = open(save_filename, 'wb') + save_file.write(flow) + save_file.close() + + except IOError: + self.log(u"Error: File '{}' cannot be opened to save to".format(filename_no_extension + extension)) + raise + + self.end_log_block() + return save_filename + + def publish_flow(self, flow_filename, flow_name, project_obj, overwrite=False, connection_username=None, + connection_password=None, save_credentials=True, description=None, oauth_flag=False): + """ + :type flow_filename: unicode + :type flow_name: unicode + :type project_obj: Project20 or Project21 or Project28 + :type overwrite: bool + :type connection_username: unicode + :type connection_password: unicode + :type save_credentials: bool + :type description: unicode + :type oauth_flag: bool + :rtype: unicode + """ + project_luid = project_obj.luid + xml = self.publish_content(u'flow', flow_filename, flow_name, project_luid, {u"overwrite": overwrite}, + connection_username, connection_password, save_credentials, oauth_flag=oauth_flag, + description=description) + flow = xml.findall(u'.//t:flow', self.ns_map) + return flow[0].get('id') + + # Flow Methods End \ No newline at end of file diff --git a/tableau_rest_api/url_filter.py b/tableau_rest_api/url_filter.py index 951d81a..1edcdeb 100644 --- a/tableau_rest_api/url_filter.py +++ b/tableau_rest_api/url_filter.py @@ -429,3 +429,15 @@ def create_subtitle_has_filter(subtitle): :rtype: UrlFilter """ return UrlFilter(u'subtitle', u'has', [subtitle, ]) + +class UrlFilter33(UrlFilter31): + def __init__(self, field, operator, values): + UrlFilter31.__init__(self, field, operator, values) + + @staticmethod + def create_project_name_equals_filter(project_name): + """ + :type subtitle: unicode + :rtype: UrlFilter + """ + return UrlFilter(u'projectName', u'eq', [project_name, ])