diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 37d66d2dc..6798a2106 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -19,6 +19,7 @@ import logging import tableauserverclient as TSC +from tableauserverclient import ConnectionCredentials, ConnectionItem def main(): @@ -50,10 +51,26 @@ def main(): all_projects, pagination_item = server.projects.get() default_project = next((project for project in all_projects if project.is_default()), None) + connection1 = ConnectionItem() + connection1.server_address = "mssql.test.com" + connection1.connection_credentials = ConnectionCredentials("test", "password", True) + + connection2 = ConnectionItem() + connection2.server_address = "postgres.test.com" + connection2.server_port = "5432" + connection2.connection_credentials = ConnectionCredentials("test", "password", True) + + all_connections = list() + all_connections.append(connection1) + all_connections.append(connection2) + # Step 3: If default project is found, form a new workbook item and publish. if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) - new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true) + new_workbook = server.workbooks.publish(new_workbook, + args.filepath, + overwrite_true, + connections=all_connections) print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index 8c3a77925..c883a515a 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -32,3 +32,15 @@ def oauth(self): @property_is_boolean def oauth(self, value): self._oauth = value + + @classmethod + def from_xml_element(cls, parsed_response, ns): + connection_creds_xml = parsed_response.find('.//t:connectionCredentials', namespaces=ns) + + name = connection_creds_xml.get('name', None) + password = connection_creds_xml.get('password', None) + embed = connection_creds_xml.get('embed', None) + oAuth = connection_creds_xml.get('oAuth', None) + + connection_creds = cls(name, password, embed, oAuth) + return connection_creds diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index a52d32e9e..894cabe62 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET +from .connection_credentials import ConnectionCredentials class ConnectionItem(object): @@ -12,6 +13,7 @@ def __init__(self): self.server_address = None self.server_port = None self.username = None + self.connection_credentials = None @property def datasource_id(self): @@ -51,3 +53,32 @@ def from_response(cls, resp, ns): connection_item._datasource_name = datasource_elem.get('name', None) all_connection_items.append(connection_item) return all_connection_items + + @classmethod + def from_xml_element(cls, parsed_response, ns): + ''' + + + + + + + + + ''' + all_connection_items = list() + all_connection_xml = parsed_response.findall('.//t:connection', namespaces=ns) + + for connection_xml in all_connection_xml: + connection_item = cls() + + connection_item.server_address = connection_xml.get('serverAddress', None) + connection_item.server_port = connection_xml.get('serverPort', None) + + connection_credentials = connection_xml.find('.//t:connectionCredentials', namespaces=ns) + + if connection_credentials is not None: + + connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials) + + return all_connection_items diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 03e261765..5e986f91c 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -151,7 +151,8 @@ def refresh(self, datasource_item): # Publish datasource @api(version="2.0") - def publish(self, datasource_item, file_path, mode, connection_credentials=None): + @parameter_added_in(connections="99.99") + def publish(self, datasource_item, file_path, mode, connection_credentials=None, connections=None): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -180,7 +181,8 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None) upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked(datasource_item, - connection_credentials) + connection_credentials, + connections) else: logger.info('Publishing {0} to server'.format(filename)) with open(file_path, 'rb') as f: @@ -188,7 +190,8 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None) xml_request, content_type = RequestFactory.Datasource.publish_req(datasource_item, filename, file_contents, - connection_credentials) + connection_credentials, + connections) server_response = self.post_request(url, xml_request, content_type) new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index e78b2e0cd..a19c32acd 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -32,7 +32,7 @@ def _safe_to_log(server_response): '''Checks if the server_response content is not xml (eg binary image or zip) and and replaces it with a constant ''' - ALLOWED_CONTENT_TYPES = ('application/xml',) + ALLOWED_CONTENT_TYPES = ('application/xml', 'application/xml;charset=utf-8') if server_response.headers.get('Content-Type', None) not in ALLOWED_CONTENT_TYPES: return '[Truncated File Contents]' else: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 4ce9983f3..537e3ec81 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -199,7 +199,14 @@ def _get_wb_preview_image(self, workbook_item): # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") - def publish(self, workbook_item, file_path, mode, connection_credentials=None): + @parameter_added_in(connections='2.8') + def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None): + + if connection_credentials is not None: + import warnings + warnings.warn("connection_credentials is being deprecated. Use connections instead", + DeprecationWarning) + if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -230,16 +237,21 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None): logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(filename)) upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(workbook_item, - connection_credentials) + connection_credentials=conn_creds, + connections=connections) else: logger.info('Publishing {0} to server'.format(filename)) with open(file_path, 'rb') as f: file_contents = f.read() + conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req(workbook_item, filename, file_contents, - connection_credentials) + connection_credentials=conn_creds, + connections=connections) + logger.debug('Request xml: {0} '.format(xml_request[:1000])) server_response = self.post_request(url, xml_request, content_type) new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c4f10d731..5f8b153dc 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -25,6 +25,25 @@ def wrapper(self, *args, **kwargs): return wrapper +def _add_connections_element(connections_element, connection): + connection_element = ET.SubElement(connections_element, 'connection') + connection_element.attrib['serverAddress'] = connection.server_address + if connection.server_port: + connection_element.attrib['serverPort'] = connection.server_port + if connection.connection_credentials: + connection_credentials = connection.connection_credentials + _add_credentials_element(connection_element, connection_credentials) + + +def _add_credentials_element(parent_element, connection_credentials): + credentials_element = ET.SubElement(parent_element, 'connectionCredentials') + credentials_element.attrib['name'] = connection_credentials.name + credentials_element.attrib['password'] = connection_credentials.password + credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' + if connection_credentials.oauth: + credentials_element.attrib['oAuth'] = 'true' + + class AuthRequest(object): def signin_req(self, auth_item): xml_request = ET.Element('tsRequest') @@ -40,20 +59,23 @@ def signin_req(self, auth_item): class DatasourceRequest(object): - def _generate_xml(self, datasource_item, connection_credentials=None): + def _generate_xml(self, datasource_item, connection_credentials=None, connections=None): xml_request = ET.Element('tsRequest') datasource_element = ET.SubElement(xml_request, 'datasource') datasource_element.attrib['name'] = datasource_item.name project_element = ET.SubElement(datasource_element, 'project') project_element.attrib['id'] = datasource_item.project_id - if connection_credentials: - credentials_element = ET.SubElement(datasource_element, 'connectionCredentials') - credentials_element.attrib['name'] = connection_credentials.name - credentials_element.attrib['password'] = connection_credentials.password - credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' - - if connection_credentials.oauth: - credentials_element.attrib['oAuth'] = 'true' + + if connection_credentials is not None and connections is not None: + raise RuntimeError('You cannot set both `connections` and `connection_credentials`') + + if connection_credentials is not None: + _add_credentials_element(datasource_element, connection_credentials) + + if connections is not None: + connections_element = ET.SubElement(datasource_element, 'connections') + for connection in connections: + _add_connections_element(connections_element, connection) return ET.tostring(xml_request) def update_req(self, datasource_item): @@ -73,15 +95,15 @@ def update_req(self, datasource_item): return ET.tostring(xml_request) - def publish_req(self, datasource_item, filename, file_contents, connection_credentials=None): - xml_request = self._generate_xml(datasource_item, connection_credentials) + def publish_req(self, datasource_item, filename, file_contents, connection_credentials=None, connections=None): + xml_request = self._generate_xml(datasource_item, connection_credentials, connections) parts = {'request_payload': ('', xml_request, 'text/xml'), 'tableau_datasource': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) def publish_req_chunked(self, datasource_item, connection_credentials=None): - xml_request = self._generate_xml(datasource_item, connection_credentials) + xml_request = self._generate_xml(datasource_item, connection_credentials, connections) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) @@ -313,7 +335,7 @@ def add_req(self, user_item): class WorkbookRequest(object): - def _generate_xml(self, workbook_item, connection_credentials=None): + def _generate_xml(self, workbook_item, connection_credentials=None, connections=None): xml_request = ET.Element('tsRequest') workbook_element = ET.SubElement(xml_request, 'workbook') workbook_element.attrib['name'] = workbook_item.name @@ -321,14 +343,17 @@ def _generate_xml(self, workbook_item, connection_credentials=None): workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() project_element = ET.SubElement(workbook_element, 'project') project_element.attrib['id'] = workbook_item.project_id - if connection_credentials: - credentials_element = ET.SubElement(workbook_element, 'connectionCredentials') - credentials_element.attrib['name'] = connection_credentials.name - credentials_element.attrib['password'] = connection_credentials.password - credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' - - if connection_credentials.oauth: - credentials_element.attrib['oAuth'] = 'true' + + if connection_credentials is not None and connections is not None: + raise RuntimeError('You cannot set both `connections` and `connection_credentials`') + + if connection_credentials is not None: + _add_credentials_element(workbook_element, connection_credentials) + + if connections is not None: + connections_element = ET.SubElement(workbook_element, 'connections') + for connection in connections: + _add_connections_element(connections_element, connection) return ET.tostring(xml_request) def update_req(self, workbook_item): @@ -344,15 +369,19 @@ def update_req(self, workbook_item): owner_element.attrib['id'] = workbook_item.owner_id return ET.tostring(xml_request) - def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None): - xml_request = self._generate_xml(workbook_item, connection_credentials) + def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None, connections=None): + xml_request = self._generate_xml(workbook_item, + connection_credentials=connection_credentials, + connections=connections) parts = {'request_payload': ('', xml_request, 'text/xml'), 'tableau_workbook': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) - def publish_req_chunked(self, workbook_item, connection_credentials=None): - xml_request = self._generate_xml(workbook_item, connection_credentials) + def publish_req_chunked(self, workbook_item, connections=None): + xml_request = self._generate_xml(workbook_item, + connection_credentials=connection_credentials, + connections=connections) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) diff --git a/test/test_datasource.py b/test/test_datasource.py index ff1546d62..112c698d0 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -1,8 +1,10 @@ import unittest import os import requests_mock +import xml.etree.ElementTree as ET import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.request_factory import RequestFactory from ._utils import read_xml_asset, read_xml_assets, asset ADD_TAGS_XML = 'datasource_add_tags.xml' @@ -241,3 +243,48 @@ def test_publish_invalid_file_type(self): new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, asset('SampleWB.twbx'), self.server.PublishMode.Append) + + def test_publish_multi_connection(self): + new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection2 = TSC.ConnectionItem() + connection2.server_address = 'pgsql.test.com' + connection2.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Datasource._generate_xml(new_datasource, connections=[connection1, connection2]) + # Can't use ConnectionItem parser due to xml namespace problems + connection_results = ET.fromstring(response).findall('.//connection') + + self.assertEqual(connection_results[0].get('serverAddress', None), 'mysql.test.com') + self.assertEqual(connection_results[0].find('connectionCredentials').get('name', None), 'test') + self.assertEqual(connection_results[1].get('serverAddress', None), 'pgsql.test.com') + self.assertEqual(connection_results[1].find('connectionCredentials').get('password', None), 'secret') + + def test_publish_single_connection(self): + new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Datasource._generate_xml(new_datasource, connection_credentials=connection_creds) + # Can't use ConnectionItem parser due to xml namespace problems + credentials = ET.fromstring(response).findall('.//connectionCredentials') + + self.assertEqual(len(credentials), 1) + self.assertEqual(credentials[0].get('name', None), 'test') + self.assertEqual(credentials[0].get('password', None), 'secret') + self.assertEqual(credentials[0].get('embed', None), 'true') + + def test_credentials_and_multi_connect_raises_exception(self): + new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + with self.assertRaises(RuntimeError): + response = RequestFactory.Datasource._generate_xml(new_datasource, + connection_credentials=connection_creds, + connections=[connection1]) diff --git a/test/test_workbook.py b/test/test_workbook.py index 8c36f0229..2e2e45396 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -2,7 +2,10 @@ import os import requests_mock import tableauserverclient as TSC +import xml.etree.ElementTree as ET + from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.request_factory import RequestFactory TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -314,3 +317,50 @@ def test_publish_invalid_file_type(self): self.assertRaises(ValueError, self.server.workbooks.publish, new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleDS.tds'), self.server.PublishMode.CreateNew) + + def test_publish_multi_connection(self): + new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection2 = TSC.ConnectionItem() + connection2.server_address = 'pgsql.test.com' + connection2.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2]) + # Can't use ConnectionItem parser due to xml namespace problems + connection_results = ET.fromstring(response).findall('.//connection') + + self.assertEqual(connection_results[0].get('serverAddress', None), 'mysql.test.com') + self.assertEqual(connection_results[0].find('connectionCredentials').get('name', None), 'test') + self.assertEqual(connection_results[1].get('serverAddress', None), 'pgsql.test.com') + self.assertEqual(connection_results[1].find('connectionCredentials').get('password', None), 'secret') + + def test_publish_single_connection(self): + new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds) + # Can't use ConnectionItem parser due to xml namespace problems + credentials = ET.fromstring(response).findall('.//connectionCredentials') + self.assertEqual(len(credentials), 1) + self.assertEqual(credentials[0].get('name', None), 'test') + self.assertEqual(credentials[0].get('password', None), 'secret') + self.assertEqual(credentials[0].get('embed', None), 'true') + + def test_credentials_and_multi_connect_raises_exception(self): + new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + with self.assertRaises(RuntimeError): + response = RequestFactory.Workbook._generate_xml(new_workbook, + connection_credentials=connection_creds, + connections=[connection1])