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,