diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md
index 68f86321f..532d5d9b9 100644
--- a/docs/docs/api-ref.md
+++ b/docs/docs/api-ref.md
@@ -2405,11 +2405,14 @@ Source file: models/view_item.py
Name | Description
:--- | :---
+`created_at` | The date and time when the view was created.
`id` | The identifier of the view item.
`name` | The name of the view.
`owner_id` | The id for the owner of the view.
`preview_image` | The thumbnail image for the view.
+`sheet_type` | The type of the view which is either a worksheet, a dashboard or a story.
`total_views` | The usage statistics for the view. Indicates the total number of times the view has been looked at.
+`updated_at` | The date and time when the view was last updated.
`workbook_id` | The id of the workbook associated with the view.
diff --git a/samples/login.py b/samples/login.py
new file mode 100644
index 000000000..aaa21ab25
--- /dev/null
+++ b/samples/login.py
@@ -0,0 +1,52 @@
+####
+# This script demonstrates how to log in to Tableau Server Client.
+#
+# To run the script, you must have installed Python 2.7.9 or later.
+####
+
+import argparse
+import getpass
+import logging
+
+import tableauserverclient as TSC
+
+
+def main():
+ parser = argparse.ArgumentParser(description='Logs in to the server.')
+
+ parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error',
+ help='desired logging level (set to error by default)')
+
+ parser.add_argument('--server', '-s', required=True, help='server address')
+
+ group = parser.add_mutually_exclusive_group(required=True)
+ group.add_argument('--username', '-u', help='username to sign into the server')
+ group.add_argument('--token-name', '-n', help='name of the personal access token used to sign into the server')
+
+ args = parser.parse_args()
+
+ # Set logging level based on user input, or error by default.
+ logging_level = getattr(logging, args.logging_level.upper())
+ logging.basicConfig(level=logging_level)
+
+ # Make sure we use an updated version of the rest apis.
+ server = TSC.Server(args.server, use_server_version=True)
+
+ if args.username:
+ # Trying to authenticate using username and password.
+ password = getpass.getpass("Password: ")
+ tableau_auth = TSC.TableauAuth(args.username, password)
+ with server.auth.sign_in(tableau_auth):
+ print('Logged in successfully')
+
+ else:
+ # Trying to authenticate using personal access tokens.
+ personal_access_token = getpass.getpass("Personal Access Token: ")
+ tableau_auth = TSC.PersonalAccessTokenAuth(token_name=args.token_name,
+ personal_access_token=personal_access_token)
+ with server.auth.sign_in_with_personal_access_token(tableau_auth):
+ print('Logged in successfully')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/setup.py b/setup.py
index 2c8718d5d..a7b29aa90 100644
--- a/setup.py
+++ b/setup.py
@@ -1,9 +1,16 @@
+import sys
import versioneer
+
try:
from setuptools import setup
except ImportError:
from distutils.core import setup
+# Only install pytest and runner when test command is run
+# This makes work easier for offline installs or low bandwidth machines
+needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv)
+pytest_runner = ['pytest-runner'] if needs_pytest else []
+
setup(
name='tableauserverclient',
version=versioneer.get_version(),
@@ -16,11 +23,10 @@
license='MIT',
description='A Python module for working with the Tableau Server REST API.',
test_suite='test',
- setup_requires=[
- 'pytest-runner'
- ],
+ setup_requires=pytest_runner,
install_requires=[
- 'requests>=2.11,<3.0'
+ 'requests>=2.11,<3.0',
+ 'urllib3==1.24.3'
],
tests_require=[
'requests-mock>=1.0,<2.0',
diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py
index 85972d48b..d1b8a4e74 100644
--- a/tableauserverclient/__init__.py
+++ b/tableauserverclient/__init__.py
@@ -1,9 +1,9 @@
from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE
from .models import ConnectionCredentials, ConnectionItem, DatasourceItem,\
GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \
- SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \
+ SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \
HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \
- SubscriptionItem, Target
+ SubscriptionItem, Target, PermissionsRule, Permission
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 63a861cbb..f96f78565 100644
--- a/tableauserverclient/models/__init__.py
+++ b/tableauserverclient/models/__init__.py
@@ -11,9 +11,11 @@
from .server_info_item import ServerInfoItem
from .site_item import SiteItem
from .tableau_auth import TableauAuth
+from .personal_access_token_auth import PersonalAccessTokenAuth
from .target import Target
from .task_item import TaskItem
from .user_item import UserItem
from .view_item import ViewItem
from .workbook_item import WorkbookItem
from .subscription_item import SubscriptionItem
+from .permissions_item import PermissionsRule, Permission
diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py
index b00e6cbea..e76a42aae 100644
--- a/tableauserverclient/models/datasource_item.py
+++ b/tableauserverclient/models/datasource_item.py
@@ -23,6 +23,8 @@ def __init__(self, project_id, name=None):
self.project_id = project_id
self.tags = set()
+ self._permissions = None
+
@property
def connections(self):
if self._connections is None:
@@ -30,6 +32,13 @@ def connections(self):
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 content_url(self):
return self._content_url
@@ -84,6 +93,9 @@ def updated_at(self):
def _set_connections(self, connections):
self._connections = connections
+ def _set_permissions(self, permissions):
+ self._permissions = permissions
+
def _parse_common_elements(self, datasource_xml, ns):
if not isinstance(datasource_xml, ET.Element):
datasource_xml = ET.fromstring(datasource_xml).find('.//t:datasource', namespaces=ns)
diff --git a/tableauserverclient/models/exceptions.py b/tableauserverclient/models/exceptions.py
index 28d738e73..86c28ac33 100644
--- a/tableauserverclient/models/exceptions.py
+++ b/tableauserverclient/models/exceptions.py
@@ -1,2 +1,6 @@
class UnpopulatedPropertyError(Exception):
pass
+
+
+class UnknownGranteeTypeError(Exception):
+ pass
diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py
index 0efdfa6ea..d37769006 100644
--- a/tableauserverclient/models/group_item.py
+++ b/tableauserverclient/models/group_item.py
@@ -1,10 +1,14 @@
import xml.etree.ElementTree as ET
from .exceptions import UnpopulatedPropertyError
from .property_decorators import property_not_empty
+from .reference_item import ResourceReference
class GroupItem(object):
- def __init__(self, name):
+
+ tag_name = 'group'
+
+ def __init__(self, name=None):
self._domain_name = None
self._id = None
self._users = None
@@ -35,6 +39,9 @@ def users(self):
# Each call to `.users` should create a new pager, this just runs the callable
return self._users()
+ def to_reference(self):
+ return ResourceReference(id_=self.id, tag_name=self.tag_name)
+
def _set_users(self, users):
self._users = users
@@ -53,3 +60,7 @@ def from_response(cls, resp, ns):
group_item._domain_name = domain_elem.get('name', None)
all_group_items.append(group_item)
return all_group_items
+
+ @staticmethod
+ def as_reference(id_):
+ return ResourceReference(id_, GroupItem.tag_name)
diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py
new file mode 100644
index 000000000..2c2abdf82
--- /dev/null
+++ b/tableauserverclient/models/permissions_item.py
@@ -0,0 +1,93 @@
+import xml.etree.ElementTree as ET
+import logging
+
+from .exceptions import UnknownGranteeTypeError
+from .user_item import UserItem
+from .group_item import GroupItem
+
+logger = logging.getLogger('tableau.models.permissions_item')
+
+
+class Permission:
+
+ class Mode:
+ Allow = 'Allow'
+ Deny = 'Deny'
+
+ class Capability:
+ AddComment = 'AddComment'
+ ChangeHierarchy = 'ChangeHierarchy'
+ ChangePermissions = 'ChangePermissions'
+ Connect = 'Connect'
+ Delete = 'Delete'
+ ExportData = 'ExportData'
+ ExportImage = 'ExportImage'
+ ExportXml = 'ExportXml'
+ Filter = 'Filter'
+ ProjectLeader = 'ProjectLeader'
+ Read = 'Read'
+ ShareView = 'ShareView'
+ ViewComments = 'ViewComments'
+ ViewUnderlyingData = 'ViewUnderlyingData'
+ WebAuthoring = 'WebAuthoring'
+ Write = 'Write'
+
+ class Resource:
+ Workbook = 'workbook'
+ Datasource = 'datasource'
+ Flow = 'flow'
+
+
+class PermissionsRule(object):
+
+ def __init__(self, grantee, capabilities):
+ self.grantee = grantee
+ self.capabilities = capabilities
+
+ @classmethod
+ def from_response(cls, resp, ns=None):
+ parsed_response = ET.fromstring(resp)
+
+ rules = []
+ permissions_rules_list_xml = parsed_response.findall('.//t:granteeCapabilities',
+ namespaces=ns)
+
+ for grantee_capability_xml in permissions_rules_list_xml:
+ capability_dict = {}
+
+ grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns)
+
+ for capability_xml in grantee_capability_xml.findall(
+ './/t:capabilities/t:capability', namespaces=ns):
+ name = capability_xml.get('name')
+ mode = capability_xml.get('mode')
+
+ capability_dict[name] = mode
+
+ rule = PermissionsRule(grantee,
+ capability_dict)
+ rules.append(rule)
+
+ return rules
+
+ @staticmethod
+ def _parse_grantee_element(grantee_capability_xml, ns):
+ """Use Xpath magic and some string splitting to get the right object type from the xml"""
+
+ # Get the first element in the tree with an 'id' attribute
+ grantee_element = grantee_capability_xml.findall('.//*[@id]', namespaces=ns).pop()
+ grantee_id = grantee_element.get('id', None)
+ grantee_type = grantee_element.tag.split('}').pop()
+
+ if grantee_id is None:
+ logger.error('Cannot find grantee type in response')
+ raise UnknownGranteeTypeError()
+
+ if grantee_type == 'user':
+ grantee = UserItem.as_reference(grantee_id)
+ elif grantee_type == 'group':
+ grantee = GroupItem.as_reference(grantee_id)
+ else:
+ raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type))
+
+ return grantee
diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py
new file mode 100644
index 000000000..0bb9b2c02
--- /dev/null
+++ b/tableauserverclient/models/personal_access_token_auth.py
@@ -0,0 +1,11 @@
+class PersonalAccessTokenAuth(object):
+ def __init__(self, token_name, personal_access_token, site_id=''):
+ self.token_name = token_name
+ self.personal_access_token = personal_access_token
+ self.site_id = site_id
+ # Personal Access Tokens doesn't support impersonation.
+ self.user_id_to_impersonate = None
+
+ @property
+ def credentials(self):
+ return {'clientId': self.token_name, 'personalAccessToken': self.personal_access_token}
diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py
index 92e0282ae..15223e695 100644
--- a/tableauserverclient/models/project_item.py
+++ b/tableauserverclient/models/project_item.py
@@ -1,5 +1,9 @@
import xml.etree.ElementTree as ET
+
+from .permissions_item import Permission
+
from .property_decorators import property_is_enum, property_not_empty
+from .exceptions import UnpopulatedPropertyError
class ProjectItem(object):
@@ -15,10 +19,43 @@ def __init__(self, name, description=None, content_permissions=None, parent_id=N
self.content_permissions = content_permissions
self.parent_id = parent_id
+ self._permissions = None
+ self._default_workbook_permissions = None
+ self._default_datasource_permissions = None
+ self._default_flow_permissions = None
+
@property
def content_permissions(self):
return self._content_permissions
+ @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 default_datasource_permissions(self):
+ if self._default_datasource_permissions is None:
+ error = "Project item must be populated with permissions first."
+ raise UnpopulatedPropertyError(error)
+ return self._default_datasource_permissions()
+
+ @property
+ def default_workbook_permissions(self):
+ if self._default_workbook_permissions is None:
+ error = "Project item must be populated with permissions first."
+ raise UnpopulatedPropertyError(error)
+ return self._default_workbook_permissions()
+
+ @property
+ def default_flow_permissions(self):
+ if self._default_flow_permissions is None:
+ error = "Project item must be populated with permissions first."
+ raise UnpopulatedPropertyError(error)
+ return self._default_flow_permissions()
+
@content_permissions.setter
@property_is_enum(ContentPermissions)
def content_permissions(self, value):
@@ -61,6 +98,12 @@ def _set_values(self, project_id, name, description, content_permissions, parent
if parent_id:
self.parent_id = parent_id
+ def _set_permissions(self, permissions):
+ self._permissions = permissions
+
+ def _set_default_permissions(self, permissions, content_type):
+ setattr(self, "_default_{content}_permissions".format(content=content_type), permissions)
+
@classmethod
def from_response(cls, resp, ns):
all_project_items = list()
diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py
new file mode 100644
index 000000000..2cf0f0119
--- /dev/null
+++ b/tableauserverclient/models/reference_item.py
@@ -0,0 +1,21 @@
+class ResourceReference(object):
+
+ def __init__(self, id_, tag_name):
+ self.id = id_
+ self.tag_name = tag_name
+
+ @property
+ def id(self):
+ return self._id
+
+ @id.setter
+ def id(self, value):
+ self._id = value
+
+ @property
+ def tag_name(self):
+ return self._tag_name
+
+ @tag_name.setter
+ def tag_name(self, value):
+ self._tag_name = value
diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py
index 3b60741d6..cf04c1a97 100644
--- a/tableauserverclient/models/tableau_auth.py
+++ b/tableauserverclient/models/tableau_auth.py
@@ -25,3 +25,7 @@ def site(self, value):
warnings.warn('TableauAuth.site is deprecated, use TableauAuth.site_id instead.',
DeprecationWarning)
self.site_id = value
+
+ @property
+ def credentials(self):
+ return {'name': self.username, 'password': self.password}
diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py
index 48e942ece..10ca7527d 100644
--- a/tableauserverclient/models/user_item.py
+++ b/tableauserverclient/models/user_item.py
@@ -2,9 +2,13 @@
from .exceptions import UnpopulatedPropertyError
from .property_decorators import property_is_enum, property_not_empty, property_not_nullable
from ..datetime_helpers import parse_datetime
+from .reference_item import ResourceReference
class UserItem(object):
+
+ tag_name = 'user'
+
class Roles:
Interactor = 'Interactor'
Publisher = 'Publisher'
@@ -30,7 +34,7 @@ class Auth:
SAML = 'SAML'
ServerDefault = 'ServerDefault'
- def __init__(self, name, site_role, auth_setting=None):
+ def __init__(self, name=None, site_role=None, auth_setting=None):
self._auth_setting = None
self._domain_name = None
self._external_auth_user_id = None
@@ -94,6 +98,9 @@ def workbooks(self):
raise UnpopulatedPropertyError(error)
return self._workbooks()
+ def to_reference(self):
+ return ResourceReference(id_=self.id, tag_name=self.tag_name)
+
def _set_workbooks(self, workbooks):
self._workbooks = workbooks
@@ -140,6 +147,10 @@ def from_response(cls, resp, ns):
all_user_items.append(user_item)
return all_user_items
+ @staticmethod
+ def as_reference(id_):
+ return ResourceReference(id_, UserItem.tag_name)
+
@staticmethod
def _parse_element(user_xml, ns):
id = user_xml.get('id', None)
diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py
index 1fc6d4e8e..3dd9e065b 100644
--- a/tableauserverclient/models/view_item.py
+++ b/tableauserverclient/models/view_item.py
@@ -1,10 +1,13 @@
import xml.etree.ElementTree as ET
+from ..datetime_helpers import parse_datetime
from .exceptions import UnpopulatedPropertyError
+from .tag_item import TagItem
class ViewItem(object):
def __init__(self):
self._content_url = None
+ self._created_at = None
self._id = None
self._image = None
self._initial_tags = set()
@@ -15,6 +18,8 @@ def __init__(self):
self._pdf = None
self._csv = None
self._total_views = None
+ self._sheet_type = None
+ self._updated_at = None
self._workbook_id = None
self.tags = set()
@@ -34,6 +39,10 @@ def _set_csv(self, csv):
def content_url(self):
return self._content_url
+ @property
+ def created_at(self):
+ return self._created_at
+
@property
def id(self):
return self._id
@@ -78,6 +87,10 @@ def csv(self):
raise UnpopulatedPropertyError(error)
return self._csv()
+ @property
+ def sheet_type(self):
+ return self._sheet_type
+
@property
def total_views(self):
if self._total_views is None:
@@ -85,6 +98,10 @@ def total_views(self):
raise UnpopulatedPropertyError(error)
return self._total_views
+ @property
+ def updated_at(self):
+ return self._updated_at
+
@property
def workbook_id(self):
return self._workbook_id
@@ -103,9 +120,14 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''):
workbook_elem = view_xml.find('.//t:workbook', namespaces=ns)
owner_elem = view_xml.find('.//t:owner', namespaces=ns)
project_elem = view_xml.find('.//t:project', namespaces=ns)
+ tags_elem = view_xml.find('.//t:tags', namespaces=ns)
+ view_item._created_at = parse_datetime(view_xml.get('createdAt', None))
+ view_item._updated_at = parse_datetime(view_xml.get('updatedAt', None))
view_item._id = view_xml.get('id', None)
view_item._name = view_xml.get('name', None)
view_item._content_url = view_xml.get('contentUrl', None)
+ view_item._sheet_type = view_xml.get('sheetType', None)
+
if usage_elem is not None:
total_view = usage_elem.get('totalViewCount', None)
if total_view:
@@ -122,5 +144,10 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''):
elif workbook_elem is not None:
view_item._workbook_id = workbook_elem.get('id', None)
+ if tags_elem is not None:
+ tags = TagItem.from_xml_element(tags_elem, ns)
+ view_item.tags = tags
+ view_item._initial_tags = tags
+
all_view_items.append(view_item)
return all_view_items
diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py
index 8df036516..d518f23a4 100644
--- a/tableauserverclient/models/workbook_item.py
+++ b/tableauserverclient/models/workbook_item.py
@@ -3,6 +3,7 @@
from .property_decorators import property_not_nullable, property_is_boolean, property_is_materialized_views_config
from .tag_item import TagItem
from .view_item import ViewItem
+from .permissions_item import PermissionsRule
from ..datetime_helpers import parse_datetime
import copy
@@ -27,6 +28,7 @@ def __init__(self, project_id, name=None, show_tabs=False):
self.tags = set()
self.materialized_views_config = {'materialized_views_enabled': None,
'run_materialization_now': None}
+ self._permissions = None
@property
def connections(self):
@@ -35,6 +37,13 @@ def connections(self):
raise UnpopulatedPropertyError(error)
return self._connections()
+ @property
+ def permissions(self):
+ if self._permissions is None:
+ error = "Workbook item must be populated with permissions first."
+ raise UnpopulatedPropertyError(error)
+ return self._permissions()
+
@property
def content_url(self):
return self._content_url
@@ -120,6 +129,9 @@ def materialized_views_config(self, value):
def _set_connections(self, connections):
self._connections = connections
+ def _set_permissions(self, permissions):
+ self._permissions = permissions
+
def _set_views(self, views):
self._views = views
diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py
index 8c5cb314c..7fa59ef3c 100644
--- a/tableauserverclient/server/__init__.py
+++ b/tableauserverclient/server/__init__.py
@@ -4,7 +4,7 @@
from .sort import Sort
from .. import ConnectionItem, DatasourceItem, JobItem, BackgroundJobItem, \
GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\
- UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem
+ UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem, PermissionsRule, Permission
from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \
Sites, Users, Views, Workbooks, Subscriptions, ServerResponseError, \
MissingRequiredFieldError
diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py
index 84938ba63..10f4cb4db 100644
--- a/tableauserverclient/server/endpoint/auth_endpoint.py
+++ b/tableauserverclient/server/endpoint/auth_endpoint.py
@@ -35,9 +35,14 @@ def sign_in(self, auth_req):
user_id = parsed_response.find('.//t:user', namespaces=self.parent_srv.namespace).get('id', None)
auth_token = parsed_response.find('t:credentials', namespaces=self.parent_srv.namespace).get('token', None)
self.parent_srv._set_auth(site_id, user_id, auth_token)
- logger.info('Signed into {0} as {1}'.format(self.parent_srv.server_address, auth_req.username))
+ logger.info('Signed into {0} as user with id {1}'.format(self.parent_srv.server_address, user_id))
return Auth.contextmgr(self.sign_out)
+ @api(version="3.6")
+ def sign_in_with_personal_access_token(self, auth_req):
+ # We use the same request that username/password login uses.
+ return self.sign_in(auth_req)
+
@api(version="2.0")
def sign_out(self):
url = "{0}/{1}".format(self.baseurl, 'signout')
diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py
index 4d7a20b70..c46a7dc74 100644
--- a/tableauserverclient/server/endpoint/datasources_endpoint.py
+++ b/tableauserverclient/server/endpoint/datasources_endpoint.py
@@ -1,5 +1,8 @@
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, DatasourceItem, PaginationItem, ConnectionItem
@@ -24,6 +27,7 @@ class Datasources(Endpoint):
def __init__(self, parent_srv):
super(Datasources, self).__init__(parent_srv)
self._resource_tagger = _ResourceTagger(parent_srv)
+ self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
@property
def baseurl(self):
@@ -213,3 +217,19 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None,
new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0]
logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id))
return new_datasource
+ server_response = self.post_request(url, xml_request, content_type)
+ new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+ logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id))
+ return new_datasource
+
+ @api(version='2.0')
+ def populate_permissions(self, item):
+ self._permissions.populate(item)
+
+ @api(version='2.0')
+ def update_permission(self, item, permission_item):
+ self._permissions.update(item, permission_item)
+
+ @api(version='2.0')
+ def delete_permission(self, item, capability_item):
+ self._permissions.delete(item, capability_item)
diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py
new file mode 100644
index 000000000..f2e48db7a
--- /dev/null
+++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py
@@ -0,0 +1,79 @@
+import logging
+
+from .. import RequestFactory
+from ...models import PermissionsRule
+
+from .endpoint import Endpoint, api
+from .exceptions import MissingRequiredFieldError
+
+
+logger = logging.getLogger(__name__)
+
+
+class _DefaultPermissionsEndpoint(Endpoint):
+ ''' Adds default-permission model to another endpoint
+
+ Tableau default-permissions model applies only to databases and projects
+ and then takes an object type in the uri to set the defaults.
+ This class is meant to be instantated inside a parent endpoint which
+ has these supported endpoints
+ '''
+ def __init__(self, parent_srv, owner_baseurl):
+ super(_DefaultPermissionsEndpoint, self).__init__(parent_srv)
+
+ # owner_baseurl is the baseurl of the parent. The MUST be a lambda
+ # since we don't know the full site URL until we sign in. If
+ # populated without, we will get a sign-in error
+ self.owner_baseurl = owner_baseurl
+
+ def update_default_permissions(self, resource, permissions, content_type):
+ url = '{0}/{1}/default-permissions/{2}'.format(self.owner_baseurl(), resource.id, content_type)
+ update_req = RequestFactory.Permission.add_req(permissions)
+ response = self.put_request(url, update_req)
+ permissions = PermissionsRule.from_response(response.content,
+ self.parent_srv.namespace)
+ logger.info('Updated permissions for resource {0}'.format(resource.id))
+
+ return permissions
+
+ def delete_default_permission(self, resource, rule, content_type):
+ for capability, mode in rule.capabilities.items():
+ # Made readibility better but line is too long, will make this look better
+ url = '{baseurl}/{content_id}/default-permissions/\
+ {content_type}/{grantee_type}/{grantee_id}/{cap}/{mode}'.format(
+ baseurl=self.owner_baseurl(),
+ content_id=resource.id,
+ content_type=content_type,
+ grantee_type=rule.grantee.tag_name + 's',
+ grantee_id=rule.grantee.id,
+ cap=capability,
+ mode=mode)
+
+ logger.debug('Removing {0} permission for capabilty {1}'.format(
+ mode, capability))
+
+ self.delete_request(url)
+
+ logger.info('Deleted permission for {0} {1} item {2}'.format(
+ rule.grantee.tag_name,
+ rule.grantee.id,
+ resource.id))
+
+ def populate_default_permissions(self, item, content_type):
+ if not item.id:
+ error = "Server item is missing ID. Item must be retrieved from server first."
+ raise MissingRequiredFieldError(error)
+
+ def permission_fetcher():
+ return self._get_default_permissions(item, content_type)
+
+ item._set_default_permissions(permission_fetcher, content_type)
+ logger.info('Populated {0} permissions for item (ID: {1})'.format(item.id, content_type))
+
+ def _get_default_permissions(self, item, content_type, req_options=None):
+ url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, content_type + "s")
+ server_response = self.get_request(url, req_options)
+ permissions = PermissionsRule.from_response(server_response.content,
+ self.parent_srv.namespace)
+
+ return permissions
diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py
index f16c9f8df..8c7e93607 100644
--- a/tableauserverclient/server/endpoint/endpoint.py
+++ b/tableauserverclient/server/endpoint/endpoint.py
@@ -1,5 +1,6 @@
from .exceptions import ServerResponseError, InternalServerError
from functools import wraps
+from xml.etree.ElementTree import ParseError
import logging
@@ -65,7 +66,11 @@ def _check_status(self, server_response):
if server_response.status_code >= 500:
raise InternalServerError(server_response)
elif server_response.status_code not in Success_codes:
- raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace)
+ try:
+ raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace)
+ except ParseError:
+ # not an xml error
+ raise NonXMLResponseError(server_response.content)
def get_unauthenticated_request(self, url, request_object=None):
return self._make_request(self.parent_srv.session.get, url, request_object=request_object)
diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py
index 0648d8814..757ca5552 100644
--- a/tableauserverclient/server/endpoint/exceptions.py
+++ b/tableauserverclient/server/endpoint/exceptions.py
@@ -46,6 +46,10 @@ class ItemTypeNotAllowed(Exception):
pass
+class NonXMLResponseError(Exception):
+ pass
+
+
class GraphQLError(Exception):
def __init__(self, error_payload):
self.error = error_payload
diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py
new file mode 100644
index 000000000..6405f96a0
--- /dev/null
+++ b/tableauserverclient/server/endpoint/permissions_endpoint.py
@@ -0,0 +1,83 @@
+import logging
+
+from .. import RequestFactory, PermissionsRule
+
+from .endpoint import Endpoint, api
+from .exceptions import MissingRequiredFieldError
+
+
+logger = logging.getLogger(__name__)
+
+
+class _PermissionsEndpoint(Endpoint):
+ ''' Adds permission model to another endpoint
+
+ Tableau permissions model is identical between objects but they are nested under
+ the parent object endpoint (i.e. permissions for workbooks are under
+ /workbooks/:id/permission). This class is meant to be instantated inside a
+ parent endpoint which has these supported endpoints
+ '''
+ def __init__(self, parent_srv, owner_baseurl):
+ super(_PermissionsEndpoint, self).__init__(parent_srv)
+
+ # owner_baseurl is the baseurl of the parent. The MUST be a lambda
+ # since we don't know the full site URL until we sign in. If
+ # populated without, we will get a sign-in error
+ self.owner_baseurl = owner_baseurl
+
+ def update(self, resource, permissions):
+ url = '{0}/{1}/permissions'.format(self.owner_baseurl(), resource.id)
+ update_req = RequestFactory.Permission.add_req(permissions)
+ response = self.put_request(url, update_req)
+ permissions = PermissionsRule.from_response(response.content,
+ self.parent_srv.namespace)
+ logger.info('Updated permissions for resource {0}'.format(resource.id))
+
+ return permissions
+
+ def delete(self, resource, rules):
+ # Delete is the only endpoint that doesn't take a list of rules
+ # so let's fake it to keep it consistent
+ # TODO that means we need error handling around the call
+ if isinstance(rules, PermissionsRule):
+ rules = [rules]
+
+ for rule in rules:
+ for capability, mode in rule.capabilities.items():
+ " /permissions/groups/group-id/capability-name/capability-mode"
+ url = '{0}/{1}/permissions/{2}/{3}/{4}/{5}'.format(
+ self.owner_baseurl(),
+ resource.id,
+ rule.grantee.permissions_grantee_type + 's',
+ rule.grantee.id,
+ capability,
+ mode)
+
+ logger.debug('Removing {0} permission for capabilty {1}'.format(
+ mode, capability))
+
+ self.delete_request(url)
+
+ logger.info('Deleted permission for {0} {1} item {2}'.format(
+ rule.grantee.permissions_grantee_type,
+ rule.grantee.id,
+ resource.id))
+
+ def populate(self, item):
+ if not item.id:
+ error = "Server item is missing ID. Item must be retrieved from server first."
+ raise MissingRequiredFieldError(error)
+
+ def permission_fetcher():
+ return self._get_permissions(item)
+
+ item._set_permissions(permission_fetcher)
+ logger.info('Populated permissions for item (ID: {0})'.format(item.id))
+
+ def _get_permissions(self, item, req_options=None):
+ url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id)
+ server_response = self.get_request(url, req_options)
+ permissions = PermissionsRule.from_response(server_response.content,
+ self.parent_srv.namespace)
+
+ return permissions
diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py
index 8157e1f59..e4dafcbcc 100644
--- a/tableauserverclient/server/endpoint/projects_endpoint.py
+++ b/tableauserverclient/server/endpoint/projects_endpoint.py
@@ -1,12 +1,22 @@
-from .endpoint import Endpoint, api
+from .endpoint import api, Endpoint
from .exceptions import MissingRequiredFieldError
-from .. import RequestFactory, ProjectItem, PaginationItem
+from .permissions_endpoint import _PermissionsEndpoint
+from .default_permissions_endpoint import _DefaultPermissionsEndpoint
+
+from .. import RequestFactory, ProjectItem, PaginationItem, PermissionsRule, Permission
+
import logging
logger = logging.getLogger('tableau.endpoint.projects')
class Projects(Endpoint):
+ def __init__(self, parent_srv):
+ super(Projects, self).__init__(parent_srv)
+
+ self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
+ self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl)
+
@property
def baseurl(self):
return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id)
@@ -50,3 +60,51 @@ def create(self, project_item):
new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0]
logger.info('Created new project (ID: {0})'.format(new_project.id))
return new_project
+
+ @api(version='2.0')
+ def populate_permissions(self, item):
+ self._permissions.populate(item)
+
+ @api(version='2.0')
+ def update_permission(self, item, rules):
+ return self._permissions.update(item, rules)
+
+ @api(version='2.0')
+ def delete_permission(self, item, rules):
+ return self._permissions.delete(item, rules)
+
+ @api(version='2.1')
+ def populate_workbook_default_permissions(self, item):
+ self._default_permissions.populate_default_permissions(item, Permission.Resource.Workbook)
+
+ @api(version='2.1')
+ def populate_datasource_default_permissions(self, item):
+ self._default_permissions.populate_default_permissions(item, Permission.Resource.Datasource)
+
+ @api(version='3.4')
+ def populate_flow_default_permissions(self, item):
+ self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow)
+
+ @api(version='2.1')
+ def update_workbook_default_permissions(self, item):
+ self._default_permissions.update_default_permissions(item, Permission.Resource.Workbook)
+
+ @api(version='2.1')
+ def update_datasource_default_permissions(self, item):
+ self._default_permissions.update_default_permissions(item, Permission.Resource.Datasource)
+
+ @api(version='3.4')
+ def update_flow_default_permissions(self, item):
+ self._default_permissions.update_default_permissions(item, Permission.Resource.Flow)
+
+ @api(version='2.1')
+ def delete_workbook_default_permissions(self, item):
+ self._default_permissions.delete_default_permissions(item, Permission.Resource.Workbook)
+
+ @api(version='2.1')
+ def delete_datasource_default_permissions(self, item):
+ self._default_permissions.delete_default_permissions(item, Permission.Resource.Datasource)
+
+ @api(version='3.4')
+ def delete_flow_default_permissions(self, item):
+ self._default_permissions.delete_default_permissions(item, Permission.Resource.Flow)
diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py
index 772ed79b9..445b0ccde 100644
--- a/tableauserverclient/server/endpoint/workbooks_endpoint.py
+++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py
@@ -1,5 +1,8 @@
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, WorkbookItem, ConnectionItem, ViewItem, PaginationItem
@@ -25,6 +28,7 @@ class Workbooks(Endpoint):
def __init__(self, parent_srv):
super(Workbooks, self).__init__(parent_srv)
self._resource_tagger = _ResourceTagger(parent_srv)
+ self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
@property
def baseurl(self):
@@ -216,6 +220,18 @@ def _get_wb_preview_image(self, workbook_item):
preview_image = server_response.content
return preview_image
+ @api(version='2.0')
+ def populate_permissions(self, item):
+ self._permissions.populate(item)
+
+ @api(version='2.0')
+ def update_permissions(self, resource, rules):
+ return self._permissions.update(resource, rules)
+
+ @api(version='2.0')
+ def delete_permission(self, item, capability_item):
+ return self._permissions.delete(item, capability_item)
+
# Publishes workbook. Chunking method if file over 64MB
@api(version="2.0")
@parameter_added_in(as_job='3.0')
diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py
index 72bf90d80..b6739af6b 100644
--- a/tableauserverclient/server/request_factory.py
+++ b/tableauserverclient/server/request_factory.py
@@ -5,6 +5,8 @@
from requests.packages.urllib3.fields import RequestField
from requests.packages.urllib3.filepost import encode_multipart_formdata
+from ..models import UserItem, GroupItem, PermissionsRule
+
def _add_multipart(parts):
mime_multipart_parts = list()
@@ -47,11 +49,14 @@ def _add_credentials_element(parent_element, connection_credentials):
class AuthRequest(object):
def signin_req(self, auth_item):
xml_request = ET.Element('tsRequest')
+
credentials_element = ET.SubElement(xml_request, 'credentials')
- credentials_element.attrib['name'] = auth_item.username
- credentials_element.attrib['password'] = auth_item.password
+ for attribute_name, attribute_value in auth_item.credentials.items():
+ credentials_element.attrib[attribute_name] = attribute_value
+
site_element = ET.SubElement(credentials_element, 'site')
site_element.attrib['contentUrl'] = auth_item.site_id
+
if auth_item.user_id_to_impersonate:
user_element = ET.SubElement(credentials_element, 'user')
user_element.attrib['id'] = auth_item.user_id_to_impersonate
@@ -142,32 +147,26 @@ def update_req(self, group_item, default_site_role):
class PermissionRequest(object):
- def _add_capability(self, parent_element, capability_set, mode):
- for capability_item in capability_set:
- capability_element = ET.SubElement(parent_element, 'capability')
- capability_element.attrib['name'] = capability_item
- capability_element.attrib['mode'] = mode
-
- def add_req(self, permission_item):
+ def add_req(self, rules):
xml_request = ET.Element('tsRequest')
permissions_element = ET.SubElement(xml_request, 'permissions')
- for user_capability in permission_item.user_capabilities:
- grantee_element = ET.SubElement(permissions_element, 'granteeCapabilities')
- grantee_capabilities_element = ET.SubElement(grantee_element, user_capability.User)
- grantee_capabilities_element.attrib['id'] = user_capability.grantee_id
- capabilities_element = ET.SubElement(grantee_element, 'capabilities')
- self._add_capability(capabilities_element, user_capability.allowed, user_capability.Allow)
- self._add_capability(capabilities_element, user_capability.denied, user_capability.Deny)
-
- for group_capability in permission_item.group_capabilities:
- grantee_element = ET.SubElement(permissions_element, 'granteeCapabilities')
- ET.SubElement(grantee_element, group_capability, id=group_capability.grantee_id)
- capabilities_element = ET.SubElement(grantee_element, 'capabilities')
- self._add_capability(capabilities_element, group_capability.allowed, group_capability.Allow)
- self._add_capability(capabilities_element, group_capability.denied, group_capability.Deny)
+ for rule in rules:
+ grantee_capabilities_element = ET.SubElement(permissions_element, 'granteeCapabilities')
+ grantee_element = ET.SubElement(grantee_capabilities_element, rule.grantee.tag_name)
+ grantee_element.attrib['id'] = rule.grantee.id
+
+ capabilities_element = ET.SubElement(grantee_capabilities_element, 'capabilities')
+ self._add_all_capabilities(capabilities_element, rule.capabilities)
+
return ET.tostring(xml_request)
+ def _add_all_capabilities(self, capabilities_element, capabilities_map):
+ for name, mode in capabilities_map.items():
+ capability_element = ET.SubElement(capabilities_element, 'capability')
+ capability_element.attrib['name'] = name
+ capability_element.attrib['mode'] = mode
+
class ProjectRequest(object):
def update_req(self, project_item):
diff --git a/test/assets/datasource_populate_permissions.xml b/test/assets/datasource_populate_permissions.xml
new file mode 100644
index 000000000..db967f4a9
--- /dev/null
+++ b/test/assets/datasource_populate_permissions.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/project_populate_permissions.xml b/test/assets/project_populate_permissions.xml
new file mode 100644
index 000000000..7a49391af
--- /dev/null
+++ b/test/assets/project_populate_permissions.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/project_populate_workbook_default_permissions.xml b/test/assets/project_populate_workbook_default_permissions.xml
new file mode 100644
index 000000000..e6f3804be
--- /dev/null
+++ b/test/assets/project_populate_workbook_default_permissions.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/assets/view_get.xml b/test/assets/view_get.xml
index 36f43e255..283488a4b 100644
--- a/test/assets/view_get.xml
+++ b/test/assets/view_get.xml
@@ -6,11 +6,15 @@
+
+
+
+
-
+
-
\ No newline at end of file
+
diff --git a/test/assets/view_get_usage.xml b/test/assets/view_get_usage.xml
index a6844879d..741e607e7 100644
--- a/test/assets/view_get_usage.xml
+++ b/test/assets/view_get_usage.xml
@@ -8,11 +8,11 @@
-
+
-
\ No newline at end of file
+
diff --git a/test/assets/workbook_populate_permissions.xml b/test/assets/workbook_populate_permissions.xml
new file mode 100644
index 000000000..57517d719
--- /dev/null
+++ b/test/assets/workbook_populate_permissions.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/workbook_update_permissions.xml b/test/assets/workbook_update_permissions.xml
new file mode 100644
index 000000000..fffd90491
--- /dev/null
+++ b/test/assets/workbook_update_permissions.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/test_auth.py b/test/test_auth.py
index 870064db0..28e241335 100644
--- a/test/test_auth.py
+++ b/test/test_auth.py
@@ -27,6 +27,19 @@ def test_sign_in(self):
self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id)
self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id)
+ def test_sign_in_with_personal_access_tokens(self):
+ with open(SIGN_IN_XML, 'rb') as f:
+ response_xml = f.read().decode('utf-8')
+ with requests_mock.mock() as m:
+ m.post(self.baseurl + '/signin', text=response_xml)
+ tableau_auth = TSC.PersonalAccessTokenAuth(token_name='mytoken',
+ personal_access_token='Random123Generated', site_id='Samples')
+ self.server.auth.sign_in(tableau_auth)
+
+ self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token)
+ self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id)
+ self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id)
+
def test_sign_in_impersonate(self):
with open(SIGN_IN_IMPERSONATE_XML, 'rb') as f:
response_xml = f.read().decode('utf-8')
@@ -48,6 +61,14 @@ def test_sign_in_error(self):
tableau_auth = TSC.TableauAuth('testuser', 'wrongpassword')
self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth)
+ def test_sign_in_invalid_token(self):
+ with open(SIGN_IN_ERROR_XML, 'rb') as f:
+ response_xml = f.read().decode('utf-8')
+ with requests_mock.mock() as m:
+ m.post(self.baseurl + '/signin', text=response_xml, status_code=401)
+ tableau_auth = TSC.PersonalAccessTokenAuth(token_name='mytoken', personal_access_token='invalid')
+ self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth)
+
def test_sign_in_without_auth(self):
with open(SIGN_IN_ERROR_XML, 'rb') as f:
response_xml = f.read().decode('utf-8')
diff --git a/test/test_datasource.py b/test/test_datasource.py
index 0563d2af7..fdf3c2e51 100644
--- a/test/test_datasource.py
+++ b/test/test_datasource.py
@@ -13,6 +13,7 @@
GET_EMPTY_XML = 'datasource_get_empty.xml'
GET_BY_ID_XML = 'datasource_get_by_id.xml'
POPULATE_CONNECTIONS_XML = 'datasource_populate_connections.xml'
+POPULATE_PERMISSIONS_XML = 'datasource_populate_permissions.xml'
PUBLISH_XML = 'datasource_publish.xml'
PUBLISH_XML_ASYNC = 'datasource_publish_async.xml'
UPDATE_XML = 'datasource_update.xml'
@@ -181,6 +182,32 @@ def test_update_connection(self):
self.assertEquals('9876', new_connection.server_port)
self.assertEqual('foo', new_connection.username)
+ 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.DatasourceItem('test')
+ single_datasource._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5'
+
+ self.server.datasources.populate_permissions(single_datasource)
+ permissions = single_datasource.permissions
+
+ self.assertEqual(permissions[0].grantee.tag_name, 'group')
+ self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af')
+ self.assertDictEqual(permissions[0].capabilities, {
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow,
+ })
+
+ self.assertEqual(permissions[1].grantee.tag_name, 'user')
+ self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a')
+ self.assertDictEqual(permissions[1].capabilities, {
+ TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow,
+ })
+
def test_publish(self):
response_xml = read_xml_asset(PUBLISH_XML)
with requests_mock.mock() as m:
diff --git a/test/test_project.py b/test/test_project.py
index c0958f761..6e055e50f 100644
--- a/test/test_project.py
+++ b/test/test_project.py
@@ -3,11 +3,15 @@
import requests_mock
import tableauserverclient as TSC
+from ._utils import read_xml_asset, read_xml_assets, asset
+
TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets')
-GET_XML = os.path.join(TEST_ASSET_DIR, 'project_get.xml')
-UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'project_update.xml')
-CREATE_XML = os.path.join(TEST_ASSET_DIR, 'project_create.xml')
+GET_XML = asset('project_get.xml')
+UPDATE_XML = asset('project_update.xml')
+CREATE_XML = asset('project_create.xml')
+POPULATE_PERMISSIONS_XML = 'project_populate_permissions.xml'
+POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = 'project_populate_workbook_default_permissions.xml'
class ProjectTests(unittest.TestCase):
@@ -97,3 +101,54 @@ def test_create(self):
def test_create_missing_name(self):
self.assertRaises(ValueError, TSC.ProjectItem, '')
+
+ 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_project = TSC.ProjectItem('Project3')
+ single_project._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5'
+
+ self.server.projects.populate_permissions(single_project)
+ permissions = single_project.permissions
+
+ self.assertEqual(permissions[0].grantee.tag_name, 'group')
+ self.assertEqual(permissions[0].grantee.id, 'c8f2773a-c83a-11e8-8c8f-33e6d787b506')
+ self.assertDictEqual(permissions[0].capabilities, {
+ TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow,
+ })
+
+ def test_populate_workbooks(self):
+ response_xml = read_xml_asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML)
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks',
+ text=response_xml)
+ single_project = TSC.ProjectItem('test', '1d0304cd-3796-429f-b815-7258370b9b74')
+ single_project.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794'
+ single_project._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb'
+
+ self.server.projects.populate_workbook_default_permissions(single_project)
+ permissions = single_project.default_workbook_permissions
+
+ rule1 = permissions.pop()
+
+ self.assertEqual('c8f2773a-c83a-11e8-8c8f-33e6d787b506', rule1.grantee.id)
+ self.assertEqual('group', rule1.grantee.tag_name)
+ self.assertDictEqual(rule1.capabilities, {
+ TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow,
+ })
diff --git a/test/test_sort.py b/test/test_sort.py
index 17a69e900..a6a57497d 100644
--- a/test/test_sort.py
+++ b/test/test_sort.py
@@ -57,8 +57,8 @@ def test_filter_in(self):
request_object=opts,
auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM',
content_type='text/xml')
-
- self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:%5bstocks,market%5d')
+ self.assertDictEqual(resp.request.qs, {'pagenumber': ['13'], 'pagesize': [
+ '13'], 'filter': ['tags:in:[stocks,market]']})
def test_sort_asc(self):
with requests_mock.mock() as m:
diff --git a/test/test_view.py b/test/test_view.py
index 292f86887..fcf7d986c 100644
--- a/test/test_view.py
+++ b/test/test_view.py
@@ -3,6 +3,8 @@
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')
ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, 'view_add_tags.xml')
@@ -40,6 +42,10 @@ def test_get(self):
self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_views[0].workbook_id)
self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[0].owner_id)
self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_views[0].project_id)
+ self.assertEqual(set(['tag1', 'tag2']), all_views[0].tags)
+ self.assertIsNone(all_views[0].created_at)
+ self.assertIsNone(all_views[0].updated_at)
+ self.assertIsNone(all_views[0].sheet_type)
self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id)
self.assertEqual('Overview', all_views[1].name)
@@ -47,6 +53,9 @@ def test_get(self):
self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_views[1].workbook_id)
self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[1].owner_id)
self.assertEqual('5b534f74-3226-11e8-b47a-cb2e00f738a3', all_views[1].project_id)
+ self.assertEqual('2002-05-30T09:00:00Z', format_datetime(all_views[1].created_at))
+ self.assertEqual('2002-06-05T08:00:59Z', format_datetime(all_views[1].updated_at))
+ self.assertEqual('story', all_views[1].sheet_type)
def test_get_with_usage(self):
with open(GET_XML_USAGE, 'rb') as f:
@@ -57,8 +66,15 @@ def test_get_with_usage(self):
self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', all_views[0].id)
self.assertEqual(7, all_views[0].total_views)
+ self.assertIsNone(all_views[0].created_at)
+ self.assertIsNone(all_views[0].updated_at)
+ self.assertIsNone(all_views[0].sheet_type)
+
self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id)
self.assertEqual(13, all_views[1].total_views)
+ self.assertEqual('2002-05-30T09:00:00Z', format_datetime(all_views[1].created_at))
+ self.assertEqual('2002-06-05T08:00:59Z', format_datetime(all_views[1].updated_at))
+ self.assertEqual('story', all_views[1].sheet_type)
def test_get_with_usage_and_filter(self):
with open(GET_XML_USAGE, 'rb') as f:
diff --git a/test/test_workbook.py b/test/test_workbook.py
index ae814c0b2..0317ba115 100644
--- a/test/test_workbook.py
+++ b/test/test_workbook.py
@@ -7,6 +7,10 @@
from tableauserverclient.datetime_helpers import format_datetime
from tableauserverclient.server.endpoint.exceptions import InternalServerError
from tableauserverclient.server.request_factory import RequestFactory
+from tableauserverclient.models.permissions_item import PermissionsRule
+from tableauserverclient.models.user_item import UserItem
+from tableauserverclient.models.group_item import GroupItem
+
from ._utils import asset
TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets')
@@ -17,12 +21,14 @@
GET_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get.xml')
POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_connections.xml')
POPULATE_PDF = os.path.join(TEST_ASSET_DIR, 'populate_pdf.pdf')
+POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_permissions.xml')
POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, 'RESTAPISample Image.png')
POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views.xml')
POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views_usage.xml')
PUBLISH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish.xml')
PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish_async.xml')
UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml')
+UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, 'workbook_update_permissions.xml')
class WorkbookTests(unittest.TestCase):
@@ -270,6 +276,66 @@ def test_populate_connections(self):
self.assertEqual('4506225a-0d32-4ab1-82d3-c24e85f7afba', single_workbook.connections[0].datasource_id)
self.assertEqual('World Indicators', single_workbook.connections[0].datasource_name)
+ def test_populate_permissions(self):
+ with open(POPULATE_PERMISSIONS_XML, 'rb') as f:
+ response_xml = f.read().decode('utf-8')
+ with requests_mock.mock() as m:
+ m.get(self.baseurl + '/21778de4-b7b9-44bc-a599-1506a2639ace/permissions', text=response_xml)
+ single_workbook = TSC.WorkbookItem('test')
+ single_workbook._id = '21778de4-b7b9-44bc-a599-1506a2639ace'
+
+ self.server.workbooks.populate_permissions(single_workbook)
+ permissions = single_workbook.permissions
+
+ self.assertEqual(permissions[0].grantee.tag_name, 'group')
+ self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af')
+ self.assertDictEqual(permissions[0].capabilities, {
+ TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow
+ })
+
+ self.assertEqual(permissions[1].grantee.tag_name, 'user')
+ self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a')
+ self.assertDictEqual(permissions[1].capabilities, {
+ TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow,
+ TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Deny,
+ TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Deny
+ })
+
+ def test_add_permissions(self):
+ with open(UPDATE_PERMISSIONS, 'rb') as f:
+ response_xml = f.read().decode('utf-8')
+
+ single_workbook = TSC.WorkbookItem('test')
+ single_workbook._id = '21778de4-b7b9-44bc-a599-1506a2639ace'
+
+ bob = UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a")
+ group_of_people = GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af")
+
+ new_permissions = [
+ PermissionsRule(bob, {'Write': 'Allow'}),
+ PermissionsRule(group_of_people, {'Read': 'Deny'})
+ ]
+
+ with requests_mock.mock() as m:
+ m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml)
+ permissions = self.server.workbooks.update_permissions(single_workbook, new_permissions)
+
+ self.assertEqual(permissions[0].grantee.tag_name, 'group')
+ self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af')
+ self.assertDictEqual(permissions[0].capabilities, {
+ TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny
+ })
+
+ self.assertEqual(permissions[1].grantee.tag_name, 'user')
+ self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a')
+ self.assertDictEqual(permissions[1].capabilities, {
+ TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow
+ })
+
def test_populate_connections_missing_id(self):
single_workbook = TSC.WorkbookItem('test')
self.assertRaises(TSC.MissingRequiredFieldError,