diff --git a/.travis.yml b/.travis.yml index f57c19434..cc261b20c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,10 @@ +dist: xenial language: python python: - "2.7" - "3.5" - "3.6" - - "pypy" + - "3.7" # command to install dependencies install: - "pip install -e ." @@ -12,5 +13,4 @@ install: script: # Tests - python setup.py test - # pep8 - disabled for now until we can scrub the files to make sure we pass before turning it on - pycodestyle tableauserverclient test samples diff --git a/CHANGELOG.md b/CHANGELOG.md index 357a89b93..a2881e514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 0.9 (4 Oct 2019) + +* Added Metadata API endpoints (#431) +* Added site settings for Data Catalog and Prep Conductor (#434) +* Added new fields to ViewItem (#331) +* Added support and samples for Tableau Server Personal Access Tokens (#465) +* Added Permissions endpoints (#429) +* Added tags to ViewItem (#470) +* Added Databases and Tables endpoints (#445) +* Added Flow endpoints (#494) +* Added ability to filter projects by topLevelProject attribute (#497) +* Improved server_info endpoint error handling (#439) +* Improved Pager to take in keyword arguments (#451) +* Fixed UUID serialization error while publishing workbook (#449) +* Fixed materalized views in request body for update_workbook (#461) + ## 0.8.1 (17 July 2019) * Fixed update_workbook endpoint (#454) @@ -13,7 +29,6 @@ * Fixed checked upload (#309, #319, #326, #329) * Fixed embed_password field on publish (#416) - ## 0.7 (2 Jul 2018) * Added cancel job (#299) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index bffde46c7..8022c5f49 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -19,16 +19,26 @@ The following people have contributed to this project to make it possible, and w * [Bruce Zhang](https://github.com/baixin137) * [Bumsoo Kim](https://github.com/bskim45) * [daniel1608](https://github.com/daniel1608) +* [Joshua Jacob](https://github.com/jacobj10) +* [Francisco Pagliaricci](https://github.com/fpagliar) +* [Tomasz Machalski](https://github.com/toomyem) +* [Jared Dominguez](https://github.com/jdomingu) +* [Brendan Lee](https://github.com/lbrendanl) +* [Martin Dertz](https://github.com/martydertz) +* [Christian Oliff](https://github.com/coliff) +* [Albin Antony](https://github.com/user9747) +* [prae04](https://github.com/prae04) ## Core Team -* [Shin Chris](https://github.com/shinchris) +* [Chris Shin](https://github.com/shinchris) * [Lee Graber](https://github.com/lgraber) * [Tyler Doyle](https://github.com/t8y8) * [Russell Hay](https://github.com/RussTheAerialist) * [Ben Lower](https://github.com/benlower) -* [Jared Dominguez](https://github.com/jdomingu) * [Jackson Huang](https://github.com/jz-huang) -* [Brendan Lee](https://github.com/lbrendanl) * [Ang Gao](https://github.com/gaoang2148) -* [Priya R](https://github.com/preguraman) +* [Priya Reguraman](https://github.com/preguraman) +* [Jac Fitzgerald](https://github.com/jacalata) +* [Dan Zucker](https://github.com/dzucker-tab) +* [Irwin Dolobowsky](https://github.com/irwando) 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..eb647ed25 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, \ - HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ - SubscriptionItem, Target + GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem,\ + SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError,\ + HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem,\ + SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem, FlowItem from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager from ._version import get_versions diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 63a861cbb..a3517e13f 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,8 +1,11 @@ from .connection_credentials import ConnectionCredentials from .connection_item import ConnectionItem +from .column_item import ColumnItem from .datasource_item import DatasourceItem +from .database_item import DatabaseItem from .exceptions import UnpopulatedPropertyError from .group_item import GroupItem +from .flow_item import FlowItem from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval from .job_item import JobItem, BackgroundJobItem from .pagination_item import PaginationItem @@ -11,9 +14,12 @@ 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 .table_item import TableItem 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/column_item.py b/tableauserverclient/models/column_item.py new file mode 100644 index 000000000..475dd0e2a --- /dev/null +++ b/tableauserverclient/models/column_item.py @@ -0,0 +1,69 @@ +import xml.etree.ElementTree as ET + +from .property_decorators import property_is_enum, property_not_empty +from .exceptions import UnpopulatedPropertyError + + +class ColumnItem(object): + def __init__(self, name, description=None): + self._id = None + self.description = description + self.name = name + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @name.setter + @property_not_empty + def name(self, value): + self._name = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def remote_type(self): + return self._remote_type + + def _set_values(self, id, name, description, remote_type): + if id is not None: + self._id = id + if name: + self._name = name + if description: + self.description = description + if remote_type: + self._remote_type = remote_type + + @classmethod + def from_response(cls, resp, ns): + all_column_items = list() + parsed_response = ET.fromstring(resp) + all_column_xml = parsed_response.findall('.//t:column', namespaces=ns) + + for column_xml in all_column_xml: + (id, name, description, remote_type) = cls._parse_element(column_xml, ns) + column_item = cls(name) + column_item._set_values(id, name, description, remote_type) + all_column_items.append(column_item) + + return all_column_items + + @staticmethod + def _parse_element(column_xml, ns): + id = column_xml.get('id', None) + name = column_xml.get('name', None) + description = column_xml.get('description', None) + remote_type = column_xml.get('remoteType', None) + + return id, name, description, remote_type diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py new file mode 100644 index 000000000..9aecca6cc --- /dev/null +++ b/tableauserverclient/models/database_item.py @@ -0,0 +1,260 @@ +import xml.etree.ElementTree as ET + +from .permissions_item import Permission + +from .property_decorators import property_is_enum, property_not_empty, property_is_boolean +from .exceptions import UnpopulatedPropertyError + + +class DatabaseItem(object): + class ContentPermissions: + LockedToProject = 'LockedToDatabase' + ManagedByOwner = 'ManagedByOwner' + + def __init__(self, name, description=None, content_permissions=None): + self._id = None + self.name = name + self.description = description + self.content_permissions = content_permissions + self._certified = None + self._certification_note = None + self._contact_id = None + + self._connector_url = None + self._connection_type = None + self._embedded = None + self._file_extension = None + self._file_id = None + self._file_path = None + self._host_name = None + self._metadata_type = None + self._mime_type = None + self._port = None + self._provider = None + self._request_url = None + + self._permissions = None + self._default_table_permissions = None + + self._tables = None # Not implemented yet + + @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_table_permissions(self): + if self._default_table_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_table_permissions() + + @content_permissions.setter + @property_is_enum(ContentPermissions) + def content_permissions(self, value): + self._content_permissions = value + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @name.setter + @property_not_empty + def name(self, value): + self._name = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def embedded(self): + return self._embedded + + @property + def certified(self): + return self._certified + + @certified.setter + @property_is_boolean + def certified(self, value): + self._certified = value + + @property + def certification_note(self): + return self._certification_note + + @certification_note.setter + def certification_note(self, value): + self._certification_note = value + + @property + def metadata_type(self): + return self._metadata_type + + @property + def host_name(self): + return self._host_name + + @property + def port(self): + return self._port + + @property + def file_path(self): + return self._file_path + + @property + def provider(self): + return self._provider + + @property + def mime_type(self): + return self._mime_type + + @property + def connector_url(self): + return self._connector_url + + @property + def connection_type(self): + return self._connection_type + + @property + def request_url(self): + return self._request_url + + @property + def file_extension(self): + return self._file_extension + + @property + def file_id(self): + return self._file_id + + @property + def contact_id(self): + return self._contact_id + + @contact_id.setter + def contact_id(self, value): + self._contact_id = value + + @property + def tables(self): + if self._tables is None: + error = "Database must be populated with tables first." + raise UnpopulatedPropertyError(error) + # Each call to `.tables` should create a new pager, this just runs the callable + return self._tables() + + def _set_values(self, database_values): + # ID & Settable + if 'id' in database_values: + self._id = database_values['id'] + + if 'contact' in database_values: + self._contact_id = database_values['contact']['id'] + + if 'name' in database_values: + self._name = database_values['name'] + + if 'description' in database_values: + self._description = database_values['description'] + + if 'isCertified' in database_values: + self._certified = string_to_bool(database_values['isCertified']) + + if 'certificationNote' in database_values: + self._certification_note = database_values['certificationNote'] + + # Not settable, alphabetical + + if 'connectionType' in database_values: + self._connection_type = database_values['connectionType'] + + if 'connectorUrl' in database_values: + self._connector_url = database_values['connectorUrl'] + + if 'contentPermissions' in database_values: + self._content_permissions = database_values['contentPermissions'] + + if 'isEmbedded' in database_values: + self._embedded = string_to_bool(database_values['isEmbedded']) + + if 'fileExtension' in database_values: + self._file_extension = database_values['fileExtension'] + + if 'fileId' in database_values: + self._file_id = database_values['fileId'] + + if 'filePath' in database_values: + self._file_path = database_values['filePath'] + + if 'hostName' in database_values: + self._host_name = database_values['hostName'] + + if 'mimeType' in database_values: + self._mime_type = database_values['mimeType'] + + if 'port' in database_values: + self._port = int(database_values['port']) + + if 'provider' in database_values: + self._provider = database_values['provider'] + + if 'requestUrl' in database_values: + self._request_url = database_values['requestUrl'] + + if 'type' in database_values: + self._metadata_type = database_values['type'] + + def _set_permissions(self, permissions): + self._permissions = permissions + + def _set_tables(self, tables): + self._tables = tables + + 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_database_items = list() + parsed_response = ET.fromstring(resp) + all_database_xml = parsed_response.findall('.//t:database', namespaces=ns) + + for database_xml in all_database_xml: + parsed_database = cls._parse_element(database_xml, ns) + database_item = cls(parsed_database['name']) + database_item._set_values(parsed_database) + all_database_items.append(database_item) + return all_database_items + + @staticmethod + def _parse_element(database_xml, ns): + database_values = database_xml.attrib.copy() + contact = database_xml.find('.//t:contact', namespaces=ns) + if contact is not None: + database_values['contact'] = contact.attrib.copy() + return database_values + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s): + return s.lower() == 'true' 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/flow_item.py b/tableauserverclient/models/flow_item.py new file mode 100644 index 000000000..790000df2 --- /dev/null +++ b/tableauserverclient/models/flow_item.py @@ -0,0 +1,162 @@ +import xml.etree.ElementTree as ET +from .exceptions import UnpopulatedPropertyError +from .property_decorators import property_not_nullable, property_is_boolean +from .tag_item import TagItem +from ..datetime_helpers import parse_datetime +import copy + + +class FlowItem(object): + def __init__(self, project_id, name=None): + self._webpage_url = None + self._created_at = None + self._id = None + self._initial_tags = set() + self._project_name = None + self._updated_at = None + self.name = name + self.owner_id = None + self.project_id = project_id + self.tags = set() + self.description = None + + self._connections = None + self._permissions = None + + @property + def connections(self): + if self._connections is None: + error = 'Flow item must be populated with connections first.' + raise UnpopulatedPropertyError(error) + return self._connections() + + @property + def permissions(self): + if self._permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions() + + @property + def webpage_url(self): + return self._webpage_url + + @property + def created_at(self): + return self._created_at + + @property + def id(self): + return self._id + + @property + def project_id(self): + return self._project_id + + @project_id.setter + @property_not_nullable + def project_id(self, value): + self._project_id = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def project_name(self): + return self._project_name + + @property + def flow_type(self): + return self._flow_type + + @property + def updated_at(self): + return self._updated_at + + def _set_connections(self, connections): + self._connections = connections + + def _set_permissions(self, permissions): + self._permissions = permissions + + def _parse_common_elements(self, flow_xml, ns): + if not isinstance(flow_xml, ET.Element): + flow_xml = ET.fromstring(flow_xml).find('.//t:flow', namespaces=ns) + if flow_xml is not None: + (_, _, _, _, _, updated_at, _, project_id, project_name, owner_id) = self._parse_element(flow_xml, ns) + self._set_values(None, None, None, None, None, updated_at, None, project_id, + project_name, owner_id) + return self + + def _set_values(self, id, name, description, webpage_url, created_at, + updated_at, tags, project_id, project_name, owner_id): + if id is not None: + self._id = id + if name: + self.name = name + if description: + self.description = description + if webpage_url: + self._webpage_url = webpage_url + if created_at: + self._created_at = created_at + if updated_at: + self._updated_at = updated_at + if tags: + self.tags = tags + self._initial_tags = copy.copy(tags) + if project_id: + self.project_id = project_id + if project_name: + self._project_name = project_name + if owner_id: + self.owner_id = owner_id + + @classmethod + def from_response(cls, resp, ns): + all_flow_items = list() + parsed_response = ET.fromstring(resp) + all_flow_xml = parsed_response.findall('.//t:flow', namespaces=ns) + + for flow_xml in all_flow_xml: + (id_, name, description, webpage_url, created_at, updated_at, + tags, project_id, project_name, owner_id) = cls._parse_element(flow_xml, ns) + flow_item = cls(project_id) + flow_item._set_values(id_, name, description, webpage_url, created_at, updated_at, + tags, None, project_name, owner_id) + all_flow_items.append(flow_item) + return all_flow_items + + @staticmethod + def _parse_element(flow_xml, ns): + id_ = flow_xml.get('id', None) + name = flow_xml.get('name', None) + description = flow_xml.get('description', None) + webpage_url = flow_xml.get('webpageUrl', None) + created_at = parse_datetime(flow_xml.get('createdAt', None)) + updated_at = parse_datetime(flow_xml.get('updatedAt', None)) + + tags = None + tags_elem = flow_xml.find('.//t:tags', namespaces=ns) + if tags_elem is not None: + tags = TagItem.from_xml_element(tags_elem, ns) + + project_id = None + project_name = None + project_elem = flow_xml.find('.//t:project', namespaces=ns) + if project_elem is not None: + project_id = project_elem.get('id', None) + project_name = project_elem.get('name', None) + + owner_id = None + owner_elem = flow_xml.find('.//t:owner', namespaces=ns) + if owner_elem is not None: + owner_id = owner_elem.get('id', None) + + return (id_, name, description, webpage_url, created_at, updated_at, tags, project_id, + project_name, owner_id) diff --git a/tableauserverclient/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..6487b6ca5 --- /dev/null +++ b/tableauserverclient/models/permissions_item.py @@ -0,0 +1,95 @@ +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' + Table = 'table' + Database = 'database' + + +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..c9b892cf6 --- /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 {'personalAccessTokenName': self.token_name, 'personalAccessTokenSecret': 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/site_item.py b/tableauserverclient/models/site_item.py index 21031ff80..238332597 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -17,7 +17,7 @@ class State: def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_quota=None, disable_subscriptions=False, subscribe_others_enabled=True, revision_history_enabled=False, - revision_limit=None, materialized_views_mode=None): + revision_limit=None, materialized_views_mode=None, flows_enabled=None, cataloging_enabled=None): self._admin_mode = None self._id = None self._num_users = None @@ -34,6 +34,8 @@ def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_ self.subscribe_others_enabled = subscribe_others_enabled self.admin_mode = admin_mode self.materialized_views_mode = materialized_views_mode + self.cataloging_enabled = cataloging_enabled + self.flows_enabled = flows_enabled @property def admin_mode(self): @@ -132,6 +134,22 @@ def materialized_views_mode(self): def materialized_views_mode(self, value): self._materialized_views_mode = value + @property + def cataloging_enabled(self): + return self._cataloging_enabled + + @cataloging_enabled.setter + def cataloging_enabled(self, value): + self._cataloging_enabled = value + + @property + def flows_enabled(self): + return self._flows_enabled + + @flows_enabled.setter + def flows_enabled(self, value): + self._flows_enabled = value + def is_default(self): return self.name.lower() == 'default' @@ -142,16 +160,18 @@ def _parse_common_tags(self, site_xml, ns): (_, name, content_url, _, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, revision_limit, num_users, storage, - materialized_views_mode) = self._parse_element(site_xml, ns) + materialized_views_mode, cataloging_enabled, flows_enabled) = self._parse_element(site_xml, ns) self._set_values(None, name, content_url, None, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, materialized_views_mode) + revision_limit, num_users, storage, materialized_views_mode, cataloging_enabled, + flows_enabled) return self def _set_values(self, id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage, materialized_views_mode): + user_quota, storage_quota, revision_limit, num_users, storage, materialized_views_mode, + flows_enabled, cataloging_enabled): if id is not None: self._id = id if name: @@ -182,6 +202,10 @@ def _set_values(self, id, name, content_url, status_reason, admin_mode, state, self._storage = storage if materialized_views_mode: self._materialized_views_mode = materialized_views_mode + if flows_enabled is not None: + self.flows_enabled = flows_enabled + if cataloging_enabled is not None: + self.cataloging_enabled = cataloging_enabled @classmethod def from_response(cls, resp, ns): @@ -191,13 +215,14 @@ def from_response(cls, resp, ns): for site_xml in all_site_xml: (id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, materialized_views_mode) = cls._parse_element(site_xml, ns) + revision_limit, num_users, storage, materialized_views_mode, flows_enabled, + cataloging_enabled) = cls._parse_element(site_xml, ns) site_item = cls(name, content_url) site_item._set_values(id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, revision_limit, num_users, storage, - materialized_views_mode) + materialized_views_mode, flows_enabled, cataloging_enabled) all_site_items.append(site_item) return all_site_items @@ -234,9 +259,12 @@ def _parse_element(site_xml, ns): materialized_views_mode = site_xml.get('materializedViewsMode', '') + flows_enabled = string_to_bool(site_xml.get('flowsEnabled', '')) + cataloging_enabled = string_to_bool(site_xml.get('catalogingEnabled', '')) + return id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled,\ disable_subscriptions, revision_history_enabled, user_quota, storage_quota,\ - revision_limit, num_users, storage, materialized_views_mode + revision_limit, num_users, storage, materialized_views_mode, flows_enabled, cataloging_enabled # Used to convert string represented boolean to a boolean type diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py new file mode 100644 index 000000000..8d8f63674 --- /dev/null +++ b/tableauserverclient/models/table_item.py @@ -0,0 +1,147 @@ +import xml.etree.ElementTree as ET + +from .permissions_item import Permission +from .column_item import ColumnItem + +from .property_decorators import property_is_enum, property_not_empty, property_is_boolean +from .exceptions import UnpopulatedPropertyError + + +class TableItem(object): + def __init__(self, name, description=None): + self._id = None + self.description = description + self.name = name + + self._contact_id = None + self._certified = None + self._certification_note = None + self._permissions = None + self._schema = None + + self._columns = None + + @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 id(self): + return self._id + + @property + def name(self): + return self._name + + @name.setter + @property_not_empty + def name(self, value): + self._name = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def certified(self): + return self._certified + + @certified.setter + @property_is_boolean + def certified(self, value): + self._certified = value + + @property + def certification_note(self): + return self._certification_note + + @certification_note.setter + def certification_note(self, value): + self._certification_note = value + + @property + def contact_id(self): + return self._contact_id + + @contact_id.setter + def contact_id(self, value): + self._contact_id = value + + @property + def schema(self): + return self._schema + + @property + def columns(self): + if self._columns is None: + error = "Table must be populated with columns first." + raise UnpopulatedPropertyError(error) + # Each call to `.columns` should create a new pager, this just runs the callable + return self._columns() + + def _set_columns(self, columns): + self._columns = columns + + def _set_values(self, table_values): + if 'id' in table_values: + self._id = table_values['id'] + + if 'name' in table_values: + self._name = table_values['name'] + + if 'description' in table_values: + self._description = table_values['description'] + + if 'isCertified' in table_values: + self._certified = string_to_bool(table_values['isCertified']) + + if 'certificationNote' in table_values: + self._certification_note = table_values['certificationNote'] + + if 'isEmbedded' in table_values: + self._embedded = string_to_bool(table_values['isEmbedded']) + + if 'schema' in table_values: + self._schema = table_values['schema'] + + if 'contact' in table_values: + self._contact_id = table_values['contact']['id'] + + def _set_permissions(self, permissions): + self._permissions = permissions + + @classmethod + def from_response(cls, resp, ns): + all_table_items = list() + parsed_response = ET.fromstring(resp) + all_table_xml = parsed_response.findall('.//t:table', namespaces=ns) + + for table_xml in all_table_xml: + parsed_table = cls._parse_element(table_xml, ns) + table_item = cls(parsed_table["name"]) + table_item._set_values(parsed_table) + all_table_items.append(table_item) + return all_table_items + + @staticmethod + def _parse_element(table_xml, ns): + + table_values = table_xml.attrib.copy() + + contact = table_xml.find('.//t:contact', namespaces=ns) + if contact is not None: + table_values['contact'] = contact.attrib.copy() + + return table_values + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s): + return s.lower() == 'true' 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..a76fd3246 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -2,12 +2,13 @@ from .request_options import CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions from .filter import Filter from .sort import Sort -from .. import ConnectionItem, DatasourceItem, JobItem, BackgroundJobItem, \ +from .. import ConnectionItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ - UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem + UserItem, ViewItem, WorkbookItem, TableItem, TaskItem, SubscriptionItem, \ + PermissionsRule, Permission, ColumnItem, FlowItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ - Sites, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ - MissingRequiredFieldError + Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ + MissingRequiredFieldError, Flows from .server import Server from .pager import Pager from .exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index c75fe8519..dbf501fe3 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,13 +1,17 @@ from .auth_endpoint import Auth from .datasources_endpoint import Datasources +from .databases_endpoint import Databases from .endpoint import Endpoint +from .flows_endpoint import Flows from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError from .groups_endpoint import Groups from .jobs_endpoint import Jobs +from .metadata_endpoint import Metadata from .projects_endpoint import Projects from .schedules_endpoint import Schedules from .server_info_endpoint import ServerInfo from .sites_endpoint import Sites +from .tables_endpoint import Tables from .tasks_endpoint import Tasks from .users_endpoint import Users from .views_endpoint import Views 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/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py new file mode 100644 index 000000000..c0726abe2 --- /dev/null +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -0,0 +1,108 @@ +from .endpoint import api, Endpoint +from .exceptions import MissingRequiredFieldError +from .permissions_endpoint import _PermissionsEndpoint +from .default_permissions_endpoint import _DefaultPermissionsEndpoint + +from .. import RequestFactory, DatabaseItem, PaginationItem, PermissionsRule, Permission + +import logging + +logger = logging.getLogger('tableau.endpoint.databases') + + +class Databases(Endpoint): + def __init__(self, parent_srv): + super(Databases, 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}/databases".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.5") + def get(self, req_options=None): + logger.info('Querying all databases on site') + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_database_items = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace) + return all_database_items, pagination_item + + # Get 1 database + @api(version="3.5") + def get_by_id(self, database_id): + if not database_id: + error = "database ID undefined." + raise ValueError(error) + logger.info('Querying single database (ID: {0})'.format(database_id)) + url = "{0}/{1}".format(self.baseurl, database_id) + server_response = self.get_request(url) + return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.5") + def delete(self, database_id): + if not database_id: + error = "Database ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, database_id) + self.delete_request(url) + logger.info('Deleted single database (ID: {0})'.format(database_id)) + + @api(version="3.5") + def update(self, database_item): + if not database_item.id: + error = "Database item missing ID." + raise MissingRequiredFieldError(error) + + url = "{0}/{1}".format(self.baseurl, database_item.id) + update_req = RequestFactory.Database.update_req(database_item) + server_response = self.put_request(url, update_req) + logger.info('Updated database item (ID: {0})'.format(database_item.id)) + updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return updated_database + + # Not Implemented Yet + @api(version="99") + def populate_tables(self, database_item): + if not database_item.id: + error = "database item missing ID. database must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def column_fetcher(): + return self._get_tables_for_database(database_item) + + database_item._set_tables(column_fetcher) + logger.info('Populated tables for database (ID: {0}'.format(database_item.id)) + + def _get_tables_for_database(self, database_item): + url = "{0}/{1}/tables".format(self.baseurl, database_item.id) + server_response = self.get_request(url) + tables = TableItem.from_response(server_response.content, + self.parent_srv.namespace) + return tables + + @api(version='3.5') + def populate_permissions(self, item): + self._permissions.populate(item) + + @api(version='3.5') + def update_permission(self, item, rules): + return self._permissions.update(item, rules) + + @api(version='3.5') + def delete_permission(self, item, rules): + return self._permissions.delete(item, rules) + + @api(version='3.5') + def populate_table_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Permission.Resource.Table) + + @api(version='3.5') + def update_table_default_permissions(self, item): + self._default_permissions.update_default_permissions(item, Permission.Resource.Table) + + @api(version='3.5') + def delete_table_default_permissions(self, item): + self._default_permissions.delete_default_permissions(item, Permission.Resource.Table) 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 080eca9c8..757ca5552 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -44,3 +44,16 @@ class EndpointUnavailableError(Exception): class ItemTypeNotAllowed(Exception): pass + + +class NonXMLResponseError(Exception): + pass + + +class GraphQLError(Exception): + def __init__(self, error_payload): + self.error = error_payload + + def __str__(self): + from pprint import pformat + return pformat(self.error) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py new file mode 100644 index 000000000..b2c616959 --- /dev/null +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -0,0 +1,215 @@ +from .endpoint import Endpoint, api, parameter_added_in +from .exceptions import InternalServerError, MissingRequiredFieldError +from .endpoint import api, parameter_added_in, Endpoint +from .permissions_endpoint import _PermissionsEndpoint +from .exceptions import MissingRequiredFieldError +from .fileuploads_endpoint import Fileuploads +from .resource_tagger import _ResourceTagger +from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem +from ...filesys_helpers import to_filename +from ...models.tag_item import TagItem +from ...models.job_item import JobItem +import os +import logging +import copy +import cgi +from contextlib import closing + +# The maximum size of a file that can be published in a single request is 64MB +FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB + +ALLOWED_FILE_EXTENSIONS = ['tfl', 'tflx'] + +logger = logging.getLogger('tableau.endpoint.flows') + + +class Flows(Endpoint): + def __init__(self, parent_srv): + super(Flows, self).__init__(parent_srv) + self._resource_tagger = _ResourceTagger(parent_srv) + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + + @property + def baseurl(self): + return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + # Get all flows + @api(version="3.3") + def get(self, req_options=None): + logger.info('Querying all flows on site') + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_flow_items = FlowItem.from_response(server_response.content, self.parent_srv.namespace) + return all_flow_items, pagination_item + + # Get 1 flow by id + @api(version="3.3") + def get_by_id(self, flow_id): + if not flow_id: + error = "Flow ID undefined." + raise ValueError(error) + logger.info('Querying single flow (ID: {0})'.format(flow_id)) + url = "{0}/{1}".format(self.baseurl, flow_id) + server_response = self.get_request(url) + return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + # Populate flow item's connections + @api(version="3.3") + def populate_connections(self, flow_item): + if not flow_item.id: + error = 'Flow item missing ID. Flow must be retrieved from server first.' + raise MissingRequiredFieldError(error) + + def connections_fetcher(): + return self._get_flow_connections(flow_item) + + flow_item._set_connections(connections_fetcher) + logger.info('Populated connections for flow (ID: {0})'.format(flow_item.id)) + + def _get_flow_connections(self, flow_item, req_options=None): + url = '{0}/{1}/connections'.format(self.baseurl, flow_item.id) + server_response = self.get_request(url, req_options) + connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + return connections + + # Delete 1 flow by id + @api(version="3.3") + def delete(self, flow_id): + if not flow_id: + error = "Flow ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, flow_id) + self.delete_request(url) + logger.info('Deleted single flow (ID: {0})'.format(flow_id)) + + # Download 1 flow by id + @api(version="3.3") + def download(self, flow_id, filepath=None): + if not flow_id: + error = "Flow ID undefined." + raise ValueError(error) + url = "{0}/{1}/content".format(self.baseurl, flow_id) + + with closing(self.get_request(url, parameters={'stream': True})) as server_response: + _, params = cgi.parse_header(server_response.headers['Content-Disposition']) + filename = to_filename(os.path.basename(params['filename'])) + if filepath is None: + filepath = filename + elif os.path.isdir(filepath): + filepath = os.path.join(filepath, filename) + + with open(filepath, 'wb') as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + + logger.info('Downloaded flow to {0} (ID: {1})'.format(filepath, flow_id)) + return os.path.abspath(filepath) + + # Update flow + @api(version="3.3") + def update(self, flow_item): + if not flow_item.id: + error = 'Flow item missing ID. Flow must be retrieved from server first.' + raise MissingRequiredFieldError(error) + + self._resource_tagger.update_tags(self.baseurl, flow_item) + + # Update the flow itself + url = "{0}/{1}".format(self.baseurl, flow_item.id) + update_req = RequestFactory.Flow.update_req(flow_item) + server_response = self.put_request(url, update_req) + logger.info('Updated flow item (ID: {0})'.format(flow_item.id)) + updated_flow = copy.copy(flow_item) + return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace) + + # Update flow connections + @api(version="3.3") + def update_connection(self, flow_item, connection_item): + url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) + + update_req = RequestFactory.Connection.update_req(connection_item) + server_response = self.put_request(url, update_req) + connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + logger.info('Updated flow item (ID: {0} & connection item {1}'.format(flow_item.id, + connection_item.id)) + return connection + + @api(version="3.3") + def refresh(self, flow_item): + url = "{0}/{1}/run".format(self.baseurl, flow_item.id) + empty_req = RequestFactory.Empty.empty_req() + server_response = self.post_request(url, empty_req) + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return new_job + + # Publish flow + @api(version="3.3") + def publish(self, flow_item, file_path, mode, connections=None): + if not os.path.isfile(file_path): + error = "File path does not lead to an existing file." + raise IOError(error) + if not mode or not hasattr(self.parent_srv.PublishMode, mode): + error = 'Invalid mode defined.' + raise ValueError(error) + + filename = os.path.basename(file_path) + file_extension = os.path.splitext(filename)[1][1:] + + # If name is not defined, grab the name from the file to publish + if not flow_item.name: + flow_item.name = os.path.splitext(filename)[0] + if file_extension not in ALLOWED_FILE_EXTENSIONS: + error = "Only {} files can be published as flows.".format(', '.join(ALLOWED_FILE_EXTENSIONS)) + raise ValueError(error) + + # Construct the url with the defined mode + url = "{0}?flowType={1}".format(self.baseurl, file_extension) + if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: + url += '&{0}=true'.format(mode.lower()) + + # Determine if chunking is required (64MB is the limit for single upload method) + if os.path.getsize(file_path) >= FILESIZE_LIMIT: + logger.info('Publishing {0} to server with chunking method (flow over 64MB)'.format(filename)) + upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path) + url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, + connections) + else: + logger.info('Publishing {0} to server'.format(filename)) + with open(file_path, 'rb') as f: + file_contents = f.read() + xml_request, content_type = RequestFactory.Flow.publish_req(flow_item, + filename, + file_contents, + connections) + + # Send the publishing request to server + try: + server_response = self.post_request(url, xml_request, content_type) + except InternalServerError as err: + if err.code == 504: + err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." + raise err + else: + new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_flow.id)) + return new_flow + + server_response = self.post_request(url, xml_request, content_type) + new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_flow.id)) + return new_flow + + @api(version='3.3') + def populate_permissions(self, item): + self._permissions.populate(item) + + @api(version='3.3') + def update_permission(self, item, permission_item): + self._permissions.update(item, permission_item) + + @api(version='3.3') + def delete_permission(self, item, capability_item): + self._permissions.delete(item, capability_item) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py new file mode 100644 index 000000000..002379407 --- /dev/null +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -0,0 +1,32 @@ +from .endpoint import Endpoint, api +from .exceptions import GraphQLError +import logging +import json + +logger = logging.getLogger('tableau.endpoint.metadata') + + +class Metadata(Endpoint): + @property + def baseurl(self): + return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) + + @api("3.2") + def query(self, query, variables=None, abort_on_error=False): + logger.info('Querying Metadata API') + url = self.baseurl + + try: + graphql_query = json.dumps({'query': query, 'variables': variables}) + except Exception: + # Place holder for now + raise Exception('Must provide a string') + + # Setting content type because post_reuqest defaults to text/xml + server_response = self.post_request(url, graphql_query, content_type='text/json') + results = server_response.json() + + if abort_on_error and results.get('errors', None): + raise GraphQLError(results['errors']) + + return results 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/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 97901d7ae..0a6b9ec89 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,5 +1,5 @@ from .endpoint import Endpoint, api -from .exceptions import ServerResponseError, ServerInfoEndpointNotFoundError +from .exceptions import ServerResponseError, ServerInfoEndpointNotFoundError, EndpointUnavailableError from ...models import ServerInfoItem import logging @@ -19,6 +19,8 @@ def get(self): except ServerResponseError as e: if e.code == "404003": raise ServerInfoEndpointNotFoundError + if e.code == "404001": + raise EndpointUnavailableError server_info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) return server_info diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py new file mode 100644 index 000000000..b8430a124 --- /dev/null +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -0,0 +1,108 @@ +from .endpoint import api, Endpoint +from .exceptions import MissingRequiredFieldError +from .permissions_endpoint import _PermissionsEndpoint +from .default_permissions_endpoint import _DefaultPermissionsEndpoint +from ..pager import Pager + +from .. import RequestFactory, TableItem, ColumnItem, PaginationItem, PermissionsRule, Permission + +import logging + +logger = logging.getLogger('tableau.endpoint.tables') + + +class Tables(Endpoint): + def __init__(self, parent_srv): + super(Tables, self).__init__(parent_srv) + + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + + @property + def baseurl(self): + return "{0}/sites/{1}/tables".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.5") + def get(self, req_options=None): + logger.info('Querying all tables on site') + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_table_items = TableItem.from_response(server_response.content, self.parent_srv.namespace) + return all_table_items, pagination_item + + # Get 1 table + @api(version="3.5") + def get_by_id(self, table_id): + if not table_id: + error = "table ID undefined." + raise ValueError(error) + logger.info('Querying single table (ID: {0})'.format(table_id)) + url = "{0}/{1}".format(self.baseurl, table_id) + server_response = self.get_request(url) + return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.5") + def delete(self, table_id): + if not table_id: + error = "Database ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, table_id) + self.delete_request(url) + logger.info('Deleted single table (ID: {0})'.format(table_id)) + + @api(version="3.5") + def update(self, table_item): + if not table_item.id: + error = "table item missing ID." + raise MissingRequiredFieldError(error) + + url = "{0}/{1}".format(self.baseurl, table_item.id) + update_req = RequestFactory.Table.update_req(table_item) + server_response = self.put_request(url, update_req) + logger.info('Updated table item (ID: {0})'.format(table_item.id)) + updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return updated_table + + # Get all columns of the table + @api(version="3.5") + def populate_columns(self, table_item, req_options=None): + if not table_item.id: + error = "Table item missing ID. table must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def column_fetcher(): + return Pager(lambda options: self._get_columns_for_table(table_item, options), req_options) + + table_item._set_columns(column_fetcher) + logger.info('Populated columns for table (ID: {0}'.format(table_item.id)) + + def _get_columns_for_table(self, table_item, req_options=None): + url = "{0}/{1}/columns".format(self.baseurl, table_item.id) + server_response = self.get_request(url, req_options) + columns = ColumnItem.from_response(server_response.content, + self.parent_srv.namespace) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + return columns, pagination_item + + @api(version="3.5") + def update_column(self, table_item, column_item): + url = "{0}/{1}/columns/{2}".format(self.baseurl, table_item.id, column_item.id) + update_req = RequestFactory.Column.update_req(column_item) + server_response = self.put_request(url, update_req) + column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + logger.info('Updated table item (ID: {0} & column item {1}'.format(table_item.id, + column_item.id)) + return column + + @api(version='3.5') + def populate_permissions(self, item): + self._permissions.populate(item) + + @api(version='3.5') + def update_permission(self, item, rules): + return self._permissions.update(item, rules) + + @api(version='3.5') + def delete_permission(self, item, rules): + return self._permissions.delete(item, rules) 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/pager.py b/tableauserverclient/server/pager.py index 92c0f0423..75ac8be4e 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,3 +1,5 @@ +from functools import partial + from . import RequestOptions from . import Sort @@ -11,13 +13,15 @@ class Pager(object): Will loop over anything that returns (List[ModelItem], PaginationItem). """ - def __init__(self, endpoint, request_opts=None): + def __init__(self, endpoint, request_opts=None, **kwargs): if hasattr(endpoint, 'get'): # The simpliest case is to take an Endpoint and call its get - self._endpoint = endpoint.get + endpoint = partial(endpoint.get, **kwargs) + self._endpoint = endpoint elif callable(endpoint): # but if they pass a callable then use that instead (used internally) + endpoint = partial(endpoint, **kwargs) self._endpoint = endpoint else: # Didn't get something we can page over diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 0a8f5e1ba..ad484e6a8 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,17 +49,50 @@ 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 return ET.tostring(xml_request) +class ColumnRequest(object): + def update_req(self, column_item): + xml_request = ET.Element('tsRequest') + column_element = ET.SubElement(xml_request, 'column') + + if column_item.description: + column_element.attrib['description'] = str(column_item.description) + + return ET.tostring(xml_request) + + +class DatabaseRequest(object): + def update_req(self, database_item): + xml_request = ET.Element('tsRequest') + database_element = ET.SubElement(xml_request, 'database') + if database_item.contact_id: + contact_element = ET.SubElement(database_element, 'contact') + contact_element.attrib['id'] = database_item.contact_id + + database_element.attrib['isCertified'] = str(database_item.certified).lower() + + if database_item.certification_note: + database_element.attrib['certificationNote'] = str(database_item.certification_note) + + if database_item.description: + database_element.attrib['description'] = str(database_item.description) + + return ET.tostring(xml_request) + + class DatasourceRequest(object): def _generate_xml(self, datasource_item, connection_credentials=None, connections=None): xml_request = ET.Element('tsRequest') @@ -116,6 +151,46 @@ def chunk_req(self, chunk): return _add_multipart(parts) +class FlowRequest(object): + def _generate_xml(self, flow_item, connections=None): + xml_request = ET.Element('tsRequest') + flow_element = ET.SubElement(xml_request, 'flow') + flow_element.attrib['name'] = flow_item.name + project_element = ET.SubElement(flow_element, 'project') + project_element.attrib['id'] = flow_item.project_id + + if connections is not None: + connections_element = ET.SubElement(flow_element, 'connections') + for connection in connections: + _add_connections_element(connections_element, connection) + return ET.tostring(xml_request) + + def update_req(self, flow_item): + xml_request = ET.Element('tsRequest') + flow_element = ET.SubElement(xml_request, 'flow') + if flow_item.project_id: + project_element = ET.SubElement(flow_element, 'project') + project_element.attrib['id'] = flow_item.project_id + if flow_item.owner_id: + owner_element = ET.SubElement(flow_element, 'owner') + owner_element.attrib['id'] = flow_item.owner_id + + return ET.tostring(xml_request) + + def publish_req(self, flow_item, filename, file_contents, connections=None): + xml_request = self._generate_xml(flow_item, connections) + + parts = {'request_payload': ('', xml_request, 'text/xml'), + 'tableau_flow': (filename, file_contents, 'application/octet-stream')} + return _add_multipart(parts) + + def publish_req_chunked(self, flow_item, connections=None): + xml_request = self._generate_xml(flow_item, connections) + + parts = {'request_payload': ('', xml_request, 'text/xml')} + return _add_multipart(parts) + + class GroupRequest(object): def add_user_req(self, user_id): xml_request = ET.Element('tsRequest') @@ -142,32 +217,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): @@ -292,6 +361,10 @@ def update_req(self, site_item): site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() if site_item.materialized_views_mode is not None: site_element.attrib['materializedViewsMode'] = str(site_item.materialized_views_mode).lower() + if site_item.flows_enabled is not None: + site_element.attrib['flowsEnabled'] = str(site_item.flows_enabled).lower() + if site_item.cataloging_enabled is not None: + site_element.attrib['catalogingEnabled'] = str(site_item.cataloging_enabled).lower() return ET.tostring(xml_request) def create_req(self, site_item): @@ -307,6 +380,30 @@ def create_req(self, site_item): site_element.attrib['storageQuota'] = str(site_item.storage_quota) if site_item.disable_subscriptions: site_element.attrib['disableSubscriptions'] = str(site_item.disable_subscriptions).lower() + if site_item.flows_enabled is not None: + site_element.attrib['flowsEnabled'] = str(site_item.flows_enabled).lower() + if site_item.cataloging_enabled is not None: + site_element.attrib['catalogingEnabled'] = str(site_item.cataloging_enabled).lower() + return ET.tostring(xml_request) + + +class TableRequest(object): + def update_req(self, table_item): + xml_request = ET.Element('tsRequest') + table_element = ET.SubElement(xml_request, 'table') + + if table_item.contact_id: + contact_element = ET.SubElement(table_element, 'contact') + contact_element.attrib['id'] = table_item.contact_id + + table_element.attrib['isCertified'] = str(table_item.certified).lower() + + if table_item.certification_note: + table_element.attrib['certificationNote'] = str(table_item.certification_note) + + if table_item.description: + table_element.attrib['description'] = str(table_item.description) + return ET.tostring(xml_request) @@ -355,7 +452,7 @@ def _generate_xml(self, workbook_item, connection_credentials=None, connections= if workbook_item.show_tabs: workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() project_element = ET.SubElement(workbook_element, 'project') - project_element.attrib['id'] = workbook_item.project_id + project_element.attrib['id'] = str(workbook_item.project_id) if connection_credentials is not None and connections is not None: raise RuntimeError('You cannot set both `connections` and `connection_credentials`') @@ -461,14 +558,18 @@ def empty_req(self, xml_request): class RequestFactory(object): Auth = AuthRequest() Connection = Connection() + Column = ColumnRequest() Datasource = DatasourceRequest() + Database = DatabaseRequest() Empty = EmptyRequest() Fileupload = FileuploadRequest() + Flow = FlowRequest() Group = GroupRequest() Permission = PermissionRequest() Project = ProjectRequest() Schedule = ScheduleRequest() Site = SiteRequest() + Table = TableRequest() Tag = TagRequest() Task = TaskRequest() User = UserRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 9f3247e7b..7e1e6a808 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -34,6 +34,7 @@ class Field: Subtitle = 'subtitle' Tags = 'tags' Title = 'title' + TopLevelProject = 'topLevelProject' Type = 'type' UpdatedAt = 'updatedAt' UserCount = 'userCount' diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 95ee564ee..b11f55d17 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -3,8 +3,9 @@ from .exceptions import NotSignedInError from ..namespace import Namespace from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ - Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs -from .endpoint.exceptions import EndpointUnavailableError + Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs, Metadata,\ + Databases, Tables, Flows +from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -45,11 +46,15 @@ def __init__(self, server_address, use_server_version=False): self.jobs = Jobs(self) self.workbooks = Workbooks(self) self.datasources = Datasources(self) + self.flows = Flows(self) self.projects = Projects(self) self.schedules = Schedules(self) self.server_info = ServerInfo(self) self.tasks = Tasks(self) self.subscriptions = Subscriptions(self) + self.metadata = Metadata(self) + self.databases = Databases(self) + self.tables = Tables(self) self._namespace = Namespace() if use_server_version: diff --git a/test/assets/database_get.xml b/test/assets/database_get.xml new file mode 100644 index 000000000..7d22daf4c --- /dev/null +++ b/test/assets/database_get.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/database_populate_permissions.xml b/test/assets/database_populate_permissions.xml new file mode 100644 index 000000000..21f30fea9 --- /dev/null +++ b/test/assets/database_populate_permissions.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/database_update.xml b/test/assets/database_update.xml new file mode 100644 index 000000000..b2cbd68c9 --- /dev/null +++ b/test/assets/database_update.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file 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/flow_get.xml b/test/assets/flow_get.xml new file mode 100644 index 000000000..406cded8e --- /dev/null +++ b/test/assets/flow_get.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/flow_populate_connections.xml b/test/assets/flow_populate_connections.xml new file mode 100644 index 000000000..5c013770c --- /dev/null +++ b/test/assets/flow_populate_connections.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/test/assets/flow_populate_permissions.xml b/test/assets/flow_populate_permissions.xml new file mode 100644 index 000000000..59fe5bd67 --- /dev/null +++ b/test/assets/flow_populate_permissions.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/flow_update.xml b/test/assets/flow_update.xml new file mode 100644 index 000000000..5ab69f583 --- /dev/null +++ b/test/assets/flow_update.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/metadata_query_error.json b/test/assets/metadata_query_error.json new file mode 100644 index 000000000..1c575ee23 --- /dev/null +++ b/test/assets/metadata_query_error.json @@ -0,0 +1,29 @@ +{ + "data": { + "publishedDatasources": [ + { + "id": "01cf92b2-2d17-b656-fc48-5c25ef6d5352", + "name": "Batters (TestV1)" + }, + { + "id": "020ae1cd-c356-f1ad-a846-b0094850d22a", + "name": "SharePoint_List_sharepoint2010.test.tsi.lan" + }, + { + "id": "061493a0-c3b2-6f39-d08c-bc3f842b44af", + "name": "Batters_mongodb" + }, + { + "id": "089fe515-ad2f-89bc-94bd-69f55f69a9c2", + "name": "Sample - Superstore" + } + ] + }, + "errors": [ + { + "message": "Reached time limit of PT5S for query execution.", + "path": null, + "extensions": null + } + ] +} \ No newline at end of file diff --git a/test/assets/metadata_query_success.json b/test/assets/metadata_query_success.json new file mode 100644 index 000000000..056f29fb6 --- /dev/null +++ b/test/assets/metadata_query_success.json @@ -0,0 +1,22 @@ +{ + "data": { + "publishedDatasources": [ + { + "id": "01cf92b2-2d17-b656-fc48-5c25ef6d5352", + "name": "Batters (TestV1)" + }, + { + "id": "020ae1cd-c356-f1ad-a846-b0094850d22a", + "name": "SharePoint_List_sharepoint2010.test.tsi.lan" + }, + { + "id": "061493a0-c3b2-6f39-d08c-bc3f842b44af", + "name": "Batters_mongodb" + }, + { + "id": "089fe515-ad2f-89bc-94bd-69f55f69a9c2", + "name": "Sample - Superstore" + } + ] + } + } \ No newline at end of file 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/site_update.xml b/test/assets/site_update.xml index 716314d29..30e434373 100644 --- a/test/assets/site_update.xml +++ b/test/assets/site_update.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/test/assets/table_get.xml b/test/assets/table_get.xml new file mode 100644 index 000000000..0bd2763d5 --- /dev/null +++ b/test/assets/table_get.xml @@ -0,0 +1,21 @@ + + + + + + + +
+ + + +
+ + +
+ + +
+
+
\ No newline at end of file diff --git a/test/assets/table_update.xml b/test/assets/table_update.xml new file mode 100644 index 000000000..975f0cedb --- /dev/null +++ b/test/assets/table_update.xml @@ -0,0 +1,8 @@ + + + + + +
+
\ 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_database.py b/test/test_database.py new file mode 100644 index 000000000..fb9ffbd86 --- /dev/null +++ b/test/test_database.py @@ -0,0 +1,87 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_XML = 'database_get.xml' +POPULATE_PERMISSIONS_XML = 'database_populate_permissions.xml' +UPDATE_XML = 'database_update.xml' + + +class DatabaseTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server.version = "3.5" + + self.baseurl = self.server.databases.baseurl + + def test_get(self): + response_xml = read_xml_asset(GET_XML) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_databases, pagination_item = self.server.databases.get() + + self.assertEqual(5, pagination_item.total_available) + self.assertEqual('5ea59b45-e497-4827-8809-bfe213236f75', all_databases[0].id) + self.assertEqual('hyper', all_databases[0].connection_type) + self.assertEqual('hyper_0.hyper', all_databases[0].name) + + self.assertEqual('23591f2c-4802-4d6a-9e28-574a8ea9bc4c', all_databases[1].id) + self.assertEqual('sqlserver', all_databases[1].connection_type) + self.assertEqual('testv1', all_databases[1].name) + self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', all_databases[1].contact_id) + self.assertEqual(True, all_databases[1].certified) + + def test_update(self): + response_xml = read_xml_asset(UPDATE_XML) + with requests_mock.mock() as m: + m.put(self.baseurl + '/23591f2c-4802-4d6a-9e28-574a8ea9bc4c', text=response_xml) + single_database = TSC.DatabaseItem('test') + single_database.contact_id = '9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0' + single_database._id = '23591f2c-4802-4d6a-9e28-574a8ea9bc4c' + single_database.certified = True + single_database.certification_note = "Test" + single_database = self.server.databases.update(single_database) + + self.assertEqual('23591f2c-4802-4d6a-9e28-574a8ea9bc4c', single_database.id) + self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', single_database.contact_id) + self.assertEqual(True, single_database.certified) + self.assertEqual("Test", single_database.certification_note) + + 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_database = TSC.DatabaseItem('test') + single_database._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + + self.server.databases.populate_permissions(single_database) + permissions = single_database.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.ChangePermissions: TSC.Permission.Mode.Deny, + 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_delete(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) + self.server.databases.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') 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_flow.py b/test/test_flow.py new file mode 100644 index 000000000..f5c057c30 --- /dev/null +++ b/test/test_flow.py @@ -0,0 +1,115 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_XML = 'flow_get.xml' +POPULATE_CONNECTIONS_XML = 'flow_populate_connections.xml' +POPULATE_PERMISSIONS_XML = 'flow_populate_permissions.xml' +UPDATE_XML = 'flow_update.xml' + + +class FlowTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server.version = "3.5" + + self.baseurl = self.server.flows.baseurl + + def test_get(self): + response_xml = read_xml_asset(GET_XML) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_flows, pagination_item = self.server.flows.get() + + self.assertEqual(5, pagination_item.total_available) + self.assertEqual('587daa37-b84d-4400-a9a2-aa90e0be7837', all_flows[0].id) + self.assertEqual('http://tableauserver/#/flows/1', all_flows[0].webpage_url) + self.assertEqual('2019-06-16T21:43:28Z', format_datetime(all_flows[0].created_at)) + self.assertEqual('2019-06-16T21:43:28Z', format_datetime(all_flows[0].updated_at)) + self.assertEqual('Default', all_flows[0].project_name) + self.assertEqual('FlowOne', all_flows[0].name) + self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flows[0].project_id) + self.assertEqual('7ebb3f20-0fd2-4f27-a2f6-c539470999e2', all_flows[0].owner_id) + self.assertEqual({'i_love_tags'}, all_flows[0].tags) + self.assertEqual('Descriptive', all_flows[0].description) + + self.assertEqual('5c36be69-eb30-461b-b66e-3e2a8e27cc35', all_flows[1].id) + self.assertEqual('http://tableauserver/#/flows/4', all_flows[1].webpage_url) + self.assertEqual('2019-06-18T03:08:19Z', format_datetime(all_flows[1].created_at)) + self.assertEqual('2019-06-18T03:08:19Z', format_datetime(all_flows[1].updated_at)) + self.assertEqual('Default', all_flows[1].project_name) + self.assertEqual('FlowTwo', all_flows[1].name) + self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flows[1].project_id) + self.assertEqual('9127d03f-d996-405f-b392-631b25183a0f', all_flows[1].owner_id) + + def test_update(self): + response_xml = read_xml_asset(UPDATE_XML) + with requests_mock.mock() as m: + m.put(self.baseurl + '/587daa37-b84d-4400-a9a2-aa90e0be7837', text=response_xml) + single_datasource = TSC.FlowItem('test', 'aa23f4ac-906f-11e9-86fb-3f0f71412e77') + single_datasource.owner_id = '7ebb3f20-0fd2-4f27-a2f6-c539470999e2' + single_datasource._id = '587daa37-b84d-4400-a9a2-aa90e0be7837' + single_datasource.description = "So fun to see" + single_datasource = self.server.flows.update(single_datasource) + + self.assertEqual('587daa37-b84d-4400-a9a2-aa90e0be7837', single_datasource.id) + self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', single_datasource.project_id) + self.assertEqual('7ebb3f20-0fd2-4f27-a2f6-c539470999e2', single_datasource.owner_id) + self.assertEqual("So fun to see", single_datasource.description) + + def test_populate_connections(self): + response_xml = read_xml_asset(POPULATE_CONNECTIONS_XML) + with requests_mock.mock() as m: + m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections', text=response_xml) + single_datasource = TSC.FlowItem('test', 'aa23f4ac-906f-11e9-86fb-3f0f71412e77') + single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + self.server.flows.populate_connections(single_datasource) + self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) + connections = single_datasource.connections + + self.assertTrue(connections) + conn1, conn2, conn3 = connections + self.assertEqual('405c1e4b-60c9-499f-9c47-a4ef1af69359', conn1.id) + self.assertEqual('excel-direct', conn1.connection_type) + self.assertEqual('', conn1.server_address) + self.assertEqual('', conn1.username) + self.assertEqual(False, conn1.embed_password) + self.assertEqual('b47f41b1-2c47-41a3-8b17-a38ebe8b340c', conn2.id) + self.assertEqual('sqlserver', conn2.connection_type) + self.assertEqual('test.database.com', conn2.server_address) + self.assertEqual('bob', conn2.username) + self.assertEqual(False, conn2.embed_password) + self.assertEqual('4f4a3b78-0554-43a7-b327-9605e9df9dd2', conn3.id) + self.assertEqual('tableau-server-site', conn3.connection_type) + self.assertEqual('http://tableauserver', conn3.server_address) + self.assertEqual('sally', conn3.username) + self.assertEqual(True, conn3.embed_password) + + def test_populate_permissions(self): + with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) + single_datasource = TSC.FlowItem('test') + single_datasource._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + + self.server.flows.populate_permissions(single_datasource) + permissions = single_datasource.permissions + + self.assertEqual(permissions[0].grantee.tag_name, 'group') + self.assertEqual(permissions[0].grantee.id, 'aa42f384-906f-11e9-86fc-bb24278874b9') + self.assertDictEqual(permissions[0].capabilities, { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }) diff --git a/test/test_metadata.py b/test/test_metadata.py new file mode 100644 index 000000000..e2a44734c --- /dev/null +++ b/test/test_metadata.py @@ -0,0 +1,69 @@ +import unittest +import os.path +import json +import requests_mock +import tableauserverclient as TSC + +from tableauserverclient.server.endpoint.exceptions import GraphQLError + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') + +METADATA_QUERY_SUCCESS = os.path.join(TEST_ASSET_DIR, 'metadata_query_success.json') +METADATA_QUERY_ERROR = os.path.join(TEST_ASSET_DIR, 'metadata_query_error.json') + +EXPECTED_DICT = {'publishedDatasources': + [{'id': '01cf92b2-2d17-b656-fc48-5c25ef6d5352', 'name': 'Batters (TestV1)'}, + {'id': '020ae1cd-c356-f1ad-a846-b0094850d22a', 'name': 'SharePoint_List_sharepoint2010.test.tsi.lan'}, + {'id': '061493a0-c3b2-6f39-d08c-bc3f842b44af', 'name': 'Batters_mongodb'}, + {'id': '089fe515-ad2f-89bc-94bd-69f55f69a9c2', 'name': 'Sample - Superstore'}]} + +EXPECTED_DICT_ERROR = [ + { + "message": "Reached time limit of PT5S for query execution.", + "path": None, + "extensions": None + } +] + + +class MetadataTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.baseurl = self.server.metadata.baseurl + self.server.version = "3.2" + + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + def test_metadata_query(self): + with open(METADATA_QUERY_SUCCESS, 'rb') as f: + response_json = json.loads(f.read().decode()) + with requests_mock.mock() as m: + m.post(self.baseurl, json=response_json) + actual = self.server.metadata.query('fake query') + + datasources = actual['data'] + + self.assertDictEqual(EXPECTED_DICT, datasources) + + def test_metadata_query_ignore_error(self): + with open(METADATA_QUERY_ERROR, 'rb') as f: + response_json = json.loads(f.read().decode()) + with requests_mock.mock() as m: + m.post(self.baseurl, json=response_json) + actual = self.server.metadata.query('fake query') + datasources = actual['data'] + + self.assertNotEqual(actual.get('errors', None), None) + self.assertListEqual(EXPECTED_DICT_ERROR, actual['errors']) + self.assertDictEqual(EXPECTED_DICT, datasources) + + def test_metadata_query_abort_on_error(self): + with open(METADATA_QUERY_ERROR, 'rb') as f: + response_json = json.loads(f.read().decode()) + with requests_mock.mock() as m: + m.post(self.baseurl, json=response_json) + + with self.assertRaises(GraphQLError) as e: + self.server.metadata.query('fake query', abort_on_error=True) + self.assertListEqual(e.error, EXPECTED_DICT_ERROR) 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_site.py b/test/test_site.py index 9603e73c2..8283a7bdd 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -106,6 +106,8 @@ def test_update(self): self.assertEqual(True, single_site.disable_subscriptions) self.assertEqual(15, single_site.user_quota) self.assertEqual('disable', single_site.materialized_views_mode) + self.assertEqual(True, single_site.flows_enabled) + self.assertEqual(True, single_site.cataloging_enabled) def test_update_missing_id(self): single_site = TSC.SiteItem('test', 'test') diff --git a/test/test_sort.py b/test/test_sort.py index 17a69e900..5eef07a9d 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -57,8 +57,7 @@ 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.assertEqual(resp.request.query, '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_table.py b/test/test_table.py new file mode 100644 index 000000000..45af43c9a --- /dev/null +++ b/test/test_table.py @@ -0,0 +1,62 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_XML = 'table_get.xml' +UPDATE_XML = 'table_update.xml' + + +class TableTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server.version = "3.5" + + self.baseurl = self.server.tables.baseurl + + def test_get(self): + response_xml = read_xml_asset(GET_XML) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_tables, pagination_item = self.server.tables.get() + + self.assertEqual(4, pagination_item.total_available) + self.assertEqual('10224773-ecee-42ac-b822-d786b0b8e4d9', all_tables[0].id) + self.assertEqual('dim_Product', all_tables[0].name) + + self.assertEqual('53c77bc1-fb41-4342-a75a-f68ac0656d0d', all_tables[1].id) + self.assertEqual('customer', all_tables[1].name) + self.assertEqual('dbo', all_tables[1].schema) + self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', all_tables[1].contact_id) + self.assertEqual(False, all_tables[1].certified) + + def test_update(self): + response_xml = read_xml_asset(UPDATE_XML) + with requests_mock.mock() as m: + m.put(self.baseurl + '/10224773-ecee-42ac-b822-d786b0b8e4d9', text=response_xml) + single_table = TSC.TableItem('test') + single_table._id = '10224773-ecee-42ac-b822-d786b0b8e4d9' + + single_table.contact_id = '8e1a8235-c9ee-4d61-ae82-2ffacceed8e0' + single_table.certified = True + single_table.certification_note = "Test" + single_table = self.server.tables.update(single_table) + + self.assertEqual('10224773-ecee-42ac-b822-d786b0b8e4d9', single_table.id) + self.assertEqual('8e1a8235-c9ee-4d61-ae82-2ffacceed8e0', single_table.contact_id) + self.assertEqual(True, single_table.certified) + self.assertEqual("Test", single_table.certification_note) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) + self.server.tables.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') 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,