diff --git a/.gitignore b/.gitignore index 5f5db36d7..36c353401 100644 --- a/.gitignore +++ b/.gitignore @@ -92,7 +92,8 @@ ENV/ # Rope project settings .ropeproject - +# VSCode project settings +.vscode/ # macOS.gitignore from https://github.com/github/gitignore *.DS_Store diff --git a/.travis.yml b/.travis.yml index 68cee02ad..41316d700 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ python: - "3.5" - "3.6" - "3.7" + - "3.8" # command to install dependencies install: - "pip install -e ." diff --git a/CHANGELOG.md b/CHANGELOG.md index d0da3f294..a0e8333e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 0.13 (1 Sept 2020) +* Added notes field to JobItem (#571) +* Added webpage_url field to WorkbookItem (#661) +* Added support for switching between sites (#655) +* Added support for querying favorites for a user (#656) +* Added support for Python 3.8 (#659) +* Added support for Data Alerts (#667) +* Added support for basic Extract operations - Create, Delete, en/re/decrypt for site (#672) +* Added support for creating and querying Active Directory groups (#674) +* Added support for asynchronously updating a group (#674) +* Improved handling of invalid dates (#529) +* Improved consistency of update_permission endpoints (#668) +* Documentation updates (#658, #669, #670, #673, #683) + ## 0.12.1 (22 July 2020) * Fixed login.py sample to properly handle sitename (#652) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e5c80d4ac..1f9714c6d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -37,6 +37,8 @@ The following people have contributed to this project to make it possible, and w * [Jordan Woods](https://github.com/jorwoods) * [Reba Magier](https://github.com/rmagier1) * [Stephen Mitchell](https://github.com/scuml) +* [absentmoose](https://github.com/absentmoose) +* [Paul Vickers](https://github.com/paulvic) ## Core Team @@ -49,3 +51,4 @@ 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) +* [Brian Cantoni](https://github.com/bcantoni) diff --git a/README.md b/README.md index 51e23549a..e2c30704a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Tableau Server Client (Python) -[![Tableau Supported](https://img.shields.io/badge/Support%20Level-Tableau%20Supported-53bd92.svg)](https://www.tableau.com/support-levels-it-and-developer-tools) + +[![Tableau Supported](https://img.shields.io/badge/Support%20Level-Tableau%20Supported-53bd92.svg)](https://www.tableau.com/support-levels-it-and-developer-tools) [![Build Status](https://travis-ci.org/tableau/server-client-python.svg?branch=master)](https://travis-ci.org/tableau/server-client-python) Use the Tableau Server Client (TSC) library to increase your productivity as you interact with the Tableau Server REST API. With the TSC library you can do almost everything that you can do with the REST API, including: @@ -7,8 +8,7 @@ Use the Tableau Server Client (TSC) library to increase your productivity as you * Create users and groups. * Query projects, sites, and more. -This repository contains Python source code and sample files. +This repository contains Python source code and sample files. Python versions 3.5 and up are supported. For more information on installing and using TSC, see the documentation: - diff --git a/contributing.md b/contributing.md index 4c7cdef00..c7f487ec3 100644 --- a/contributing.md +++ b/contributing.md @@ -15,7 +15,7 @@ a feature do not require the CLA. ## Issues and Feature Requests -To submit an issue/bug report, or to request a feature, please submit a [github issue](https://github.com/tableau/server-client-python/issues) to the repo. +To submit an issue/bug report, or to request a feature, please submit a [GitHub issue](https://github.com/tableau/server-client-python/issues) to the repo. If you are submitting a bug report, please provide as much information as you can, including clear and concise repro steps, attaching any necessary files to assist in the repro. **Be sure to scrub the files of any potentially sensitive information. Issues are public.** @@ -48,19 +48,24 @@ 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](https://tableau.github.io/server-client-python/docs/dev-guide) +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 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 + +```shell +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. + +```shell +pycodestyle tableauserverclient test samples +``` diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index b6dbdd479..63c38f53d 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -17,7 +17,7 @@ def main(): - parser = argparse.ArgumentParser(description='Add workbook default permission for a given project') + parser = argparse.ArgumentParser(description='Add workbook default permissions for a given project.') 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, help='Site to sign into - default site if not provided') diff --git a/samples/create_group.py b/samples/create_group.py index c6865bc56..7f9dc1e96 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -1,5 +1,5 @@ #### -# This script demonstrates how to create groups using the Tableau +# This script demonstrates how to create a group using the Tableau # Server Client. # # To run the script, you must have installed Python 3.5 or later. @@ -17,7 +17,7 @@ def main(): - parser = argparse.ArgumentParser(description='Creates sample schedules for each type of frequency.') + parser = argparse.ArgumentParser(description='Creates a sample user group.') 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('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', diff --git a/samples/create_project.py b/samples/create_project.py index ac55da17e..0380cb8a0 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -26,7 +26,7 @@ def create_project(server, project_item): def main(): - parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + parser = argparse.ArgumentParser(description='Create new projects.') 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) diff --git a/samples/download_view_image.py b/samples/download_view_image.py index ce6dd3165..07162eebf 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -17,7 +17,7 @@ def main(): - parser = argparse.ArgumentParser(description='Query View Image From Server') + parser = argparse.ArgumentParser(description='Download image of a specified view.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--site-id', '-si', required=False, help='content url for site the view is on') diff --git a/samples/export.py b/samples/export.py index 67b3319a8..b8cd01140 100644 --- a/samples/export.py +++ b/samples/export.py @@ -1,3 +1,10 @@ +#### +# This script demonstrates how to export a view using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.5 or later. +#### + import argparse import getpass import logging @@ -6,7 +13,7 @@ def main(): - parser = argparse.ArgumentParser(description='Export a view as an image, pdf, or csv') + parser = argparse.ArgumentParser(description='Export a view as an image, PDF, or CSV') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--site', '-S', default=None) diff --git a/samples/export_wb.py b/samples/export_wb.py index 8d3640ab4..334d57c89 100644 --- a/samples/export_wb.py +++ b/samples/export_wb.py @@ -1,9 +1,12 @@ -# +#### # This sample uses the PyPDF2 library for combining pdfs together to get the full pdf for all the views in a # workbook. # # You will need to do `pip install PyPDF2` to use this sample. # +# To run the script, you must have installed Python 3.5 or later. +#### + import argparse import getpass @@ -48,7 +51,7 @@ def cleanup(tempdir): def main(): - parser = argparse.ArgumentParser(description='Export to PDF all of the views in a workbook') + parser = argparse.ArgumentParser(description='Export to PDF all of the views in a workbook.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--site', '-S', default=None, help='Site to log into, do not specify for default site') parser.add_argument('--username', '-u', required=True, help='username to sign into server') diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index fa0c2318e..f8123a29c 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -1,5 +1,5 @@ #### -# This script demonstrates how to filter groups using the Tableau +# This script demonstrates how to filter and sort groups using the Tableau # Server Client. # # To run the script, you must have installed Python 3.5 or later. @@ -24,7 +24,7 @@ def create_example_group(group_name='Example Group', server=None): def main(): - parser = argparse.ArgumentParser(description='Filter on groups') + parser = argparse.ArgumentParser(description='Filter and sort groups.') 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('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 91633f38f..0c62614b0 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -2,7 +2,6 @@ # This script demonstrates how to use the Tableau Server Client # to filter and sort on the name of the projects present on site. # -# # To run the script, you must have installed Python 3.5 or later. #### @@ -26,7 +25,7 @@ def create_example_project(name='Example Project', content_permissions='LockedTo def main(): - parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + parser = argparse.ArgumentParser(description='Filter and sort projects.') 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) diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 9d3c7836a..1aeb7298e 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -12,7 +12,7 @@ def main(): - parser = argparse.ArgumentParser(description='Cancel all of the running background jobs') + parser = argparse.ArgumentParser(description='Cancel all of the running background jobs.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--site', '-S', default=None, help='site to log into, do not specify for default site') parser.add_argument('--username', '-u', required=True, help='username to sign into server') diff --git a/samples/list.py b/samples/list.py index 84b3c70d2..10e11ac04 100644 --- a/samples/list.py +++ b/samples/list.py @@ -14,7 +14,7 @@ def main(): - parser = argparse.ArgumentParser(description='List out the names and LUIDs for different resource types') + 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="", help='site to log into, do not specify for default site') parser.add_argument('--token-name', '-n', required=True, help='username to signin under') diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index 25effd7b2..6779023ba 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -19,7 +19,7 @@ def main(): - parser = argparse.ArgumentParser(description='Return a list of all of the workbooks on your server') + parser = argparse.ArgumentParser(description='Demonstrate pagination on the list of workbooks on the server.') 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('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 48120f398..a253adc9a 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -14,7 +14,7 @@ def main(): - parser = argparse.ArgumentParser(description='Query permissions of a given resource') + parser = argparse.ArgumentParser(description='Query permissions of a given resource.') 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, help='Site to sign into - default site if not provided') diff --git a/samples/refresh.py b/samples/refresh.py index ba3a2f183..96937a6e3 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -12,7 +12,7 @@ def main(): - parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + parser = argparse.ArgumentParser(description='Trigger a refresh task on a workbook or datasource.') 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) diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index edb94f47e..2d4761560 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -1,3 +1,11 @@ +#### +# This script demonstrates how to set the refresh schedule for +# a workbook or datasource. +# +# To run the script, you must have installed Python 3.5 or later. +#### + + import argparse import getpass import logging @@ -6,7 +14,7 @@ def usage(args): - parser = argparse.ArgumentParser(description='Explore workbook functions supported by the Server API.') + parser = argparse.ArgumentParser(description='Set refresh schedule for a workbook or datasource.') 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('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', diff --git a/setup.py b/setup.py index 58fa03311..5586e4716 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,6 @@ setup_requires=pytest_runner, install_requires=[ 'requests>=2.11,<3.0', - 'urllib3>=1.24.3,<2.0' ], tests_require=[ 'requests-mock>=1.0,<2.0', diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index bb938c8fa..b438d8a2e 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,5 +1,5 @@ from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import ConnectionCredentials, ConnectionItem, DatasourceItem,\ +from .models import ConnectionCredentials, ConnectionItem, DataAlertItem, DatasourceItem,\ GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem,\ SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError,\ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem,\ diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 95041f8e1..2b1df202c 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -28,8 +28,14 @@ def parse_datetime(date): if date is None: return None - return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) + try: + return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) + except ValueError: + return None def format_datetime(date): + if date is None: + return None + return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index c86057a3d..dff12a29d 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -2,6 +2,7 @@ from .connection_item import ConnectionItem from .column_item import ColumnItem from .data_acceleration_report_item import DataAccelerationReportItem +from .data_alert_item import DataAlertItem from .datasource_item import DatasourceItem from .database_item import DatabaseItem from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py new file mode 100644 index 000000000..559050b4b --- /dev/null +++ b/tableauserverclient/models/data_alert_item.py @@ -0,0 +1,197 @@ +import xml.etree.ElementTree as ET + +from .property_decorators import property_not_empty, property_is_enum, property_is_boolean +from .user_item import UserItem +from .view_item import ViewItem + + +class DataAlertItem(object): + class Frequency: + Once = 'Once' + Frequently = 'Frequently' + Hourly = 'Hourly' + Daily = 'Daily' + Weekly = 'Weekly' + + def __init__(self): + self._id = None + self._subject = None + self._creatorId = None + self._createdAt = None + self._updatedAt = None + self._frequency = None + self._public = None + self._owner_id = None + self._owner_name = None + self._view_id = None + self._view_name = None + self._workbook_id = None + self._workbook_name = None + self._project_id = None + self._project_name = None + self._recipients = None + + def __repr__(self): + return "".format(**self.__dict__) + + @property + def id(self): + return self._id + + @property + def subject(self): + return self._subject + + @subject.setter + @property_not_empty + def subject(self, value): + self._subject = value + + @property + def frequency(self): + return self._frequency + + @frequency.setter + @property_is_enum(Frequency) + def frequency(self, value): + self._frequency = value + + @property + def public(self): + return self._public + + @public.setter + @property_is_boolean + def public(self, value): + self._public = value + + @property + def creatorId(self): + return self._creatorId + + @property + def recipients(self): + return self._recipients or list() + + @property + def createdAt(self): + return self._createdAt + + @property + def updatedAt(self): + return self._updatedAt + + @property + def owner_id(self): + return self._owner_id + + @property + def owner_name(self): + return self._owner_name + + @property + def view_id(self): + return self._view_id + + @property + def view_name(self): + return self._view_name + + @property + def workbook_id(self): + return self._workbook_id + + @property + def workbook_name(self): + return self._workbook_name + + @property + def project_id(self): + return self._project_id + + @property + def project_name(self): + return self._project_name + + def _set_values(self, id, subject, creatorId, createdAt, updatedAt, + frequency, public, recipients, owner_id, owner_name, + view_id, view_name, workbook_id, workbook_name, project_id, + project_name): + if id is not None: + self._id = id + if subject: + self._subject = subject + if creatorId: + self._creatorId = creatorId + if createdAt: + self._createdAt = createdAt + if updatedAt: + self._updatedAt = updatedAt + if frequency: + self._frequency = frequency + if public: + self._public = public + if owner_id: + self._owner_id = owner_id + if owner_name: + self._owner_name = owner_name + if view_id: + self._view_id = view_id + if view_name: + self._view_name = view_name + if workbook_id: + self._workbook_id = workbook_id + if workbook_name: + self._workbook_name = workbook_name + if project_id: + self._project_id = project_id + if project_name: + self._project_name = project_name + if recipients: + self._recipients = recipients + + @classmethod + def from_response(cls, resp, ns): + all_alert_items = list() + parsed_response = ET.fromstring(resp) + all_alert_xml = parsed_response.findall('.//t:dataAlert', namespaces=ns) + + for alert_xml in all_alert_xml: + kwargs = cls._parse_element(alert_xml, ns) + alert_item = cls() + alert_item._set_values(**kwargs) + all_alert_items.append(alert_item) + + return all_alert_items + + @staticmethod + def _parse_element(alert_xml, ns): + kwargs = dict() + kwargs['id'] = alert_xml.get('id', None) + kwargs['subject'] = alert_xml.get('subject', None) + kwargs['creatorId'] = alert_xml.get('creatorId', None) + kwargs['createdAt'] = alert_xml.get('createdAt', None) + kwargs['updatedAt'] = alert_xml.get('updatedAt', None) + kwargs['frequency'] = alert_xml.get('frequency', None) + kwargs['public'] = alert_xml.get('public', None) + + owner = alert_xml.findall('.//t:owner', namespaces=ns)[0] + kwargs['owner_id'] = owner.get('id', None) + kwargs['owner_name'] = owner.get('name', None) + + view_response = alert_xml.findall('.//t:view', namespaces=ns)[0] + kwargs['view_id'] = view_response.get('id', None) + kwargs['view_name'] = view_response.get('name', None) + + workbook_response = view_response.findall('.//t:workbook', namespaces=ns)[0] + kwargs['workbook_id'] = workbook_response.get('id', None) + kwargs['workbook_name'] = workbook_response.get('name', None) + project_response = view_response.findall('.//t:project', namespaces=ns)[0] + kwargs['project_id'] = project_response.get('id', None) + kwargs['project_name'] = project_response.get('name', None) + + recipients = alert_xml.findall('.//t:recipient', namespaces=ns) + kwargs['recipients'] = [recipient.get('id', None) for recipient in recipients] + + return kwargs diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index d37769006..ba9beec27 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,7 +1,8 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_empty +from .property_decorators import property_not_empty, property_is_enum from .reference_item import ResourceReference +from .user_item import UserItem class GroupItem(object): @@ -13,11 +14,17 @@ def __init__(self, name=None): self._id = None self._users = None self.name = name + self._license_mode = None + self._minimum_site_role = None @property def domain_name(self): return self._domain_name + @domain_name.setter + def domain_name(self, value): + self._domain_name = value + @property def id(self): return self._id @@ -31,6 +38,24 @@ def name(self): def name(self, value): self._name = value + @property + def license_mode(self): + return self._license_mode + + @license_mode.setter + def license_mode(self, value): + # valid values = onSync, onLogin + self._license_mode = value + + @property + def minimum_site_role(self): + return self._minimum_site_role + + @minimum_site_role.setter + @property_is_enum(UserItem.Roles) + def minimum_site_role(self, value): + self._minimum_site_role = value + @property def users(self): if self._users is None: @@ -54,10 +79,18 @@ def from_response(cls, resp, ns): name = group_xml.get('name', None) group_item = cls(name) group_item._id = group_xml.get('id', None) + # AD groups have an extra element under this + import_elem = group_xml.find('.//t:import', namespaces=ns) + if (import_elem is not None): + group_item.domain_name = import_elem.get('domainName') + group_item.license_mode = import_elem.get('grantLicenseMode') + group_item.minimum_site_role = import_elem.get('siteRole') + else: + # local group, we will just have two extra attributes here + group_item.domain_name = 'local' + group_item.license_mode = group_xml.get('grantLicenseMode') + group_item.minimum_site_role = group_xml.get('siteRole') - domain_elem = group_xml.find('.//t:domain', namespaces=ns) - if domain_elem is not None: - group_item._domain_name = domain_elem.get('name', None) all_group_items.append(group_item) return all_group_items diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 58d1f1396..985907ba3 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -3,7 +3,8 @@ class JobItem(object): - def __init__(self, id_, job_type, progress, created_at, started_at=None, completed_at=None, finish_code=0): + def __init__(self, id_, job_type, progress, created_at, started_at=None, + completed_at=None, finish_code=0, notes=None, mode=None): self._id = id_ self._type = job_type self._progress = progress @@ -11,6 +12,8 @@ def __init__(self, id_, job_type, progress, created_at, started_at=None, complet self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code + self._notes = notes or [] + self._mode = mode @property def id(self): @@ -40,6 +43,19 @@ def completed_at(self): def finish_code(self): return self._finish_code + @property + def notes(self): + return self._notes + + @property + def mode(self): + return self._mode + + @mode.setter + def mode(self, value): + # check for valid data here + self._mode = value + def __repr__(self): return "".format(**self.__dict__) @@ -63,7 +79,10 @@ def _parse_element(cls, element, ns): started_at = parse_datetime(element.get('startedAt', None)) completed_at = parse_datetime(element.get('completedAt', None)) finish_code = element.get('finishCode', -1) - return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code) + notes = [note.text for note in + element.findall('.//t:notes', namespaces=ns)] or None + mode = element.get('mode', None) + return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code, notes, mode) class BackgroundJobItem(object): diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index a7decd41f..3a3ddcdf9 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -12,6 +12,7 @@ class WorkbookItem(object): def __init__(self, project_id, name=None, show_tabs=False): self._connections = None self._content_url = None + self._webpage_url = None self._created_at = None self._id = None self._initial_tags = set() @@ -51,6 +52,10 @@ def permissions(self): def content_url(self): return self._content_url + @property + def webpage_url(self): + return self._webpage_url + @property def created_at(self): return self._created_at @@ -152,17 +157,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: - (_, _, _, _, description, updated_at, _, show_tabs, + (_, _, _, _, _, description, updated_at, _, show_tabs, project_id, project_name, owner_id, _, _, data_acceleration_config) = self._parse_element(workbook_xml, ns) - self._set_values(None, None, None, None, description, updated_at, + self._set_values(None, None, None, None, None, description, updated_at, None, show_tabs, project_id, project_name, owner_id, None, None, data_acceleration_config) return self - def _set_values(self, id, name, content_url, created_at, description, updated_at, + def _set_values(self, id, name, content_url, webpage_url, created_at, description, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, data_acceleration_config): if id is not None: @@ -171,6 +176,8 @@ def _set_values(self, id, name, content_url, created_at, description, updated_at self.name = name if content_url: self._content_url = content_url + if webpage_url: + self._webpage_url = webpage_url if created_at: self._created_at = created_at if description: @@ -201,12 +208,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, description, updated_at, size, show_tabs, + (id, name, content_url, webpage_url, created_at, description, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, data_acceleration_config) = cls._parse_element(workbook_xml, ns) workbook_item = cls(project_id) - workbook_item._set_values(id, name, content_url, created_at, description, updated_at, + workbook_item._set_values(id, name, content_url, webpage_url, created_at, description, updated_at, size, show_tabs, None, project_name, owner_id, tags, views, data_acceleration_config) all_workbook_items.append(workbook_item) @@ -217,6 +224,7 @@ def _parse_element(workbook_xml, ns): id = workbook_xml.get('id', None) name = workbook_xml.get('name', None) content_url = workbook_xml.get('contentUrl', None) + webpage_url = workbook_xml.get('webpageUrl', 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)) @@ -256,7 +264,7 @@ def _parse_element(workbook_xml, ns): if data_acceleration_elem is not None: data_acceleration_config = parse_data_acceleration_config(data_acceleration_elem) - return id, name, content_url, created_at, description, updated_at, size, show_tabs, \ + return id, name, content_url, webpage_url, created_at, description, updated_at, size, show_tabs, \ project_id, project_name, owner_id, tags, views, data_acceleration_config diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index aff549559..afebafabe 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -2,11 +2,11 @@ from .request_options import CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions from .filter import Filter from .sort import Sort -from .. import ConnectionItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \ +from .. import ConnectionItem, DataAlertItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ UserItem, ViewItem, WorkbookItem, TableItem, TaskItem, SubscriptionItem, \ PermissionsRule, Permission, ColumnItem, FlowItem, WebhookItem -from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ +from .endpoint import Auth, DataAlerts, Datasources, Endpoint, Groups, Projects, Schedules, \ Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ MissingRequiredFieldError, Flows, Favorites from .server import Server diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 1341ecd3f..5d55509cf 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,5 +1,6 @@ from .auth_endpoint import Auth from .data_acceleration_report_endpoint import DataAccelerationReport +from .data_alert_endpoint import DataAlerts from .datasources_endpoint import Datasources from .databases_endpoint import Databases from .endpoint import Endpoint diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 10f4cb4db..f74b88b21 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -1,5 +1,5 @@ from ..request_factory import RequestFactory - +from .exceptions import ServerResponseError from .endpoint import Endpoint, api import xml.etree.ElementTree as ET import logging @@ -52,3 +52,24 @@ def sign_out(self): self.post_request(url, '') self.parent_srv._clear_auth() logger.info('Signed out') + + @api(version="2.6") + def switch_site(self, site_item): + url = "{0}/{1}".format(self.baseurl, 'switchSite') + switch_req = RequestFactory.Auth.switch_req(site_item.content_url) + try: + server_response = self.post_request(url, switch_req) + except ServerResponseError as e: + if e.code == "403070": + return Auth.contextmgr(self.sign_out) + else: + raise e + self.parent_srv._namespace.detect(server_response.content) + self._check_status(server_response) + parsed_response = ET.fromstring(server_response.content) + site_id = parsed_response.find('.//t:site', namespaces=self.parent_srv.namespace).get('id', None) + user_id = parsed_response.find('.//t:user', namespaces=self.parent_srv.namespace).get('id', None) + auth_token = parsed_response.find('t:credentials', namespaces=self.parent_srv.namespace).get('token', None) + self.parent_srv._set_auth(site_id, user_id, auth_token) + logger.info('Signed into {0} as user with id {1}'.format(self.parent_srv.server_address, user_id)) + return Auth.contextmgr(self.sign_out) diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py new file mode 100644 index 000000000..b28ec14c4 --- /dev/null +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -0,0 +1,94 @@ +from .endpoint import api, Endpoint +from .exceptions import MissingRequiredFieldError +from .permissions_endpoint import _PermissionsEndpoint +from .default_permissions_endpoint import _DefaultPermissionsEndpoint + +from .. import RequestFactory, DataAlertItem, PaginationItem, UserItem + +import logging + +logger = logging.getLogger('tableau.endpoint.dataAlerts') + + +class DataAlerts(Endpoint): + def __init__(self, parent_srv): + super(DataAlerts, self).__init__(parent_srv) + + @property + def baseurl(self): + return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.2") + def get(self, req_options=None): + logger.info('Querying all dataAlerts on site') + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_dataAlert_items = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace) + return all_dataAlert_items, pagination_item + + # Get 1 dataAlert + @api(version="3.2") + def get_by_id(self, dataAlert_id): + if not dataAlert_id: + error = "dataAlert ID undefined." + raise ValueError(error) + logger.info('Querying single dataAlert (ID: {0})'.format(dataAlert_id)) + url = "{0}/{1}".format(self.baseurl, dataAlert_id) + server_response = self.get_request(url) + return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.2") + def delete(self, dataAlert): + dataAlert_id = getattr(dataAlert, 'id', dataAlert) + if not dataAlert_id: + error = "Dataalert ID undefined." + raise ValueError(error) + # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id + url = "{0}/{1}".format(self.baseurl, dataAlert_id) + self.delete_request(url) + logger.info('Deleted single dataAlert (ID: {0})'.format(dataAlert_id)) + + @api(version="3.2") + def delete_user_from_alert(self, dataAlert, user): + dataAlert_id = getattr(dataAlert, 'id', dataAlert) + user_id = getattr(user, 'id', user) + if not dataAlert_id: + error = "Dataalert ID undefined." + raise ValueError(error) + if not user_id: + error = "User ID undefined." + raise ValueError(error) + # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id + url = "{0}/{1}/users/{2}".format(self.baseurl, dataAlert_id, user_id) + self.delete_request(url) + logger.info('Deleted User (ID {0}) from dataAlert (ID: {1})'.format(user_id, dataAlert_id)) + + @api(version="3.2") + def add_user_to_alert(self, dataAlert_item, user): + if not dataAlert_item.id: + error = "Dataalert item missing ID." + raise MissingRequiredFieldError(error) + user_id = getattr(user, 'id', user) + if not user_id: + error = "User ID undefined." + raise ValueError(error) + url = "{0}/{1}/users".format(self.baseurl, dataAlert_item.id) + update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) + server_response = self.post_request(url, update_req) + logger.info('Added user (ID {0}) to dataAlert item (ID: {1})'.format(user_id, dataAlert_item.id)) + user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return user + + @api(version="3.2") + def update(self, dataAlert_item): + if not dataAlert_item.id: + error = "Dataalert item missing ID." + raise MissingRequiredFieldError(error) + + url = "{0}/{1}".format(self.baseurl, dataAlert_item.id) + update_req = RequestFactory.DataAlert.update_req(dataAlert_item) + server_response = self.put_request(url, update_req) + logger.info('Updated dataAlert item (ID: {0})'.format(dataAlert_item.id)) + updated_dataAlert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return updated_dataAlert diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 85dd406ef..6f15ed0d1 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -89,6 +89,14 @@ def populate_permissions(self, item): @api(version='3.5') def update_permission(self, item, rules): + import warnings + warnings.warn('Server.databases.update_permission is deprecated, ' + 'please use Server.databases.update_permissions instead.', + DeprecationWarning) + return self._permissions.update(item, rules) + + @api(version='3.5') + def update_permissions(self, item, rules): return self._permissions.update(item, rules) @api(version='3.5') diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7a00157fe..87afb4914 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -152,6 +152,22 @@ def refresh(self, datasource_item): new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job + @api(version='3.5') + def create_extract(self, datasource_item, encrypt=False): + id_ = getattr(datasource_item, 'id', datasource_item) + url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + empty_req = RequestFactory.Empty.empty_req() + server_response = self.post_request(url, empty_req) + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return new_job + + @api(version='3.5') + def delete_extract(self, datasource_item): + id_ = getattr(datasource_item, 'id', datasource_item) + url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + empty_req = RequestFactory.Empty.empty_req() + self.post_request(url, empty_req) + # Publish datasource @api(version="2.0") @parameter_added_in(connections="2.8") @@ -227,6 +243,14 @@ def populate_permissions(self, item): @api(version='2.0') def update_permission(self, item, permission_item): + import warnings + warnings.warn('Server.datasources.update_permission is deprecated, ' + 'please use Server.datasources.update_permissions instead.', + DeprecationWarning) + self._permissions.update(item, permission_item) + + @api(version='2.0') + def update_permissions(self, item, permission_item): self._permissions.update(item, permission_item) @api(version='2.0') diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 44a110e7e..dfe16f904 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -204,6 +204,14 @@ def populate_permissions(self, item): @api(version='3.3') def update_permission(self, item, permission_item): + import warnings + warnings.warn('Server.flows.update_permission is deprecated, ' + 'please use Server.flows.update_permissions instead.', + DeprecationWarning) + self._permissions.update(item, permission_item) + + @api(version='3.3') + def update_permissions(self, item, permission_item): self._permissions.update(item, permission_item) @api(version='3.3') diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index e0acb4477..c873dc159 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, GroupItem, UserItem, PaginationItem +from .. import RequestFactory, GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager import logging @@ -58,7 +58,7 @@ def delete(self, group_id): logger.info('Deleted single group (ID: {0})'.format(group_id)) @api(version="2.0") - def update(self, group_item, default_site_role=UNLICENSED_USER): + def update(self, group_item, default_site_role=UNLICENSED_USER, as_job=False): if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -66,17 +66,31 @@ def update(self, group_item, default_site_role=UNLICENSED_USER): update_req = RequestFactory.Group.update_req(group_item, default_site_role) server_response = self.put_request(url, update_req) logger.info('Updated group item (ID: {0})'.format(group_item.id)) - updated_group = GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - return updated_group + if (as_job): + return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + else: + return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Create a 'local' Tableau group @api(version="2.0") def create(self, group_item): url = self.baseurl - create_req = RequestFactory.Group.create_req(group_item) + create_req = RequestFactory.Group.create_local_req(group_item) server_response = self.post_request(url, create_req) return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] + # Create a group based on Active Directory + @api(version="2.0") + def create_AD_group(self, group_item, asJob=False): + asJobparameter = "?asJob=true" if asJob else "" + url = self.baseurl + asJobparameter + create_req = RequestFactory.Group.create_ad_req(group_item) + server_response = self.post_request(url, create_req) + if (asJob): + return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + else: + return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] + # Removes 1 user from 1 group @api(version="2.0") def remove_user(self, group_item, user_id): @@ -102,5 +116,6 @@ def add_user(self, group_item, user_id): url = "{0}/{1}/users".format(self.baseurl, group_item.id) add_req = RequestFactory.Group.add_user_req(user_id) server_response = self.post_request(url, add_req) - return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() + user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() logger.info('Added user (id: {0}) to group (ID: {1})'.format(user_id, group_item.id)) + return user diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index a7f22795c..170425eab 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -67,6 +67,14 @@ def populate_permissions(self, item): @api(version='2.0') def update_permission(self, item, rules): + import warnings + warnings.warn('Server.projects.update_permission is deprecated, ' + 'please use Server.projects.update_permissions instead.', + DeprecationWarning) + return self._permissions.update(item, rules) + + @api(version='2.0') + def update_permissions(self, item, rules): return self._permissions.update(item, rules) @api(version='2.0') diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 8a6212a28..c57cb3d4f 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -103,3 +103,31 @@ def create(self, site_item): new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Created new site (ID: {0})'.format(new_site.id)) return new_site + + @api(version="3.5") + def encrypt_extracts(self, site_id): + if not site_id: + error = "Site ID undefined." + raise ValueError(error) + url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id) + empty_req = RequestFactory.Empty.empty_req() + self.post_request(url, empty_req) + + @api(version="3.5") + def decrypt_extracts(self, site_id): + if not site_id: + error = "Site ID undefined." + raise ValueError(error) + url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id) + empty_req = RequestFactory.Empty.empty_req() + self.post_request(url, empty_req) + + @api(version="3.5") + def re_encrypt_extracts(self, site_id): + if not site_id: + error = "Site ID undefined." + raise ValueError(error) + url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id) + + empty_req = RequestFactory.Empty.empty_req() + self.post_request(url, empty_req) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 032f13016..3a5c2f3f4 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -100,6 +100,14 @@ def populate_permissions(self, item): @api(version='3.5') def update_permission(self, item, rules): + import warnings + warnings.warn('Server.tables.update_permission is deprecated, ' + 'please use Server.tables.update_permissions instead.', + DeprecationWarning) + return self._permissions.update(item, rules) + + @api(version='3.5') + def update_permissions(self, item, rules): return self._permissions.update(item, rules) @api(version='3.5') diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 3ce1f16ab..868287493 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -90,4 +90,4 @@ def _get_wbs_for_user(self, user_item, req_options=None): return workbook_item, pagination_item def populate_favorites(self, user_item): - raise NotImplementedError('REST API currently does not support the ability to query favorites') + self.parent_srv.favorites.get(user_item) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 82a5f9cd0..2bde77dc9 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -61,6 +61,25 @@ def refresh(self, workbook_id): new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job + # create one or more extracts on 1 workbook, optionally encrypted + @api(version='3.5') + def create_extract(self, workbook_item, encrypt=False, includeAll=True, datasources=None): + id_ = getattr(workbook_item, 'id', workbook_item) + url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + + datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) + server_response = self.post_request(url, datasource_req) + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return new_job + + # delete all the extracts on 1 workbook + @api(version='3.5') + def delete_extract(self, workbook_item): + id_ = getattr(workbook_item, 'id', workbook_item) + url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + empty_req = RequestFactory.Empty.empty_req() + server_response = self.post_request(url, empty_req) + # Delete 1 workbook by id @api(version="2.0") def delete(self, workbook_id): diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 9c869c686..7cd38189c 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -67,6 +67,13 @@ def signin_req(self, auth_item): user_element.attrib['id'] = auth_item.user_id_to_impersonate return ET.tostring(xml_request) + def switch_req(self, site_content_url): + xml_request = ET.Element('tsRequest') + + site_element = ET.SubElement(xml_request, 'site') + site_element.attrib['contentUrl'] = site_content_url + return ET.tostring(xml_request) + class ColumnRequest(object): def update_req(self, column_item): @@ -79,6 +86,27 @@ def update_req(self, column_item): return ET.tostring(xml_request) +class DataAlertRequest(object): + def add_user_to_alert(self, alert_item, user_id): + xml_request = ET.Element('tsRequest') + user_element = ET.SubElement(xml_request, 'user') + user_element.attrib['id'] = user_id + + return ET.tostring(xml_request) + + def update_req(self, alert_item): + xml_request = ET.Element('tsRequest') + dataAlert_element = ET.SubElement(xml_request, 'dataAlert') + dataAlert_element.attrib['subject'] = alert_item.subject + dataAlert_element.attrib['frequency'] = alert_item.frequency.lower() + dataAlert_element.attrib['public'] = alert_item.public + + owner = ET.SubElement(dataAlert_element, 'owner') + owner.attrib['id'] = alert_item.owner_id + + return ET.tostring(xml_request) + + class DatabaseRequest(object): def update_req(self, database_item): xml_request = ET.Element('tsRequest') @@ -231,13 +259,36 @@ def add_user_req(self, user_id): user_element.attrib['id'] = user_id return ET.tostring(xml_request) - def create_req(self, group_item): + def create_local_req(self, group_item): + xml_request = ET.Element('tsRequest') + group_element = ET.SubElement(xml_request, 'group') + group_element.attrib['name'] = group_item.name + if group_item.license_mode is not None: + group_element.attrib['grantLicenseMode'] = group_item.license_mode + if group_item.minimum_site_role is not None: + group_element.attrib['SiteRole'] = group_item.minimum_site_role + return ET.tostring(xml_request) + + def create_ad_req(self, group_item): xml_request = ET.Element('tsRequest') group_element = ET.SubElement(xml_request, 'group') group_element.attrib['name'] = group_item.name + import_element = ET.SubElement(group_element, 'import') + import_element.attrib['source'] = "ActiveDirectory" + if group_item.domain_name is None: + error = "Group Domain undefined." + raise ValueError(error) + + import_element.attrib['domainName'] = group_item.domain_name + if group_item.license_mode is not None: + import_element.attrib['grantLicenseMode'] = group_item.license + if group_item.minimum_site_role is not None: + import_element.attrib['SiteRole'] = group_item.minimum_site_role return ET.tostring(xml_request) - def update_req(self, group_item, default_site_role): + def update_req(self, group_item, default_site_role=None): + if default_site_role is not None: + group_item.minimum_site_role = default_site_role xml_request = ET.Element('tsRequest') group_element = ET.SubElement(xml_request, 'group') group_element.attrib['name'] = group_item.name @@ -245,7 +296,9 @@ def update_req(self, group_item, default_site_role): project_element = ET.SubElement(group_element, 'import') project_element.attrib['source'] = "ActiveDirectory" project_element.attrib['domainName'] = group_item.domain_name - project_element.attrib['siteRole'] = default_site_role + project_element.attrib['siteRole'] = group_item.minimum_site_role + project_element.attrib['grantLicenseMode'] = group_item.license_mode + return ET.tostring(xml_request) @@ -561,6 +614,16 @@ def publish_req_chunked( parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) + @_tsrequest_wrapped + def embedded_extract_req(self, xml_request, include_all=True, datasources=None): + list_element = ET.SubElement(xml_request, 'datasources') + if include_all: + list_element.attrib['includeAll'] = "true" + else: + for datasource_item in datasources: + datasource_element = list_element.SubElement(xml_request, 'datasource') + datasource_element.attrib['id'] = datasource_item.id + class Connection(object): @_tsrequest_wrapped @@ -616,7 +679,7 @@ def create_req(self, xml_request, webhook_item): webhook.attrib['name'] = webhook_item.name source = ET.SubElement(webhook, 'webhook-source') - event = ET.SubElement(source, webhook_item._event) + ET.SubElement(source, webhook_item._event) destination = ET.SubElement(webhook, 'webhook-destination') post = ET.SubElement(destination, 'webhook-destination-http') @@ -630,6 +693,7 @@ class RequestFactory(object): Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() + DataAlert = DataAlertRequest() Datasource = DatasourceRequest() Database = DatabaseRequest() Empty = EmptyRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index c36ee0f4b..6aff0c126 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, Subscriptions, Jobs, Metadata,\ - Databases, Tables, Flows, Webhooks, DataAccelerationReport, Favorites + Databases, Tables, Flows, Webhooks, DataAccelerationReport, Favorites, DataAlerts from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -58,6 +58,7 @@ def __init__(self, server_address, use_server_version=False): self.tables = Tables(self) self.webhooks = Webhooks(self) self.data_acceleration_report = DataAccelerationReport(self) + self.data_alerts = DataAlerts(self) self._namespace = Namespace() if use_server_version: diff --git a/test/assets/data_alerts_add_user.xml b/test/assets/data_alerts_add_user.xml new file mode 100644 index 000000000..2a367a7f1 --- /dev/null +++ b/test/assets/data_alerts_add_user.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/test/assets/data_alerts_get.xml b/test/assets/data_alerts_get.xml new file mode 100644 index 000000000..78a55d4ca --- /dev/null +++ b/test/assets/data_alerts_get.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/data_alerts_get_by_id.xml b/test/assets/data_alerts_get_by_id.xml new file mode 100644 index 000000000..1a7456545 --- /dev/null +++ b/test/assets/data_alerts_get_by_id.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/data_alerts_update.xml b/test/assets/data_alerts_update.xml new file mode 100644 index 000000000..78a55d4ca --- /dev/null +++ b/test/assets/data_alerts_update.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/group_create.xml b/test/assets/group_create.xml index 8fb3902a4..face05cf0 100644 --- a/test/assets/group_create.xml +++ b/test/assets/group_create.xml @@ -2,5 +2,7 @@ - + \ No newline at end of file diff --git a/test/assets/group_create_ad.xml b/test/assets/group_create_ad.xml new file mode 100644 index 000000000..26ddd94b0 --- /dev/null +++ b/test/assets/group_create_ad.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/test/assets/group_update.xml b/test/assets/group_update.xml index b5dba4bc6..828e3f251 100644 --- a/test/assets/group_update.xml +++ b/test/assets/group_update.xml @@ -2,5 +2,7 @@ - + /> \ No newline at end of file diff --git a/test/assets/job_get_by_id.xml b/test/assets/job_get_by_id.xml new file mode 100644 index 000000000..b142dfe2f --- /dev/null +++ b/test/assets/job_get_by_id.xml @@ -0,0 +1,14 @@ + + + + + Job detail notes + + + More detail + + + diff --git a/test/assets/workbook_get.xml b/test/assets/workbook_get.xml index e5fd3967b..873ca3848 100644 --- a/test/assets/workbook_get.xml +++ b/test/assets/workbook_get.xml @@ -2,13 +2,12 @@ - + - - + diff --git a/test/assets/workbook_get_by_id.xml b/test/assets/workbook_get_by_id.xml index 1b2fe9120..98dfc4a75 100644 --- a/test/assets/workbook_get_by_id.xml +++ b/test/assets/workbook_get_by_id.xml @@ -1,6 +1,6 @@ - + @@ -11,4 +11,4 @@ - \ No newline at end of file + diff --git a/test/assets/workbook_get_invalid_date.xml b/test/assets/workbook_get_invalid_date.xml new file mode 100644 index 000000000..c580f9eb6 --- /dev/null +++ b/test/assets/workbook_get_invalid_date.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index 28e241335..b879ab121 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -90,3 +90,19 @@ def test_sign_out(self): self.assertIsNone(self.server._auth_token) self.assertIsNone(self.server._site_id) self.assertIsNone(self.server._user_id) + + def test_switch_site(self): + self.server.version = '2.6' + baseurl = self.server.auth.baseurl + site_id, user_id, auth_token = list('123') + self.server._set_auth(site_id, user_id, auth_token) + with open(SIGN_IN_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(baseurl + '/switchSite', text=response_xml) + site = TSC.SiteItem('Samples', 'Samples') + self.server.auth.switch_site(site) + + self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) + self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) + self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) diff --git a/test/test_dataalert.py b/test/test_dataalert.py new file mode 100644 index 000000000..7822d3000 --- /dev/null +++ b/test/test_dataalert.py @@ -0,0 +1,115 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_XML = 'data_alerts_get.xml' +GET_BY_ID_XML = 'data_alerts_get_by_id.xml' +ADD_USER_TO_ALERT = 'data_alerts_add_user.xml' +UPDATE_XML = 'data_alerts_update.xml' + + +class DataAlertTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server.version = "3.2" + + self.baseurl = self.server.data_alerts.baseurl + + def test_get(self): + response_xml = read_xml_asset(GET_XML) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_alerts, pagination_item = self.server.data_alerts.get() + + self.assertEqual(1, pagination_item.total_available) + self.assertEqual('5ea59b45-e497-5673-8809-bfe213236f75', all_alerts[0].id) + self.assertEqual('Data Alert test', all_alerts[0].subject) + self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_alerts[0].creatorId) + self.assertEqual('2020-08-10T23:17:06Z', all_alerts[0].createdAt) + self.assertEqual('2020-08-10T23:17:06Z', all_alerts[0].updatedAt) + self.assertEqual('Daily', all_alerts[0].frequency) + self.assertEqual('true', all_alerts[0].public) + self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_alerts[0].owner_id) + self.assertEqual('Bob', all_alerts[0].owner_name) + self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', all_alerts[0].view_id) + self.assertEqual('ENDANGERED SAFARI', all_alerts[0].view_name) + self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_alerts[0].workbook_id) + self.assertEqual('Safari stats', all_alerts[0].workbook_name) + self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_alerts[0].project_id) + self.assertEqual('Default', all_alerts[0].project_name) + + def test_get_by_id(self): + response_xml = read_xml_asset(GET_BY_ID_XML) + with requests_mock.mock() as m: + m.get(self.baseurl + '/5ea59b45-e497-5673-8809-bfe213236f75', text=response_xml) + alert = self.server.data_alerts.get_by_id('5ea59b45-e497-5673-8809-bfe213236f75') + + self.assertTrue(isinstance(alert.recipients, list)) + self.assertEqual(len(alert.recipients), 1) + self.assertEqual(alert.recipients[0], 'dd2239f6-ddf1-4107-981a-4cf94e415794') + + def test_update(self): + response_xml = read_xml_asset(UPDATE_XML) + with requests_mock.mock() as m: + m.put(self.baseurl + '/5ea59b45-e497-5673-8809-bfe213236f75', text=response_xml) + single_alert = TSC.DataAlertItem() + single_alert._id = '5ea59b45-e497-5673-8809-bfe213236f75' + single_alert._subject = 'Data Alert test' + single_alert._frequency = 'Daily' + single_alert._public = "true" + single_alert._owner_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + single_alert = self.server.data_alerts.update(single_alert) + + self.assertEqual('5ea59b45-e497-5673-8809-bfe213236f75', single_alert.id) + self.assertEqual('Data Alert test', single_alert.subject) + self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_alert.creatorId) + self.assertEqual('2020-08-10T23:17:06Z', single_alert.createdAt) + self.assertEqual('2020-08-10T23:17:06Z', single_alert.updatedAt) + self.assertEqual('Daily', single_alert.frequency) + self.assertEqual('true', single_alert.public) + self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_alert.owner_id) + self.assertEqual('Bob', single_alert.owner_name) + self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', single_alert.view_id) + self.assertEqual('ENDANGERED SAFARI', single_alert.view_name) + self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', single_alert.workbook_id) + self.assertEqual('Safari stats', single_alert.workbook_name) + self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', single_alert.project_id) + self.assertEqual('Default', single_alert.project_name) + + def test_add_user_to_alert(self): + response_xml = read_xml_asset(ADD_USER_TO_ALERT) + single_alert = TSC.DataAlertItem() + single_alert._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + in_user = TSC.UserItem('Bob', TSC.UserItem.Roles.Explorer) + in_user._id = '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7' + + with requests_mock.mock() as m: + m.post(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/users', text=response_xml) + + out_user = self.server.data_alerts.add_user_to_alert(single_alert, in_user) + + self.assertEqual(out_user.id, in_user.id) + self.assertEqual(out_user.name, in_user.name) + self.assertEqual(out_user.site_role, in_user.site_role) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) + self.server.data_alerts.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') + + def test_delete_user_from_alert(self): + alert_id = '5ea59b45-e497-5673-8809-bfe213236f75' + user_id = '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7' + with requests_mock.mock() as m: + m.delete(self.baseurl + '/{0}/users/{1}'.format(alert_id, user_id), status_code=204) + self.server.data_alerts.delete_user_from_alert(alert_id, user_id) diff --git a/test/test_datasource.py b/test/test_datasource.py index 2b7cc623c..7c6be6f67 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -381,3 +381,30 @@ def test_synchronous_publish_timeout_error(self): self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts.', self.server.datasources.publish, new_datasource, asset('SampleDS.tds'), publish_mode) + + def test_delete_extracts(self): + self.server.version = "3.10" + self.baseurl = self.server.datasources.baseurl + with requests_mock.mock() as m: + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract', status_code=200) + self.server.datasources.delete_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + + def test_create_extracts(self): + self.server.version = "3.10" + self.baseurl = self.server.datasources.baseurl + + response_xml = read_xml_asset(PUBLISH_XML_ASYNC) + with requests_mock.mock() as m: + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', + status_code=200, text=response_xml) + self.server.datasources.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + + def test_create_extracts_encrypted(self): + self.server.version = "3.10" + self.baseurl = self.server.datasources.baseurl + + response_xml = read_xml_asset(PUBLISH_XML_ASYNC) + with requests_mock.mock() as m: + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', + status_code=200, text=response_xml) + self.server.datasources.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42', True) diff --git a/test/test_group.py b/test/test_group.py index 7096ca408..8aeb4817d 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -13,6 +13,7 @@ ADD_USER = os.path.join(TEST_ASSET_DIR, 'group_add_user.xml') ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, 'group_users_added.xml') CREATE_GROUP = os.path.join(TEST_ASSET_DIR, 'group_create.xml') +CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, 'group_create_ad.xml') CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, 'group_create_async.xml') UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'group_update.xml') @@ -185,6 +186,30 @@ def test_create_group(self): self.assertEqual(group.name, u'試供品') self.assertEqual(group.id, '3e4a9ea0-a07a-4fe6-b50f-c345c8c81034') + def test_create_ad_group(self): + with open(CREATE_GROUP_AD, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + group_to_create = TSC.GroupItem(u'試供品') + group_to_create.domain_name = 'just-has-to-exist' + group = self.server.groups.create_AD_group(group_to_create, False) + self.assertEqual(group.name, u'試供品') + self.assertEqual(group.license_mode, 'onLogin') + self.assertEqual(group.minimum_site_role, 'Creator') + self.assertEqual(group.domain_name, 'active-directory-domain-name') + + def test_create_group_async(self): + with open(CREATE_GROUP_ASYNC, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + group_to_create = TSC.GroupItem(u'試供品') + group_to_create.domain_name = 'woohoo' + job = self.server.groups.create_AD_group(group_to_create, True) + self.assertEqual(job.mode, 'Asynchronous') + self.assertEqual(job.type, 'GroupImport') + def test_update(self): with open(UPDATE_XML, 'rb') as f: response_xml = f.read().decode('utf-8') @@ -197,3 +222,5 @@ def test_update(self): self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', group.id) self.assertEqual('Group updated name', group.name) + self.assertEqual('ExplorerCanPublish', group.minimum_site_role) + self.assertEqual('onLogin', group.license_mode) diff --git a/test/test_job.py b/test/test_job.py index ee80450ca..08b98b815 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -4,10 +4,12 @@ import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import utc +from ._utils import read_xml_asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') -GET_XML = os.path.join(TEST_ASSET_DIR, 'job_get.xml') +GET_XML = 'job_get.xml' +GET_BY_ID_XML = 'job_get_by_id.xml' class JobTests(unittest.TestCase): @@ -22,8 +24,7 @@ def setUp(self): self.baseurl = self.server.jobs.baseurl def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_jobs, pagination_item = self.server.jobs.get() @@ -41,6 +42,19 @@ def test_get(self): self.assertEqual(started_at, job.started_at) self.assertEqual(ended_at, job.ended_at) + def test_get_by_id(self): + response_xml = read_xml_asset(GET_BY_ID_XML) + job_id = '2eef4225-aa0c-41c4-8662-a76d89ed7336' + with requests_mock.mock() as m: + m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + job = self.server.jobs.get_by_id(job_id) + + created_at = datetime(2020, 5, 13, 20, 23, 45, tzinfo=utc) + updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) + ended_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) + self.assertEqual(job_id, job.id) + self.assertListEqual(job.notes, ['Job detail notes']) + def test_get_before_signin(self): self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.jobs.get) diff --git a/test/test_site.py b/test/test_site.py index 09063b861..a06876e2a 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -15,6 +15,7 @@ class SiteTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') + self.server.version = "3.10" # Fake signin self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' @@ -140,3 +141,18 @@ def test_delete(self): def test_delete_missing_id(self): self.assertRaises(ValueError, self.server.sites.delete, '') + + def test_encrypt(self): + with requests_mock.mock() as m: + m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/encrypt-extracts', status_code=200) + self.server.sites.encrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') + + def test_recrypt(self): + with requests_mock.mock() as m: + m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/reencrypt-extracts', status_code=200) + self.server.sites.re_encrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') + + def test_decrypt(self): + with requests_mock.mock() as m: + m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts', status_code=200) + self.server.sites.decrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') diff --git a/test/test_task.py b/test/test_task.py index cf7879305..789f97187 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -105,7 +105,7 @@ def test_get_materializeviews_tasks(self): 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): + def test_delete_data_acceleration(self): with requests_mock.mock() as m: m.delete('{}/{}/{}'.format( self.server.tasks.baseurl, TaskItem.Type.DataAcceleration, diff --git a/test/test_user.py b/test/test_user.py index 8df2f2b2e..6eb6ad223 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -12,7 +12,7 @@ UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'user_update.xml') ADD_XML = os.path.join(TEST_ASSET_DIR, 'user_add.xml') POPULATE_WORKBOOKS_XML = os.path.join(TEST_ASSET_DIR, 'user_populate_workbooks.xml') -ADD_FAVORITE_XML = os.path.join(TEST_ASSET_DIR, 'user_add_favorite.xml') +GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, 'favorites_get.xml') class UserTests(unittest.TestCase): @@ -146,3 +146,28 @@ def test_populate_workbooks(self): def test_populate_workbooks_missing_id(self): single_user = TSC.UserItem('test', 'Interactor') self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user) + + def test_populate_favorites(self): + self.server.version = '2.5' + baseurl = self.server.favorites.baseurl + single_user = TSC.UserItem('test', 'Interactor') + with open(GET_FAVORITES_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get('{0}/{1}'.format(baseurl, single_user.id), text=response_xml) + self.server.users.populate_favorites(single_user) + self.assertIsNotNone(single_user._favorites) + self.assertEqual(len(single_user.favorites['workbooks']), 1) + self.assertEqual(len(single_user.favorites['views']), 1) + self.assertEqual(len(single_user.favorites['projects']), 1) + self.assertEqual(len(single_user.favorites['datasources']), 1) + + workbook = single_user.favorites['workbooks'][0] + view = single_user.favorites['views'][0] + datasource = single_user.favorites['datasources'][0] + project = single_user.favorites['projects'][0] + + self.assertEqual(workbook.id, '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00') + self.assertEqual(view.id, 'd79634e1-6063-4ec9-95ff-50acbf609ff5') + self.assertEqual(datasource.id, 'e76a1461-3b1d-4588-bf1b-17551a879ad9') + self.assertEqual(project.id, '1d0304cd-3796-429f-b815-7258370b9b74') diff --git a/test/test_workbook.py b/test/test_workbook.py index f1d9df9e0..8c361e713 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -1,5 +1,6 @@ import unittest import os +import re import requests_mock import tableauserverclient as TSC import xml.etree.ElementTree as ET @@ -19,6 +20,7 @@ ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_add_tags.xml') GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id.xml') GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_empty.xml') +GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_invalid_date.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') @@ -55,6 +57,7 @@ def test_get(self): self.assertEqual('Superstore', all_workbooks[0].name) self.assertEqual('Superstore', all_workbooks[0].content_url) self.assertEqual(False, all_workbooks[0].show_tabs) + self.assertEqual('http://tableauserver/#/workbooks/1/views', all_workbooks[0].webpage_url) 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) @@ -66,6 +69,7 @@ def test_get(self): self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_workbooks[1].id) self.assertEqual('SafariSample', all_workbooks[1].name) self.assertEqual('SafariSample', all_workbooks[1].content_url) + self.assertEqual('http://tableauserver/#/workbooks/2/views', all_workbooks[1].webpage_url) 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)) @@ -76,6 +80,15 @@ def test_get(self): self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_workbooks[1].owner_id) self.assertEqual(set(['Safari', 'Sample']), all_workbooks[1].tags) + def test_get_ignore_invalid_date(self): + with open(GET_INVALID_DATE_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_workbooks, pagination_item = self.server.workbooks.get() + self.assertEqual(None, format_datetime(all_workbooks[0].created_at)) + self.assertEqual('2016-08-04T17:56:41Z', format_datetime(all_workbooks[0].updated_at)) + def test_get_before_signin(self): self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.workbooks.get) @@ -100,6 +113,7 @@ def test_get_by_id(self): self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', single_workbook.id) self.assertEqual('SafariSample', single_workbook.name) self.assertEqual('SafariSample', single_workbook.content_url) + self.assertEqual('http://tableauserver/#/workbooks/2/views', single_workbook.webpage_url) 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)) @@ -456,8 +470,9 @@ def test_publish_with_hidden_view(self): hidden_views=['GDP per capita']) request_body = m._adapter.request_history[0]._request.body - self.assertIn( - b'', request_body) + # order of attributes in xml is unspecified + self.assertTrue(re.search(rb'<\/views>', request_body)) + self.assertTrue(re.search(rb'<\/views>', request_body)) def test_publish_async(self): self.server.version = '3.0' @@ -552,3 +567,35 @@ def test_synchronous_publish_timeout_error(self): self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts', self.server.workbooks.publish, new_workbook, asset('SampleWB.twbx'), publish_mode) + + def test_delete_extracts_all(self): + self.server.version = "3.10" + self.baseurl = self.server.workbooks.baseurl + with requests_mock.mock() as m: + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract', status_code=200) + self.server.workbooks.delete_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + + def test_create_extracts_all(self): + self.server.version = "3.10" + self.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 + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', + status_code=200, text=response_xml) + self.server.workbooks.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + + def test_create_extracts_one(self): + self.server.version = "3.10" + self.baseurl = self.server.workbooks.baseurl + + datasource = TSC.DatasourceItem('test') + datasource._id = '1f951daf-4061-451a-9df1-69a8062664f2' + + 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 + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', + status_code=200, text=response_xml) + self.server.workbooks.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42', False, datasource)