diff --git a/CHANGELOG.md b/CHANGELOG.md index d8950a4cb..77aab3ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## 0.7 (2 Jul 2018) + +* Added cancel job (#299) +* Added Get background jobs (#298) +* Added Multi-credential support (#276) +* Added Update Groups (#279) +* Adding project_id to view (#285) +* Added ability to rename workbook using `update workbook` (#284) +* Added Sample for exporting full pdf using pdf page combining (#267) +* Added Sample for exporting data, images, and single view pdfs (#263) +* Added view filters to the populate request options (#260) +* Add Async publishing for workbook and datasource endpoints (#311) +* Fixed ability to update datasource server connection port (#283) +* Fixed next project handling (#267) +* Cleanup debugging output to strip out non-xml response +* Improved refresh sample for readability (#288) + ## 0.6.1 (26 Jan 2018) * Fixed #257 where refreshing extracts does not work due to a missing "self" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4143570cf..25ac5718b 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,6 +15,7 @@ The following people have contributed to this project to make it possible, and w * [William Lang](https://github.com/williamlang) * [Jim Morris](https://github.com/jimbodriven) * [BingoDinkus](https://github.com/BingoDinkus) +* [Sergey Sotnichenko](https://github.com/sotnich) ## Core Team diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md index c8beb6c15..68f86321f 100644 --- a/docs/docs/api-ref.md +++ b/docs/docs/api-ref.md @@ -849,6 +849,52 @@ Error | Description

+#### groups.update + +```py +groups.update(group_item, default_site_role=UserItem.Roles.Unlicensed) +``` + +Updates the group on the site. +If domain_name = 'local' then update only the name of the group. +If not - update group from the Active Directory with domain_name. + +REST API: [Update Group](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Update_Group%3FTocPath%3DAPI%2520Reference%7C_____95){:target="_blank"} + + +**Parameters** + +Name | Description +:--- | :--- +`group_item` | the group_item specifies the group to update. +`default_site_role` | if group updates from Active Directory then this is the default role for the new users. + + +**Exceptions** + +Error | Description +:--- | :--- +`Group item missing ID` | Raises an exception if a valid `group_item.id` is not provided. + + +**Example** + +```py +# Update a group + +# import tableauserverclient as TSC +# tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') +# server = TSC.Server('http://SERVERURL') + + with server.auth.sign_in(tableau_auth): + all_groups, pagination_item = server.groups.get() + + for group in all_groups: + server.groups.update(group) +``` +
+
+ #### groups.get ```py diff --git a/samples/download_view_image.py b/samples/download_view_image.py index 2da232061..b95a8628b 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -43,7 +43,7 @@ def main(): tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_id) server = TSC.Server(args.server) # The new endpoint was introduced in Version 2.5 - server.version = 2.5 + server.version = "2.5" with server.auth.sign_in(tableau_auth): # Step 2: Query for the view that we want an image of diff --git a/samples/export.py b/samples/export.py new file mode 100644 index 000000000..67b3319a8 --- /dev/null +++ b/samples/export.py @@ -0,0 +1,74 @@ +import argparse +import getpass +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Export a view as an image, pdf, or csv') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--site', '-S', default=None) + parser.add_argument('-p', default=None) + + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--pdf', dest='type', action='store_const', const=('populate_pdf', 'PDFRequestOptions', 'pdf', + 'pdf')) + group.add_argument('--png', dest='type', action='store_const', const=('populate_image', 'ImageRequestOptions', + 'image', 'png')) + group.add_argument('--csv', dest='type', action='store_const', const=('populate_csv', 'CSVRequestOptions', 'csv', + 'csv')) + + parser.add_argument('--file', '-f', help='filename to store the exported data') + parser.add_argument('--filter', '-vf', metavar='COLUMN:VALUE', + help='View filter to apply to the view') + parser.add_argument('resource_id', help='LUID for the view') + + args = parser.parse_args() + + if args.p is None: + password = getpass.getpass("Password: ") + else: + password = args.p + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.TableauAuth(args.username, password, args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + views = filter(lambda x: x.id == args.resource_id, + TSC.Pager(server.views.get)) + view = views.pop() + + # We have a number of different types and functions for each different export type. + # We encode that information above in the const=(...) parameter to the add_argument function to make + # the code automatically adapt for the type of export the user is doing. + # We unroll that information into methods we can call, or objects we can create by using getattr() + (populate_func_name, option_factory_name, member_name, extension) = args.type + populate = getattr(server.views, populate_func_name) + option_factory = getattr(TSC, option_factory_name) + + if args.filter: + options = option_factory().vf(*args.filter.split(':')) + else: + options = None + if args.file: + filename = args.file + else: + filename = 'out.{}'.format(extension) + + populate(view, options) + with file(filename, 'wb') as f: + if member_name == 'csv': + f.writelines(getattr(view, member_name)) + else: + f.write(getattr(view, member_name)) + + +if __name__ == '__main__': + main() diff --git a/samples/export_wb.py b/samples/export_wb.py new file mode 100644 index 000000000..8d3640ab4 --- /dev/null +++ b/samples/export_wb.py @@ -0,0 +1,92 @@ +# +# This sample uses the PyPDF2 library for combining pdfs together to get the full pdf for all the views in a +# workbook. +# +# You will need to do `pip install PyPDF2` to use this sample. +# + +import argparse +import getpass +import logging +import tempfile +import shutil +import functools +import os.path + +import tableauserverclient as TSC +try: + import PyPDF2 +except ImportError: + print('Please `pip install PyPDF2` to use this sample') + import sys + sys.exit(1) + + +def get_views_for_workbook(server, workbook_id): # -> Iterable of views + workbook = server.workbooks.get_by_id(workbook_id) + server.workbooks.populate_views(workbook) + return workbook.views + + +def download_pdf(server, tempdir, view): # -> Filename to downloaded pdf + logging.info("Exporting {}".format(view.id)) + destination_filename = os.path.join(tempdir, view.id) + server.views.populate_pdf(view) + with file(destination_filename, 'wb') as f: + f.write(view.pdf) + + return destination_filename + + +def combine_into(dest_pdf, filename): # -> None + dest_pdf.append(filename) + return dest_pdf + + +def cleanup(tempdir): + shutil.rmtree(tempdir) + + +def main(): + parser = argparse.ArgumentParser(description='Export to PDF all of the views in a workbook') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-S', default=None, help='Site to log into, do not specify for default site') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--password', '-p', default=None, help='password for the user') + + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + parser.add_argument('--file', '-f', default='out.pdf', help='filename to store the exported data') + parser.add_argument('resource_id', help='LUID for the workbook') + + args = parser.parse_args() + + if args.password is None: + password = getpass.getpass("Password: ") + else: + password = args.password + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tempdir = tempfile.mkdtemp('tsc') + logging.debug("Saving to tempdir: %s", tempdir) + + tableau_auth = TSC.TableauAuth(args.username, password, args.site) + server = TSC.Server(args.server, use_server_version=True) + try: + with server.auth.sign_in(tableau_auth): + get_list = functools.partial(get_views_for_workbook, server) + download = functools.partial(download_pdf, server, tempdir) + + downloaded = (download(x) for x in get_list(args.resource_id)) + output = reduce(combine_into, downloaded, PyPDF2.PdfFileMerger()) + with file(args.file, 'wb') as f: + output.write(f) + finally: + cleanup(tempdir) + + +if __name__ == '__main__': + main() diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py new file mode 100644 index 000000000..9c5f52a50 --- /dev/null +++ b/samples/kill_all_jobs.py @@ -0,0 +1,47 @@ +#### +# This script demonstrates how to kill all of the running jobs +# +# To run the script, you must have installed Python 2.7.X or 3.3 and later. +#### + +import argparse +import getpass +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Cancel all of the running background jobs') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-S', default=None, help='site to log into, do not specify for default site') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--password', '-p', default=None, help='password for the user') + + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + + args = parser.parse_args() + + if args.password is None: + password = getpass.getpass("Password: ") + else: + password = args.password + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + # SIGN IN + tableau_auth = TSC.TableauAuth(args.username, password, args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + req = TSC.RequestOptions() + + req.filter.add(TSC.Filter("progress", TSC.RequestOptions.Operator.LessThanOrEqual, 0)) + for job in TSC.Pager(server.jobs, request_opts=req): + print(server.jobs.cancel(job.id), job.id, job.status, job.type) + + +if __name__ == '__main__': + main() diff --git a/samples/list.py b/samples/list.py index ec2ff9a6b..090d7dfdf 100644 --- a/samples/list.py +++ b/samples/list.py @@ -12,23 +12,23 @@ def main(): - parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + parser = argparse.ArgumentParser(description='List out the names and LUIDs for different resource types') parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-S', default=None, help='site to log into, do not specify for default site') parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--site', '-S', default=None) - parser.add_argument('-p', default=None) + parser.add_argument('--password', '-p', default=None, help='password for the user') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - parser.add_argument('resource_type', choices=['workbook', 'datasource']) + parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job']) args = parser.parse_args() - if args.p is None: + if args.password is None: password = getpass.getpass("Password: ") else: - password = args.p + password = args.password # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) @@ -40,7 +40,10 @@ def main(): with server.auth.sign_in(tableau_auth): endpoint = { 'workbook': server.workbooks, - 'datasource': server.datasources + 'datasource': server.datasources, + 'view': server.views, + 'job': server.jobs, + 'project': server.projects, }.get(args.resource_type) for resource in TSC.Pager(endpoint.get): diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index dea1d5bf0..2d460abaf 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(): @@ -51,14 +52,29 @@ 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) if args.as_job: - new_job = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, as_job=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, as_job=args.as_job) + 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." diff --git a/samples/refresh.py b/samples/refresh.py index dd39bc6f6..73aa7fb2f 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -16,7 +16,7 @@ def main(): parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--site', '-S', default=None) - parser.add_argument('-p', default=None) + parser.add_argument('--password', '-p', default=None, help='if not specified, you will be prompted') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') @@ -26,10 +26,10 @@ def main(): args = parser.parse_args() - if args.p is None: + if args.password is None: password = getpass.getpass("Password: ") else: - password = args.p + password = args.password # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) @@ -39,15 +39,21 @@ def main(): tableau_auth = TSC.TableauAuth(args.username, password, args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - endpoint = { - 'workbook': server.workbooks, - 'datasource': server.datasources - }.get(args.resource_type) + if args.resource_type == "workbook": + # Get the workbook by its Id to make sure it exists + resource = server.workbooks.get_by_id(args.resource_id) - refresh_func = endpoint.refresh - resource = endpoint.get_by_id(args.resource_id) + # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done + results = server.workbooks.refresh(resource) + else: + # Get the datasource by its Id to make sure it exists + resource = server.datasources.get_by_id(args.resource_id) - print(refresh_func(resource)) + # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done + results = server.datasources.refresh(resource) + + print(results) + # TODO: Add a flag that will poll and wait for the returned job to be done if __name__ == '__main__': diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index c5840d7b6..3f2970281 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,10 +1,10 @@ from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .models import ConnectionCredentials, ConnectionItem, DatasourceItem,\ - GroupItem, JobItem, PaginationItem, ProjectItem, ScheduleItem, \ + GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ SubscriptionItem -from .server import RequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ +from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager from ._version import get_versions __version__ = get_versions()['version'] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 1ff6be869..710831e07 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -4,7 +4,7 @@ from .exceptions import UnpopulatedPropertyError from .group_item import GroupItem from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval -from .job_item import JobItem +from .job_item import JobItem, BackgroundJobItem from .pagination_item import PaginationItem from .project_item import ProjectItem from .schedule_item import ScheduleItem diff --git a/tableauserverclient/models/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/models/job_item.py b/tableauserverclient/models/job_item.py index 88c9057cf..6ad7f0256 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET +from ..datetime_helpers import parse_datetime from .target import Target from ..datetime_helpers import parse_datetime @@ -65,3 +66,87 @@ def _parse_element(cls, element, ns): completed_at = parse_datetime(element.get('completedAt', None)) finish_code = element.get('finishCode', -1) return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code) + + +class BackgroundJobItem(object): + class Status: + Pending = "Pending" + InProgress = "InProgress" + Success = "Success" + Failed = "Failed" + Cancelled = "Cancelled" + + def __init__(self, id_, created_at, priority, job_type, status, title=None, subtitle=None, started_at=None, + ended_at=None): + self._id = id_ + self._type = job_type + self._status = status + self._created_at = created_at + self._started_at = started_at + self._ended_at = ended_at + self._priority = priority + self._title = title + self._subtitle = subtitle + + @property + def id(self): + return self._id + + @property + def name(self): + """For API consistency - all other resource endpoints have a name attribute which is used to display what + they are. Alias title as name to allow consistent handling of resources in the list sample.""" + return self._title + + @property + def status(self): + return self._status + + @property + def type(self): + return self._type + + @property + def created_at(self): + return self._created_at + + @property + def started_at(self): + return self._started_at + + @property + def ended_at(self): + return self._ended_at + + @property + def title(self): + return self._title + + @property + def subtitle(self): + return self._subtitle + + @property + def priority(self): + return self._priority + + @classmethod + def from_response(cls, xml, ns): + parsed_response = ET.fromstring(xml) + all_tasks_xml = parsed_response.findall( + './/t:backgroundJob', namespaces=ns) + return [cls._parse_element(x, ns) for x in all_tasks_xml] + + @classmethod + def _parse_element(cls, element, ns): + id_ = element.get('id', None) + type_ = element.get('jobType', None) + status = element.get('status', None) + created_at = parse_datetime(element.get('createdAt', None)) + started_at = parse_datetime(element.get('startedAt', None)) + ended_at = parse_datetime(element.get('endedAt', None)) + priority = element.get('priority', None) + title = element.get('title', None) + subtitle = element.get('subtitle', None) + + return cls(id_, created_at, priority, type_, status, title, subtitle, started_at, ended_at) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 91b82ef0e..92e0282ae 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -80,6 +80,6 @@ def _parse_element(project_xml): name = project_xml.get('name', None) description = project_xml.get('description', None) content_permissions = project_xml.get('contentPermissions', None) - parent_id = project_xml.get('parentId', None) + parent_id = project_xml.get('parentProjectId', None) return id, name, description, content_permissions, parent_id diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index a8c1a6988..1fc6d4e8e 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -11,6 +11,7 @@ def __init__(self): self._name = None self._owner_id = None self._preview_image = None + self._project_id = None self._pdf = None self._csv = None self._total_views = None @@ -59,6 +60,10 @@ def preview_image(self): raise UnpopulatedPropertyError(error) return self._preview_image() + @property + def project_id(self): + return self._project_id + @property def pdf(self): if self._pdf is None: @@ -97,6 +102,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): usage_elem = view_xml.find('.//t:usage', namespaces=ns) workbook_elem = view_xml.find('.//t:workbook', namespaces=ns) owner_elem = view_xml.find('.//t:owner', namespaces=ns) + project_elem = view_xml.find('.//t:project', namespaces=ns) view_item._id = view_xml.get('id', None) view_item._name = view_xml.get('name', None) view_item._content_url = view_xml.get('contentUrl', None) @@ -107,10 +113,14 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): if owner_elem is not None: view_item._owner_id = owner_elem.get('id', None) - all_view_items.append(view_item) + + if project_elem is not None: + view_item._project_id = project_elem.get('id', None) if workbook_id: view_item._workbook_id = workbook_id elif workbook_elem is not None: view_item._workbook_id = workbook_elem.get('id', None) + + all_view_items.append(view_item) return all_view_items diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 12a640723..8c5cb314c 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -1,8 +1,8 @@ from .request_factory import RequestFactory -from .request_options import ImageRequestOptions, PDFRequestOptions, RequestOptions +from .request_options import CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions from .filter import Filter from .sort import Sort -from .. import ConnectionItem, DatasourceItem, JobItem, \ +from .. import ConnectionItem, DatasourceItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 30fce963a..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="2.8") @parameter_added_in(as_job='3.0') - def publish(self, datasource_item, file_path, mode, connection_credentials=None, as_job=False): + 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) @@ -184,7 +185,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: @@ -192,7 +194,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) if as_job: diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index deaa94a30..994d2133d 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -27,6 +27,17 @@ def _make_common_headers(auth_token, content_type): return headers + @staticmethod + def _safe_to_log(server_response): + """Checks if the server_response content is not xml (eg binary image or zip) + and replaces it with a constant + """ + ALLOWED_CONTENT_TYPES = ('application/xml', 'application/xml;charset=utf-8') + if server_response.headers.get('Content-Type', None) not in ALLOWED_CONTENT_TYPES: + return '[Truncated File Contents]' + else: + return server_response.content + def _make_request(self, method, url, content=None, request_object=None, auth_token=None, content_type=None, parameters=None): if request_object is not None: @@ -50,7 +61,7 @@ def _make_request(self, method, url, content=None, request_object=None, return server_response def _check_status(self, server_response): - logger.debug(server_response.content) + logger.debug(self._safe_to_log(server_response)) if server_response.status_code not in Success_codes: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) @@ -65,7 +76,7 @@ def delete_request(self, url): # We don't return anything for a delete self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) - def put_request(self, url, xml_request, content_type='text/xml'): + def put_request(self, url, xml_request=None, content_type='text/xml'): return self._make_request(self.parent_srv.session.put, url, content=xml_request, auth_token=self.parent_srv.auth_token, @@ -79,7 +90,7 @@ def post_request(self, url, xml_request, content_type='text/xml'): def api(version): - '''Annotate the minimum supported version for an endpoint. + """Annotate the minimum supported version for an endpoint. Checks the version on the server object and compares normalized versions. It will raise an exception if the server version is > the version specified. @@ -95,23 +106,18 @@ def api(version): >>> @api(version="2.3") >>> def get(self, req_options=None): >>> ... - ''' + """ def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): - server_version = Version(self.parent_srv.version or "0.0") - minimum_supported = Version(version) - if server_version < minimum_supported: - error = "This endpoint is not available in API version {}. Requires {}".format( - server_version, minimum_supported) - raise EndpointUnavailableError(error) + self.parent_srv.assert_at_least_version(version) return func(self, *args, **kwargs) return wrapper return _decorator def parameter_added_in(**params): - '''Annotate minimum versions for new parameters or request options on an endpoint. + """Annotate minimum versions for new parameters or request options on an endpoint. The api decorator documents when an endpoint was added, this decorator annotates keyword arguments on endpoints that may control functionality added after an endpoint was introduced. @@ -131,7 +137,7 @@ def parameter_added_in(**params): >>> @parameter_added_in(no_extract='2.5') >>> def download(self, workbook_id, filepath=None, extract_only=False): >>> ... - ''' + """ def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index bee595b25..2428ff9be 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -7,6 +7,8 @@ logger = logging.getLogger('tableau.endpoint.groups') +UNLICENSED_USER = UserItem.Roles.Unlicensed + class Groups(Endpoint): @property @@ -55,6 +57,18 @@ def delete(self, group_id): self.delete_request(url) logger.info('Deleted single group (ID: {0})'.format(group_id)) + @api(version="2.0") + def update(self, group_item, default_site_role=UNLICENSED_USER): + if not group_item.id: + error = "Group item missing ID." + raise MissingRequiredFieldError(error) + url = "{0}/{1}".format(self.baseurl, group_item.id) + update_req = RequestFactory.Group.update_req(group_item, default_site_role) + server_response = self.put_request(url, update_req) + logger.info('Updated group item (ID: {0})'.format(group_item.id)) + updated_group = GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return updated_group + # Create a 'local' Tableau group @api(version="2.0") def create(self, group_item): diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 243b04d63..f3432d605 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,5 +1,6 @@ from .endpoint import Endpoint, api -from .. import JobItem +from .. import JobItem, BackgroundJobItem, PaginationItem +from ..request_options import RequestOptionsBase import logging logger = logging.getLogger('tableau.endpoint.jobs') @@ -11,7 +12,28 @@ def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version='2.6') - def get(self, job_id): + def get(self, job_id=None, req_options=None): + # Backwards Compatibility fix until we rev the major version + if job_id is not None and isinstance(job_id, basestring): + import warnings + warnings.warn("Jobs.get(job_id) is deprecated, update code to use Jobs.get_by_id(job_id)") + return self.get_by_id(job_id) + if isinstance(job_id, RequestOptionsBase): + req_options = job_id + + self.parent_srv.assert_at_least_version('3.1') + server_response = self.get_request(self.baseurl, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + jobs = BackgroundJobItem.from_response(server_response.content, self.parent_srv.namespace) + return jobs, pagination_item + + @api(version='3.1') + def cancel(self, job_id): + url = '{0}/{1}'.format(self.baseurl, job_id) + return self.put_request(url) + + @api(version='2.6') + def get_by_id(self, job_id): logger.info('Query for information about job ' + job_id) url = "{0}/{1}".format(self.baseurl, job_id) server_response = self.get_request(url) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 0335ce781..62cd3af50 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -105,7 +105,7 @@ def csv_fetcher(): def _get_view_csv(self, view_item, req_options): url = "{0}/{1}/data".format(self.baseurl, view_item.id) - with closing(self.get_request(url, parameters={"stream": True})) as server_response: + with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: csv = server_response.iter_content(1024) return csv diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 72936b277..79b15f379 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -200,7 +200,14 @@ 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') - def publish(self, workbook_item, file_path, mode, connection_credentials=None, as_job=False): + @parameter_added_in(connections='2.8') + def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None, as_job=False): + + 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) @@ -234,16 +241,21 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, a 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) if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c4f10d731..d479807d2 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) @@ -107,6 +129,17 @@ def create_req(self, group_item): group_element.attrib['name'] = group_item.name return ET.tostring(xml_request) + def update_req(self, group_item, default_site_role): + xml_request = ET.Element('tsRequest') + group_element = ET.SubElement(xml_request, 'group') + group_element.attrib['name'] = group_item.name + if group_item.domain_name != 'local': + project_element = ET.SubElement(group_element, 'import') + project_element.attrib['source'] = "ActiveDirectory" + project_element.attrib['domainName'] = group_item.domain_name + project_element.attrib['siteRole'] = default_site_role + return ET.tostring(xml_request) + class PermissionRequest(object): def _add_capability(self, parent_element, capability_set, mode): @@ -157,7 +190,7 @@ def create_req(self, project_item): if project_item.content_permissions: project_element.attrib['contentPermissions'] = project_item.content_permissions if project_item.parent_id: - project_element.attrib['parentId'] = project_item.parent_id + project_element.attrib['parentProjectId'] = project_item.parent_id return ET.tostring(xml_request) @@ -313,7 +346,7 @@ def add_req(self, user_item): class WorkbookRequest(object): - def _generate_xml(self, workbook_item, connection_credentials=None): + def _generate_xml(self, workbook_item, connection_credentials=None, connections=None): xml_request = ET.Element('tsRequest') workbook_element = ET.SubElement(xml_request, 'workbook') workbook_element.attrib['name'] = workbook_item.name @@ -321,19 +354,24 @@ 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): xml_request = ET.Element('tsRequest') workbook_element = ET.SubElement(xml_request, 'workbook') + if workbook_item.name: + workbook_element.attrib['name'] = workbook_item.name if workbook_item.show_tabs: workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() if workbook_item.project_id: @@ -344,15 +382,19 @@ def update_req(self, workbook_item): owner_element.attrib['id'] = workbook_item.owner_id return ET.tostring(xml_request) - def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None): - xml_request = self._generate_xml(workbook_item, connection_credentials) + def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None, connections=None): + xml_request = self._generate_xml(workbook_item, + connection_credentials=connection_credentials, + connections=connections) parts = {'request_payload': ('', xml_request, 'text/xml'), 'tableau_workbook': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) - def publish_req_chunked(self, workbook_item, connection_credentials=None): - xml_request = self._generate_xml(workbook_item, connection_credentials) + def publish_req_chunked(self, workbook_item, connections=None): + xml_request = self._generate_xml(workbook_item, + connection_credentials=connection_credentials, + connections=connections) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) @@ -365,7 +407,7 @@ def update_req(self, xml_request, connection_item): if connection_item.server_address: connection_element.attrib['serverAddress'] = connection_item.server_address.lower() if connection_item.server_port: - connection_element.attrib['port'] = str(connection_item.server_port) + connection_element.attrib['serverPort'] = str(connection_item.server_port) if connection_item.username: connection_element.attrib['userName'] = connection_item.username if connection_item.password: diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 37f23f54c..0e3601a25 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -13,13 +13,30 @@ class Operator: In = 'in' class Field: + Args = 'args' + CompletedAt = 'completedAt' CreatedAt = 'createdAt' + DomainName = 'domainName' + DomainNickname = 'domainNickname' + HitsTotal = 'hitsTotal' + IsLocal = 'isLocal' + JobType = 'jobType' LastLogin = 'lastLogin' + MinimumSiteRole = 'minimumSiteRole' Name = 'name' + Notes = 'notes' + OwnerDomain = 'ownerDomain' + OwnerEmail = 'ownerEmail' OwnerName = 'ownerName' + Progress = 'progress' + ProjectName = 'projectName' SiteRole = 'siteRole' + Subtitle = 'subtitle' Tags = 'tags' + Title = 'title' + Type = 'type' UpdatedAt = 'updatedAt' + UserCount = 'userCount' class Direction: Desc = 'desc' @@ -62,12 +79,37 @@ def apply_query_params(self, url): return "{0}?{1}".format(url, '&'.join(params)) -class ImageRequestOptions(RequestOptionsBase): +class _FilterOptionsBase(RequestOptionsBase): + """ Provide a basic implementation of adding view filters to the url """ + def __init__(self): + self.view_filters = [] + + def apply_query_params(self, url): + raise NotImplementedError() + + def vf(self, name, value): + self.view_filters.append((name, value)) + return self + + def _append_view_filters(self, params): + for name, value in self.view_filters: + params.append('vf_{}={}'.format(name, value)) + + +class CSVRequestOptions(_FilterOptionsBase): + def apply_query_params(self, url): + params = [] + self._append_view_filters(params) + return "{0}?{1}".format(url, '&'.join(params)) + + +class ImageRequestOptions(_FilterOptionsBase): # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: High = 'high' def __init__(self, imageresolution=None): + super(ImageRequestOptions, self).__init__() self.image_resolution = imageresolution def apply_query_params(self, url): @@ -75,11 +117,12 @@ def apply_query_params(self, url): if self.image_resolution: params.append('resolution={0}'.format(self.image_resolution)) + self._append_view_filters(params) + return "{0}?{1}".format(url, '&'.join(params)) -class PDFRequestOptions(RequestOptionsBase): - # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution +class PDFRequestOptions(_FilterOptionsBase): class PageType: A3 = "a3" A4 = "a4" @@ -100,6 +143,7 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None): + super(PDFRequestOptions, self).__init__() self.page_type = page_type self.orientation = orientation @@ -111,4 +155,6 @@ def apply_query_params(self, url): if self.orientation: params.append('orientation={0}'.format(self.orientation)) + self._append_view_filters(params) + return "{0}?{1}".format(url, '&'.join(params)) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 0c2b4f1c2..95ee564ee 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -4,9 +4,15 @@ from ..namespace import Namespace from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs +from .endpoint.exceptions import EndpointUnavailableError import requests +try: + from distutils2.version import NormalizedVersion as Version +except ImportError: + from distutils.version import LooseVersion as Version + _PRODUCT_TO_REST_VERSION = { '10.0': '2.3', '9.3': '2.2', @@ -94,6 +100,14 @@ def use_highest_version(self): import warnings warnings.warn("use use_server_version instead", DeprecationWarning) + def assert_at_least_version(self, version): + server_version = Version(self.version or "0.0") + minimum_supported = Version(version) + if server_version < minimum_supported: + error = "This endpoint is not available in API version {}. Requires {}".format( + server_version, minimum_supported) + raise EndpointUnavailableError(error) + @property def baseurl(self): return "{0}/api/{1}".format(self._server_address, str(self.version)) diff --git a/test/assets/datasource_connection_update.xml b/test/assets/datasource_connection_update.xml index 0e4d21ed0..5b84616dd 100644 --- a/test/assets/datasource_connection_update.xml +++ b/test/assets/datasource_connection_update.xml @@ -2,4 +2,4 @@ \ No newline at end of file + type="textscan" serverAddress="bar" serverPort="9876" userName="foo"/> \ No newline at end of file diff --git a/test/assets/group_update.xml b/test/assets/group_update.xml new file mode 100644 index 000000000..b5dba4bc6 --- /dev/null +++ b/test/assets/group_update.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/test/assets/job_get.xml b/test/assets/job_get.xml new file mode 100644 index 000000000..4a9f271cc --- /dev/null +++ b/test/assets/job_get.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/test/assets/project_create.xml b/test/assets/project_create.xml index ebfada762..5cd29d954 100644 --- a/test/assets/project_create.xml +++ b/test/assets/project_create.xml @@ -1,4 +1,4 @@ - + diff --git a/test/assets/project_get.xml b/test/assets/project_get.xml index 12133f432..777412b30 100644 --- a/test/assets/project_get.xml +++ b/test/assets/project_get.xml @@ -4,6 +4,6 @@ - + diff --git a/test/assets/project_update.xml b/test/assets/project_update.xml index 0307e7c18..eaa884627 100644 --- a/test/assets/project_update.xml +++ b/test/assets/project_update.xml @@ -1,4 +1,4 @@ - + diff --git a/test/assets/view_get.xml b/test/assets/view_get.xml index c8e0601bd..36f43e255 100644 --- a/test/assets/view_get.xml +++ b/test/assets/view_get.xml @@ -5,10 +5,12 @@ + + \ No newline at end of file diff --git a/test/assets/workbook_update.xml b/test/assets/workbook_update.xml index 9c9674700..2470347a8 100644 --- a/test/assets/workbook_update.xml +++ b/test/assets/workbook_update.xml @@ -1,6 +1,6 @@ - + diff --git a/test/test_datasource.py b/test/test_datasource.py index 6022ce50e..1b21c0194 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' @@ -162,10 +164,14 @@ def test_update_connection(self): self.server.datasources.populate_connections(single_datasource) connection = single_datasource.connections[0] + connection.server_address = 'bar' + connection.server_port = '9876' connection.username = 'foo' new_connection = self.server.datasources.update_connection(single_datasource, connection) self.assertEqual(connection.id, new_connection.id) self.assertEqual(connection.connection_type, new_connection.connection_type) + self.assertEquals('bar', new_connection.server_address) + self.assertEquals('9876', new_connection.server_port) self.assertEqual('foo', new_connection.username) def test_publish(self): @@ -262,3 +268,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_group.py b/test/test_group.py index 244ba47b8..7096ca408 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -14,6 +14,7 @@ ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, 'group_users_added.xml') CREATE_GROUP = os.path.join(TEST_ASSET_DIR, 'group_create.xml') CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, 'group_create_async.xml') +UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'group_update.xml') class GroupTests(unittest.TestCase): @@ -183,3 +184,16 @@ def test_create_group(self): group = self.server.groups.create(group_to_create) self.assertEqual(group.name, u'試供品') self.assertEqual(group.id, '3e4a9ea0-a07a-4fe6-b50f-c345c8c81034') + + def test_update(self): + with open(UPDATE_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.put(self.baseurl + '/ef8b19c0-43b6-11e6-af50-63f5805dbe3c', text=response_xml) + group = TSC.GroupItem(name='Test Group') + group._domain_name = 'local' + group._id = 'ef8b19c0-43b6-11e6-af50-63f5805dbe3c' + group = self.server.groups.update(group) + + self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', group.id) + self.assertEqual('Group updated name', group.name) diff --git a/test/test_job.py b/test/test_job.py new file mode 100644 index 000000000..5da0f76fa --- /dev/null +++ b/test/test_job.py @@ -0,0 +1,51 @@ +import unittest +import os +from datetime import datetime +import requests_mock +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import utc + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') + +GET_XML = os.path.join(TEST_ASSET_DIR, 'job_get.xml') + + +class JobTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.server.version = '3.1' + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = self.server.jobs.baseurl + + def test_get(self): + with open(GET_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_jobs, pagination_item = self.server.jobs.get() + job = all_jobs[0] + created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) + started_at = datetime(2018, 5, 22, 13, 0, 37, tzinfo=utc) + ended_at = datetime(2018, 5, 22, 13, 0, 45, tzinfo=utc) + + self.assertEquals(1, pagination_item.total_available) + self.assertEquals('2eef4225-aa0c-41c4-8662-a76d89ed7336', job.id) + self.assertEquals('Success', job.status) + self.assertEquals('50', job.priority) + self.assertEquals('single_subscription_notify', job.type) + self.assertEquals(created_at, job.created_at) + self.assertEquals(started_at, job.started_at) + self.assertEquals(ended_at, job.ended_at) + + def test_get_before_signin(self): + self.server._auth_token = None + self.assertRaises(TSC.NotSignedInError, self.server.jobs.get) + + def test_cancel(self): + with requests_mock.mock() as m: + m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + self.server.jobs.cancel('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 95bdceacb..8958c3cf8 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,8 +1,23 @@ import unittest import tableauserverclient.server.request_factory as factory +from tableauserverclient.server.endpoint import Endpoint class BugFix257(unittest.TestCase): def test_empty_request_works(self): result = factory.EmptyRequest().empty_req() self.assertEqual(b'', result) + + +class BugFix273(unittest.TestCase): + def test_binary_log_truncated(self): + + class FakeResponse(object): + + headers = {'Content-Type': 'application/octet-stream'} + content = b'\x1337' * 1000 + status_code = 200 + + server_response = FakeResponse() + + self.assertEqual(Endpoint._safe_to_log(server_response), '[Truncated File Contents]') diff --git a/test/test_view.py b/test/test_view.py index 09ce2f3d7..292f86887 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -39,12 +39,14 @@ def test_get(self): self.assertEqual('SafariSample/sheets/ENDANGEREDSAFARI', all_views[0].content_url) self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_views[0].workbook_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[0].owner_id) + self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_views[0].project_id) self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id) self.assertEqual('Overview', all_views[1].name) self.assertEqual('Superstore/sheets/Overview', all_views[1].content_url) self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_views[1].workbook_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[1].owner_id) + self.assertEqual('5b534f74-3226-11e8-b47a-cb2e00f738a3', all_views[1].project_id) def test_get_with_usage(self): with open(GET_XML_USAGE, 'rb') as f: diff --git a/test/test_workbook.py b/test/test_workbook.py index 9657997d3..d4e2275f4 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') @@ -115,12 +118,14 @@ def test_update(self): single_workbook = TSC.WorkbookItem('1d0304cd-3796-429f-b815-7258370b9b74', show_tabs=True) single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' single_workbook.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + single_workbook.name = 'renamedWorkbook' single_workbook = self.server.workbooks.update(single_workbook) self.assertEqual('1f951daf-4061-451a-9df1-69a8062664f2', single_workbook.id) self.assertEqual(True, single_workbook.show_tabs) self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_workbook.project_id) self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_workbook.owner_id) + self.assertEqual('renamedWorkbook', single_workbook.name) def test_update_missing_id(self): single_workbook = TSC.WorkbookItem('test') @@ -346,3 +351,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])