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,