diff --git a/.travis.yml b/.travis.yml index b0d0b8b7b..33e133203 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ python: - "3.3" - "3.4" - "3.5" + - "3.6" - "pypy" # command to install dependencies install: @@ -14,4 +15,4 @@ script: # Tests - python setup.py test # pep8 - disabled for now until we can scrub the files to make sure we pass before turning it on - - pycodestyle . + - pycodestyle tableauserverclient test diff --git a/CHANGELOG.md b/CHANGELOG.md index d9aa404ed..8505d4c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 0.3 (11 January 2017) + +* Return DateTime objects instead of strings (#102) +* UserItem now is compatible with Pager (#107, #109) +* Deprecated site in favor of site_id (#97) +* Improved handling of large downloads (#105, #111) +* Added support for oAuth when publishing (#117) +* Added Testing against Py36 (#122, #123) +* Added Version Checking to use highest supported REST api version (#100) +* Added Infrastructure for throwing error if trying to do something that is not supported by REST api version (#124) +* Various Code Cleanup +* Added Documentation (#98) +* Improved Test Infrastructure (#91) + ## 0.2 (02 November 2016) * Added Initial Schedules Support (#48) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index c97e9301d..553a3c2b9 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -5,6 +5,7 @@ The following people have contributed to this project to make it possible, and w ## Contributors * [geordielad](https://github.com/geordielad) +* [Hugo Stijns)(https://github.com/hugoboos) * [kovner](https://github.com/kovner) @@ -14,3 +15,5 @@ The following people have contributed to this project to make it possible, and w * [lgraber](https://github.com/lgraber) * [t8y8](https://github.com/t8y8) * [RussTheAerialist](https://github.com/RussTheAerialist) +* [Ben Lower](https://github.com/benlower) +* [Jared Dominguez](https://github.com/jdomingu) diff --git a/README.md b/README.md index 099f4ba7d..f1f8f462a 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,4 @@ This repository contains Python source code and sample files. For more information on installing and using TSC, see the documentation: + diff --git a/samples/initialize_server.py b/samples/initialize_server.py new file mode 100644 index 000000000..e37317c0e --- /dev/null +++ b/samples/initialize_server.py @@ -0,0 +1,107 @@ +#### +# This script sets up a server. It uploads datasources and workbooks from the local filesystem. +# +# By default, all content is published to the Default project on the Default site. +#### + +import tableauserverclient as TSC +import argparse +import getpass +import logging +import glob + + +def main(): + parser = argparse.ArgumentParser(description='Initialize a server with content.') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--datasources-folder', '-df', required=True, help='folder containing datasources') + parser.add_argument('--workbooks-folder', '-wf', required=True, help='folder containing workbooks') + parser.add_argument('--site', '-si', required=False, default='Default', help='site to use') + parser.add_argument('--project', '-p', required=False, default='Default', help='project to use') + 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', + help='desired logging level (set to error by default)') + args = parser.parse_args() + + password = getpass.getpass("Password: ") + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + ################################################################################ + # Step 1: Sign in to server. + ################################################################################ + tableau_auth = TSC.TableauAuth(args.username, password) + server = TSC.Server(args.server) + + with server.auth.sign_in(tableau_auth): + + ################################################################################ + # Step 2: Create the site we need only if it doesn't exist + ################################################################################ + print("Checking to see if we need to create the site...") + + all_sites, _ = server.sites.get() + existing_site = next((s for s in all_sites if s.name == args.site), None) + + # Create the site if it doesn't exist + if existing_site is None: + print("Site not found: {0} Creating it...").format(args.site) + new_site = TSC.SiteItem(name=args.site, content_url=args.site.replace(" ", ""), + admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers) + server.sites.create(new_site) + else: + print("Site {0} exists. Moving on...").format(args.site) + + ################################################################################ + # Step 3: Sign-in to our target site + ################################################################################ + print("Starting our content upload...") + server_upload = TSC.Server(args.server) + tableau_auth.site = args.site + + with server_upload.auth.sign_in(tableau_auth): + + ################################################################################ + # Step 4: Create the project we need only if it doesn't exist + ################################################################################ + all_projects, _ = server_upload.projects.get() + project = next((p for p in all_projects if p.name == args.project), None) + + # Create our project if it doesn't exist + if project is None: + print("Project not found: {0} Creating it...").format(args.project) + new_project = TSC.ProjectItem(name=args.project) + project = server_upload.projects.create(new_project) + + ################################################################################ + # Step 5: Set up our content + # Publish datasources to our site and project + # Publish workbooks to our site and project + ################################################################################ + publish_datasources_to_site(server_upload, project, args.datasources_folder) + publish_workbooks_to_site(server_upload, project, args.workbooks_folder) + + +def publish_datasources_to_site(server_object, project, folder): + path = folder + '/*.tds*' + + for fname in glob.glob(path): + new_ds = TSC.DatasourceItem(project.id) + new_ds = server_object.datasources.publish(new_ds, fname, server_object.PublishMode.Overwrite) + print("Datasource published. ID: {0}".format(new_ds.id)) + + +def publish_workbooks_to_site(server_object, project, folder): + path = folder + '/*.twb*' + + for fname in glob.glob(path): + new_workbook = TSC.WorkbookItem(project.id) + new_workbook.show_tabs = True + new_workbook = server_object.workbooks.publish(new_workbook, fname, server_object.PublishMode.Overwrite) + print("Workbook published. ID: {0}".format(new_workbook.id)) + + +if __name__ == "__main__": + main() diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index 882fc85ad..25effd7b2 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -66,5 +66,6 @@ def main(): # >>> request_options = TSC.RequestOptions(pagesize=1000) # >>> all_workbooks = list(TSC.Pager(server.workbooks, request_options)) + if __name__ == '__main__': main() diff --git a/setup.py b/setup.py index e4214aa70..ac932390d 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='tableauserverclient', - version='0.2', + version='0.3', author='Tableau', author_email='github@tableau.com', url='https://github.com/tableau/server-client-python', diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py new file mode 100644 index 000000000..af88d5c71 --- /dev/null +++ b/tableauserverclient/datetime_helpers.py @@ -0,0 +1,37 @@ +import datetime + + +# This code below is from the python documentation for tzinfo: https://docs.python.org/2.3/lib/datetime-tzinfo.html +ZERO = datetime.timedelta(0) +HOUR = datetime.timedelta(hours=1) + +# A UTC class. + + +class UTC(datetime.tzinfo): + """UTC""" + + def utcoffset(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return ZERO + + +utc = UTC() + +TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +def parse_datetime(date): + if date is None: + return None + + return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) + + +def format_datetime(date): + return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index d823b0b7f..8c3a77925 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -9,10 +9,11 @@ class ConnectionCredentials(object): """ - def __init__(self, name, password, embed=True): + def __init__(self, name, password, embed=True, oauth=False): self.name = name self.password = password self.embed = embed + self.oauth = oauth @property def embed(self): @@ -22,3 +23,12 @@ def embed(self): @property_is_boolean def embed(self, value): self._embed = value + + @property + def oauth(self): + return self._oauth + + @oauth.setter + @property_is_boolean + def oauth(self, value): + self._oauth = value diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 3ae4c5743..2ae469674 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -3,6 +3,7 @@ from .property_decorators import property_not_nullable from .tag_item import TagItem from .. import NAMESPACE +from ..datetime_helpers import parse_datetime class DatasourceItem(object): @@ -118,8 +119,8 @@ def _parse_element(datasource_xml): name = datasource_xml.get('name', None) datasource_type = datasource_xml.get('type', None) content_url = datasource_xml.get('contentUrl', None) - created_at = datasource_xml.get('createdAt', None) - updated_at = datasource_xml.get('updatedAt', None) + created_at = parse_datetime(datasource_xml.get('createdAt', None)) + updated_at = parse_datetime(datasource_xml.get('updatedAt', None)) tags = None tags_elem = datasource_xml.find('.//t:tags', namespaces=NAMESPACE) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index de8fe8d8c..77612b172 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,5 +1,12 @@ +import datetime import re from functools import wraps +from ..datetime_helpers import parse_datetime +try: + basestring +except NameError: + # In case we are in python 3 the string check is different + basestring = str def property_is_enum(enum_type): @@ -99,3 +106,25 @@ def validate_regex_decorator(self, value): return func(self, value) return validate_regex_decorator return wrapper + + +def property_is_datetime(func): + """ Takes the following datetime format and turns it into a datetime object: + + 2016-08-18T18:25:36Z + + Because we return everything with Z as the timezone, we assume everything is in UTC and create + a timezone aware datetime. + """ + + @wraps(func) + def wrapper(self, value): + if isinstance(value, datetime.datetime): + return func(self, value) + if not isinstance(value, basestring): + raise ValueError("Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, + func.__name__)) + + dt = parse_datetime(value) + return func(self, dt) + return wrapper diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index b0f7d1edb..84b070044 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -4,6 +4,7 @@ from .interval_item import IntervalItem, HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval from .property_decorators import property_is_enum, property_not_nullable, property_is_int from .. import NAMESPACE +from ..datetime_helpers import parse_datetime class ScheduleItem(object): @@ -208,12 +209,12 @@ def _parse_element(schedule_xml): id = schedule_xml.get('id', None) name = schedule_xml.get('name', None) state = schedule_xml.get('state', None) - created_at = schedule_xml.get('createdAt', None) - updated_at = schedule_xml.get('updatedAt', None) + created_at = parse_datetime(schedule_xml.get('createdAt', None)) + updated_at = parse_datetime(schedule_xml.get('updatedAt', None)) schedule_type = schedule_xml.get('type', None) frequency = schedule_xml.get('frequency', None) - next_run_at = schedule_xml.get('nextRunAt', None) - end_schedule_at = schedule_xml.get('endScheduleAt', None) + next_run_at = parse_datetime(schedule_xml.get('nextRunAt', None)) + end_schedule_at = parse_datetime(schedule_xml.get('endScheduleAt', None)) execution_order = schedule_xml.get('executionOrder', None) priority = schedule_xml.get('priority', None) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 7670e2812..3b60741d6 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -18,3 +18,10 @@ def site(self): warnings.warn('TableauAuth.site is deprecated, use TableauAuth.site_id instead.', DeprecationWarning) return self.site_id + + @site.setter + def site(self, value): + import warnings + warnings.warn('TableauAuth.site is deprecated, use TableauAuth.site_id instead.', + DeprecationWarning) + self.site_id = value diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 49a048f69..2df6764d9 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,6 +2,7 @@ from .exceptions import UnpopulatedPropertyError from .property_decorators import property_is_enum, property_not_empty, property_not_nullable from .. import NAMESPACE +from ..datetime_helpers import parse_datetime class UserItem(object): @@ -118,7 +119,7 @@ def _set_values(self, id, name, site_role, last_login, @classmethod def from_response(cls, resp): - all_user_items = set() + all_user_items = [] parsed_response = ET.fromstring(resp) all_user_xml = parsed_response.findall('.//t:user', namespaces=NAMESPACE) for user_xml in all_user_xml: @@ -127,7 +128,7 @@ def from_response(cls, resp): user_item = cls(name, site_role) user_item._set_values(id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name) - all_user_items.add(user_item) + all_user_items.append(user_item) return all_user_items @staticmethod @@ -135,7 +136,7 @@ def _parse_element(user_xml): id = user_xml.get('id', None) name = user_xml.get('name', None) site_role = user_xml.get('siteRole', None) - last_login = user_xml.get('lastLogin', None) + last_login = parse_datetime(user_xml.get('lastLogin', None)) external_auth_user_id = user_xml.get('externalAuthUserId', None) fullname = user_xml.get('fullName', None) email = user_xml.get('email', None) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 9ccde5606..26a3a00c3 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -4,6 +4,7 @@ from .tag_item import TagItem from .view_item import ViewItem from .. import NAMESPACE +from ..datetime_helpers import parse_datetime import copy @@ -163,8 +164,8 @@ def _parse_element(workbook_xml): id = workbook_xml.get('id', None) name = workbook_xml.get('name', None) content_url = workbook_xml.get('contentUrl', None) - created_at = workbook_xml.get('createdAt', None) - updated_at = workbook_xml.get('updatedAt', None) + created_at = parse_datetime(workbook_xml.get('createdAt', None)) + updated_at = parse_datetime(workbook_xml.get('updatedAt', None)) size = workbook_xml.get('size', None) if size: diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 63d69510c..d9dca0f42 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,7 +1,7 @@ from .auth_endpoint import Auth from .datasources_endpoint import Datasources from .endpoint import Endpoint -from .exceptions import ServerResponseError, MissingRequiredFieldError +from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError from .groups_endpoint import Groups from .projects_endpoint import Projects from .schedules_endpoint import Schedules diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index e8e4e4bf6..af8efcd13 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,6 +6,7 @@ import logging import copy import cgi +from contextlib import closing # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -64,16 +65,18 @@ def download(self, datasource_id, filepath=None): error = "Datasource ID undefined." raise ValueError(error) url = "{0}/{1}/content".format(self.baseurl, datasource_id) - server_response = self.get_request(url) - _, params = cgi.parse_header(server_response.headers['Content-Disposition']) - filename = os.path.basename(params['filename']) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - - with open(filepath, 'wb') as f: - f.write(server_response.content) + with closing(self.get_request(url, parameters={'stream': True})) as server_response: + _, params = cgi.parse_header(server_response.headers['Content-Disposition']) + filename = os.path.basename(params['filename']) + if filepath is None: + filepath = filename + elif os.path.isdir(filepath): + filepath = os.path.join(filepath, filename) + + with open(filepath, 'wb') as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + logger.info('Downloaded datasource to {0} (ID: {1})'.format(filepath, datasource_id)) return os.path.abspath(filepath) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index c90b91004..9f8a6dc3a 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,6 +1,12 @@ -from .exceptions import ServerResponseError +from .exceptions import ServerResponseError, EndpointUnavailableError +from functools import wraps + import logging +try: + from distutils2.version import NormalizedVersion as Version +except ImportError: + from distutils.version import LooseVersion as Version logger = logging.getLogger('tableau.endpoint') @@ -21,10 +27,11 @@ def _make_common_headers(auth_token, content_type): return headers - def _make_request(self, method, url, content=None, request_object=None, auth_token=None, content_type=None): + def _make_request(self, method, url, content=None, request_object=None, + auth_token=None, content_type=None, parameters=None): if request_object is not None: url = request_object.apply_query_params(url) - parameters = {} + parameters = parameters or {} parameters.update(self.parent_srv.http_options) parameters['headers'] = Endpoint._make_common_headers(auth_token, content_type) @@ -49,9 +56,9 @@ def _check_status(server_response): def get_unauthenticated_request(self, url, request_object=None): return self._make_request(self.parent_srv.session.get, url, request_object=request_object) - def get_request(self, url, request_object=None): + def get_request(self, url, request_object=None, parameters=None): return self._make_request(self.parent_srv.session.get, url, auth_token=self.parent_srv.auth_token, - request_object=request_object) + request_object=request_object, parameters=parameters) def delete_request(self, url): # We don't return anything for a delete @@ -68,3 +75,35 @@ def post_request(self, url, xml_request, content_type='text/xml'): content=xml_request, auth_token=self.parent_srv.auth_token, content_type=content_type) + + +def api(version): + '''Annotate the minimum supported version for an endpoint. + + Checks the version on the server object and compares normalized versions. + It will raise an exception if the server version is > the version specified. + + Args: + `version` minimum version that supports the endpoint. String. + Raises: + EndpointUnavailableError + Returns: + None + + Example: + >>> @api(version="2.3") + >>> def get(self, req_options=None): + >>> ... + ''' + def _decorator(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + server_version = Version(self.parent_srv.version) + minimum_supported = Version(version) + if server_version < minimum_supported: + error = "This endpoint is not available in API version {}. Requires {}".format( + server_version, minimum_supported) + raise EndpointUnavailableError(error) + return func(self, *args, **kwargs) + return wrapper + return _decorator diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 7907a6dab..5cb6a06d7 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -24,3 +24,11 @@ def from_response(cls, resp): class MissingRequiredFieldError(Exception): pass + + +class ServerInfoEndpointNotFoundError(Exception): + pass + + +class EndpointUnavailableError(Exception): + pass diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 1fb17f26f..d6b2b7d96 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,4 +1,5 @@ from .endpoint import Endpoint +from .exceptions import ServerResponseError, ServerInfoEndpointNotFoundError from ...models import ServerInfoItem import logging @@ -12,6 +13,11 @@ def baseurl(self): def get(self): """ Retrieve the server info for the server. This is an unauthenticated call """ - server_response = self.get_unauthenticated_request(self.baseurl) + try: + server_response = self.get_unauthenticated_request(self.baseurl) + except ServerResponseError as e: + if e.code == "404003": + raise ServerInfoEndpointNotFoundError + server_info = ServerInfoItem.from_response(server_response.content) return server_info diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 6aabc6029..eb185476e 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,6 +7,7 @@ import logging import copy import cgi +from contextlib import closing # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -92,16 +93,18 @@ def download(self, workbook_id, filepath=None): error = "Workbook ID undefined." raise ValueError(error) url = "{0}/{1}/content".format(self.baseurl, workbook_id) - server_response = self.get_request(url) - _, params = cgi.parse_header(server_response.headers['Content-Disposition']) - filename = os.path.basename(params['filename']) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - - with open(filepath, 'wb') as f: - f.write(server_response.content) + + with closing(self.get_request(url, parameters={"stream": True})) as server_response: + _, params = cgi.parse_header(server_response.headers['Content-Disposition']) + filename = os.path.basename(params['filename']) + if filepath is None: + filepath = filename + elif os.path.isdir(filepath): + filepath = os.path.join(filepath, filename) + + with open(filepath, 'wb') as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) logger.info('Downloaded workbook to {0} (ID: {1})'.format(filepath, workbook_id)) return os.path.abspath(filepath) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 9a9bf53e1..db82b52aa 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,3 +1,4 @@ +from ..datetime_helpers import format_datetime import xml.etree.ElementTree as ET from requests.packages.urllib3.fields import RequestField @@ -41,6 +42,9 @@ def _generate_xml(self, datasource_item, connection_credentials=None): credentials_element.attrib['name'] = connection_credentials.name credentials_element.attrib['password'] = connection_credentials.password credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' + + if connection_credentials.oauth: + credentials_element.attrib['oAuth'] = 'true' return ET.tostring(xml_request) def update_req(self, datasource_item): @@ -278,6 +282,9 @@ def _generate_xml(self, workbook_item, connection_credentials=None): credentials_element.attrib['name'] = connection_credentials.name credentials_element.attrib['password'] = connection_credentials.password credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' + + if connection_credentials.oauth: + credentials_element.attrib['oAuth'] = 'true' return ET.tostring(xml_request) def update_req(self, workbook_item): diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 2cb08a892..b233377fe 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,8 +1,19 @@ +import xml.etree.ElementTree as ET + from .exceptions import NotSignedInError -from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, Schedules, ServerInfo +from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ + Schedules, ServerInfo, ServerInfoEndpointNotFoundError import requests +_PRODUCT_TO_REST_VERSION = { + '10.0': '2.3', + '9.3': '2.2', + '9.2': '2.1', + '9.1': '2.0', + '9.0': '2.0' +} + class Server(object): class PublishMode: @@ -47,6 +58,29 @@ def _set_auth(self, site_id, user_id, auth_token): self._user_id = user_id self._auth_token = auth_token + def _get_legacy_version(self): + response = self._session.get(self.server_address + "/auth?format=xml") + info_xml = ET.fromstring(response.content) + prod_version = info_xml.find('.//product_version').text + version = _PRODUCT_TO_REST_VERSION.get(prod_version, '2.1') # 2.1 + return version + + def _determine_highest_version(self): + try: + old_version = self.version + self.version = "2.4" + version = self.server_info.get().rest_api_version + except ServerInfoEndpointNotFoundError: + version = self._get_legacy_version() + + finally: + self.version = old_version + + return version + + def use_highest_version(self): + self.version = self._determine_highest_version() + @property def baseurl(self): return "{0}/api/{1}".format(self._server_address, str(self.version)) diff --git a/test/assets/server_info_404.xml b/test/assets/server_info_404.xml new file mode 100644 index 000000000..a23abf9ae --- /dev/null +++ b/test/assets/server_info_404.xml @@ -0,0 +1,7 @@ + + + + Resource Not Found + Unknown resource '/2.4/serverInfo' specified in URI. + + diff --git a/test/assets/server_info_auth_info.xml b/test/assets/server_info_auth_info.xml new file mode 100644 index 000000000..58d9c5baf --- /dev/null +++ b/test/assets/server_info_auth_info.xml @@ -0,0 +1,12 @@ + + +0.31 +0.31 +9.2 +9.3 +9.3.4 +hello.16.1106.2025 +unrestricted +2.6 + + diff --git a/test/test_datasource.py b/test/test_datasource.py index d01f3cb0f..9a1e07a24 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -2,6 +2,7 @@ import os import requests_mock import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -33,8 +34,8 @@ def test_get(self): self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', all_datasources[0].id) self.assertEqual('dataengine', all_datasources[0].datasource_type) self.assertEqual('SampleDS', all_datasources[0].content_url) - self.assertEqual('2016-08-11T21:22:40Z', all_datasources[0].created_at) - self.assertEqual('2016-08-11T21:34:17Z', all_datasources[0].updated_at) + self.assertEqual('2016-08-11T21:22:40Z', format_datetime(all_datasources[0].created_at)) + self.assertEqual('2016-08-11T21:34:17Z', format_datetime(all_datasources[0].updated_at)) self.assertEqual('default', all_datasources[0].project_name) self.assertEqual('SampleDS', all_datasources[0].name) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_datasources[0].project_id) @@ -43,8 +44,8 @@ def test_get(self): self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', all_datasources[1].id) self.assertEqual('dataengine', all_datasources[1].datasource_type) self.assertEqual('Sampledatasource', all_datasources[1].content_url) - self.assertEqual('2016-08-04T21:31:55Z', all_datasources[1].created_at) - self.assertEqual('2016-08-04T21:31:55Z', all_datasources[1].updated_at) + self.assertEqual('2016-08-04T21:31:55Z', format_datetime(all_datasources[1].created_at)) + self.assertEqual('2016-08-04T21:31:55Z', format_datetime(all_datasources[1].updated_at)) self.assertEqual('default', all_datasources[1].project_name) self.assertEqual('Sample datasource', all_datasources[1].name) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_datasources[1].project_id) @@ -75,8 +76,8 @@ def test_get_by_id(self): self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) self.assertEqual('dataengine', single_datasource.datasource_type) self.assertEqual('Sampledatasource', single_datasource.content_url) - self.assertEqual('2016-08-04T21:31:55Z', single_datasource.created_at) - self.assertEqual('2016-08-04T21:31:55Z', single_datasource.updated_at) + self.assertEqual('2016-08-04T21:31:55Z', format_datetime(single_datasource.created_at)) + self.assertEqual('2016-08-04T21:31:55Z', format_datetime(single_datasource.updated_at)) self.assertEqual('default', single_datasource.project_name) self.assertEqual('Sample datasource', single_datasource.name) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', single_datasource.project_id) @@ -125,8 +126,8 @@ def test_publish(self): self.assertEqual('SampleDS', new_datasource.name) self.assertEqual('SampleDS', new_datasource.content_url) self.assertEqual('dataengine', new_datasource.datasource_type) - self.assertEqual('2016-08-11T21:22:40Z', new_datasource.created_at) - self.assertEqual('2016-08-17T23:37:08Z', new_datasource.updated_at) + self.assertEqual('2016-08-11T21:22:40Z', format_datetime(new_datasource.created_at)) + self.assertEqual('2016-08-17T23:37:08Z', format_datetime(new_datasource.updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_datasource.project_id) self.assertEqual('default', new_datasource.project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index b43cc3f3d..600587801 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -1,3 +1,4 @@ +import datetime import unittest import tableauserverclient as TSC diff --git a/test/test_group.py b/test/test_group.py index ff928bf17..2f7f22701 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -3,6 +3,7 @@ import os import requests_mock import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -61,7 +62,7 @@ def test_populate_users(self): self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', user.id) self.assertEqual('alice', user.name) self.assertEqual('Publisher', user.site_role) - self.assertEqual('2016-08-16T23:17:06Z', user.last_login) + self.assertEqual('2016-08-16T23:17:06Z', format_datetime(user.last_login)) def test_delete(self): with requests_mock.mock() as m: diff --git a/test/test_requests.py b/test/test_requests.py new file mode 100644 index 000000000..686a4bbb4 --- /dev/null +++ b/test/test_requests.py @@ -0,0 +1,47 @@ +import unittest + +import requests +import requests_mock + +import tableauserverclient as TSC + + +class RequestTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # Fake sign in + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = self.server.workbooks.baseurl + + def test_make_get_request(self): + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" + opts = TSC.RequestOptions(pagesize=13, pagenumber=13) + resp = self.server.workbooks._make_request(requests.get, + url, + content=None, + request_object=opts, + auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', + content_type='text/xml') + + self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13') + self.assertEqual(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') + self.assertEqual(resp.request.headers['content-type'], 'text/xml') + + def test_make_post_request(self): + with requests_mock.mock() as m: + m.post(requests_mock.ANY) + url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" + resp = self.server.workbooks._make_request(requests.post, + url, + content=b'1337', + request_object=None, + auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', + content_type='multipart/mixed') + 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') diff --git a/test/test_schedule.py b/test/test_schedule.py index 710bfe2a2..965e414a8 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -1,8 +1,9 @@ +from datetime import time import unittest import os import requests_mock import tableauserverclient as TSC -from datetime import time +from tableauserverclient.datetime_helpers import format_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -37,19 +38,19 @@ def test_get(self): 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", all_schedules[0].created_at) - self.assertEqual("2016-09-13T11:00:32Z", all_schedules[0].updated_at) + 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", all_schedules[0].next_run_at) + 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", all_schedules[1].created_at) - self.assertEqual("2016-09-12T16:39:38Z", all_schedules[1].updated_at) + 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", all_schedules[1].next_run_at) + self.assertEqual("2016-09-18T06:00:00Z", format_datetime(all_schedules[1].next_run_at)) def test_get_empty(self): with open(GET_EMPTY_XML, "rb") as f: @@ -82,10 +83,10 @@ def test_create_hourly(self): self.assertEqual("hourly-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(50, new_schedule.priority) - self.assertEqual("2016-09-15T20:47:33Z", new_schedule.created_at) - self.assertEqual("2016-09-15T20:47:33Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T20:47:33Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T20:47:33Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-09-16T01:30:00Z", new_schedule.next_run_at) + self.assertEqual("2016-09-16T01:30:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) self.assertEqual(time(2, 30), new_schedule.interval_item.start_time) self.assertEqual(time(23), new_schedule.interval_item.end_time) @@ -105,10 +106,10 @@ def test_create_daily(self): self.assertEqual("daily-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(90, new_schedule.priority) - self.assertEqual("2016-09-15T21:01:09Z", new_schedule.created_at) - self.assertEqual("2016-09-15T21:01:09Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T21:01:09Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T21:01:09Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Subscription, new_schedule.schedule_type) - self.assertEqual("2016-09-16T11:45:00Z", new_schedule.next_run_at) + self.assertEqual("2016-09-16T11:45:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) self.assertEqual(time(4, 45), new_schedule.interval_item.start_time) @@ -128,10 +129,10 @@ def test_create_weekly(self): self.assertEqual("weekly-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(80, new_schedule.priority) - self.assertEqual("2016-09-15T21:12:50Z", new_schedule.created_at) - self.assertEqual("2016-09-15T21:12:50Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T21:12:50Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T21:12:50Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-09-16T16:15:00Z", new_schedule.next_run_at) + self.assertEqual("2016-09-16T16:15:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) self.assertEqual(time(9, 15), new_schedule.interval_item.start_time) self.assertEqual(("Monday", "Wednesday", "Friday"), @@ -151,10 +152,10 @@ def test_create_monthly(self): self.assertEqual("monthly-schedule-1", new_schedule.name) self.assertEqual("Active", new_schedule.state) self.assertEqual(20, new_schedule.priority) - self.assertEqual("2016-09-15T21:16:56Z", new_schedule.created_at) - self.assertEqual("2016-09-15T21:16:56Z", new_schedule.updated_at) + self.assertEqual("2016-09-15T21:16:56Z", format_datetime(new_schedule.created_at)) + self.assertEqual("2016-09-15T21:16:56Z", format_datetime(new_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-10-12T14:00:00Z", new_schedule.next_run_at) + self.assertEqual("2016-10-12T14:00:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) self.assertEqual(time(7), new_schedule.interval_item.start_time) self.assertEqual("12", new_schedule.interval_item.interval) @@ -174,9 +175,9 @@ def test_update(self): self.assertEqual("7bea1766-1543-4052-9753-9d224bc069b5", single_schedule.id) self.assertEqual("weekly-schedule-1", single_schedule.name) self.assertEqual(90, single_schedule.priority) - self.assertEqual("2016-09-15T23:50:02Z", single_schedule.updated_at) + self.assertEqual("2016-09-15T23:50:02Z", format_datetime(single_schedule.updated_at)) self.assertEqual(TSC.ScheduleItem.Type.Extract, single_schedule.schedule_type) - self.assertEqual("2016-09-16T14:00:00Z", single_schedule.next_run_at) + self.assertEqual("2016-09-16T14:00:00Z", format_datetime(single_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, single_schedule.execution_order) self.assertEqual(time(7), single_schedule.interval_item.start_time) self.assertEqual(("Monday", "Friday"), diff --git a/test/test_server_info.py b/test/test_server_info.py index 03e39210f..084e6c91f 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -6,21 +6,48 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') SERVER_INFO_GET_XML = os.path.join(TEST_ASSET_DIR, 'server_info_get.xml') +SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, 'server_info_404.xml') +SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, 'server_info_auth_info.xml') class ServerInfoTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') - self.server.version = '2.4' self.baseurl = self.server.server_info.baseurl def test_server_info_get(self): with open(SERVER_INFO_GET_XML, 'rb') as f: response_xml = f.read().decode('utf-8') with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) + self.server.version = '2.4' + m.get(self.server.server_info.baseurl, text=response_xml) actual = self.server.server_info.get() self.assertEqual('10.1.0', actual.product_version) self.assertEqual('10100.16.1024.2100', actual.build_number) self.assertEqual('2.4', actual.rest_api_version) + + def test_server_info_use_highest_version_downgrades(self): + with open(SERVER_INFO_AUTH_INFO_XML, 'rb') as f: + # This is the auth.xml endpoint present back to 9.0 Servers + auth_response_xml = f.read().decode('utf-8') + with open(SERVER_INFO_404, 'rb') as f: + # 10.1 serverInfo response + si_response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + # Return a 404 for serverInfo so we can pretend this is an old Server + m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml, status_code=404) + m.get(self.server.server_address + "/auth?format=xml", text=auth_response_xml) + self.server.use_highest_version() + self.assertEqual(self.server.version, '2.2') + + def test_server_info_use_highest_version_upgrades(self): + with open(SERVER_INFO_GET_XML, 'rb') as f: + si_response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml) + # Pretend we're old + self.server.version = '2.0' + self.server.use_highest_version() + # Did we upgrade to 2.4? + self.assertEqual(self.server.version, '2.4') diff --git a/test/test_user.py b/test/test_user.py index 71ec30207..fa8344371 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -2,6 +2,7 @@ import os import requests_mock import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -38,7 +39,7 @@ def test_get(self): single_user = next(user for user in all_users if user.id == 'dd2239f6-ddf1-4107-981a-4cf94e415794') self.assertEqual('alice', single_user.name) self.assertEqual('Publisher', single_user.site_role) - self.assertEqual('2016-08-16T23:17:06Z', single_user.last_login) + self.assertEqual('2016-08-16T23:17:06Z', format_datetime(single_user.last_login)) self.assertTrue(any(user.id == '2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3' for user in all_users)) single_user = next(user for user in all_users if user.id == '2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3') @@ -53,7 +54,7 @@ def test_get_empty(self): all_users, pagination_item = self.server.users.get() self.assertEqual(0, pagination_item.total_available) - self.assertEqual(set(), all_users) + self.assertEqual([], all_users) def test_get_before_signin(self): self.server._auth_token = None @@ -71,7 +72,7 @@ def test_get_by_id(self): self.assertEqual('Alice', single_user.fullname) self.assertEqual('Publisher', single_user.site_role) self.assertEqual('ServerDefault', single_user.auth_setting) - self.assertEqual('2016-08-16T23:17:06Z', single_user.last_login) + self.assertEqual('2016-08-16T23:17:06Z', format_datetime(single_user.last_login)) self.assertEqual('local', single_user.domain_name) def test_get_by_id_missing_id(self): @@ -136,8 +137,8 @@ def test_populate_workbooks(self): self.assertEqual('SafariSample', workbook_list[0].content_url) self.assertEqual(False, workbook_list[0].show_tabs) self.assertEqual(26, workbook_list[0].size) - self.assertEqual('2016-07-26T20:34:56Z', workbook_list[0].created_at) - self.assertEqual('2016-07-26T20:35:05Z', workbook_list[0].updated_at) + self.assertEqual('2016-07-26T20:34:56Z', format_datetime(workbook_list[0].created_at)) + self.assertEqual('2016-07-26T20:35:05Z', format_datetime(workbook_list[0].updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', workbook_list[0].project_id) self.assertEqual('default', workbook_list[0].project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', workbook_list[0].owner_id) diff --git a/test/test_workbook.py b/test/test_workbook.py index e99d07f81..4ad38b17d 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -2,6 +2,7 @@ import os import requests_mock import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -39,8 +40,8 @@ def test_get(self): self.assertEqual('Superstore', all_workbooks[0].content_url) self.assertEqual(False, all_workbooks[0].show_tabs) self.assertEqual(1, all_workbooks[0].size) - self.assertEqual('2016-08-03T20:34:04Z', all_workbooks[0].created_at) - self.assertEqual('2016-08-04T17:56:41Z', all_workbooks[0].updated_at) + self.assertEqual('2016-08-03T20:34:04Z', format_datetime(all_workbooks[0].created_at)) + self.assertEqual('2016-08-04T17:56:41Z', format_datetime(all_workbooks[0].updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_workbooks[0].project_id) self.assertEqual('default', all_workbooks[0].project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_workbooks[0].owner_id) @@ -50,8 +51,8 @@ def test_get(self): self.assertEqual('SafariSample', all_workbooks[1].content_url) self.assertEqual(False, all_workbooks[1].show_tabs) self.assertEqual(26, all_workbooks[1].size) - self.assertEqual('2016-07-26T20:34:56Z', all_workbooks[1].created_at) - self.assertEqual('2016-07-26T20:35:05Z', all_workbooks[1].updated_at) + self.assertEqual('2016-07-26T20:34:56Z', format_datetime(all_workbooks[1].created_at)) + self.assertEqual('2016-07-26T20:35:05Z', format_datetime(all_workbooks[1].updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_workbooks[1].project_id) self.assertEqual('default', all_workbooks[1].project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_workbooks[1].owner_id) @@ -83,8 +84,8 @@ def test_get_by_id(self): self.assertEqual('SafariSample', single_workbook.content_url) self.assertEqual(False, single_workbook.show_tabs) self.assertEqual(26, single_workbook.size) - self.assertEqual('2016-07-26T20:34:56Z', single_workbook.created_at) - self.assertEqual('2016-07-26T20:35:05Z', single_workbook.updated_at) + self.assertEqual('2016-07-26T20:34:56Z', format_datetime(single_workbook.created_at)) + self.assertEqual('2016-07-26T20:35:05Z', format_datetime(single_workbook.updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', single_workbook.project_id) self.assertEqual('default', single_workbook.project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_workbook.owner_id) @@ -250,8 +251,8 @@ def test_publish(self): self.assertEqual('RESTAPISample_0', new_workbook.content_url) self.assertEqual(False, new_workbook.show_tabs) self.assertEqual(1, new_workbook.size) - self.assertEqual('2016-08-18T18:33:24Z', new_workbook.created_at) - self.assertEqual('2016-08-18T20:31:34Z', new_workbook.updated_at) + self.assertEqual('2016-08-18T18:33:24Z', format_datetime(new_workbook.created_at)) + self.assertEqual('2016-08-18T20:31:34Z', format_datetime(new_workbook.updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_workbook.project_id) self.assertEqual('default', new_workbook.project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_workbook.owner_id)