diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 3494e5f1f..d1b8a4e74 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -3,7 +3,7 @@ GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ - SubscriptionItem, Target + 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 872909adb..f96f78565 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -18,3 +18,4 @@ 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/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/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/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/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 6a3f811b6..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() @@ -145,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/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_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_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,