diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index fb0dabe44..3a5996dea 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from .permissions_item import PermissionsRule from .connection_item import ConnectionItem + from .revision_item import RevisionItem import datetime @@ -168,7 +169,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self): + def revisions(self) -> List["RevisionItem"]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index f09d08e68..6fac9d7d3 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,46 +1,66 @@ import xml.etree.ElementTree as ET +from ..datetime_helpers import parse_datetime +from typing import List, Optional, TYPE_CHECKING, Type + +if TYPE_CHECKING: + from datetime import datetime class RevisionItem(object): def __init__(self): - self._resource_id = None - self._resource_name = None - self._revision_number = None - self._current = None - self._deleted = None - self._created_at = None + self._resource_id: Optional[str] = None + self._resource_name: Optional[str] = None + self._revision_number: Optional[str] = None + self._current: Optional[bool] = None + self._deleted: Optional[bool] = None + self._created_at: Optional["datetime"] = None + self._user_id: Optional[str] = None + self._user_name: Optional[str] = None @property - def resource_id(self): + def resource_id(self) -> Optional[str]: return self._resource_id @property - def resource_name(self): + def resource_name(self) -> Optional[str]: return self._resource_name @property - def revision_number(self): + def revision_number(self) -> Optional[str]: return self._revision_number @property - def current(self): + def current(self) -> Optional[bool]: return self._current @property - def deleted(self): + def deleted(self) -> Optional[bool]: return self._deleted @property - def created_at(self): + def created_at(self) -> Optional["datetime"]: return self._created_at + @property + def user_id(self) -> Optional[str]: + return self._user_id + + @property + def user_name(self) -> Optional[str]: + return self._user_name + def __repr__(self): return ( "".format(**self.__dict__) + "current={_current} deleted={_deleted} user={_user_id}>".format(**self.__dict__) ) @classmethod - def from_response(cls, resp, ns, resource_item): + def from_response( + cls, + resp: bytes, + ns, + resource_item + ) -> List["RevisionItem"]: all_revision_items = list() parsed_response = ET.fromstring(resp) all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) @@ -49,14 +69,17 @@ def from_response(cls, resp, ns, resource_item): revision_item._resource_id = resource_item.id revision_item._resource_name = resource_item.name revision_item._revision_number = revision_xml.get("revisionNumber", None) - revision_item._current = string_to_bool(revision_xml.get("current", "")) - revision_item._deleted = string_to_bool(revision_xml.get("deleted", "")) - revision_item._created_at = revision_xml.get("createdAt", None) + revision_item._current = string_to_bool(revision_xml.get("isCurrent", "")) + revision_item._deleted = string_to_bool(revision_xml.get("isDeleted", "")) + revision_item._created_at = parse_datetime(revision_xml.get("createdAt", None)) + for user in revision_xml.findall('.//t:user', namespaces=ns): + revision_item._user_id = user.get("id", None) + revision_item._user_name = user.get("name", None) all_revision_items.append(revision_item) return all_revision_items # Used to convert string represented boolean to a boolean type -def string_to_bool(s): +def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 1e44a901e..8e342686c 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -12,12 +12,13 @@ import copy import uuid -from typing import Dict, List, Optional, Set, TYPE_CHECKING, Union +from typing import Dict, List, Optional, Set, TYPE_CHECKING if TYPE_CHECKING: from .connection_item import ConnectionItem from .permissions_item import PermissionsRule import datetime + from .revision_item import RevisionItem class WorkbookItem(object): @@ -40,7 +41,7 @@ def __init__(self, project_id: str, name: str = None, show_tabs: bool = False) - self.owner_id: Optional[str] = None self.project_id = project_id self.show_tabs = show_tabs - self.hidden_views = None + self.hidden_views: Optional[List[str]] = None self.tags: Set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, @@ -157,7 +158,7 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def revisions(self): + def revisions(self) -> List["RevisionItem"]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 98dd09bed..6b5825126 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -12,7 +12,7 @@ get_file_object_size, ) from ...models.job_item import JobItem -from ...models import ConnectionCredentials +from ...models import ConnectionCredentials, RevisionItem import io import os @@ -393,7 +393,8 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) # Populate datasource item's revisions - def populate_revisions(self, datasource_item): + @api(version="2.3") + def populate_revisions(self, datasource_item: DatasourceItem) -> None: if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -406,7 +407,11 @@ def revisions_fetcher(): "Populated revisions for datasource (ID: {0})".format(datasource_item.id) ) - def _get_datasource_revisions(self, datasource_item, req_options=None): + def _get_datasource_revisions( + self, + datasource_item: DatasourceItem, + req_options: Optional["RequestOptions"] = None + ) -> List[RevisionItem]: url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response( @@ -415,14 +420,15 @@ def _get_datasource_revisions(self, datasource_item, req_options=None): return revisions # Download 1 datasource revision by revision number + @api(version="2.3") def download_revision( self, - datasource_id, - revision_number, - filepath=None, - include_extract=True, - no_extract=None, - ): + datasource_id: str, + revision_number: str, + filepath: Optional[PathOrFile] = None, + include_extract: bool = True, + no_extract: Optional[bool] = None, + ) -> str: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -459,3 +465,13 @@ def download_revision( ) ) return os.path.abspath(download_path) + + @api(version="2.3") + def delete_revision(self, datasource_id: str, revision_number: str) -> None: + if datasource_id is None or revision_number is None: + raise ValueError + url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) + + self.delete_request(url) + logger.info("Deleted single datasource revsision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number)) + diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 32d3cc21a..aa9fdf05c 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -4,6 +4,7 @@ from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem from ...models.job_item import JobItem +from ...models.revision_item import RevisionItem from ...filesys_helpers import ( to_filename, make_download_path, @@ -429,7 +430,8 @@ def publish( return new_workbook # Populate workbook item's revisions - def populate_revisions(self, workbook_item): + @api(version="2.3") + def populate_revisions(self, workbook_item: WorkbookItem) -> None: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -442,7 +444,11 @@ def revisions_fetcher(): "Populated revisions for workbook (ID: {0})".format(workbook_item.id) ) - def _get_workbook_revisions(self, workbook_item, req_options=None): + def _get_workbook_revisions( + self, + workbook_item: WorkbookItem, + req_options: Optional["RequestOptions"]=None + ) -> List[RevisionItem]: url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response( @@ -451,14 +457,15 @@ def _get_workbook_revisions(self, workbook_item, req_options=None): return revisions # Download 1 workbook revision by revision number + @api(version="2.3") def download_revision( self, - workbook_id, - revision_number, - filepath=None, - include_extract=True, - no_extract=None, - ): + workbook_id: str, + revision_number: str, + filepath: Optional[PathOrFile] = None, + include_extract: bool = True, + no_extract: Optional[bool] = None, + ) -> str: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -495,3 +502,12 @@ def download_revision( ) ) return os.path.abspath(download_path) + + @api(version="2.3") + def delete_revision(self, workbook_id: str, revision_number: str) -> None: + if workbook_id is None or revision_number is None: + raise ValueError + url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) + + self.delete_request(url) + logger.info("Deleted single workbook revsision (ID: {0}) (Revision: {1})".format(workbook_id, revision_number)) diff --git a/test/assets/datasource_revision.xml b/test/assets/datasource_revision.xml new file mode 100644 index 000000000..598c8ad45 --- /dev/null +++ b/test/assets/datasource_revision.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_revision.xml b/test/assets/workbook_revision.xml new file mode 100644 index 000000000..598c8ad45 --- /dev/null +++ b/test/assets/workbook_revision.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_datasource.py b/test/test_datasource.py index d00c05080..b943f341d 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -5,6 +5,7 @@ import requests_mock import xml.etree.ElementTree as ET from zipfile import ZipFile +import tempfile import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime @@ -22,6 +23,7 @@ PUBLISH_XML = 'datasource_publish.xml' PUBLISH_XML_ASYNC = 'datasource_publish_async.xml' REFRESH_XML = 'datasource_refresh.xml' +REVISION_XML = 'datasource_revision.xml' UPDATE_XML = 'datasource_update.xml' UPDATE_HYPER_DATA_XML = 'datasource_data_update.xml' UPDATE_CONNECTION_XML = 'datasource_connection_update.xml' @@ -600,3 +602,47 @@ def test_create_extracts_encrypted(self) -> None: 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) + + def test_revisions(self) -> None: + datasource = TSC.DatasourceItem('project', 'test') + datasource._id = '06b944d2-959d-4604-9305-12323c95e70e' + + response_xml = read_xml_asset(REVISION_XML) + with requests_mock.mock() as m: + m.get("{0}/{1}/revisions".format(self.baseurl, datasource.id), text=response_xml) + self.server.datasources.populate_revisions(datasource) + revisions = datasource.revisions + + self.assertEqual(len(revisions), 3) + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(revisions[0].created_at)) + self.assertEqual("2016-07-27T20:34:56Z", format_datetime(revisions[1].created_at)) + self.assertEqual("2016-07-28T20:34:56Z", format_datetime(revisions[2].created_at)) + + self.assertEqual(False, revisions[0].deleted) + self.assertEqual(False, revisions[0].current) + self.assertEqual(False, revisions[1].deleted) + self.assertEqual(False, revisions[1].current) + self.assertEqual(False, revisions[2].deleted) + self.assertEqual(True, revisions[2].current) + + self.assertEqual("Cassie", revisions[0].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[0].user_id) + self.assertIsNone(revisions[1].user_name) + self.assertIsNone(revisions[1].user_id) + self.assertEqual("Cassie", revisions[2].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[2].user_id) + + def test_delete_revision(self) -> None: + datasource = TSC.DatasourceItem('project', 'test') + datasource._id = '06b944d2-959d-4604-9305-12323c95e70e' + + with requests_mock.mock() as m: + m.delete("{0}/{1}/revisions/3".format(self.baseurl, datasource.id)) + self.server.datasources.delete_revision(datasource.id, "3") + + def test_download_revision(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/revisions/3/content', + headers={'Content-Disposition': 'name="tableau_datasource"; filename="Sample datasource.tds"'}) + file_path = self.server.datasources.download_revision('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', "3", td) + self.assertTrue(os.path.exists(file_path)) diff --git a/test/test_workbook.py b/test/test_workbook.py index 9bf81dbfd..b73c000f5 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -6,6 +6,7 @@ import tableauserverclient as TSC import xml.etree.ElementTree as ET from pathlib import Path +import tempfile from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError @@ -33,6 +34,7 @@ PUBLISH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish.xml') PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish_async.xml') REFRESH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_refresh.xml') +REVISION_XML = os.path.join(TEST_ASSET_DIR, 'workbook_revision.xml') UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, 'workbook_update_permissions.xml') @@ -773,3 +775,51 @@ def test_create_extracts_one(self) -> None: 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) + + def test_revisions(self) -> None: + self.baseurl = self.server.workbooks.baseurl + workbook = TSC.WorkbookItem('project', 'test') + workbook._id = '06b944d2-959d-4604-9305-12323c95e70e' + + with open(REVISION_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get("{0}/{1}/revisions".format(self.baseurl, workbook.id), text=response_xml) + self.server.workbooks.populate_revisions(workbook) + revisions = workbook.revisions + + self.assertEqual(len(revisions), 3) + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(revisions[0].created_at)) + self.assertEqual("2016-07-27T20:34:56Z", format_datetime(revisions[1].created_at)) + self.assertEqual("2016-07-28T20:34:56Z", format_datetime(revisions[2].created_at)) + + self.assertEqual(False, revisions[0].deleted) + self.assertEqual(False, revisions[0].current) + self.assertEqual(False, revisions[1].deleted) + self.assertEqual(False, revisions[1].current) + self.assertEqual(False, revisions[2].deleted) + self.assertEqual(True, revisions[2].current) + + self.assertEqual("Cassie", revisions[0].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[0].user_id) + self.assertIsNone(revisions[1].user_name) + self.assertIsNone(revisions[1].user_id) + self.assertEqual("Cassie", revisions[2].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[2].user_id) + + def test_delete_revision(self) -> None: + self.baseurl = self.server.workbooks.baseurl + workbook = TSC.WorkbookItem('project', 'test') + workbook._id = '06b944d2-959d-4604-9305-12323c95e70e' + + with requests_mock.mock() as m: + m.delete("{0}/{1}/revisions/3".format(self.baseurl, workbook.id)) + self.server.workbooks.delete_revision(workbook.id, "3") + + def test_download_revision(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/revisions/3/content', + headers={'Content-Disposition': 'name="tableau_datasource"; filename="Sample datasource.tds"'}) + file_path = self.server.workbooks.download_revision('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', "3", td) + self.assertTrue(os.path.exists(file_path)) +