diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md index 81d1211dd..68f86321f 100644 --- a/docs/docs/api-ref.md +++ b/docs/docs/api-ref.md @@ -1077,7 +1077,7 @@ The project resources for Tableau are defined in the `ProjectItem` class. The cl ```py -ProjectItem(name, description=None, content_permissions=None) +ProjectItem(name, description=None, content_permissions=None, parent_id=None) ``` The project resources for Tableau are defined in the `ProjectItem` class. The class corresponds to the project resources you can access using the Tableau Server REST API. @@ -1090,6 +1090,7 @@ Name | Description `name` | Name of the project. `description` | The description of the project. `id` | The project id. +`parent_id` | The parent project id. diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 6798a2106..2d460abaf 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -30,6 +30,7 @@ def main(): parser.add_argument('--filepath', '-f', required=True, help='filepath to the workbook to publish') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + parser.add_argument('--as-job', '-a', help='Publishing asynchronously', action='store_true') args = parser.parse_args() @@ -67,11 +68,14 @@ def main(): # Step 3: If default project is found, form a new workbook item and publish. if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) - new_workbook = server.workbooks.publish(new_workbook, - args.filepath, - overwrite_true, - connections=all_connections) - print("Workbook published. ID: {0}".format(new_workbook.id)) + if args.as_job: + new_job = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, + connections=all_connections, as_job=args.as_job) + print("Workbook published. JOB ID: {0}".format(new_job.id)) + else: + new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, + connections=all_connections, as_job=args.as_job) + print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." raise LookupError(error) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index f8b68d87f..6ad7f0256 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,12 +1,14 @@ import xml.etree.ElementTree as ET from ..datetime_helpers import parse_datetime from .target import Target +from ..datetime_helpers import parse_datetime class JobItem(object): - def __init__(self, id_, job_type, created_at, started_at=None, completed_at=None, finish_code=0): + def __init__(self, id_, job_type, progress, created_at, started_at=None, completed_at=None, finish_code=0): self._id = id_ self._type = job_type + self._progress = progress self._created_at = created_at self._started_at = started_at self._completed_at = completed_at @@ -20,6 +22,10 @@ def id(self): def type(self): return self._type + @property + def progress(self): + return self._progress + @property def created_at(self): return self._created_at @@ -38,7 +44,7 @@ def finish_code(self): def __repr__(self): return "".format(**self.__dict__) + " progress ({_progress}) finish_code({_finish_code})>".format(**self.__dict__) @classmethod def from_response(cls, xml, ns): @@ -54,11 +60,12 @@ def from_response(cls, xml, ns): def _parse_element(cls, element, ns): id_ = element.get('id', None) type_ = element.get('type', None) - created_at = element.get('createdAt', None) - started_at = element.get('startedAt', None) - completed_at = element.get('completedAt', None) + progress = element.get('progress', None) + created_at = parse_datetime(element.get('createdAt', None)) + started_at = parse_datetime(element.get('startedAt', None)) + completed_at = parse_datetime(element.get('completedAt', None)) finish_code = element.get('finishCode', -1) - return cls(id_, type_, created_at, started_at, completed_at, finish_code) + return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code) class BackgroundJobItem(object): diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index b0da7b3d0..47d12b662 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -16,6 +16,14 @@ class Roles: ViewerWithPublish = 'ViewerWithPublish' Guest = 'Guest' + Creator = 'Creator' + Explorer = 'Explorer' + ExplorerCanPublish = 'ExplorerCanPublish' + ReadOnly = 'ReadOnly' + SiteAdministratorCreator = 'SiteAdministratorCreator' + SiteAdministratorExplorer = 'SiteAdministratorExplorer' + UnlicensedWithPublish = 'UnlicensedWithPublish' + class Auth: SAML = 'SAML' ServerDefault = 'ServerDefault' @@ -147,3 +155,6 @@ def _parse_element(user_xml, ns): domain_name = domain_elem.get('name', None) return id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name + + def __repr__(self): + return "".format(self.id, self.name, self.site_role) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 5e986f91c..904d27144 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -151,8 +151,9 @@ def refresh(self, datasource_item): # Publish datasource @api(version="2.0") - @parameter_added_in(connections="99.99") - def publish(self, datasource_item, file_path, mode, connection_credentials=None, connections=None): + @parameter_added_in(connections="2.8") + @parameter_added_in(as_job='3.0') + def publish(self, datasource_item, file_path, mode, connection_credentials=None, connections=None, as_job=False): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -175,6 +176,9 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None, if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: url += '&{0}=true'.format(mode.lower()) + if as_job: + url += '&{0}=true'.format('asJob') + # Determine if chunking is required (64MB is the limit for single upload method) if os.path.getsize(file_path) >= FILESIZE_LIMIT: logger.info('Publishing {0} to server with chunking method (datasource over 64MB)'.format(filename)) @@ -193,6 +197,12 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None, connection_credentials, connections) server_response = self.post_request(url, xml_request, content_type) - new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) - return new_datasource + + if as_job: + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) + return new_job + else: + new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) + return new_datasource diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 537e3ec81..79b15f379 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -199,8 +199,9 @@ def _get_wb_preview_image(self, workbook_item): # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") + @parameter_added_in(as_job='3.0') @parameter_added_in(connections='2.8') - def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None): + def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None, as_job=False): if connection_credentials is not None: import warnings @@ -232,6 +233,9 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c error = 'Workbooks cannot be appended.' raise ValueError(error) + if as_job: + url += '&{0}=true'.format('asJob') + # Determine if chunking is required (64MB is the limit for single upload method) if os.path.getsize(file_path) >= FILESIZE_LIMIT: logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(filename)) @@ -253,6 +257,11 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c connections=connections) logger.debug('Request xml: {0} '.format(xml_request[:1000])) server_response = self.post_request(url, xml_request, content_type) - new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) - return new_workbook + if as_job: + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) + return new_job + else: + new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) + return new_workbook diff --git a/test/assets/datasource_publish_async.xml b/test/assets/datasource_publish_async.xml new file mode 100644 index 000000000..a32fccd2a --- /dev/null +++ b/test/assets/datasource_publish_async.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/assets/workbook_publish_async.xml b/test/assets/workbook_publish_async.xml new file mode 100644 index 000000000..21e4e83ed --- /dev/null +++ b/test/assets/workbook_publish_async.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index 9ddf5a3c8..1b21c0194 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -13,6 +13,7 @@ GET_BY_ID_XML = 'datasource_get_by_id.xml' POPULATE_CONNECTIONS_XML = 'datasource_populate_connections.xml' PUBLISH_XML = 'datasource_publish.xml' +PUBLISH_XML_ASYNC = 'datasource_publish_async.xml' UPDATE_XML = 'datasource_update.xml' UPDATE_CONNECTION_XML = 'datasource_connection_update.xml' @@ -178,9 +179,11 @@ def test_publish(self): with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + publish_mode = self.server.PublishMode.CreateNew + new_datasource = self.server.datasources.publish(new_datasource, asset('SampleDS.tds'), - mode=self.server.PublishMode.CreateNew) + mode=publish_mode) self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) self.assertEqual('SampleDS', new_datasource.name) @@ -192,6 +195,24 @@ def test_publish(self): self.assertEqual('default', new_datasource.project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) + def test_publish_async(self): + response_xml = read_xml_asset(PUBLISH_XML_ASYNC) + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + publish_mode = self.server.PublishMode.CreateNew + + new_job = self.server.datasources.publish(new_datasource, + asset('SampleDS.tds'), + mode=publish_mode, + as_job=True) + + self.assertEqual('9a373058-af5f-4f83-8662-98b3e0228a73', new_job.id) + self.assertEqual('PublishDatasource', new_job.type) + self.assertEqual('0', new_job.progress) + self.assertEqual('2018-06-30T00:54:54Z', format_datetime(new_job.created_at)) + self.assertEqual('1', new_job.finish_code) + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', status_code=204) diff --git a/test/test_workbook.py b/test/test_workbook.py index 7aab1279b..d4e2275f4 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -18,6 +18,7 @@ POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views.xml') POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views_usage.xml') PUBLISH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish.xml') +PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish_async.xml') UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') @@ -290,10 +291,17 @@ def test_publish(self): response_xml = f.read().decode('utf-8') with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + + new_workbook = TSC.WorkbookItem(name='Sample', + show_tabs=False, project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - new_workbook = self.server.workbooks.publish(new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx'), - self.server.PublishMode.CreateNew) + + sample_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + new_workbook = self.server.workbooks.publish(new_workbook, + sample_workbok, + publish_mode) self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) self.assertEqual('RESTAPISample', new_workbook.name) @@ -309,10 +317,34 @@ def test_publish(self): self.assertEqual('GDP per capita', new_workbook.views[0].name) self.assertEqual('RESTAPISample_0/sheets/GDPpercapita', new_workbook.views[0].content_url) + def test_publish_async(self): + with open(PUBLISH_ASYNC_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + + new_workbook = TSC.WorkbookItem(name='Sample', + show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + sample_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + new_job = self.server.workbooks.publish(new_workbook, + sample_workbok, + publish_mode, + as_job=True) + + self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) + self.assertEqual('PublishWorkbook', new_job.type) + self.assertEqual('0', new_job.progress) + self.assertEqual('2018-06-29T23:22:32Z', format_datetime(new_job.created_at)) + self.assertEqual('1', new_job.finish_code) + def test_publish_invalid_file(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, - '.', self.server.PublishMode.CreateNew) + self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, '.', + self.server.PublishMode.CreateNew) def test_publish_invalid_file_type(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760')