diff --git a/CHANGELOG.md b/CHANGELOG.md index 77aab3ed7..421d577fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 0.8 (8 Apr 2019) + +* Added Max Age to download view image request (#360) +* Added Materialized Views (#378, #394, #396) +* Added PDF export of Workbook (#376) +* Added Support User Role (#392) +* Added Flows (#403) +* Updated Pager to handle un-paged results (#322) +* Fixed checked upload (#309, #319, #326, #329) +* Fixed embed_password field on publish (#416) + + ## 0.7 (2 Jul 2018) * Added cancel job (#299) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 25ac5718b..bffde46c7 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -16,6 +16,9 @@ The following people have contributed to this project to make it possible, and w * [Jim Morris](https://github.com/jimbodriven) * [BingoDinkus](https://github.com/BingoDinkus) * [Sergey Sotnichenko](https://github.com/sotnich) +* [Bruce Zhang](https://github.com/baixin137) +* [Bumsoo Kim](https://github.com/bskim45) +* [daniel1608](https://github.com/daniel1608) ## Core Team @@ -27,3 +30,5 @@ The following people have contributed to this project to make it possible, and w * [Jared Dominguez](https://github.com/jdomingu) * [Jackson Huang](https://github.com/jz-huang) * [Brendan Lee](https://github.com/lbrendanl) +* [Ang Gao](https://github.com/gaoang2148) +* [Priya R](https://github.com/preguraman) diff --git a/contributing.md b/contributing.md index 0c856c06a..c95191e0e 100644 --- a/contributing.md +++ b/contributing.md @@ -48,7 +48,7 @@ anyone can add to an issue: ## Fixes, Implementations, and Documentation For all other things, please submit a PR that includes the fix, documentation, or new code that you are trying to contribute. More information on -creating a PR can be found in the [Development Guide](docs/docs/dev-guide.md) +creating a PR can be found in the [Development Guide](https://tableau.github.io/server-client-python/docs/dev-guide) 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 diff --git a/samples/download_view_image.py b/samples/download_view_image.py index b95a8628b..df2331596 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -25,6 +25,7 @@ def main(): parser.add_argument('--view-name', '-v', required=True, help='name of view to download an image of') parser.add_argument('--filepath', '-f', required=True, help='filepath to save the image returned') + parser.add_argument('--maxage', '-m', required=False, help='max age of the image in the cache in minutes.') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') @@ -55,8 +56,12 @@ def main(): raise LookupError("View with the specified name was not found.") view_item = all_views[0] - # Step 3: Query the image endpoint and save the image to the specified location - image_req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High) + max_age = args.maxage + if not max_age: + max_age = 1 + + image_req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, + maxage=max_age) server.views.populate_image(view_item, image_req_option) with open(args.filepath, "wb") as image_file: diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py new file mode 100644 index 000000000..696dda4b7 --- /dev/null +++ b/samples/materialize_workbooks.py @@ -0,0 +1,342 @@ +import argparse +import getpass +import logging +import os +import tableauserverclient as TSC +from collections import defaultdict + + +def main(): + parser = argparse.ArgumentParser(description='Materialized views settings for sites/workbooks.') + parser.add_argument('--server', '-s', required=True, help='Tableau server address') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--password', '-p', required=False, help='password to sign into server') + parser.add_argument('--mode', '-m', required=False, choices=['disable', 'enable', 'enable_all', 'enable_selective'], + help='enable/disable materialized views for sites/workbooks') + parser.add_argument('--status', '-st', required=False, action='store_true', + help='show materialized views enabled sites/workbooks') + parser.add_argument('--site-id', '-si', required=False, + help='set to Default site by default') + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + parser.add_argument('--type', '-t', required=False, choices=['site', 'workbook', 'project_name', 'project_path'], + help='type of content you want to update materialized views settings on') + parser.add_argument('--path-list', '-pl', required=False, help='path to a list of workbook paths') + parser.add_argument('--name-list', '-nl', required=False, help='path to a list of workbook names') + parser.add_argument('--project-name', '-pn', required=False, help='name of the project') + parser.add_argument('--project-path', '-pp', required=False, help="path of the project") + parser.add_argument('--materialize-now', '-mn', required=False, action='store_true', + help='create materialized views for workbooks immediately') + + args = parser.parse_args() + + if args.password: + password = args.password + else: + password = getpass.getpass("Password: ") + + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + # site content url is the TSC term for site id + site_content_url = args.site_id if args.site_id is not None else "" + + if not assert_options_valid(args): + return + + materialized_views_config = create_materialized_views_config(args) + + # enable/disable materialized views for site + if args.type == 'site': + if not update_site(args, password, site_content_url): + return + + # enable/disable materialized views for workbook + # works only when the site the workbooks belong to are enabled too + elif args.type == 'workbook': + if not update_workbook(args, materialized_views_config, password, site_content_url): + return + + # enable/disable materialized views for project by project name + # will show possible projects when project name is not unique + elif args.type == 'project_name': + if not update_project_by_name(args, materialized_views_config, password, site_content_url): + return + + # enable/disable materialized views for project by project path, for example: project1/project2 + elif args.type == 'project_path': + if not update_project_by_path(args, materialized_views_config, password, site_content_url): + return + + # show enabled sites and workbooks + if args.status: + show_materialized_views_status(args, password, site_content_url) + + +def find_project_path(project, all_projects, path): + # project stores the id of it's parent + # this method is to run recursively to find the path from root project to given project + path = project.name if len(path) == 0 else project.name + '/' + path + + if project.parent_id is None: + return path + else: + return find_project_path(all_projects[project.parent_id], all_projects, path) + + +def get_project_paths(server, projects): + # most likely user won't have too many projects so we store them in a dict to search + all_projects = {project.id: project for project in TSC.Pager(server.projects)} + + result = dict() + for project in projects: + result[find_project_path(project, all_projects, "")] = project + return result + + +def print_paths(paths): + for path in paths.keys(): + print(path) + + +def show_materialized_views_status(args, password, site_content_url): + tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) + server = TSC.Server(args.server, use_server_version=True) + enabled_sites = set() + with server.auth.sign_in(tableau_auth): + # For server admin, this will prints all the materialized views enabled sites + # For other users, this only prints the status of the site they belong to + print("Materialized views is enabled on sites:") + # only server admins can get all the sites in the server + # other users can only get the site they are in + for site in TSC.Pager(server.sites): + if site.materialized_views_mode != "disable": + enabled_sites.add(site) + print("Site name: {}".format(site.name)) + print('\n') + + print("Materialized views is enabled on workbooks:") + # Individual workbooks can be enabled only when the sites they belong to are enabled too + for site in enabled_sites: + site_auth = TSC.TableauAuth(args.username, password, site.content_url) + with server.auth.sign_in(site_auth): + for workbook in TSC.Pager(server.workbooks): + if workbook.materialized_views_config['materialized_views_enabled']: + print("Workbook: {} from site: {}".format(workbook.name, site.name)) + + +def update_project_by_path(args, materialized_views_config, password, site_content_url): + if args.project_path is None: + print("Use --project_path to specify the path of the project") + return False + tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) + server = TSC.Server(args.server, use_server_version=True) + project_name = args.project_path.split('/')[-1] + with server.auth.sign_in(tableau_auth): + if not assert_site_enabled_for_materialized_views(server, site_content_url): + return False + projects = [project for project in TSC.Pager(server.projects) if project.name == project_name] + if not assert_project_valid(args.project_path, projects): + return False + + possible_paths = get_project_paths(server, projects) + update_project(possible_paths[args.project_path], server, materialized_views_config) + return True + + +def update_project_by_name(args, materialized_views_config, password, site_content_url): + if args.project_name is None: + print("Use --project-name to specify the name of the project") + return False + tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + if not assert_site_enabled_for_materialized_views(server, site_content_url): + return False + # get all projects with given name + projects = [project for project in TSC.Pager(server.projects) if project.name == args.project_name] + if not assert_project_valid(args.project_name, projects): + return False + + if len(projects) > 1: + possible_paths = get_project_paths(server, projects) + print("Project name is not unique, use '--project_path '") + print("Possible project paths:") + print_paths(possible_paths) + print('\n') + return False + else: + update_project(projects[0], server, materialized_views_config) + return True + + +def update_project(project, server, materialized_views_config): + all_projects = list(TSC.Pager(server.projects)) + project_ids = find_project_ids_to_update(all_projects, project) + for workbook in TSC.Pager(server.workbooks): + if workbook.project_id in project_ids: + workbook.materialized_views_config = materialized_views_config + server.workbooks.update(workbook) + + print("Updated materialized views settings for project: {}".format(project.name)) + print('\n') + + +def find_project_ids_to_update(all_projects, project): + projects_to_update = [] + find_projects_to_update(project, all_projects, projects_to_update) + return set([project_to_update.id for project_to_update in projects_to_update]) + + +def parse_workbook_path(file_path): + # parse the list of project path of workbooks + workbook_paths = sanitize_workbook_list(file_path, "path") + + workbook_path_mapping = defaultdict(list) + for workbook_path in workbook_paths: + workbook_project = workbook_path.rstrip().split('/') + workbook_path_mapping[workbook_project[-1]].append('/'.join(workbook_project[:-1])) + return workbook_path_mapping + + +def update_workbook(args, materialized_views_config, password, site_content_url): + if args.path_list is None and args.name_list is None: + print("Use '--path-list ' or '--name-list ' to specify the path of a list of workbooks") + print('\n') + return False + tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + if not assert_site_enabled_for_materialized_views(server, site_content_url): + return False + if args.path_list is not None: + workbook_path_mapping = parse_workbook_path(args.path_list) + all_projects = {project.id: project for project in TSC.Pager(server.projects)} + update_workbooks_by_paths(all_projects, materialized_views_config, server, workbook_path_mapping) + elif args.name_list is not None: + update_workbooks_by_names(args.name_list, server, materialized_views_config) + return True + + +def update_workbooks_by_paths(all_projects, materialized_views_config, server, workbook_path_mapping): + for workbook_name, workbook_paths in workbook_path_mapping.items(): + req_option = TSC.RequestOptions() + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.Equals, + workbook_name)) + workbooks = list(TSC.Pager(server.workbooks, req_option)) + all_paths = set(workbook_paths[:]) + for workbook in workbooks: + path = find_project_path(all_projects[workbook.project_id], all_projects, "") + if path in workbook_paths: + all_paths.remove(path) + workbook.materialized_views_config = materialized_views_config + server.workbooks.update(workbook) + print("Updated materialized views settings for workbook: {}".format(path + '/' + workbook.name)) + + for path in all_paths: + print("Cannot find workbook path: {}, each line should only contain one workbook path" + .format(path + '/' + workbook_name)) + print('\n') + + +def update_workbooks_by_names(name_list, server, materialized_views_config): + workbook_names = sanitize_workbook_list(name_list, "name") + for workbook_name in workbook_names: + req_option = TSC.RequestOptions() + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.Equals, + workbook_name.rstrip())) + workbooks = list(TSC.Pager(server.workbooks, req_option)) + if len(workbooks) == 0: + print("Cannot find workbook name: {}, each line should only contain one workbook name" + .format(workbook_name)) + for workbook in workbooks: + workbook.materialized_views_config = materialized_views_config + server.workbooks.update(workbook) + print("Updated materialized views settings for workbook: {}".format(workbook.name)) + print('\n') + + +def update_site(args, password, site_content_url): + if not assert_site_options_valid(args): + return False + tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + site_to_update = server.sites.get_by_content_url(site_content_url) + site_to_update.materialized_views_mode = args.mode + + server.sites.update(site_to_update) + print("Updated materialized views settings for site: {}".format(site_to_update.name)) + print('\n') + return True + + +def create_materialized_views_config(args): + materialized_views_config = dict() + materialized_views_config['materialized_views_enabled'] = args.mode == "enable" + materialized_views_config['run_materialization_now'] = True if args.materialize_now else False + return materialized_views_config + + +def assert_site_options_valid(args): + if args.materialize_now: + print('"--materialize-now" only applies to workbook/project type') + return False + if args.mode == 'enable': + print('For site type please choose from "disable", "enable_all", or "enable_selective"') + return False + return True + + +def assert_options_valid(args): + if args.type != "site" and args.mode in ("enable_all", "enable_selective"): + print('"enable_all" and "enable_selective" do not apply to workbook/project type') + return False + if (args.type is None) != (args.mode is None): + print("Use '--type --mode ' to update materialized views settings.") + return False + return True + + +def assert_site_enabled_for_materialized_views(server, site_content_url): + parent_site = server.sites.get_by_content_url(site_content_url) + if parent_site.materialized_views_mode == "disable": + print('Cannot update workbook/project because site is disabled for materialized views') + return False + return True + + +def assert_project_valid(project_name, projects): + if len(projects) == 0: + print("Cannot find project: {}".format(project_name)) + return False + return True + + +def find_projects_to_update(project, all_projects, projects_to_update): + # Use recursion to find all the sub-projects and enable/disable the workbooks in them + projects_to_update.append(project) + children_projects = [child for child in all_projects if child.parent_id == project.id] + if len(children_projects) == 0: + return + + for child in children_projects: + find_projects_to_update(child, all_projects, projects_to_update) + + +def sanitize_workbook_list(file_name, file_type): + if not os.path.isfile(file_name): + print("Invalid file name '{}'".format(file_name)) + return [] + file_list = open(file_name, "r") + + if file_type == "name": + return [workbook.rstrip() for workbook in file_list if not workbook.isspace()] + if file_type == "path": + return [workbook.rstrip() for workbook in file_list if not workbook.isspace()] + + +if __name__ == "__main__": + main() diff --git a/samples/name.txt b/samples/name.txt new file mode 100644 index 000000000..9db947b3d --- /dev/null +++ b/samples/name.txt @@ -0,0 +1,2 @@ +92 08 23 +Book2 \ No newline at end of file diff --git a/samples/refresh.py b/samples/refresh.py index 73aa7fb2f..58e3110f3 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -44,7 +44,7 @@ def main(): resource = server.workbooks.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) + results = server.workbooks.refresh(args.resource_id) else: # Get the datasource by its Id to make sure it exists resource = server.datasources.get_by_id(args.resource_id) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 3f2970281..85972d48b 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -3,7 +3,7 @@ GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ - SubscriptionItem + SubscriptionItem, Target from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager from ._version import get_versions diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 710831e07..63a861cbb 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -11,6 +11,7 @@ from .server_info_item import ServerInfoItem from .site_item import SiteItem from .tableau_auth import TableauAuth +from .target import Target from .task_item import TaskItem from .user_item import UserItem from .view_item import ViewItem diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 894cabe62..829564839 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -44,6 +44,7 @@ def from_response(cls, resp, ns): connection_item = cls() connection_item._id = connection_xml.get('id', None) connection_item._connection_type = connection_xml.get('type', None) + connection_item.embed_password = string_to_bool(connection_xml.get('embedPassword', '')) connection_item.server_address = connection_xml.get('serverAddress', None) connection_item.server_port = connection_xml.get('serverPort', None) connection_item.username = connection_xml.get('userName', None) @@ -82,3 +83,8 @@ def from_xml_element(cls, parsed_response, ns): connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials) return all_connection_items + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s): + return s.lower() == 'true' diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index f8a8662a8..a4ef0ef3f 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -136,3 +136,19 @@ def wrapper(self, value): dt = parse_datetime(value) return func(self, dt) return wrapper + + +def property_is_materialized_views_config(func): + @wraps(func) + def wrapper(self, value): + if not isinstance(value, dict): + raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, + func.__name__)) + if len(value) != 2 or not all(attr in value.keys() for attr in ('materialized_views_enabled', + 'run_materialization_now')): + error = "{} should have 2 keys ".format(func.__name__) + error += "'materialized_views_enabled' and 'run_materialization_now'" + error += "instead you have {}".format(value.keys()) + raise ValueError(error) + return func(self, value) + return wrapper diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 3e97ccc15..11c403764 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -9,6 +9,7 @@ class ScheduleItem(object): class Type: Extract = "Extract" + Flow = "Flow" Subscription = "Subscription" class ExecutionOrder: diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 6ee64e227..21031ff80 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -17,7 +17,7 @@ class State: def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_quota=None, disable_subscriptions=False, subscribe_others_enabled=True, revision_history_enabled=False, - revision_limit=None): + revision_limit=None, materialized_views_mode=None): self._admin_mode = None self._id = None self._num_users = None @@ -33,6 +33,7 @@ def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_ self.revision_history_enabled = revision_history_enabled self.subscribe_others_enabled = subscribe_others_enabled self.admin_mode = admin_mode + self.materialized_views_mode = materialized_views_mode @property def admin_mode(self): @@ -123,6 +124,14 @@ def subscribe_others_enabled(self): def subscribe_others_enabled(self, value): self._subscribe_others_enabled = value + @property + def materialized_views_mode(self): + return self._materialized_views_mode + + @materialized_views_mode.setter + def materialized_views_mode(self, value): + self._materialized_views_mode = value + def is_default(self): return self.name.lower() == 'default' @@ -132,16 +141,17 @@ def _parse_common_tags(self, site_xml, ns): if site_xml is not None: (_, name, content_url, _, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage) = self._parse_element(site_xml, ns) + user_quota, storage_quota, revision_limit, num_users, storage, + materialized_views_mode) = self._parse_element(site_xml, ns) self._set_values(None, name, content_url, None, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage) + revision_limit, num_users, storage, materialized_views_mode) return self def _set_values(self, id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage): + user_quota, storage_quota, revision_limit, num_users, storage, materialized_views_mode): if id is not None: self._id = id if name: @@ -170,6 +180,8 @@ def _set_values(self, id, name, content_url, status_reason, admin_mode, state, self._num_users = num_users if storage: self._storage = storage + if materialized_views_mode: + self._materialized_views_mode = materialized_views_mode @classmethod def from_response(cls, resp, ns): @@ -179,12 +191,13 @@ def from_response(cls, resp, ns): for site_xml in all_site_xml: (id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage) = cls._parse_element(site_xml, ns) + revision_limit, num_users, storage, materialized_views_mode) = cls._parse_element(site_xml, ns) site_item = cls(name, content_url) site_item._set_values(id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage) + user_quota, storage_quota, revision_limit, num_users, storage, + materialized_views_mode) all_site_items.append(site_item) return all_site_items @@ -219,9 +232,11 @@ def _parse_element(site_xml, ns): num_users = usage_elem.get('numUsers', None) storage = usage_elem.get('storage', None) + materialized_views_mode = site_xml.get('materializedViewsMode', '') + return id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled,\ disable_subscriptions, revision_history_enabled, user_quota, storage_quota,\ - revision_limit, num_users, storage + revision_limit, num_users, storage, materialized_views_mode # Used to convert string represented boolean to a boolean type diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 47d12b662..48e942ece 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -22,7 +22,9 @@ class Roles: ReadOnly = 'ReadOnly' SiteAdministratorCreator = 'SiteAdministratorCreator' SiteAdministratorExplorer = 'SiteAdministratorExplorer' - UnlicensedWithPublish = 'UnlicensedWithPublish' + + # Online only + SupportUser = 'SupportUser' class Auth: SAML = 'SAML' diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index bcf13b9ac..8df036516 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean +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 ..datetime_helpers import parse_datetime @@ -14,6 +14,7 @@ def __init__(self, project_id, name=None, show_tabs=False): self._created_at = None self._id = None self._initial_tags = set() + self._pdf = None self._preview_image = None self._project_name = None self._size = None @@ -24,6 +25,8 @@ def __init__(self, project_id, name=None, show_tabs=False): self.project_id = project_id self.show_tabs = show_tabs self.tags = set() + self.materialized_views_config = {'materialized_views_enabled': None, + 'run_materialization_now': None} @property def connections(self): @@ -44,6 +47,13 @@ def created_at(self): def id(self): return self._id + @property + def pdf(self): + if self._pdf is None: + error = "Workbook item must be populated with its pdf first." + raise UnpopulatedPropertyError(error) + return self._pdf() + @property def preview_image(self): if self._preview_image is None: @@ -98,12 +108,24 @@ def views(self): # We had views included in a WorkbookItem response return self._views + @property + def materialized_views_config(self): + return self._materialized_views_config + + @materialized_views_config.setter + @property_is_materialized_views_config + def materialized_views_config(self, value): + self._materialized_views_config = value + def _set_connections(self, connections): self._connections = connections def _set_views(self, views): self._views = views + def _set_pdf(self, pdf): + self._pdf = pdf + def _set_preview_image(self, preview_image): self._preview_image = preview_image @@ -112,15 +134,18 @@ def _parse_common_tags(self, workbook_xml, ns): workbook_xml = ET.fromstring(workbook_xml).find('.//t:workbook', namespaces=ns) if workbook_xml is not None: (_, _, _, _, updated_at, _, show_tabs, - project_id, project_name, owner_id, _, _) = self._parse_element(workbook_xml, ns) + project_id, project_name, owner_id, _, _, + materialized_views_config) = self._parse_element(workbook_xml, ns) self._set_values(None, None, None, None, updated_at, - None, show_tabs, project_id, project_name, owner_id, None, None) + 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, - size, show_tabs, project_id, project_name, owner_id, tags, views): + size, show_tabs, project_id, project_name, owner_id, tags, views, + materialized_views_config): if id is not None: self._id = id if name: @@ -146,6 +171,8 @@ def _set_values(self, id, name, content_url, created_at, updated_at, self._initial_tags = copy.copy(tags) if views: self._views = views + if materialized_views_config is not None: + self.materialized_views_config = materialized_views_config @classmethod def from_response(cls, resp, ns): @@ -154,11 +181,13 @@ def from_response(cls, resp, ns): 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, - project_id, project_name, owner_id, tags, views) = cls._parse_element(workbook_xml, ns) + 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, - size, show_tabs, None, project_name, owner_id, tags, views) + size, show_tabs, None, project_name, owner_id, tags, views, + materialized_views_config) all_workbook_items.append(workbook_item) return all_workbook_items @@ -199,8 +228,29 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) + materialized_views_config = {'materialized_views_enabled': None, 'run_materialization_now': None} + materialized_views_elem = workbook_xml.find('.//t:materializedViewsEnablementConfig', namespaces=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,\ - project_id, project_name, owner_id, tags, views + project_id, project_name, owner_id, tags, views, materialized_views_config + + +def parse_materialized_views_config(materialized_views_elem): + materialized_views_config = dict() + + materialized_views_enabled = materialized_views_elem.get('materializedViewsEnabled', None) + if materialized_views_enabled is not None: + materialized_views_enabled = string_to_bool(materialized_views_enabled) + + run_materialization_now = materialized_views_elem.get('runMaterializationNow', None) + if run_materialization_now is not None: + run_materialization_now = string_to_bool(run_materialization_now) + + materialized_views_config['materialized_views_enabled'] = materialized_views_enabled + materialized_views_config['run_materialization_now'] = run_materialization_now + return materialized_views_config # Used to convert string represented boolean to a boolean type diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 904d27144..4d7a20b70 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,5 +1,5 @@ from .endpoint import Endpoint, api, parameter_added_in -from .exceptions import MissingRequiredFieldError +from .exceptions import InternalServerError, MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem @@ -196,7 +196,14 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None, file_contents, connection_credentials, connections) - server_response = self.post_request(url, xml_request, content_type) + + # Send the publishing request to server + try: + server_response = self.post_request(url, xml_request, content_type) + except InternalServerError as err: + if err.code == 504 and not as_job: + err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." + raise err if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 994d2133d..f16c9f8df 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,4 +1,4 @@ -from .exceptions import ServerResponseError, EndpointUnavailableError, ItemTypeNotAllowed +from .exceptions import ServerResponseError, InternalServerError from functools import wraps import logging @@ -62,7 +62,9 @@ def _make_request(self, method, url, content=None, request_object=None, def _check_status(self, server_response): logger.debug(self._safe_to_log(server_response)) - if server_response.status_code not in Success_codes: + if server_response.status_code >= 500: + raise InternalServerError(server_response) + elif server_response.status_code not in Success_codes: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) def get_unauthenticated_request(self, url, request_object=None): diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index d77cdea3e..080eca9c8 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -21,6 +21,15 @@ def from_response(cls, resp, ns): return error_response +class InternalServerError(Exception): + def __init__(self, server_response): + self.code = server_response.status_code + self.content = server_response.content + + def __str__(self): + return "\n\nError status code: {0}\n{1}".format(self.code, self.content) + + class MissingRequiredFieldError(Exception): pass diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index f3432d605..92285c3db 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -3,6 +3,12 @@ from ..request_options import RequestOptionsBase import logging +try: + basestring +except NameError: + # In case we are in python 3 the string check is different + basestring = str + logger = logging.getLogger('tableau.endpoint.jobs') diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 81b782c05..6d67fe69e 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -44,6 +44,17 @@ def get_by_name(self, site_name): server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] + # Gets 1 site by content url + @api(version="2.0") + def get_by_content_url(self, content_url): + if content_url is None: + error = "Content URL undefined." + raise ValueError(error) + logger.info('Querying single site (Content URL: {0})'.format(content_url)) + url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) + server_response = self.get_request(url) + return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] + # Update site @api(version="2.0") def update(self, site_item): diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 79b15f379..772ed79b9 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,5 +1,5 @@ from .endpoint import Endpoint, api, parameter_added_in -from .exceptions import MissingRequiredFieldError +from .exceptions import InternalServerError, MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem @@ -178,6 +178,25 @@ def _get_workbook_connections(self, workbook_item, req_options=None): connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections + # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled + @api(version="3.4") + def populate_pdf(self, workbook_item, req_options=None): + if not workbook_item.id: + error = "Workbook item missing ID." + raise MissingRequiredFieldError(error) + + def pdf_fetcher(): + return self._get_wb_pdf(workbook_item, req_options) + + workbook_item._set_pdf(pdf_fetcher) + logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) + + def _get_wb_pdf(self, workbook_item, req_options): + url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) + server_response = self.get_request(url, req_options) + pdf = server_response.content + return pdf + # Get preview image of workbook @api(version="2.0") def populate_preview_image(self, workbook_item): @@ -256,7 +275,15 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c 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) + + # Send the publishing request to server + try: + server_response = self.post_request(url, xml_request, content_type) + except InternalServerError as err: + if err.code == 504 and not as_job: + err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." + raise err + if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 78c927dda..92c0f0423 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -36,6 +36,13 @@ def __iter__(self): # Fetch the first page current_item_list, last_pagination_item = self._endpoint(self._options) + if last_pagination_item.total_available is None: + # This endpoint does not support pagination, drain the list and return + while current_item_list: + yield current_item_list.pop(0) + + return + # Get the rest on demand as a generator while self._count < last_pagination_item.total_available: if len(current_item_list) == 0: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index d479807d2..0e528d002 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -102,7 +102,7 @@ def publish_req(self, datasource_item, filename, file_contents, connection_crede 'tableau_datasource': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) - def publish_req_chunked(self, datasource_item, connection_credentials=None): + def publish_req_chunked(self, datasource_item, connection_credentials=None, connections=None): xml_request = self._generate_xml(datasource_item, connection_credentials, connections) parts = {'request_payload': ('', xml_request, 'text/xml')} @@ -290,6 +290,8 @@ def update_req(self, site_item): site_element.attrib['revisionLimit'] = str(site_item.revision_limit) if site_item.subscribe_others_enabled: site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() + if site_item.materialized_views_mode is not None: + site_element.attrib['materializedViewsMode'] = str(site_item.materialized_views_mode).lower() return ET.tostring(xml_request) def create_req(self, site_item): @@ -380,6 +382,14 @@ 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 is not None: + 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() + return ET.tostring(xml_request) def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None, connections=None): @@ -391,7 +401,7 @@ def publish_req(self, workbook_item, filename, file_contents, connection_credent 'tableau_workbook': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) - def publish_req_chunked(self, workbook_item, connections=None): + def publish_req_chunked(self, workbook_item, connection_credentials=None, connections=None): xml_request = self._generate_xml(workbook_item, connection_credentials=connection_credentials, connections=connections) @@ -412,8 +422,8 @@ def update_req(self, xml_request, connection_item): connection_element.attrib['userName'] = connection_item.username if connection_item.password: connection_element.attrib['password'] = connection_item.password - if connection_item.embed_password: - connection_element.attrib['embedPassword'] = str(connection_item.embed_password) + if connection_item.embed_password is not None: + connection_element.attrib['embedPassword'] = str(connection_item.embed_password).lower() class TaskRequest(object): diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 0e3601a25..9f3247e7b 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -108,14 +108,17 @@ class ImageRequestOptions(_FilterOptionsBase): class Resolution: High = 'high' - def __init__(self, imageresolution=None): + def __init__(self, imageresolution=None, maxage=None): super(ImageRequestOptions, self).__init__() self.image_resolution = imageresolution + self.max_age = maxage def apply_query_params(self, url): params = [] if self.image_resolution: params.append('resolution={0}'.format(self.image_resolution)) + if self.max_age: + params.append('maxAge={0}'.format(self.max_age)) self._append_view_filters(params) diff --git a/test/assets/datasource_populate_connections.xml b/test/assets/datasource_populate_connections.xml index 442a78323..eaaa24934 100644 --- a/test/assets/datasource_populate_connections.xml +++ b/test/assets/datasource_populate_connections.xml @@ -1,8 +1,7 @@ - - - + + \ No newline at end of file diff --git a/test/assets/schedule_get.xml b/test/assets/schedule_get.xml index 3d8578ede..66e4d6e51 100644 --- a/test/assets/schedule_get.xml +++ b/test/assets/schedule_get.xml @@ -4,5 +4,6 @@ + \ No newline at end of file diff --git a/test/assets/site_update.xml b/test/assets/site_update.xml index ade302fef..716314d29 100644 --- a/test/assets/site_update.xml +++ b/test/assets/site_update.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/test/assets/subscription_create.xml b/test/assets/subscription_create.xml new file mode 100644 index 000000000..48f391416 --- /dev/null +++ b/test/assets/subscription_create.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update.xml b/test/assets/workbook_update.xml index 2470347a8..7a72759d8 100644 --- a/test/assets/workbook_update.xml +++ b/test/assets/workbook_update.xml @@ -4,5 +4,6 @@ + \ No newline at end of file diff --git a/test/test_datasource.py b/test/test_datasource.py index 1b21c0194..0563d2af7 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -4,6 +4,7 @@ import xml.etree.ElementTree as ET import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.request_factory import RequestFactory from ._utils import read_xml_asset, read_xml_assets, asset @@ -140,15 +141,21 @@ def test_populate_connections(self): single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' self.server.datasources.populate_connections(single_datasource) - self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) - connections = single_datasource.connections - self.assertTrue(connections) - ds1, ds2, ds3 = connections - self.assertEqual(ds1.id, 'be786ae0-d2bf-4a4b-9b34-e2de8d2d4488') - self.assertEqual(ds2.id, '970e24bc-e200-4841-a3e9-66e7d122d77e') - self.assertEqual(ds3.id, '7d85b889-283b-42df-b23e-3c811e402f1f') + + self.assertTrue(connections) + ds1, ds2 = connections + self.assertEqual('be786ae0-d2bf-4a4b-9b34-e2de8d2d4488', ds1.id) + self.assertEqual('textscan', ds1.connection_type) + self.assertEqual('forty-two.net', ds1.server_address) + self.assertEqual('duo', ds1.username) + self.assertEqual(True, ds1.embed_password) + self.assertEqual('970e24bc-e200-4841-a3e9-66e7d122d77e', ds2.id) + self.assertEqual('sqlserver', ds2.connection_type) + self.assertEqual('database.com', ds2.server_address) + self.assertEqual('heero', ds2.username) + self.assertEqual(False, ds2.embed_password) def test_update_connection(self): populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTION_XML) @@ -313,3 +320,14 @@ def test_credentials_and_multi_connect_raises_exception(self): response = RequestFactory.Datasource._generate_xml(new_datasource, connection_credentials=connection_creds, connections=[connection1]) + + def test_synchronous_publish_timeout_error(self): + with requests_mock.mock() as m: + m.register_uri('POST', self.baseurl, status_code=504) + + 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) diff --git a/test/test_requests.py b/test/test_requests.py index 686a4bbb4..80216ec85 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -5,6 +5,8 @@ import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import InternalServerError + class RequestTests(unittest.TestCase): def setUp(self): @@ -45,3 +47,11 @@ def test_make_post_request(self): self.assertEqual(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') self.assertEqual(resp.request.headers['content-type'], 'multipart/mixed') self.assertEqual(resp.request.body, b'1337') + + # Test that 500 server errors are handled properly + def test_internal_server_error(self): + self.server.version = "3.2" + 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) diff --git a/test/test_schedule.py b/test/test_schedule.py index a9ae9bb67..b5aadcbca 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -36,24 +36,37 @@ def test_get(self): m.get(self.baseurl, text=response_xml) all_schedules, pagination_item = self.server.schedules.get() + extract = all_schedules[0] + subscription = all_schedules[1] + flow = all_schedules[2] + self.assertEqual(2, pagination_item.total_available) - self.assertEqual("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", all_schedules[0].id) - self.assertEqual("Weekday early mornings", all_schedules[0].name) - self.assertEqual("Active", all_schedules[0].state) - self.assertEqual(50, all_schedules[0].priority) - self.assertEqual("2016-07-06T20:19:00Z", format_datetime(all_schedules[0].created_at)) - self.assertEqual("2016-09-13T11:00:32Z", format_datetime(all_schedules[0].updated_at)) - self.assertEqual("Extract", all_schedules[0].schedule_type) - self.assertEqual("2016-09-14T11:00:00Z", format_datetime(all_schedules[0].next_run_at)) - - self.assertEqual("bcb79d07-6e47-472f-8a65-d7f51f40c36c", all_schedules[1].id) - self.assertEqual("Saturday night", all_schedules[1].name) - self.assertEqual("Active", all_schedules[1].state) - self.assertEqual(80, all_schedules[1].priority) - self.assertEqual("2016-07-07T20:19:00Z", format_datetime(all_schedules[1].created_at)) - self.assertEqual("2016-09-12T16:39:38Z", format_datetime(all_schedules[1].updated_at)) - self.assertEqual("Subscription", all_schedules[1].schedule_type) - self.assertEqual("2016-09-18T06:00:00Z", format_datetime(all_schedules[1].next_run_at)) + self.assertEqual("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", extract.id) + self.assertEqual("Weekday early mornings", extract.name) + self.assertEqual("Active", extract.state) + self.assertEqual(50, extract.priority) + self.assertEqual("2016-07-06T20:19:00Z", format_datetime(extract.created_at)) + self.assertEqual("2016-09-13T11:00:32Z", format_datetime(extract.updated_at)) + self.assertEqual("Extract", extract.schedule_type) + self.assertEqual("2016-09-14T11:00:00Z", format_datetime(extract.next_run_at)) + + self.assertEqual("bcb79d07-6e47-472f-8a65-d7f51f40c36c", subscription.id) + self.assertEqual("Saturday night", subscription.name) + self.assertEqual("Active", subscription.state) + self.assertEqual(80, subscription.priority) + self.assertEqual("2016-07-07T20:19:00Z", format_datetime(subscription.created_at)) + self.assertEqual("2016-09-12T16:39:38Z", format_datetime(subscription.updated_at)) + self.assertEqual("Subscription", subscription.schedule_type) + self.assertEqual("2016-09-18T06:00:00Z", format_datetime(subscription.next_run_at)) + + self.assertEqual("f456e8f2-aeb2-4a8e-b823-00b6f08640f0", flow.id) + self.assertEqual("First of the month 1:00AM", flow.name) + self.assertEqual("Active", flow.state) + self.assertEqual(50, flow.priority) + self.assertEqual("2019-02-19T18:52:19Z", format_datetime(flow.created_at)) + self.assertEqual("2019-02-19T18:55:51Z", format_datetime(flow.updated_at)) + self.assertEqual("Flow", flow.schedule_type) + self.assertEqual("2019-03-01T09:00:00Z", format_datetime(flow.next_run_at)) def test_get_empty(self): with open(GET_EMPTY_XML, "rb") as f: diff --git a/test/test_site.py b/test/test_site.py index 8113613ca..9603e73c2 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -91,7 +91,8 @@ def test_update(self): single_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, user_quota=15, storage_quota=1000, - disable_subscriptions=True, revision_history_enabled=False) + disable_subscriptions=True, revision_history_enabled=False, + materialized_views_mode='disable') single_site._id = '6b7179ba-b82b-4f0f-91ed-812074ac5da6' single_site = self.server.sites.update(single_site) @@ -104,6 +105,7 @@ def test_update(self): self.assertEqual(13, single_site.revision_limit) self.assertEqual(True, single_site.disable_subscriptions) self.assertEqual(15, single_site.user_quota) + self.assertEqual('disable', single_site.materialized_views_mode) def test_update_missing_id(self): single_site = TSC.SiteItem('test', 'test') diff --git a/test/test_subscription.py b/test/test_subscription.py index 50fc7046f..2e4b1eadf 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -5,6 +5,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +CREATE_XML = os.path.join(TEST_ASSET_DIR, "subscription_create.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "subscription_get.xml") GET_XML_BY_ID = os.path.join(TEST_ASSET_DIR, "subscription_get_by_id.xml") @@ -48,3 +49,26 @@ def test_get_subscription_by_id(self): self.assertEqual('c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e', subscription.user_id) self.assertEqual('Not Found Alert', subscription.subject) self.assertEqual('7617c389-cdca-4940-a66e-69956fcebf3e', subscription.schedule_id) + + def test_create_subscription(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) + + target_item = TSC.Target("960e61f2-1838-40b2-bba2-340c9492f943", "workbook") + new_subscription = TSC.SubscriptionItem("subject", "4906c453-d5ec-4972-9ff4-789b629bdfa2", + "8d30c8de-0a5f-4bee-b266-c621b4f3eed0", target_item) + new_subscription = self.server.subscriptions.create(new_subscription) + + self.assertEqual("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", new_subscription.id) + self.assertEqual("sub_name", new_subscription.subject) + self.assertEqual("960e61f2-1838-40b2-bba2-340c9492f943", new_subscription.target.id) + self.assertEqual("Workbook", new_subscription.target.type) + self.assertEqual("4906c453-d5ec-4972-9ff4-789b629bdfa2", new_subscription.schedule_id) + self.assertEqual("8d30c8de-0a5f-4bee-b266-c621b4f3eed0", new_subscription.user_id) + + def test_delete_subscription(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc', status_code=204) + self.server.subscriptions.delete('78e9318d-2d29-4d67-b60f-3f2f5fd89ecc') diff --git a/test/test_workbook.py b/test/test_workbook.py index d4e2275f4..ae814c0b2 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -5,7 +5,9 @@ import xml.etree.ElementTree as ET from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.request_factory import RequestFactory +from ._utils import asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -14,6 +16,7 @@ GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_empty.xml') GET_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get.xml') POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_connections.xml') +POPULATE_PDF = os.path.join(TEST_ASSET_DIR, 'populate_pdf.pdf') POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, 'RESTAPISample Image.png') POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views.xml') POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views_usage.xml') @@ -119,6 +122,8 @@ def test_update(self): single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' single_workbook.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' single_workbook.name = 'renamedWorkbook' + single_workbook.materialized_views_config = {'materialized_views_enabled': True, + 'run_materialization_now': False} single_workbook = self.server.workbooks.update(single_workbook) self.assertEqual('1f951daf-4061-451a-9df1-69a8062664f2', single_workbook.id) @@ -126,6 +131,8 @@ def test_update(self): 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) + self.assertEqual(True, single_workbook.materialized_views_config['materialized_views_enabled']) + self.assertEqual(False, single_workbook.materialized_views_config['run_materialization_now']) def test_update_missing_id(self): single_workbook = TSC.WorkbookItem('test') @@ -269,6 +276,24 @@ def test_populate_connections_missing_id(self): self.server.workbooks.populate_connections, single_workbook) + def test_populate_pdf(self): + self.server.version = "3.4" + self.baseurl = self.server.workbooks.baseurl + with open(POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", + content=response) + single_workbook = TSC.WorkbookItem('test') + single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + + type = TSC.PDFRequestOptions.PageType.A5 + orientation = TSC.PDFRequestOptions.Orientation.Landscape + req_option = TSC.PDFRequestOptions(type, orientation) + + self.server.workbooks.populate_pdf(single_workbook, req_option) + self.assertEqual(response, single_workbook.pdf) + def test_populate_preview_image(self): with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: response = f.read() @@ -296,11 +321,11 @@ def test_publish(self): show_tabs=False, project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - sample_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') publish_mode = self.server.PublishMode.CreateNew new_workbook = self.server.workbooks.publish(new_workbook, - sample_workbok, + sample_workbook, publish_mode) self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) @@ -327,11 +352,11 @@ def test_publish_async(self): show_tabs=False, project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - sample_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') publish_mode = self.server.PublishMode.CreateNew new_job = self.server.workbooks.publish(new_workbook, - sample_workbok, + sample_workbook, publish_mode, as_job=True) @@ -398,3 +423,13 @@ def test_credentials_and_multi_connect_raises_exception(self): response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds, connections=[connection1]) + + def test_synchronous_publish_timeout_error(self): + with requests_mock.mock() as m: + m.register_uri('POST', self.baseurl, status_code=504) + + 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)