diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py
index d435962b1..eb647ed25 100644
--- a/tableauserverclient/__init__.py
+++ b/tableauserverclient/__init__.py
@@ -3,7 +3,7 @@
GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem,\
SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError,\
HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem,\
- SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem
+ SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem, FlowItem
from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \
Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager
from ._version import get_versions
diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py
index 4cfdd4846..a3517e13f 100644
--- a/tableauserverclient/models/__init__.py
+++ b/tableauserverclient/models/__init__.py
@@ -5,6 +5,7 @@
from .database_item import DatabaseItem
from .exceptions import UnpopulatedPropertyError
from .group_item import GroupItem
+from .flow_item import FlowItem
from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval
from .job_item import JobItem, BackgroundJobItem
from .pagination_item import PaginationItem
diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py
new file mode 100644
index 000000000..790000df2
--- /dev/null
+++ b/tableauserverclient/models/flow_item.py
@@ -0,0 +1,162 @@
+import xml.etree.ElementTree as ET
+from .exceptions import UnpopulatedPropertyError
+from .property_decorators import property_not_nullable, property_is_boolean
+from .tag_item import TagItem
+from ..datetime_helpers import parse_datetime
+import copy
+
+
+class FlowItem(object):
+ def __init__(self, project_id, name=None):
+ self._webpage_url = None
+ self._created_at = None
+ self._id = None
+ self._initial_tags = set()
+ self._project_name = None
+ self._updated_at = None
+ self.name = name
+ self.owner_id = None
+ self.project_id = project_id
+ self.tags = set()
+ self.description = None
+
+ self._connections = None
+ self._permissions = None
+
+ @property
+ def connections(self):
+ if self._connections is None:
+ error = 'Flow item must be populated with connections first.'
+ raise UnpopulatedPropertyError(error)
+ return self._connections()
+
+ @property
+ def permissions(self):
+ if self._permissions is None:
+ error = "Project item must be populated with permissions first."
+ raise UnpopulatedPropertyError(error)
+ return self._permissions()
+
+ @property
+ def webpage_url(self):
+ return self._webpage_url
+
+ @property
+ def created_at(self):
+ return self._created_at
+
+ @property
+ def id(self):
+ return self._id
+
+ @property
+ def project_id(self):
+ return self._project_id
+
+ @project_id.setter
+ @property_not_nullable
+ def project_id(self, value):
+ self._project_id = value
+
+ @property
+ def description(self):
+ return self._description
+
+ @description.setter
+ def description(self, value):
+ self._description = value
+
+ @property
+ def project_name(self):
+ return self._project_name
+
+ @property
+ def flow_type(self):
+ return self._flow_type
+
+ @property
+ def updated_at(self):
+ return self._updated_at
+
+ def _set_connections(self, connections):
+ self._connections = connections
+
+ def _set_permissions(self, permissions):
+ self._permissions = permissions
+
+ def _parse_common_elements(self, flow_xml, ns):
+ if not isinstance(flow_xml, ET.Element):
+ flow_xml = ET.fromstring(flow_xml).find('.//t:flow', namespaces=ns)
+ if flow_xml is not None:
+ (_, _, _, _, _, updated_at, _, project_id, project_name, owner_id) = self._parse_element(flow_xml, ns)
+ self._set_values(None, None, None, None, None, updated_at, None, project_id,
+ project_name, owner_id)
+ return self
+
+ def _set_values(self, id, name, description, webpage_url, created_at,
+ updated_at, tags, project_id, project_name, owner_id):
+ if id is not None:
+ self._id = id
+ if name:
+ self.name = name
+ if description:
+ self.description = description
+ if webpage_url:
+ self._webpage_url = webpage_url
+ if created_at:
+ self._created_at = created_at
+ if updated_at:
+ self._updated_at = updated_at
+ if tags:
+ self.tags = tags
+ self._initial_tags = copy.copy(tags)
+ if project_id:
+ self.project_id = project_id
+ if project_name:
+ self._project_name = project_name
+ if owner_id:
+ self.owner_id = owner_id
+
+ @classmethod
+ def from_response(cls, resp, ns):
+ all_flow_items = list()
+ parsed_response = ET.fromstring(resp)
+ all_flow_xml = parsed_response.findall('.//t:flow', namespaces=ns)
+
+ for flow_xml in all_flow_xml:
+ (id_, name, description, webpage_url, created_at, updated_at,
+ tags, project_id, project_name, owner_id) = cls._parse_element(flow_xml, ns)
+ flow_item = cls(project_id)
+ flow_item._set_values(id_, name, description, webpage_url, created_at, updated_at,
+ tags, None, project_name, owner_id)
+ all_flow_items.append(flow_item)
+ return all_flow_items
+
+ @staticmethod
+ def _parse_element(flow_xml, ns):
+ id_ = flow_xml.get('id', None)
+ name = flow_xml.get('name', None)
+ description = flow_xml.get('description', None)
+ webpage_url = flow_xml.get('webpageUrl', None)
+ created_at = parse_datetime(flow_xml.get('createdAt', None))
+ updated_at = parse_datetime(flow_xml.get('updatedAt', None))
+
+ tags = None
+ tags_elem = flow_xml.find('.//t:tags', namespaces=ns)
+ if tags_elem is not None:
+ tags = TagItem.from_xml_element(tags_elem, ns)
+
+ project_id = None
+ project_name = None
+ project_elem = flow_xml.find('.//t:project', namespaces=ns)
+ if project_elem is not None:
+ project_id = project_elem.get('id', None)
+ project_name = project_elem.get('name', None)
+
+ owner_id = None
+ owner_elem = flow_xml.find('.//t:owner', namespaces=ns)
+ if owner_elem is not None:
+ owner_id = owner_elem.get('id', None)
+
+ return (id_, name, description, webpage_url, created_at, updated_at, tags, project_id,
+ project_name, owner_id)
diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py
index dcbdc8d13..a76fd3246 100644
--- a/tableauserverclient/server/__init__.py
+++ b/tableauserverclient/server/__init__.py
@@ -5,10 +5,10 @@
from .. import ConnectionItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \
GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\
UserItem, ViewItem, WorkbookItem, TableItem, TaskItem, SubscriptionItem, \
- PermissionsRule, Permission, ColumnItem
+ PermissionsRule, Permission, ColumnItem, FlowItem
from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \
Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \
- MissingRequiredFieldError
+ MissingRequiredFieldError, Flows
from .server import Server
from .pager import Pager
from .exceptions import NotSignedInError
diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py
index 99bb37005..dbf501fe3 100644
--- a/tableauserverclient/server/endpoint/__init__.py
+++ b/tableauserverclient/server/endpoint/__init__.py
@@ -2,6 +2,7 @@
from .datasources_endpoint import Datasources
from .databases_endpoint import Databases
from .endpoint import Endpoint
+from .flows_endpoint import Flows
from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError
from .groups_endpoint import Groups
from .jobs_endpoint import Jobs
diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py
new file mode 100644
index 000000000..b2c616959
--- /dev/null
+++ b/tableauserverclient/server/endpoint/flows_endpoint.py
@@ -0,0 +1,215 @@
+from .endpoint import Endpoint, api, parameter_added_in
+from .exceptions import InternalServerError, MissingRequiredFieldError
+from .endpoint import api, parameter_added_in, Endpoint
+from .permissions_endpoint import _PermissionsEndpoint
+from .exceptions import MissingRequiredFieldError
+from .fileuploads_endpoint import Fileuploads
+from .resource_tagger import _ResourceTagger
+from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem
+from ...filesys_helpers import to_filename
+from ...models.tag_item import TagItem
+from ...models.job_item import JobItem
+import os
+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
+
+ALLOWED_FILE_EXTENSIONS = ['tfl', 'tflx']
+
+logger = logging.getLogger('tableau.endpoint.flows')
+
+
+class Flows(Endpoint):
+ def __init__(self, parent_srv):
+ super(Flows, self).__init__(parent_srv)
+ self._resource_tagger = _ResourceTagger(parent_srv)
+ self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
+
+ @property
+ def baseurl(self):
+ return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id)
+
+ # Get all flows
+ @api(version="3.3")
+ def get(self, req_options=None):
+ logger.info('Querying all flows 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_flow_items = FlowItem.from_response(server_response.content, self.parent_srv.namespace)
+ return all_flow_items, pagination_item
+
+ # Get 1 flow by id
+ @api(version="3.3")
+ def get_by_id(self, flow_id):
+ if not flow_id:
+ error = "Flow ID undefined."
+ raise ValueError(error)
+ logger.info('Querying single flow (ID: {0})'.format(flow_id))
+ url = "{0}/{1}".format(self.baseurl, flow_id)
+ server_response = self.get_request(url)
+ return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+
+ # Populate flow item's connections
+ @api(version="3.3")
+ def populate_connections(self, flow_item):
+ if not flow_item.id:
+ error = 'Flow item missing ID. Flow must be retrieved from server first.'
+ raise MissingRequiredFieldError(error)
+
+ def connections_fetcher():
+ return self._get_flow_connections(flow_item)
+
+ flow_item._set_connections(connections_fetcher)
+ logger.info('Populated connections for flow (ID: {0})'.format(flow_item.id))
+
+ def _get_flow_connections(self, flow_item, req_options=None):
+ url = '{0}/{1}/connections'.format(self.baseurl, flow_item.id)
+ server_response = self.get_request(url, req_options)
+ connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
+ return connections
+
+ # Delete 1 flow by id
+ @api(version="3.3")
+ def delete(self, flow_id):
+ if not flow_id:
+ error = "Flow ID undefined."
+ raise ValueError(error)
+ url = "{0}/{1}".format(self.baseurl, flow_id)
+ self.delete_request(url)
+ logger.info('Deleted single flow (ID: {0})'.format(flow_id))
+
+ # Download 1 flow by id
+ @api(version="3.3")
+ def download(self, flow_id, filepath=None):
+ if not flow_id:
+ error = "Flow ID undefined."
+ raise ValueError(error)
+ url = "{0}/{1}/content".format(self.baseurl, flow_id)
+
+ with closing(self.get_request(url, parameters={'stream': True})) as server_response:
+ _, params = cgi.parse_header(server_response.headers['Content-Disposition'])
+ filename = to_filename(os.path.basename(params['filename']))
+ if filepath is None:
+ filepath = filename
+ elif os.path.isdir(filepath):
+ filepath = os.path.join(filepath, filename)
+
+ with open(filepath, 'wb') as f:
+ for chunk in server_response.iter_content(1024): # 1KB
+ f.write(chunk)
+
+ logger.info('Downloaded flow to {0} (ID: {1})'.format(filepath, flow_id))
+ return os.path.abspath(filepath)
+
+ # Update flow
+ @api(version="3.3")
+ def update(self, flow_item):
+ if not flow_item.id:
+ error = 'Flow item missing ID. Flow must be retrieved from server first.'
+ raise MissingRequiredFieldError(error)
+
+ self._resource_tagger.update_tags(self.baseurl, flow_item)
+
+ # Update the flow itself
+ url = "{0}/{1}".format(self.baseurl, flow_item.id)
+ update_req = RequestFactory.Flow.update_req(flow_item)
+ server_response = self.put_request(url, update_req)
+ logger.info('Updated flow item (ID: {0})'.format(flow_item.id))
+ updated_flow = copy.copy(flow_item)
+ return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace)
+
+ # Update flow connections
+ @api(version="3.3")
+ def update_connection(self, flow_item, connection_item):
+ url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id)
+
+ update_req = RequestFactory.Connection.update_req(connection_item)
+ server_response = self.put_request(url, update_req)
+ connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+
+ logger.info('Updated flow item (ID: {0} & connection item {1}'.format(flow_item.id,
+ connection_item.id))
+ return connection
+
+ @api(version="3.3")
+ def refresh(self, flow_item):
+ url = "{0}/{1}/run".format(self.baseurl, flow_item.id)
+ 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
+
+ # Publish flow
+ @api(version="3.3")
+ def publish(self, flow_item, file_path, mode, connections=None):
+ if not os.path.isfile(file_path):
+ error = "File path does not lead to an existing file."
+ raise IOError(error)
+ if not mode or not hasattr(self.parent_srv.PublishMode, mode):
+ error = 'Invalid mode defined.'
+ raise ValueError(error)
+
+ filename = os.path.basename(file_path)
+ file_extension = os.path.splitext(filename)[1][1:]
+
+ # If name is not defined, grab the name from the file to publish
+ if not flow_item.name:
+ flow_item.name = os.path.splitext(filename)[0]
+ if file_extension not in ALLOWED_FILE_EXTENSIONS:
+ error = "Only {} files can be published as flows.".format(', '.join(ALLOWED_FILE_EXTENSIONS))
+ raise ValueError(error)
+
+ # Construct the url with the defined mode
+ url = "{0}?flowType={1}".format(self.baseurl, file_extension)
+ if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append:
+ url += '&{0}=true'.format(mode.lower())
+
+ # Determine if chunking is required (64MB is the limit for single upload method)
+ if os.path.getsize(file_path) >= FILESIZE_LIMIT:
+ logger.info('Publishing {0} to server with chunking method (flow over 64MB)'.format(filename))
+ upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path)
+ url = "{0}&uploadSessionId={1}".format(url, upload_session_id)
+ xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item,
+ connections)
+ else:
+ logger.info('Publishing {0} to server'.format(filename))
+ with open(file_path, 'rb') as f:
+ file_contents = f.read()
+ xml_request, content_type = RequestFactory.Flow.publish_req(flow_item,
+ filename,
+ file_contents,
+ connections)
+
+ # Send the publishing request to server
+ try:
+ server_response = self.post_request(url, xml_request, content_type)
+ except InternalServerError as err:
+ if err.code == 504:
+ err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts."
+ raise err
+ else:
+ new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+ logger.info('Published {0} (ID: {1})'.format(filename, new_flow.id))
+ return new_flow
+
+ server_response = self.post_request(url, xml_request, content_type)
+ new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+ logger.info('Published {0} (ID: {1})'.format(filename, new_flow.id))
+ return new_flow
+
+ @api(version='3.3')
+ def populate_permissions(self, item):
+ self._permissions.populate(item)
+
+ @api(version='3.3')
+ def update_permission(self, item, permission_item):
+ self._permissions.update(item, permission_item)
+
+ @api(version='3.3')
+ def delete_permission(self, item, capability_item):
+ self._permissions.delete(item, capability_item)
diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py
index 1f787382e..ad484e6a8 100644
--- a/tableauserverclient/server/request_factory.py
+++ b/tableauserverclient/server/request_factory.py
@@ -151,6 +151,46 @@ def chunk_req(self, chunk):
return _add_multipart(parts)
+class FlowRequest(object):
+ def _generate_xml(self, flow_item, connections=None):
+ xml_request = ET.Element('tsRequest')
+ flow_element = ET.SubElement(xml_request, 'flow')
+ flow_element.attrib['name'] = flow_item.name
+ project_element = ET.SubElement(flow_element, 'project')
+ project_element.attrib['id'] = flow_item.project_id
+
+ if connections is not None:
+ connections_element = ET.SubElement(flow_element, 'connections')
+ for connection in connections:
+ _add_connections_element(connections_element, connection)
+ return ET.tostring(xml_request)
+
+ def update_req(self, flow_item):
+ xml_request = ET.Element('tsRequest')
+ flow_element = ET.SubElement(xml_request, 'flow')
+ if flow_item.project_id:
+ project_element = ET.SubElement(flow_element, 'project')
+ project_element.attrib['id'] = flow_item.project_id
+ if flow_item.owner_id:
+ owner_element = ET.SubElement(flow_element, 'owner')
+ owner_element.attrib['id'] = flow_item.owner_id
+
+ return ET.tostring(xml_request)
+
+ def publish_req(self, flow_item, filename, file_contents, connections=None):
+ xml_request = self._generate_xml(flow_item, connections)
+
+ parts = {'request_payload': ('', xml_request, 'text/xml'),
+ 'tableau_flow': (filename, file_contents, 'application/octet-stream')}
+ return _add_multipart(parts)
+
+ def publish_req_chunked(self, flow_item, connections=None):
+ xml_request = self._generate_xml(flow_item, connections)
+
+ parts = {'request_payload': ('', xml_request, 'text/xml')}
+ return _add_multipart(parts)
+
+
class GroupRequest(object):
def add_user_req(self, user_id):
xml_request = ET.Element('tsRequest')
@@ -523,6 +563,7 @@ class RequestFactory(object):
Database = DatabaseRequest()
Empty = EmptyRequest()
Fileupload = FileuploadRequest()
+ Flow = FlowRequest()
Group = GroupRequest()
Permission = PermissionRequest()
Project = ProjectRequest()
diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py
index 9ba195d9d..b11f55d17 100644
--- a/tableauserverclient/server/server.py
+++ b/tableauserverclient/server/server.py
@@ -4,7 +4,7 @@
from ..namespace import Namespace
from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \
Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs, Metadata,\
- Databases, Tables
+ Databases, Tables, Flows
from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError
import requests
@@ -46,6 +46,7 @@ def __init__(self, server_address, use_server_version=False):
self.jobs = Jobs(self)
self.workbooks = Workbooks(self)
self.datasources = Datasources(self)
+ self.flows = Flows(self)
self.projects = Projects(self)
self.schedules = Schedules(self)
self.server_info = ServerInfo(self)
diff --git a/test/assets/flow_get.xml b/test/assets/flow_get.xml
new file mode 100644
index 000000000..406cded8e
--- /dev/null
+++ b/test/assets/flow_get.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/flow_populate_connections.xml b/test/assets/flow_populate_connections.xml
new file mode 100644
index 000000000..5c013770c
--- /dev/null
+++ b/test/assets/flow_populate_connections.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/flow_populate_permissions.xml b/test/assets/flow_populate_permissions.xml
new file mode 100644
index 000000000..59fe5bd67
--- /dev/null
+++ b/test/assets/flow_populate_permissions.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/flow_update.xml b/test/assets/flow_update.xml
new file mode 100644
index 000000000..5ab69f583
--- /dev/null
+++ b/test/assets/flow_update.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/test_flow.py b/test/test_flow.py
new file mode 100644
index 000000000..f5c057c30
--- /dev/null
+++ b/test/test_flow.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 = 'flow_get.xml'
+POPULATE_CONNECTIONS_XML = 'flow_populate_connections.xml'
+POPULATE_PERMISSIONS_XML = 'flow_populate_permissions.xml'
+UPDATE_XML = 'flow_update.xml'
+
+
+class FlowTests(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.5"
+
+ self.baseurl = self.server.flows.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_flows, pagination_item = self.server.flows.get()
+
+ self.assertEqual(5, pagination_item.total_available)
+ self.assertEqual('587daa37-b84d-4400-a9a2-aa90e0be7837', all_flows[0].id)
+ self.assertEqual('http://tableauserver/#/flows/1', all_flows[0].webpage_url)
+ self.assertEqual('2019-06-16T21:43:28Z', format_datetime(all_flows[0].created_at))
+ self.assertEqual('2019-06-16T21:43:28Z', format_datetime(all_flows[0].updated_at))
+ self.assertEqual('Default', all_flows[0].project_name)
+ self.assertEqual('FlowOne', all_flows[0].name)
+ self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flows[0].project_id)
+ self.assertEqual('7ebb3f20-0fd2-4f27-a2f6-c539470999e2', all_flows[0].owner_id)
+ self.assertEqual({'i_love_tags'}, all_flows[0].tags)
+ self.assertEqual('Descriptive', all_flows[0].description)
+
+ self.assertEqual('5c36be69-eb30-461b-b66e-3e2a8e27cc35', all_flows[1].id)
+ self.assertEqual('http://tableauserver/#/flows/4', all_flows[1].webpage_url)
+ self.assertEqual('2019-06-18T03:08:19Z', format_datetime(all_flows[1].created_at))
+ self.assertEqual('2019-06-18T03:08:19Z', format_datetime(all_flows[1].updated_at))
+ self.assertEqual('Default', all_flows[1].project_name)
+ self.assertEqual('FlowTwo', all_flows[1].name)
+ self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flows[1].project_id)
+ self.assertEqual('9127d03f-d996-405f-b392-631b25183a0f', all_flows[1].owner_id)
+
+ def test_update(self):
+ response_xml = read_xml_asset(UPDATE_XML)
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + '/587daa37-b84d-4400-a9a2-aa90e0be7837', text=response_xml)
+ single_datasource = TSC.FlowItem('test', 'aa23f4ac-906f-11e9-86fb-3f0f71412e77')
+ single_datasource.owner_id = '7ebb3f20-0fd2-4f27-a2f6-c539470999e2'
+ single_datasource._id = '587daa37-b84d-4400-a9a2-aa90e0be7837'
+ single_datasource.description = "So fun to see"
+ single_datasource = self.server.flows.update(single_datasource)
+
+ self.assertEqual('587daa37-b84d-4400-a9a2-aa90e0be7837', single_datasource.id)
+ self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', single_datasource.project_id)
+ self.assertEqual('7ebb3f20-0fd2-4f27-a2f6-c539470999e2', single_datasource.owner_id)
+ self.assertEqual("So fun to see", single_datasource.description)
+
+ def test_populate_connections(self):
+ response_xml = read_xml_asset(POPULATE_CONNECTIONS_XML)
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections', text=response_xml)
+ single_datasource = TSC.FlowItem('test', 'aa23f4ac-906f-11e9-86fb-3f0f71412e77')
+ single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794'
+ single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb'
+ self.server.flows.populate_connections(single_datasource)
+ self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id)
+ connections = single_datasource.connections
+
+ self.assertTrue(connections)
+ conn1, conn2, conn3 = connections
+ self.assertEqual('405c1e4b-60c9-499f-9c47-a4ef1af69359', conn1.id)
+ self.assertEqual('excel-direct', conn1.connection_type)
+ self.assertEqual('', conn1.server_address)
+ self.assertEqual('', conn1.username)
+ self.assertEqual(False, conn1.embed_password)
+ self.assertEqual('b47f41b1-2c47-41a3-8b17-a38ebe8b340c', conn2.id)
+ self.assertEqual('sqlserver', conn2.connection_type)
+ self.assertEqual('test.database.com', conn2.server_address)
+ self.assertEqual('bob', conn2.username)
+ self.assertEqual(False, conn2.embed_password)
+ self.assertEqual('4f4a3b78-0554-43a7-b327-9605e9df9dd2', conn3.id)
+ self.assertEqual('tableau-server-site', conn3.connection_type)
+ self.assertEqual('http://tableauserver', conn3.server_address)
+ self.assertEqual('sally', conn3.username)
+ self.assertEqual(True, conn3.embed_password)
+
+ def test_populate_permissions(self):
+ with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f:
+ response_xml = f.read().decode('utf-8')
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml)
+ single_datasource = TSC.FlowItem('test')
+ single_datasource._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5'
+
+ self.server.flows.populate_permissions(single_datasource)
+ permissions = single_datasource.permissions
+
+ self.assertEqual(permissions[0].grantee.tag_name, 'group')
+ self.assertEqual(permissions[0].grantee.id, 'aa42f384-906f-11e9-86fc-bb24278874b9')
+ self.assertDictEqual(permissions[0].capabilities, {
+ TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow,
+ })