diff --git a/.travis.yml b/.travis.yml index cc261b20c..68cee02ad 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ dist: xenial language: python python: - - "2.7" - "3.5" - "3.6" - "3.7" diff --git a/CHANGELOG.md b/CHANGELOG.md index a2881e514..6189ab78b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 0.10 (21 Feb 2020) + +* Added a way to handle non-xml errors (#515) +* Added Webhooks endpoints for create, delete, get, list, and test (#523, #532) +* Added delete method in the tasks endpoint (#524) +* Added description attribute to WorkbookItem (#533) +* Added support for materializeViews as schedule and task types (#542) +* Added warnings to schedules (#550, #551) +* Added ability to update parent_id attribute of projects (#560, #567) +* Improved filename behavior for download endpoints (#517) +* Improved logging (#508) +* Fixed runtime error in permissions endpoint (#513) +* Fixed move_workbook_sites sample (#503) +* Fixed project permissions endpoints (#527) +* Fixed login.py sample to accept site name (#549) + ## 0.9 (4 Oct 2019) * Added Metadata API endpoints (#431) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8022c5f49..a23213598 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -28,6 +28,12 @@ The following people have contributed to this project to make it possible, and w * [Christian Oliff](https://github.com/coliff) * [Albin Antony](https://github.com/user9747) * [prae04](https://github.com/prae04) +* [Martin Peters](https://github.com/martinbpeters) +* [Sherman K](https://github.com/shrmnk) +* [Jorge Fonseca](https://github.com/JorgeFonseca) +* [Kacper Wolkiewicz](https://github.com/wolkiewiczk) +* [Dahai Guo](https://github.com/guodah) +* [Geraldine Zanolli](https://github.com/illonage) ## Core Team @@ -41,4 +47,3 @@ The following people have contributed to this project to make it possible, and w * [Priya Reguraman](https://github.com/preguraman) * [Jac Fitzgerald](https://github.com/jacalata) * [Dan Zucker](https://github.com/dzucker-tab) -* [Irwin Dolobowsky](https://github.com/irwando) diff --git a/contributing.md b/contributing.md index c95191e0e..4c7cdef00 100644 --- a/contributing.md +++ b/contributing.md @@ -53,3 +53,14 @@ creating a PR can be found in the [Development Guide](https://tableau.github.io/ If the feature is complex or has multiple solutions that could be equally appropriate approaches, it would be helpful to file an issue to discuss the design trade-offs of each solution before implementing, to allow us to collectively arrive at the best solution, which most likely exists in the middle somewhere. + + +## Getting Started +> pip install versioneer +> python setup.py build +> python setup.py test +> + +### before committing +Our CI runs include a python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin +> pycodestyle tableauserverclient test samples diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py new file mode 100644 index 000000000..ab94f7195 --- /dev/null +++ b/samples/explore_webhooks.py @@ -0,0 +1,82 @@ +#### +# This script demonstrates how to use the Tableau Server Client +# to interact with webhooks. It explores the different +# functions that the Server API supports on webhooks. +# +# With no flags set, this sample will query all webhooks, +# pick one webhook and print the name of the webhook. +# Adding flags will demonstrate the specific feature +# on top of the general operations. +#### + +import argparse +import getpass +import logging +import os.path + +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description='Explore webhook functions supported by the Server API.') + 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, help='password') + parser.add_argument('--create', '-c', help='create a webhook') + parser.add_argument('--delete', '-d', help='delete a webhook', action='store_true') + 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.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) + + # SIGN IN + tableau_auth = TSC.TableauAuth(args.username, password, args.site) + print("Signing in to " + args.server + " [" + args.site + "] as " + args.username) + server = TSC.Server(args.server) + + # Set http options to disable verifying SSL + server.add_http_options({'verify': False}) + + server.use_server_version() + + with server.auth.sign_in(tableau_auth): + + # Create webhook if create flag is set (-create, -c) + if args.create: + + new_webhook = TSC.WebhookItem() + new_webhook.name = args.create + new_webhook.url = "https://ifttt.com/maker-url" + new_webhook.event = "datasource-created" + print(new_webhook) + new_webhook = server.webhooks.create(new_webhook) + print("Webhook created. ID: {}".format(new_webhook.id)) + + # Gets all webhook items + all_webhooks, pagination_item = server.webhooks.get() + print("\nThere are {} webhooks on site: ".format(pagination_item.total_available)) + print([webhook.name for webhook in all_webhooks]) + + if all_webhooks: + # Pick one webhook from the list and delete it + sample_webhook = all_webhooks[0] + # sample_webhook.delete() + print("+++"+sample_webhook.name) + + if (args.delete): + print("Deleting webhook " + sample_webhook.name) + server.webhooks.delete(sample_webhook.id) + + +if __name__ == '__main__': + main() diff --git a/samples/list.py b/samples/list.py index 090d7dfdf..e103eb862 100644 --- a/samples/list.py +++ b/samples/list.py @@ -7,6 +7,8 @@ import argparse import getpass import logging +import os +import sys import tableauserverclient as TSC @@ -14,28 +16,27 @@ def main(): 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('--password', '-p', default=None, help='password for the user') + parser.add_argument('--site', '-S', default="", help='site to log into, do not specify for default site') + parser.add_argument('--token-name', '-n', required=True, help='username to signin under') + parser.add_argument('--token', '-t', help='personal access token for logging in') 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', 'project', 'view', 'job']) + parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job', 'webhooks']) args = parser.parse_args() - - if args.password is None: - password = getpass.getpass("Password: ") - else: - password = args.password + token = os.environ.get('TOKEN', args.token) + if not token: + print("--token or TOKEN environment variable needs to be set") + sys.exit(1) # 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) + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, token, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): endpoint = { @@ -44,6 +45,7 @@ def main(): 'view': server.views, 'job': server.jobs, 'project': server.projects, + 'webhooks': server.webhooks, }.get(args.resource_type) for resource in TSC.Pager(endpoint.get): diff --git a/samples/login.py b/samples/login.py index aaa21ab25..d3862503d 100644 --- a/samples/login.py +++ b/samples/login.py @@ -22,6 +22,7 @@ def main(): group = parser.add_mutually_exclusive_group(required=True) group.add_argument('--username', '-u', help='username to sign into the server') group.add_argument('--token-name', '-n', help='name of the personal access token used to sign into the server') + parser.add_argument('--sitename', '-S', default=None) args = parser.parse_args() @@ -41,9 +42,9 @@ def main(): else: # Trying to authenticate using personal access tokens. - personal_access_token = getpass.getpass("Personal Access Token: ") + personal_access_token = input("Personal Access Token: ") tableau_auth = TSC.PersonalAccessTokenAuth(token_name=args.token_name, - personal_access_token=personal_access_token) + personal_access_token=personal_access_token, site_id=args.sitename) with server.auth.sign_in_with_personal_access_token(tableau_auth): print('Logged in successfully') diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index d81c96767..40f0350e5 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -58,7 +58,7 @@ def main(): workbook_path = source_server.workbooks.download(all_workbooks[0].id, tmpdir) # Step 4: Check if destination site exists, then sign in to the site - pagination_info, all_sites = source_server.sites.get() + all_sites, pagination_info = source_server.sites.get() found_destination_site = any((True for site in all_sites if args.destination_site.lower() == site.content_url.lower())) if not found_destination_site: @@ -71,21 +71,14 @@ def main(): # because of the different auth token and site ID. with dest_server.auth.sign_in(tableau_auth): - # Step 5: Find destination site's default project - pagination_info, dest_projects = dest_server.projects.get() - target_project = next((project for project in dest_projects if project.is_default()), None) - - # Step 6: If default project is found, form a new workbook item and publish. - if target_project is not None: - new_workbook = TSC.WorkbookItem(name=args.workbook_name, project_id=target_project.id) - new_workbook = dest_server.workbooks.publish(new_workbook, workbook_path, - mode=TSC.Server.PublishMode.Overwrite) - print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) - else: - error = "The default project could not be found." - raise LookupError(error) - - # Step 7: Delete workbook from source site and delete temp directory + # Step 5: Create a new workbook item and publish workbook. Note that + # an empty project_id will publish to the 'Default' project. + new_workbook = TSC.WorkbookItem(name=args.workbook_name, project_id="") + new_workbook = dest_server.workbooks.publish(new_workbook, workbook_path, + mode=TSC.Server.PublishMode.Overwrite) + print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) + + # Step 6: Delete workbook from source site and delete temp directory source_server.workbooks.delete(all_workbooks[0].id) finally: diff --git a/setup.py b/setup.py index a7b29aa90..82c611f0f 100644 --- a/setup.py +++ b/setup.py @@ -26,10 +26,11 @@ setup_requires=pytest_runner, install_requires=[ 'requests>=2.11,<3.0', - 'urllib3==1.24.3' + 'urllib3>=1.24.3,<2.0' ], tests_require=[ 'requests-mock>=1.0,<2.0', - 'pytest' + 'pytest', + 'mock' ] ) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index eb647ed25..bb938c8fa 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -3,7 +3,8 @@ GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem,\ SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError,\ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem,\ - SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem, FlowItem + SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem, FlowItem, \ + WebhookItem, PersonalAccessTokenAuth from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager from ._version import get_versions diff --git a/tableauserverclient/filesys_helpers.py b/tableauserverclient/filesys_helpers.py index 0cf304b32..9d0b443bf 100644 --- a/tableauserverclient/filesys_helpers.py +++ b/tableauserverclient/filesys_helpers.py @@ -1,6 +1,22 @@ +import os ALLOWED_SPECIAL = (' ', '.', '_', '-') def to_filename(string_to_sanitize): sanitized = (c for c in string_to_sanitize if c.isalnum() or c in ALLOWED_SPECIAL) return "".join(sanitized) + + +def make_download_path(filepath, filename): + download_path = None + + if filepath is None: + download_path = filename + + elif os.path.isdir(filepath): + download_path = os.path.join(filepath, filename) + + else: + download_path = filepath + os.path.splitext(filename)[1] + + return download_path diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index a3517e13f..172877060 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -23,3 +23,5 @@ from .workbook_item import WorkbookItem from .subscription_item import SubscriptionItem from .permissions_item import PermissionsRule, Permission +from .webhook_item import WebhookItem +from .personal_access_token_auth import PersonalAccessTokenAuth diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 545679945..98d6b42f9 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -29,3 +29,12 @@ def from_response(cls, resp, ns): pagination_item._page_size = int(pagination_xml.get('pageSize', '-1')) pagination_item._total_available = int(pagination_xml.get('totalAvailable', '-1')) return pagination_item + + @classmethod + def from_single_page_list(cls, l): + item = cls() + item._page_number = 1 + item._page_size = len(l) + item._total_available = len(l) + + return item diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py index c9b892cf6..875f68c48 100644 --- a/tableauserverclient/models/personal_access_token_auth.py +++ b/tableauserverclient/models/personal_access_token_auth.py @@ -9,3 +9,6 @@ def __init__(self, token_name, personal_access_token, site_id=''): @property def credentials(self): return {'personalAccessTokenName': self.token_name, 'personalAccessTokenSecret': self.personal_access_token} + + def __repr__(self): + return "".format(self.token_name, self.personal_access_token) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 11c403764..5ece2f8fe 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -11,6 +11,7 @@ class Type: Extract = "Extract" Flow = "Flow" Subscription = "Subscription" + MaterializeViews = "MaterializeViews" class ExecutionOrder: Parallel = "Parallel" @@ -102,6 +103,10 @@ def state(self, value): def updated_at(self): return self._updated_at + @property + def warnings(self): + return self._warnings + def _parse_common_tags(self, schedule_xml, ns): if not isinstance(schedule_xml, ET.Element): schedule_xml = ET.fromstring(schedule_xml).find('.//t:schedule', namespaces=ns) @@ -124,7 +129,7 @@ def _parse_common_tags(self, schedule_xml, ns): return self def _set_values(self, id_, name, state, created_at, updated_at, schedule_type, - next_run_at, end_schedule_at, execution_order, priority, interval_item): + next_run_at, end_schedule_at, execution_order, priority, interval_item, warnings=None): if id_ is not None: self._id = id_ if name: @@ -147,6 +152,8 @@ def _set_values(self, id_, name, state, created_at, updated_at, schedule_type, self._priority = priority if interval_item: self._interval_item = interval_item + if warnings: + self._warnings = warnings @classmethod def from_response(cls, resp, ns): @@ -155,6 +162,8 @@ def from_response(cls, resp, ns): @classmethod def from_element(cls, parsed_response, ns): + warnings = cls._read_warnings(parsed_response, ns) + all_schedule_items = [] all_schedule_xml = parsed_response.findall('.//t:schedule', namespaces=ns) for schedule_xml in all_schedule_xml: @@ -173,7 +182,8 @@ def from_element(cls, parsed_response, ns): end_schedule_at=end_schedule_at, execution_order=None, priority=None, - interval_item=None) + interval_item=None, + warnings=warnings) all_schedule_items.append(schedule_item) return all_schedule_items @@ -199,7 +209,7 @@ def _parse_interval_item(parsed_response, frequency, ns): # We use fractional hours for the two minute-based intervals. # Need to convert to hours from minutes here if interval_occurrence == IntervalItem.Occurrence.Minutes: - interval_value = float(interval_value / 60) + interval_value = float(interval_value) / 60 return HourlyInterval(start_time, end_time, interval_value) @@ -235,3 +245,22 @@ def _parse_element(schedule_xml, ns): return id, name, state, created_at, updated_at, schedule_type, \ next_run_at, end_schedule_at, execution_order, priority, interval_item + + @staticmethod + def parse_add_to_schedule_response(response, ns): + parsed_response = ET.fromstring(response.content) + warnings = ScheduleItem._read_warnings(parsed_response, ns) + all_task_xml = parsed_response.findall('.//t:task', namespaces=ns) + + error = "Status {}: {}".format(response.status_code, response.reason) \ + if response.status_code < 200 or response.status_code >= 300 else None + task_created = len(all_task_xml) > 0 + return error, warnings, task_created + + @staticmethod + def _read_warnings(parsed_response, ns): + all_warning_xml = parsed_response.findall('.//t:warning', namespaces=ns) + warnings = list() if len(all_warning_xml) > 0 else None + for warning_xml in all_warning_xml: + warnings.append(warning_xml.get('message', None)) + return warnings diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index d9d7f2cd0..2b08eee05 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,14 +1,23 @@ import xml.etree.ElementTree as ET from .target import Target +from .schedule_item import ScheduleItem +from ..datetime_helpers import parse_datetime class TaskItem(object): - def __init__(self, id_, task_type, priority, consecutive_failed_count=0, schedule_id=None, target=None): + class Type: + ExtractRefresh = "extractRefresh" + MaterializeViews = "materializeViews" + + def __init__(self, id_, task_type, priority, consecutive_failed_count=0, schedule_id=None, + schedule_item=None, last_run_at=None, target=None): self.id = id_ self.task_type = task_type self.priority = priority self.consecutive_failed_count = consecutive_failed_count self.schedule_id = schedule_id + self.schedule_item = schedule_item + self.last_run_at = last_run_at self.target = target def __repr__(self): @@ -16,10 +25,10 @@ def __repr__(self): "schedule_id}) target({target})>".format(**self.__dict__) @classmethod - def from_response(cls, xml, ns): + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh): parsed_response = ET.fromstring(xml) all_tasks_xml = parsed_response.findall( - './/t:task/t:extractRefresh', namespaces=ns) + './/t:task/t:{}'.format(task_type), namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) @@ -27,13 +36,17 @@ def from_response(cls, xml, ns): @classmethod def _parse_element(cls, element, ns): - schedule = None + schedule_id = None + schedule_item = None target = None - schedule_element = element.find('.//t:schedule', namespaces=ns) + last_run_at = None workbook_element = element.find('.//t:workbook', namespaces=ns) datasource_element = element.find('.//t:datasource', namespaces=ns) - if schedule_element is not None: - schedule = schedule_element.get('id', None) + last_run_at_element = element.find('.//t:lastRunAt', namespaces=ns) + + schedule_item_list = ScheduleItem.from_element(element, ns) + if len(schedule_item_list) >= 1: + schedule_item = schedule_item_list[0] # according to the Tableau Server REST API documentation, # there should be only one of workbook or datasource @@ -43,9 +56,12 @@ def _parse_element(cls, element, ns): if datasource_element is not None: datasource_id = datasource_element.get('id', None) target = Target(datasource_id, "datasource") + if last_run_at_element is not None: + last_run_at = parse_datetime(last_run_at_element.text) task_type = element.get('type', None) priority = int(element.get('priority', -1)) consecutive_failed_count = int(element.get('consecutiveFailedCount', 0)) id_ = element.get('id', None) - return cls(id_, task_type, priority, consecutive_failed_count, schedule, target) + return cls(id_, task_type, priority, consecutive_failed_count, schedule_item.id, + schedule_item, last_run_at, target) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py new file mode 100644 index 000000000..4b1a350ee --- /dev/null +++ b/tableauserverclient/models/webhook_item.py @@ -0,0 +1,89 @@ +import xml.etree.ElementTree as ET +from .exceptions import UnpopulatedPropertyError +from .property_decorators import property_not_nullable, property_is_boolean, property_is_materialized_views_config +from .tag_item import TagItem +from .view_item import ViewItem +from .permissions_item import PermissionsRule +from ..datetime_helpers import parse_datetime +import re + + +NAMESPACE_RE = re.compile(r'^{.*}') + + +def _parse_event(events): + event = events[0] + # Strip out the namespace from the tag name + return NAMESPACE_RE.sub('', event.tag) + + +class WebhookItem(object): + def __init__(self): + self._id = None + self.name = None + self.url = None + self._event = None + self.owner_id = None + + def _set_values(self, id, name, url, event, owner_id): + if id is not None: + self._id = id + if name: + self.name = name + if url: + self.url = url + if event: + self.event = event + if owner_id: + self.owner_id = owner_id + + @property + def id(self): + return self._id + + @property + def event(self): + if self._event: + return self._event.replace("webhook-source-event-", "") + return None + + @event.setter + def event(self, value): + self._event = "webhook-source-event-{}".format(value) + + @classmethod + def from_response(cls, resp, ns): + all_webhooks_items = list() + parsed_response = ET.fromstring(resp) + all_webhooks_xml = parsed_response.findall('.//t:webhook', namespaces=ns) + for webhook_xml in all_webhooks_xml: + values = cls._parse_element(webhook_xml, ns) + + webhook_item = cls() + webhook_item._set_values(*values) + all_webhooks_items.append(webhook_item) + return all_webhooks_items + + @staticmethod + def _parse_element(webhook_xml, ns): + id = webhook_xml.get('id', None) + name = webhook_xml.get('name', None) + + url = None + url_tag = webhook_xml.find('.//t:webhook-destination-http', namespaces=ns) + if url_tag is not None: + url = url_tag.get('url', None) + + event = webhook_xml.findall('.//t:webhook-source/*', namespaces=ns) + if event is not None and len(event) > 0: + event = _parse_event(event) + + owner_id = None + owner_tag = webhook_xml.find('.//t:owner', namespaces=ns) + if owner_tag is not None: + owner_id = owner_tag.get('id', None) + + return id, name, url, event, owner_id + + def __repr__(self): + return "".format(self.id, self.name, self.url, self.event) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index d518f23a4..ce4f43ed5 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -22,6 +22,7 @@ def __init__(self, project_id, name=None, show_tabs=False): self._updated_at = None self._views = None self.name = name + self._description = None self.owner_id = None self.project_id = project_id self.show_tabs = show_tabs @@ -52,6 +53,10 @@ def content_url(self): def created_at(self): return self._created_at + @property + def description(self): + return self._description + @property def id(self): return self._id @@ -145,17 +150,17 @@ def _parse_common_tags(self, workbook_xml, ns): if not isinstance(workbook_xml, ET.Element): workbook_xml = ET.fromstring(workbook_xml).find('.//t:workbook', namespaces=ns) if workbook_xml is not None: - (_, _, _, _, updated_at, _, show_tabs, + (_, _, _, _, description, updated_at, _, show_tabs, project_id, project_name, owner_id, _, _, materialized_views_config) = self._parse_element(workbook_xml, ns) - self._set_values(None, None, None, None, updated_at, + self._set_values(None, None, None, None, description, updated_at, None, show_tabs, project_id, project_name, owner_id, None, None, materialized_views_config) return self - def _set_values(self, id, name, content_url, created_at, updated_at, + def _set_values(self, id, name, content_url, created_at, description, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, materialized_views_config): if id is not None: @@ -166,6 +171,8 @@ def _set_values(self, id, name, content_url, created_at, updated_at, self._content_url = content_url if created_at: self._created_at = created_at + if description: + self._description = description if updated_at: self._updated_at = updated_at if size: @@ -192,12 +199,12 @@ def from_response(cls, resp, ns): parsed_response = ET.fromstring(resp) all_workbook_xml = parsed_response.findall('.//t:workbook', namespaces=ns) for workbook_xml in all_workbook_xml: - (id, name, content_url, created_at, updated_at, size, show_tabs, + (id, name, content_url, created_at, description, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, materialized_views_config) = cls._parse_element(workbook_xml, ns) workbook_item = cls(project_id) - workbook_item._set_values(id, name, content_url, created_at, updated_at, + workbook_item._set_values(id, name, content_url, created_at, description, updated_at, size, show_tabs, None, project_name, owner_id, tags, views, materialized_views_config) all_workbook_items.append(workbook_item) @@ -209,6 +216,7 @@ def _parse_element(workbook_xml, ns): name = workbook_xml.get('name', None) content_url = workbook_xml.get('contentUrl', None) created_at = parse_datetime(workbook_xml.get('createdAt', None)) + description = workbook_xml.get('description', None) updated_at = parse_datetime(workbook_xml.get('updatedAt', None)) size = workbook_xml.get('size', None) @@ -245,7 +253,7 @@ def _parse_element(workbook_xml, ns): if materialized_views_elem is not None: materialized_views_config = parse_materialized_views_config(materialized_views_elem) - return id, name, content_url, created_at, updated_at, size, show_tabs,\ + return id, name, content_url, created_at, description, updated_at, size, show_tabs,\ project_id, project_name, owner_id, tags, views, materialized_views_config diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index a76fd3246..f382d0dba 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -5,7 +5,7 @@ from .. import ConnectionItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ UserItem, ViewItem, WorkbookItem, TableItem, TaskItem, SubscriptionItem, \ - PermissionsRule, Permission, ColumnItem, FlowItem + PermissionsRule, Permission, ColumnItem, FlowItem, WebhookItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ MissingRequiredFieldError, Flows diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index dbf501fe3..34c45a89a 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -11,9 +11,10 @@ from .schedules_endpoint import Schedules from .server_info_endpoint import ServerInfo from .sites_endpoint import Sites +from .subscriptions_endpoint import Subscriptions from .tables_endpoint import Tables from .tasks_endpoint import Tasks from .users_endpoint import Users from .views_endpoint import Views +from .webhooks_endpoint import Webhooks from .workbooks_endpoint import Workbooks -from .subscriptions_endpoint import Subscriptions diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index c46a7dc74..eef88d09e 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,7 +6,7 @@ from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem -from ...filesys_helpers import to_filename +from ...filesys_helpers import to_filename, make_download_path from ...models.tag_item import TagItem from ...models.job_item import JobItem import os @@ -104,17 +104,15 @@ def download(self, datasource_id, filepath=None, include_extract=True, no_extrac with closing(self.get_request(url, parameters={'stream': True})) as server_response: _, params = cgi.parse_header(server_response.headers['Content-Disposition']) filename = to_filename(os.path.basename(params['filename'])) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - with open(filepath, 'wb') as f: + download_path = make_download_path(filepath, filename) + + with open(download_path, 'wb') as f: for chunk in server_response.iter_content(1024): # 1KB f.write(chunk) - logger.info('Downloaded datasource to {0} (ID: {1})'.format(filepath, datasource_id)) - return os.path.abspath(filepath) + logger.info('Downloaded datasource to {0} (ID: {1})'.format(download_path, datasource_id)) + return os.path.abspath(download_path) # Update datasource @api(version="2.0") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index f2e48db7a..9934ee176 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -38,16 +38,17 @@ def update_default_permissions(self, resource, permissions, content_type): def delete_default_permission(self, resource, rule, content_type): for capability, mode in rule.capabilities.items(): - # Made readibility better but line is too long, will make this look better - url = '{baseurl}/{content_id}/default-permissions/\ - {content_type}/{grantee_type}/{grantee_id}/{cap}/{mode}'.format( - baseurl=self.owner_baseurl(), - content_id=resource.id, - content_type=content_type, - grantee_type=rule.grantee.tag_name + 's', - grantee_id=rule.grantee.id, - cap=capability, - mode=mode) + # Made readability better but line is too long, will make this look better + url = '{baseurl}/{content_id}/default-permissions/' \ + '{content_type}/{grantee_type}/{grantee_id}/{cap}/{mode}' \ + .format( + baseurl=self.owner_baseurl(), + content_id=resource.id, + content_type=content_type, + grantee_type=rule.grantee.tag_name + 's', + grantee_id=rule.grantee.id, + cap=capability, + mode=mode) logger.debug('Removing {0} permission for capabilty {1}'.format( mode, capability)) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 8c7e93607..2b2bca229 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,4 +1,4 @@ -from .exceptions import ServerResponseError, InternalServerError +from .exceptions import ServerResponseError, InternalServerError, NonXMLResponseError from functools import wraps from xml.etree.ElementTree import ParseError @@ -69,8 +69,14 @@ def _check_status(self, server_response): try: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) except ParseError: - # not an xml error + # This will happen if we get a non-success HTTP code that + # doesn't return an xml error object (like metadata endpoints) + # we convert this to a better exception and pass through the raw + # response body raise NonXMLResponseError(server_response.content) + except Exception as e: + # anything else re-raise here + raise def get_unauthenticated_request(self, url, request_object=None): return self._make_request(self.parent_srv.session.get, url, request_object=request_object) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index b2c616959..7bad807e4 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -6,7 +6,7 @@ from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem -from ...filesys_helpers import to_filename +from ...filesys_helpers import to_filename, make_download_path from ...models.tag_item import TagItem from ...models.job_item import JobItem import os @@ -94,17 +94,15 @@ def download(self, flow_id, filepath=None): with closing(self.get_request(url, parameters={'stream': True})) as server_response: _, params = cgi.parse_header(server_response.headers['Content-Disposition']) filename = to_filename(os.path.basename(params['filename'])) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - with open(filepath, 'wb') as f: + download_path = make_download_path(filepath, filename) + + with open(download_path, 'wb') as f: for chunk in server_response.iter_content(1024): # 1KB f.write(chunk) - logger.info('Downloaded flow to {0} (ID: {1})'.format(filepath, flow_id)) - return os.path.abspath(filepath) + logger.info('Downloaded flow to {0} (ID: {1})'.format(download_path, flow_id)) + return os.path.abspath(download_path) # Update flow @api(version="3.3") diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 6405f96a0..b28d6fa17 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -48,7 +48,7 @@ def delete(self, resource, rules): url = '{0}/{1}/permissions/{2}/{3}/{4}/{5}'.format( self.owner_baseurl(), resource.id, - rule.grantee.permissions_grantee_type + 's', + rule.grantee.tag_name + 's', rule.grantee.id, capability, mode) @@ -59,7 +59,7 @@ def delete(self, resource, rules): self.delete_request(url) logger.info('Deleted permission for {0} {1} item {2}'.format( - rule.grantee.permissions_grantee_type, + rule.grantee.tag_name, rule.grantee.id, resource.id)) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index e4dafcbcc..3b5216899 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -86,25 +86,25 @@ def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow) @api(version='2.1') - def update_workbook_default_permissions(self, item): - self._default_permissions.update_default_permissions(item, Permission.Resource.Workbook) + def update_workbook_default_permissions(self, item, rules): + self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook) @api(version='2.1') - def update_datasource_default_permissions(self, item): - self._default_permissions.update_default_permissions(item, Permission.Resource.Datasource) + def update_datasource_default_permissions(self, item, rules): + self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Datasource) @api(version='3.4') - def update_flow_default_permissions(self, item): - self._default_permissions.update_default_permissions(item, Permission.Resource.Flow) + def update_flow_default_permissions(self, item, rules): + self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow) @api(version='2.1') - def delete_workbook_default_permissions(self, item): - self._default_permissions.delete_default_permissions(item, Permission.Resource.Workbook) + def delete_workbook_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Workbook) @api(version='2.1') - def delete_datasource_default_permissions(self, item): - self._default_permissions.delete_default_permissions(item, Permission.Resource.Datasource) + def delete_datasource_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Datasource) @api(version='3.4') - def delete_flow_default_permissions(self, item): - self._default_permissions.delete_default_permissions(item, Permission.Resource.Flow) + def delete_flow_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Flow) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 2de488bdb..06fb7e408 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -1,14 +1,14 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, PaginationItem, ScheduleItem, WorkbookItem, DatasourceItem +from .. import RequestFactory, PaginationItem, ScheduleItem, WorkbookItem, DatasourceItem, TaskItem import logging import copy from collections import namedtuple logger = logging.getLogger('tableau.endpoint.schedules') # Oh to have a first class Result concept in Python... -AddResponse = namedtuple('AddResponse', ('result', 'error')) -OK = AddResponse(result=True, error=None) +AddResponse = namedtuple('AddResponse', ('result', 'error', 'warnings', 'task_created')) +OK = AddResponse(result=True, error=None, warnings=None, task_created=None) class Schedules(Endpoint): @@ -68,18 +68,23 @@ def create(self, schedule_item): return new_schedule @api(version="2.8") - def add_to_schedule(self, schedule_id, workbook=None, datasource=None): - + def add_to_schedule(self, schedule_id, workbook=None, datasource=None, + task_type=TaskItem.Type.ExtractRefresh): def add_to(resource, type_, req_factory): id_ = resource.id url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) - add_req = req_factory(id_) + add_req = req_factory(id_, task_type=task_type) response = self.put_request(url, add_req) - if response.status_code < 200 or response.status_code >= 300: - return AddResponse(result=False, - error="Status {}: {}".format(response.status_code, response.reason)) - logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) - return OK + + error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response( + response, self.parent_srv.namespace) + if task_created: + logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) + + if error is not None or warnings is not None: + return AddResponse(result=False, error=error, warnings=warnings, task_created=task_created) + else: + return OK items = [] diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 1c93181df..2abe87104 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -9,18 +9,35 @@ class Tasks(Endpoint): @property def baseurl(self): - return "{0}/sites/{1}/tasks/extractRefreshes".format(self.parent_srv.baseurl, - self.parent_srv.site_id) + return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, + self.parent_srv.site_id) + + def __normalize_task_type(self, task_type): + """ + The word for extract refresh used in API URL is "extractRefreshes". + It is different than the tag "extractRefresh" used in the request body. + """ + if task_type == TaskItem.Type.ExtractRefresh: + return '{}es'.format(task_type) + else: + return task_type @api(version='2.6') - def get(self, req_options=None): - logger.info('Querying all tasks for the site') - url = self.baseurl + def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh): + if task_type == TaskItem.Type.MaterializeViews: + self.parent_srv.assert_at_least_version("3.8") + + logger.info('Querying all {} tasks for the site'.format(task_type)) + + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) server_response = self.get_request(url, req_options) - pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - all_extract_tasks = TaskItem.from_response(server_response.content, self.parent_srv.namespace) - return all_extract_tasks, pagination_item + pagination_item = PaginationItem.from_response(server_response.content, + self.parent_srv.namespace) + all_tasks = TaskItem.from_response(server_response.content, + self.parent_srv.namespace, + task_type) + return all_tasks, pagination_item @api(version='2.6') def get_by_id(self, task_id): @@ -28,7 +45,8 @@ def get_by_id(self, task_id): error = "No Task ID provided" raise ValueError(error) logger.info("Querying a single task by id ({})".format(task_id)) - url = "{}/{}".format(self.baseurl, task_id) + url = "{}/{}/{}".format(self.baseurl, + self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_id) server_response = self.get_request(url) return TaskItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -38,7 +56,22 @@ def run(self, task_item): error = "User item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}/runNow".format(self.baseurl, task_item.id) + url = "{0}/{1}/{2}/runNow".format(self.baseurl, + self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id) run_req = RequestFactory.Task.run_req(task_item) server_response = self.post_request(url, run_req) return server_response.content + + # Delete 1 task by id + @api(version="3.6") + def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh): + if task_type == TaskItem.Type.MaterializeViews: + self.parent_srv.assert_at_least_version("3.8") + + if not task_id: + error = "No Task ID provided" + raise ValueError(error) + url = "{0}/{1}/{2}".format(self.baseurl, + self.__normalize_task_type(task_type), task_id) + self.delete_request(url) + logger.info('Deleted single task (ID: {0})'.format(task_id)) diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py new file mode 100644 index 000000000..4e69974d1 --- /dev/null +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -0,0 +1,63 @@ +from .endpoint import Endpoint, api, parameter_added_in +from ...models import WebhookItem, PaginationItem +from .. import RequestFactory + +import logging +logger = logging.getLogger('tableau.endpoint.webhooks') + + +class Webhooks(Endpoint): + def __init__(self, parent_srv): + super(Webhooks, self).__init__(parent_srv) + + @property + def baseurl(self): + return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.6") + def get(self, req_options=None): + logger.info('Querying all Webhooks on site') + url = self.baseurl + server_response = self.get_request(url, req_options) + all_webhook_items = WebhookItem.from_response(server_response.content, self.parent_srv.namespace) + pagination_item = PaginationItem.from_single_page_list(all_webhook_items) + return all_webhook_items, pagination_item + + @api(version="3.6") + def get_by_id(self, webhook_id): + if not webhook_id: + error = "Webhook ID undefined." + raise ValueError(error) + logger.info('Querying single webhook (ID: {0})'.format(webhook_id)) + url = "{0}/{1}".format(self.baseurl, webhook_id) + server_response = self.get_request(url) + return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.6") + def delete(self, webhook_id): + if not webhook_id: + error = "Webhook ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, webhook_id) + self.delete_request(url) + logger.info('Deleted single webhook (ID: {0})'.format(webhook_id)) + + @api(version="3.6") + def create(self, webhook_item): + url = self.baseurl + create_req = RequestFactory.Webhook.create_req(webhook_item) + server_response = self.post_request(url, create_req) + new_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + logger.info('Created new webhook (ID: {0})'.format(new_webhook.id)) + return new_webhook + + @api(version="3.6") + def test(self, webhook_id): + if not webhook_id: + error = "Webhook ID undefined." + raise ValueError(error) + url = "{0}/{1}/test".format(self.baseurl, webhook_id) + testOutcome = self.get_request(url) + logger.info('Testing webhook (ID: {0} returned {1})'.format(webhook_id, testOutcome)) + return testOutcome diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 445b0ccde..a6a49fedf 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,6 +1,5 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError -from .endpoint import api, parameter_added_in, Endpoint from .permissions_endpoint import _PermissionsEndpoint from .exceptions import MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads @@ -8,7 +7,7 @@ from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem from ...models.tag_item import TagItem from ...models.job_item import JobItem -from ...filesys_helpers import to_filename +from ...filesys_helpers import to_filename, make_download_path import os import logging @@ -86,7 +85,7 @@ def update(self, workbook_item): url = "{0}/{1}".format(self.baseurl, workbook_item.id) update_req = RequestFactory.Workbook.update_req(workbook_item) server_response = self.put_request(url, update_req) - logger.info('Updated workbook item (ID: {0}'.format(workbook_item.id)) + logger.info('Updated workbook item (ID: {0})'.format(workbook_item.id)) updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -104,8 +103,8 @@ def update_connection(self, workbook_item, connection_item): server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Updated workbook item (ID: {0} & connection item {1}'.format(workbook_item.id, - connection_item.id)) + logger.info('Updated workbook item (ID: {0} & connection item {1})'.format(workbook_item.id, + connection_item.id)) return connection # Download workbook contents with option of passing in filepath @@ -129,16 +128,14 @@ def download(self, workbook_id, filepath=None, include_extract=True, no_extract= with closing(self.get_request(url, parameters={"stream": True})) as server_response: _, params = cgi.parse_header(server_response.headers['Content-Disposition']) filename = to_filename(os.path.basename(params['filename'])) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - with open(filepath, 'wb') as f: + download_path = make_download_path(filepath, filename) + + with open(download_path, 'wb') as f: for chunk in server_response.iter_content(1024): # 1KB f.write(chunk) - logger.info('Downloaded workbook to {0} (ID: {1})'.format(filepath, workbook_id)) - return os.path.abspath(filepath) + logger.info('Downloaded workbook to {0} (ID: {1})'.format(download_path, workbook_id)) + return os.path.abspath(download_path) # Get all views of workbook @api(version="2.0") @@ -151,7 +148,7 @@ def view_fetcher(): return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) - logger.info('Populated views for workbook (ID: {0}'.format(workbook_item.id)) + logger.info('Populated views for workbook (ID: {0})'.format(workbook_item.id)) def _get_views_for_workbook(self, workbook_item, usage): url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index ad484e6a8..b447b072b 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -5,7 +5,7 @@ from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata -from ..models import UserItem, GroupItem, PermissionsRule +from ..models import TaskItem, UserItem, GroupItem, PermissionsRule def _add_multipart(parts): @@ -24,6 +24,7 @@ def wrapper(self, *args, **kwargs): xml_request = ET.Element('tsRequest') func(self, xml_request, *args, **kwargs) return ET.tostring(xml_request) + return wrapper @@ -248,6 +249,8 @@ def update_req(self, project_item): project_element.attrib['description'] = project_item.description if project_item.content_permissions: project_element.attrib['contentPermissions'] = project_item.content_permissions + if project_item.parent_id is not None: + project_element.attrib['parentProjectId'] = project_item.parent_id return ET.tostring(xml_request) def create_req(self, project_item): @@ -275,7 +278,7 @@ def create_req(self, schedule_item): schedule_element.attrib['frequency'] = interval_item._frequency frequency_element = ET.SubElement(schedule_element, 'frequencyDetails') frequency_element.attrib['start'] = str(interval_item.start_time) - if hasattr(interval_item, 'end_time') and interval_item.end_time: + if hasattr(interval_item, 'end_time') and interval_item.end_time is not None: frequency_element.attrib['end'] = str(interval_item.end_time) if hasattr(interval_item, 'interval') and interval_item.interval: intervals_element = ET.SubElement(frequency_element, 'intervals') @@ -301,7 +304,7 @@ def update_req(self, schedule_item): schedule_element.attrib['frequency'] = interval_item._frequency frequency_element = ET.SubElement(schedule_element, 'frequencyDetails') frequency_element.attrib['start'] = str(interval_item.start_time) - if hasattr(interval_item, 'end_time') and interval_item.end_time: + if hasattr(interval_item, 'end_time') and interval_item.end_time is not None: frequency_element.attrib['end'] = str(interval_item.end_time) intervals_element = ET.SubElement(frequency_element, 'intervals') if hasattr(interval_item, 'interval'): @@ -311,28 +314,28 @@ def update_req(self, schedule_item): single_interval_element.attrib[expression] = value return ET.tostring(xml_request) - def _add_to_req(self, id_, type_): + def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh): """ - + - + """ xml_request = ET.Element('tsRequest') task_element = ET.SubElement(xml_request, 'task') - refresh = ET.SubElement(task_element, 'extractRefresh') - workbook = ET.SubElement(refresh, type_) + task = ET.SubElement(task_element, task_type) + workbook = ET.SubElement(task, target_type) workbook.attrib['id'] = id_ return ET.tostring(xml_request) - def add_workbook_req(self, id_): - return self._add_to_req(id_, "workbook") + def add_workbook_req(self, id_, task_type=TaskItem.Type.ExtractRefresh): + return self._add_to_req(id_, "workbook", task_type) - def add_datasource_req(self, id_): - return self._add_to_req(id_, "datasource") + def add_datasource_req(self, id_, task_type=TaskItem.Type.ExtractRefresh): + return self._add_to_req(id_, "datasource", task_type) class SiteRequest(object): @@ -479,14 +482,15 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, 'owner') owner_element.attrib['id'] = workbook_item.owner_id - if workbook_item.materialized_views_config['materialized_views_enabled']\ - and workbook_item.materialized_views_config['run_materialization_now']: + if workbook_item.materialized_views_config is not None and \ + 'materialized_views_enabled' in workbook_item.materialized_views_config: materialized_views_config = workbook_item.materialized_views_config materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsEnablementConfig') materialized_views_element.attrib['materializedViewsEnabled'] = str(materialized_views_config ["materialized_views_enabled"]).lower() - materialized_views_element.attrib['materializeNow'] = str(materialized_views_config - ["run_materialization_now"]).lower() + if "run_materialization_now" in materialized_views_config: + materialized_views_element.attrib['materializeNow'] = str(materialized_views_config + ["run_materialization_now"]).lower() return ET.tostring(xml_request) @@ -555,6 +559,23 @@ def empty_req(self, xml_request): pass +class WebhookRequest(object): + @_tsrequest_wrapped + def create_req(self, xml_request, webhook_item): + webhook = ET.SubElement(xml_request, 'webhook') + webhook.attrib['name'] = webhook_item.name + + source = ET.SubElement(webhook, 'webhook-source') + event = ET.SubElement(source, webhook_item._event) + + destination = ET.SubElement(webhook, 'webhook-destination') + post = ET.SubElement(destination, 'webhook-destination-http') + post.attrib['method'] = 'POST' + post.attrib['url'] = webhook_item.url + + return ET.tostring(xml_request) + + class RequestFactory(object): Auth = AuthRequest() Connection = Connection() @@ -569,9 +590,10 @@ class RequestFactory(object): Project = ProjectRequest() Schedule = ScheduleRequest() Site = SiteRequest() + Subscription = SubscriptionRequest() Table = TableRequest() Tag = TagRequest() Task = TaskRequest() User = UserRequest() Workbook = WorkbookRequest() - Subscription = SubscriptionRequest() + Webhook = WebhookRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index b11f55d17..6c36482fd 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -4,7 +4,7 @@ from ..namespace import Namespace from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs, Metadata,\ - Databases, Tables, Flows + Databases, Tables, Flows, Webhooks from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -55,6 +55,7 @@ def __init__(self, server_address, use_server_version=False): self.metadata = Metadata(self) self.databases = Databases(self) self.tables = Tables(self) + self.webhooks = Webhooks(self) self._namespace = Namespace() if use_server_version: diff --git a/test/assets/schedule_add_datasource.xml b/test/assets/schedule_add_datasource.xml new file mode 100644 index 000000000..e57d2c8d2 --- /dev/null +++ b/test/assets/schedule_add_datasource.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/schedule_add_workbook.xml b/test/assets/schedule_add_workbook.xml new file mode 100644 index 000000000..a6adb005e --- /dev/null +++ b/test/assets/schedule_add_workbook.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/schedule_add_workbook_with_warnings.xml b/test/assets/schedule_add_workbook_with_warnings.xml new file mode 100644 index 000000000..0c376d018 --- /dev/null +++ b/test/assets/schedule_add_workbook_with_warnings.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_create_weekly.xml b/test/assets/schedule_create_weekly.xml index 624a56e25..a12a6eace 100644 --- a/test/assets/schedule_create_weekly.xml +++ b/test/assets/schedule_create_weekly.xml @@ -9,4 +9,8 @@ + + + + \ No newline at end of file diff --git a/test/assets/tasks_run_now_response.xml b/test/assets/tasks_run_now_response.xml new file mode 100644 index 000000000..6a8860cd7 --- /dev/null +++ b/test/assets/tasks_run_now_response.xml @@ -0,0 +1,6 @@ + + + + diff --git a/test/assets/tasks_with_materializeviews_task.xml b/test/assets/tasks_with_materializeviews_task.xml new file mode 100644 index 000000000..e586b6bb1 --- /dev/null +++ b/test/assets/tasks_with_materializeviews_task.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + 2019-12-09T20:45:04Z + + + + \ No newline at end of file diff --git a/test/assets/webhook_create.xml b/test/assets/webhook_create.xml new file mode 100644 index 000000000..24a5ca99b --- /dev/null +++ b/test/assets/webhook_create.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/webhook_create_request.xml b/test/assets/webhook_create_request.xml new file mode 100644 index 000000000..0578c2c48 --- /dev/null +++ b/test/assets/webhook_create_request.xml @@ -0,0 +1 @@ + diff --git a/test/assets/webhook_get.xml b/test/assets/webhook_get.xml new file mode 100644 index 000000000..7d527fc00 --- /dev/null +++ b/test/assets/webhook_get.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/test/assets/workbook_get.xml b/test/assets/workbook_get.xml index 6a753f70c..e5fd3967b 100644 --- a/test/assets/workbook_get.xml +++ b/test/assets/workbook_get.xml @@ -2,12 +2,12 @@ - + - + diff --git a/test/assets/workbook_get_by_id.xml b/test/assets/workbook_get_by_id.xml index 13bb76523..1b2fe9120 100644 --- a/test/assets/workbook_get_by_id.xml +++ b/test/assets/workbook_get_by_id.xml @@ -1,6 +1,6 @@ - + diff --git a/test/test_datasource.py b/test/test_datasource.py index fdf3c2e51..c90cf4601 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -178,8 +178,8 @@ def test_update_connection(self): 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('bar', new_connection.server_address) + self.assertEqual('9876', new_connection.server_port) self.assertEqual('foo', new_connection.username) def test_populate_permissions(self): @@ -230,9 +230,11 @@ def test_publish(self): self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) def test_publish_async(self): + self.server.version = "3.0" + baseurl = self.server.datasources.baseurl response_xml = read_xml_asset(PUBLISH_XML_ASYNC) with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) + m.post(baseurl, text=response_xml) new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') publish_mode = self.server.PublishMode.CreateNew @@ -355,6 +357,6 @@ def test_synchronous_publish_timeout_error(self): new_datasource = TSC.DatasourceItem(project_id='') publish_mode = self.server.PublishMode.CreateNew - self.assertRaisesRegexp(InternalServerError, 'Please use asynchronous publishing to avoid timeouts.', - self.server.datasources.publish, new_datasource, - asset('SampleDS.tds'), publish_mode) + self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts.', + self.server.datasources.publish, new_datasource, + asset('SampleDS.tds'), publish_mode) diff --git a/test/test_job.py b/test/test_job.py index 5da0f76fa..ee8316168 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -32,14 +32,14 @@ def test_get(self): 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) + self.assertEqual(1, pagination_item.total_available) + self.assertEqual('2eef4225-aa0c-41c4-8662-a76d89ed7336', job.id) + self.assertEqual('Success', job.status) + self.assertEqual('50', job.priority) + self.assertEqual('single_subscription_notify', job.type) + self.assertEqual(created_at, job.created_at) + self.assertEqual(started_at, job.started_at) + self.assertEqual(ended_at, job.ended_at) def test_get_before_signin(self): self.server._auth_token = None diff --git a/test/test_project.py b/test/test_project.py index 6e055e50f..d4a0de283 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -152,3 +152,97 @@ def test_populate_workbooks(self): TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, }) + + def test_delete_permission(self): + with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) + + single_group = TSC.GroupItem('Group1') + single_group._id = 'c8f2773a-c83a-11e8-8c8f-33e6d787b506' + + single_project = TSC.ProjectItem('Project3') + single_project._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + + self.server.projects.populate_permissions(single_project) + permissions = single_project.permissions + + capabilities = {} + + for permission in permissions: + if permission.grantee.tag_name == "group": + if permission.grantee.id == single_group._id: + capabilities = permission.capabilities + + rules = TSC.PermissionsRule( + grantee=single_group, + capabilities=capabilities + ) + + endpoint = '{}/permissions/groups/{}'.format(single_project._id, single_group._id) + m.delete('{}/{}/Read/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/Write/Allow'.format(self.baseurl, endpoint), status_code=204) + self.server.projects.delete_permission(item=single_project, rules=rules) + + def test_delete_workbook_default_permission(self): + with open(asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML), 'rb') as f: + response_xml = f.read().decode('utf-8') + + with requests_mock.mock() as m: + m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks', + text=response_xml) + + single_group = TSC.GroupItem('Group1') + single_group._id = 'c8f2773a-c83a-11e8-8c8f-33e6d787b506' + + single_project = TSC.ProjectItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') + single_project.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + single_project._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + + self.server.projects.populate_workbook_default_permissions(single_project) + permissions = single_project.default_workbook_permissions + + capabilities = { + # View + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + + # Interact/Edit + TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, + + # Edit + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow + } + + rules = TSC.PermissionsRule( + grantee=single_group, + capabilities=capabilities + ) + + endpoint = '{}/default-permissions/workbook/groups/{}'.format(single_project._id, single_group._id) + m.delete('{}/{}/Read/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ExportImage/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ExportData/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ViewComments/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/AddComment/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/Filter/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ViewUnderlyingData/Deny'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ShareView/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/WebAuthoring/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/Write/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ExportXml/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ChangeHierarchy/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/Delete/Deny'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ChangePermissions/Allow'.format(self.baseurl, endpoint), status_code=204) + self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 8958c3cf8..281f3fbca 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,6 +1,13 @@ import unittest + +try: + from unittest import mock +except ImportError: + import mock + import tableauserverclient.server.request_factory as factory from tableauserverclient.server.endpoint import Endpoint +from tableauserverclient.filesys_helpers import to_filename, make_download_path class BugFix257(unittest.TestCase): @@ -21,3 +28,36 @@ class FakeResponse(object): server_response = FakeResponse() self.assertEqual(Endpoint._safe_to_log(server_response), '[Truncated File Contents]') + + +class FileSysHelpers(unittest.TestCase): + def test_to_filename(self): + invalid = [ + "23brhafbjrjhkbbea.txt", + 'a_b_C.txt', + 'windows space.txt', + 'abc#def.txt', + 't@bL3A()', + ] + + valid = [ + "23brhafbjrjhkbbea.txt", + 'a_b_C.txt', + 'windows space.txt', + 'abcdef.txt', + 'tbL3A', + ] + + self.assertTrue(all([(to_filename(i) == v) for i, v in zip(invalid, valid)])) + + def test_make_download_path(self): + no_file_path = (None, 'file.ext') + has_file_path_folder = ('/root/folder/', 'file.ext') + has_file_path_file = ('out', 'file.ext') + + self.assertEqual('file.ext', make_download_path(*no_file_path)) + self.assertEqual('out.ext', make_download_path(*has_file_path_file)) + + with mock.patch('os.path.isdir') as mocked_isdir: + mocked_isdir.return_value = True + self.assertEqual('/root/folder/file.ext', make_download_path(*has_file_path_folder)) diff --git a/test/test_requests.py b/test/test_requests.py index 80216ec85..d064e080e 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -5,7 +5,7 @@ import tableauserverclient as TSC -from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.endpoint.exceptions import InternalServerError, NonXMLResponseError class RequestTests(unittest.TestCase): @@ -54,4 +54,12 @@ def test_internal_server_error(self): server_response = "500: Internal Server Error" with requests_mock.mock() as m: m.register_uri('GET', self.server.server_info.baseurl, status_code=500, text=server_response) - self.assertRaisesRegexp(InternalServerError, server_response, self.server.server_info.get) + self.assertRaisesRegex(InternalServerError, server_response, self.server.server_info.get) + + # Test that non-xml server errors are handled properly + def test_non_xml_error(self): + self.server.version = "3.2" + server_response = "this is not xml" + with requests_mock.mock() as m: + m.register_uri('GET', self.server.server_info.baseurl, status_code=499, text=server_response) + self.assertRaisesRegex(NonXMLResponseError, server_response, self.server.server_info.get) diff --git a/test/test_schedule.py b/test/test_schedule.py index b5aadcbca..b7b047d02 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -14,6 +14,9 @@ CREATE_WEEKLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_weekly.xml") CREATE_MONTHLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_monthly.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "schedule_update.xml") +ADD_WORKBOOK_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook.xml") +ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook_with_warnings.xml") +ADD_DATASOURCE_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_datasource.xml") WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id.xml') DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'datasource_get_by_id.xml') @@ -153,6 +156,9 @@ def test_create_weekly(self): self.assertEqual(time(9, 15), new_schedule.interval_item.start_time) self.assertEqual(("Monday", "Wednesday", "Friday"), new_schedule.interval_item.interval) + self.assertEqual(2, len(new_schedule.warnings)) + self.assertEqual("warning 1", new_schedule.warnings[0]) + self.assertEqual("warning 2", new_schedule.warnings[1]) def test_create_monthly(self): with open(CREATE_MONTHLY_XML, "rb") as f: @@ -205,24 +211,42 @@ def test_add_workbook(self): with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") + with open(ADD_WORKBOOK_TO_SCHEDULE, "rb") as f: + add_workbook_response = f.read().decode("utf-8") with requests_mock.mock() as m: - # TODO: Replace with real response m.get(self.server.workbooks.baseurl + '/bar', text=workbook_response) - m.put(baseurl + '/foo/workbooks', text="OK") + m.put(baseurl + '/foo/workbooks', text=add_workbook_response) workbook = self.server.workbooks.get_by_id("bar") result = self.server.schedules.add_to_schedule('foo', workbook=workbook) self.assertEqual(0, len(result), "Added properly") + def test_add_workbook_with_warnings(self): + self.server.version = "2.8" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + + with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: + workbook_response = f.read().decode("utf-8") + with open(ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS, "rb") as f: + add_workbook_response = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.workbooks.baseurl + '/bar', text=workbook_response) + m.put(baseurl + '/foo/workbooks', text=add_workbook_response) + workbook = self.server.workbooks.get_by_id("bar") + result = self.server.schedules.add_to_schedule('foo', workbook=workbook) + self.assertEqual(1, len(result), "Not added properly") + self.assertEqual(2, len(result[0].warnings)) + def test_add_datasource(self): self.server.version = "2.8" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: datasource_response = f.read().decode("utf-8") + with open(ADD_DATASOURCE_TO_SCHEDULE, "rb") as f: + add_datasource_response = f.read().decode("utf-8") with requests_mock.mock() as m: - # TODO: Replace with real response m.get(self.server.datasources.baseurl + '/bar', text=datasource_response) - m.put(baseurl + '/foo/datasources', text="OK") + m.put(baseurl + '/foo/datasources', text=add_datasource_response) datasource = self.server.datasources.get_by_id("bar") result = self.server.schedules.add_to_schedule('foo', datasource=datasource) self.assertEqual(0, len(result), "Added properly") diff --git a/test/test_sort.py b/test/test_sort.py index 5eef07a9d..40936d835 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -57,7 +57,7 @@ def test_filter_in(self): request_object=opts, auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='text/xml') - self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:[stocks,market]') + self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:%5bstocks,market%5d') def test_sort_asc(self): with requests_mock.mock() as m: diff --git a/test/test_task.py b/test/test_task.py index 2529f811a..a0699bc49 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -2,6 +2,8 @@ import os import requests_mock import tableauserverclient as TSC +from tableauserverclient.models.task_item import TaskItem +from tableauserverclient.datetime_helpers import parse_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -9,18 +11,21 @@ GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml") GET_XML_WITH_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_datasource.xml") GET_XML_WITH_WORKBOOK_AND_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook_and_datasource.xml") +GET_XML_MATERIALIZEVIEWS_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_materializeviews_task.xml") +GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") class TaskTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test") - self.server.version = '2.6' + self.server.version = '3.8' # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = self.server.tasks.baseurl + # default task type is extractRefreshes + self.baseurl = "{}/{}".format(self.server.tasks.baseurl, "extractRefreshes") def test_get_tasks_with_no_workbook(self): with open(GET_XML_NO_WORKBOOK, "rb") as f: @@ -76,3 +81,58 @@ def test_get_task_with_schedule(self): self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) self.assertEqual('workbook', task.target.type) self.assertEqual('b60b4efd-a6f7-4599-beb3-cb677e7abac1', task.schedule_id) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/c7a9327e-1cda-4504-b026-ddb43b976d1d', status_code=204) + self.server.tasks.delete('c7a9327e-1cda-4504-b026-ddb43b976d1d') + + def test_delete_missing_id(self): + self.assertRaises(ValueError, self.server.tasks.delete, '') + + def test_get_materializeviews_tasks(self): + with open(GET_XML_MATERIALIZEVIEWS_TASK, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get('{}/{}'.format( + self.server.tasks.baseurl, TaskItem.Type.MaterializeViews), text=response_xml) + all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.MaterializeViews) + + task = all_tasks[0] + self.assertEqual('a462c148-fc40-4670-a8e4-39b7f0c58c7f', task.target.id) + self.assertEqual('workbook', task.target.type) + self.assertEqual('b22190b4-6ac2-4eed-9563-4afc03444413', task.schedule_id) + self.assertEqual(parse_datetime('2019-12-09T22:30:00Z'), task.schedule_item.next_run_at) + self.assertEqual(parse_datetime('2019-12-09T20:45:04Z'), task.last_run_at) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete('{}/{}/{}'.format( + self.server.tasks.baseurl, TaskItem.Type.MaterializeViews, + 'c9cff7f9-309c-4361-99ff-d4ba8c9f5467'), status_code=204) + self.server.tasks.delete('c9cff7f9-309c-4361-99ff-d4ba8c9f5467', + TaskItem.Type.MaterializeViews) + + def test_get_by_id(self): + with open(GET_XML_WITH_WORKBOOK, "rb") as f: + response_xml = f.read().decode("utf-8") + task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6' + with requests_mock.mock() as m: + m.get('{}/{}'.format(self.baseurl, task_id), text=response_xml) + task = self.server.tasks.get_by_id(task_id) + + self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) + self.assertEqual('workbook', task.target.type) + self.assertEqual('b60b4efd-a6f7-4599-beb3-cb677e7abac1', task.schedule_id) + + def test_run_now(self): + task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6' + task = TaskItem(task_id, TaskItem.Type.ExtractRefresh, 100) + with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post('{}/{}/runNow'.format(self.baseurl, task_id), text=response_xml) + job_response_content = self.server.tasks.run(task).decode("utf-8") + + self.assertTrue('7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6' in job_response_content) + self.assertTrue('RefreshExtract' in job_response_content) diff --git a/test/test_webhook.py b/test/test_webhook.py new file mode 100644 index 000000000..819de18ae --- /dev/null +++ b/test/test_webhook.py @@ -0,0 +1,83 @@ +import unittest +import os +import requests_mock +import tableauserverclient as TSC +from tableauserverclient.server import RequestFactory, WebhookItem + +from ._utils import read_xml_asset, read_xml_assets, asset + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') + +GET_XML = asset('webhook_get.xml') +CREATE_XML = asset('webhook_create.xml') +CREATE_REQUEST_XML = asset('webhook_create_request.xml') + + +class WebhookTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.server.version = "3.6" + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = self.server.webhooks.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) + webhooks, _ = self.server.webhooks.get() + self.assertEqual(len(webhooks), 1) + webhook = webhooks[0] + + self.assertEqual(webhook.url, "url") + self.assertEqual(webhook.event, "datasource-created") + self.assertEqual(webhook.owner_id, "webhook_owner_luid") + self.assertEqual(webhook.name, "webhook-name") + self.assertEqual(webhook.id, "webhook-id") + + def test_get_before_signin(self): + self.server._auth_token = None + self.assertRaises(TSC.NotSignedInError, self.server.webhooks.get) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + self.server.webhooks.delete('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + def test_delete_missing_id(self): + self.assertRaises(ValueError, self.server.webhooks.delete, '') + + def test_test(self): + with requests_mock.mock() as m: + m.get(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760/test', status_code=200) + self.server.webhooks.test('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + def test_create(self): + with open(CREATE_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + webhook_model = TSC.WebhookItem() + webhook_model.name = "Test Webhook" + webhook_model.url = "https://ifttt.com/maker-url" + webhook_model.event = "datasource-created" + + new_webhook = self.server.webhooks.create(webhook_model) + + self.assertNotEqual(new_webhook.id, None) + + def test_request_factory(self): + with open(CREATE_REQUEST_XML, 'rb') as f: + webhook_request_expected = f.read().decode('utf-8') + + webhook_item = WebhookItem() + webhook_item._set_values("webhook-id", "webhook-name", "url", "api-event-name", + None) + webhook_request_actual = '{}\n'.format(RequestFactory.Webhook.create_req(webhook_item).decode('utf-8')) + self.maxDiff = None + # windows does /r/n for linebreaks, remove the extra char if it is there + self.assertEqual(webhook_request_expected.replace('\r', ''), webhook_request_actual) diff --git a/test/test_workbook.py b/test/test_workbook.py index 0317ba115..fac6c49a1 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -55,6 +55,7 @@ def test_get(self): self.assertEqual(False, all_workbooks[0].show_tabs) self.assertEqual(1, all_workbooks[0].size) self.assertEqual('2016-08-03T20:34:04Z', format_datetime(all_workbooks[0].created_at)) + self.assertEqual('description for Superstore', all_workbooks[0].description) self.assertEqual('2016-08-04T17:56:41Z', format_datetime(all_workbooks[0].updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_workbooks[0].project_id) self.assertEqual('default', all_workbooks[0].project_name) @@ -66,6 +67,7 @@ def test_get(self): self.assertEqual(False, all_workbooks[1].show_tabs) self.assertEqual(26, all_workbooks[1].size) self.assertEqual('2016-07-26T20:34:56Z', format_datetime(all_workbooks[1].created_at)) + self.assertEqual('description for SafariSample', all_workbooks[1].description) self.assertEqual('2016-07-26T20:35:05Z', format_datetime(all_workbooks[1].updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_workbooks[1].project_id) self.assertEqual('default', all_workbooks[1].project_name) @@ -99,6 +101,7 @@ def test_get_by_id(self): self.assertEqual(False, single_workbook.show_tabs) self.assertEqual(26, single_workbook.size) self.assertEqual('2016-07-26T20:34:56Z', format_datetime(single_workbook.created_at)) + self.assertEqual('description for SafariSample', single_workbook.description) self.assertEqual('2016-07-26T20:35:05Z', format_datetime(single_workbook.updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', single_workbook.project_id) self.assertEqual('default', single_workbook.project_name) @@ -409,10 +412,12 @@ def test_publish(self): self.assertEqual('RESTAPISample_0/sheets/GDPpercapita', new_workbook.views[0].content_url) def test_publish_async(self): + self.server.version = '3.0' + baseurl = self.server.workbooks.baseurl 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) + m.post(baseurl, text=response_xml) new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, @@ -497,5 +502,5 @@ def test_synchronous_publish_timeout_error(self): new_workbook = TSC.WorkbookItem(project_id='') publish_mode = self.server.PublishMode.CreateNew - self.assertRaisesRegexp(InternalServerError, 'Please use asynchronous publishing to avoid timeouts', - self.server.workbooks.publish, new_workbook, asset('SampleWB.twbx'), publish_mode) + self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts', + self.server.workbooks.publish, new_workbook, asset('SampleWB.twbx'), publish_mode)