diff --git a/README.md b/README.md index ac00c41..410a696 100644 --- a/README.md +++ b/README.md @@ -166,15 +166,26 @@ If you want to log something in your script into this log, you can call where l is a string. You do not need to add a "\n", it will be added automatically. -The Logger class by default only logs Requests but not Responses. If you need to see the full responses, use the following method: +The Logger class, starting in tableau_tools 5.1, has multiple options to show different levels of response. -`Logger.enable_debug_level()`b +By default, the Logger will only show the HTTP requests with URI, along the chain of nested methods used to perform the actions. +`Logger.enable_request_logging()` -### 0.3 TableauBase class -Many classes within the tableau_tools package inherit from the TableauBase class. TableauBase implements the `enable_logging(Logger)` method, along with other a `.log()` method that calls to `Logger.log()`. It also has many static methods, mapping dicts, and helper classes related to Tableau in general. +will display the string version of the XML requests sent along with the HTTP requests. -It should never be necessary to use TableauBase by itself. +`Logger.enable_response_logging()` + +will display the string version of all XML responses in the logs. This is far more verbose, so is only suggested when you are encountering errors based on expectations of what should be in the response. + +`Logger.enable_debug_level()` + +makes the Logger indent the lines of the log, so that you can see the nesting of the actions that happen more easily. This is what the logs looked like in previous version of tableau_tools, but now it must be turned on if you want that mode. + +### 0.3 TableauRestXml class +There is a class called TableauRestXml which holds static methods and properties that are useful on any Tableau REST XML request or response. + +TableauServerRest and TableauRestApiConnection both inherit from this class so you can call any of the methods from one of those objects rather than calling it directly. ### 0.4 tableau_exceptions The tableau_exceptions file defines a variety of Exceptions that are specific to Tableau, particularly the REST API. They are not very complex, and most simply include a msg property that will clarify the problem if logged @@ -912,12 +923,18 @@ For Projects, since the standard `query_project()` method returns the Project ob Projects have additional commands that the other classes do not: -`Project.lock_permissions()` +`Project.lock_permissions() -> Project` -`Project.unlock_permission()` +`Project.unlock_permission() -> Project` `Project.are_permissions_locked()` +If you are locking or unlocking permissions, you should replace the project object you used with the response that comes back: + + proj = t.projects.query_project('My Project') + proj = proj.lock_permissions() # You want to updated object returned here to use from here on out + ... + You access the default permissions objects with the following, which reference the objects of the correct type that have already been built within the Project object: `Project.workbook_defaults` diff --git a/examples/create_site_sample.py b/examples/create_site_sample.py index c42e521..570ab46 100644 --- a/examples/create_site_sample.py +++ b/examples/create_site_sample.py @@ -46,23 +46,23 @@ def tableau_rest_api_connection_version(): # Add in any default permissions you'd like at this point - admin_perms = default_proj.create_project_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.get_permissions_obj(group_name_or_luid='Administrators', role='Project Leader') - default_proj.set_permissions_by_permissions_obj_list([admin_perms, ]) + default_proj.set_permissions([admin_perms, ]) - admin_perms = default_proj.create_workbook_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.workbook_defaults.get_permissions_obj(group_name_or_luid='Administrators', role='Editor') admin_perms.set_capability(capability_name='Download Full Data', mode='Deny') - default_proj.workbook_defaults.set_permissions_by_permissions_obj_list([admin_perms, ]) + default_proj.workbook_defaults.set_permissions([admin_perms, ]) - admin_perms = default_proj.create_datasource_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.datasource_defaults.get_permissions_obj(group_name_or_luid='Administrators', role='Editor') - default_proj.datasource_defaults.set_permissions_by_permissions_obj_list([admin_perms, ]) + default_proj.datasource_defaults.set_permissions([admin_perms, ]) # Change one of these - new_perms = default_proj.create_project_permissions_object_for_group(group_name_or_luid='Administrators', + new_perms = default_proj.get_permissions_obj(group_name_or_luid='Administrators', role='Publisher') - default_proj.set_permissions_by_permissions_obj_list([new_perms, ]) + default_proj.set_permissions([new_perms, ]) # Create Additional Projects projects_to_create = ['Sandbox', 'Data Source Definitions', 'UAT', 'Finance', 'Real Financials'] @@ -117,21 +117,21 @@ def tableau_server_rest_version(): default_proj.clear_all_permissions() # This clears all, including the defaults # Add in any default permissions you'd like at this point - admin_perms = default_proj.create_project_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.get_permissions_obj(group_name_or_luid='Administrators', role='Project Leader') default_proj.set_permissions_by_permissions_obj_list([admin_perms, ]) - admin_perms = default_proj.create_workbook_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.workbook_defaults.get_permissions_obj(group_name_or_luid='Administrators', role='Editor') admin_perms.set_capability(capability_name='Download Full Data', mode='Deny') default_proj.workbook_defaults.set_permissions_by_permissions_obj_list([admin_perms, ]) - admin_perms = default_proj.create_datasource_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.datasource_defaults.get_permissions_obj(group_name_or_luid='Administrators', role='Editor') default_proj.datasource_defaults.set_permissions_by_permissions_obj_list([admin_perms, ]) # Change one of these - new_perms = default_proj.create_project_permissions_object_for_group(group_name_or_luid='Administrators', + new_perms = default_proj.get_permissions_obj(group_name_or_luid='Administrators', role='Publisher') default_proj.set_permissions_by_permissions_obj_list([new_perms, ]) diff --git a/examples/test_suite_all_querying_tableau_server_rest.py b/examples/test_suite_all_querying_tableau_server_rest.py index 409d21b..36ad4fe 100644 --- a/examples/test_suite_all_querying_tableau_server_rest.py +++ b/examples/test_suite_all_querying_tableau_server_rest.py @@ -32,7 +32,6 @@ def run_tests(server_url: str, username: str, password: str, site_content_url: s site_content_url=site_content_url) t.signin() t.enable_logging(rest_request_log_obj) - # Server info and methods server_info = t.query_server_info() server_version = t.query_server_version() diff --git a/examples/user_sync_sample.py b/examples/user_sync_sample.py index cd61c43..8733f07 100644 --- a/examples/user_sync_sample.py +++ b/examples/user_sync_sample.py @@ -66,8 +66,8 @@ for user in users_dict: proj_obj = t.projects.create_project("My Saved Reports - {}".format(user)) user_luid = users_dict[user] - perms_obj = proj_obj.create_project_permissions_object_for_user(username_or_luid=user_luid, role='Publisher') - proj_obj.set_permissions_by_permissions_obj_list([perms_obj, ]) + perms_obj = proj_obj.get_permissions_obj(username_or_luid=user_luid, role='Publisher') + proj_obj.set_permissions([perms_obj, ]) # Reset back to beginning to reuse query diff --git a/logger.py b/logger.py index 323ea70..06a791e 100644 --- a/logger.py +++ b/logger.py @@ -3,6 +3,11 @@ import xml.etree.ElementTree as ET from typing import Union, Any, Optional, List, Dict, Tuple +# Logger has several modes +# Default just shows REST URL requests +# If you "enable_request_xml_logging", then it will show the full XML of the request +# If you "enable_debugging mode", then the log will indent to show which calls are wrapped within another +# "enabled_response_logging" will log the response class Logger(object): def __init__(self, filename): self._log_level = 'standard' @@ -13,9 +18,17 @@ def __init__(self, filename): except IOError: print("Error: File '{}' cannot be opened to write for logging".format(filename)) raise + self._log_modes = {'debug': False, 'response': False, 'request': False} def enable_debug_level(self): self._log_level = 'debug' + self._log_modes['debug'] = True + + def enable_request_logging(self): + self._log_modes['request'] = True + + def enable_response_logging(self): + self._log_modes['response'] = True def log(self, l: str): cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) @@ -29,7 +42,7 @@ def log(self, l: str): self.__log_handle.write(log_line) def log_debug(self, l: str): - if self._log_level == 'debug': + if self._log_modes['debug'] is True: self.log(l) def start_log_block(self): @@ -38,10 +51,17 @@ def start_log_block(self): class_path = c.split('.') short_class = class_path[len(class_path)-1] short_class = short_class[:-2] - cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()).encode('utf-8') + cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + + + # Only move the log depth in debug mode + if self._log_modes['debug'] is True: + self.log_depth += 2 + log_line = '{}vv-- {} : {} {} started --------vv\n'.format(" " * self.log_depth, str(cur_time), short_class, + caller_function_name) + else: + log_line = '{} : {} {} started --------vv\n'.format(str(cur_time), short_class, caller_function_name) - log_line = '{}vv-- {} : {} {} started --------vv\n'.format(" "*self.log_depth, cur_time, short_class, caller_function_name) - self.log_depth += 2 self.__log_handle.write(log_line.encode('utf-8')) def end_log_block(self): @@ -50,23 +70,33 @@ def end_log_block(self): class_path = c.split('.') short_class = class_path[len(class_path)-1] short_class = short_class[:-2] - cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()).encode('utf-8') - self.log_depth -= 2 - log_line = '{}^^-- {} : {} {} ended --------^^\n'.format(" "*self.log_depth, cur_time, short_class, caller_function_name) + cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + if self._log_modes['debug'] is True: + self.log_depth -= 2 + log_line = '{}^^-- {} : {} {} ended --------^^\n'.format(" "*self.log_depth, str(cur_time), short_class, caller_function_name) + else: + log_line = '{} : {} {} ended --------^^\n'.format(str(cur_time), short_class, caller_function_name) self.__log_handle.write(log_line.encode('utf-8')) def log_uri(self, uri: str, verb: str): - self.log('Sending {} request via: \n{}'.format(verb, uri)) + self.log('[{}] {}'.format(verb.upper(), uri)) - def log_xml_request(self, xml: ET.Element, verb: str): - if isinstance(xml, str): - self.log('Sending {} request with XML: \n{}'.format(verb, xml)) + def log_xml_request(self, xml: Union[ET.Element, str], verb: str, uri: str): + if self._log_modes['request'] is True: + if isinstance(xml, str): + self.log('[{}] \n{}'.format(verb.upper(), xml)) + else: + self.log('[{}] \n{}'.format(verb.upper(), ET.tostring(xml))) else: - self.log('Sending {} request with XML: \n{}'.format(verb, ET.tostring(xml))) + self.log('[{}] {}'.format(verb.upper(), uri)) - def log_xml_response(self, xml: ET.Element): - if isinstance(xml, str): - self.log('Received response with XML: \n{}'.format(xml)) - else: - self.log('Received response with XML: \n{}'.format(ET.tostring(xml))) + def log_xml_response(self, xml: Union[str, ET.Element]): + if self._log_modes['response'] is True: + if isinstance(xml, str): + self.log('[XML Response] \n{}'.format(xml)) + else: + self.log('[XML Response] \n{}'.format(ET.tostring(xml))) + + def log_error(self, error_text: str): + self.log('[ERROR] {}'.format(error_text)) \ No newline at end of file diff --git a/logging_methods.py b/logging_methods.py index 5c2bb4e..6fa51c1 100644 --- a/logging_methods.py +++ b/logging_methods.py @@ -1,7 +1,7 @@ -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Union import xml.etree.ElementTree as ET -from tableau_tools.logger import Logger +from .logger import Logger class LoggingMethods: # Logging Methods @@ -26,8 +26,16 @@ def end_log_block(self): def log_uri(self, uri: str, verb: str): if self.logger is not None: - self.logger.log_uri(verb, uri) + self.logger.log_uri(uri=uri, verb=verb) - def log_xml_request(self, xml: ET.Element, verb: str): + def log_xml_request(self, xml: ET.Element, verb: str, uri: str): if self.logger is not None: - self.logger.log_xml_request(verb, xml) \ No newline at end of file + self.logger.log_xml_request(xml=xml, verb=verb, uri=uri) + + def log_xml_response(self, xml: Union[str, ET.Element]): + if self.logger is not None: + self.logger.log_xml_response(xml=xml) + + def log_error(self, error_text: str): + if self.logger is not None: + self.logger.log_error(error_text=error_text) \ No newline at end of file diff --git a/setup.py b/setup.py index a0bbebc..9b6ca90 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='tableau_tools', python_requires='>=3.6', - version='5.0.7', + version='5.1.0', packages=['tableau_tools', 'tableau_tools.tableau_rest_api', 'tableau_tools.tableau_documents', 'tableau_tools.examples', 'tableau_tools.tableau_rest_api.methods'], url='https://github.com/bryantbhowell/tableau_tools', diff --git a/tableau_documents/table_relations.py b/tableau_documents/table_relations.py index c4b998a..c1ea942 100644 --- a/tableau_documents/table_relations.py +++ b/tableau_documents/table_relations.py @@ -3,8 +3,10 @@ import random from xml.sax.saxutils import quoteattr, unescape import copy +import datetime -from tableau_tools.tableau_exceptions import * +from ..tableau_exceptions import * +from ..tableau_rest_xml import TableauRestXml # This represents the classic Tableau data connection window relations # Allows for changes in JOINs, Stored Proc values, and Custom SQL @@ -17,8 +19,8 @@ def __init__(self, relation_xml_obj: ET.Element): self.main_table: ET.Element self.table_relations: List[ET.Element] self.join_relations = [] - self.ns_map = {"user": 'http://www.tableausoftware.com/xml/user', 't': 'http://tableau.com/api'} - ET.register_namespace('t', self.ns_map['t']) + #self.ns_map = {"user": 'http://www.tableausoftware.com/xml/user', 't': 'http://tableau.com/api'} + #ET.register_namespace('t', self.ns_map['t']) self._read_existing_relations() def _read_existing_relations(self): @@ -29,7 +31,7 @@ def _read_existing_relations(self): self.table_relations = [self.relation_xml_obj, ] else: - table_relations = self.relation_xml_obj.findall('.//relation', self.ns_map) + table_relations = self.relation_xml_obj.findall('.//relation', TableauRestXml.ns_map) final_table_relations = [] # ElementTree doesn't implement the != operator, so have to find all then iterate through to exclude # the JOINs to only get the tables, stored-procs and Custom SQLs @@ -118,7 +120,7 @@ def set_stored_proc_parameter_value_by_name(self, parameter_name: str, parameter if self._stored_proc_parameters_xml is None: self._stored_proc_parameters_xml = ET.Element('actual-parameters') # Find parameter with that name (if exists) - param = self._stored_proc_parameters_xml.find('.//column[@name="{}"]'.format(parameter_name), self.ns_map) + param = self._stored_proc_parameters_xml.find('.//column[@name="{}"]'.format(parameter_name), TableauRestXml.ns_map) if param is None: # create_stored... already converts to correct quoting diff --git a/tableau_documents/tableau_datasource.py b/tableau_documents/tableau_datasource.py index 2de475f..bf71134 100644 --- a/tableau_documents/tableau_datasource.py +++ b/tableau_documents/tableau_datasource.py @@ -98,23 +98,11 @@ def __init__(self, datasource_xml: Optional[ET.Element] = None, logger_obj: Opti "Hyper": 'hyper', } - # Create from new or from existing object + # Create from new. Only doing 10.5 style for now. if datasource_xml is None: - if ds_version is None: - raise InvalidOptionException('When creating Datasource from scratch, must declare a ds_version') - self._ds_version = ds_version - version_split = self._ds_version.split('.') - if version_split[0] == '10': - if int(version_split[1]) < 5: - self.ds_version_type = '10' - else: - self.ds_version_type = '10.5' - elif version_split[0] == '9': - self.ds_version_type = '9' - else: - raise InvalidOptionException('Datasource being created with wrong version type') + self.ds_version_type = '10.5' self.xml = self.create_new_datasource_xml(ds_version) - + # Read existing data source else: self.xml = datasource_xml if self.xml.get("caption"): @@ -173,15 +161,6 @@ def __init__(self, datasource_xml: Optional[ET.Element] = None, logger_obj: Opti else: self.log('Found a Parameters datasource') - - #self.repository_location = None - - #if self.xml.find(u'repository-location') is not None: - # if len(self.xml.find(u'repository-location')) == 0: - # self._published = True - # repository_location_xml = self.xml.find(u'repository-location') - # self.repository_location = repository_location_xml - # Grab the extract filename if there is an extract section if self.xml.find('extract') is not None: e = self.xml.find('extract') @@ -282,16 +261,21 @@ def published_ds_content_url(self, new_content_url: str): # It seems some databases like Oracle and Teradata need this as well to swap a database def update_tables_with_new_database_or_schema(self, original_db_or_schema: str, new_db_or_schema: str): - for relation in self.table_relations: + for relation in self.tables.table_relations: if relation.get('type') == "table": relation.set('table', relation.get('table').replace("[{}]".format(original_db_or_schema), "[{}]".format(new_db_or_schema))) + # Start of data sources creation methods (from scratch) + # Need considerable review and testing + @staticmethod def create_new_datasource_xml(version: str) -> ET.Element: # nsmap = {u"user": u'http://www.tableausoftware.com/xml/user'} + # The most basic component is just a datasource element with a version. ds_xml = ET.Element("datasource") ds_xml.set('version', version) + # Unclear if this is even necessary. May only appear in TWBXs ds_xml.set('inline', "true") return ds_xml @@ -300,9 +284,7 @@ def create_new_connection_xml(ds_version: str, ds_type: str, server: str, db_nam authentication: Optional[str] = None, initial_sql: Optional[str] = None) -> ET.Element: connection = ET.Element("connection") - if ds_version == '9': - c = connection - elif ds_version in ['10', '10.5']: + if ds_version in ['10', '10.5']: nc = ET.Element('named-connection') nc.set('caption', 'Connection') # Connection has a random number of 20 digits appended @@ -427,23 +409,6 @@ def translate_columns(self, translation_dict: Dict): self.columns.translate_captions(translation_dict=translation_dict) self.end_log_block() - def add_extract(self, new_extract_filename: str): - self.log('add_extract called, checking if extract exists already') - # Test to see if extract exists already - e = self.xml.find('extract') - if e is not None: - self.log("Existing extract found, no need to add") - raise AlreadyExistsException("An extract already exists, can't add a new one", "") - else: - self.log('Extract doesnt exist') - new_extract_filename_start = new_extract_filename.split(".")[0] - if self.ds_version_type == '10.5': - final_extract_filename = "{}.hyper".format(new_extract_filename_start) - else: - final_extract_filename = "{}.tde".format(new_extract_filename_start) - self._extract_filename = final_extract_filename - self.log('Adding extract to the data source') - def generate_extract_section(self) -> Union[ET.Element, bool]: # Short circuit if no extract had been set if self._extract_filename is None: diff --git a/tableau_documents/tableau_file.py b/tableau_documents/tableau_file.py index 9f4b712..7e96bb1 100644 --- a/tableau_documents/tableau_file.py +++ b/tableau_documents/tableau_file.py @@ -322,10 +322,10 @@ def __init__(self, filename: str, logger_obj: Optional[Logger] = None): self.file_replacement_map: Dict = {} # Packaged up nicely but always run in constructor - self._open_file_and_intialize(filename=filename) + self._open_file_and_initialize(filename=filename) @abstractmethod - def _open_file_and_intialize(self, filename): + def _open_file_and_initialize(self, filename): pass @property @@ -353,7 +353,7 @@ def save_new_file(self, new_filename_no_extension: str) -> str: class TDSX(DatasourceFileInterface, TableauPackagedFile): - def _open_file_and_intialize(self, filename): + def _open_file_and_initialize(self, filename): try: file_obj = open(filename, 'rb') self.log('File type is {}'.format(self.file_type)) @@ -459,7 +459,7 @@ class TWBX(DatasourceFileInterface, TableauPackagedFile): #self._open_file_and_intialize(filename=filename) - def _open_file_and_intialize(self, filename): + def _open_file_and_initialize(self, filename): try: file_obj = open(filename, 'rb') self.log('File type is {}'.format(self.file_type)) @@ -578,6 +578,8 @@ def save_new_file(self, new_filename_no_extension: str): return save_filename +# TFL files are actually JSON rather than XML. I don't think there are actually any XML calls except +# possibly when creating the TableauDocument file. class TFL(TableauXmlFile): @property diff --git a/tableau_exceptions.py b/tableau_exceptions.py index 0c14459..3f5bece 100644 --- a/tableau_exceptions.py +++ b/tableau_exceptions.py @@ -1,41 +1,49 @@ # -*- coding: utf-8 -*- +class TableauException(Exception): + def __str__(self): + return "{} Exception: {}".format(self.__class__.__name__,self.msg) -class NoMatchFoundException(Exception): +class NoMatchFoundException(TableauException): def __init__(self, msg): self.msg = msg -class AlreadyExistsException(Exception): +class AlreadyExistsException(TableauException): def __init__(self, msg, existing_luid): self.msg = msg self.existing_luid = existing_luid # Raised when an action is attempted that requires being signed into that site -class NotSignedInException(Exception): +class NotSignedInException(TableauException): def __init__(self, msg): self.msg = msg # Raise when something an option is passed that is not valid in the REST API (site_role, permissions name, etc) -class InvalidOptionException(Exception): +class InvalidOptionException(TableauException): def __init__(self, msg): self.msg = msg -class RecoverableHTTPException(Exception): +class RecoverableHTTPException(TableauException): def __init__(self, http_code, tableau_error_code, luid): self.http_code = http_code self.tableau_error_code = tableau_error_code self.luid = luid +class PossibleInvalidPublishException(TableauException): + def __init__(self, http_code, tableau_error_code, msg): + self.http_code = http_code + self.tableau_error_code = tableau_error_code + self.msg = msg -class MultipleMatchesFoundException(Exception): +class MultipleMatchesFoundException(TableauException): def __init__(self, count): self.msg = 'Found {} matches for the request, something has the same name'.format(str(count)) -class NoResultsException(Exception): +class NoResultsException(TableauException): def __init__(self, msg): self.msg = msg diff --git a/tableau_rest_api/methods/_lookups.py b/tableau_rest_api/methods/_lookups.py index 22ec6fc..5361756 100644 --- a/tableau_rest_api/methods/_lookups.py +++ b/tableau_rest_api/methods/_lookups.py @@ -1,5 +1,7 @@ #from tableau_rest_api.methods.rest_api_base import * from typing import Union, Optional +from ...tableau_rest_xml import TableauRestXml +from ...tableau_exceptions import * # These find LUIDs from real names or other aspects. They get added to the RestApiBase class because methods on # almost any different object might need a LUID from any of the others class LookupMethods(): @@ -35,7 +37,7 @@ def query_datasource_luid(self, datasource_name: str, project_name_or_luid: Opti # Search for ContentUrl which should be unique, return if content_url is not None: datasources_with_content_url = datasources_with_name.findall( - './/t:datasource[@contentUrl="{}"]'.format(content_url), self.ns_map) + './/t:datasource[@contentUrl="{}"]'.format(content_url), TableauRestXml.ns_map) self.end_log_block() if len(datasources_with_name) == 1: return datasources_with_content_url[0].get("id") @@ -58,11 +60,11 @@ def query_datasource_luid(self, datasource_name: str, project_name_or_luid: Opti else: if self.is_luid(project_name_or_luid): ds_in_proj = datasources_with_name.findall('.//t:project[@id="{}"]/..'.format(project_name_or_luid), - self.ns_map) + TableauRestXml.ns_map) else: ds_in_proj = datasources_with_name.findall( './/t:project[@name="{}"]/..'.format(project_name_or_luid), - self.ns_map) + TableauRestXml.ns_map) if len(ds_in_proj) == 1: self.end_log_block() return ds_in_proj[0].get("id") @@ -123,9 +125,9 @@ def query_workbook_view_luid(self, wb_name_or_luid: str, view_name: Optional[str wb_luid = self.query_workbook_luid(wb_name_or_luid, proj_name_or_luid) vws = self.query_resource("workbooks/{}/views?includeUsageStatistics={}".format(wb_luid, str(usage).lower())) if view_content_url is not None: - views_with_name = vws.findall('.//t:view[@contentUrl="{}"]'.format(view_content_url), self.ns_map) + views_with_name = vws.findall('.//t:view[@contentUrl="{}"]'.format(view_content_url), TableauRestXml.ns_map) else: - views_with_name = vws.findall('.//t:view[@name="{}"]'.format(view_name), self.ns_map) + views_with_name = vws.findall('.//t:view[@name="{}"]'.format(view_name), TableauRestXml.ns_map) if len(views_with_name) == 0: self.end_log_block() raise NoMatchFoundException('No view found with name {} or content_url {} in workbook {}'.format(view_name, view_content_url, wb_name_or_luid)) @@ -153,11 +155,11 @@ def query_workbook_luid(self, wb_name: str, proj_name_or_luid: Optional[str] = N elif len(workbooks_with_name) > 1 and proj_name_or_luid is not None: if self.is_luid(proj_name_or_luid): wb_in_proj = workbooks_with_name.findall('.//t:project[@id="{}"]/..'.format(proj_name_or_luid), - self.ns_map) + TableauRestXml.ns_map) else: wb_in_proj = workbooks_with_name.findall( './/t:project[@name="{}"]/..'.format(proj_name_or_luid), - self.ns_map) + TableauRestXml.ns_map) if len(wb_in_proj) == 0: self.end_log_block() raise NoMatchFoundException('No workbook found with name {} in project {}'.format(wb_name, proj_name_or_luid)) @@ -174,7 +176,7 @@ def query_database_luid(self, database_name: str) -> str: if self.is_luid(database_name): return database_name databases = self.query_resource("databases") - databases_with_name = databases.findall('.//t:database[@name="{}"]'.format(database_name), self.ns_map) + databases_with_name = databases.findall('.//t:database[@name="{}"]'.format(database_name), TableauRestXml.ns_map) if len(databases_with_name) == 0: self.end_log_block() raise NoMatchFoundException( @@ -194,7 +196,7 @@ def query_table_luid(self, table_name: str) -> str: if self.is_luid(table_name): return table_name tables = self.query_resource("tables") - tables_with_name = tables.findall('.//t:table[@name="{}"]'.format(table_name), self.ns_map) + tables_with_name = tables.findall('.//t:table[@name="{}"]'.format(table_name), TableauRestXml.ns_map) if len(tables_with_name) == 0: self.end_log_block() raise NoMatchFoundException( diff --git a/tableau_rest_api/methods/datasource.py b/tableau_rest_api/methods/datasource.py index fe8d903..2b25f35 100644 --- a/tableau_rest_api/methods/datasource.py +++ b/tableau_rest_api/methods/datasource.py @@ -1,5 +1,6 @@ from .rest_api_base import * from ..published_content import Datasource, Datasource28 +from ...tableau_rest_xml import TableauRestXml class DatasourceMethods(): def __init__(self, rest_api_base: TableauRestApiBase): @@ -34,7 +35,7 @@ def query_datasources(self, project_name_or_luid: Optional[str] = None, all_fiel # If there is a project filter if project_name_or_luid is not None: project_luid = self.query_project_luid(project_name_or_luid) - dses_in_project = datasources.findall('.//t:project[@id="{}"]/..'.format(project_luid), self.ns_map) + dses_in_project = datasources.findall('.//t:project[@id="{}"]/..'.format(project_luid), TableauRestXml.ns_map) dses = ET.Element(self.ns_prefix + 'datasources') for ds in dses_in_project: dses.append(ds) @@ -181,7 +182,7 @@ def publish_datasource(self, ds_filename: str, ds_name: str, project_obj: Projec project_luid = project_obj.luid xml = self._publish_content('datasource', ds_filename, ds_name, project_luid, {"overwrite": overwrite}, connection_username, connection_password, save_credentials, oauth_flag=oauth_flag) - datasource = xml.findall('.//t:datasource', self.ns_map) + datasource = xml.findall('.//t:datasource', TableauRestXml.ns_map) return datasource[0].get('id') # diff --git a/tableau_rest_api/methods/project.py b/tableau_rest_api/methods/project.py index 8941630..79aeabe 100644 --- a/tableau_rest_api/methods/project.py +++ b/tableau_rest_api/methods/project.py @@ -107,8 +107,9 @@ def update_project(self, name_or_luid: str, new_project_name: Optional[str] = No url += '?publishSamples=true' response = self.send_update_request(url, tsr) + proj_xml_obj = response.findall(".//t:project", TableauRestXml.ns_map)[0] self.end_log_block() - return self.get_published_project_object(project_luid, response) + return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=proj_xml_obj) def delete_projects(self, project_name_or_luid_s: Union[List[str], str]): self.start_log_block() @@ -251,8 +252,9 @@ def update_project(self, name_or_luid: str, parent_project_name_or_luid: Optiona url += '?publishSamples=true' response = self.send_update_request(url, tsr) + proj_xml_obj = response.findall(".//t:project", TableauRestXml.ns_map)[0] self.end_log_block() - return self.get_published_project_object(project_luid, response) + return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=proj_xml_obj) def query_project(self, project_name_or_luid: str) -> Project28: @@ -378,8 +380,9 @@ def update_project(self, name_or_luid: str, parent_project_name_or_luid: Optiona url += '?publishSamples=true' response = self.send_update_request(url, tsr) + proj_xml_obj = response.findall(".//t:project", TableauRestXml.ns_map)[0] self.end_log_block() - return self.get_published_project_object(project_luid, response) + return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=proj_xml_obj) class ProjectMethods34(ProjectMethods33): def __init__(self, rest_api_base: TableauRestApiBase34): diff --git a/tableau_rest_api/methods/rest_api_base.py b/tableau_rest_api/methods/rest_api_base.py index 1be9a22..72e3355 100644 --- a/tableau_rest_api/methods/rest_api_base.py +++ b/tableau_rest_api/methods/rest_api_base.py @@ -18,9 +18,9 @@ from tableau_tools.tableau_rest_api.published_content import Project, Project28, Project33, Workbook, Datasource, Flow33 from tableau_tools.tableau_rest_api.url_filter import * from tableau_tools.tableau_rest_api.sort import * +from ...tableau_rest_xml import TableauRestXml - -class TableauRestApiBase(LookupMethods, LoggingMethods): +class TableauRestApiBase(LookupMethods, LoggingMethods, TableauRestXml): # Defines a class that represents a RESTful connection to Tableau Server. Use full URL (http:// or https://) def __init__(self, server: str, username: str, password: str, site_content_url: Optional[str] = ""): if server.find('http') == -1: @@ -82,83 +82,7 @@ def __init__(self, server: str, username: str, password: str, site_content_url: u'SiteAdministratorCreator' ) - # URI is different form actual URL you need to load a particular view in iframe - @staticmethod - def convert_view_content_url_to_embed_url(content_url: str) -> str: - split_url = content_url.split('/') - return 'views/{}/{}'.format(split_url[0], split_url[2]) - - # Generic method for XML lists for the "query" actions to name -> id dict - @staticmethod - def convert_xml_list_to_name_id_dict(xml_obj: ET.Element) -> Dict: - d = {} - for element in xml_obj: - e_id = element.get("id") - # If list is collection, have to run one deeper - if e_id is None: - for list_element in element: - e_id = list_element.get("id") - name = list_element.get("name") - d[name] = e_id - else: - name = element.get("name") - d[name] = e_id - return d - - # Repeat of above method with shorter name - @staticmethod - def xml_list_to_dict(xml_obj: ET.Element) -> Dict: - d = {} - for element in xml_obj: - e_id = element.get("id") - # If list is collection, have to run one deeper - if e_id is None: - for list_element in element: - e_id = list_element.get("id") - name = list_element.get("name") - d[name] = e_id - else: - name = element.get("name") - d[name] = e_id - return d - - @staticmethod - def luid_name_dict_from_xml(xml_obj: ET.Element) -> Dict: - d = {} - for element in xml_obj: - e_id = element.get("id") - # If list is collection, have to run one deeper - if e_id is None: - for list_element in element: - e_id = list_element.get("id") - name = list_element.get("name") - d[e_id] = name - else: - name = element.get("name") - d[e_id] = name - return d - - @staticmethod - def luid_content_url_dict_from_xml(xml_obj: ET.Element) -> Dict: - d = {} - for element in xml_obj: - e_id = element.get("id") - # If list is collection, have to run one deeper - if e_id is None: - for list_element in element: - e_id = list_element.get("id") - name = list_element.get("contentUrl") - d[e_id] = name - else: - name = element.get("contentUrl") - d[e_id] = name - return d - # This corrects for the first element in any response by the plural collection tag, which leads to differences - # with the XPath search currently - @staticmethod - def make_xml_list_iterable(xml_obj: ET.Element) -> List[ET.Element]: - pass def set_tableau_server_version(self, tableau_server_version: str) -> str: if str(tableau_server_version)in ["10.3", "10.4", "10.5", '2018.1', '2018.2', '2018.3', '2019.1', @@ -212,25 +136,7 @@ def read_file_in_chunks(file_object, chunk_size=(1024 * 1024 * 10)): break yield data - # You must generate a boundary string that is used both in the headers and the generated request that you post. - # This builds a simple 30 hex digit string - @staticmethod - def generate_boundary_string() -> str: - random_digits = [random.SystemRandom().choice('0123456789abcdef') for n in range(30)] - s = "".join(random_digits) - return s - # 32 hex characters with 4 dashes - @staticmethod - def is_luid(val: str) -> bool: - luid_pattern = r"[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*" - if len(val) == 36: - if re.match(luid_pattern, val) is not None: - return True - else: - return False - else: - return False @property def token(self) -> str: @@ -347,12 +253,6 @@ def __build_connection_update_xml(new_server_address: Optional[str] = None, tsr.append(c) return tsr - # - # Factory methods for PublishedContent and Permissions objects - # - - - # # Sign-in and Sign-out # @@ -387,20 +287,24 @@ def signin(self, user_luid_to_impersonate: Optional[str] = None): self._request_obj.xml_request = tsr self._request_obj.http_verb = 'post' self.log('Login payload is\n {}'.format(ET.tostring(tsr))) + try: + self._request_obj.request_from_api(0) + # self.log(api.get_raw_response()) + xml = self._request_obj.get_response() - self._request_obj.request_from_api(0) - # self.log(api.get_raw_response()) - xml = self._request_obj.get_response() - - credentials_element = xml.findall('.//t:credentials', self.ns_map) - self.token = credentials_element[0].get("token") - self.log("Token is " + self.token) - self._request_obj.token = self.token - self.site_luid = credentials_element[0].findall(".//t:site", self.ns_map)[0].get("id") - self.user_luid = credentials_element[0].findall(".//t:user", self.ns_map)[0].get("id") - self.log("Site ID is " + self.site_luid) - self._request_obj.url = None - self._request_obj.xml_request = None + credentials_element = xml.findall('.//t:credentials', self.ns_map) + self.token = credentials_element[0].get("token") + self.log("Token is " + self.token) + self._request_obj.token = self.token + self.site_luid = credentials_element[0].findall(".//t:site", self.ns_map)[0].get("id") + self.user_luid = credentials_element[0].findall(".//t:user", self.ns_map)[0].get("id") + self.log("Site ID is " + self.site_luid) + self._request_obj.url = None + self._request_obj.xml_request = None + except RecoverableHTTPException as e: + if e.tableau_error_code == '401001': + self.end_log_block() + raise NotSignedInException('Sign-in failed due to wrong credentials') self.end_log_block() def swap_token(self, site_luid: str, user_luid: str, token: str): @@ -474,6 +378,8 @@ def query_resource(self, url_ending: str, server_level:bool = False, filters: Op sorts: Optional[List[Sort]] = None, additional_url_ending: Optional[str] = None, fields: Optional[List[str]] = None) -> ET.Element: self.start_log_block() + if self.token == "": + raise NotSignedInException('Must use .signin() to create REST API session first') url_endings = [] if filters is not None: if len(filters) > 0: @@ -625,6 +531,8 @@ def query_resource_json(self, url_ending: str, server_level: bool = False, sorts: Optional[List[Sort]] = None, additional_url_ending: str = None, fields: Optional[List[str]] = None, page_number: Optional[int] = None) -> Dict: self.start_log_block() + if self.token == "": + raise NotSignedInException('Must use .signin() to create REST API session first') url_endings = [] if filters is not None: if len(filters) > 0: @@ -877,144 +785,146 @@ def _publish_content(self, content_type: str, content_filename: str, content_nam # Open the file to be uploaded try: content_file = open(content_filename, 'rb') - file_size = os.path.getsize(content_filename) - file_size_mb = float(file_size) / float(1000000) - self.log("File {} is size {} MBs".format(content_filename, file_size_mb)) - final_filename = content_filename - - # Request type is mixed and require a boundary - boundary_string = self.generate_boundary_string() - - # Create the initial XML portion of the request - publish_request = bytes("--{}\r\n".format(boundary_string).encode('utf-8')) - publish_request += bytes('Content-Disposition: name="request_payload"\r\n'.encode('utf-8')) - publish_request += bytes('Content-Type: text/xml\r\n\r\n'.encode('utf-8')) - - # Build publish request in ElementTree then convert at publish - publish_request_xml = ET.Element('tsRequest') - # could be either workbook, datasource, or flow - t1 = ET.Element(content_type) - t1.set('name', content_name) - if show_tabs is not False: - t1.set('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('generateThumbnailsAsUser', thumbnail_user_luid) - - if connection_username is not None: - cc = ET.Element('connectionCredentials') - cc.set('name', connection_username) - if oauth_flag is True: - cc.set('oAuth', "True") - if connection_password is not None: - cc.set('password', connection_password) - cc.set('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 = ET.Element('views') - for view_name in views_to_hide_list: - v = ET.Element('view') - v.set('name', view_name) - v.set('hidden', 'true') - t1.append(vs) - - # Description only allowed for Flows as of 3.3 - if description is not None: - t1.set('description', description) - p = ET.Element('project') - p.set('id', project_luid) - t1.append(p) - publish_request_xml.append(t1) - - encoded_request = ET.tostring(publish_request_xml, encoding='utf-8') - - publish_request += bytes(encoded_request) - publish_request += bytes("\r\n--{}".format(boundary_string).encode('utf-8')) - - # Upload as single if less than file_size_limit MB - if file_size_mb <= single_upload_limit: - # If part of a single upload, this if the next portion - self.log("Less than {} MB, uploading as a single call".format(str(single_upload_limit))) - publish_request += bytes('\r\n'.encode('utf-8')) - publish_request += bytes('Content-Disposition: name="tableau_{}"; filename="{}"\r\n'.format( - content_type, final_filename).encode('utf-8')) - publish_request += bytes('Content-Type: application/octet-stream\r\n\r\n'.encode('utf-8')) - - # Content needs to be read unencoded from the file - content = content_file.read() - - # Add to string as regular binary, no encoding - publish_request += content - - publish_request += bytes("\r\n--{}--".format(boundary_string).encode('utf-8')) - - url = self.build_api_url("{}s").format(content_type) - - # Allow additional parameters on the publish url - if len(url_params) > 0: - additional_params = '?' - i = 1 - for param in url_params: - if i > 1: - additional_params += "&" - additional_params += "{}={}".format(param, str(url_params[param]).lower()) - i += 1 - url += additional_params - - content_file.close() - if temp_wb_filename is not None: - os.remove(temp_wb_filename) - if cleanup_temp_file is True: - os.remove(final_filename) - return self.send_publish_request(url, None, publish_request, boundary_string) - # Break up into chunks for upload - else: - self.log("Greater than 10 MB, uploading in chunks") - upload_session_id = self.initiate_file_upload() - - # Upload each chunk - for piece in self.read_file_in_chunks(content_file): - self.log("Appending chunk to upload session {}".format(upload_session_id)) - self.append_to_file_upload(upload_session_id, piece, final_filename) - - # Finalize the publish - url = self.build_api_url("{}s").format(content_type) + "?uploadSessionId={}".format( - upload_session_id) + "&{}Type={}".format(content_type, file_extension) - - # Allow additional parameters on the publish url - if len(url_params) > 0: - additional_params = '&' - i = 1 - for param in url_params: - if i > 1: - additional_params += "&" - additional_params += "{}={}".format(param, str(url_params[param]).lower()) - i += 1 - url += additional_params - - publish_request += bytes("--".encode('utf-8')) # Need to finish off the last boundary - self.log("Finishing the upload with a publish request") - content_file.close() - if temp_wb_filename is not None: - os.remove(temp_wb_filename) - if cleanup_temp_file is True: - os.remove(final_filename) - return self.send_publish_request(url=url, xml_request=None, content=publish_request, - boundary_string=boundary_string) - except IOError: print("Error: File '{}' cannot be opened to upload".format(content_filename)) raise + file_size = os.path.getsize(content_filename) + file_size_mb = float(file_size) / float(1000000) + self.log("File {} is size {} MBs".format(content_filename, file_size_mb)) + final_filename = content_filename + + # Request type is mixed and require a boundary + boundary_string = self.generate_boundary_string() + + # Create the initial XML portion of the request + publish_request = bytes("--{}\r\n".format(boundary_string).encode('utf-8')) + publish_request += bytes('Content-Disposition: name="request_payload"\r\n'.encode('utf-8')) + publish_request += bytes('Content-Type: text/xml\r\n\r\n'.encode('utf-8')) + + # Build publish request in ElementTree then convert at publish + publish_request_xml = ET.Element('tsRequest') + # could be either workbook, datasource, or flow + t1 = ET.Element(content_type) + t1.set('name', content_name) + if show_tabs is not False: + t1.set('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('generateThumbnailsAsUser', thumbnail_user_luid) + + if connection_username is not None: + cc = ET.Element('connectionCredentials') + cc.set('name', connection_username) + if oauth_flag is True: + cc.set('oAuth', "True") + if connection_password is not None: + cc.set('password', connection_password) + cc.set('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 = ET.Element('views') + for view_name in views_to_hide_list: + v = ET.Element('view') + v.set('name', view_name) + v.set('hidden', 'true') + t1.append(vs) + + # Description only allowed for Flows as of 3.3 + if description is not None: + t1.set('description', description) + p = ET.Element('project') + p.set('id', project_luid) + t1.append(p) + publish_request_xml.append(t1) + + encoded_request = ET.tostring(publish_request_xml, encoding='utf-8') + + publish_request += bytes(encoded_request) + publish_request += bytes("\r\n--{}".format(boundary_string).encode('utf-8')) + + # Upload as single if less than file_size_limit MB + if file_size_mb <= single_upload_limit: + # If part of a single upload, this if the next portion + self.log("Less than {} MB, uploading as a single call".format(str(single_upload_limit))) + publish_request += bytes('\r\n'.encode('utf-8')) + publish_request += bytes('Content-Disposition: name="tableau_{}"; filename="{}"\r\n'.format( + content_type, final_filename).encode('utf-8')) + publish_request += bytes('Content-Type: application/octet-stream\r\n\r\n'.encode('utf-8')) + + # Content needs to be read unencoded from the file + content = content_file.read() + + # Add to string as regular binary, no encoding + publish_request += content + + publish_request += bytes("\r\n--{}--".format(boundary_string).encode('utf-8')) + + url = self.build_api_url("{}s").format(content_type) + + # Allow additional parameters on the publish url + if len(url_params) > 0: + additional_params = '?' + i = 1 + for param in url_params: + if i > 1: + additional_params += "&" + additional_params += "{}={}".format(param, str(url_params[param]).lower()) + i += 1 + url += additional_params + + content_file.close() + if temp_wb_filename is not None: + os.remove(temp_wb_filename) + if cleanup_temp_file is True: + os.remove(final_filename) + + results = self.send_publish_request(url=url, xml_request=None, content=publish_request, + boundary_string=boundary_string) + return results + # Break up into chunks for upload + else: + self.log("Greater than 10 MB, uploading in chunks") + upload_session_id = self.initiate_file_upload() + + # Upload each chunk + for piece in self.read_file_in_chunks(content_file): + self.log("Appending chunk to upload session {}".format(upload_session_id)) + self.append_to_file_upload(upload_session_id, piece, final_filename) + + # Finalize the publish + url = self.build_api_url("{}s").format(content_type) + "?uploadSessionId={}".format( + upload_session_id) + "&{}Type={}".format(content_type, file_extension) + + # Allow additional parameters on the publish url + if len(url_params) > 0: + additional_params = '&' + i = 1 + for param in url_params: + if i > 1: + additional_params += "&" + additional_params += "{}={}".format(param, str(url_params[param]).lower()) + i += 1 + url += additional_params + + publish_request += bytes("--".encode('utf-8')) # Need to finish off the last boundary + self.log("Finishing the upload with a publish request") + content_file.close() + if temp_wb_filename is not None: + os.remove(temp_wb_filename) + if cleanup_temp_file is True: + os.remove(final_filename) + return self.send_publish_request(url=url, xml_request=None, content=publish_request, + boundary_string=boundary_string) if file_extension is None: raise InvalidOptionException( - "File {} does not have an acceptable extension. Should be .twb,.twbx,.tde,.tdsx,.tds,.tde".format( + "File {} does not have an acceptable extension. Should be .twb,.twbx,.tde,.tdsx,.tds,.tde, .tfl, .tlfx, .hyper".format( content_filename)) def initiate_file_upload(self) -> str: diff --git a/tableau_rest_api/methods/workbook.py b/tableau_rest_api/methods/workbook.py index 1091c96..2bd87bd 100644 --- a/tableau_rest_api/methods/workbook.py +++ b/tableau_rest_api/methods/workbook.py @@ -12,7 +12,7 @@ def __getattr__(self, attr): def get_published_workbook_object(self, workbook_name_or_luid: str, project_name_or_luid: Optional[str] = None) -> Workbook: luid = self.query_workbook_luid(workbook_name_or_luid, project_name_or_luid) - wb_obj = Workbook(luid=luid, tableau_rest_api_obj=self, + wb_obj = Workbook(luid=luid, tableau_rest_api_obj=self.rest_api_base, default=False, logger_obj=self.logger) return wb_obj diff --git a/tableau_rest_api/permissions.py b/tableau_rest_api/permissions.py index 0730980..68f8a3a 100644 --- a/tableau_rest_api/permissions.py +++ b/tableau_rest_api/permissions.py @@ -304,7 +304,7 @@ def __init__(self, group_or_user: str, luid: str, content_type: Optional[str] = '3.6': server_content_roles_3_5 } - self.__server_to_rest_capability_map = { + self.server_to_rest_capability_map = { 'Add Comment': 'AddComment', 'Move': 'ChangeHierarchy', 'Set Permissions': 'ChangePermissions', @@ -365,15 +365,15 @@ def group_or_user(self, group_or_user): # Just use the direct "to_allow" and "to_deny" methods def set_capability(self, capability_name: str, mode: str): - if capability_name not in list(self.__server_to_rest_capability_map.values()): + if capability_name not in list(self.server_to_rest_capability_map.values()): # If it's the Tableau UI naming, translate it over - if capability_name in self.__server_to_rest_capability_map: + if capability_name in self.server_to_rest_capability_map: # InheritedProjectLeader (2.8+) is Read-Only if capability_name == 'InheritedProjectLeader': self.log('InheritedProjectLeader permission is read-only, skipping') return if capability_name != 'all': - capability_name = self.__server_to_rest_capability_map[capability_name] + capability_name = self.server_to_rest_capability_map[capability_name] else: raise InvalidOptionException('"{}" is not a capability in REST API or Server'.format(capability_name)) self.capabilities[capability_name] = mode @@ -387,23 +387,23 @@ def set_capability_to_deny(self, capability_name: str): def set_capability_to_unspecified(self, capability_name: str): if capability_name not in self.capabilities: # If it's the Tableau UI naming, translate it over - if capability_name in self.__server_to_rest_capability_map: + if capability_name in self.server_to_rest_capability_map: if capability_name == 'InheritedProjectLeader': self.log('InheritedProjectLeader permission is read-only, skipping') return if capability_name != 'all': - capability_name = self.__server_to_rest_capability_map[capability_name] + capability_name = self.server_to_rest_capability_map[capability_name] else: raise InvalidOptionException('"{}" is not a capability in REST API or Server'.format(capability_name)) self.capabilities[capability_name] = None # This exists specifically to allow the setting of read-only permissions def _set_capability_from_published_content(self, capability_name: str, mode: str): - if capability_name not in list(self.__server_to_rest_capability_map.values()): + if capability_name not in list(self.server_to_rest_capability_map.values()): # If it's the Tableau UI naming, translate it over - if capability_name in self.__server_to_rest_capability_map: + if capability_name in self.server_to_rest_capability_map: if capability_name != 'all': - capability_name = self.__server_to_rest_capability_map[capability_name] + capability_name = self.server_to_rest_capability_map[capability_name] else: raise InvalidOptionException('"{}" is not a capability in REST API or Server'.format(capability_name)) self.capabilities[capability_name] = mode diff --git a/tableau_rest_api/published_content.py b/tableau_rest_api/published_content.py index 7bbf4b7..5e9b199 100644 --- a/tableau_rest_api/published_content.py +++ b/tableau_rest_api/published_content.py @@ -3,6 +3,8 @@ import copy from typing import Union, Any, Optional, List, Dict, TYPE_CHECKING +from ..tableau_rest_xml import TableauRestXml + if TYPE_CHECKING: from tableau_tools.logging_methods import LoggingMethods from tableau_tools.logger import Logger @@ -26,10 +28,11 @@ def __init__(self, luid: str, obj_type: str, tableau_rest_api_obj: Union['Tablea self.current_perms_obj_list: Optional[List[Permissions]] = None self.__permissionable_objects = self.permissionable_objects self.get_permissions_from_server() - self.xml_obj = content_xml_obj + #self.log('Creating a Published Project Object from this XML:') + #self.log_xml_response(content_xml_obj) self.api_version = tableau_rest_api_obj.api_version self.permissions_object_class = ProjectPermissions # Override in any child class with specific - + self.xml_obj = content_xml_obj # If you want to know the name that matches to the group or user, need these # But no need to request every single time # self.groups_dict_cache = None @@ -86,7 +89,7 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username # Copy Permissions for users or group def _copy_permissions_obj(self, perms_obj, user_or_group, name_or_luid): self.start_log_block() - if self.is_luid(name_or_luid): + if TableauRestXml.is_luid(name_or_luid): luid = name_or_luid else: if user_or_group == 'group': @@ -339,12 +342,7 @@ def are_capabilities_obj_dicts_identical(new_obj_dict: Dict, dest_obj_dict: Dict return False # Dict { capability_name : mode } into XML with checks for validity. Set type to 'workbook' or 'datasource' - def build_capabilities_xml_from_dict(self, capabilities_dict, obj_type): - """ - :type capabilities_dict: dict - :type obj_type: unicode - :return: ET.Element - """ + def build_capabilities_xml_from_dict(self, capabilities_dict: Dict, obj_type: str) -> ET.Element: if obj_type not in self.permissionable_objects: error_text = 'objtype can only be "project", "workbook" or "datasource", was given {}' raise InvalidOptionException(error_text.format('obj_type')) @@ -391,7 +389,8 @@ def _build_add_permissions_request(self, permissions_obj: 'Permissions') -> ET.E return tsr # Template stub - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['Permissions']: + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['Permissions']: pass def get_permissions_from_server(self, obj_perms_xml: Optional[ET.Element] = None) -> List['Permissions']: @@ -565,33 +564,34 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username return self._get_permissions_object(group_name_or_luid=group_name_or_luid, username_or_luid=username_or_luid, role=role) - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['WorkbookPermissions']: + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['WorkbookPermissions']: - self.start_log_block() + #self.start_log_block() obj_list = [] - xml = xml_obj.findall('.//t:granteeCapabilities', self.t_rest_api.ns_map) + xml = xml_obj.findall('.//t:granteeCapabilities', TableauRestXml.ns_map) if len(xml) == 0: - self.end_log_block() + # self.end_log_block() return [] else: for gcaps in xml: for tags in gcaps: # Namespace fun - if tags.tag == '{}group'.format(self.t_rest_api.ns_prefix): + if tags.tag == '{}group'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = WorkbookPermissions('group', luid) - self.log_debug('group {}'.format(luid)) - elif tags.tag == '{}user'.format(self.t_rest_api.ns_prefix): + # self.log_debug('group {}'.format(luid)) + elif tags.tag == '{}user'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = WorkbookPermissions('user', luid) - self.log_debug('user {}'.format(luid)) - elif tags.tag == '{}capabilities'.format(self.t_rest_api.ns_prefix): + # self.log_debug('user {}'.format(luid)) + elif tags.tag == '{}capabilities'.format(TableauRestXml.ns_prefix): for caps in tags: - self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) + # self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) perms_obj.set_capability(caps.get('name'), caps.get('mode')) obj_list.append(perms_obj) - self.log('Permissions object list has {} items'.format(str(len(obj_list)))) - self.end_log_block() + #self.log('Permissions object list has {} items'.format(str(len(obj_list)))) + # self.end_log_block() return obj_list class Workbook28(Workbook): @@ -630,37 +630,39 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username return self._get_permissions_object(group_name_or_luid=group_name_or_luid, username_or_luid=username_or_luid, role=role) - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['DatasourcePermissions']: - self.start_log_block() + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['DatasourcePermissions']: + #self.start_log_block() obj_list = [] - xml = xml_obj.findall('.//t:granteeCapabilities', self.t_rest_api.ns_map) + xml = xml_obj.findall('.//t:granteeCapabilities', TableauRestXml.ns_map) if len(xml) == 0: - self.end_log_block() + # self.end_log_block() return [] else: for gcaps in xml: for tags in gcaps: # Namespace fun - if tags.tag == '{}group'.format(self.t_rest_api.ns_prefix): + if tags.tag == '{}group'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = DatasourcePermissions('group', luid) - self.log_debug('group {}'.format(luid)) - elif tags.tag == '{}user'.format(self.t_rest_api.ns_prefix): + #self.log_debug('group {}'.format(luid)) + elif tags.tag == '{}user'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = DatasourcePermissions('user', luid) - self.log_debug('user {}'.format(luid)) - elif tags.tag == '{}capabilities'.format(self.t_rest_api.ns_prefix): + #self.log_debug('user {}'.format(luid)) + elif tags.tag == '{}capabilities'.format(TableauRestXml.ns_prefix): for caps in tags: - self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) + #self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) perms_obj.set_capability(caps.get('name'), caps.get('mode')) obj_list.append(perms_obj) - self.log('Permissions object list has {} items'.format(str(len(obj_list)))) - self.end_log_block() + #self.log('Permissions object list has {} items'.format(str(len(obj_list)))) + # self.end_log_block() return obj_list class Datasource28(Datasource): - def __init__(self, luid, tableau_rest_api_obj, default=False, logger_obj=None, - content_xml_obj=None): + def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], + default: bool = False, logger_obj: Optional['Logger'] = None, + content_xml_obj: Optional[ET.Element] = None): Datasource.__init__(self, luid=luid, tableau_rest_api_obj=tableau_rest_api_obj, default=default, logger_obj=logger_obj, content_xml_obj=content_xml_obj) self.__available_capabilities = Permissions.available_capabilities[self.api_version]["datasource"] @@ -672,8 +674,9 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username role=role) class View(PublishedContent): - def __init__(self, luid, tableau_rest_api_obj, default=False, logger_obj=None, - content_xml_obj=None): + def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], + default: bool = False, logger_obj: Optional['Logger'] = None, + content_xml_obj: Optional[ET.Element] = None): PublishedContent.__init__(self, luid, "view", tableau_rest_api_obj, default=default, logger_obj=logger_obj, content_xml_obj=content_xml_obj) self.__available_capabilities = Permissions.available_capabilities[self.api_version]["workbook"] @@ -688,32 +691,33 @@ def luid(self, luid: str): # Maybe implement a search at some point self._luid = luid - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['WorkbookPermissions']: - self.start_log_block() + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['WorkbookPermissions']: + #self.start_log_block() obj_list = [] - xml = xml_obj.findall('.//t:granteeCapabilities', self.t_rest_api.ns_map) + xml = xml_obj.findall('.//t:granteeCapabilities', TableauRestXml.ns_map) if len(xml) == 0: - self.end_log_block() + #self.end_log_block() return [] else: for gcaps in xml: for tags in gcaps: # Namespace fun - if tags.tag == '{}group'.format(self.t_rest_api.ns_prefix): + if tags.tag == '{}group'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = WorkbookPermissions('group', luid) - self.log_debug('group {}'.format(luid)) - elif tags.tag == '{}user'.format(self.t_rest_api.ns_prefix): + #self.log_debug('group {}'.format(luid)) + elif tags.tag == '{}user'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = WorkbookPermissions('user', luid) - self.log_debug('user {}'.format(luid)) - elif tags.tag == '{}capabilities'.format(self.t_rest_api.ns_prefix): + #self.log_debug('user {}'.format(luid)) + elif tags.tag == '{}capabilities'.format(TableauRestXml.ns_prefix): for caps in tags: - self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) + #self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) perms_obj.set_capability(caps.get('name'), caps.get('mode')) obj_list.append(perms_obj) - self.log('Permissions object list has {} items'.format(str(len(obj_list)))) - self.end_log_block() + #self.log('Permissions object list has {} items'.format(str(len(obj_list)))) + #self.end_log_block() return obj_list @@ -760,11 +764,16 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username role=role) + class Project(PublishedContent): - def __init__(self, luid, tableau_rest_api_obj, logger_obj=None, - content_xml_obj=None): - PublishedContent.__init__(self, luid, "project", tableau_rest_api_obj, + def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], + logger_obj: Optional['Logger'] = None, content_xml_obj: Optional[ET.Element] = None): + PublishedContent.__init__(self, luid=luid, obj_type="project", tableau_rest_api_obj=tableau_rest_api_obj, logger_obj=logger_obj, content_xml_obj=content_xml_obj) + self.log('Building Project object from this XML: ') + self.log_xml_response(content_xml_obj) + self.log('Project object has this XML: ') + self.log_xml_response(self.xml_obj) # projects in 9.2 have child workbook and datasource permissions self._workbook_defaults = Workbook(self.luid, self.t_rest_api, default=True, logger_obj=logger_obj) @@ -781,8 +790,8 @@ def luid(self): return self._luid @luid.setter - def luid(self, name_or_luid): - if self.is_luid(name_or_luid): + def luid(self, name_or_luid: str): + if TableauRestXml.is_luid(name_or_luid): luid = name_or_luid else: luid = self.t_rest_api.query_project_luid(name_or_luid) @@ -794,39 +803,41 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username role=role) # Simpler synonym - def convert_xml_into_permissions_list(self, xml_obj: ET.Element) -> List['ProjectPermissions']: - return self.convert_capabilities_xml_into_obj_list(xml_obj=xml_obj) + @staticmethod + def convert_xml_into_permissions_list(xml_obj: ET.Element) -> List['ProjectPermissions']: + return Project.convert_capabilities_xml_into_obj_list(xml_obj=xml_obj) # Available for legacy - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['ProjectPermissions']: - self.start_log_block() + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['ProjectPermissions']: + #self.start_log_block() obj_list = [] - xml = xml_obj.findall('.//t:granteeCapabilities', self.t_rest_api.ns_map) + xml = xml_obj.findall('.//t:granteeCapabilities', TableauRestXml.ns_map) if len(xml) == 0: - self.end_log_block() + #self.end_log_block() return [] else: for gcaps in xml: for tags in gcaps: # Namespace fun - if tags.tag == '{}group'.format(self.t_rest_api.ns_prefix): + if tags.tag == '{}group'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = ProjectPermissions('group', luid) - self.log_debug('group {}'.format(luid)) - elif tags.tag == '{}user'.format(self.t_rest_api.ns_prefix): + # self.log_debug('group {}'.format(luid)) + elif tags.tag == '{}user'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = ProjectPermissions('user', luid) - self.log_debug('user {}'.format(luid)) - elif tags.tag == '{}capabilities'.format(self.t_rest_api.ns_prefix): + # self.log_debug('user {}'.format(luid)) + elif tags.tag == '{}capabilities'.format(TableauRestXml.ns_prefix): for caps in tags: - self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) + # self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) perms_obj.set_capability(caps.get('name'), caps.get('mode')) obj_list.append(perms_obj) - self.log('Permissions object list has {} items'.format(str(len(obj_list)))) - self.end_log_block() + # self.log('Permissions object list has {} items'.format(str(len(obj_list)))) + #self.end_log_block() return obj_list - def replicate_permissions(self, orig_content: 'PublishedContent'): + def replicate_permissions(self, orig_content: 'Project'): self.start_log_block() self.clear_all_permissions() @@ -851,12 +862,7 @@ def replicate_permissions(self, orig_content: 'PublishedContent'): self.end_log_block() - def replicate_permissions_direct_xml(self, orig_content, username_map=None): - """ - :type orig_content: Project - :type username_map: dict[unicode, unicode] - :return: - """ + def replicate_permissions_direct_xml(self, orig_content: 'Project', username_map: Optional[Dict] = None): self.start_log_block() self.clear_all_permissions() @@ -925,7 +931,6 @@ def create_datasource_permissions_object_for_user(self, username_or_luid: str, return self._get_permissions_object(username_or_luid=username_or_luid, role=role, permissions_class_override=DatasourcePermissions) - @property def workbook_defaults(self) -> Workbook: return self._workbook_defaults @@ -943,43 +948,42 @@ def clear_all_permissions(self, clear_defaults: bool = True): self.datasource_defaults.clear_all_permissions() self.end_log_block() - def are_permissions_locked(self): - """ - :return: bool - """ + def are_permissions_locked(self) -> bool: proj = self.xml_obj locked_permissions = proj.get('contentPermissions') - if locked_permissions == 'ManagedByOwner': - return False - if locked_permissions == 'LockedToProject': - return True + mapping = {'ManagedByOwner' : False, 'LockedToProject': True} + return mapping[locked_permissions] - def lock_permissions(self): - """ - :return: - """ + def lock_permissions(self) -> 'Project': self.start_log_block() if self.permissions_locked is False: - if(isinstance(self.t_rest_api, TableauRestApiConnection)): - self.t_rest_api.update_project(self.luid, locked_permissions=True) - if(isinstance(self.t_rest_api, TableauServerRest)): - self.t_rest_api.projects.update_project(self.luid, locked_permissions=True) - self.end_log_block() + # This allows type checking without importing the class + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=True) + else: + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=True) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self - def unlock_permissions(self): - """ - :return: - """ + def unlock_permissions(self) -> 'Project': self.start_log_block() if self.permissions_locked is True: - if(isinstance(self.t_rest_api, TableauRestApiConnection)): - self.t_rest_api.update_project(self.luid, locked_permissions=False) - if(isinstance(self.t_rest_api, TableauServerRest)): - self.t_rest_api.projects.update_project(self.luid, locked_permissions=False) - - self.end_log_block() + # This allows type checking without importing the class + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=False) + else: + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=False) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self - def query_all_permissions(self): + # These are speciality methods just for exporting everything out for audit + def query_all_permissions(self) -> Dict: # Returns all_permissions[luid] = { name: , type: , project_caps, workbook_default_caps: , # datasource_default_caps: } @@ -1017,7 +1021,7 @@ def query_all_permissions(self): return all_permissions # Exports all of the permissions on a project in the order displayed in Tableau Server - def convert_all_permissions_to_list(self, all_permissions): + def convert_all_permissions_to_list(self, all_permissions: Dict): final_list = [] # Project @@ -1042,10 +1046,11 @@ def convert_all_permissions_to_list(self, all_permissions): class Project28(Project): - def __init__(self, luid, tableau_rest_api_obj, logger_obj=None, - content_xml_obj=None, parent_project_luid=None): + def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], + logger_obj: Optional['Logger'] = None, + content_xml_obj: Optional[ET.Element] = None, parent_project_luid: Optional[str] = None): Project.__init__(self, luid=luid, tableau_rest_api_obj=tableau_rest_api_obj, logger_obj=logger_obj, - content_xml_obj=content_xml_obj) + content_xml_obj=content_xml_obj) self._parent_project_luid = parent_project_luid self.permissions_object_class = ProjectPermissions28 @@ -1054,47 +1059,76 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username return self._get_permissions_object(group_name_or_luid=group_name_or_luid, username_or_luid=username_or_luid, role=role) @property - def parent_project_luid(self): + def parent_project_luid(self) -> str: return self._parent_project_luid def query_child_projects(self) -> ET.Element: self.start_log_block() - if (isinstance(self.t_rest_api, TableauRestApiConnection)): + # This allows type checking without importing the class + if (type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): projects = self.t_rest_api.query_projects() - elif (isinstance(self.t_rest_api, TableauServerRest)): - projects = self.t_rest_api.projects.query_projects() else: - raise InvalidOptionException('t_rest_api needs to be either TableauRestApiConnection or TableauServerRest descended') + projects = self.t_rest_api.projects.query_projects() + child_projects = projects.findall('.//t:project[@parentProjectId="{}"]'.format(self.luid), self.t_rest_api.ns_map) self.end_log_block() return child_projects - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['ProjectPermissions']: + def lock_permissions(self) -> 'Project28': + self.start_log_block() + if self.permissions_locked is False: + # This allows type checking without importing the class + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=True) + else: + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=True) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self + + def unlock_permissions(self) -> 'Project28': self.start_log_block() + if self.permissions_locked is True: + # This allows type checking without importing the class + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=False) + else: + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=False) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self + + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['ProjectPermissions']: + # self.start_log_block() obj_list = [] - xml = xml_obj.findall('.//t:granteeCapabilities', self.t_rest_api.ns_map) + xml = xml_obj.findall('.//t:granteeCapabilities', TableauRestXml.ns_map) if len(xml) == 0: - self.end_log_block() + # self.end_log_block() return [] else: for gcaps in xml: for tags in gcaps: # Namespace fun - if tags.tag == '{}group'.format(self.t_rest_api.ns_prefix): + if tags.tag == '{}group'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = ProjectPermissions28('group', luid) - self.log_debug('group {}'.format(luid)) - elif tags.tag == '{}user'.format(self.t_rest_api.ns_prefix): + # self.log_debug('group {}'.format(luid)) + elif tags.tag == '{}user'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = ProjectPermissions28('user', luid) - self.log_debug('user {}'.format(luid)) - elif tags.tag == '{}capabilities'.format(self.t_rest_api.ns_prefix): + # self.log_debug('user {}'.format(luid)) + elif tags.tag == '{}capabilities'.format(TableauRestXml.ns_prefix): for caps in tags: - self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) + # self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) perms_obj._set_capability_from_published_content(caps.get('name'), caps.get('mode')) obj_list.append(perms_obj) - self.log('Permissions object list has {} items'.format(str(len(obj_list)))) - self.end_log_block() + # self.log('Permissions object list has {} items'.format(str(len(obj_list)))) + # self.end_log_block() return obj_list # There are all legacy for compatibility purposes @@ -1130,12 +1164,41 @@ def create_datasource_permissions_object_for_user(self, username_or_luid: str, class Project33(Project28): - def __init__(self, luid, tableau_rest_api_obj, logger_obj=None, - content_xml_obj=None, parent_project_luid=None): + def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], + logger_obj: Optional['Logger'] = None, content_xml_obj: Optional[ET.Element] = None, + parent_project_luid:str = None): Project28.__init__(self, luid=luid, tableau_rest_api_obj=tableau_rest_api_obj, logger_obj=logger_obj, content_xml_obj=content_xml_obj, parent_project_luid=parent_project_luid) self.flow_defaults = Flow33(self.luid, self.t_rest_api, default=True, logger_obj=logger_obj) + def lock_permissions(self) -> 'Project33': + self.start_log_block() + if self.permissions_locked is False: + # This allows type checking without importing the class + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=True) + else: + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=True) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self + + def unlock_permissions(self) -> 'Project33': + self.start_log_block() + if self.permissions_locked is True: + # This allows type checking without importing the class + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=False) + else: + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=False) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self + def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username_or_luid: Optional[str] = None, role: Optional[str] = None) -> 'ProjectPermissions28': return self._get_permissions_object(group_name_or_luid=group_name_or_luid, username_or_luid=username_or_luid, diff --git a/tableau_rest_api/rest_xml_request.py b/tableau_rest_api/rest_xml_request.py index c7a0aa0..2bcbbca 100644 --- a/tableau_rest_api/rest_xml_request.py +++ b/tableau_rest_api/rest_xml_request.py @@ -1,6 +1,3 @@ -from tableau_tools.logging_methods import LoggingMethods -from tableau_tools.tableau_exceptions import * -from tableau_tools.logger import Logger import xml.etree.ElementTree as ET # from HTMLParser import HTMLParser # from StringIO import StringIO @@ -12,6 +9,9 @@ import sys from typing import Union, Any, Optional, List, Dict, Tuple +from ..logging_methods import LoggingMethods +from ..tableau_exceptions import * +from ..logger import Logger # Handles all of the actual HTTP calling class RestXmlRequest(LoggingMethods): @@ -137,13 +137,12 @@ def __make_request(self, page_number:int = 1): if self.__publish is True: request_headers['Content-Type'] = 'multipart/mixed; boundary={}'.format(self.__boundary_string) - # Need to handle binary return for image somehow - self.log("Request {} {}".format(self._http_verb.upper(), url)) - # Log the XML request being sent encoded_request = "" + if self.xml_request is None: + self.log_uri(verb=self._http_verb.upper(), uri=url) if self.xml_request is not None: - self.log("Request XML: {}".format(ET.tostring(self.xml_request, encoding='utf-8').decode('utf-8'))) + self.log_xml_request(xml=self.xml_request, verb=self.http_verb, uri=url) if isinstance(self.xml_request, str): encoded_request = self.xml_request.encode('utf-8') else: @@ -200,8 +199,8 @@ def _handle_http_error(self, response, e): raise e # REST API returns 400 type errors that can be recovered from, so handle them raw_error_response = response.content - self.log("Received a {} error, here was response:".format(str(status_code))) - self.log(raw_error_response.decode('utf8')) + self.log_error("{} error. Full response:".format(str(status_code))) + self.log_error(raw_error_response.decode('utf8')) utf8_parser = ET.XMLParser(encoding='utf-8') xml = ET.parse(BytesIO(raw_error_response), parser=utf8_parser) @@ -222,7 +221,7 @@ def _handle_http_error(self, response, e): detail_luid = detail_luid_match_obj.group(0) else: detail_luid = False - self.log('Tableau REST API error code is: {}'.format(error_code)) + self.log_error('Tableau REST API error code: {}'.format(error_code)) # If you are not signed in if error_code == '401000': raise NotSignedInException('401000 error, no session token was provided. Please sign in again.') @@ -237,6 +236,11 @@ def _handle_http_error(self, response, e): if status_code == 409: self.log('HTTP 409 error, most likely an already exists') raise RecoverableHTTPException(status_code, error_code, detail_luid) + # Invalid Hyper Extract publish does this + elif status_code == 400 and self._http_verb == 'post': + if error_code == '400011': + raise PossibleInvalidPublishException(http_code=400, tableau_error_code='400011', + msg="400011 on a Publish of a .hyper file could caused when the Hyper file either more than one table or the single table is not named 'Extract'.") else: raise e @@ -245,8 +249,9 @@ def _set_raw_response(self, unicode_raw_response): self.__raw_response = unicode_raw_response unicode_raw_response = unicode_raw_response.decode('utf-8') + # Shows each individual request if self.__response_type == 'xml': - self.log_debug("Raw Response: {}".format(unicode_raw_response)) + self.log_xml_response(format(unicode_raw_response)) # This has always brought back ALL listings from long paginated lists # But really should support three behaviors: @@ -292,9 +297,9 @@ def request_from_api(self, page_number: int = 1): combined_xml_obj.append(e) self.__xml_object = combined_xml_obj - self.log_debug("Logging the combined xml object") - self.log_debug(ET.tostring(self.__xml_object, encoding='utf-8').decode('utf-8')) - self.log("Request succeeded") + self.log_xml_response("Combined XML Response") + self.log_xml_response(ET.tostring(self.__xml_object, encoding='utf-8').decode('utf-8')) + # self.log("Request succeeded") return True elif self.__response_type in ['binary', 'png', 'csv']: self.log('Non XML response') diff --git a/tableau_rest_xml.py b/tableau_rest_xml.py new file mode 100644 index 0000000..942c49f --- /dev/null +++ b/tableau_rest_xml.py @@ -0,0 +1,108 @@ +# This is intended to be full of static helper methods +import xml.etree.ElementTree as ET +import random +from typing import Union, Optional, List, Dict, Tuple +import re + +class TableauRestXml: + tableau_namespace = 'http://tableau.com/api' + ns_map = {'t': 'http://tableau.com/api'} + ns_prefix = '{' + ns_map['t'] + '}' + # ET.register_namespace('t', ns_map['t']) + # Generic method for XML lists for the "query" actions to name -> id dict + @staticmethod + def convert_xml_list_to_name_id_dict(xml_obj: ET.Element) -> Dict: + d = {} + for element in xml_obj: + e_id = element.get("id") + # If list is collection, have to run one deeper + if e_id is None: + for list_element in element: + e_id = list_element.get("id") + name = list_element.get("name") + d[name] = e_id + else: + name = element.get("name") + d[name] = e_id + return d + + # Repeat of above method with shorter name + @staticmethod + def xml_list_to_dict(xml_obj: ET.Element) -> Dict: + d = {} + for element in xml_obj: + e_id = element.get("id") + # If list is collection, have to run one deeper + if e_id is None: + for list_element in element: + e_id = list_element.get("id") + name = list_element.get("name") + d[name] = e_id + else: + name = element.get("name") + d[name] = e_id + return d + + @staticmethod + def luid_name_dict_from_xml(xml_obj: ET.Element) -> Dict: + d = {} + for element in xml_obj: + e_id = element.get("id") + # If list is collection, have to run one deeper + if e_id is None: + for list_element in element: + e_id = list_element.get("id") + name = list_element.get("name") + d[e_id] = name + else: + name = element.get("name") + d[e_id] = name + return d + + @staticmethod + def luid_content_url_dict_from_xml(xml_obj: ET.Element) -> Dict: + d = {} + for element in xml_obj: + e_id = element.get("id") + # If list is collection, have to run one deeper + if e_id is None: + for list_element in element: + e_id = list_element.get("id") + name = list_element.get("contentUrl") + d[e_id] = name + else: + name = element.get("contentUrl") + d[e_id] = name + return d + + # This corrects for the first element in any response by the plural collection tag, which leads to differences + # with the XPath search currently + @staticmethod + def make_xml_list_iterable(xml_obj: ET.Element) -> List[ET.Element]: + pass + + # 32 hex characters with 4 dashes + @staticmethod + def is_luid(val: str) -> bool: + luid_pattern = r"[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*" + if len(val) == 36: + if re.match(luid_pattern, val) is not None: + return True + else: + return False + else: + return False + + # URI is different form actual URL you need to load a particular view in iframe + @staticmethod + def convert_view_content_url_to_embed_url(content_url: str) -> str: + split_url = content_url.split('/') + return 'views/{}/{}'.format(split_url[0], split_url[2]) + + # You must generate a boundary string that is used both in the headers and the generated request that you post. + # This builds a simple 30 hex digit string + @staticmethod + def generate_boundary_string() -> str: + random_digits = [random.SystemRandom().choice('0123456789abcdef') for n in range(30)] + s = "".join(random_digits) + return s \ No newline at end of file