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, + })